From 6ad5d0a3062966515730aa1f8d62db5d2a7704ee Mon Sep 17 00:00:00 2001 From: 6543 <6543@obermui.de> Date: Thu, 17 Jun 2021 10:58:10 +0200 Subject: [PATCH] [API] ListReleases add filter for draft and pre-releases (#16175) * invent ctx.QueryOptionalBool * [API] ListReleases add draft and pre-release filter * Add X-Total-Count header * Add a release to fixtures * Add TEST for API ListReleases --- integrations/api_releases_test.go | 53 +++++++++++++++++++++++++++++++ integrations/api_repo_test.go | 2 +- integrations/release_test.go | 23 ++++++++------ models/fixtures/release.yml | 27 +++++++++++----- models/release.go | 14 ++++++++ modules/context/context.go | 6 ++++ modules/context/form.go | 14 ++++++++ routers/api/v1/repo/release.go | 29 +++++++++++++++-- templates/swagger/v1_json.tmpl | 12 +++++++ 9 files changed, 158 insertions(+), 22 deletions(-) diff --git a/integrations/api_releases_test.go b/integrations/api_releases_test.go index 26bf752ccae..027b282036f 100644 --- a/integrations/api_releases_test.go +++ b/integrations/api_releases_test.go @@ -7,6 +7,7 @@ package integrations import ( "fmt" "net/http" + "net/url" "testing" "code.gitea.io/gitea/models" @@ -16,6 +17,58 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAPIListReleases(t *testing.T) { + defer prepareTestEnv(t)() + + repo := models.AssertExistsAndLoadBean(t, &models.Repository{ID: 1}).(*models.Repository) + user2 := models.AssertExistsAndLoadBean(t, &models.User{ID: 2}).(*models.User) + session := loginUser(t, user2.LowerName) + token := getTokenForLoggedInUser(t, session) + + link, _ := url.Parse(fmt.Sprintf("/api/v1/repos/%s/%s/releases", user2.Name, repo.Name)) + link.RawQuery = url.Values{"token": {token}}.Encode() + resp := session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + var apiReleases []*api.Release + DecodeJSON(t, resp, &apiReleases) + if assert.Len(t, apiReleases, 3) { + for _, release := range apiReleases { + switch release.ID { + case 1: + assert.False(t, release.IsDraft) + assert.False(t, release.IsPrerelease) + case 4: + assert.True(t, release.IsDraft) + assert.False(t, release.IsPrerelease) + case 5: + assert.False(t, release.IsDraft) + assert.True(t, release.IsPrerelease) + default: + assert.NoError(t, fmt.Errorf("unexpected release: %v", release)) + } + } + } + + // test filter + testFilterByLen := func(auth bool, query url.Values, expectedLength int, msgAndArgs ...string) { + link.RawQuery = query.Encode() + if auth { + query.Set("token", token) + resp = session.MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + } else { + resp = MakeRequest(t, NewRequest(t, "GET", link.String()), http.StatusOK) + } + DecodeJSON(t, resp, &apiReleases) + 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") +} + func createNewReleaseUsingAPI(t *testing.T, session *TestSession, token string, owner *models.User, repo *models.Repository, name, target, title, desc string) *api.Release { urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/releases?token=%s", owner.Name, repo.Name, token) diff --git a/integrations/api_repo_test.go b/integrations/api_repo_test.go index cfd3b58d649..1ca4575508d 100644 --- a/integrations/api_repo_test.go +++ b/integrations/api_repo_test.go @@ -223,7 +223,7 @@ func TestAPIViewRepo(t *testing.T) { DecodeJSON(t, resp, &repo) assert.EqualValues(t, 1, repo.ID) assert.EqualValues(t, "repo1", repo.Name) - assert.EqualValues(t, 2, repo.Releases) + assert.EqualValues(t, 3, repo.Releases) assert.EqualValues(t, 1, repo.OpenIssues) assert.EqualValues(t, 3, repo.OpenPulls) diff --git a/integrations/release_test.go b/integrations/release_test.go index 365bc04d873..ac5df315d59 100644 --- a/integrations/release_test.go +++ b/integrations/release_test.go @@ -85,7 +85,7 @@ func TestCreateRelease(t *testing.T) { session := loginUser(t, "user2") createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, false) - checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.stable"), 3) + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.stable"), 4) } func TestCreateReleasePreRelease(t *testing.T) { @@ -94,7 +94,7 @@ func TestCreateReleasePreRelease(t *testing.T) { session := loginUser(t, "user2") createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", true, false) - checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.prerelease"), 3) + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.prerelease"), 4) } func TestCreateReleaseDraft(t *testing.T) { @@ -103,7 +103,7 @@ func TestCreateReleaseDraft(t *testing.T) { session := loginUser(t, "user2") createNewRelease(t, session, "/user2/repo1", "v0.0.1", "v0.0.1", false, true) - checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.draft"), 3) + checkLatestReleaseAndCount(t, session, "/user2/repo1", "v0.0.1", i18n.Tr("en", "repo.release.draft"), 4) } func TestCreateReleasePaging(t *testing.T) { @@ -142,7 +142,7 @@ func TestViewReleaseListNoLogin(t *testing.T) { htmlDoc := NewHTMLParser(t, rsp.Body) releases := htmlDoc.Find("#release-list li.ui.grid") - assert.Equal(t, 1, releases.Length()) + assert.Equal(t, 2, releases.Length()) links := make([]string, 0, 5) releases.Each(func(i int, s *goquery.Selection) { @@ -153,7 +153,7 @@ func TestViewReleaseListNoLogin(t *testing.T) { links = append(links, link) }) - assert.EqualValues(t, []string{"/user2/repo1/releases/tag/v1.1"}, links) + assert.EqualValues(t, []string{"/user2/repo1/releases/tag/v1.0", "/user2/repo1/releases/tag/v1.1"}, links) } func TestViewReleaseListLogin(t *testing.T) { @@ -169,7 +169,7 @@ func TestViewReleaseListLogin(t *testing.T) { htmlDoc := NewHTMLParser(t, rsp.Body) releases := htmlDoc.Find("#release-list li.ui.grid") - assert.Equal(t, 2, releases.Length()) + assert.Equal(t, 3, releases.Length()) links := make([]string, 0, 5) releases.Each(func(i int, s *goquery.Selection) { @@ -180,8 +180,11 @@ func TestViewReleaseListLogin(t *testing.T) { links = append(links, link) }) - assert.EqualValues(t, []string{"/user2/repo1/releases/tag/draft-release", - "/user2/repo1/releases/tag/v1.1"}, links) + assert.EqualValues(t, []string{ + "/user2/repo1/releases/tag/draft-release", + "/user2/repo1/releases/tag/v1.0", + "/user2/repo1/releases/tag/v1.1", + }, links) } func TestViewTagsList(t *testing.T) { @@ -197,12 +200,12 @@ func TestViewTagsList(t *testing.T) { htmlDoc := NewHTMLParser(t, rsp.Body) tags := htmlDoc.Find(".tag-list tr") - assert.Equal(t, 2, tags.Length()) + assert.Equal(t, 3, tags.Length()) tagNames := make([]string, 0, 5) tags.Each(func(i int, s *goquery.Selection) { tagNames = append(tagNames, s.Find(".tag a.df.ac").Text()) }) - assert.EqualValues(t, []string{"delete-tag", "v1.1"}, tagNames) + assert.EqualValues(t, []string{"v1.0", "delete-tag", "v1.1"}, tagNames) } diff --git a/models/fixtures/release.yml b/models/fixtures/release.yml index 5e577d3fddb..1703f959d26 100644 --- a/models/fixtures/release.yml +++ b/models/fixtures/release.yml @@ -1,5 +1,4 @@ -- - id: 1 +- id: 1 repo_id: 1 publisher_id: 2 tag_name: "v1.1" @@ -13,8 +12,7 @@ is_tag: false created_unix: 946684800 -- - id: 2 +- id: 2 repo_id: 40 publisher_id: 2 tag_name: "v1.1" @@ -28,8 +26,7 @@ is_tag: false created_unix: 946684800 -- - id: 3 +- id: 3 repo_id: 1 publisher_id: 2 tag_name: "delete-tag" @@ -43,8 +40,7 @@ is_tag: true created_unix: 946684800 -- - id: 4 +- id: 4 repo_id: 1 publisher_id: 2 tag_name: "draft-release" @@ -55,3 +51,18 @@ is_prerelease: false is_tag: false created_unix: 1619524806 + +- id: 5 + repo_id: 1 + publisher_id: 2 + tag_name: "v1.0" + lower_tag_name: "v1.0" + target: "master" + title: "pre-release" + note: "some text for a pre release" + sha1: "65f1bf27bc3bf70f64657658635e66094edbcb4d" + num_commits: 1 + is_draft: false + is_prerelease: true + is_tag: false + created_unix: 946684800 diff --git a/models/release.go b/models/release.go index 13b8f17218c..1ce88a8210c 100644 --- a/models/release.go +++ b/models/release.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/timeutil" + "code.gitea.io/gitea/modules/util" "xorm.io/builder" ) @@ -173,6 +174,8 @@ type FindReleasesOptions struct { ListOptions IncludeDrafts bool IncludeTags bool + IsPreRelease util.OptionalBool + IsDraft util.OptionalBool TagNames []string } @@ -189,6 +192,12 @@ func (opts *FindReleasesOptions) toConds(repoID int64) builder.Cond { if len(opts.TagNames) > 0 { cond = cond.And(builder.In("tag_name", opts.TagNames)) } + if !opts.IsPreRelease.IsNone() { + cond = cond.And(builder.Eq{"is_prerelease": opts.IsPreRelease.IsTrue()}) + } + if !opts.IsDraft.IsNone() { + cond = cond.And(builder.Eq{"is_draft": opts.IsDraft.IsTrue()}) + } return cond } @@ -206,6 +215,11 @@ func GetReleasesByRepoID(repoID int64, opts FindReleasesOptions) ([]*Release, er return rels, sess.Find(&rels) } +// CountReleasesByRepoID returns a number of releases matching FindReleaseOptions and RepoID. +func CountReleasesByRepoID(repoID int64, opts FindReleasesOptions) (int64, error) { + return x.Where(opts.toConds(repoID)).Count(new(Release)) +} + // GetLatestReleaseByRepoID returns the latest release for a repository func GetLatestReleaseByRepoID(repoID int64) (*Release, error) { cond := builder.NewCond(). diff --git a/modules/context/context.go b/modules/context/context.go index 492b3f80ded..7b3fd2899ac 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -27,6 +27,7 @@ import ( "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/translation" + "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web/middleware" "code.gitea.io/gitea/services/auth" @@ -319,6 +320,11 @@ func (ctx *Context) QueryBool(key string, defaults ...bool) bool { return (*Forms)(ctx.Req).MustBool(key, defaults...) } +// QueryOptionalBool returns request form as OptionalBool with default +func (ctx *Context) QueryOptionalBool(key string, defaults ...util.OptionalBool) util.OptionalBool { + return (*Forms)(ctx.Req).MustOptionalBool(key, defaults...) +} + // HandleText handles HTTP status code func (ctx *Context) HandleText(status int, title string) { if (status/100 == 4) || (status/100 == 5) { diff --git a/modules/context/form.go b/modules/context/form.go index c7b76c614c2..e3afad0a904 100644 --- a/modules/context/form.go +++ b/modules/context/form.go @@ -13,6 +13,7 @@ import ( "text/template" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/util" ) // Forms a new enhancement of http.Request @@ -225,3 +226,16 @@ func (f *Forms) MustBool(key string, defaults ...bool) bool { } return v } + +// MustOptionalBool returns request form as OptionalBool with default +func (f *Forms) MustOptionalBool(key string, defaults ...util.OptionalBool) util.OptionalBool { + value := (*http.Request)(f).FormValue(key) + if len(value) == 0 { + return util.OptionalBoolNone + } + v, err := strconv.ParseBool((*http.Request)(f).FormValue(key)) + if len(defaults) > 0 && err != nil { + return defaults[0] + } + return util.OptionalBoolOf(v) +} diff --git a/routers/api/v1/repo/release.go b/routers/api/v1/repo/release.go index 327a2d790b6..1b52de55fff 100644 --- a/routers/api/v1/repo/release.go +++ b/routers/api/v1/repo/release.go @@ -5,6 +5,7 @@ package repo import ( + "fmt" "net/http" "code.gitea.io/gitea/models" @@ -83,6 +84,14 @@ func ListReleases(ctx *context.APIContext) { // description: name of the repo // type: string // required: true + // - name: draft + // in: query + // description: filter (exclude / include) drafts, if you dont have repo write access none will show + // type: boolean + // - name: pre-release + // in: query + // description: filter (exclude / include) pre-releases + // type: boolean // - name: per_page // in: query // description: page size of results, deprecated - use limit @@ -100,15 +109,19 @@ func ListReleases(ctx *context.APIContext) { // "200": // "$ref": "#/responses/ReleaseList" listOptions := utils.GetListOptions(ctx) - if ctx.QueryInt("per_page") != 0 { + if listOptions.PageSize == 0 && ctx.QueryInt("per_page") != 0 { listOptions.PageSize = ctx.QueryInt("per_page") } - releases, err := models.GetReleasesByRepoID(ctx.Repo.Repository.ID, models.FindReleasesOptions{ + opts := models.FindReleasesOptions{ ListOptions: listOptions, IncludeDrafts: ctx.Repo.AccessMode >= models.AccessModeWrite, IncludeTags: false, - }) + IsDraft: ctx.QueryOptionalBool("draft"), + IsPreRelease: ctx.QueryOptionalBool("pre-release"), + } + + releases, err := models.GetReleasesByRepoID(ctx.Repo.Repository.ID, opts) if err != nil { ctx.Error(http.StatusInternalServerError, "GetReleasesByRepoID", err) return @@ -121,6 +134,16 @@ func ListReleases(ctx *context.APIContext) { } rels[i] = convert.ToRelease(release) } + + filteredCount, err := models.CountReleasesByRepoID(ctx.Repo.Repository.ID, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + ctx.SetLinkHeader(int(filteredCount), listOptions.PageSize) + ctx.Header().Set("X-Total-Count", fmt.Sprint(filteredCount)) + ctx.Header().Set("Access-Control-Expose-Headers", "X-Total-Count, Link") ctx.JSON(http.StatusOK, rels) } diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 23e13337677..18b870517ec 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -8076,6 +8076,18 @@ "in": "path", "required": true }, + { + "type": "boolean", + "description": "filter (exclude / include) drafts, if you dont have repo write access none will show", + "name": "draft", + "in": "query" + }, + { + "type": "boolean", + "description": "filter (exclude / include) pre-releases", + "name": "pre-release", + "in": "query" + }, { "type": "integer", "description": "page size of results, deprecated - use limit",