internal/httpd/builds.go
307 lines · 10001 bytes
1package httpd
2
3import (
4 "net/http"
5 "net/url"
6 "slices"
7 "strconv"
8
9 "gitbay.org/gitbay/internal/control"
10)
11
12// buildFilter is the builds page's GET filter: branch, status and job,
13// each optional and independent (#224).
14type buildFilter struct {
15 Ref string
16 Status string
17 Job string
18}
19
20// buildFilterLink is one of the links buildFacets splits into the side
21// column's Status and Jobs groups: a status or a job, with the other two
22// parameters carried along so clicking one never drops another.
23type buildFilterLink struct {
24 Label string
25 Href string
26 Active bool
27}
28
29// buildStatuses is the fixed vocabulary a build's status takes, in the
30// order the Status group offers them. Its length is also where buildFacets
31// cuts filterLinks' rows apart.
32var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
33
34// filterLinks builds the status and job rows: "all" (clears status and
35// job), one link per status, and one per job the repository's CI config
36// names. Each link keeps the filter's other two parameters and net/url encodes
37// them, so a branch name or job name with an odd character does not break
38// the query string it lands in.
39func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
40 href := func(status, job string) string {
41 q := url.Values{}
42 if f.Ref != "" {
43 q.Set("ref", f.Ref)
44 }
45 if status != "" {
46 q.Set("status", status)
47 }
48 if job != "" {
49 q.Set("job", job)
50 }
51 return "?" + q.Encode()
52 }
53 links := []buildFilterLink{
54 {Label: "all", Href: href("", ""), Active: f.Status == "" && f.Job == ""},
55 }
56 for _, s := range buildStatuses {
57 links = append(links, buildFilterLink{Label: s, Href: href(s, f.Job), Active: f.Status == s})
58 }
59 for _, j := range jobs {
60 links = append(links, buildFilterLink{Label: j.Name, Href: href(f.Status, j.Name), Active: f.Job == j.Name})
61 }
62 return links
63}
64
65// maxBranchFacets caps the Branches group, the way topicFacets caps
66// topics: a page of builds names as many refs as it likes and the column
67// is not a branch listing. The field below the column takes any ref.
68const maxBranchFacets = 10
69
70// buildFacets is the builds page's side column: filterLinks' rows split
71// into their groups, plus one link per branch seen, which keeps status
72// and job and clears itself when active.
73func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup {
74 links := filterLinks(f, jobs)
75 n := 1 + len(buildStatuses)
76 status := facetGroup{Title: "Status"}
77 for _, l := range links[:n] {
78 status.Items = append(status.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
79 }
80 job := facetGroup{Title: "Jobs"}
81 for _, l := range links[n:] {
82 job.Items = append(job.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
83 }
84 branch := facetGroup{Title: "Branches"}
85 base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}}
86 shown := refs
87 if len(shown) > maxBranchFacets {
88 shown = shown[:maxBranchFacets]
89 // the ref in force belongs in the group wherever it sits, or the
90 // filter it set cannot be cleared from the column. The reslice
91 // caps the capacity so the append copies instead of writing
92 // through to refs.
93 if i := slices.Index(refs, f.Ref); i >= maxBranchFacets {
94 shown = append(shown[:maxBranchFacets-1:maxBranchFacets-1], refs[i])
95 }
96 }
97 for _, ref := range shown {
98 active := ref == f.Ref
99 href := facetHref(base, "ref", ref)
100 if active {
101 href = facetHref(base, "ref", "")
102 }
103 branch.Items = append(branch.Items, facetItem{Label: ref, Href: href, Active: active})
104 }
105 return []facetGroup{status, job, branch}
106}
107
108// distinctRefs lists each ref among builds once, in order, plus the
109// current filter value if it is not already there. It backs the branch
110// field's <datalist> suggestions, not a claim about what branches exist:
111// a ref that matched nothing under the current status/job filter still
112// belongs in the list the person typed it from.
113func distinctRefs(builds []control.BuildOut, current string) []string {
114 seen := map[string]bool{}
115 var refs []string
116 add := func(ref string) {
117 if ref != "" && !seen[ref] {
118 seen[ref] = true
119 refs = append(refs, ref)
120 }
121 }
122 for _, b := range builds {
123 add(b.Ref)
124 }
125 add(current)
126 return refs
127}
128
129// buildRun is one commit's builds, grouped for display: the builds tab
130// reads by commit, not by job, so a push that runs three jobs shows as one
131// row with three chips rather than three unrelated rows (#224). A run is
132// one queueing of a commit, not the commit — see groupRuns (#240).
133type buildRun struct {
134 SHA string
135 Subject string
136 Ref string
137 CreatedAt string
138 Status string
139 Builds []control.BuildOut
140}
141
142// runStatusPriority orders combinedStatus's worst-first check: a run reads
143// as its least finished or least successful build.
144var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
145
146// combinedStatus is the run's status: the worst of its builds' statuses,
147// success only when every one of them is.
148func combinedStatus(builds []control.BuildOut) string {
149 statuses := make([]string, len(builds))
150 for i, b := range builds {
151 statuses[i] = b.Status
152 }
153 return worstStatus(statuses)
154}
155
156// worstStatus is combinedStatus's ordering rule, factored out so the
157// dashboard feed can apply the same worst-first precedence to a folded
158// build run (D04). A status outside runStatusPriority (a future state
159// such as "skipped") is still not "success": it is returned unchanged
160// rather than falling through and reading as green.
161func worstStatus(statuses []string) string {
162 has := map[string]bool{}
163 for _, s := range statuses {
164 has[s] = true
165 }
166 for _, s := range runStatusPriority {
167 if has[s] {
168 return s
169 }
170 }
171 for _, s := range statuses {
172 if s != "success" {
173 return s
174 }
175 }
176 return "success"
177}
178
179// groupRuns folds consecutive builds of the same commit and the same
180// created_at into one run. build list orders builds newest first, so one
181// push's jobs are adjacent; this does not sort or otherwise assume anything
182// beyond that adjacency.
183//
184// The commit alone is not the run. A scheduled job fires against the same
185// sha every tick for as long as the branch tip does not move, so keying on
186// the sha collapsed a week of daily runs into one row carrying the newest
187// timestamp and status (#240). What separates them is when they were
188// queued: a push's jobs are queued in one loop and share a created_at to
189// the second, a schedule's are hours or days apart. A queue loop that
190// straddles a second boundary shows as two rows for one push.
191func groupRuns(builds []control.BuildOut) []buildRun {
192 var runs []buildRun
193 for _, b := range builds {
194 if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA && runs[n-1].CreatedAt == b.CreatedAt {
195 runs[n-1].Builds = append(runs[n-1].Builds, b)
196 continue
197 }
198 runs = append(runs, buildRun{SHA: b.SHA, Subject: b.Subject, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}})
199 }
200 for i := range runs {
201 runs[i].Status = combinedStatus(runs[i].Builds)
202 }
203 return runs
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).
209const 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.
214func 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
227func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
228 p, ok := s.repoFor(w, r, "")
229 if !ok {
230 return
231 }
232 p.Tab = "builds"
233 viewer := s.webViewer(r)
234
235 qv := r.URL.Query()
236 filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")}
237 argv := []string{"build", "list", p.Repo.Path(), "--limit", strconv.Itoa(buildsPerPage)}
238 if filter.Ref != "" {
239 argv = append(argv, "--ref", filter.Ref)
240 }
241 if filter.Status != "" {
242 argv = append(argv, "--status", filter.Status)
243 }
244 if filter.Job != "" {
245 argv = append(argv, "--job", filter.Job)
246 }
247 if cursor := qv.Get("cursor"); cursor != "" {
248 argv = append(argv, "--cursor", cursor)
249 }
250
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
257
258 // The jobs a trigger can name. A repo without a CI config has none;
259 // that is not an error for this page.
260 var jobs []control.JobOut
261 s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs)
262
263 refs := distinctRefs(builds, filter.Ref)
264
265 s.render(w, "builds.html", struct {
266 repoPage
267 Builds []control.BuildOut
268 Jobs []control.JobOut
269 Runs []buildRun
270 Filter buildFilter
271 Facets []facetGroup
272 Refs []string
273 Older string
274 CanWrite bool
275 Notice string
276 }{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs,
277 olderBuilds(filter, page.Next), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
278}
279
280func (s *Server) build(w http.ResponseWriter, r *http.Request) {
281 p, ok := s.repoFor(w, r, "")
282 if !ok {
283 return
284 }
285 p.Tab = "builds"
286 if _, err := strconv.ParseInt(r.PathValue("n"), 10, 64); err != nil {
287 s.notFound(w, r)
288 return
289 }
290 n := r.PathValue("n")
291 viewer := s.webViewer(r)
292
293 var b control.BuildOut
294 if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok {
295 s.notFound(w, r)
296 return
297 }
298 log, _, _ := s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
299
300 s.render(w, "build.html", struct {
301 repoPage
302 Build control.BuildOut
303 Log string
304 CanWrite bool
305 Notice string
306 }{p, b, log, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
307}