diff --git a/models/repo/release.go b/models/repo/release.go index b4f5fa902b0..f10ecc89409 100644 --- a/models/repo/release.go +++ b/models/repo/release.go @@ -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 } diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index 12ad8a1014c..d6f3576f310 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -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) diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index d4f693f5f4d..fff20fcc480 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -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)", diff --git a/templates/swagger/v1_openapi3_json.tmpl b/templates/swagger/v1_openapi3_json.tmpl index be2f0951093..5afa9be8cb1 100644 --- a/templates/swagger/v1_openapi3_json.tmpl +++ b/templates/swagger/v1_openapi3_json.tmpl @@ -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", diff --git a/tests/integration/api_releases_test.go b/tests/integration/api_releases_test.go index 27b6a925ac9..640a090bfd6 100644 --- a/tests/integration/api_releases_test.go +++ b/tests/integration/api_releases_test.go @@ -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) {