From 783a02188970ba5800514f7c64f6a818f65c04a1 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 8 Apr 2022 02:59:56 +0800 Subject: [PATCH] Never use /api/v1 from Gitea UI Pages (#19318) Reusing `/api/v1` from Gitea UI Pages have pros and cons. Pros: 1) Less code copy Cons: 1) API/v1 have to support shared session with page requests. 2) You need to consider for each other when you want to change something about api/v1 or page. This PR moves all dependencies to API/v1 from UI Pages. Partially replace #16052 --- integrations/issue_test.go | 208 ++++++++++ integrations/org_test.go | 29 ++ integrations/repo_topic_test.go | 46 +++ integrations/user_test.go | 26 ++ modules/context/api.go | 16 - modules/context/context.go | 36 ++ .../api/v1/utils => modules/context}/utils.go | 18 +- routers/api/v1/notify/notifications.go | 2 +- routers/api/v1/repo/issue.go | 4 +- routers/api/v1/repo/issue_comment.go | 6 +- routers/api/v1/repo/issue_tracked_time.go | 6 +- routers/api/v1/repo/repo.go | 19 +- routers/api/v1/utils/page.go | 19 + routers/web/explore/topic.go | 42 ++ routers/web/org/teams.go | 48 +++ routers/web/repo/issue.go | 382 ++++++++++++++++++ routers/web/repo/repo.go | 112 +++++ routers/web/user/notification.go | 6 + routers/web/user/search.go | 44 ++ routers/web/user/stop_watch.go | 41 ++ routers/web/web.go | 16 +- .../repo/issue/view_content/sidebar.tmpl | 2 +- web_src/js/components/ContextPopup.vue | 2 +- web_src/js/components/DashboardRepoList.js | 4 +- web_src/js/features/comp/SearchUserBox.js | 2 +- web_src/js/features/notification.js | 2 +- web_src/js/features/org-team.js | 2 +- web_src/js/features/repo-home.js | 2 +- web_src/js/features/repo-issue.js | 8 +- web_src/js/features/repo-settings.js | 2 +- web_src/js/features/repo-template.js | 2 +- web_src/js/features/stopwatch.js | 2 +- 32 files changed, 1082 insertions(+), 74 deletions(-) create mode 100644 integrations/repo_topic_test.go rename {routers/api/v1/utils => modules/context}/utils.go (66%) create mode 100644 routers/api/v1/utils/page.go create mode 100644 routers/web/explore/topic.go create mode 100644 routers/web/user/search.go create mode 100644 routers/web/user/stop_watch.go diff --git a/integrations/issue_test.go b/integrations/issue_test.go index 6a9b48e5a4..8a58f59baa 100644 --- a/integrations/issue_test.go +++ b/integrations/issue_test.go @@ -7,6 +7,7 @@ package integrations import ( "fmt" "net/http" + "net/url" "path" "strconv" "strings" @@ -20,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/indexer/issues" "code.gitea.io/gitea/modules/references" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "github.com/PuerkitoBio/goquery" @@ -347,3 +349,209 @@ func TestIssueRedirect(t *testing.T) { resp = session.MakeRequest(t, req, http.StatusSeeOther) assert.Equal(t, "/"+path.Join("org26", "repo_external_tracker_alpha", "pulls", "1"), test.RedirectURL(resp)) } + +func TestSearchIssues(t *testing.T) { + defer prepareTestEnv(t)() + + session := loginUser(t, "user2") + + link, _ := url.Parse("/issues/search") + req := NewRequest(t, "GET", link.String()) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiIssues []*api.Issue + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 10) + + 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 := url.Values{} + 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()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.Len(t, apiIssues, 2) + + query.Set("state", "all") + link.RawQuery = query.Encode() + req = NewRequest(t, "GET", link.String()) + resp = session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &apiIssues) + assert.EqualValues(t, "15", resp.Header().Get("X-Total-Count")) + assert.Len(t, apiIssues, 10) // there are more but 10 is page item limit + + 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, 15) + + 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) + + query = url.Values{"milestones": {"milestone1"}, "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) + + query = url.Values{"milestones": {"milestone1,milestone3"}, "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, 2) + + query = url.Values{"owner": {"user2"}} // user + 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, 6) + + query = url.Values{"owner": {"user3"}} // organization + 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, 3) + + query = url.Values{"owner": {"user3"}, "team": {"team1"}} // organization + team + 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) +} + +func TestSearchIssuesWithLabels(t *testing.T) { + defer prepareTestEnv(t)() + + session := loginUser(t, "user1") + + link, _ := url.Parse("/api/v1/repos/issues/search") + req := NewRequest(t, "GET", link.String()) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiIssues []*api.Issue + DecodeJSON(t, resp, &apiIssues) + + assert.Len(t, apiIssues, 10) + + query := url.Values{} + 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) + + query.Add("labels", "label1") + 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) + + // multiple labels + query.Set("labels", "label1,label2") + 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) + + // an org label + query.Set("labels", "orglabel4") + 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) + + // org and repo label + query.Set("labels", "label2,orglabel4") + query.Add("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, 2) + + // org and repo label which share the same issue + query.Set("labels", "label1,orglabel4") + 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) +} + +func TestGetIssueInfo(t *testing.T) { + defer prepareTestEnv(t)() + + issue := unittest.AssertExistsAndLoadBean(t, &models.Issue{ID: 10}).(*models.Issue) + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issue.RepoID}).(*repo_model.Repository) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}).(*user_model.User) + assert.NoError(t, issue.LoadAttributes()) + assert.Equal(t, int64(1019307200), int64(issue.DeadlineUnix)) + assert.Equal(t, api.StateOpen, issue.State()) + + session := loginUser(t, owner.Name) + + urlStr := fmt.Sprintf("/%s/%s/issues/%d/info", owner.Name, repo.Name, issue.Index) + req := NewRequest(t, "GET", urlStr) + resp := session.MakeRequest(t, req, http.StatusOK) + var apiIssue api.Issue + DecodeJSON(t, resp, &apiIssue) + + assert.EqualValues(t, issue.ID, apiIssue.ID) +} + +func TestUpdateIssueDeadline(t *testing.T) { + defer prepareTestEnv(t)() + + issueBefore := unittest.AssertExistsAndLoadBean(t, &models.Issue{ID: 10}).(*models.Issue) + repoBefore := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: issueBefore.RepoID}).(*repo_model.Repository) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repoBefore.OwnerID}).(*user_model.User) + assert.NoError(t, issueBefore.LoadAttributes()) + assert.Equal(t, int64(1019307200), int64(issueBefore.DeadlineUnix)) + assert.Equal(t, api.StateOpen, issueBefore.State()) + + session := loginUser(t, owner.Name) + + issueURL := fmt.Sprintf("%s/%s/issues/%d", owner.Name, repoBefore.Name, issueBefore.Index) + req := NewRequest(t, "GET", issueURL) + resp := session.MakeRequest(t, req, http.StatusOK) + htmlDoc := NewHTMLParser(t, resp.Body) + + urlStr := issueURL + "/deadline?_csrf=" + htmlDoc.GetCSRF() + req = NewRequestWithJSON(t, "POST", urlStr, map[string]string{ + "due_date": "2022-04-06T00:00:00.000Z", + }) + + resp = session.MakeRequest(t, req, http.StatusCreated) + var apiIssue api.IssueDeadline + DecodeJSON(t, resp, &apiIssue) + + assert.EqualValues(t, "2022-04-06", apiIssue.Deadline.Format("2006-01-02")) +} diff --git a/integrations/org_test.go b/integrations/org_test.go index 794475a924..227a1b8d40 100644 --- a/integrations/org_test.go +++ b/integrations/org_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" api "code.gitea.io/gitea/modules/structs" "github.com/stretchr/testify/assert" @@ -173,3 +175,30 @@ func TestOrgRestrictedUser(t *testing.T) { req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s", orgName, repoName)) restrictedSession.MakeRequest(t, req, http.StatusOK) } + +func TestTeamSearch(t *testing.T) { + defer prepareTestEnv(t)() + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}).(*user_model.User) + org := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 3}).(*user_model.User) + + var results TeamSearchResults + + session := loginUser(t, user.Name) + csrf := GetCSRF(t, session, "/"+org.Name) + req := NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "_team") + req.Header.Add("X-Csrf-Token", csrf) + resp := session.MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + assert.Len(t, results.Data, 1) + assert.Equal(t, "test_team", results.Data[0].Name) + + // no access if not organization member + user5 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 5}).(*user_model.User) + session = loginUser(t, user5.Name) + csrf = GetCSRF(t, session, "/"+org.Name) + req = NewRequestf(t, "GET", "/org/%s/teams/-/search?q=%s", org.Name, "team") + req.Header.Add("X-Csrf-Token", csrf) + session.MakeRequest(t, req, http.StatusNotFound) +} diff --git a/integrations/repo_topic_test.go b/integrations/repo_topic_test.go new file mode 100644 index 0000000000..146f90e710 --- /dev/null +++ b/integrations/repo_topic_test.go @@ -0,0 +1,46 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package integrations + +import ( + "net/http" + "net/url" + "testing" + + api "code.gitea.io/gitea/modules/structs" + "github.com/stretchr/testify/assert" +) + +func TestTopicSearch(t *testing.T) { + defer prepareTestEnv(t)() + searchURL, _ := url.Parse("/explore/topics/search") + var topics struct { + TopicNames []*api.TopicResponse `json:"topics"` + } + + query := url.Values{"page": []string{"1"}, "limit": []string{"4"}} + + searchURL.RawQuery = query.Encode() + res := MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 4) + assert.EqualValues(t, "6", res.Header().Get("x-total-count")) + + query.Add("q", "topic") + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + assert.Len(t, topics.TopicNames, 2) + + query.Set("q", "database") + searchURL.RawQuery = query.Encode() + res = MakeRequest(t, NewRequest(t, "GET", searchURL.String()), http.StatusOK) + DecodeJSON(t, res, &topics) + if assert.Len(t, topics.TopicNames, 1) { + assert.EqualValues(t, 2, topics.TopicNames[0].ID) + assert.EqualValues(t, "database", topics.TopicNames[0].Name) + assert.EqualValues(t, 1, topics.TopicNames[0].RepoCount) + } +} diff --git a/integrations/user_test.go b/integrations/user_test.go index e8fbccd51e..d0523d8b3a 100644 --- a/integrations/user_test.go +++ b/integrations/user_test.go @@ -8,8 +8,11 @@ import ( "net/http" "testing" + "code.gitea.io/gitea/models" + repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/translation/i18n" @@ -222,3 +225,26 @@ func testExportUserGPGKeys(t *testing.T, user, expected string) { // t.Log(resp.Body.String()) assert.Equal(t, expected, resp.Body.String()) } + +func TestListStopWatches(t *testing.T) { + defer prepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}).(*repo_model.Repository) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}).(*user_model.User) + + session := loginUser(t, owner.Name) + req := NewRequestf(t, "GET", "/user/stopwatches") + resp := session.MakeRequest(t, req, http.StatusOK) + var apiWatches []*api.StopWatch + DecodeJSON(t, resp, &apiWatches) + stopwatch := unittest.AssertExistsAndLoadBean(t, &models.Stopwatch{UserID: owner.ID}).(*models.Stopwatch) + issue := unittest.AssertExistsAndLoadBean(t, &models.Issue{ID: stopwatch.IssueID}).(*models.Issue) + if assert.Len(t, apiWatches, 1) { + assert.EqualValues(t, stopwatch.CreatedUnix.AsTime().Unix(), apiWatches[0].Created.Unix()) + assert.EqualValues(t, issue.Index, apiWatches[0].IssueIndex) + assert.EqualValues(t, issue.Title, apiWatches[0].IssueTitle) + assert.EqualValues(t, repo.Name, apiWatches[0].RepoName) + assert.EqualValues(t, repo.OwnerName, apiWatches[0].RepoOwnerName) + assert.Greater(t, int64(apiWatches[0].Seconds), int64(0)) + } +} diff --git a/modules/context/api.go b/modules/context/api.go index ae516503e4..da08f990bb 100644 --- a/modules/context/api.go +++ b/modules/context/api.go @@ -191,22 +191,6 @@ func (ctx *APIContext) SetLinkHeader(total, pageSize int) { } } -// SetTotalCountHeader set "X-Total-Count" header -func (ctx *APIContext) SetTotalCountHeader(total int64) { - ctx.RespHeader().Set("X-Total-Count", fmt.Sprint(total)) - ctx.AppendAccessControlExposeHeaders("X-Total-Count") -} - -// AppendAccessControlExposeHeaders append headers by name to "Access-Control-Expose-Headers" header -func (ctx *APIContext) AppendAccessControlExposeHeaders(names ...string) { - val := ctx.RespHeader().Get("Access-Control-Expose-Headers") - if len(val) != 0 { - ctx.RespHeader().Set("Access-Control-Expose-Headers", fmt.Sprintf("%s, %s", val, strings.Join(names, ", "))) - } else { - ctx.RespHeader().Set("Access-Control-Expose-Headers", strings.Join(names, ", ")) - } -} - // RequireCSRF requires a validated a CSRF token func (ctx *APIContext) RequireCSRF() { headerToken := ctx.Req.Header.Get(ctx.csrf.GetHeaderName()) diff --git a/modules/context/context.go b/modules/context/context.go index f73b5f19c0..8ede3646a4 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -10,6 +10,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "html" "html/template" "io" @@ -21,6 +22,7 @@ import ( "strings" "time" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" @@ -577,6 +579,22 @@ func (ctx *Context) Value(key interface{}) interface{} { return ctx.Req.Context().Value(key) } +// SetTotalCountHeader set "X-Total-Count" header +func (ctx *Context) SetTotalCountHeader(total int64) { + ctx.RespHeader().Set("X-Total-Count", fmt.Sprint(total)) + ctx.AppendAccessControlExposeHeaders("X-Total-Count") +} + +// AppendAccessControlExposeHeaders append headers by name to "Access-Control-Expose-Headers" header +func (ctx *Context) AppendAccessControlExposeHeaders(names ...string) { + val := ctx.RespHeader().Get("Access-Control-Expose-Headers") + if len(val) != 0 { + ctx.RespHeader().Set("Access-Control-Expose-Headers", fmt.Sprintf("%s, %s", val, strings.Join(names, ", "))) + } else { + ctx.RespHeader().Set("Access-Control-Expose-Headers", strings.Join(names, ", ")) + } +} + // Handler represents a custom handler type Handler func(*Context) @@ -780,3 +798,21 @@ func Contexter() func(next http.Handler) http.Handler { }) } } + +// SearchOrderByMap represents all possible search order +var SearchOrderByMap = map[string]map[string]db.SearchOrderBy{ + "asc": { + "alpha": db.SearchOrderByAlphabetically, + "created": db.SearchOrderByOldest, + "updated": db.SearchOrderByLeastUpdated, + "size": db.SearchOrderBySize, + "id": db.SearchOrderByID, + }, + "desc": { + "alpha": db.SearchOrderByAlphabeticallyReverse, + "created": db.SearchOrderByNewest, + "updated": db.SearchOrderByRecentUpdated, + "size": db.SearchOrderBySizeReverse, + "id": db.SearchOrderByIDReverse, + }, +} diff --git a/routers/api/v1/utils/utils.go b/modules/context/utils.go similarity index 66% rename from routers/api/v1/utils/utils.go rename to modules/context/utils.go index 7564857115..aea51cc5d6 100644 --- a/routers/api/v1/utils/utils.go +++ b/modules/context/utils.go @@ -2,20 +2,16 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package utils +package context import ( "net/url" "strings" "time" - - "code.gitea.io/gitea/models/db" - "code.gitea.io/gitea/modules/context" - "code.gitea.io/gitea/modules/convert" ) // GetQueryBeforeSince return parsed time (unix format) from URL query's before and since -func GetQueryBeforeSince(ctx *context.APIContext) (before, since int64, err error) { +func GetQueryBeforeSince(ctx *Context) (before, since int64, err error) { qCreatedBefore, err := prepareQueryArg(ctx, "before") if err != nil { return 0, 0, err @@ -53,16 +49,8 @@ func parseTime(value string) (int64, error) { } // prepareQueryArg unescape and trim a query arg -func prepareQueryArg(ctx *context.APIContext, name string) (value string, err error) { +func prepareQueryArg(ctx *Context, name string) (value string, err error) { value, err = url.PathUnescape(ctx.FormString(name)) value = strings.TrimSpace(value) return } - -// GetListOptions returns list options using the page and limit parameters -func GetListOptions(ctx *context.APIContext) db.ListOptions { - return db.ListOptions{ - Page: ctx.FormInt("page"), - PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), - } -} diff --git a/routers/api/v1/notify/notifications.go b/routers/api/v1/notify/notifications.go index 1821c30377..c707cf4524 100644 --- a/routers/api/v1/notify/notifications.go +++ b/routers/api/v1/notify/notifications.go @@ -26,7 +26,7 @@ func NewAvailable(ctx *context.APIContext) { } func getFindNotificationOptions(ctx *context.APIContext) *models.FindNotificationOptions { - before, since, err := utils.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Context) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return nil diff --git a/routers/api/v1/repo/issue.go b/routers/api/v1/repo/issue.go index 05c9c27144..cd05ce12ca 100644 --- a/routers/api/v1/repo/issue.go +++ b/routers/api/v1/repo/issue.go @@ -111,7 +111,7 @@ func SearchIssues(ctx *context.APIContext) { // "200": // "$ref": "#/responses/IssueList" - before, since, err := utils.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Context) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return @@ -359,7 +359,7 @@ func ListIssues(ctx *context.APIContext) { // responses: // "200": // "$ref": "#/responses/IssueList" - before, since, err := utils.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Context) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return diff --git a/routers/api/v1/repo/issue_comment.go b/routers/api/v1/repo/issue_comment.go index d63a71afc2..ef91a2481c 100644 --- a/routers/api/v1/repo/issue_comment.go +++ b/routers/api/v1/repo/issue_comment.go @@ -58,7 +58,7 @@ func ListIssueComments(ctx *context.APIContext) { // "200": // "$ref": "#/responses/CommentList" - before, since, err := utils.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Context) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return @@ -150,7 +150,7 @@ func ListIssueCommentsAndTimeline(ctx *context.APIContext) { // "200": // "$ref": "#/responses/TimelineList" - before, since, err := utils.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Context) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return @@ -253,7 +253,7 @@ func ListRepoIssueComments(ctx *context.APIContext) { // "200": // "$ref": "#/responses/CommentList" - before, since, err := utils.GetQueryBeforeSince(ctx) + before, since, err := context.GetQueryBeforeSince(ctx.Context) if err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return diff --git a/routers/api/v1/repo/issue_tracked_time.go b/routers/api/v1/repo/issue_tracked_time.go index 19732c101f..e42dc60a94 100644 --- a/routers/api/v1/repo/issue_tracked_time.go +++ b/routers/api/v1/repo/issue_tracked_time.go @@ -103,7 +103,7 @@ func ListTrackedTimes(ctx *context.APIContext) { opts.UserID = user.ID } - if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = utils.GetQueryBeforeSince(ctx); err != nil { + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Context); err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return } @@ -522,7 +522,7 @@ func ListTrackedTimesByRepository(ctx *context.APIContext) { } var err error - if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = utils.GetQueryBeforeSince(ctx); err != nil { + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Context); err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return } @@ -597,7 +597,7 @@ func ListMyTrackedTimes(ctx *context.APIContext) { } var err error - if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = utils.GetQueryBeforeSince(ctx); err != nil { + if opts.CreatedBeforeUnix, opts.CreatedAfterUnix, err = context.GetQueryBeforeSince(ctx.Context); err != nil { ctx.Error(http.StatusUnprocessableEntity, "GetQueryBeforeSince", err) return } diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index aca1338a27..f645502590 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -31,23 +31,6 @@ import ( repo_service "code.gitea.io/gitea/services/repository" ) -var searchOrderByMap = map[string]map[string]db.SearchOrderBy{ - "asc": { - "alpha": db.SearchOrderByAlphabetically, - "created": db.SearchOrderByOldest, - "updated": db.SearchOrderByLeastUpdated, - "size": db.SearchOrderBySize, - "id": db.SearchOrderByID, - }, - "desc": { - "alpha": db.SearchOrderByAlphabeticallyReverse, - "created": db.SearchOrderByNewest, - "updated": db.SearchOrderByRecentUpdated, - "size": db.SearchOrderBySizeReverse, - "id": db.SearchOrderByIDReverse, - }, -} - // Search repositories via options func Search(ctx *context.APIContext) { // swagger:operation GET /repos/search repository repoSearch @@ -193,7 +176,7 @@ func Search(ctx *context.APIContext) { if len(sortOrder) == 0 { sortOrder = "asc" } - if searchModeMap, ok := searchOrderByMap[sortOrder]; ok { + if searchModeMap, ok := context.SearchOrderByMap[sortOrder]; ok { if orderBy, ok := searchModeMap[sortMode]; ok { opts.OrderBy = orderBy } else { diff --git a/routers/api/v1/utils/page.go b/routers/api/v1/utils/page.go new file mode 100644 index 0000000000..608bec7395 --- /dev/null +++ b/routers/api/v1/utils/page.go @@ -0,0 +1,19 @@ +// Copyright 2017 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package utils + +import ( + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" +) + +// GetListOptions returns list options using the page and limit parameters +func GetListOptions(ctx *context.APIContext) db.ListOptions { + return db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } +} diff --git a/routers/web/explore/topic.go b/routers/web/explore/topic.go new file mode 100644 index 0000000000..39b87f2498 --- /dev/null +++ b/routers/web/explore/topic.go @@ -0,0 +1,42 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package explore + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" + api "code.gitea.io/gitea/modules/structs" +) + +// TopicSearch search for creating topic +func TopicSearch(ctx *context.Context) { + opts := &repo_model.FindTopicOptions{ + Keyword: ctx.FormString("q"), + ListOptions: db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + } + + topics, total, err := repo_model.FindTopics(opts) + if err != nil { + ctx.Error(http.StatusInternalServerError) + return + } + + topicResponses := make([]*api.TopicResponse, len(topics)) + for i, topic := range topics { + topicResponses[i] = convert.ToTopicResponse(topic) + } + + ctx.SetTotalCountHeader(total) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "topics": topicResponses, + }) +} diff --git a/routers/web/org/teams.go b/routers/web/org/teams.go index 034a8ce978..31bfaea92f 100644 --- a/routers/web/org/teams.go +++ b/routers/web/org/teams.go @@ -13,6 +13,7 @@ import ( "strings" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/organization" "code.gitea.io/gitea/models/perm" repo_model "code.gitea.io/gitea/models/repo" @@ -20,7 +21,9 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" "code.gitea.io/gitea/modules/log" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/routers/utils" "code.gitea.io/gitea/services/forms" @@ -329,6 +332,51 @@ func TeamRepositories(ctx *context.Context) { ctx.HTML(http.StatusOK, tplTeamRepositories) } +// SearchTeam api for searching teams +func SearchTeam(ctx *context.Context) { + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } + + opts := &organization.SearchTeamOptions{ + UserID: ctx.Doer.ID, + Keyword: ctx.FormTrim("q"), + OrgID: ctx.Org.Organization.ID, + IncludeDesc: ctx.FormString("include_desc") == "" || ctx.FormBool("include_desc"), + ListOptions: listOptions, + } + + teams, maxResults, err := organization.SearchTeam(opts) + if err != nil { + log.Error("SearchTeam failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "error": "SearchTeam internal failure", + }) + return + } + + apiTeams := make([]*api.Team, len(teams)) + for i := range teams { + if err := teams[i].GetUnits(); err != nil { + log.Error("Team GetUnits failed: %v", err) + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "error": "SearchTeam failed to get units", + }) + return + } + apiTeams[i] = convert.ToTeam(teams[i]) + } + + ctx.SetTotalCountHeader(maxResults) + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + "data": apiTeams, + }) +} + // EditTeam render team edit page func EditTeam(ctx *context.Context) { ctx.Data["Title"] = ctx.Org.Organization.FullName diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go index a1a7200ba4..1fd60812f9 100644 --- a/routers/web/repo/issue.go +++ b/routers/web/repo/issue.go @@ -16,6 +16,7 @@ import ( "path" "strconv" "strings" + "time" "code.gitea.io/gitea/models" "code.gitea.io/gitea/models/db" @@ -36,6 +37,7 @@ import ( "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates/vars" + "code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/upload" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" @@ -1762,6 +1764,20 @@ func getActionIssues(ctx *context.Context) []*models.Issue { return issues } +// GetIssueInfo get an issue of a repository +func GetIssueInfo(ctx *context.Context) { + issue, err := models.GetIssueWithAttrsByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.Error(http.StatusNotFound) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + } + return + } + ctx.JSON(http.StatusOK, convert.ToAPIIssue(issue)) +} + // UpdateIssueTitle change issue's title func UpdateIssueTitle(ctx *context.Context) { issue := GetActionIssue(ctx) @@ -1856,6 +1872,40 @@ func UpdateIssueContent(ctx *context.Context) { }) } +// UpdateIssueDeadline updates an issue deadline +func UpdateIssueDeadline(ctx *context.Context) { + form := web.GetForm(ctx).(*api.EditDeadlineOption) + issue, err := models.GetIssueByIndex(ctx.Repo.Repository.ID, ctx.ParamsInt64(":index")) + if err != nil { + if models.IsErrIssueNotExist(err) { + ctx.NotFound("GetIssueByIndex", err) + } else { + ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err.Error()) + } + return + } + + if !ctx.Repo.CanWriteIssuesOrPulls(issue.IsPull) { + ctx.Error(http.StatusForbidden, "", "Not repo writer") + return + } + + var deadlineUnix timeutil.TimeStamp + var deadline time.Time + if form.Deadline != nil && !form.Deadline.IsZero() { + deadline = time.Date(form.Deadline.Year(), form.Deadline.Month(), form.Deadline.Day(), + 23, 59, 59, 0, time.Local) + deadlineUnix = timeutil.TimeStamp(deadline.Unix()) + } + + if err := models.UpdateIssueDeadline(issue, deadlineUnix, ctx.Doer); err != nil { + ctx.Error(http.StatusInternalServerError, "UpdateIssueDeadline", err.Error()) + return + } + + ctx.JSON(http.StatusCreated, api.IssueDeadline{Deadline: &deadline}) +} + // UpdateIssueMilestone change issue's milestone func UpdateIssueMilestone(ctx *context.Context) { issues := getActionIssues(ctx) @@ -2052,6 +2102,338 @@ func UpdatePullReviewRequest(ctx *context.Context) { }) } +// SearchIssues searches for issues across the repositories that the user has access to +func SearchIssues(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, err.Error()) + return + } + + var isClosed util.OptionalBool + switch ctx.FormString("state") { + case "closed": + isClosed = util.OptionalBoolTrue + case "all": + isClosed = util.OptionalBoolNone + default: + isClosed = util.OptionalBoolFalse + } + + // find repos user can access (for issue search) + opts := &models.SearchRepoOptions{ + Private: false, + AllPublic: true, + TopicOnly: false, + Collaborate: util.OptionalBoolNone, + // This needs to be a column that is not nil in fixtures or + // MySQL will return different results when sorting by null in some cases + OrderBy: db.SearchOrderByAlphabetically, + Actor: ctx.Doer, + } + if ctx.IsSigned { + opts.Private = true + opts.AllLimited = true + } + if ctx.FormString("owner") != "" { + owner, err := user_model.GetUserByName(ctx.FormString("owner")) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.Error(http.StatusBadRequest, "Owner not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return + } + opts.OwnerID = owner.ID + opts.AllLimited = false + opts.AllPublic = false + opts.Collaborate = util.OptionalBoolFalse + } + if ctx.FormString("team") != "" { + if ctx.FormString("owner") == "" { + ctx.Error(http.StatusBadRequest, "", "Owner organisation is required for filtering on team") + return + } + team, err := organization.GetTeam(opts.OwnerID, ctx.FormString("team")) + if err != nil { + if organization.IsErrTeamNotExist(err) { + ctx.Error(http.StatusBadRequest, "Team not found", err.Error()) + } else { + ctx.Error(http.StatusInternalServerError, "GetUserByName", err.Error()) + } + return + } + opts.TeamID = team.ID + } + + repoIDs, _, err := models.SearchRepositoryIDs(opts) + if err != nil { + ctx.Error(http.StatusInternalServerError, "SearchRepositoryByName", err.Error()) + return + } + + var issues []*models.Issue + var filteredCount int64 + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + var issueIDs []int64 + if len(keyword) > 0 && len(repoIDs) > 0 { + if issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx, repoIDs, keyword); err != nil { + ctx.Error(http.StatusInternalServerError, "SearchIssuesByKeyword", err.Error()) + return + } + } + + var isPull util.OptionalBool + switch ctx.FormString("type") { + case "pulls": + isPull = util.OptionalBoolTrue + case "issues": + isPull = util.OptionalBoolFalse + default: + isPull = util.OptionalBoolNone + } + + labels := ctx.FormTrim("labels") + var includedLabelNames []string + if len(labels) > 0 { + includedLabelNames = strings.Split(labels, ",") + } + + milestones := ctx.FormTrim("milestones") + var includedMilestones []string + if len(milestones) > 0 { + includedMilestones = strings.Split(milestones, ",") + } + + // this api is also used in UI, + // so the default limit is set to fit UI needs + limit := ctx.FormInt("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(includedLabelNames) > 0 || len(includedMilestones) > 0 { + issuesOpt := &models.IssuesOptions{ + ListOptions: db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: limit, + }, + RepoIDs: repoIDs, + IsClosed: isClosed, + IssueIDs: issueIDs, + IncludedLabelNames: includedLabelNames, + IncludeMilestones: includedMilestones, + SortType: "priorityrepo", + PriorityRepoID: ctx.FormInt64("priority_repo_id"), + IsPull: isPull, + UpdatedBeforeUnix: before, + UpdatedAfterUnix: since, + } + + ctxUserID := int64(0) + if ctx.IsSigned { + ctxUserID = ctx.Doer.ID + } + + // Filter for: Created by User, Assigned to User, Mentioning User, Review of User Requested + if ctx.FormBool("created") { + issuesOpt.PosterID = ctxUserID + } + if ctx.FormBool("assigned") { + issuesOpt.AssigneeID = ctxUserID + } + if ctx.FormBool("mentioned") { + issuesOpt.MentionedID = ctxUserID + } + if ctx.FormBool("review_requested") { + issuesOpt.ReviewRequestedID = ctxUserID + } + + if issues, err = models.Issues(issuesOpt); err != nil { + ctx.Error(http.StatusInternalServerError, "Issues", err.Error()) + return + } + + issuesOpt.ListOptions = db.ListOptions{ + Page: -1, + } + if filteredCount, err = models.CountIssues(issuesOpt); err != nil { + ctx.Error(http.StatusInternalServerError, "CountIssues", err.Error()) + return + } + } + + ctx.SetTotalCountHeader(filteredCount) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(issues)) +} + +func getUserIDForFilter(ctx *context.Context, queryName string) int64 { + userName := ctx.FormString(queryName) + if len(userName) == 0 { + return 0 + } + + user, err := user_model.GetUserByName(userName) + if user_model.IsErrUserNotExist(err) { + ctx.NotFound("", err) + return 0 + } + + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return 0 + } + + return user.ID +} + +// ListIssues list the issues of a repository +func ListIssues(ctx *context.Context) { + before, since, err := context.GetQueryBeforeSince(ctx) + if err != nil { + ctx.Error(http.StatusUnprocessableEntity, err.Error()) + return + } + + var isClosed util.OptionalBool + switch ctx.FormString("state") { + case "closed": + isClosed = util.OptionalBoolTrue + case "all": + isClosed = util.OptionalBoolNone + default: + isClosed = util.OptionalBoolFalse + } + + var issues []*models.Issue + var filteredCount int64 + + keyword := ctx.FormTrim("q") + if strings.IndexByte(keyword, 0) >= 0 { + keyword = "" + } + var issueIDs []int64 + var labelIDs []int64 + if len(keyword) > 0 { + issueIDs, err = issue_indexer.SearchIssuesByKeyword(ctx, []int64{ctx.Repo.Repository.ID}, keyword) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + if splitted := strings.Split(ctx.FormString("labels"), ","); len(splitted) > 0 { + labelIDs, err = models.GetLabelIDsInRepoByNames(ctx.Repo.Repository.ID, splitted) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + var mileIDs []int64 + if part := strings.Split(ctx.FormString("milestones"), ","); len(part) > 0 { + for i := range part { + // uses names and fall back to ids + // non existent milestones are discarded + mile, err := models.GetMilestoneByRepoIDANDName(ctx.Repo.Repository.ID, part[i]) + if err == nil { + mileIDs = append(mileIDs, mile.ID) + continue + } + if !models.IsErrMilestoneNotExist(err) { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + id, err := strconv.ParseInt(part[i], 10, 64) + if err != nil { + continue + } + mile, err = models.GetMilestoneByRepoID(ctx.Repo.Repository.ID, id) + if err == nil { + mileIDs = append(mileIDs, mile.ID) + continue + } + if models.IsErrMilestoneNotExist(err) { + continue + } + ctx.Error(http.StatusInternalServerError, err.Error()) + } + } + + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } + + var isPull util.OptionalBool + switch ctx.FormString("type") { + case "pulls": + isPull = util.OptionalBoolTrue + case "issues": + isPull = util.OptionalBoolFalse + default: + isPull = util.OptionalBoolNone + } + + // FIXME: we should be more efficient here + createdByID := getUserIDForFilter(ctx, "created_by") + if ctx.Written() { + return + } + assignedByID := getUserIDForFilter(ctx, "assigned_by") + if ctx.Written() { + return + } + mentionedByID := getUserIDForFilter(ctx, "mentioned_by") + if ctx.Written() { + return + } + + // 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: listOptions, + RepoIDs: []int64{ctx.Repo.Repository.ID}, + IsClosed: isClosed, + IssueIDs: issueIDs, + LabelIDs: labelIDs, + MilestoneIDs: mileIDs, + IsPull: isPull, + UpdatedBeforeUnix: before, + UpdatedAfterUnix: since, + PosterID: createdByID, + AssigneeID: assignedByID, + MentionedID: mentionedByID, + } + + if issues, err = models.Issues(issuesOpt); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + issuesOpt.ListOptions = db.ListOptions{ + Page: -1, + } + if filteredCount, err = models.CountIssues(issuesOpt); err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + } + + ctx.SetTotalCountHeader(filteredCount) + ctx.JSON(http.StatusOK, convert.ToAPIIssueList(issues)) +} + // UpdateIssueStatus change issue's status func UpdateIssueStatus(ctx *context.Context) { issues := getActionIssues(ctx) diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index 989c1a565e..60298121df 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -20,11 +20,14 @@ import ( user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/forms" repo_service "code.gitea.io/gitea/services/repository" @@ -503,3 +506,112 @@ func InitiateDownload(ctx *context.Context) { "complete": completed, }) } + +// SearchRepo repositories via options +func SearchRepo(ctx *context.Context) { + opts := &models.SearchRepoOptions{ + ListOptions: db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }, + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + OwnerID: ctx.FormInt64("uid"), + PriorityOwnerID: ctx.FormInt64("priority_owner_id"), + TeamID: ctx.FormInt64("team_id"), + TopicOnly: ctx.FormBool("topic"), + Collaborate: util.OptionalBoolNone, + Private: ctx.IsSigned && (ctx.FormString("private") == "" || ctx.FormBool("private")), + Template: util.OptionalBoolNone, + StarredByID: ctx.FormInt64("starredBy"), + IncludeDescription: ctx.FormBool("includeDesc"), + } + + if ctx.FormString("template") != "" { + opts.Template = util.OptionalBoolOf(ctx.FormBool("template")) + } + + if ctx.FormBool("exclusive") { + opts.Collaborate = util.OptionalBoolFalse + } + + mode := ctx.FormString("mode") + switch mode { + case "source": + opts.Fork = util.OptionalBoolFalse + opts.Mirror = util.OptionalBoolFalse + case "fork": + opts.Fork = util.OptionalBoolTrue + case "mirror": + opts.Mirror = util.OptionalBoolTrue + case "collaborative": + opts.Mirror = util.OptionalBoolFalse + opts.Collaborate = util.OptionalBoolTrue + case "": + default: + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid search mode: \"%s\"", mode)) + return + } + + if ctx.FormString("archived") != "" { + opts.Archived = util.OptionalBoolOf(ctx.FormBool("archived")) + } + + if ctx.FormString("is_private") != "" { + opts.IsPrivate = util.OptionalBoolOf(ctx.FormBool("is_private")) + } + + sortMode := ctx.FormString("sort") + if len(sortMode) > 0 { + sortOrder := ctx.FormString("order") + if len(sortOrder) == 0 { + sortOrder = "asc" + } + if searchModeMap, ok := context.SearchOrderByMap[sortOrder]; ok { + if orderBy, ok := searchModeMap[sortMode]; ok { + opts.OrderBy = orderBy + } else { + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort mode: \"%s\"", sortMode)) + return + } + } else { + ctx.Error(http.StatusUnprocessableEntity, fmt.Sprintf("Invalid sort order: \"%s\"", sortOrder)) + return + } + } + + var err error + repos, count, err := models.SearchRepository(opts) + if err != nil { + ctx.JSON(http.StatusInternalServerError, api.SearchError{ + OK: false, + Error: err.Error(), + }) + return + } + + results := make([]*api.Repository, len(repos)) + for i, repo := range repos { + if err = repo.GetOwner(ctx); err != nil { + ctx.JSON(http.StatusInternalServerError, api.SearchError{ + OK: false, + Error: err.Error(), + }) + return + } + accessMode, err := models.AccessLevel(ctx.Doer, repo) + if err != nil { + ctx.JSON(http.StatusInternalServerError, api.SearchError{ + OK: false, + Error: err.Error(), + }) + } + results[i] = convert.ToRepo(repo, accessMode) + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, api.SearchResults{ + OK: true, + Data: results, + }) +} diff --git a/routers/web/user/notification.go b/routers/web/user/notification.go index 04e987924d..f7848de90a 100644 --- a/routers/web/user/notification.go +++ b/routers/web/user/notification.go @@ -15,6 +15,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" ) const ( @@ -191,3 +192,8 @@ func NotificationPurgePost(c *context.Context) { c.Redirect(setting.AppSubURL+"/notifications", http.StatusSeeOther) } + +// NewAvailable returns the notification counts +func NewAvailable(ctx *context.APIContext) { + ctx.JSON(http.StatusOK, api.NotificationCount{New: models.CountUnread(ctx.Doer)}) +} diff --git a/routers/web/user/search.go b/routers/web/user/search.go new file mode 100644 index 0000000000..328c7bade4 --- /dev/null +++ b/routers/web/user/search.go @@ -0,0 +1,44 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" +) + +// Search search users +func Search(ctx *context.Context) { + listOptions := db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + } + + users, maxResults, err := user_model.SearchUsers(&user_model.SearchUserOptions{ + Actor: ctx.Doer, + Keyword: ctx.FormTrim("q"), + UID: ctx.FormInt64("uid"), + Type: user_model.UserTypeIndividual, + ListOptions: listOptions, + }) + if err != nil { + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "ok": false, + "error": err.Error(), + }) + return + } + + ctx.SetTotalCountHeader(maxResults) + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "ok": true, + "data": convert.ToUsers(ctx.Doer, users), + }) +} diff --git a/routers/web/user/stop_watch.go b/routers/web/user/stop_watch.go new file mode 100644 index 0000000000..4b16c9aeda --- /dev/null +++ b/routers/web/user/stop_watch.go @@ -0,0 +1,41 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/convert" +) + +// GetStopwatches get all stopwatches +func GetStopwatches(ctx *context.Context) { + sws, err := models.GetUserStopwatches(ctx.Doer.ID, db.ListOptions{ + Page: ctx.FormInt("page"), + PageSize: convert.ToCorrectPageSize(ctx.FormInt("limit")), + }) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + count, err := models.CountUserStopwatches(ctx.Doer.ID) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + apiSWs, err := convert.ToStopWatches(sws) + if err != nil { + ctx.Error(http.StatusInternalServerError, err.Error()) + return + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiSWs) +} diff --git a/routers/web/web.go b/routers/web/web.go index 3bdedab854..9a2e96aeec 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/public" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/validation" "code.gitea.io/gitea/modules/web" @@ -289,8 +290,13 @@ func RegisterRoutes(m *web.Route) { m.Get("/users", explore.Users) m.Get("/organizations", explore.Organizations) m.Get("/code", explore.Code) + m.Get("/topics/search", explore.TopicSearch) }, ignExploreSignIn) - m.Get("/issues", reqSignIn, user.Issues) + m.Group("/issues", func() { + m.Get("", user.Issues) + m.Get("/search", repo.SearchIssues) + }, reqSignIn) + m.Get("/pulls", reqSignIn, user.Pulls) m.Get("/milestones", reqSignIn, reqMilestonesDashboardPageEnabled, user.Milestones) @@ -421,6 +427,8 @@ func RegisterRoutes(m *web.Route) { m.Post("/forgot_password", auth.ForgotPasswdPost) m.Post("/logout", auth.SignOut) m.Get("/task/{task}", user.TaskStatus) + m.Get("/stopwatches", user.GetStopwatches, reqSignIn) + m.Get("/search", user.Search, ignExploreSignIn) }) // ***** END: User ***** @@ -605,6 +613,7 @@ func RegisterRoutes(m *web.Route) { m.Group("/{org}", func() { m.Get("/teams/new", org.NewTeam) m.Post("/teams/new", bindIgnErr(forms.CreateTeamForm{}), org.NewTeamPost) + m.Get("/teams/-/search", org.SearchTeam) m.Get("/teams/{team}/edit", org.EditTeam) m.Post("/teams/{team}/edit", bindIgnErr(forms.CreateTeamForm{}), org.EditTeamPost) m.Post("/teams/{team}/delete", org.DeleteTeam) @@ -669,6 +678,7 @@ func RegisterRoutes(m *web.Route) { m.Combo("/{repoid}").Get(repo.Fork). Post(bindIgnErr(forms.CreateRepoForm{}), repo.ForkPost) }, context.RepoIDAssignment(), context.UnitTypes(), reqRepoCodeReader) + m.Get("/search", repo.SearchRepo) }, reqSignIn) m.Group("/{username}/-", func() { @@ -811,13 +821,16 @@ func RegisterRoutes(m *web.Route) { Post(bindIgnErr(forms.CreateIssueForm{}), repo.NewIssuePost) m.Get("/choose", context.RepoRef(), repo.NewIssueChooseTemplate) }) + m.Get("/search", repo.ListIssues) }, context.RepoMustNotBeArchived(), reqRepoIssueReader) // FIXME: should use different URLs but mostly same logic for comments of issue and pull request. // So they can apply their own enable/disable logic on routers. m.Group("/{type:issues|pulls}", func() { m.Group("/{index}", func() { + m.Get("/info", repo.GetIssueInfo) m.Post("/title", repo.UpdateIssueTitle) m.Post("/content", repo.UpdateIssueContent) + m.Post("/deadline", bindIgnErr(structs.EditDeadlineOption{}), repo.UpdateIssueDeadline) m.Post("/watch", repo.IssueWatch) m.Post("/ref", repo.UpdateIssueRef) m.Group("/dependency", func() { @@ -1195,6 +1208,7 @@ func RegisterRoutes(m *web.Route) { m.Get("", user.Notifications) m.Post("/status", user.NotificationStatusPost) m.Post("/purge", user.NotificationPurgePost) + m.Get("/new", user.NewAvailable) }, reqSignIn) if setting.API.EnableSwagger { diff --git a/templates/repo/issue/view_content/sidebar.tmpl b/templates/repo/issue/view_content/sidebar.tmpl index aed155fdbf..e673add812 100644 --- a/templates/repo/issue/view_content/sidebar.tmpl +++ b/templates/repo/issue/view_content/sidebar.tmpl @@ -429,7 +429,7 @@ {{if and .HasIssuesOrPullsWritePermission (not .Repository.IsArchived)}}