web: builds page filters by branch, status and job, grouped by run !406
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 | ||
| 508 | Each job becomes a build (=build list=, =build log=, the builds tab on | 508 | Each job becomes a build (=build list=, =build log=, the builds tab on |
| 509 | the web) and a =ci/<job>= commit status, which =repo settings | 509 | the web) and a =ci/<job>= commit status, which =repo settings |
| 510 | require-checks= can gate merges on. Steps run with =sh -c= on the | 510 | require-checks= can gate merges on. =build list= takes =--ref=, |
| 511 | instance's runner, stopping at the first failure; a broken config | 511 | =--status= and =--job= to narrow the listing, combinable; the builds |
| 512 | surfaces as a failed =ci/config= status. Environment: =GITBAY_REPO=, | 512 | tab reads the same flags from its =?ref=, =?status= and =?job= query |
| 513 | parameters and groups the result into one row per commit. Steps run | ||
| 514 | with =sh -c= on the instance's runner, stopping at the first failure; | ||
| 515 | a 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=, |
| 514 | the instance's ssh destination as the build reaches it (=git@gitbay.org= | 518 | the instance's ssh destination as the build reaches it (=git@gitbay.org= |
| 515 | from a runner elsewhere; inside a container on the server's own runner | 519 | from 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 ( | |||
| 21 | func init() { | 22 | func 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. | ||
| 108 | var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"} | ||
| 109 | |||
| 105 | func runBuildList(c *Ctx, args []string) int { | 110 | func 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 @@ | |||
| 1 | package control | 1 | package control |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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). | ||
| 614 | func 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. |
| 216 | func (f *shapeFixture) mark(sha string) { | 216 | func (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. |
| 227 | func (f *shapeFixture) observe() map[string]string { | 227 | func (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. |
| 22 | func TestBuildsPageRendersCommandOutput(t *testing.T) { | 22 | func 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 | ||
| 3 | import ( | 3 | import ( |
| 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). | ||
| 13 | type 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. | ||
| 22 | type 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. | ||
| 30 | var 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. | ||
| 37 | func 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. | ||
| 68 | func 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). | ||
| 87 | type 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. | ||
| 97 | var 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. | ||
| 101 | func 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. | ||
| 117 | func 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 | |||
| 10 | func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | 132 | func (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 | ||
| 35 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { | 175 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { |
internal/httpd/builds_test.go added +147
| @@ -0,0 +1,147 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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). | ||
| 14 | func 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 | |||
| 36 | func 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". | ||
| 47 | func 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). | ||
| 74 | func 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. | ||
| 119 | func 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. | ||
| 131 | func 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 | |||
| 140 | func 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 | ||
| 277 | func (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) | 278 | type BuildFilter struct { |
| 279 | Ref string | ||
| 280 | Status string | ||
| 281 | Job string | ||
| 282 | } | ||
| 283 | |||
| 284 | func (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). | ||
| 337 | func 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. | ||
| 383 | func 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">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> | 30 | <pre class="code">[](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}} |