feat(api): add tag_filter query parameter to release list API (#38681)

Adds a `tag_filter` query parameter to `GET /repos/{owner}/{repo}/releases` that filters releases by tag name, with `*` as a wildcard (e.g. `v1*`, `*beta`, `*rc*`). Matching is
case-insensitive and done in the database query. Literal `%`, `_` and `\` in the filter are escaped.

Fixes: https://github.com/go-gitea/gitea/issues/38513
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
Mitrahsoft
2026-07-31 16:10:17 +05:30
committed by GitHub
parent a30d865b78
commit bcf45803af
5 changed files with 59 additions and 42 deletions
+9 -1
View File
@@ -266,6 +266,7 @@ type FindReleasesOptions struct {
TagNames []string
HasSha1 optional.Option[bool] // useful to find draft releases which are created with existing tags
NamePattern optional.Option[string]
TagFilter string
}
func (opts FindReleasesOptions) ToConds() builder.Cond {
@@ -297,7 +298,14 @@ func (opts FindReleasesOptions) ToConds() builder.Cond {
if opts.NamePattern.Has() && opts.NamePattern.Value() != "" {
cond = cond.And(builder.Like{"lower_tag_name", strings.ToLower(opts.NamePattern.Value())})
}
if opts.TagFilter != "" {
pattern := strings.ToLower(opts.TagFilter)
pattern = strings.ReplaceAll(pattern, "\\", "\\\\")
pattern = strings.ReplaceAll(pattern, "_", "\\_")
pattern = strings.ReplaceAll(pattern, "%", "\\%")
pattern = strings.ReplaceAll(pattern, "*", "%")
cond = cond.And(builder.Like{"lower_tag_name", pattern})
}
return cond
}
+5
View File
@@ -154,6 +154,10 @@ func ListReleases(ctx *context.APIContext) {
// in: query
// description: filter (exclude / include) pre-releases
// type: boolean
// - name: tag_filter
// in: query
// description: 'filter releases by tag. supports "*" as a wildcard (for example: v1*, *beta, *rc*).'
// type: string
// - name: page
// in: query
// description: page number of results to return (1-based)
@@ -178,6 +182,7 @@ func ListReleases(ctx *context.APIContext) {
IsDraft: ctx.FormOptionalBool("draft"),
IsPreRelease: ctx.FormOptionalBool("pre-release"),
RepoID: ctx.Repo.Repository.ID,
TagFilter: ctx.FormString("tag_filter"),
}
releases, err := db.Find[repo_model.Release](ctx, opts)
+6
View File
@@ -16196,6 +16196,12 @@
"name": "pre-release",
"in": "query"
},
{
"type": "string",
"description": "filter releases by tag. supports \"*\" as a wildcard (for example: v1*, *beta, *rc*).",
"name": "tag_filter",
"in": "query"
},
{
"type": "integer",
"description": "page number of results to return (1-based)",
+8
View File
@@ -28109,6 +28109,14 @@
"type": "boolean"
}
},
{
"description": "filter releases by tag. supports \"*\" as a wildcard (for example: v1*, *beta, *rc*).",
"in": "query",
"name": "tag_filter",
"schema": {
"type": "string"
}
},
{
"description": "page number of results to return (1-based)",
"in": "query",
+31 -41
View File
@@ -10,7 +10,6 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
@@ -41,13 +40,24 @@ func TestAPIReleaseRead(t *testing.T) {
t.Run("EditReleaseAttachmentWithUnallowedFile", testAPIEditReleaseAttachmentWithUnallowedFile) // failed attempt, so it is also a read test
}
func testAPIListReleasesTagNames(t *testing.T, queryURL, authToken string, expectedTagNames []string, msgAndArgs ...string) {
t.Helper()
resp := MakeRequest(t, NewRequest(t, "GET", queryURL).AddTokenAuth(authToken), http.StatusOK)
apiReleases := DecodeJSON(t, resp, []*api.Release{})
tagNames := make([]string, 0, len(apiReleases))
for _, release := range apiReleases {
tagNames = append(tagNames, release.TagName)
}
assert.ElementsMatch(t, expectedTagNames, tagNames, msgAndArgs)
}
func testAPIListReleasesWithWriteToken(t *testing.T) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository)
link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name))
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
link := fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name)
resp := MakeRequest(t, NewRequest(t, "GET", link).AddTokenAuth(token), http.StatusOK)
apiReleases := DecodeJSON(t, resp, []*api.Release{})
if assert.Len(t, apiReleases, 3) {
for _, release := range apiReleases {
@@ -70,24 +80,16 @@ func testAPIListReleasesWithWriteToken(t *testing.T) {
}
}
// test filter
testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) {
link.RawQuery = query.Encode()
req := NewRequest(t, "GET", link.String())
if auth {
req.AddTokenAuth(token)
}
resp = MakeRequest(t, req, http.StatusOK)
apiReleases = DecodeJSON(t, resp, []*api.Release{})
assert.Len(t, apiReleases, expectedLength, msgAndArgs)
}
testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts")
testFilterByLen(true, url.Values{"draft": {"true"}}, 1, "repo owner should see drafts")
testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts")
testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases")
testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release")
testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft")
testAPIListReleasesTagNames(t, link+"?draft=true", "", nil, "anon should not see drafts")
testAPIListReleasesTagNames(t, link+"?draft=true", token, []string{"draft-release"}, "repo owner should see drafts")
testAPIListReleasesTagNames(t, link+"?draft=false", token, []string{"v1.0", "v1.1"}, "exclude drafts")
testAPIListReleasesTagNames(t, link+"?draft=false&pre-release=false", token, []string{"v1.1"}, "exclude drafts and pre-releases")
testAPIListReleasesTagNames(t, link+"?pre-release=true", token, []string{"v1.0"}, "only get pre-release")
testAPIListReleasesTagNames(t, link+"?draft=true&pre-release=true", token, nil, "there is no pre-release draft")
testAPIListReleasesTagNames(t, link+"?tag_filter=v1*", token, []string{"v1.0", "v1.1"}, "prefix tag filter")
testAPIListReleasesTagNames(t, link+"?tag_filter=*1.0", token, []string{"v1.0"}, "suffix tag filter")
testAPIListReleasesTagNames(t, link+"?tag_filter=*raft*", token, []string{"draft-release"}, "substring tag filter")
testAPIListReleasesTagNames(t, link+"?tag_filter=v1._", token, nil, "underscore is not a wildcard")
}
func testAPIListReleasesWithReadToken(t *testing.T) {
@@ -95,8 +97,8 @@ func testAPIListReleasesWithReadToken(t *testing.T) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeReadRepository)
link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name))
resp := MakeRequest(t, NewRequest(t, "GET", link.String()).AddTokenAuth(token), http.StatusOK)
link := fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name)
resp := MakeRequest(t, NewRequest(t, "GET", link).AddTokenAuth(token), http.StatusOK)
apiReleases := DecodeJSON(t, resp, []*api.Release{})
if assert.Len(t, apiReleases, 2) {
for _, release := range apiReleases {
@@ -115,24 +117,12 @@ func testAPIListReleasesWithReadToken(t *testing.T) {
}
}
// test filter
testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) {
link.RawQuery = query.Encode()
req := NewRequest(t, "GET", link.String())
if auth {
req.AddTokenAuth(token)
}
resp = MakeRequest(t, req, http.StatusOK)
apiReleases = DecodeJSON(t, resp, []*api.Release{})
assert.Len(t, apiReleases, expectedLength, msgAndArgs)
}
testFilterByLen(false, url.Values{"draft": {"true"}}, 0, "anon should not see drafts")
testFilterByLen(true, url.Values{"draft": {"true"}}, 0, "repo owner with read token should not see drafts")
testFilterByLen(true, url.Values{"draft": {"false"}}, 2, "exclude drafts")
testFilterByLen(true, url.Values{"draft": {"false"}, "pre-release": {"false"}}, 1, "exclude drafts and pre-releases")
testFilterByLen(true, url.Values{"pre-release": {"true"}}, 1, "only get pre-release")
testFilterByLen(true, url.Values{"draft": {"true"}, "pre-release": {"true"}}, 0, "there is no pre-release draft")
testAPIListReleasesTagNames(t, link+"?draft=true", "", nil, "anon should not see drafts")
testAPIListReleasesTagNames(t, link+"?draft=true", token, nil, "repo owner with read token should not see drafts")
testAPIListReleasesTagNames(t, link+"?draft=false", token, []string{"v1.0", "v1.1"}, "exclude drafts")
testAPIListReleasesTagNames(t, link+"?draft=false&pre-release=false", token, []string{"v1.1"}, "exclude drafts and pre-releases")
testAPIListReleasesTagNames(t, link+"?pre-release=true", token, []string{"v1.0"}, "only get pre-release")
testAPIListReleasesTagNames(t, link+"?draft=true&pre-release=true", token, nil, "there is no pre-release draft")
}
func testAPIGetDraftRelease(t *testing.T) {