web: builds page filters by branch, status and job, grouped by run !406

merged merged by cmc on 2026-09-18 06:32 UTC · krz/gitbay:stack-224 into main

14 files changed, +518 −52

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -179,6 +179,7 @@ always markdown.
179| atom feeds | n/a | yes | n/a | 179| atom feeds | n/a | yes | n/a |
180| release create, edit | yes | yes | yes | 180| release create, edit | yes | yes | yes |
181| build list | yes | yes | yes | 181| build list | yes | yes | yes |
182| build list filters (ref, status, job) | yes | yes | no |
182| build show (one build) | yes | yes | yes | 183| build show (one build) | yes | yes | yes |
183| build log | yes | yes | yes | 184| build log | yes | yes | yes |
184| build jobs | yes | yes | yes | 185| build jobs | yes | yes | yes |
.gitbay/wiki/Users.org +7 −3
@@ -507,9 +507,13 @@ queues nothing, so look there when a push builds nothing.
507 507
508Each job becomes a build (=build list=, =build log=, the builds tab on 508Each job becomes a build (=build list=, =build log=, the builds tab on
509the web) and a =ci/<job>= commit status, which =repo settings 509the web) and a =ci/<job>= commit status, which =repo settings
510require-checks= can gate merges on. Steps run with =sh -c= on the 510require-checks= can gate merges on. =build list= takes =--ref=,
511instance's runner, stopping at the first failure; a broken config 511=--status= and =--job= to narrow the listing, combinable; the builds
512surfaces as a failed =ci/config= status. Environment: =GITBAY_REPO=, 512tab reads the same flags from its =?ref=, =?status= and =?job= query
513parameters and groups the result into one row per commit. Steps run
514with =sh -c= on the instance's runner, stopping at the first failure;
515a broken config surfaces as a failed =ci/config= status. Environment:
516=GITBAY_REPO=,
513=GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=, and =GITBAY_SSH=, 517=GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=, and =GITBAY_SSH=,
514the instance's ssh destination as the build reaches it (=git@gitbay.org= 518the instance's ssh destination as the build reaches it (=git@gitbay.org=
515from a runner elsewhere; inside a container on the server's own runner 519from a runner elsewhere; inside a container on the server's own runner
internal/ci/sched_test.go +5 −5
@@ -70,7 +70,7 @@ func TestSchedulerRunDue(t *testing.T) {
70 RepoDir: func(owner, name string) string { return bare }} 70 RepoDir: func(owner, name string) string { return bare }}
71 s.RunDue(now) 71 s.RunDue(now)
72 72
73 builds, err := st.ListBuilds(repoID, 10) 73 builds, err := st.ListBuilds(repoID, store.BuildFilter{}, 10)
74 if err != nil || len(builds) != 1 { 74 if err != nil || len(builds) != 1 {
75 t.Fatalf("builds after run: %v %v", builds, err) 75 t.Fatalf("builds after run: %v %v", builds, err)
76 } 76 }
@@ -98,7 +98,7 @@ func TestSchedulerRunDue(t *testing.T) {
98 98
99 // A second pass fires nothing: next_run is in the future. 99 // A second pass fires nothing: next_run is in the future.
100 s.RunDue(now) 100 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 {
102 t.Fatalf("second pass queued extra builds: %+v", builds) 102 t.Fatalf("second pass queued extra builds: %+v", builds)
103 } 103 }
104 104
@@ -111,7 +111,7 @@ func TestSchedulerRunDue(t *testing.T) {
111 t.Fatal(err) 111 t.Fatal(err)
112 } 112 }
113 s.RunDue(now) 113 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 {
115 t.Fatalf("tick with the last build pending queued another: %+v", builds) 115 t.Fatalf("tick with the last build pending queued another: %+v", builds)
116 } 116 }
117 b, ok, err := st.ClaimBuild(nil, false) 117 b, ok, err := st.ClaimBuild(nil, false)
@@ -122,7 +122,7 @@ func TestSchedulerRunDue(t *testing.T) {
122 t.Fatal(err) 122 t.Fatal(err)
123 } 123 }
124 s.RunDue(now) 124 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 {
126 t.Fatalf("tick with the last build running queued another: %+v", builds) 126 t.Fatalf("tick with the last build running queued another: %+v", builds)
127 } 127 }
128 if err := st.FinishBuild(b.ID, "success"); err != nil { 128 if err := st.FinishBuild(b.ID, "success"); err != nil {
@@ -132,7 +132,7 @@ func TestSchedulerRunDue(t *testing.T) {
132 t.Fatal(err) 132 t.Fatal(err)
133 } 133 }
134 s.RunDue(now) 134 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 {
136 t.Fatalf("tick after the last build finished did not queue: %+v", builds) 136 t.Fatalf("tick after the last build finished did not queue: %+v", builds)
137 } 137 }
138} 138}
internal/control/build.go +18 −4
@@ -7,6 +7,7 @@ import (
7 "io" 7 "io"
8 "log/slog" 8 "log/slog"
9 "regexp" 9 "regexp"
10 "slices"
10 "strconv" 11 "strconv"
11 "strings" 12 "strings"
12 "time" 13 "time"
@@ -21,7 +22,7 @@ import (
21func init() { 22func init() {
22 register(Command{Path: []string{"build", "list"}, 23 register(Command{Path: []string{"build", "list"},
23 Summary: "list recent builds", 24 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})
25 register(Command{Path: []string{"build", "show"}, 26 register(Command{Path: []string{"build", "show"},
26 Summary: "show one build", 27 Summary: "show one build",
27 Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow}) 28 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) {
102 return repo, b, -1 103 return repo, b, -1
103} 104}
104 105
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
105func runBuildList(c *Ctx, args []string) int { 110func 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 == "" {
107 return c.usage() 117 return c.usage()
108 } 118 }
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)
110 if code >= 0 { 124 if code >= 0 {
111 return code 125 return code
112 } 126 }
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)
114 if err != nil { 128 if err != nil {
115 return c.fail(protocol.ExitFailure, "%v", err) 129 return c.fail(protocol.ExitFailure, "%v", err)
116 } 130 }
internal/control/build_test.go +71 −11
@@ -1,6 +1,7 @@
1package control 1package control
2 2
3import ( 3import (
4 "bytes"
4 "os" 5 "os"
5 "os/exec" 6 "os/exec"
6 "path/filepath" 7 "path/filepath"
@@ -8,6 +9,7 @@ import (
8 "testing" 9 "testing"
9 "time" 10 "time"
10 11
12 "gitbay.org/gitbay/internal/protocol"
11 "gitbay.org/gitbay/internal/store" 13 "gitbay.org/gitbay/internal/store"
12) 14)
13 15
@@ -102,7 +104,7 @@ func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) {
102 104
103 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", old, newSHA, time.Now()) 105 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", old, newSHA, time.Now())
104 106
105 builds, err := st.ListBuilds(repo.ID, 10) 107 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
106 if err != nil || len(builds) != 1 { 108 if err != nil || len(builds) != 1 {
107 t.Fatalf("builds after queue: %v %v", builds, err) 109 t.Fatalf("builds after queue: %v %v", builds, err)
108 } 110 }
@@ -142,7 +144,7 @@ func TestQueueBranchBuildsNewBranchIgnoredPathSkips(t *testing.T) {
142 144
143 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now()) 145 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
144 146
145 builds, err := st.ListBuilds(repo.ID, 10) 147 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
146 if err != nil { 148 if err != nil {
147 t.Fatal(err) 149 t.Fatal(err)
148 } 150 }
@@ -180,7 +182,7 @@ func TestQueueBranchBuildsNewBranchMatchedPathQueues(t *testing.T) {
180 182
181 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now()) 183 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, featureSHA, time.Now())
182 184
183 builds, err := st.ListBuilds(repo.ID, 10) 185 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
184 if err != nil || len(builds) != 1 { 186 if err != nil || len(builds) != 1 {
185 t.Fatalf("builds after queue: %v %v", builds, err) 187 t.Fatalf("builds after queue: %v %v", builds, err)
186 } 188 }
@@ -225,7 +227,7 @@ func TestQueueBranchBuildsNewBranchFailsOpenWithoutMergeBase(t *testing.T) {
225 227
226 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, otherSHA, time.Now()) 228 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feature", testZeroSHA, otherSHA, time.Now())
227 229
228 builds, err := st.ListBuilds(repo.ID, 10) 230 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
229 if err != nil || len(builds) != 1 { 231 if err != nil || len(builds) != 1 {
230 t.Fatalf("expected fail-open to queue the job: %v %v", builds, err) 232 t.Fatalf("expected fail-open to queue the job: %v %v", builds, err)
231 } 233 }
@@ -257,7 +259,7 @@ func TestQueueBranchBuildsFreshDefaultBranchFailsOpen(t *testing.T) {
257 259
258 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", testZeroSHA, sha, time.Now()) 260 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", testZeroSHA, sha, time.Now())
259 261
260 builds, err := st.ListBuilds(repo.ID, 10) 262 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
261 if err != nil || len(builds) != 1 { 263 if err != nil || len(builds) != 1 {
262 t.Fatalf("expected fail-open on the repository's first commit: %v %v", builds, err) 264 t.Fatalf("expected fail-open on the repository's first commit: %v %v", builds, err)
263 } 265 }
@@ -292,7 +294,7 @@ func TestQueueBranchBuildsOrdinaryPushStillFilters(t *testing.T) {
292 294
293 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now()) 295 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", oldSHA, newSHA, time.Now())
294 296
295 builds, err := st.ListBuilds(repo.ID, 10) 297 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
296 if err != nil { 298 if err != nil {
297 t.Fatal(err) 299 t.Fatal(err)
298 } 300 }
@@ -445,7 +447,7 @@ func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) {
445 if len(statuses) != 0 { 447 if len(statuses) != 0 {
446 t.Fatalf("already-built job recorded a status: %+v", statuses) 448 t.Fatalf("already-built job recorded a status: %+v", statuses)
447 } 449 }
448 builds, err := st.ListBuilds(repo.ID, 10) 450 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
449 if err != nil || len(builds) != 1 { 451 if err != nil || len(builds) != 1 {
450 t.Fatalf("expected only the pre-existing build: %v %v", builds, err) 452 t.Fatalf("expected only the pre-existing build: %v %v", builds, err)
451 } 453 }
@@ -484,7 +486,7 @@ func TestQueueMRBuildsStillFailsOpen(t *testing.T) {
484 486
485 QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, prSHA) 487 QueueMRBuilds(st, root, "https://x.test", repo, uid, 1, prSHA)
486 488
487 builds, err := st.ListBuilds(repo.ID, 10) 489 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
488 if err != nil || len(builds) != 1 { 490 if err != nil || len(builds) != 1 {
489 t.Fatalf("expected the MR head to fail open and queue a build: %v %v", builds, err) 491 t.Fatalf("expected the MR head to fail open and queue a build: %v %v", builds, err)
490 } 492 }
@@ -541,7 +543,7 @@ func TestQueueBranchBuildsRebaseFiltersAgainstMergeBase(t *testing.T) {
541 543
542 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feat", oldTip, newTip, time.Now()) 544 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "feat", oldTip, newTip, time.Now())
543 545
544 builds, err := st.ListBuilds(repo.ID, 10) 546 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, 10)
545 if err != nil { 547 if err != nil {
546 t.Fatal(err) 548 t.Fatal(err)
547 } 549 }
@@ -578,7 +580,7 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
578 git(root, "clone", "-q", "--bare", src, dir) 580 git(root, "clone", "-q", "--bare", src, dir)
579 581
580 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", "", first, time.Now()) 582 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)
582 if len(builds) != 1 { 584 if len(builds) != 1 {
583 t.Fatalf("first commit queued %d builds, want 1", len(builds)) 585 t.Fatalf("first commit queued %d builds, want 1", len(builds))
584 } 586 }
@@ -590,7 +592,7 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
590 } 592 }
591 593
592 QueueBranchBuilds(st, root, "https://x.test", repo, uid, "main", first, second, time.Now()) 594 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)
594 if len(builds) != 1 { 596 if len(builds) != 1 {
595 t.Fatalf("same tree queued a second build: %+v", builds) 597 t.Fatalf("same tree queued a second build: %+v", builds)
596 } 598 }
@@ -605,3 +607,61 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) {
605 t.Fatalf("second commit has no success status from the first: %+v", statuses) 607 t.Fatalf("second commit has no success status from the first: %+v", statuses)
606 } 608 }
607} 609}
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) {
165 t.Fatalf("expected no pending builds, got:\n%s", out.String()) 165 t.Fatalf("expected no pending builds, got:\n%s", out.String())
166 } 166 }
167 167
168 builds, err := st.ListBuilds(repo.ID, total+1) 168 builds, err := st.ListBuilds(repo.ID, store.BuildFilter{}, total+1)
169 if err != nil { 169 if err != nil {
170 t.Fatal(err) 170 t.Fatal(err)
171 } 171 }
internal/hookd/pushshapes_test.go +2 −2
@@ -214,7 +214,7 @@ func (f *shapeFixture) schedules() map[string]bool {
214 214
215// mark snapshots the state the observed action starts from. 215// mark snapshots the state the observed action starts from.
216func (f *shapeFixture) mark(sha string) { 216func (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)
218 if err != nil { 218 if err != nil {
219 f.t.Fatal(err) 219 f.t.Fatal(err)
220 } 220 }
@@ -226,7 +226,7 @@ func (f *shapeFixture) mark(sha string) {
226// the ci/config status. The words are the table's vocabulary. 226// the ci/config status. The words are the table's vocabulary.
227func (f *shapeFixture) observe() map[string]string { 227func (f *shapeFixture) observe() map[string]string {
228 out := map[string]string{} 228 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)
230 if err != nil { 230 if err != nil {
231 f.t.Fatal(err) 231 f.t.Fatal(err)
232 } 232 }
internal/httpd/buildpages_test.go +18 −13
@@ -21,28 +21,33 @@ func testRepoPage() repoPage {
21// compile error, so render both pages and look for the values. 21// compile error, so render both pages and look for the values.
22func TestBuildsPageRendersCommandOutput(t *testing.T) { 22func TestBuildsPageRendersCommandOutput(t *testing.T) {
23 var sb strings.Builder 23 var sb strings.Builder
24 builds := []control.BuildOut{{
25 Number: 60, Job: "build", Status: "success",
26 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
27 Ref: "cli-coverage", CreatedAt: "2026-08-28T04:42:54Z",
28 }}
29 jobs := []control.JobOut{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}}
30 filter := buildFilter{}
24 err := web.Render(&sb, "builds.html", struct { 31 err := web.Render(&sb, "builds.html", struct {
25 repoPage 32 repoPage
26 Builds []control.BuildOut 33 Builds []control.BuildOut
27 Jobs []control.JobOut 34 Jobs []control.JobOut
28 CanWrite bool 35 Runs []buildRun
29 Notice string 36 Filter buildFilter
37 FilterLinks []buildFilterLink
38 Refs []string
39 CanWrite bool
40 Notice string
30 }{ 41 }{
31 testRepoPage(), 42 testRepoPage(), builds, jobs, groupRuns(builds), filter, filterLinks(filter, jobs),
32 []control.BuildOut{{ 43 distinctRefs(builds, filter.Ref), true, "",
33 Number: 60, Job: "build", Status: "success",
34 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
35 Ref: "cli-coverage", CreatedAt: "2026-08-28T04:42:54Z",
36 }},
37 []control.JobOut{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}},
38 true, "",
39 }) 44 })
40 if err != nil { 45 if err != nil {
41 t.Fatalf("render: %v", err) 46 t.Fatalf("render: %v", err)
42 } 47 }
43 out := sb.String() 48 out := sb.String()
44 for _, want := range []string{ 49 for _, want := range []string{
45 "#60 build", "success", "cli-coverage", "ff6271a9d4", 50 "build", "success", "cli-coverage", "ff6271a9d4",
46 `value="build"`, `value="nightly"`, "schedule 0 3 * * *", 51 `value="build"`, `value="nightly"`, "schedule 0 3 * * *",
47 } { 52 } {
48 if !strings.Contains(out, want) { 53 if !strings.Contains(out, want) {
internal/httpd/builds.go +146 −6
@@ -2,11 +2,133 @@ package httpd
2 2
3import ( 3import (
4 "net/http" 4 "net/http"
5 "net/url"
5 "strconv" 6 "strconv"
6 7
7 "gitbay.org/gitbay/internal/control" 8 "gitbay.org/gitbay/internal/control"
8) 9)
9 10
11// buildFilter is the builds page's GET filter: branch, status and job,
12// each optional and independent (#224).
13type buildFilter struct {
14 Ref string
15 Status string
16 Job string
17}
18
19// buildFilterLink is one entry in the nav.filters row above the build
20// list: a status or a job, with the other two parameters carried along so
21// clicking one never drops another.
22type buildFilterLink struct {
23 Label string
24 Href string
25 Active bool
26}
27
28// buildStatuses is the fixed vocabulary a build's status takes, in the
29// order the nav.filters row offers them.
30var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
31
32// filterLinks builds the nav.filters row: "all" (clears status and job),
33// one link per status, and one per job the repository's CI config names.
34// Each link keeps the filter's other two parameters and net/url encodes
35// them, so a branch name or job name with an odd character does not break
36// the query string it lands in.
37func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
38 href := func(status, job string) string {
39 q := url.Values{}
40 if f.Ref != "" {
41 q.Set("ref", f.Ref)
42 }
43 if status != "" {
44 q.Set("status", status)
45 }
46 if job != "" {
47 q.Set("job", job)
48 }
49 return "?" + q.Encode()
50 }
51 links := []buildFilterLink{
52 {Label: "all", Href: href("", ""), Active: f.Status == "" && f.Job == ""},
53 }
54 for _, s := range buildStatuses {
55 links = append(links, buildFilterLink{Label: s, Href: href(s, f.Job), Active: f.Status == s})
56 }
57 for _, j := range jobs {
58 links = append(links, buildFilterLink{Label: j.Name, Href: href(f.Status, j.Name), Active: f.Job == j.Name})
59 }
60 return links
61}
62
63// distinctRefs lists each ref among builds once, in order, plus the
64// current filter value if it is not already there. It backs the branch
65// field's <datalist> suggestions, not a claim about what branches exist:
66// a ref that matched nothing under the current status/job filter still
67// belongs in the list the person typed it from.
68func distinctRefs(builds []control.BuildOut, current string) []string {
69 seen := map[string]bool{}
70 var refs []string
71 add := func(ref string) {
72 if ref != "" && !seen[ref] {
73 seen[ref] = true
74 refs = append(refs, ref)
75 }
76 }
77 for _, b := range builds {
78 add(b.Ref)
79 }
80 add(current)
81 return refs
82}
83
84// buildRun is one commit's builds, grouped for display: the builds tab
85// reads by commit, not by job, so a push that runs three jobs shows as one
86// row with three chips rather than three unrelated rows (#224).
87type buildRun struct {
88 SHA string
89 Ref string
90 CreatedAt string
91 Status string
92 Builds []control.BuildOut
93}
94
95// runStatusPriority orders combinedStatus's worst-first check: a run reads
96// as its least finished or least successful build.
97var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
98
99// combinedStatus is the run's status: the worst of its builds' statuses,
100// success only when every one of them is.
101func combinedStatus(builds []control.BuildOut) string {
102 has := map[string]bool{}
103 for _, b := range builds {
104 has[b.Status] = true
105 }
106 for _, s := range runStatusPriority {
107 if has[s] {
108 return s
109 }
110 }
111 return "success"
112}
113
114// groupRuns folds consecutive builds of the same commit into one run.
115// build list orders builds newest first, so one push's jobs are adjacent;
116// this does not sort or otherwise assume anything beyond that adjacency.
117func groupRuns(builds []control.BuildOut) []buildRun {
118 var runs []buildRun
119 for _, b := range builds {
120 if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA {
121 runs[n-1].Builds = append(runs[n-1].Builds, b)
122 continue
123 }
124 runs = append(runs, buildRun{SHA: b.SHA, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}})
125 }
126 for i := range runs {
127 runs[i].Status = combinedStatus(runs[i].Builds)
128 }
129 return runs
130}
131
10func (s *Server) builds(w http.ResponseWriter, r *http.Request) { 132func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
11 p, ok := s.repoFor(w, r, "") 133 p, ok := s.repoFor(w, r, "")
12 if !ok { 134 if !ok {
@@ -15,8 +137,21 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
15 p.Tab = "builds" 137 p.Tab = "builds"
16 viewer := s.webViewer(r) 138 viewer := s.webViewer(r)
17 139
140 qv := r.URL.Query()
141 filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")}
142 argv := []string{"build", "list", p.Repo.Path()}
143 if filter.Ref != "" {
144 argv = append(argv, "--ref", filter.Ref)
145 }
146 if filter.Status != "" {
147 argv = append(argv, "--status", filter.Status)
148 }
149 if filter.Job != "" {
150 argv = append(argv, "--job", filter.Job)
151 }
152
18 var builds []control.BuildOut 153 var builds []control.BuildOut
19 s.runControlInto(viewer, []string{"build", "list", p.Repo.Path()}, &builds) 154 s.runControlInto(viewer, argv, &builds)
20 155
21 // The jobs a trigger can name. A repo without a CI config has none; 156 // The jobs a trigger can name. A repo without a CI config has none;
22 // that is not an error for this page. 157 // that is not an error for this page.
@@ -25,11 +160,16 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
25 160
26 s.render(w, "builds.html", struct { 161 s.render(w, "builds.html", struct {
27 repoPage 162 repoPage
28 Builds []control.BuildOut 163 Builds []control.BuildOut
29 Jobs []control.JobOut 164 Jobs []control.JobOut
30 CanWrite bool 165 Runs []buildRun
31 Notice string 166 Filter buildFilter
32 }{p, builds, jobs, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) 167 FilterLinks []buildFilterLink
168 Refs []string
169 CanWrite bool
170 Notice string
171 }{p, builds, jobs, groupRuns(builds), filter, filterLinks(filter, jobs), distinctRefs(builds, filter.Ref),
172 s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
33} 173}
34 174
35func (s *Server) build(w http.ResponseWriter, r *http.Request) { 175func (s *Server) build(w http.ResponseWriter, r *http.Request) {
internal/httpd/builds_test.go added +147
@@ -0,0 +1,147 @@
1package httpd
2
3import (
4 "reflect"
5 "testing"
6
7 "gitbay.org/gitbay/internal/control"
8)
9
10// groupRuns folds consecutive same-commit builds (the list is newest
11// first, so a commit's jobs are adjacent) into one run per commit, and
12// gives the run a combined status: worst first (failure beats everything,
13// then cancelled, running, pending), success only when every job is (#224).
14func TestGroupRunsCombinesByCommit(t *testing.T) {
15 builds := []control.BuildOut{
16 {Number: 3, Job: "lint", Status: "success", SHA: "bbb", Ref: "main", CreatedAt: "t2"},
17 {Number: 2, Job: "unit", Status: "failure", SHA: "aaa", Ref: "main", CreatedAt: "t1"},
18 {Number: 1, Job: "lint", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "t1"},
19 }
20 runs := groupRuns(builds)
21 if len(runs) != 2 {
22 t.Fatalf("groupRuns returned %d runs, want 2: %+v", len(runs), runs)
23 }
24 if runs[0].SHA != "bbb" || len(runs[0].Builds) != 1 || runs[0].Status != "success" {
25 t.Errorf("first run: %+v", runs[0])
26 }
27 if runs[1].SHA != "aaa" || len(runs[1].Builds) != 2 || runs[1].Status != "failure" {
28 t.Errorf("second run: %+v", runs[1])
29 }
30 // Order within a run is preserved from the input.
31 if runs[1].Builds[0].Job != "unit" || runs[1].Builds[1].Job != "lint" {
32 t.Errorf("run builds out of order: %+v", runs[1].Builds)
33 }
34}
35
36func TestGroupRunsEmpty(t *testing.T) {
37 if runs := groupRuns(nil); len(runs) != 0 {
38 t.Errorf("groupRuns(nil) = %+v, want empty", runs)
39 }
40}
41
42// Two builds on the same sha but on different refs (a fast-forward merge
43// can leave the commit reachable from more than one branch) are not
44// adjacent unless the list happens to put them there; groupRuns only folds
45// what is actually adjacent, so this documents that a same-sha, same-ref
46// pair from one push is what gets folded, not "any build of this sha ever".
47func TestGroupRunsCombinedStatusPriority(t *testing.T) {
48 cases := []struct {
49 statuses []string
50 want string
51 }{
52 {[]string{"success"}, "success"},
53 {[]string{"success", "pending"}, "pending"},
54 {[]string{"pending", "running"}, "running"},
55 {[]string{"running", "cancelled"}, "cancelled"},
56 {[]string{"cancelled", "failure"}, "failure"},
57 {[]string{"success", "success", "failure"}, "failure"},
58 }
59 for _, tc := range cases {
60 var builds []control.BuildOut
61 for _, s := range tc.statuses {
62 builds = append(builds, control.BuildOut{SHA: "x", Status: s})
63 }
64 runs := groupRuns(builds)
65 if len(runs) != 1 || runs[0].Status != tc.want {
66 t.Errorf("statuses %v: combined %+v, want %q", tc.statuses, runs, tc.want)
67 }
68 }
69}
70
71// filterLinks builds the nav.filters row: one link that clears both status
72// and job, one per known status and one per known job, each preserving the
73// other two query parameters and marking itself active (#224).
74func TestFilterLinksPreservesOtherParamsAndMarksActive(t *testing.T) {
75 links := filterLinks(buildFilter{Ref: "main", Status: "success", Job: "lint"},
76 []control.JobOut{{Name: "lint"}, {Name: "unit"}})
77
78 byLabel := map[string]buildFilterLink{}
79 for _, l := range links {
80 byLabel[l.Label] = l
81 }
82 all, ok := byLabel["all"]
83 if !ok {
84 t.Fatal("no \"all\" link")
85 }
86 if all.Active {
87 t.Error(`"all" is active while a status/job filter is set`)
88 }
89 if all.Href != "?ref=main" {
90 t.Errorf(`"all" href = %q, want "?ref=main" (clears status and job, keeps ref)`, all.Href)
91 }
92
93 success, ok := byLabel["success"]
94 if !ok || !success.Active {
95 t.Errorf("success link: %+v, want present and active", success)
96 }
97 if success.Href != "?job=lint&ref=main&status=success" {
98 t.Errorf("success href = %q", success.Href)
99 }
100
101 lint, ok := byLabel["lint"]
102 if !ok || !lint.Active {
103 t.Errorf("lint link: %+v, want present and active", lint)
104 }
105 if lint.Href != "?job=lint&ref=main&status=success" {
106 t.Errorf("lint href = %q", lint.Href)
107 }
108
109 unit, ok := byLabel["unit"]
110 if !ok || unit.Active {
111 t.Errorf("unit link: %+v, want present and inactive", unit)
112 }
113 if unit.Href != "?job=unit&ref=main&status=success" {
114 t.Errorf("unit href = %q", unit.Href)
115 }
116}
117
118// With no filter at all, "all" is the active link.
119func TestFilterLinksAllActiveWhenUnfiltered(t *testing.T) {
120 links := filterLinks(buildFilter{}, nil)
121 for _, l := range links {
122 if l.Label == "all" && !l.Active {
123 t.Error(`"all" is not active with no filter set`)
124 }
125 }
126}
127
128// distinctRefs lists each ref once, in the order builds carry them, and
129// always includes the current filter value even if it matched nothing —
130// it powers the branch field's suggestions, not a strict "what exists" list.
131func TestDistinctRefsDedupesAndIncludesCurrent(t *testing.T) {
132 builds := []control.BuildOut{{Ref: "main"}, {Ref: "feature"}, {Ref: "main"}}
133 got := distinctRefs(builds, "release")
134 want := []string{"main", "feature", "release"}
135 if !reflect.DeepEqual(got, want) {
136 t.Errorf("distinctRefs = %v, want %v", got, want)
137 }
138}
139
140func TestDistinctRefsNoDuplicateWhenCurrentAlreadyPresent(t *testing.T) {
141 builds := []control.BuildOut{{Ref: "main"}}
142 got := distinctRefs(builds, "main")
143 want := []string{"main"}
144 if !reflect.DeepEqual(got, want) {
145 t.Errorf("distinctRefs = %v, want %v", got, want)
146 }
147}
internal/httpd/facts.go +2 −1
@@ -6,6 +6,7 @@ import (
6 6
7 "gitbay.org/gitbay/internal/control" 7 "gitbay.org/gitbay/internal/control"
8 "gitbay.org/gitbay/internal/gitutil" 8 "gitbay.org/gitbay/internal/gitutil"
9 "gitbay.org/gitbay/internal/store"
9) 10)
10 11
11// repoFacts is the "what is this repository" summary on a repo home: the 12// repoFacts is the "what is this repository" summary on a repo home: the
@@ -86,7 +87,7 @@ func (s *Server) factsFor(p repoPage) repoFacts {
86 if rels, err := s.st.ListReleases(p.Repo.ID); err == nil && len(rels) > 0 { 87 if rels, err := s.st.ListReleases(p.Repo.ID); err == nil && len(rels) > 0 {
87 f.Release = rels[0].Tag 88 f.Release = rels[0].Tag
88 } 89 }
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 {
90 f.Build = builds[0].Status 91 f.Build = builds[0].Status
91 } 92 }
92 f.Bookmarks = s.st.BookmarkCount(p.Repo.ID) 93 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) {
274 return b, err 274 return b, err
275} 275}
276 276
277func (s *Store) ListBuilds(repoID int64, limit int) ([]Build, error) { 277// BuildFilter narrows ListBuilds to builds matching every non-empty field.
278 rows, err := s.DB.Query(buildSelect+" WHERE repo_id = ? ORDER BY number DESC LIMIT ?", repoID, limit) 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...)
279 if err != nil { 302 if err != nil {
280 return nil, err 303 return nil, err
281 } 304 }
internal/store/builds_test.go +57
@@ -332,6 +332,63 @@ func TestQueueStatsFractionalAverage(t *testing.T) {
332 } 332 }
333} 333}
334 334
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
335// A merge request head from a fork is untrusted. A claim skips it unless 392// A merge request head from a fork is untrusted. A claim skips it unless
336// the runner asked for untrusted builds, so a runner on someone's laptop 393// the runner asked for untrusted builds, so a runner on someone's laptop
337// never executes a stranger's branch by default. 394// never executes a stranger's branch by default.
internal/web/templates/builds.html +18 −4
@@ -1,7 +1,20 @@
1{{define "width"}}wide{{end}} 1{{define "width"}}wide{{end}}
2{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>Builds</h1> 4<div class="listhead">
5 <h1>Builds</h1>
6 <nav class="filters">
7 {{range .FilterLinks}}<a {{if .Active}}class="active" aria-current="page" {{end}}href="{{.Href}}">{{.Label}}</a>{{end}}
8 </nav>
9 <form method="get" class="searchform compact">
10 <label for="ref">Branch</label>
11 <input type="text" id="ref" name="ref" value="{{.Filter.Ref}}" list="buildrefs">
12 <datalist id="buildrefs">{{range .Refs}}<option value="{{.}}">{{end}}</datalist>
13 <input type="hidden" name="status" value="{{.Filter.Status}}">
14 <input type="hidden" name="job" value="{{.Filter.Job}}">
15 <button type="submit" class="btn">Filter</button>
16 </form>
17</div>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 18{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
6{{if and .CanWrite .Jobs}} 19{{if and .CanWrite .Jobs}}
7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform"> 20<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform">
@@ -17,15 +30,16 @@
17<pre class="code">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> 30<pre class="code">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
18<p class="meta">Add <code>?job=name</code> for one job.</p> 31<p class="meta">Add <code>?job=name</code> for one job.</p>
19</details> 32</details>
33<p class="meta">{{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p>
20<ul class="loglist"> 34<ul class="loglist">
21{{range .Builds}}<li> 35{{range .Runs}}<li>
22 <div class="commitmain"> 36 <div class="commitmain">
23 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/builds/{{.Number}}">#{{.Number}} {{.Job}}</a></p> 37 <p class="subject"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code></p>
24 <p class="meta">{{.Ref}} · {{when .CreatedAt}}</p> 38 <p class="meta">{{.Ref}} · {{when .CreatedAt}}</p>
25 </div> 39 </div>
26 <div class="commitside"> 40 <div class="commitside">
27 <span class="badge check-{{.Status}}">{{.Status}}</span> 41 <span class="badge check-{{.Status}}">{{.Status}}</span>
28 <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code> 42 {{range .Builds}}<a class="chip check-{{.Status}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/builds/{{.Number}}">{{.Job}}</a>{{end}}
29 </div> 43 </div>
30</li> 44</li>
31{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}} 45{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}}