Commit c55799d192
Verified · cmc
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/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}} |