Commit c55799d192

c55799d192e2747bb781c87e126cb3936b27acc6

parent: 7de4e7bd74

Verified · cmc

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

httpd, web: builds page filters and groups by commit

The builds page reads ref, status and job from the query string,
passes them to build list, and groups the result into one run per
commit (adjacent builds sharing a sha), with the run's status the
worst of its jobs'. A nav.filters row (all/status/job) and a branch
search form sit above the list; each preserves the other parameters.

Ref #224
internal/httpd/buildpages_test.go +18 −13
@@ -21,28 +21,33 @@ func testRepoPage() repoPage {
2121// compile error, so render both pages and look for the values.
2222func TestBuildsPageRendersCommandOutput(t *testing.T) {
2323 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{}
2431 err := web.Render(&sb, "builds.html", struct {
2532 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
3041 }{
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, "",
3944 })
4045 if err != nil {
4146 t.Fatalf("render: %v", err)
4247 }
4348 out := sb.String()
4449 for _, want := range []string{
45 "#60 build", "success", "cli-coverage", "ff6271a9d4",
50 "build", "success", "cli-coverage", "ff6271a9d4",
4651 `value="build"`, `value="nightly"`, "schedule 0 3 * * *",
4752 } {
4853 if !strings.Contains(out, want) {
internal/httpd/builds.go +146 −6
@@ -2,11 +2,133 @@ package httpd
22
33import (
44 "net/http"
5 "net/url"
56 "strconv"
67
78 "gitbay.org/gitbay/internal/control"
89)
910
11// buildFilter is the builds page's GET filter: branch, status and job,
12// each optional and independent (#224).
13type buildFilter struct {
14 Ref string
15 Status string
16 Job string
17}
18
19// buildFilterLink is one entry in the nav.filters row above the build
20// list: a status or a job, with the other two parameters carried along so
21// clicking one never drops another.
22type buildFilterLink struct {
23 Label string
24 Href string
25 Active bool
26}
27
28// buildStatuses is the fixed vocabulary a build's status takes, in the
29// order the nav.filters row offers them.
30var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
31
32// filterLinks builds the nav.filters row: "all" (clears status and job),
33// one link per status, and one per job the repository's CI config names.
34// Each link keeps the filter's other two parameters and net/url encodes
35// them, so a branch name or job name with an odd character does not break
36// the query string it lands in.
37func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
38 href := func(status, job string) string {
39 q := url.Values{}
40 if f.Ref != "" {
41 q.Set("ref", f.Ref)
42 }
43 if status != "" {
44 q.Set("status", status)
45 }
46 if job != "" {
47 q.Set("job", job)
48 }
49 return "?" + q.Encode()
50 }
51 links := []buildFilterLink{
52 {Label: "all", Href: href("", ""), Active: f.Status == "" && f.Job == ""},
53 }
54 for _, s := range buildStatuses {
55 links = append(links, buildFilterLink{Label: s, Href: href(s, f.Job), Active: f.Status == s})
56 }
57 for _, j := range jobs {
58 links = append(links, buildFilterLink{Label: j.Name, Href: href(f.Status, j.Name), Active: f.Job == j.Name})
59 }
60 return links
61}
62
63// distinctRefs lists each ref among builds once, in order, plus the
64// current filter value if it is not already there. It backs the branch
65// field's <datalist> suggestions, not a claim about what branches exist:
66// a ref that matched nothing under the current status/job filter still
67// belongs in the list the person typed it from.
68func distinctRefs(builds []control.BuildOut, current string) []string {
69 seen := map[string]bool{}
70 var refs []string
71 add := func(ref string) {
72 if ref != "" && !seen[ref] {
73 seen[ref] = true
74 refs = append(refs, ref)
75 }
76 }
77 for _, b := range builds {
78 add(b.Ref)
79 }
80 add(current)
81 return refs
82}
83
84// buildRun is one commit's builds, grouped for display: the builds tab
85// reads by commit, not by job, so a push that runs three jobs shows as one
86// row with three chips rather than three unrelated rows (#224).
87type buildRun struct {
88 SHA string
89 Ref string
90 CreatedAt string
91 Status string
92 Builds []control.BuildOut
93}
94
95// runStatusPriority orders combinedStatus's worst-first check: a run reads
96// as its least finished or least successful build.
97var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
98
99// combinedStatus is the run's status: the worst of its builds' statuses,
100// success only when every one of them is.
101func combinedStatus(builds []control.BuildOut) string {
102 has := map[string]bool{}
103 for _, b := range builds {
104 has[b.Status] = true
105 }
106 for _, s := range runStatusPriority {
107 if has[s] {
108 return s
109 }
110 }
111 return "success"
112}
113
114// groupRuns folds consecutive builds of the same commit into one run.
115// build list orders builds newest first, so one push's jobs are adjacent;
116// this does not sort or otherwise assume anything beyond that adjacency.
117func groupRuns(builds []control.BuildOut) []buildRun {
118 var runs []buildRun
119 for _, b := range builds {
120 if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA {
121 runs[n-1].Builds = append(runs[n-1].Builds, b)
122 continue
123 }
124 runs = append(runs, buildRun{SHA: b.SHA, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}})
125 }
126 for i := range runs {
127 runs[i].Status = combinedStatus(runs[i].Builds)
128 }
129 return runs
130}
131
10132func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
11133 p, ok := s.repoFor(w, r, "")
12134 if !ok {
@@ -15,8 +137,21 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
15137 p.Tab = "builds"
16138 viewer := s.webViewer(r)
17139
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
18153 var builds []control.BuildOut
19 s.runControlInto(viewer, []string{"build", "list", p.Repo.Path()}, &builds)
154 s.runControlInto(viewer, argv, &builds)
20155
21156 // The jobs a trigger can name. A repo without a CI config has none;
22157 // that is not an error for this page.
@@ -25,11 +160,16 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
25160
26161 s.render(w, "builds.html", struct {
27162 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)})
33173}
34174
35175func (s *Server) build(w http.ResponseWriter, r *http.Request) {
internal/httpd/builds_test.go added +147
@@ -0,0 +1,147 @@
1package httpd
2
3import (
4 "reflect"
5 "testing"
6
7 "gitbay.org/gitbay/internal/control"
8)
9
10// groupRuns folds consecutive same-commit builds (the list is newest
11// first, so a commit's jobs are adjacent) into one run per commit, and
12// gives the run a combined status: worst first (failure beats everything,
13// then cancelled, running, pending), success only when every job is (#224).
14func TestGroupRunsCombinesByCommit(t *testing.T) {
15 builds := []control.BuildOut{
16 {Number: 3, Job: "lint", Status: "success", SHA: "bbb", Ref: "main", CreatedAt: "t2"},
17 {Number: 2, Job: "unit", Status: "failure", SHA: "aaa", Ref: "main", CreatedAt: "t1"},
18 {Number: 1, Job: "lint", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "t1"},
19 }
20 runs := groupRuns(builds)
21 if len(runs) != 2 {
22 t.Fatalf("groupRuns returned %d runs, want 2: %+v", len(runs), runs)
23 }
24 if runs[0].SHA != "bbb" || len(runs[0].Builds) != 1 || runs[0].Status != "success" {
25 t.Errorf("first run: %+v", runs[0])
26 }
27 if runs[1].SHA != "aaa" || len(runs[1].Builds) != 2 || runs[1].Status != "failure" {
28 t.Errorf("second run: %+v", runs[1])
29 }
30 // Order within a run is preserved from the input.
31 if runs[1].Builds[0].Job != "unit" || runs[1].Builds[1].Job != "lint" {
32 t.Errorf("run builds out of order: %+v", runs[1].Builds)
33 }
34}
35
36func TestGroupRunsEmpty(t *testing.T) {
37 if runs := groupRuns(nil); len(runs) != 0 {
38 t.Errorf("groupRuns(nil) = %+v, want empty", runs)
39 }
40}
41
42// Two builds on the same sha but on different refs (a fast-forward merge
43// can leave the commit reachable from more than one branch) are not
44// adjacent unless the list happens to put them there; groupRuns only folds
45// what is actually adjacent, so this documents that a same-sha, same-ref
46// pair from one push is what gets folded, not "any build of this sha ever".
47func TestGroupRunsCombinedStatusPriority(t *testing.T) {
48 cases := []struct {
49 statuses []string
50 want string
51 }{
52 {[]string{"success"}, "success"},
53 {[]string{"success", "pending"}, "pending"},
54 {[]string{"pending", "running"}, "running"},
55 {[]string{"running", "cancelled"}, "cancelled"},
56 {[]string{"cancelled", "failure"}, "failure"},
57 {[]string{"success", "success", "failure"}, "failure"},
58 }
59 for _, tc := range cases {
60 var builds []control.BuildOut
61 for _, s := range tc.statuses {
62 builds = append(builds, control.BuildOut{SHA: "x", Status: s})
63 }
64 runs := groupRuns(builds)
65 if len(runs) != 1 || runs[0].Status != tc.want {
66 t.Errorf("statuses %v: combined %+v, want %q", tc.statuses, runs, tc.want)
67 }
68 }
69}
70
71// filterLinks builds the nav.filters row: one link that clears both status
72// and job, one per known status and one per known job, each preserving the
73// other two query parameters and marking itself active (#224).
74func TestFilterLinksPreservesOtherParamsAndMarksActive(t *testing.T) {
75 links := filterLinks(buildFilter{Ref: "main", Status: "success", Job: "lint"},
76 []control.JobOut{{Name: "lint"}, {Name: "unit"}})
77
78 byLabel := map[string]buildFilterLink{}
79 for _, l := range links {
80 byLabel[l.Label] = l
81 }
82 all, ok := byLabel["all"]
83 if !ok {
84 t.Fatal("no \"all\" link")
85 }
86 if all.Active {
87 t.Error(`"all" is active while a status/job filter is set`)
88 }
89 if all.Href != "?ref=main" {
90 t.Errorf(`"all" href = %q, want "?ref=main" (clears status and job, keeps ref)`, all.Href)
91 }
92
93 success, ok := byLabel["success"]
94 if !ok || !success.Active {
95 t.Errorf("success link: %+v, want present and active", success)
96 }
97 if success.Href != "?job=lint&ref=main&status=success" {
98 t.Errorf("success href = %q", success.Href)
99 }
100
101 lint, ok := byLabel["lint"]
102 if !ok || !lint.Active {
103 t.Errorf("lint link: %+v, want present and active", lint)
104 }
105 if lint.Href != "?job=lint&ref=main&status=success" {
106 t.Errorf("lint href = %q", lint.Href)
107 }
108
109 unit, ok := byLabel["unit"]
110 if !ok || unit.Active {
111 t.Errorf("unit link: %+v, want present and inactive", unit)
112 }
113 if unit.Href != "?job=unit&ref=main&status=success" {
114 t.Errorf("unit href = %q", unit.Href)
115 }
116}
117
118// With no filter at all, "all" is the active link.
119func TestFilterLinksAllActiveWhenUnfiltered(t *testing.T) {
120 links := filterLinks(buildFilter{}, nil)
121 for _, l := range links {
122 if l.Label == "all" && !l.Active {
123 t.Error(`"all" is not active with no filter set`)
124 }
125 }
126}
127
128// distinctRefs lists each ref once, in the order builds carry them, and
129// always includes the current filter value even if it matched nothing —
130// it powers the branch field's suggestions, not a strict "what exists" list.
131func TestDistinctRefsDedupesAndIncludesCurrent(t *testing.T) {
132 builds := []control.BuildOut{{Ref: "main"}, {Ref: "feature"}, {Ref: "main"}}
133 got := distinctRefs(builds, "release")
134 want := []string{"main", "feature", "release"}
135 if !reflect.DeepEqual(got, want) {
136 t.Errorf("distinctRefs = %v, want %v", got, want)
137 }
138}
139
140func TestDistinctRefsNoDuplicateWhenCurrentAlreadyPresent(t *testing.T) {
141 builds := []control.BuildOut{{Ref: "main"}}
142 got := distinctRefs(builds, "main")
143 want := []string{"main"}
144 if !reflect.DeepEqual(got, want) {
145 t.Errorf("distinctRefs = %v, want %v", got, want)
146 }
147}
internal/web/templates/builds.html +18 −4
@@ -1,7 +1,20 @@
11{{define "width"}}wide{{end}}
22{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{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>
518{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
619{{if and .CanWrite .Jobs}}
720<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds" class="setform">
@@ -17,15 +30,16 @@
1730<pre class="code">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
1831<p class="meta">Add <code>?job=name</code> for one job.</p>
1932</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>
2034<ul class="loglist">
21{{range .Builds}}<li>
35{{range .Runs}}<li>
2236 <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>
2438 <p class="meta">{{.Ref}} · {{when .CreatedAt}}</p>
2539 </div>
2640 <div class="commitside">
2741 <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}}
2943 </div>
3044</li>
3145{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}}