Commit 7de4e7bd74

7de4e7bd74085757ee32a091da55119b53ee5e3d

parent: b019fa0b10

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-18 06:18 UTC

store, control: build list gets --ref, --status and --job filters

ListBuilds takes a BuildFilter (Ref, Status, Job); each non-empty
field adds an AND clause. build list parses the three as flags,
validates --status against pending/running/success/failure/cancelled,
and passes them through.

Ref #224
internal/ci/sched_test.go +5 −5
@@ -70,7 +70,7 @@ func TestSchedulerRunDue(t *testing.T) {
7070 RepoDir: func(owner, name string) string { return bare }}
7171 s.RunDue(now)
7272
73 builds, err := st.ListBuilds(repoID, 10)
73 builds, err := st.ListBuilds(repoID, store.BuildFilter{}, 10)
7474 if err != nil || len(builds) != 1 {
7575 t.Fatalf("builds after run: %v %v", builds, err)
7676 }
@@ -98,7 +98,7 @@ func TestSchedulerRunDue(t *testing.T) {
9898
9999 // A second pass fires nothing: next_run is in the future.
100100 s.RunDue(now)
101 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 {
101 if builds, _ = st.ListBuilds(repoID, store.BuildFilter{}, 10); len(builds) != 1 {
102102 t.Fatalf("second pass queued extra builds: %+v", builds)
103103 }
104104
@@ -111,7 +111,7 @@ func TestSchedulerRunDue(t *testing.T) {
111111 t.Fatal(err)
112112 }
113113 s.RunDue(now)
114 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 {
114 if builds, _ = st.ListBuilds(repoID, store.BuildFilter{}, 10); len(builds) != 1 {
115115 t.Fatalf("tick with the last build pending queued another: %+v", builds)
116116 }
117117 b, ok, err := st.ClaimBuild(nil, false)
@@ -122,7 +122,7 @@ func TestSchedulerRunDue(t *testing.T) {
122122 t.Fatal(err)
123123 }
124124 s.RunDue(now)
125 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 1 {
125 if builds, _ = st.ListBuilds(repoID, store.BuildFilter{}, 10); len(builds) != 1 {
126126 t.Fatalf("tick with the last build running queued another: %+v", builds)
127127 }
128128 if err := st.FinishBuild(b.ID, "success"); err != nil {
@@ -132,7 +132,7 @@ func TestSchedulerRunDue(t *testing.T) {
132132 t.Fatal(err)
133133 }
134134 s.RunDue(now)
135 if builds, _ = st.ListBuilds(repoID, 10); len(builds) != 2 {
135 if builds, _ = st.ListBuilds(repoID, store.BuildFilter{}, 10); len(builds) != 2 {
136136 t.Fatalf("tick after the last build finished did not queue: %+v", builds)
137137 }
138138}
internal/control/build.go +18 −4
@@ -7,6 +7,7 @@ import (
77 "io"
88 "log/slog"
99 "regexp"
10 "slices"
1011 "strconv"
1112 "strings"
1213 "time"
@@ -21,7 +22,7 @@ import (
2122func init() {
2223 register(Command{Path: []string{"build", "list"},
2324 Summary: "list recent builds",
24 Usage: "build list <owner/name>", ReadOnly: true, Run: runBuildList})
25 Usage: "build list <owner/name> [--ref <branch>] [--status <state>] [--job <name>]", ReadOnly: true, Run: runBuildList})
2526 register(Command{Path: []string{"build", "show"},
2627 Summary: "show one build",
2728 Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow})
@@ -102,15 +103,28 @@ func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
102103 return repo, b, -1
103104}
104105
106// buildStatuses is the vocabulary --status accepts, and what a bad value
107// is told to pick from.
108var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
109
105110func runBuildList(c *Ctx, args []string) int {
106 if len(args) != 1 {
111 f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage})
112 if err != nil {
113 return c.fail(protocol.ExitUsage, "%v", err)
114 }
115 path := f.pos(0)
116 if path == "" {
107117 return c.usage()
108118 }
109 repo, code := resolveRepo(c, args[0], policy.CanRead)
119 status := f.Value("--status")
120 if f.Has("--status") && !slices.Contains(buildStatuses, status) {
121 return c.fail(protocol.ExitUsage, "--status must be one of %s", strings.Join(buildStatuses, ", "))
122 }
123 repo, code := resolveRepo(c, path, policy.CanRead)
110124 if code >= 0 {
111125 return code
112126 }
113 builds, err := c.Store.ListBuilds(repo.ID, 50)
127 builds, err := c.Store.ListBuilds(repo.ID, store.BuildFilter{Ref: f.Value("--ref"), Status: status, Job: f.Value("--job")}, 50)
114128 if err != nil {
115129 return c.fail(protocol.ExitFailure, "%v", err)
116130 }
internal/control/build_test.go +71 −11
@@ -1,6 +1,7 @@
11package control
22
33import (
4 "bytes"
45 "os"
56 "os/exec"
67 "path/filepath"
@@ -8,6 +9,7 @@ import (
89 "testing"
910 "time"
1011
12 "gitbay.org/gitbay/internal/protocol"
1113 "gitbay.org/gitbay/internal/store"
1214)
1315
@@ -102,7 +104,7 @@ func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) {
102104
103105 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", old, newSHA, time.Now())
104106
105 builds, err := st.ListBuilds(repo.ID, 10)
107 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
106108 if err != nil || len(builds) != 1 {
107109 t.Fatalf("builds after queue: %v %v", builds, err)
108110 }
@@ -142,7 +144,7 @@ func TestQueueBranchBuildsNewBranchIgnoredPathSkips(t *testing.T) {
142144
143145 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
144146
145 builds, err := st.ListBuilds(repo.ID, 10)
147 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
146148 if err != nil {
147149 t.Fatal(err)
148150 }
@@ -180,7 +182,7 @@ func TestQueueBranchBuildsNewBranchMatchedPathQueues(t *testing.T) {
180182
181183 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
182184
183 builds, err := st.ListBuilds(repo.ID, 10)
185 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
184186 if err != nil || len(builds) != 1 {
185187 t.Fatalf("builds after queue: %v %v", builds, err)
186188 }
@@ -225,7 +227,7 @@ func TestQueueBranchBuildsNewBranchFailsOpenWithoutMergeBase(t *testing.T) {
225227
226228 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, otherSHA, time.Now())
227229
228 builds, err := st.ListBuilds(repo.ID, 10)
230 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
229231 if err != nil || len(builds) != 1 {
230232 t.Fatalf("expected fail-open to queue the job: %v %v", builds, err)
231233 }
@@ -257,7 +259,7 @@ func TestQueueBranchBuildsFreshDefaultBranchFailsOpen(t *testing.T) {
257259
258260 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", testZeroSHA, sha, time.Now())
259261
260 builds, err := st.ListBuilds(repo.ID, 10)
262 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
261263 if err != nil || len(builds) != 1 {
262264 t.Fatalf("expected fail-open on the repository's first commit: %v %v", builds, err)
263265 }
@@ -292,7 +294,7 @@ func TestQueueBranchBuildsOrdinaryPushStillFilters(t *testing.T) {
292294
293295 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
294296
295 builds, err := st.ListBuilds(repo.ID, 10)
297 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
296298 if err != nil {
297299 t.Fatal(err)
298300 }
@@ -445,7 +447,7 @@ func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) {
445447 if len(statuses) != 0 {
446448 t.Fatalf("already-built job recorded a status: %+v", statuses)
447449 }
448 builds, err := st.ListBuilds(repo.ID, 10)
450 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
449451 if err != nil || len(builds) != 1 {
450452 t.Fatalf("expected only the pre-existing build: %v %v", builds, err)
451453 }
@@ -484,7 +486,7 @@ func TestQueueMRBuildsStillFailsOpen(t *testing.T) {
484486
485487 QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, prSHA)
486488
487 builds, err := st.ListBuilds(repo.ID, 10)
489 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
488490 if err != nil || len(builds) != 1 {
489491 t.Fatalf("expected the MR head to fail open and queue a build: %v %v", builds, err)
490492 }
@@ -541,7 +543,7 @@ func TestQueueBranchBuildsRebaseFiltersAgainstMergeBase(t *testing.T) {
541543
542544 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feat", oldTip, newTip, time.Now())
543545
544 builds, err := st.ListBuilds(repo.ID, 10)
546 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
545547 if err != nil {
546548 t.Fatal(err)
547549 }
@@ -578,7 +580,7 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
578580 git(root, "clone", "-q", "--bare", src, dir)
579581
580582 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now())
581 builds, _ := st.ListBuilds(repo.ID, 10)
583 builds, _ := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
582584 if len(builds) != 1 {
583585 t.Fatalf("first commit queued %d builds, want 1", len(builds))
584586 }
@@ -590,7 +592,7 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
590592 }
591593
592594 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now())
593 builds, _ = st.ListBuilds(repo.ID, 10)
595 builds, _ = st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
594596 if len(builds) != 1 {
595597 t.Fatalf("same tree queued a second build: %+v", builds)
596598 }
@@ -605,3 +607,61 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
605607 t.Fatalf("second commit has no success status from the first: %+v", statuses)
606608 }
607609}
610
611// build list's --ref, --status and --job flags narrow the CLI listing the
612// same way the store filter does, and combine when more than one is given.
613// An invalid --status is refused rather than silently matching nothing (#224).
614func TestBuildListFlagsFilter(t *testing.T) {
615 st, repo, uid := newQueueTestRepo(t)
616 if _, err := st.CreateBuild(repo.ID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil {
617 t.Fatal(err)
618 }
619 if _, err := st.CreateBuild(repo.ID, "lint", "bbb", "feature", `["true"]`, "", "", true); err != nil {
620 t.Fatal(err)
621 }
622 if _, ok, err := st.ClaimBuild([]int64{repo.ID}, false); err != nil || !ok {
623 t.Fatalf("claim: %v ok=%v", err, ok)
624 }
625 if err := st.FinishBuild(1, "failure"); err != nil {
626 t.Fatal(err)
627 }
628 c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid})
629
630 run := func(args ...string) string {
631 t.Helper()
632 out := c.Stdout.(*bytes.Buffer)
633 out.Reset()
634 errOut.Reset()
635 argv := append([]string{"build", "list", repo.Path()}, args...)
636 if code := Dispatch(c, argv); code != protocol.ExitOK {
637 t.Fatalf("build list %v: exit %d: %s", args, code, errOut.String())
638 }
639 return out.String()
640 }
641
642 if out := run(); !strings.Contains(out, "unit") || !strings.Contains(out, "lint") {
643 t.Fatalf("unfiltered listing missing a build:\n%s", out)
644 }
645 if out := run("--ref", "main"); !strings.Contains(out, "unit") || strings.Contains(out, "lint") {
646 t.Fatalf("--ref main:\n%s", out)
647 }
648 if out := run("--job", "lint"); strings.Contains(out, "unit") || !strings.Contains(out, "lint") {
649 t.Fatalf("--job lint:\n%s", out)
650 }
651 if out := run("--status", "failure"); !strings.Contains(out, "unit") || strings.Contains(out, "lint") {
652 t.Fatalf("--status failure:\n%s", out)
653 }
654 if out := run("--ref", "main", "--status", "pending"); strings.TrimSpace(out) != "" {
655 t.Fatalf("non-matching combination returned rows:\n%s", out)
656 }
657
658 out := c.Stdout.(*bytes.Buffer)
659 out.Reset()
660 errOut.Reset()
661 if code := Dispatch(c, []string{"build", "list", repo.Path(), "--status", "bogus"}); code != protocol.ExitUsage {
662 t.Fatalf("bad --status: exit %d, want %d (usage)", code, protocol.ExitUsage)
663 }
664 if !strings.Contains(errOut.String(), "pending") || !strings.Contains(errOut.String(), "cancelled") {
665 t.Fatalf("bad --status error does not name the valid states: %s", errOut.String())
666 }
667}
internal/control/runnernext_test.go +1 −1
@@ -165,7 +165,7 @@ func TestRunnerNextOrphanedQueuePastCapReportsNoPendingBuilds(t *testing.T) {
165165 t.Fatalf("expected no pending builds, got:\n%s", out.String())
166166 }
167167
168 builds, err := st.ListBuilds(repo.ID, total+1)
168 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, total+1)
169169 if err != nil {
170170 t.Fatal(err)
171171 }
internal/hookd/pushshapes_test.go +2 −2
@@ -214,7 +214,7 @@ func (f *shapeFixture) schedules() map[string]bool {
214214
215215// mark snapshots the state the observed action starts from.
216216func (f *shapeFixture) mark(sha string) {
217 builds, err := f.st.ListBuilds(f.repo.ID, 1000)
217 builds, err := f.st.ListBuilds(f.repo.ID, store.BuildFilter{}, 1000)
218218 if err != nil {
219219 f.t.Fatal(err)
220220 }
@@ -226,7 +226,7 @@ func (f *shapeFixture) mark(sha string) {
226226// the ci/config status. The words are the table's vocabulary.
227227func (f *shapeFixture) observe() map[string]string {
228228 out := map[string]string{}
229 builds, err := f.st.ListBuilds(f.repo.ID, 1000)
229 builds, err := f.st.ListBuilds(f.repo.ID, store.BuildFilter{}, 1000)
230230 if err != nil {
231231 f.t.Fatal(err)
232232 }
internal/httpd/facts.go +2 −1
@@ -6,6 +6,7 @@ import (
66
77 "gitbay.org/gitbay/internal/control"
88 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/store"
910)
1011
1112// repoFacts is the "what is this repository" summary on a repo home: the
@@ -86,7 +87,7 @@ func (s *Server) factsFor(p repoPage) repoFacts {
8687 if rels, err := s.st.ListReleases(p.Repo.ID); err == nil && len(rels) > 0 {
8788 f.Release = rels[0].Tag
8889 }
89 if builds, err := s.st.ListBuilds(p.Repo.ID, 1); err == nil && len(builds) > 0 {
90 if builds, err := s.st.ListBuilds(p.Repo.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 {
9091 f.Build = builds[0].Status
9192 }
9293 f.Bookmarks = s.st.BookmarkCount(p.Repo.ID)
internal/store/builds.go +25 −2
@@ -274,8 +274,31 @@ func (s *Store) BuildByNumber(repoID, number int64) (Build, error) {
274274 return b, err
275275}
276276
277func (s *Store) ListBuilds(repoID int64, limit int) ([]Build, error) {
278 rows, err := s.DB.Query(buildSelect+" WHERE repo_id = ? ORDER BY number DESC LIMIT ?", repoID, limit)
277// BuildFilter narrows ListBuilds to builds matching every non-empty field.
278type BuildFilter struct {
279 Ref string
280 Status string
281 Job string
282}
283
284func (s *Store) ListBuilds(repoID int64, f BuildFilter, limit int) ([]Build, error) {
285 q := buildSelect + " WHERE repo_id = ?"
286 args := []any{repoID}
287 if f.Ref != "" {
288 q += " AND ref = ?"
289 args = append(args, f.Ref)
290 }
291 if f.Status != "" {
292 q += " AND status = ?"
293 args = append(args, f.Status)
294 }
295 if f.Job != "" {
296 q += " AND job = ?"
297 args = append(args, f.Job)
298 }
299 q += " ORDER BY number DESC LIMIT ?"
300 args = append(args, limit)
301 rows, err := s.DB.Query(q, args...)
279302 if err != nil {
280303 return nil, err
281304 }
internal/store/builds_test.go +57
@@ -332,6 +332,63 @@ func TestQueueStatsFractionalAverage(t *testing.T) {
332332 }
333333}
334334
335// ListBuilds narrows on ref, status and job independently, and combines
336// when more than one is given (#224).
337func TestListBuildsFilters(t *testing.T) {
338 s := open(t)
339 if err := s.MigrateUp(); err != nil {
340 t.Fatal(err)
341 }
342 uid, err := s.CreateUser("cmc", true)
343 if err != nil {
344 t.Fatal(err)
345 }
346 repoID, err := s.CreateRepo("user", uid, "app", "public")
347 if err != nil {
348 t.Fatal(err)
349 }
350 if _, err := s.CreateBuild(repoID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil {
351 t.Fatal(err)
352 }
353 if _, err := s.CreateBuild(repoID, "lint", "bbb", "feature", `["true"]`, "", "", true); err != nil {
354 t.Fatal(err)
355 }
356 if err := s.FinishBuild(mustClaim(t, s, repoID).ID, "failure"); err != nil {
357 t.Fatal(err)
358 }
359
360 all, err := s.ListBuilds(repoID, BuildFilter{}, 10)
361 if err != nil || len(all) != 2 {
362 t.Fatalf("unfiltered: %+v %v", all, err)
363 }
364 if byRef, err := s.ListBuilds(repoID, BuildFilter{Ref: "main"}, 10); err != nil || len(byRef) != 1 || byRef[0].Ref != "main" {
365 t.Fatalf("by ref: %+v %v", byRef, err)
366 }
367 if byJob, err := s.ListBuilds(repoID, BuildFilter{Job: "lint"}, 10); err != nil || len(byJob) != 1 || byJob[0].Job != "lint" {
368 t.Fatalf("by job: %+v %v", byJob, err)
369 }
370 if byStatus, err := s.ListBuilds(repoID, BuildFilter{Status: "failure"}, 10); err != nil || len(byStatus) != 1 || byStatus[0].Status != "failure" {
371 t.Fatalf("by status: %+v %v", byStatus, err)
372 }
373 if combined, err := s.ListBuilds(repoID, BuildFilter{Ref: "main", Status: "failure"}, 10); err != nil || len(combined) != 1 {
374 t.Fatalf("combined filter: %+v %v", combined, err)
375 }
376 if none, err := s.ListBuilds(repoID, BuildFilter{Ref: "main", Status: "pending"}, 10); err != nil || len(none) != 0 {
377 t.Fatalf("non-matching combination: %+v %v", none, err)
378 }
379}
380
381// mustClaim claims the oldest pending build for repoID, failing the test
382// if none is available.
383func mustClaim(t *testing.T, s *Store, repoID int64) Build {
384 t.Helper()
385 b, ok, err := s.ClaimBuild([]int64{repoID}, false)
386 if err != nil || !ok {
387 t.Fatalf("claim: %v ok=%v", err, ok)
388 }
389 return b
390}
391
335392// A merge request head from a fork is untrusted. A claim skips it unless
336393// the runner asked for untrusted builds, so a runner on someone's laptop
337394// never executes a stranger's branch by default.