From 792fa5eeba4de5f54b0c31f6b0a869ba2c911396 Mon Sep 17 00:00:00 2001 From: Harsh Mahajan <127186841+HarshMN2345@users.noreply.github.com> Date: Thu, 4 Jun 2026 04:51:48 +0530 Subject: [PATCH] feat(api): add q parameter to list branches API for server-side filtering (#37982) The GET /repos/{owner}/{repo}/branches endpoint currently has no way to filter branches by name server-side, forcing API consumers to paginate through all branches and filter client-side. The UI already supports branch search (added in [#27055](https://github.com/go-gitea/gitea/pull/27055)). The underlying DB layer has a Keyword field on FindBranchOptions in models/git/branch_list.go that does a LIKE %keyword% SQL filter, it just wasn't wired up to the API handler. This PR exposes a ?q= query parameter on the endpoint that maps to FindBranchOptions.Keyword. Example: ```GET /repos/owner/repo/branches?q=feature ``` Closes #37981 --------- Co-authored-by: wxiaoguang --- routers/api/v1/repo/branch.go | 6 ++++++ templates/swagger/v1_json.tmpl | 6 ++++++ templates/swagger/v1_openapi3_json.tmpl | 8 ++++++++ tests/integration/api_repo_branch_test.go | 17 +++++++++++++++++ tests/integration/integration_test.go | 4 ++++ 5 files changed, 41 insertions(+) diff --git a/routers/api/v1/repo/branch.go b/routers/api/v1/repo/branch.go index 629945398d8..3afdf9694d1 100644 --- a/routers/api/v1/repo/branch.go +++ b/routers/api/v1/repo/branch.go @@ -306,6 +306,10 @@ func ListBranches(ctx *context.APIContext) { // in: query // description: page size of results // type: integer + // - name: q + // in: query + // description: branch name substring to filter by + // type: string // responses: // "200": // "$ref": "#/responses/BranchList" @@ -314,6 +318,7 @@ func ListBranches(ctx *context.APIContext) { var apiBranches []*api.Branch listOptions := utils.GetListOptions(ctx) + keyword := ctx.FormString("q") if !ctx.Repo.Repository.IsEmpty { if ctx.Repo.GitRepo == nil { @@ -325,6 +330,7 @@ func ListBranches(ctx *context.APIContext) { ListOptions: listOptions, RepoID: ctx.Repo.Repository.ID, IsDeletedBranch: optional.Some(false), + Keyword: keyword, } var err error totalNumOfBranches, err = db.Count[git_model.Branch](ctx, branchOpts) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 20c7abd3454..0b3226294f2 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -7158,6 +7158,12 @@ "description": "page size of results", "name": "limit", "in": "query" + }, + { + "type": "string", + "description": "branch name substring to filter by", + "name": "q", + "in": "query" } ], "responses": { diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index 0a15c8eba49..1402f76899a 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -18224,6 +18224,14 @@ "schema": { "type": "integer" } + }, + { + "description": "branch name substring to filter by", + "in": "query", + "name": "q", + "schema": { + "type": "string" + } } ], "responses": { diff --git a/tests/integration/api_repo_branch_test.go b/tests/integration/api_repo_branch_test.go index 188da619fb9..864e351c8d0 100644 --- a/tests/integration/api_repo_branch_test.go +++ b/tests/integration/api_repo_branch_test.go @@ -128,3 +128,20 @@ func TestAPIRepoBranchesMirror(t *testing.T) { assert.NoError(t, err) assert.JSONEq(t, "{\"message\":\"Git Repository is a mirror.\",\"url\":\""+setting.AppURL+"api/swagger\"}", string(bs)) } + +func TestAPIRepoBranchesSearch(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + token := getUserToken(t, "user1", auth_model.AccessTokenScopeWriteRepository) + + // "test" matches "test_branch" but not "master" + resp := MakeRequest(t, NewRequestf(t, "GET", "/api/v1/repos/org3/repo3/branches?q=test").AddTokenAuth(token), http.StatusOK) + branches := DecodeJSON(t, resp, []api.Branch{}) + assert.Len(t, branches, 1) + assert.Equal(t, "test_branch", branches[0].Name) + + // no match returns empty list + resp = MakeRequest(t, NewRequestf(t, "GET", "/api/v1/repos/org3/repo3/branches?q=doesnotexist").AddTokenAuth(token), http.StatusOK) + branches = DecodeJSON(t, resp, []api.Branch{}) + assert.Empty(t, branches) +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go index 9c42366832f..6f0928a12e1 100644 --- a/tests/integration/integration_test.go +++ b/tests/integration/integration_test.go @@ -323,6 +323,10 @@ func logUnexpectedResponse(t testing.TB, recorder *httptest.ResponseRecorder) { } } +// DecodeJSON decodes then response as JSON into typed variable and return it +// HINT: don't use it on existing variable (reuse existing variable): +// if the existing var already contains some values but the new input doesn't, then it leads to wrong test result in edge cases. +// For slice decoding, use: v := DecodeJSON(t, resp, []T{}) func DecodeJSON[T any](t testing.TB, resp *httptest.ResponseRecorder, v T) (ret T) { t.Helper()