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 ( |
| 10 | 10 | "path/filepath" |
| 11 | 11 | "regexp" |
| 12 | 12 | "strconv" |
| 13 | "strings" |
| 13 | 14 | |
| 14 | 15 | "gitbay.org/gitbay/internal/gitutil" |
| 15 | 16 | "gitbay.org/gitbay/internal/policy" |
| @@ -196,8 +197,24 @@ func runReleaseEdit(c *Ctx, args []string) int { |
| 196 | 197 | }) |
| 197 | 198 | } |
| 198 | 199 | |
| 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. |
| 203 | func 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 | |
| 199 | 216 | func runReleaseList(c *Ctx, args []string) int { |
| 200 | | rest, p, code := parsePageFlags(c, args, "release", true) |
| 217 | rest, p, code := parsePageFlags(c, args, "release", false) |
| 201 | 218 | if code >= 0 { |
| 202 | 219 | return code |
| 203 | 220 | } |
| @@ -208,11 +225,22 @@ func runReleaseList(c *Ctx, args []string) int { |
| 208 | 225 | if code >= 0 { |
| 209 | 226 | return code |
| 210 | 227 | } |
| 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) |
| 212 | 238 | if err != nil { |
| 213 | 239 | return c.fail(protocol.ExitFailure, "%v", err) |
| 214 | 240 | } |
| 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 | }) |
| 216 | 244 | var ds []releaseOut |
| 217 | 245 | for _, r := range rels { |
| 218 | 246 | ds = append(ds, releaseToOut(r, false)) |
internal/control/release_test.go
+46
| @@ -38,3 +38,49 @@ func TestReleaseListPagesAndHidesTagTitle(t *testing.T) { |
| 38 | 38 | t.Fatalf("page 2: %q", got) |
| 39 | 39 | } |
| 40 | 40 | } |
| 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. |
| 45 | func 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) { |
| 90 | 90 | |
| 91 | 91 | // ListReleases returns releases newest-first, assets included. |
| 92 | 92 | func (s *Store) ListReleases(repoID int64) ([]Release, error) { |
| 93 | | return s.ListReleasesPage(repoID, 0, 0) |
| 93 | return s.ListReleasesPage(repoID, 0, "", 0) |
| 94 | 94 | } |
| 95 | 95 | |
| 96 | | // ListReleasesPage lists newest first. limit 0 is every row; afterID is |
| 97 | | // the last release of the previous page, 0 for the first. |
| 98 | | func (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. |
| 101 | func (s *Store) ListReleasesPage(repoID int64, limit int, afterCreated string, afterID int64) ([]Release, error) { |
| 99 | 102 | q := releaseSelect + " WHERE r.repo_id = ?" |
| 100 | 103 | args := []any{repoID} |
| 101 | 104 | 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) |
| 104 | 107 | } |
| 105 | 108 | q += " ORDER BY r.created_at DESC, r.id DESC" |
| 106 | 109 | if limit > 0 { |