Commit 683d5a1794

683d5a1794acc41856c38a05aa8e97f544e35363

parent: 3e18e86aa4

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-24 00:50 UTC

release list: cursor carries created_at and id

Ref #254
internal/control/release.go +31 −3
@@ -10,6 +10,7 @@ import (
1010 "path/filepath"
1111 "regexp"
1212 "strconv"
13 "strings"
1314
1415 "gitbay.org/gitbay/internal/gitutil"
1516 "gitbay.org/gitbay/internal/policy"
@@ -196,8 +197,24 @@ func runReleaseEdit(c *Ctx, args []string) int {
196197 })
197198}
198199
200// splitReleaseCursor pulls "<created_at>|<id>" apart. The id is what a
201// deleted release loses, so the created_at half carries the sort
202// position even when the row the cursor names is gone.
203func splitReleaseCursor(key string) (created string, id int64, ok bool) {
204 i := strings.LastIndex(key, "|")
205 if i < 0 {
206 return "", 0, false
207 }
208 created = key[:i]
209 n, err := strconv.ParseInt(key[i+1:], 10, 64)
210 if err != nil || created == "" {
211 return "", 0, false
212 }
213 return created, n, true
214}
215
199216func runReleaseList(c *Ctx, args []string) int {
200 rest, p, code := parsePageFlags(c, args, "release", true)
217 rest, p, code := parsePageFlags(c, args, "release", false)
201218 if code >= 0 {
202219 return code
203220 }
@@ -208,11 +225,22 @@ func runReleaseList(c *Ctx, args []string) int {
208225 if code >= 0 {
209226 return code
210227 }
211 rels, err := c.Store.ListReleasesPage(repo.ID, p.queryLimit(), p.keyInt())
228 var afterCreated string
229 var afterID int64
230 if p.key != "" {
231 var ok bool
232 afterCreated, afterID, ok = splitReleaseCursor(p.key)
233 if !ok {
234 return c.fail(protocol.ExitUsage, "bad cursor")
235 }
236 }
237 rels, err := c.Store.ListReleasesPage(repo.ID, p.queryLimit(), afterCreated, afterID)
212238 if err != nil {
213239 return c.fail(protocol.ExitFailure, "%v", err)
214240 }
215 rels, next := trimPage(p, rels, "release", func(r store.Release) string { return strconv.FormatInt(r.ID, 10) })
241 rels, next := trimPage(p, rels, "release", func(r store.Release) string {
242 return r.CreatedAt + "|" + strconv.FormatInt(r.ID, 10)
243 })
216244 var ds []releaseOut
217245 for _, r := range rels {
218246 ds = append(ds, releaseToOut(r, false))
internal/control/release_test.go +46
@@ -38,3 +38,49 @@ func TestReleaseListPagesAndHidesTagTitle(t *testing.T) {
3838 t.Fatalf("page 2: %q", got)
3939 }
4040}
41
42// The cursor names the pointed-to release by (created_at, id), not by a
43// live lookup of that id: deleting it between page requests must not
44// blank the next page.
45func TestReleaseListPageSurvivesTheCursorReleaseBeingDeleted(t *testing.T) {
46 st, repo, uid := newQueueTestRepo(t)
47 for _, tag := range []string{"v1", "v2", "v3"} {
48 if _, err := st.CreateRelease(repo.ID, tag, tag, "", uid, "md"); err != nil {
49 t.Fatal(err)
50 }
51 }
52 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
53 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--limit", "2"}); code != protocol.ExitOK {
54 t.Fatalf("exit %d: %s", code, errOut)
55 }
56 lines := strings.Split(strings.TrimSpace(c.Stdout.(*bytes.Buffer).String()), "\n")
57 if len(lines) != 3 || !strings.HasPrefix(lines[2], "next\t") {
58 t.Fatalf("page 1:\n%s", strings.Join(lines, "\n"))
59 }
60 cursor := strings.TrimPrefix(lines[2], "next\t")
61
62 rels, err := st.ListReleases(repo.ID)
63 if err != nil {
64 t.Fatal(err)
65 }
66 var v2ID int64
67 for _, r := range rels {
68 if r.Tag == "v2" {
69 v2ID = r.ID
70 }
71 }
72 if v2ID == 0 {
73 t.Fatal("v2 not found")
74 }
75 if err := st.DeleteRelease(v2ID); err != nil {
76 t.Fatal(err)
77 }
78
79 c, errOut = pruneCtx(st, t.TempDir(), store.User{ID: uid})
80 if code := Dispatch(c, []string{"release", "list", repo.Path(), "--cursor", cursor}); code != protocol.ExitOK {
81 t.Fatalf("exit %d: %s", code, errOut)
82 }
83 if got := c.Stdout.(*bytes.Buffer).String(); got != "v1\t\t0 asset(s)\n" {
84 t.Fatalf("page 2 after deleting the cursor release: %q", got)
85 }
86}
internal/store/releases.go +9 −6
@@ -90,17 +90,20 @@ func (s *Store) ReleaseByTag(repoID int64, tag string) (Release, error) {
9090
9191// ListReleases returns releases newest-first, assets included.
9292func (s *Store) ListReleases(repoID int64) ([]Release, error) {
93 return s.ListReleasesPage(repoID, 0, 0)
93 return s.ListReleasesPage(repoID, 0, "", 0)
9494}
9595
96// ListReleasesPage lists newest first. limit 0 is every row; afterID is
97// the last release of the previous page, 0 for the first.
98func (s *Store) ListReleasesPage(repoID int64, limit int, afterID int64) ([]Release, error) {
96// ListReleasesPage lists newest first. limit 0 is every row; afterCreated
97// and afterID are the (created_at, id) of the last release of the
98// previous page, afterID 0 for the first page. The pair, not a lookup by
99// id, is what survives the pointed-to release being deleted between
100// requests.
101func (s *Store) ListReleasesPage(repoID int64, limit int, afterCreated string, afterID int64) ([]Release, error) {
99102 q := releaseSelect + " WHERE r.repo_id = ?"
100103 args := []any{repoID}
101104 if afterID > 0 {
102 q += " AND (r.created_at, r.id) < (SELECT created_at, id FROM releases WHERE id = ?)"
103 args = append(args, afterID)
105 q += " AND (r.created_at, r.id) < (?, ?)"
106 args = append(args, afterCreated, afterID)
104107 }
105108 q += " ORDER BY r.created_at DESC, r.id DESC"
106109 if limit > 0 {