Commit 92c816c58e
Verified · cmc
internal/control/build.go +46 −5
| @@ -22,7 +22,7 @@ import ( | ||
| 22 | 22 | func init() { |
| 23 | 23 | register(Command{Path: []string{"build", "list"}, |
| 24 | 24 | Summary: "list recent builds", |
| 25 | Usage: "build list <owner/name> [--ref <branch>] [--status <state>] [--job <name>]", ReadOnly: true, Run: runBuildList}) | |
| 25 | Usage: "build list <owner/name> [--ref <branch>] [--status <state>] [--job <name>] [--limit <n>] [--cursor <c>]", ReadOnly: true, Run: runBuildList}) | |
| 26 | 26 | register(Command{Path: []string{"build", "show"}, |
| 27 | 27 | Summary: "show one build", |
| 28 | 28 | Usage: "build show <owner/name> <n>", ReadOnly: true, Run: runBuildShow}) |
| @@ -78,10 +78,15 @@ type BuildOut struct { | ||
| 78 | 78 | Ref string `json:"ref"` |
| 79 | 79 | CreatedAt string `json:"created_at"` |
| 80 | 80 | FinishedAt string `json:"finished_at,omitempty"` |
| 81 | // Subject is the first line of the commit's message, so a build | |
| 82 | // names what it ran on rather than only its sha (#241). It is empty | |
| 83 | // when the commit is no longer in the repository. | |
| 84 | Subject string `json:"subject,omitempty"` | |
| 81 | 85 | } |
| 82 | 86 | |
| 83 | 87 | func buildToOut(b store.Build) BuildOut { |
| 84 | return BuildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt} | |
| 88 | return BuildOut{Number: b.Number, Job: b.Job, Status: b.Status, SHA: b.SHA, | |
| 89 | Ref: b.Ref, CreatedAt: b.CreatedAt, FinishedAt: b.FinishedAt} | |
| 85 | 90 | } |
| 86 | 91 | |
| 87 | 92 | func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { |
| @@ -107,7 +112,16 @@ func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { | ||
| 107 | 112 | // is told to pick from. |
| 108 | 113 | var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"} |
| 109 | 114 | |
| 115 | // buildPage is how many builds one page of build list returns when no | |
| 116 | // --limit is given. The cap has always been there; what it is now | |
| 117 | // reachable past, with --cursor (#244). | |
| 118 | const buildPage = 50 | |
| 119 | ||
| 110 | 120 | func runBuildList(c *Ctx, args []string) int { |
| 121 | args, p, code := parsePageFlags(c, args, "build", true) | |
| 122 | if code >= 0 { | |
| 123 | return code | |
| 124 | } | |
| 111 | 125 | f, err := parseFlags(args, flagSpec{Values: []string{"--ref", "--status", "--job"}, MaxPos: 1, Usage: c.Cmd.Usage}) |
| 112 | 126 | if err != nil { |
| 113 | 127 | return c.fail(protocol.ExitUsage, "%v", err) |
| @@ -124,21 +138,48 @@ func runBuildList(c *Ctx, args []string) int { | ||
| 124 | 138 | if code >= 0 { |
| 125 | 139 | return code |
| 126 | 140 | } |
| 127 | builds, err := c.Store.ListBuilds(repo.ID, store.BuildFilter{Ref: f.Value("--ref"), Status: status, Job: f.Value("--job")}, 50) | |
| 141 | limit := p.queryLimit() | |
| 142 | if limit == 0 { | |
| 143 | limit = buildPage | |
| 144 | } | |
| 145 | filter := store.BuildFilter{Ref: f.Value("--ref"), Status: status, Job: f.Value("--job"), Before: p.keyInt()} | |
| 146 | builds, err := c.Store.ListBuilds(repo.ID, filter, limit) | |
| 128 | 147 | if err != nil { |
| 129 | 148 | return c.fail(protocol.ExitFailure, "%v", err) |
| 130 | 149 | } |
| 150 | builds, next := trimPage(p, builds, "build", func(b store.Build) string { | |
| 151 | return strconv.FormatInt(b.Number, 10) | |
| 152 | }) | |
| 131 | 153 | var ds []BuildOut |
| 132 | 154 | for _, b := range builds { |
| 133 | 155 | ds = append(ds, buildToOut(b)) |
| 134 | 156 | } |
| 135 | return c.emit(ds, func(w io.Writer) { | |
| 157 | subjects := buildSubjects(c, repo, ds) | |
| 158 | for i := range ds { | |
| 159 | ds[i].Subject = subjects[ds[i].SHA] | |
| 160 | } | |
| 161 | return c.emitPage(p, ds, next, func(w io.Writer) { | |
| 136 | 162 | for _, d := range ds { |
| 137 | fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref) | |
| 163 | fmt.Fprintf(w, "%d\t%s\t%s\t%.10s\t%s\t%s\n", d.Number, d.Job, d.Status, d.SHA, d.Ref, d.Subject) | |
| 138 | 164 | } |
| 139 | 165 | }) |
| 140 | 166 | } |
| 141 | 167 | |
| 168 | // buildSubjects reads the commit subject of each distinct sha on a page | |
| 169 | // of builds. Several jobs of one push share a commit, so the set is | |
| 170 | // usually far smaller than the page. | |
| 171 | func buildSubjects(c *Ctx, repo store.Repo, ds []BuildOut) map[string]string { | |
| 172 | seen := map[string]bool{} | |
| 173 | var shas []string | |
| 174 | for _, d := range ds { | |
| 175 | if d.SHA != "" && !seen[d.SHA] { | |
| 176 | seen[d.SHA] = true | |
| 177 | shas = append(shas, d.SHA) | |
| 178 | } | |
| 179 | } | |
| 180 | return gitutil.Subjects(RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name), shas) | |
| 181 | } | |
| 182 | ||
| 142 | 183 | func runBuildShow(c *Ctx, args []string) int { |
| 143 | 184 | _, b, code := buildRef(c, args) |
| 144 | 185 | if code >= 0 { |
internal/control/buildlistpage_test.go added +139
| @@ -0,0 +1,139 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "encoding/json" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/protocol" | |
| 12 | "gitbay.org/gitbay/internal/store" | |
| 13 | ) | |
| 14 | ||
| 15 | // A run row led with a sha and said nothing about what the commit was | |
| 16 | // (#241). build list carries the subject now, for every surface at once. | |
| 17 | func TestBuildListCarriesCommitSubjects(t *testing.T) { | |
| 18 | st, repo, uid := newQueueTestRepo(t) | |
| 19 | git := gitRunner(t) | |
| 20 | root := t.TempDir() | |
| 21 | ||
| 22 | src := filepath.Join(root, "src") | |
| 23 | os.MkdirAll(src, 0o755) | |
| 24 | git(root, "init", "-q", "-b", "main", "src") | |
| 25 | os.WriteFile(filepath.Join(src, "a"), []byte("one\n"), 0o644) | |
| 26 | git(src, "add", ".") | |
| 27 | git(src, "commit", "-qm", "runner: cap a build's container") | |
| 28 | sha := strings.TrimSpace(git(src, "rev-parse", "HEAD")) | |
| 29 | ||
| 30 | dir := RepoDir(root, repo.OwnerName, repo.Name) | |
| 31 | os.MkdirAll(filepath.Dir(dir), 0o755) | |
| 32 | git(root, "clone", "-q", "--bare", src, dir) | |
| 33 | ||
| 34 | if _, err := st.CreateBuild(repo.ID, "unit", sha, "main", `["true"]`, "", "", true); err != nil { | |
| 35 | t.Fatal(err) | |
| 36 | } | |
| 37 | // A build whose commit is gone, as a force-push leaves behind. | |
| 38 | if _, err := st.CreateBuild(repo.ID, "lint", strings.Repeat("1", 40), "main", `["true"]`, "", "", true); err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | ||
| 42 | c, errOut := pruneCtx(st, root, store.User{ID: uid}) | |
| 43 | c.JSON = true | |
| 44 | if code := Dispatch(c, []string{"build", "list", repo.Path()}); code != protocol.ExitOK { | |
| 45 | t.Fatalf("build list: exit %d: %s", code, errOut.String()) | |
| 46 | } | |
| 47 | var got []BuildOut | |
| 48 | decodeData(t, c.Stdout.(*bytes.Buffer).Bytes(), &got) | |
| 49 | if len(got) != 2 { | |
| 50 | t.Fatalf("builds: %+v", got) | |
| 51 | } | |
| 52 | for _, b := range got { | |
| 53 | switch b.Job { | |
| 54 | case "unit": | |
| 55 | if b.Subject != "runner: cap a build's container" { | |
| 56 | t.Errorf("unit subject = %q", b.Subject) | |
| 57 | } | |
| 58 | case "lint": | |
| 59 | if b.Subject != "" { | |
| 60 | t.Errorf("a build whose commit is gone claims a subject: %q", b.Subject) | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | // The builds list was capped at 50 with no way past it, so filtering to a | |
| 67 | // sparse status reached further back and appeared to raise the total | |
| 68 | // (#244). --limit and --cursor page it like every other list command. | |
| 69 | func TestBuildListPages(t *testing.T) { | |
| 70 | st, repo, uid := newQueueTestRepo(t) | |
| 71 | for i := 0; i < 5; i++ { | |
| 72 | if _, err := st.CreateBuild(repo.ID, "unit", "aaa", "main", `["true"]`, "", "", true); err != nil { | |
| 73 | t.Fatal(err) | |
| 74 | } | |
| 75 | } | |
| 76 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid}) | |
| 77 | c.JSON = true | |
| 78 | ||
| 79 | page := func(args ...string) (nums []int64, next string) { | |
| 80 | t.Helper() | |
| 81 | out := c.Stdout.(*bytes.Buffer) | |
| 82 | out.Reset() | |
| 83 | errOut.Reset() | |
| 84 | argv := append([]string{"build", "list", repo.Path()}, args...) | |
| 85 | if code := Dispatch(c, argv); code != protocol.ExitOK { | |
| 86 | t.Fatalf("build list %v: exit %d: %s", args, code, errOut.String()) | |
| 87 | } | |
| 88 | var got struct { | |
| 89 | Items []BuildOut `json:"items"` | |
| 90 | Next string `json:"next"` | |
| 91 | } | |
| 92 | decodeData(t, out.Bytes(), &got) | |
| 93 | for _, b := range got.Items { | |
| 94 | nums = append(nums, b.Number) | |
| 95 | } | |
| 96 | return nums, got.Next | |
| 97 | } | |
| 98 | ||
| 99 | nums, next := page("--limit", "2") | |
| 100 | if len(nums) != 2 || nums[0] != 5 || nums[1] != 4 { | |
| 101 | t.Fatalf("first page: %v", nums) | |
| 102 | } | |
| 103 | if next == "" { | |
| 104 | t.Fatal("first page offers no cursor with three builds left") | |
| 105 | } | |
| 106 | nums, next = page("--limit", "2", "--cursor", next) | |
| 107 | if len(nums) != 2 || nums[0] != 3 || nums[1] != 2 { | |
| 108 | t.Fatalf("second page: %v", nums) | |
| 109 | } | |
| 110 | nums, next = page("--limit", "2", "--cursor", next) | |
| 111 | if len(nums) != 1 || nums[0] != 1 { | |
| 112 | t.Fatalf("last page: %v", nums) | |
| 113 | } | |
| 114 | if next != "" { | |
| 115 | t.Errorf("last page offers a cursor: %q", next) | |
| 116 | } | |
| 117 | ||
| 118 | // A cursor minted by another command is not a build cursor. | |
| 119 | out := c.Stdout.(*bytes.Buffer) | |
| 120 | out.Reset() | |
| 121 | errOut.Reset() | |
| 122 | if code := Dispatch(c, []string{"build", "list", repo.Path(), "--cursor", encodeCursor("issue", "3")}); code != protocol.ExitUsage { | |
| 123 | t.Fatalf("foreign cursor: exit %d, want %d", code, protocol.ExitUsage) | |
| 124 | } | |
| 125 | } | |
| 126 | ||
| 127 | // decodeData unwraps the protocol envelope the JSON emitters write. | |
| 128 | func decodeData(t *testing.T, b []byte, into any) { | |
| 129 | t.Helper() | |
| 130 | var env struct { | |
| 131 | Data json.RawMessage `json:"data"` | |
| 132 | } | |
| 133 | if err := json.Unmarshal(b, &env); err != nil { | |
| 134 | t.Fatalf("envelope: %v\n%s", err, b) | |
| 135 | } | |
| 136 | if err := json.Unmarshal(env.Data, into); err != nil { | |
| 137 | t.Fatalf("data: %v\n%s", err, env.Data) | |
| 138 | } | |
| 139 | } | |
internal/gitutil/messages.go +25
| @@ -109,3 +109,28 @@ func RevListMessages(dir, old, new string, max int) ([]CommitMsg, error) { | ||
| 109 | 109 | } |
| 110 | 110 | return msgs, nil |
| 111 | 111 | } |
| 112 | ||
| 113 | // Subjects returns the first line of each named commit's message, keyed | |
| 114 | // by sha. One git log for the whole set rather than one per sha: a page | |
| 115 | // of builds names a handful of distinct commits and a subprocess each | |
| 116 | // would show. --ignore-missing keeps a sha git cannot resolve from | |
| 117 | // failing the rest, because a build outlives the commit it ran on once a | |
| 118 | // branch is force-pushed; such a sha is simply absent from the map. | |
| 119 | func Subjects(dir string, shas []string) map[string]string { | |
| 120 | if len(shas) == 0 { | |
| 121 | return nil | |
| 122 | } | |
| 123 | args := append([]string{"-C", dir, "log", "--no-walk=unsorted", "--ignore-missing", "--format=%H%x00%s"}, shas...) | |
| 124 | args = append(args, "--") | |
| 125 | out, err := exec.Command(toolpath.Look("git"), args...).Output() | |
| 126 | if err != nil { | |
| 127 | return nil | |
| 128 | } | |
| 129 | subjects := map[string]string{} | |
| 130 | for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { | |
| 131 | if sha, subject, ok := strings.Cut(line, "\x00"); ok { | |
| 132 | subjects[sha] = subject | |
| 133 | } | |
| 134 | } | |
| 135 | return subjects | |
| 136 | } | |
internal/gitutil/subjects_test.go added +50
| @@ -0,0 +1,50 @@ | ||
| 1 | package gitutil | |
| 2 | ||
| 3 | import ( | |
| 4 | "os/exec" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | func TestSubjects(t *testing.T) { | |
| 10 | dir := t.TempDir() | |
| 11 | git(t, dir, "init", "-q", "-b", "main") | |
| 12 | ||
| 13 | write(t, dir, "a", "one\n") | |
| 14 | git(t, dir, "add", ".") | |
| 15 | git(t, dir, "commit", "-qm", "first thing") | |
| 16 | first := rev(t, dir, "HEAD") | |
| 17 | ||
| 18 | write(t, dir, "a", "two\n") | |
| 19 | git(t, dir, "commit", "-qam", "second thing") | |
| 20 | second := rev(t, dir, "HEAD") | |
| 21 | ||
| 22 | got := Subjects(dir, []string{second, first}) | |
| 23 | if got[first] != "first thing" || got[second] != "second thing" { | |
| 24 | t.Fatalf("Subjects = %v", got) | |
| 25 | } | |
| 26 | ||
| 27 | // A build outlives the commit it ran on when a branch is | |
| 28 | // force-pushed. The shas that do resolve still come back. | |
| 29 | gone := strings.Repeat("1", 40) | |
| 30 | got = Subjects(dir, []string{gone, first}) | |
| 31 | if got[first] != "first thing" { | |
| 32 | t.Errorf("a missing sha lost the rest: %v", got) | |
| 33 | } | |
| 34 | if _, ok := got[gone]; ok { | |
| 35 | t.Errorf("resolved a sha that is not there: %v", got) | |
| 36 | } | |
| 37 | ||
| 38 | if Subjects(dir, nil) != nil { | |
| 39 | t.Error("empty sha list should not run git") | |
| 40 | } | |
| 41 | } | |
| 42 | ||
| 43 | func rev(t *testing.T, dir, ref string) string { | |
| 44 | t.Helper() | |
| 45 | out, err := exec.Command("git", "-C", dir, "rev-parse", ref).Output() | |
| 46 | if err != nil { | |
| 47 | t.Fatal(err) | |
| 48 | } | |
| 49 | return strings.TrimSpace(string(out)) | |
| 50 | } | |
internal/httpd/buildpages_test.go +82 −2
| @@ -36,11 +36,12 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) { | ||
| 36 | 36 | Filter buildFilter |
| 37 | 37 | Facets []facetGroup |
| 38 | 38 | Refs []string |
| 39 | Older string | |
| 39 | 40 | CanWrite bool |
| 40 | 41 | Notice string |
| 41 | 42 | }{ |
| 42 | 43 | testRepoPage(), builds, jobs, groupRuns(builds), filter, nil, |
| 43 | distinctRefs(builds, filter.Ref), true, "", | |
| 44 | distinctRefs(builds, filter.Ref), "", true, "", | |
| 44 | 45 | }) |
| 45 | 46 | if err != nil { |
| 46 | 47 | t.Fatalf("render: %v", err) |
| @@ -111,11 +112,12 @@ func TestBuildsPageCountsBuildsAndRuns(t *testing.T) { | ||
| 111 | 112 | Filter buildFilter |
| 112 | 113 | Facets []facetGroup |
| 113 | 114 | Refs []string |
| 115 | Older string | |
| 114 | 116 | CanWrite bool |
| 115 | 117 | Notice string |
| 116 | 118 | }{ |
| 117 | 119 | testRepoPage(), builds, nil, groupRuns(builds), filter, nil, |
| 118 | distinctRefs(builds, filter.Ref), true, "", | |
| 120 | distinctRefs(builds, filter.Ref), "", true, "", | |
| 119 | 121 | }) |
| 120 | 122 | if err != nil { |
| 121 | 123 | t.Fatalf("render: %v", err) |
| @@ -124,3 +126,81 @@ func TestBuildsPageCountsBuildsAndRuns(t *testing.T) { | ||
| 124 | 126 | t.Errorf("builds.html count line: %q", sb.String()) |
| 125 | 127 | } |
| 126 | 128 | } |
| 129 | ||
| 130 | // A run row led with a ten-character sha and nothing said what the commit | |
| 131 | // was (#241). The subject leads now, the sha follows as metadata, and a | |
| 132 | // build whose commit is gone falls back to the sha alone. | |
| 133 | func TestBuildsPageLeadsWithTheSubject(t *testing.T) { | |
| 134 | builds := []control.BuildOut{ | |
| 135 | {Number: 2, Job: "unit", Status: "success", SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", | |
| 136 | Ref: "main", CreatedAt: "2026-09-20T06:00:00Z", Subject: "runner: cap a build's container"}, | |
| 137 | {Number: 1, Job: "unit", Status: "failure", SHA: "aa11bb22cc33dd44ee55ff6677889900aabbccdd", | |
| 138 | Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"}, | |
| 139 | } | |
| 140 | out := renderBuilds(t, builds, buildFilter{}, "?cursor=abc") | |
| 141 | if !strings.Contains(out, "runner: cap a build's container") { | |
| 142 | t.Errorf("builds.html does not lead with the subject:\n%s", out) | |
| 143 | } | |
| 144 | if !strings.Contains(out, "<code>ff6271a9d4</code>") { | |
| 145 | t.Errorf("builds.html drops the sha from the metadata line:\n%s", out) | |
| 146 | } | |
| 147 | // The commit is gone, so the sha is all there is to name the row by. | |
| 148 | if !strings.Contains(out, ">aa11bb22cc</a>") { | |
| 149 | t.Errorf("a subjectless run does not fall back to its sha:\n%s", out) | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 153 | // Filtering appeared to raise the run count because the list was capped | |
| 154 | // at 50 with the window unstated (#244). The page is paged now: the count | |
| 155 | // says it counts this page, and the link to the next one carries every | |
| 156 | // filter. | |
| 157 | func TestBuildsPagePagesAndSaysSo(t *testing.T) { | |
| 158 | builds := []control.BuildOut{{Number: 1, Job: "unit", Status: "failure", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"}} | |
| 159 | out := renderBuilds(t, builds, buildFilter{Status: "failure"}, "?cursor=abc&status=failure") | |
| 160 | if !strings.Contains(out, "1 build in 1 run on this page") { | |
| 161 | t.Errorf("count line does not name the page:\n%s", out) | |
| 162 | } | |
| 163 | if !strings.Contains(out, `href="?cursor=abc&status=failure"`) { | |
| 164 | t.Errorf("pager link missing or drops the filter:\n%s", out) | |
| 165 | } | |
| 166 | // With everything on one page the count is the whole count. | |
| 167 | if out := renderBuilds(t, builds, buildFilter{}, ""); strings.Contains(out, "on this page") { | |
| 168 | t.Errorf("an unpaged listing still hedges the count:\n%s", out) | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | func TestOlderBuildsCarriesFilters(t *testing.T) { | |
| 173 | if got := olderBuilds(buildFilter{Ref: "main"}, ""); got != "" { | |
| 174 | t.Errorf("no next cursor should mean no link, got %q", got) | |
| 175 | } | |
| 176 | got := olderBuilds(buildFilter{Ref: "feature/x", Status: "failure", Job: "unit"}, "c1") | |
| 177 | for _, want := range []string{"cursor=c1", "ref=feature%2Fx", "status=failure", "job=unit"} { | |
| 178 | if !strings.Contains(got, want) { | |
| 179 | t.Errorf("olderBuilds = %q, missing %q", got, want) | |
| 180 | } | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 184 | func renderBuilds(t *testing.T, builds []control.BuildOut, filter buildFilter, older string) string { | |
| 185 | t.Helper() | |
| 186 | var sb strings.Builder | |
| 187 | err := web.Render(&sb, "builds.html", struct { | |
| 188 | repoPage | |
| 189 | Builds []control.BuildOut | |
| 190 | Jobs []control.JobOut | |
| 191 | Runs []buildRun | |
| 192 | Filter buildFilter | |
| 193 | Facets []facetGroup | |
| 194 | Refs []string | |
| 195 | Older string | |
| 196 | CanWrite bool | |
| 197 | Notice string | |
| 198 | }{ | |
| 199 | testRepoPage(), builds, nil, groupRuns(builds), filter, nil, | |
| 200 | distinctRefs(builds, filter.Ref), older, true, "", | |
| 201 | }) | |
| 202 | if err != nil { | |
| 203 | t.Fatalf("render: %v", err) | |
| 204 | } | |
| 205 | return sb.String() | |
| 206 | } | |
internal/httpd/builds.go +35 −5
| @@ -132,6 +132,7 @@ func distinctRefs(builds []control.BuildOut, current string) []string { | ||
| 132 | 132 | // one queueing of a commit, not the commit — see groupRuns (#240). |
| 133 | 133 | type buildRun struct { |
| 134 | 134 | SHA string |
| 135 | Subject string | |
| 135 | 136 | Ref string |
| 136 | 137 | CreatedAt string |
| 137 | 138 | Status string |
| @@ -194,7 +195,7 @@ func groupRuns(builds []control.BuildOut) []buildRun { | ||
| 194 | 195 | runs[n-1].Builds = append(runs[n-1].Builds, b) |
| 195 | 196 | continue |
| 196 | 197 | } |
| 197 | runs = append(runs, buildRun{SHA: b.SHA, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}}) | |
| 198 | runs = append(runs, buildRun{SHA: b.SHA, Subject: b.Subject, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}}) | |
| 198 | 199 | } |
| 199 | 200 | for i := range runs { |
| 200 | 201 | runs[i].Status = combinedStatus(runs[i].Builds) |
| @@ -202,6 +203,27 @@ func groupRuns(builds []control.BuildOut) []buildRun { | ||
| 202 | 203 | return runs |
| 203 | 204 | } |
| 204 | 205 | |
| 206 | // buildsPerPage is how many builds one page of the builds tab asks for. | |
| 207 | // Fewer than the command's own default, because the page folds them into | |
| 208 | // runs and a run is several builds tall (#244). | |
| 209 | const buildsPerPage = 30 | |
| 210 | ||
| 211 | // olderBuilds is the link to the page after this one: the command's own | |
| 212 | // keyset cursor with the three filters carried along, so paging never | |
| 213 | // drops a filter and a filter never lands on page two. | |
| 214 | func olderBuilds(f buildFilter, next string) string { | |
| 215 | if next == "" { | |
| 216 | return "" | |
| 217 | } | |
| 218 | q := url.Values{"cursor": {next}} | |
| 219 | for k, v := range map[string]string{"ref": f.Ref, "status": f.Status, "job": f.Job} { | |
| 220 | if v != "" { | |
| 221 | q.Set(k, v) | |
| 222 | } | |
| 223 | } | |
| 224 | return "?" + q.Encode() | |
| 225 | } | |
| 226 | ||
| 205 | 227 | func (s *Server) builds(w http.ResponseWriter, r *http.Request) { |
| 206 | 228 | p, ok := s.repoFor(w, r, "") |
| 207 | 229 | if !ok { |
| @@ -212,7 +234,7 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | ||
| 212 | 234 | |
| 213 | 235 | qv := r.URL.Query() |
| 214 | 236 | filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")} |
| 215 | argv := []string{"build", "list", p.Repo.Path()} | |
| 237 | argv := []string{"build", "list", p.Repo.Path(), "--limit", strconv.Itoa(buildsPerPage)} | |
| 216 | 238 | if filter.Ref != "" { |
| 217 | 239 | argv = append(argv, "--ref", filter.Ref) |
| 218 | 240 | } |
| @@ -222,9 +244,16 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | ||
| 222 | 244 | if filter.Job != "" { |
| 223 | 245 | argv = append(argv, "--job", filter.Job) |
| 224 | 246 | } |
| 247 | if cursor := qv.Get("cursor"); cursor != "" { | |
| 248 | argv = append(argv, "--cursor", cursor) | |
| 249 | } | |
| 225 | 250 | |
| 226 | var builds []control.BuildOut | |
| 227 | s.runControlInto(viewer, argv, &builds) | |
| 251 | var page struct { | |
| 252 | Items []control.BuildOut `json:"items"` | |
| 253 | Next string `json:"next"` | |
| 254 | } | |
| 255 | s.runControlInto(viewer, argv, &page) | |
| 256 | builds := page.Items | |
| 228 | 257 | |
| 229 | 258 | // The jobs a trigger can name. A repo without a CI config has none; |
| 230 | 259 | // that is not an error for this page. |
| @@ -241,10 +270,11 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | ||
| 241 | 270 | Filter buildFilter |
| 242 | 271 | Facets []facetGroup |
| 243 | 272 | Refs []string |
| 273 | Older string | |
| 244 | 274 | CanWrite bool |
| 245 | 275 | Notice string |
| 246 | 276 | }{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs, |
| 247 | s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) | |
| 277 | olderBuilds(filter, page.Next), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) | |
| 248 | 278 | } |
| 249 | 279 | |
| 250 | 280 | func (s *Server) build(w http.ResponseWriter, r *http.Request) { |
internal/httpd/repohead_test.go +2 −1
| @@ -26,9 +26,10 @@ func TestRepoHeaderTwoRows(t *testing.T) { | ||
| 26 | 26 | Filter buildFilter |
| 27 | 27 | Facets []facetGroup |
| 28 | 28 | Refs []string |
| 29 | Older string | |
| 29 | 30 | CanWrite bool |
| 30 | 31 | Notice string |
| 31 | }{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""}) | |
| 32 | }{p, nil, nil, nil, buildFilter{}, nil, nil, "", true, ""}) | |
| 32 | 33 | if err != nil { |
| 33 | 34 | t.Fatal(err) |
| 34 | 35 | } |
internal/store/builds.go +7
| @@ -275,10 +275,13 @@ func (s *Store) BuildByNumber(repoID, number int64) (Build, error) { | ||
| 275 | 275 | } |
| 276 | 276 | |
| 277 | 277 | // BuildFilter narrows ListBuilds to builds matching every non-empty field. |
| 278 | // Before is the keyset cursor: only builds numbered below it, which with | |
| 279 | // the newest-first order is the page after the one that ended there. | |
| 278 | 280 | type BuildFilter struct { |
| 279 | 281 | Ref string |
| 280 | 282 | Status string |
| 281 | 283 | Job string |
| 284 | Before int64 | |
| 282 | 285 | } |
| 283 | 286 | |
| 284 | 287 | func (s *Store) ListBuilds(repoID int64, f BuildFilter, limit int) ([]Build, error) { |
| @@ -296,6 +299,10 @@ func (s *Store) ListBuilds(repoID int64, f BuildFilter, limit int) ([]Build, err | ||
| 296 | 299 | q += " AND job = ?" |
| 297 | 300 | args = append(args, f.Job) |
| 298 | 301 | } |
| 302 | if f.Before > 0 { | |
| 303 | q += " AND number < ?" | |
| 304 | args = append(args, f.Before) | |
| 305 | } | |
| 299 | 306 | q += " ORDER BY number DESC LIMIT ?" |
| 300 | 307 | args = append(args, limit) |
| 301 | 308 | rows, err := s.DB.Query(q, args...) |
internal/web/templates/builds.html +4 −3
| @@ -38,12 +38,12 @@ | ||
| 38 | 38 | <pre class="code" tabindex="0">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> |
| 39 | 39 | <p class="meta">Add <code>?job=name</code> for one job.</p> |
| 40 | 40 | </details> |
| 41 | <p class="meta">{{len .Builds}} build{{if ne (len .Builds) 1}}s{{end}} in {{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> | |
| 41 | <p class="meta">{{len .Builds}} build{{if ne (len .Builds) 1}}s{{end}} in {{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if .Older}} on this page{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p> | |
| 42 | 42 | <ul class="loglist rows"> |
| 43 | 43 | {{range .Runs}}<li> |
| 44 | 44 | <div class="commitmain"> |
| 45 | <p class="subject"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code></p> | |
| 46 | <p class="meta">{{.Ref}} · {{when .CreatedAt}}</p> | |
| 45 | <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{if .Subject}}{{.Subject}}{{else}}{{printf "%.10s" .SHA}}{{end}}</a></p> | |
| 46 | <p class="meta">{{if .Subject}}<code>{{printf "%.10s" .SHA}}</code> · {{end}}{{.Ref}} · {{when .CreatedAt}}</p> | |
| 47 | 47 | </div> |
| 48 | 48 | <div class="commitside"> |
| 49 | 49 | <span class="badge check-{{.Status}}">{{.Status}}</span> |
| @@ -52,6 +52,7 @@ | ||
| 52 | 52 | </li> |
| 53 | 53 | {{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}} |
| 54 | 54 | </ul> |
| 55 | {{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} | |
| 55 | 56 | </div> |
| 56 | 57 | </div> |
| 57 | 58 | {{end}} |