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 | 179 | | atom feeds | n/a | yes | n/a | |
| 180 | 180 | | release create, edit | yes | yes | yes | |
| 181 | 181 | | build list | yes | yes | yes | |
| 182 | | build list filters (ref, status, job) | yes | yes | no | | |
| 182 | 183 | | build show (one build) | yes | yes | yes | |
| 183 | 184 | | build log | yes | yes | yes | |
| 184 | 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 | 508 | Each job becomes a build (=build list=, =build log=, the builds tab on |
| 509 | 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 | |
| 511 | instance's runner, stopping at the first failure; a broken config | |
| 512 | surfaces as a failed =ci/config= status. Environment: =GITBAY_REPO=, | |
| 510 | require-checks= can gate merges on. =build list= takes =--ref=, | |
| 511 | =--status= and =--job= to narrow the listing, combinable; the builds | |
| 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 | 517 | =GITBAY_SHA=, =GITBAY_REF=, =GITBAY_JOB=, =CI=true=, and =GITBAY_SSH=, |
| 514 | 518 | the instance's ssh destination as the build reaches it (=git@gitbay.org= |
| 515 | 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 | 70 | RepoDir: func(owner, name string) string { return bare }} |
| 71 | 71 | s.RunDue(now) |
| 72 | 72 | |
| 73 | builds, err := st.ListBuilds(repoID, 10) | |
| 73 | builds, err := st.ListBuilds(repoID, store.BuildFilter{}, 10) | |
| 74 | 74 | if err != nil || len(builds) != 1 { |
| 75 | 75 | t.Fatalf("builds after run: %v %v", builds, err) |
| 76 | 76 | } |
| @@ -98,7 +98,7 @@ func TestSchedulerRunDue(t *testing.T) { | ||
| 98 | 98 | |
| 99 | 99 | // A second pass fires nothing: next_run is in the future. |
| 100 | 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 | 102 | t.Fatalf("second pass queued extra builds: %+v", builds) |
| 103 | 103 | } |
| 104 | 104 | |
| @@ -111,7 +111,7 @@ func TestSchedulerRunDue(t *testing.T) { | ||
| 111 | 111 | t.Fatal(err) |
| 112 | 112 | } |
| 113 | 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 | 115 | t.Fatalf("tick with the last build pending queued another: %+v", builds) |
| 116 | 116 | } |
| 117 | 117 | b, ok, err := st.ClaimBuild(nil, false) |
| @@ -122,7 +122,7 @@ func TestSchedulerRunDue(t *testing.T) { | ||
| 122 | 122 | t.Fatal(err) |
| 123 | 123 | } |
| 124 | 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 | 126 | t.Fatalf("tick with the last build running queued another: %+v", builds) |
| 127 | 127 | } |
| 128 | 128 | if err := st.FinishBuild(b.ID, "success"); err != nil { |
| @@ -132,7 +132,7 @@ func TestSchedulerRunDue(t *testing.T) { | ||
| 132 | 132 | t.Fatal(err) |
| 133 | 133 | } |
| 134 | 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 | 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 | 7 | "io" |
| 8 | 8 | "log/slog" |
| 9 | 9 | "regexp" |
| 10 | "slices" | |
| 10 | 11 | "strconv" |
| 11 | 12 | "strings" |
| 12 | 13 | "time" |
| @@ -21,7 +22,7 @@ import ( | ||
| 21 | 22 | func init() { |
| 22 | 23 | register(Command{Path: []string{"build", "list"}, |
| 23 | 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 | 26 | register(Command{Path: []string{"build", "show"}, |
| 26 | 27 | Summary: "show one build", |
| 27 | 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 | 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 | 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 | 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 | 124 | if code >= 0 { |
| 111 | 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 | 128 | if err != nil { |
| 115 | 129 | return c.fail(protocol.ExitFailure, "%v", err) |
| 116 | 130 | } |
internal/control/build_test.go +71 −11
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "bytes" | |
| 4 | 5 | "os" |
| 5 | 6 | "os/exec" |
| 6 | 7 | "path/filepath" |
| @@ -8,6 +9,7 @@ import ( | ||
| 8 | 9 | "testing" |
| 9 | 10 | "time" |
| 10 | 11 | |
| 12 | "gitbay.org/gitbay/internal/protocol" | |
| 11 | 13 | "gitbay.org/gitbay/internal/store" |
| 12 | 14 | ) |
| 13 | 15 | |
| @@ -102,7 +104,7 @@ func TestQueueBranchBuildsFailsOpenOnDiffFailure(t *testing.T) { | ||
| 102 | 104 | |
| 103 | 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 | 108 | if err != nil || len(builds) != 1 { |
| 107 | 109 | t.Fatalf("builds after queue: %v %v", builds, err) |
| 108 | 110 | } |
| @@ -142,7 +144,7 @@ func TestQueueBranchBuildsNewBranchIgnoredPathSkips(t *testing.T) { | ||
| 142 | 144 | |
| 143 | 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 | 148 | if err != nil { |
| 147 | 149 | t.Fatal(err) |
| 148 | 150 | } |
| @@ -180,7 +182,7 @@ func TestQueueBranchBuildsNewBranchMatchedPathQueues(t *testing.T) { | ||
| 180 | 182 | |
| 181 | 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 | 186 | if err != nil || len(builds) != 1 { |
| 185 | 187 | t.Fatalf("builds after queue: %v %v", builds, err) |
| 186 | 188 | } |
| @@ -225,7 +227,7 @@ func TestQueueBranchBuildsNewBranchFailsOpenWithoutMergeBase(t *testing.T) { | ||
| 225 | 227 | |
| 226 | 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 | 231 | if err != nil || len(builds) != 1 { |
| 230 | 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 | 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 | 263 | if err != nil || len(builds) != 1 { |
| 262 | 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 | 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 | 298 | if err != nil { |
| 297 | 299 | t.Fatal(err) |
| 298 | 300 | } |
| @@ -445,7 +447,7 @@ func TestQueueBranchBuildsAlreadyBuiltJobRecordsNoSkippedStatus(t *testing.T) { | ||
| 445 | 447 | if len(statuses) != 0 { |
| 446 | 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 | 451 | if err != nil || len(builds) != 1 { |
| 450 | 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 | 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 | 490 | if err != nil || len(builds) != 1 { |
| 489 | 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 | 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 | 547 | if err != nil { |
| 546 | 548 | t.Fatal(err) |
| 547 | 549 | } |
| @@ -578,7 +580,7 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) { | ||
| 578 | 580 | git(root, "clone", "-q", "--bare", src, dir) |
| 579 | 581 | |
| 580 | 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 | 584 | if len(builds) != 1 { |
| 583 | 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 | 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 | 596 | if len(builds) != 1 { |
| 595 | 597 | t.Fatalf("same tree queued a second build: %+v", builds) |
| 596 | 598 | } |
| @@ -605,3 +607,61 @@ func TestQueueBranchBuildsSameTreeReusesSuccess(t *testing.T) { | ||
| 605 | 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 | 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 | 169 | if err != nil { |
| 170 | 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 | 215 | // mark snapshots the state the observed action starts from. |
| 216 | 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 | 218 | if err != nil { |
| 219 | 219 | f.t.Fatal(err) |
| 220 | 220 | } |
| @@ -226,7 +226,7 @@ func (f *shapeFixture) mark(sha string) { | ||
| 226 | 226 | // the ci/config status. The words are the table's vocabulary. |
| 227 | 227 | func (f *shapeFixture) observe() map[string]string { |
| 228 | 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 | 230 | if err != nil { |
| 231 | 231 | f.t.Fatal(err) |
| 232 | 232 | } |
internal/httpd/buildpages_test.go +18 −13
| @@ -21,28 +21,33 @@ func testRepoPage() repoPage { | ||
| 21 | 21 | // compile error, so render both pages and look for the values. |
| 22 | 22 | func TestBuildsPageRendersCommandOutput(t *testing.T) { |
| 23 | 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 | 31 | err := web.Render(&sb, "builds.html", struct { |
| 25 | 32 | repoPage |
| 26 | Builds []control.BuildOut | |
| 27 | Jobs []control.JobOut | |
| 28 | CanWrite bool | |
| 29 | Notice string | |
| 33 | Builds []control.BuildOut | |
| 34 | Jobs []control.JobOut | |
| 35 | Runs []buildRun | |
| 36 | Filter buildFilter | |
| 37 | FilterLinks []buildFilterLink | |
| 38 | Refs []string | |
| 39 | CanWrite bool | |
| 40 | Notice string | |
| 30 | 41 | }{ |
| 31 | testRepoPage(), | |
| 32 | []control.BuildOut{{ | |
| 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, "", | |
| 42 | testRepoPage(), builds, jobs, groupRuns(builds), filter, filterLinks(filter, jobs), | |
| 43 | distinctRefs(builds, filter.Ref), true, "", | |
| 39 | 44 | }) |
| 40 | 45 | if err != nil { |
| 41 | 46 | t.Fatalf("render: %v", err) |
| 42 | 47 | } |
| 43 | 48 | out := sb.String() |
| 44 | 49 | for _, want := range []string{ |
| 45 | "#60 build", "success", "cli-coverage", "ff6271a9d4", | |
| 50 | "build", "success", "cli-coverage", "ff6271a9d4", | |
| 46 | 51 | `value="build"`, `value="nightly"`, "schedule 0 3 * * *", |
| 47 | 52 | } { |
| 48 | 53 | if !strings.Contains(out, want) { |
internal/httpd/builds.go +146 −6
| @@ -2,11 +2,133 @@ package httpd | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | "net/url" | |
| 5 | 6 | "strconv" |
| 6 | 7 | |
| 7 | 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 | 132 | func (s *Server) builds(w http.ResponseWriter, r *http.Request) { |
| 11 | 133 | p, ok := s.repoFor(w, r, "") |
| 12 | 134 | if !ok { |
| @@ -15,8 +137,21 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | ||
| 15 | 137 | p.Tab = "builds" |
| 16 | 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 | 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 | 156 | // The jobs a trigger can name. A repo without a CI config has none; |
| 22 | 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 | 161 | s.render(w, "builds.html", struct { |
| 27 | 162 | repoPage |
| 28 | Builds []control.BuildOut | |
| 29 | Jobs []control.JobOut | |
| 30 | CanWrite bool | |
| 31 | Notice string | |
| 32 | }{p, builds, jobs, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) | |
| 163 | Builds []control.BuildOut | |
| 164 | Jobs []control.JobOut | |
| 165 | Runs []buildRun | |
| 166 | Filter buildFilter | |
| 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 | 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 | 7 | "gitbay.org/gitbay/internal/control" |
| 8 | 8 | "gitbay.org/gitbay/internal/gitutil" |
| 9 | "gitbay.org/gitbay/internal/store" | |
| 9 | 10 | ) |
| 10 | 11 | |
| 11 | 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 | 87 | if rels, err := s.st.ListReleases(p.Repo.ID); err == nil && len(rels) > 0 { |
| 87 | 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 | 91 | f.Build = builds[0].Status |
| 91 | 92 | } |
| 92 | 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 | 274 | return b, err |
| 275 | 275 | } |
| 276 | 276 | |
| 277 | func (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. | |
| 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 | 302 | if err != nil { |
| 280 | 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 | 392 | // A merge request head from a fork is untrusted. A claim skips it unless |
| 336 | 393 | // the runner asked for untrusted builds, so a runner on someone's laptop |
| 337 | 394 | // never executes a stranger's branch by default. |
internal/web/templates/builds.html +18 −4
| @@ -1,7 +1,20 @@ | ||
| 1 | 1 | {{define "width"}}wide{{end}} |
| 2 | 2 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 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 | 18 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 6 | 19 | {{if and .CanWrite .Jobs}} |
| 7 | 20 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform"> |
| @@ -17,15 +30,16 @@ | ||
| 17 | 30 | <pre class="code">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> |
| 18 | 31 | <p class="meta">Add <code>?job=name</code> for one job.</p> |
| 19 | 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 | 34 | <ul class="loglist"> |
| 21 | {{range .Builds}}<li> | |
| 35 | {{range .Runs}}<li> | |
| 22 | 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 | 38 | <p class="meta">{{.Ref}} · {{when .CreatedAt}}</p> |
| 25 | 39 | </div> |
| 26 | 40 | <div class="commitside"> |
| 27 | 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 | 43 | </div> |
| 30 | 44 | </li> |
| 31 | 45 | {{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}} |