From f88a2eae9777e0be612647bc17227c1ca13616ba Mon Sep 17 00:00:00 2001
From: 6543 <6543@obermui.de>
Date: Mon, 23 Nov 2020 21:49:36 +0100
Subject: [PATCH] [API] Add more filters to issues search (#13514)

* Add time filter for issue search

* Add limit option for paggination

* Add Filter for: Created by User, Assigned to User, Mentioning User

* update swagger

* Add Tests for limit, before & since
---
 integrations/api_issue_test.go | 29 +++++++++++++---
 models/issue.go                |  9 +++++
 routers/api/v1/repo/issue.go   | 61 ++++++++++++++++++++++++++++++++--
 templates/swagger/v1_json.tmpl | 40 +++++++++++++++++++++-
 4 files changed, 130 insertions(+), 9 deletions(-)

diff --git a/integrations/api_issue_test.go b/integrations/api_issue_test.go
index 9311d50c5c..81e5c44873 100644
--- a/integrations/api_issue_test.go
+++ b/integrations/api_issue_test.go
@@ -9,6 +9,7 @@ import (
 	"net/http"
 	"net/url"
 	"testing"
+	"time"
 
 	"code.gitea.io/gitea/models"
 	api "code.gitea.io/gitea/modules/structs"
@@ -152,17 +153,27 @@ func TestAPISearchIssues(t *testing.T) {
 	resp := session.MakeRequest(t, req, http.StatusOK)
 	var apiIssues []*api.Issue
 	DecodeJSON(t, resp, &apiIssues)
-
 	assert.Len(t, apiIssues, 10)
 
-	query := url.Values{}
-	query.Add("token", token)
+	query := url.Values{"token": {token}}
 	link.RawQuery = query.Encode()
 	req = NewRequest(t, "GET", link.String())
 	resp = session.MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
 	assert.Len(t, apiIssues, 10)
 
+	since := "2000-01-01T00%3A50%3A01%2B00%3A00" // 946687801
+	before := time.Unix(999307200, 0).Format(time.RFC3339)
+	query.Add("since", since)
+	query.Add("before", before)
+	link.RawQuery = query.Encode()
+	req = NewRequest(t, "GET", link.String())
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &apiIssues)
+	assert.Len(t, apiIssues, 8)
+	query.Del("since")
+	query.Del("before")
+
 	query.Add("state", "closed")
 	link.RawQuery = query.Encode()
 	req = NewRequest(t, "GET", link.String())
@@ -175,14 +186,22 @@ func TestAPISearchIssues(t *testing.T) {
 	req = NewRequest(t, "GET", link.String())
 	resp = session.MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
+	assert.EqualValues(t, "12", resp.Header().Get("X-Total-Count"))
 	assert.Len(t, apiIssues, 10) //there are more but 10 is page item limit
 
-	query.Add("page", "2")
+	query.Add("limit", "20")
 	link.RawQuery = query.Encode()
 	req = NewRequest(t, "GET", link.String())
 	resp = session.MakeRequest(t, req, http.StatusOK)
 	DecodeJSON(t, resp, &apiIssues)
-	assert.Len(t, apiIssues, 2)
+	assert.Len(t, apiIssues, 12)
+
+	query = url.Values{"assigned": {"true"}, "state": {"all"}}
+	link.RawQuery = query.Encode()
+	req = NewRequest(t, "GET", link.String())
+	resp = session.MakeRequest(t, req, http.StatusOK)
+	DecodeJSON(t, resp, &apiIssues)
+	assert.Len(t, apiIssues, 1)
 }
 
 func TestAPISearchIssuesWithLabels(t *testing.T) {
diff --git a/models/issue.go b/models/issue.go
index ee75623f53..8c135faa8d 100644
--- a/models/issue.go
+++ b/models/issue.go
@@ -1100,6 +1100,8 @@ type IssuesOptions struct {
 	ExcludedLabelNames []string
 	SortType           string
 	IssueIDs           []int64
+	UpdatedAfterUnix   int64
+	UpdatedBeforeUnix  int64
 	// prioritize issues from this repo
 	PriorityRepoID int64
 }
@@ -1178,6 +1180,13 @@ func (opts *IssuesOptions) setupSession(sess *xorm.Session) {
 		sess.In("issue.milestone_id", opts.MilestoneIDs)
 	}
 
+	if opts.UpdatedAfterUnix != 0 {
+		sess.And(builder.Gte{"issue.updated_unix": opts.UpdatedAfterUnix})
+	}
+	if opts.UpdatedBeforeUnix != 0 {
+		sess.And(builder.Lte{"issue.updated_unix": opts.UpdatedBeforeUnix})
+	}
+
 	if opts.ProjectID > 0 {
 		sess.Join("INNER", "project_issue", "issue.id = project_issue.issue_id").
 			And("project_issue.project_id=?", opts.ProjectID)
diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go
index 0dbf2741ad..c58e0bb6ce 100644
--- a/routers/api/v1/repo/issue.go
+++ b/routers/api/v1/repo/issue.go
@@ -55,14 +55,48 @@ func SearchIssues(ctx *context.APIContext) {
 	//   in: query
 	//   description: filter by type (issues / pulls) if set
 	//   type: string
+	// - name: since
+	//   in: query
+	//   description: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// - name: before
+	//   in: query
+	//   description: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format
+	//   type: string
+	//   format: date-time
+	//   required: false
+	// - name: assigned
+	//   in: query
+	//   description: filter (issues / pulls) assigned to you, default is false
+	//   type: boolean
+	// - name: created
+	//   in: query
+	//   description: filter (issues / pulls) created by you, default is false
+	//   type: boolean
+	// - name: mentioned
+	//   in: query
+	//   description: filter (issues / pulls) mentioning you, default is false
+	//   type: boolean
 	// - name: page
 	//   in: query
-	//   description: page number of requested issues
+	//   description: page number of results to return (1-based)
+	//   type: integer
+	// - name: limit
+	//   in: query
+	//   description: page size of results
 	//   type: integer
 	// responses:
 	//   "200":
 	//     "$ref": "#/responses/IssueList"
 
+	before, since, err := utils.GetQueryBeforeSince(ctx)
+	if err != nil {
+		ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err)
+		return
+	}
+
 	var isClosed util.OptionalBool
 	switch ctx.Query("state") {
 	case "closed":
@@ -119,7 +153,6 @@ func SearchIssues(ctx *context.APIContext) {
 	}
 	var issueIDs []int64
 	var labelIDs []int64
-	var err error
 	if len(keyword) > 0 && len(repoIDs) > 0 {
 		if issueIDs, err = issue_indexer.SearchIssuesByKeyword(repoIDs, keyword); err != nil {
 			ctx.Error(http.StatusInternalServerError, "SearchIssuesByKeyword", err)
@@ -143,13 +176,22 @@ func SearchIssues(ctx *context.APIContext) {
 		includedLabelNames = strings.Split(labels, ",")
 	}
 
+	// this api is also used in UI,
+	// so the default limit is set to fit UI needs
+	limit := ctx.QueryInt("limit")
+	if limit == 0 {
+		limit = setting.UI.IssuePagingNum
+	} else if limit > setting.API.MaxResponseItems {
+		limit = setting.API.MaxResponseItems
+	}
+
 	// Only fetch the issues if we either don't have a keyword or the search returned issues
 	// This would otherwise return all issues if no issues were found by the search.
 	if len(keyword) == 0 || len(issueIDs) > 0 || len(labelIDs) > 0 {
 		issuesOpt := &models.IssuesOptions{
 			ListOptions: models.ListOptions{
 				Page:     ctx.QueryInt("page"),
-				PageSize: setting.UI.IssuePagingNum,
+				PageSize: limit,
 			},
 			RepoIDs:            repoIDs,
 			IsClosed:           isClosed,
@@ -158,6 +200,19 @@ func SearchIssues(ctx *context.APIContext) {
 			SortType:           "priorityrepo",
 			PriorityRepoID:     ctx.QueryInt64("priority_repo_id"),
 			IsPull:             isPull,
+			UpdatedBeforeUnix:  before,
+			UpdatedAfterUnix:   since,
+		}
+
+		// Filter for: Created by User, Assigned to User, Mentioning User
+		if ctx.QueryBool("created") {
+			issuesOpt.PosterID = ctx.User.ID
+		}
+		if ctx.QueryBool("assigned") {
+			issuesOpt.AssigneeID = ctx.User.ID
+		}
+		if ctx.QueryBool("mentioned") {
+			issuesOpt.MentionedID = ctx.User.ID
 		}
 
 		if issues, err = models.Issues(issuesOpt); err != nil {
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index 9d775da7d6..8bcfc43d73 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -1879,11 +1879,49 @@
             "name": "type",
             "in": "query"
           },
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Only show notifications updated after the given time. This is a timestamp in RFC 3339 format",
+            "name": "since",
+            "in": "query"
+          },
+          {
+            "type": "string",
+            "format": "date-time",
+            "description": "Only show notifications updated before the given time. This is a timestamp in RFC 3339 format",
+            "name": "before",
+            "in": "query"
+          },
+          {
+            "type": "boolean",
+            "description": "filter (issues / pulls) assigned to you, default is false",
+            "name": "assigned",
+            "in": "query"
+          },
+          {
+            "type": "boolean",
+            "description": "filter (issues / pulls) created by you, default is false",
+            "name": "created",
+            "in": "query"
+          },
+          {
+            "type": "boolean",
+            "description": "filter (issues / pulls) mentioning you, default is false",
+            "name": "mentioned",
+            "in": "query"
+          },
           {
             "type": "integer",
-            "description": "page number of requested issues",
+            "description": "page number of results to return (1-based)",
             "name": "page",
             "in": "query"
+          },
+          {
+            "type": "integer",
+            "description": "page size of results",
+            "name": "limit",
+            "in": "query"
           }
         ],
         "responses": {