internal/httpd/builds.go
374 lines · 12484 bytes
1package httpd
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "io"
8 "net/http"
9 "net/url"
10 "slices"
11 "strconv"
12 "strings"
13
14 "gitbay.org/gitbay/internal/control"
15 "gitbay.org/gitbay/internal/protocol"
16 "gitbay.org/gitbay/internal/store"
17 "gitbay.org/gitbay/internal/web"
18)
19
20// buildFilter is the builds page's GET filter: branch, status and job,
21// each optional and independent (#224).
22type buildFilter struct {
23 Ref string
24 Status string
25 Job string
26}
27
28// buildFilterLink is one of the links buildFacets splits into the side
29// column's Status and Jobs groups: a status or a job, with the other two
30// parameters carried along so clicking one never drops another.
31type buildFilterLink struct {
32 Label string
33 Href string
34 Active bool
35}
36
37// buildStatuses is the fixed vocabulary a build's status takes, in the
38// order the Status group offers them. Its length is also where buildFacets
39// cuts filterLinks' rows apart.
40var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
41
42// filterLinks builds the status and job rows: "all" (clears status and
43// job), one link per status, and one per job the repository's CI config
44// names. Each link keeps the filter's other two parameters and net/url encodes
45// them, so a branch name or job name with an odd character does not break
46// the query string it lands in.
47func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
48 href := func(status, job string) string {
49 q := url.Values{}
50 if f.Ref != "" {
51 q.Set("ref", f.Ref)
52 }
53 if status != "" {
54 q.Set("status", status)
55 }
56 if job != "" {
57 q.Set("job", job)
58 }
59 return "?" + q.Encode()
60 }
61 links := []buildFilterLink{
62 {Label: "all", Href: href("", ""), Active: f.Status == "" && f.Job == ""},
63 }
64 for _, s := range buildStatuses {
65 links = append(links, buildFilterLink{Label: s, Href: href(s, f.Job), Active: f.Status == s})
66 }
67 for _, j := range jobs {
68 links = append(links, buildFilterLink{Label: j.Name, Href: href(f.Status, j.Name), Active: f.Job == j.Name})
69 }
70 return links
71}
72
73// maxBranchFacets caps the Branches group, the way topicFacets caps
74// topics: a page of builds names as many refs as it likes and the column
75// is not a branch listing. The field below the column takes any ref.
76const maxBranchFacets = 10
77
78// buildFacets is the builds page's side column: filterLinks' rows split
79// into their groups, plus one link per branch seen, which keeps status
80// and job and clears itself when active.
81func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup {
82 links := filterLinks(f, jobs)
83 n := 1 + len(buildStatuses)
84 status := facetGroup{Title: "Status"}
85 for _, l := range links[:n] {
86 status.Items = append(status.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
87 }
88 job := facetGroup{Title: "Jobs"}
89 for _, l := range links[n:] {
90 job.Items = append(job.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
91 }
92 branch := facetGroup{Title: "Branches"}
93 base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}}
94 shown := refs
95 if len(shown) > maxBranchFacets {
96 shown = shown[:maxBranchFacets]
97 // the ref in force belongs in the group wherever it sits, or the
98 // filter it set cannot be cleared from the column. The reslice
99 // caps the capacity so the append copies instead of writing
100 // through to refs.
101 if i := slices.Index(refs, f.Ref); i >= maxBranchFacets {
102 shown = append(shown[:maxBranchFacets-1:maxBranchFacets-1], refs[i])
103 }
104 }
105 for _, ref := range shown {
106 active := ref == f.Ref
107 href := facetHref(base, "ref", ref)
108 if active {
109 href = facetHref(base, "ref", "")
110 }
111 branch.Items = append(branch.Items, facetItem{Label: ref, Href: href, Active: active})
112 }
113 return []facetGroup{status, job, branch}
114}
115
116// distinctRefs lists each ref among builds once, in order, plus the
117// current filter value if it is not already there. It backs the branch
118// field's <datalist> suggestions, not a claim about what branches exist:
119// a ref that matched nothing under the current status/job filter still
120// belongs in the list the person typed it from.
121func distinctRefs(builds []control.BuildOut, current string) []string {
122 seen := map[string]bool{}
123 var refs []string
124 add := func(ref string) {
125 if ref != "" && !seen[ref] {
126 seen[ref] = true
127 refs = append(refs, ref)
128 }
129 }
130 for _, b := range builds {
131 add(b.Ref)
132 }
133 add(current)
134 return refs
135}
136
137// buildRun is one commit's builds, grouped for display: the builds tab
138// reads by commit, not by job, so a push that runs three jobs shows as one
139// row with three chips rather than three unrelated rows (#224). A run is
140// one queueing of a commit, not the commit — see groupRuns (#240).
141type buildRun struct {
142 SHA string
143 Subject string
144 Ref string
145 CreatedAt string
146 Status string
147 Builds []control.BuildOut
148}
149
150// combinedStatus is the run's status: the worst of its builds' statuses,
151// success only when every one of them is.
152func combinedStatus(builds []control.BuildOut) string {
153 statuses := make([]string, len(builds))
154 for i, b := range builds {
155 statuses[i] = b.Status
156 }
157 return control.WorstStatus(statuses)
158}
159
160// groupRuns folds consecutive builds of the same commit and the same
161// created_at into one run. build list orders builds newest first, so one
162// push's jobs are adjacent; this does not sort or otherwise assume anything
163// beyond that adjacency.
164//
165// The commit alone is not the run. A scheduled job fires against the same
166// sha every tick for as long as the branch tip does not move, so keying on
167// the sha collapsed a week of daily runs into one row carrying the newest
168// timestamp and status (#240). What separates them is when they were
169// queued: a push's jobs are queued in one loop and share a created_at to
170// the second, a schedule's are hours or days apart. A queue loop that
171// straddles a second boundary shows as two rows for one push.
172func groupRuns(builds []control.BuildOut) []buildRun {
173 var runs []buildRun
174 for _, b := range builds {
175 if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA && runs[n-1].CreatedAt == b.CreatedAt {
176 runs[n-1].Builds = append(runs[n-1].Builds, b)
177 continue
178 }
179 runs = append(runs, buildRun{SHA: b.SHA, Subject: b.Subject, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}})
180 }
181 for i := range runs {
182 runs[i].Status = combinedStatus(runs[i].Builds)
183 }
184 return runs
185}
186
187// buildsPerPage is how many builds one page of the builds tab asks for.
188// Fewer than the command's own default, because the page folds them into
189// runs and a run is several builds tall (#244).
190const buildsPerPage = 30
191
192// olderBuilds is the link to the page after this one: the command's own
193// keyset cursor with the three filters carried along, so paging never
194// drops a filter and a filter never lands on page two.
195func olderBuilds(f buildFilter, next string) string {
196 if next == "" {
197 return ""
198 }
199 q := url.Values{"cursor": {next}}
200 for k, v := range map[string]string{"ref": f.Ref, "status": f.Status, "job": f.Job} {
201 if v != "" {
202 q.Set(k, v)
203 }
204 }
205 return "?" + q.Encode()
206}
207
208func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
209 p, ok := s.repoFor(w, r, "")
210 if !ok {
211 return
212 }
213 p.Tab = "builds"
214 viewer := s.webViewer(r)
215
216 qv := r.URL.Query()
217 filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")}
218 argv := []string{"build", "list", p.Repo.Path(), "--limit", strconv.Itoa(buildsPerPage)}
219 if filter.Ref != "" {
220 argv = append(argv, "--ref", filter.Ref)
221 }
222 if filter.Status != "" {
223 argv = append(argv, "--status", filter.Status)
224 }
225 if filter.Job != "" {
226 argv = append(argv, "--job", filter.Job)
227 }
228 if cursor := qv.Get("cursor"); cursor != "" {
229 argv = append(argv, "--cursor", cursor)
230 }
231
232 var page struct {
233 Items []control.BuildOut `json:"items"`
234 Next string `json:"next"`
235 }
236 s.runControlInto(viewer, argv, &page)
237 builds := page.Items
238
239 // The jobs a trigger can name. A repo without a CI config has none;
240 // that is not an error for this page.
241 var jobs []control.JobOut
242 s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs)
243
244 refs := distinctRefs(builds, filter.Ref)
245
246 s.render(w, "builds.html", struct {
247 repoPage
248 Builds []control.BuildOut
249 Jobs []control.JobOut
250 Runs []buildRun
251 Filter buildFilter
252 Facets []facetGroup
253 Refs []string
254 Older string
255 CanWrite bool
256 Notice string
257 }{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs,
258 olderBuilds(filter, page.Next), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
259}
260
261func (s *Server) build(w http.ResponseWriter, r *http.Request) {
262 p, ok := s.repoFor(w, r, "")
263 if !ok {
264 return
265 }
266 p.Tab = "builds"
267 if _, err := strconv.ParseInt(r.PathValue("n"), 10, 64); err != nil {
268 s.notFound(w, r)
269 return
270 }
271 n := r.PathValue("n")
272 viewer := s.webViewer(r)
273
274 var b control.BuildOut
275 if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok {
276 s.notFound(w, r)
277 return
278 }
279 v := buildView{repoPage: p, Build: b, CanWrite: s.canWriteRepo(r, p.Repo), Notice: s.takeFlash(w, r)}
280 if (b.Status == "pending" || b.Status == "running") && r.URL.Query().Get("follow") != "0" && r.Method == http.MethodGet {
281 s.streamBuild(w, r, v, viewer, n)
282 return
283 }
284 v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
285 s.render(w, "build.html", v)
286}
287
288type buildView struct {
289 repoPage
290 Build control.BuildOut
291 Log string
292 Live bool
293 CanWrite bool
294 Notice string
295}
296
297// liveLogMarker stands in for the log when build.html is rendered for a
298// live build; streamBuild splits the page there and streams the log into
299// the gap. Git refs, paths and job names cannot hold the control byte.
300const liveLogMarker = "\x1elive-log\x1e"
301
302// streamBuild writes the build page with the log following the build:
303// the page up to the log, then build log --follow escaped and flushed as
304// it arrives, then the outcome and the rest of the page.
305func (s *Server) streamBuild(w http.ResponseWriter, r *http.Request, v buildView, viewer store.User, n string) {
306 v.Live, v.Log = true, liveLogMarker
307 var buf bytes.Buffer
308 if err := web.Render(&buf, "build.html", v); err != nil {
309 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
310 return
311 }
312 head, tail, ok := strings.Cut(buf.String(), liveLogMarker)
313 if !ok || !strings.HasPrefix(tail, "</pre>") {
314 http.Error(w, "template error: build.html has no live log slot", http.StatusInternalServerError)
315 return
316 }
317 tail = strings.TrimPrefix(tail, "</pre>")
318
319 h := w.Header()
320 h.Set("Content-Type", "text/html; charset=utf-8")
321 h.Set("Cache-Control", "no-store")
322 h.Set("X-Accel-Buffering", "no")
323 rc := http.NewResponseController(w)
324 io.WriteString(w, head)
325 rc.Flush()
326
327 path := v.Repo.Path()
328 msg, code := s.runControlStream(viewer, []string{"build", "log", path, n, "--follow"},
329 htmlStream{w: w, rc: rc}, s.until(r))
330 if r.Context().Err() != nil {
331 // The client left; nothing more to write.
332 return
333 }
334 if code == protocol.ExitDenied {
335 // The follow cap: the stored log once, and why it is not live.
336 log, _, _ := s.runControl(viewer, []string{"build", "log", path, n})
337 template.HTMLEscape(w, []byte(log))
338 }
339 io.WriteString(w, "</pre>")
340 switch {
341 case code == protocol.ExitOK:
342 var b control.BuildOut
343 if _, ok := s.runControlInto(viewer, []string{"build", "show", path, n}, &b); ok {
344 fmt.Fprintf(w, `<p class="notice" role="status">build finished: %s</p>`, template.HTMLEscapeString(b.Status))
345 }
346 case code == protocol.ExitDenied:
347 if viewer.ID == 0 {
348 msg = "Too many signed-out viewers are watching live builds. This is the log so far; reload to try again, or sign in."
349 }
350 fmt.Fprintf(w, `<p class="error" role="alert">%s</p>`, template.HTMLEscapeString(msg))
351 case code == protocol.ExitFailure && msg != "":
352 fmt.Fprintf(w, `<p class="notice" role="status">%s</p>`, template.HTMLEscapeString(msg))
353 }
354 io.WriteString(w, tail)
355}
356
357// htmlStream escapes each chunk of a streamed log into the page and
358// flushes it, so the browser draws it as it arrives.
359type htmlStream struct {
360 w io.Writer
361 rc *http.ResponseController
362}
363
364func (h htmlStream) Write(p []byte) (int, error) {
365 var buf bytes.Buffer
366 template.HTMLEscape(&buf, p)
367 if _, err := h.w.Write(buf.Bytes()); err != nil {
368 return 0, err
369 }
370 if err := h.rc.Flush(); err != nil {
371 return 0, err
372 }
373 return len(p), nil
374}