Skip to content

Commit b6ffe0e

Browse files
bimakwwxiaoguang
andauthored
refactor: extract helper functions from SearchIssues (#36158)
## Summary This PR refactors the `SearchIssues` function in `routers/api/v1/repo/issue.go` by extracting common logic into reusable helper functions: - `parseIssueIsClosed()`: Parses the "state" query parameter and returns the corresponding `isClosed` option - `parseIssueIsPull()`: Parses the "type" query parameter and returns the corresponding `isPull` option - `buildSearchIssuesRepoIDs()`: Builds the list of repository IDs for issue search based on query parameters ### Benefits: - Improved code readability - Smaller, more focused functions - Easier to test individual components - Potential for reuse in other handlers ### Changes: - Extracted 3 helper functions from the ~292 line `SearchIssues` function - No functional changes - behavior remains the same - Proper error handling preserved ## Test plan - [ ] Verify existing API tests pass - [ ] Manual testing of `/repos/issues/search` endpoint Ref: #35015 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
1 parent bf0b377 commit b6ffe0e

File tree

6 files changed

+100
-153
lines changed

6 files changed

+100
-153
lines changed

routers/api/v1/repo/issue.go

Lines changed: 65 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"code.gitea.io/gitea/modules/setting"
2525
api "code.gitea.io/gitea/modules/structs"
2626
"code.gitea.io/gitea/modules/timeutil"
27+
"code.gitea.io/gitea/modules/util"
2728
"code.gitea.io/gitea/modules/web"
2829
"code.gitea.io/gitea/routers/api/v1/utils"
2930
"code.gitea.io/gitea/routers/common"
@@ -32,6 +33,60 @@ import (
3233
issue_service "code.gitea.io/gitea/services/issue"
3334
)
3435

36+
// buildSearchIssuesRepoIDs builds the list of repository IDs for issue search based on query parameters.
37+
// It returns repoIDs, allPublic flag, and any error that occurred.
38+
func buildSearchIssuesRepoIDs(ctx *context.APIContext) (repoIDs []int64, allPublic bool, err error) {
39+
opts := repo_model.SearchRepoOptions{
40+
Private: false,
41+
AllPublic: true,
42+
TopicOnly: false,
43+
Collaborate: optional.None[bool](),
44+
// This needs to be a column that is not nil in fixtures or
45+
// MySQL will return different results when sorting by null in some cases
46+
OrderBy: db.SearchOrderByAlphabetically,
47+
Actor: ctx.Doer,
48+
}
49+
if ctx.IsSigned {
50+
opts.Private = !ctx.PublicOnly
51+
opts.AllLimited = true
52+
}
53+
if ctx.FormString("owner") != "" {
54+
owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner"))
55+
if err != nil {
56+
return nil, false, err
57+
}
58+
opts.OwnerID = owner.ID
59+
opts.AllLimited = false
60+
opts.AllPublic = false
61+
opts.Collaborate = optional.Some(false)
62+
}
63+
if ctx.FormString("team") != "" {
64+
if ctx.FormString("owner") == "" {
65+
return nil, false, util.NewInvalidArgumentErrorf("owner organisation is required for filtering on team")
66+
}
67+
team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team"))
68+
if err != nil {
69+
return nil, false, err
70+
}
71+
opts.TeamID = team.ID
72+
}
73+
74+
if opts.AllPublic {
75+
allPublic = true
76+
opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer
77+
}
78+
repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts)
79+
if err != nil {
80+
return nil, false, err
81+
}
82+
if len(repoIDs) == 0 {
83+
// no repos found, don't let the indexer return all repos
84+
repoIDs = []int64{0}
85+
}
86+
87+
return repoIDs, allPublic, nil
88+
}
89+
3590
// SearchIssues searches for issues across the repositories that the user has access to
3691
func SearchIssues(ctx *context.APIContext) {
3792
// swagger:operation GET /repos/issues/search issue issueSearchIssues
@@ -58,11 +113,6 @@ func SearchIssues(ctx *context.APIContext) {
58113
// in: query
59114
// description: Search string
60115
// type: string
61-
// - name: priority_repo_id
62-
// in: query
63-
// description: Repository ID to prioritize in the results
64-
// type: integer
65-
// format: int64
66116
// - name: type
67117
// in: query
68118
// description: Filter by issue type
@@ -136,97 +186,24 @@ func SearchIssues(ctx *context.APIContext) {
136186
return
137187
}
138188

139-
var isClosed optional.Option[bool]
140-
switch ctx.FormString("state") {
141-
case "closed":
142-
isClosed = optional.Some(true)
143-
case "all":
144-
isClosed = optional.None[bool]()
145-
default:
146-
isClosed = optional.Some(false)
147-
}
189+
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormString("state"))
148190

149-
var (
150-
repoIDs []int64
151-
allPublic bool
152-
)
153-
{
154-
// find repos user can access (for issue search)
155-
opts := repo_model.SearchRepoOptions{
156-
Private: false,
157-
AllPublic: true,
158-
TopicOnly: false,
159-
Collaborate: optional.None[bool](),
160-
// This needs to be a column that is not nil in fixtures or
161-
// MySQL will return different results when sorting by null in some cases
162-
OrderBy: db.SearchOrderByAlphabetically,
163-
Actor: ctx.Doer,
164-
}
165-
if ctx.IsSigned {
166-
opts.Private = !ctx.PublicOnly
167-
opts.AllLimited = true
168-
}
169-
if ctx.FormString("owner") != "" {
170-
owner, err := user_model.GetUserByName(ctx, ctx.FormString("owner"))
171-
if err != nil {
172-
if user_model.IsErrUserNotExist(err) {
173-
ctx.APIError(http.StatusBadRequest, err)
174-
} else {
175-
ctx.APIErrorInternal(err)
176-
}
177-
return
178-
}
179-
opts.OwnerID = owner.ID
180-
opts.AllLimited = false
181-
opts.AllPublic = false
182-
opts.Collaborate = optional.Some(false)
183-
}
184-
if ctx.FormString("team") != "" {
185-
if ctx.FormString("owner") == "" {
186-
ctx.APIError(http.StatusBadRequest, "Owner organisation is required for filtering on team")
187-
return
188-
}
189-
team, err := organization.GetTeam(ctx, opts.OwnerID, ctx.FormString("team"))
190-
if err != nil {
191-
if organization.IsErrTeamNotExist(err) {
192-
ctx.APIError(http.StatusBadRequest, err)
193-
} else {
194-
ctx.APIErrorInternal(err)
195-
}
196-
return
197-
}
198-
opts.TeamID = team.ID
199-
}
200-
201-
if opts.AllPublic {
202-
allPublic = true
203-
opts.AllPublic = false // set it false to avoid returning too many repos, we could filter by indexer
204-
}
205-
repoIDs, _, err = repo_model.SearchRepositoryIDs(ctx, opts)
206-
if err != nil {
191+
repoIDs, allPublic, err := buildSearchIssuesRepoIDs(ctx)
192+
if err != nil {
193+
if errors.Is(err, util.ErrNotExist) || errors.Is(err, util.ErrInvalidArgument) {
194+
ctx.APIError(http.StatusBadRequest, err)
195+
} else {
207196
ctx.APIErrorInternal(err)
208-
return
209-
}
210-
if len(repoIDs) == 0 {
211-
// no repos found, don't let the indexer return all repos
212-
repoIDs = []int64{0}
213197
}
198+
return
214199
}
215200

216201
keyword := ctx.FormTrim("q")
217202
if strings.IndexByte(keyword, 0) >= 0 {
218203
keyword = ""
219204
}
220205

221-
var isPull optional.Option[bool]
222-
switch ctx.FormString("type") {
223-
case "pulls":
224-
isPull = optional.Some(true)
225-
case "issues":
226-
isPull = optional.Some(false)
227-
default:
228-
isPull = optional.None[bool]()
229-
}
206+
isPull := common.ParseIssueFilterTypeIsPull(ctx.FormString("type"))
230207

231208
var includedAnyLabels []int64
232209
{
@@ -256,14 +233,7 @@ func SearchIssues(ctx *context.APIContext) {
256233
}
257234
}
258235

259-
// this api is also used in UI,
260-
// so the default limit is set to fit UI needs
261-
limit := ctx.FormInt("limit")
262-
if limit == 0 {
263-
limit = setting.UI.IssuePagingNum
264-
} else if limit > setting.API.MaxResponseItems {
265-
limit = setting.API.MaxResponseItems
266-
}
236+
limit := util.IfZero(ctx.FormInt("limit"), setting.API.DefaultPagingNum)
267237

268238
searchOpt := &issue_indexer.SearchOptions{
269239
Paginator: &db.ListOptions{
@@ -306,10 +276,6 @@ func SearchIssues(ctx *context.APIContext) {
306276
}
307277
}
308278

309-
// FIXME: It's unsupported to sort by priority repo when searching by indexer,
310-
// it's indeed an regression, but I think it is worth to support filtering by indexer first.
311-
_ = ctx.FormInt64("priority_repo_id")
312-
313279
ids, total, err := issue_indexer.SearchIssues(ctx, searchOpt)
314280
if err != nil {
315281
ctx.APIErrorInternal(err)
@@ -409,16 +375,7 @@ func ListIssues(ctx *context.APIContext) {
409375
return
410376
}
411377

412-
var isClosed optional.Option[bool]
413-
switch ctx.FormString("state") {
414-
case "closed":
415-
isClosed = optional.Some(true)
416-
case "all":
417-
isClosed = optional.None[bool]()
418-
default:
419-
isClosed = optional.Some(false)
420-
}
421-
378+
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormString("state"))
422379
keyword := ctx.FormTrim("q")
423380
if strings.IndexByte(keyword, 0) >= 0 {
424381
keyword = ""

routers/api/v1/repo/milestone.go

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010

1111
"code.gitea.io/gitea/models/db"
1212
issues_model "code.gitea.io/gitea/models/issues"
13-
"code.gitea.io/gitea/modules/optional"
1413
api "code.gitea.io/gitea/modules/structs"
1514
"code.gitea.io/gitea/modules/timeutil"
1615
"code.gitea.io/gitea/modules/web"
@@ -60,12 +59,7 @@ func ListMilestones(ctx *context.APIContext) {
6059
// "404":
6160
// "$ref": "#/responses/notFound"
6261

63-
state := api.StateType(ctx.FormString("state"))
64-
var isClosed optional.Option[bool]
65-
switch state {
66-
case api.StateClosed, api.StateOpen:
67-
isClosed = optional.Some(state == api.StateClosed)
68-
}
62+
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormString("state"))
6963

7064
milestones, total, err := db.FindAndCount[issues_model.Milestone](ctx, issues_model.FindMilestoneOptions{
7165
ListOptions: utils.GetListOptions(ctx),

routers/common/issue_filter.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package common
5+
6+
import (
7+
"code.gitea.io/gitea/modules/optional"
8+
)
9+
10+
func ParseIssueFilterStateIsClosed(state string) optional.Option[bool] {
11+
switch state {
12+
case "all":
13+
return optional.None[bool]()
14+
case "closed":
15+
return optional.Some(true)
16+
case "", "open":
17+
return optional.Some(false)
18+
default:
19+
return optional.Some(false) // unknown state, undefined behavior
20+
}
21+
}
22+
23+
func ParseIssueFilterTypeIsPull(typ string) optional.Option[bool] {
24+
return optional.FromMapLookup(map[string]bool{"pulls": true, "issues": false}, typ)
25+
}

routers/web/repo/issue_list.go

Lines changed: 6 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"code.gitea.io/gitea/modules/optional"
2626
"code.gitea.io/gitea/modules/setting"
2727
"code.gitea.io/gitea/modules/util"
28+
"code.gitea.io/gitea/routers/common"
2829
"code.gitea.io/gitea/routers/web/shared/issue"
2930
shared_user "code.gitea.io/gitea/routers/web/shared/user"
3031
"code.gitea.io/gitea/services/context"
@@ -45,15 +46,7 @@ func SearchIssues(ctx *context.Context) {
4546
return
4647
}
4748

48-
var isClosed optional.Option[bool]
49-
switch ctx.FormString("state") {
50-
case "closed":
51-
isClosed = optional.Some(true)
52-
case "all":
53-
isClosed = optional.None[bool]()
54-
default:
55-
isClosed = optional.Some(false)
56-
}
49+
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormString("state"))
5750

5851
var (
5952
repoIDs []int64
@@ -268,15 +261,7 @@ func SearchRepoIssuesJSON(ctx *context.Context) {
268261
return
269262
}
270263

271-
var isClosed optional.Option[bool]
272-
switch ctx.FormString("state") {
273-
case "closed":
274-
isClosed = optional.Some(true)
275-
case "all":
276-
isClosed = optional.None[bool]()
277-
default:
278-
isClosed = optional.Some(false)
279-
}
264+
isClosed := common.ParseIssueFilterStateIsClosed(ctx.FormString("state"))
280265

281266
keyword := ctx.FormTrim("q")
282267
if strings.IndexByte(keyword, 0) >= 0 {
@@ -580,17 +565,10 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
580565
}
581566
}
582567

583-
var isShowClosed optional.Option[bool]
584-
switch ctx.FormString("state") {
585-
case "closed":
586-
isShowClosed = optional.Some(true)
587-
case "all":
588-
isShowClosed = optional.None[bool]()
589-
default:
590-
isShowClosed = optional.Some(false)
591-
}
568+
isShowClosed := common.ParseIssueFilterStateIsClosed(ctx.FormString("state"))
569+
592570
// if there are closed issues and no open issues, default to showing all issues
593-
if len(ctx.FormString("state")) == 0 && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 {
571+
if ctx.FormString("state") == "" && issueStats.OpenCount == 0 && issueStats.ClosedCount != 0 {
594572
isShowClosed = optional.None[bool]()
595573
}
596574

templates/swagger/v1_json.tmpl

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/integration/api_issue_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
user_model "code.gitea.io/gitea/models/user"
2020
"code.gitea.io/gitea/modules/setting"
2121
api "code.gitea.io/gitea/modules/structs"
22+
"code.gitea.io/gitea/modules/test"
2223
"code.gitea.io/gitea/tests"
2324

2425
"github.com/stretchr/testify/assert"
@@ -264,9 +265,8 @@ func TestAPIEditIssue(t *testing.T) {
264265

265266
func TestAPISearchIssues(t *testing.T) {
266267
defer tests.PrepareTestEnv(t)()
267-
268-
// as this API was used in the frontend, it uses UI page size
269-
expectedIssueCount := min(20, setting.UI.IssuePagingNum) // 20 is from the fixtures
268+
defer test.MockVariableValue(&setting.API.DefaultPagingNum, 20)()
269+
expectedIssueCount := 20 // 20 is from the fixtures
270270

271271
link, _ := url.Parse("/api/v1/repos/issues/search")
272272
token := getUserToken(t, "user1", auth_model.AccessTokenScopeReadIssue)

0 commit comments

Comments
 (0)