summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--integrations/issue_test.go208
-rw-r--r--integrations/org_test.go29
-rw-r--r--integrations/repo_topic_test.go46
-rw-r--r--integrations/user_test.go26
-rw-r--r--modules/context/api.go16
-rw-r--r--modules/context/context.go36
-rw-r--r--modules/context/utils.go (renamed from routers/api/v1/utils/utils.go)18
-rw-r--r--routers/api/v1/notify/notifications.go2
-rw-r--r--routers/api/v1/repo/issue.go4
-rw-r--r--routers/api/v1/repo/issue_comment.go6
-rw-r--r--routers/api/v1/repo/issue_tracked_time.go6
-rw-r--r--routers/api/v1/repo/repo.go19
-rw-r--r--routers/api/v1/utils/page.go19
-rw-r--r--routers/web/explore/topic.go42
-rw-r--r--routers/web/org/teams.go48
-rw-r--r--routers/web/repo/issue.go382
-rw-r--r--routers/web/repo/repo.go112
-rw-r--r--routers/web/user/notification.go6
-rw-r--r--routers/web/user/search.go44
-rw-r--r--routers/web/user/stop_watch.go41
-rw-r--r--routers/web/web.go16
-rw-r--r--templates/repo/issue/view_content/sidebar.tmpl2
-rw-r--r--web_src/js/components/ContextPopup.vue2
-rw-r--r--web_src/js/components/DashboardRepoList.js4
-rw-r--r--web_src/js/features/comp/SearchUserBox.js2
-rw-r--r--web_src/js/features/notification.js2
-rw-r--r--web_src/js/features/org-team.js2
-rw-r--r--web_src/js/features/repo-home.js2
-rw-r--r--web_src/js/features/repo-issue.js8
-rw-r--r--web_src/js/features/repo-settings.js2
-rw-r--r--web_src/js/features/repo-template.js2
-rw-r--r--web_src/js/features/stopwatch.js2
32 files changed, 1082 insertions, 74 deletions
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
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)}}
<div {{if ne .Issue.DeadlineUnix 0}} style="display: none;"{{end}} id="deadlineForm">
- <form class="ui fluid action input issue-due-form" action="{{AppSubUrl}}/api/v1/repos/{{PathEscape .Repository.Owner.Name}}/{{PathEscape .Repository.Name}}/issues/{{.Issue.Index}}" method="post" id="update-issue-deadline-form">
+ <form class="ui fluid action input issue-due-form" action="{{AppSubUrl}}/{{PathEscape .Repository.Owner.Name}}/{{PathEscape .Repository.Name}}/issues/{{.Issue.Index}}/deadline" method="post" id="update-issue-deadline-form">
{{$.CsrfTokenHtml}}
<input required placeholder="{{.i18n.Tr "repo.issues.due_date_form"}}" {{if gt .Issue.DeadlineUnix 0}}value="{{.Issue.DeadlineUnix.Format "2006-01-02"}}"{{end}} type="date" name="deadlineDate" id="deadlineDate">
<button class="ui green icon button">
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index 0ff141476a..c176a18659 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -120,7 +120,7 @@ export default {
load(data, callback) {
this.loading = true;
this.i18nErrorMessage = null;
- $.get(`${appSubUrl}/api/v1/repos/${data.owner}/${data.repo}/issues/${data.index}`).done((issue) => {
+ $.get(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`).done((issue) => {
this.issue = issue;
}).fail((jqXHR) => {
if (jqXHR.responseJSON && jqXHR.responseJSON.message) {
diff --git a/web_src/js/components/DashboardRepoList.js b/web_src/js/components/DashboardRepoList.js
index 8d8f186cc6..e4847ec2f0 100644
--- a/web_src/js/components/DashboardRepoList.js
+++ b/web_src/js/components/DashboardRepoList.js
@@ -124,7 +124,7 @@ function initVueComponents() {
return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
},
searchURL() {
- return `${this.subUrl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
+ return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
}&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
}${this.reposFilter !== 'all' ? '&exclusive=1' : ''
}${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
@@ -302,7 +302,7 @@ function initVueComponents() {
this.isLoading = true;
if (!this.reposTotalCount) {
- const totalCountSearchURL = `${this.subUrl}/api/v1/repos/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
+ const totalCountSearchURL = `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
$.getJSON(totalCountSearchURL, (_result, _textStatus, request) => {
this.reposTotalCount = request.getResponseHeader('X-Total-Count');
});
diff --git a/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.js
index 18b67919b5..08f97595af 100644
--- a/web_src/js/features/comp/SearchUserBox.js
+++ b/web_src/js/features/comp/SearchUserBox.js
@@ -8,7 +8,7 @@ export function initCompSearchUserBox() {
$searchUserBox.search({
minCharacters: 2,
apiSettings: {
- url: `${appSubUrl}/api/v1/users/search?q={query}`,
+ url: `${appSubUrl}/user/search?q={query}`,
onResponse(response) {
const items = [];
const searchQueryUppercase = $searchUserBox.find('input').val().toUpperCase();
diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js
index 68b23ef162..36df196cac 100644
--- a/web_src/js/features/notification.js
+++ b/web_src/js/features/notification.js
@@ -158,7 +158,7 @@ async function updateNotificationTable() {
async function updateNotificationCount() {
const data = await $.ajax({
type: 'GET',
- url: `${appSubUrl}/api/v1/notifications/new`,
+ url: `${appSubUrl}/notifications/new`,
headers: {
'X-Csrf-Token': csrfToken,
},
diff --git a/web_src/js/features/org-team.js b/web_src/js/features/org-team.js
index 1a045022d2..9e6c3c7ff1 100644
--- a/web_src/js/features/org-team.js
+++ b/web_src/js/features/org-team.js
@@ -20,7 +20,7 @@ export function initOrgTeamSearchRepoBox() {
$searchRepoBox.search({
minCharacters: 2,
apiSettings: {
- url: `${appSubUrl}/api/v1/repos/search?q={query}&uid=${$searchRepoBox.data('uid')}`,
+ url: `${appSubUrl}/repo/search?q={query}&uid=${$searchRepoBox.data('uid')}`,
onResponse(response) {
const items = [];
$.each(response.data, (_i, item) => {
diff --git a/web_src/js/features/repo-home.js b/web_src/js/features/repo-home.js
index c718bd75d8..2a66dbc6f1 100644
--- a/web_src/js/features/repo-home.js
+++ b/web_src/js/features/repo-home.js
@@ -91,7 +91,7 @@ export function initRepoTopicBar() {
label: 'ui small label'
},
apiSettings: {
- url: `${appSubUrl}/api/v1/topics/search?q={query}`,
+ url: `${appSubUrl}/explore/topics/search?q={query}`,
throttle: 500,
cache: false,
onResponse(res) {
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index 43ce8a9f1b..14b1976bbb 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -54,7 +54,7 @@ function updateDeadline(deadlineString) {
realDeadline = new Date(newDate);
}
- $.ajax(`${$('#update-issue-deadline-form').attr('action')}/deadline`, {
+ $.ajax(`${$('#update-issue-deadline-form').attr('action')}`, {
data: JSON.stringify({
due_date: realDeadline,
}),
@@ -91,9 +91,9 @@ export function initRepoIssueList() {
const repoId = $('#repoId').val();
const crossRepoSearch = $('#crossRepoSearch').val();
const tp = $('#type').val();
- let issueSearchUrl = `${appSubUrl}/api/v1/repos/${repolink}/issues?q={query}&type=${tp}`;
+ let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}`;
if (crossRepoSearch === 'true') {
- issueSearchUrl = `${appSubUrl}/api/v1/repos/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`;
+ issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`;
}
$('#new-dependency-drop-list')
.dropdown({
@@ -292,7 +292,7 @@ export function initRepoIssueReferenceRepositorySearch() {
$('.issue_reference_repository_search')
.dropdown({
apiSettings: {
- url: `${appSubUrl}/api/v1/repos/search?q={query}&limit=20`,
+ url: `${appSubUrl}/repo/search?q={query}&limit=20`,
onResponse(response) {
const filteredResponse = {success: true, results: []};
$.each(response.data, (_r, repo) => {
diff --git a/web_src/js/features/repo-settings.js b/web_src/js/features/repo-settings.js
index dcb0788350..2c3694d458 100644
--- a/web_src/js/features/repo-settings.js
+++ b/web_src/js/features/repo-settings.js
@@ -21,7 +21,7 @@ export function initRepoSettingSearchTeamBox() {
$searchTeamBox.search({
minCharacters: 2,
apiSettings: {
- url: `${appSubUrl}/api/v1/orgs/${$searchTeamBox.data('org')}/teams/search?q={query}`,
+ url: `${appSubUrl}/org/${$searchTeamBox.data('org')}/teams/-/search?q={query}`,
headers: {'X-Csrf-Token': csrfToken},
onResponse(response) {
const items = [];
diff --git a/web_src/js/features/repo-template.js b/web_src/js/features/repo-template.js
index e387678909..dc4ae1e268 100644
--- a/web_src/js/features/repo-template.js
+++ b/web_src/js/features/repo-template.js
@@ -23,7 +23,7 @@ export function initRepoTemplateSearch() {
$('#repo_template_search')
.dropdown({
apiSettings: {
- url: `${appSubUrl}/api/v1/repos/search?q={query}&template=true&priority_owner_id=${$('#uid').val()}`,
+ url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${$('#uid').val()}`,
onResponse(response) {
const filteredResponse = {success: true, results: []};
filteredResponse.results.push({
diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js
index 1748c5119c..f86a801038 100644
--- a/web_src/js/features/stopwatch.js
+++ b/web_src/js/features/stopwatch.js
@@ -111,7 +111,7 @@ async function updateStopwatchWithCallback(callback, timeout) {
async function updateStopwatch() {
const data = await $.ajax({
type: 'GET',
- url: `${appSubUrl}/api/v1/user/stopwatches`,
+ url: `${appSubUrl}/user/stopwatches`,
headers: {'X-Csrf-Token': csrfToken},
});