internal/httpd/builds.go

1ed9fb9399b21da8e6cf45be8389792aac82d5bc
gitbay/internal/httpd/builds.go history · blame · raw

401 lines · 13305 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// runStatusPriority orders combinedStatus's worst-first check: a run reads
151// as its least finished or least successful build.
152var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
153
154// combinedStatus is the run's status: the worst of its builds' statuses,
155// success only when every one of them is.
156func combinedStatus(builds []control.BuildOut) string {
157	statuses := make([]string, len(builds))
158	for i, b := range builds {
159		statuses[i] = b.Status
160	}
161	return worstStatus(statuses)
162}
163
164// worstStatus is combinedStatus's ordering rule, factored out so the
165// dashboard feed can apply the same worst-first precedence to a folded
166// build run (D04). A status outside runStatusPriority (a future state
167// such as "skipped") is still not "success": it is returned unchanged
168// rather than falling through and reading as green.
169func worstStatus(statuses []string) string {
170	has := map[string]bool{}
171	for _, s := range statuses {
172		has[s] = true
173	}
174	for _, s := range runStatusPriority {
175		if has[s] {
176			return s
177		}
178	}
179	for _, s := range statuses {
180		if s != "success" {
181			return s
182		}
183	}
184	return "success"
185}
186
187// groupRuns folds consecutive builds of the same commit and the same
188// created_at into one run. build list orders builds newest first, so one
189// push's jobs are adjacent; this does not sort or otherwise assume anything
190// beyond that adjacency.
191//
192// The commit alone is not the run. A scheduled job fires against the same
193// sha every tick for as long as the branch tip does not move, so keying on
194// the sha collapsed a week of daily runs into one row carrying the newest
195// timestamp and status (#240). What separates them is when they were
196// queued: a push's jobs are queued in one loop and share a created_at to
197// the second, a schedule's are hours or days apart. A queue loop that
198// straddles a second boundary shows as two rows for one push.
199func groupRuns(builds []control.BuildOut) []buildRun {
200	var runs []buildRun
201	for _, b := range builds {
202		if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA && runs[n-1].CreatedAt == b.CreatedAt {
203			runs[n-1].Builds = append(runs[n-1].Builds, b)
204			continue
205		}
206		runs = append(runs, buildRun{SHA: b.SHA, Subject: b.Subject, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}})
207	}
208	for i := range runs {
209		runs[i].Status = combinedStatus(runs[i].Builds)
210	}
211	return runs
212}
213
214// buildsPerPage is how many builds one page of the builds tab asks for.
215// Fewer than the command's own default, because the page folds them into
216// runs and a run is several builds tall (#244).
217const buildsPerPage = 30
218
219// olderBuilds is the link to the page after this one: the command's own
220// keyset cursor with the three filters carried along, so paging never
221// drops a filter and a filter never lands on page two.
222func olderBuilds(f buildFilter, next string) string {
223	if next == "" {
224		return ""
225	}
226	q := url.Values{"cursor": {next}}
227	for k, v := range map[string]string{"ref": f.Ref, "status": f.Status, "job": f.Job} {
228		if v != "" {
229			q.Set(k, v)
230		}
231	}
232	return "?" + q.Encode()
233}
234
235func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
236	p, ok := s.repoFor(w, r, "")
237	if !ok {
238		return
239	}
240	p.Tab = "builds"
241	viewer := s.webViewer(r)
242
243	qv := r.URL.Query()
244	filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")}
245	argv := []string{"build", "list", p.Repo.Path(), "--limit", strconv.Itoa(buildsPerPage)}
246	if filter.Ref != "" {
247		argv = append(argv, "--ref", filter.Ref)
248	}
249	if filter.Status != "" {
250		argv = append(argv, "--status", filter.Status)
251	}
252	if filter.Job != "" {
253		argv = append(argv, "--job", filter.Job)
254	}
255	if cursor := qv.Get("cursor"); cursor != "" {
256		argv = append(argv, "--cursor", cursor)
257	}
258
259	var page struct {
260		Items []control.BuildOut `json:"items"`
261		Next  string             `json:"next"`
262	}
263	s.runControlInto(viewer, argv, &page)
264	builds := page.Items
265
266	// The jobs a trigger can name. A repo without a CI config has none;
267	// that is not an error for this page.
268	var jobs []control.JobOut
269	s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs)
270
271	refs := distinctRefs(builds, filter.Ref)
272
273	s.render(w, "builds.html", struct {
274		repoPage
275		Builds   []control.BuildOut
276		Jobs     []control.JobOut
277		Runs     []buildRun
278		Filter   buildFilter
279		Facets   []facetGroup
280		Refs     []string
281		Older    string
282		CanWrite bool
283		Notice   string
284	}{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs,
285		olderBuilds(filter, page.Next), s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
286}
287
288func (s *Server) build(w http.ResponseWriter, r *http.Request) {
289	p, ok := s.repoFor(w, r, "")
290	if !ok {
291		return
292	}
293	p.Tab = "builds"
294	if _, err := strconv.ParseInt(r.PathValue("n"), 10, 64); err != nil {
295		s.notFound(w, r)
296		return
297	}
298	n := r.PathValue("n")
299	viewer := s.webViewer(r)
300
301	var b control.BuildOut
302	if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok {
303		s.notFound(w, r)
304		return
305	}
306	v := buildView{repoPage: p, Build: b, CanWrite: s.canWriteRepo(r, p.Repo), Notice: s.takeFlash(w, r)}
307	if (b.Status == "pending" || b.Status == "running") && r.URL.Query().Get("follow") != "0" && r.Method == http.MethodGet {
308		s.streamBuild(w, r, v, viewer, n)
309		return
310	}
311	v.Log, _, _ = s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
312	s.render(w, "build.html", v)
313}
314
315type buildView struct {
316	repoPage
317	Build    control.BuildOut
318	Log      string
319	Live     bool
320	CanWrite bool
321	Notice   string
322}
323
324// liveLogMarker stands in for the log when build.html is rendered for a
325// live build; streamBuild splits the page there and streams the log into
326// the gap. Git refs, paths and job names cannot hold the control byte.
327const liveLogMarker = "\x1elive-log\x1e"
328
329// streamBuild writes the build page with the log following the build:
330// the page up to the log, then build log --follow escaped and flushed as
331// it arrives, then the outcome and the rest of the page.
332func (s *Server) streamBuild(w http.ResponseWriter, r *http.Request, v buildView, viewer store.User, n string) {
333	v.Live, v.Log = true, liveLogMarker
334	var buf bytes.Buffer
335	if err := web.Render(&buf, "build.html", v); err != nil {
336		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
337		return
338	}
339	head, tail, ok := strings.Cut(buf.String(), liveLogMarker)
340	if !ok || !strings.HasPrefix(tail, "</pre>") {
341		http.Error(w, "template error: build.html has no live log slot", http.StatusInternalServerError)
342		return
343	}
344	tail = strings.TrimPrefix(tail, "</pre>")
345
346	h := w.Header()
347	h.Set("Content-Type", "text/html; charset=utf-8")
348	h.Set("Cache-Control", "no-store")
349	h.Set("X-Accel-Buffering", "no")
350	rc := http.NewResponseController(w)
351	io.WriteString(w, head)
352	rc.Flush()
353
354	path := v.Repo.Path()
355	msg, code := s.runControlStream(viewer, []string{"build", "log", path, n, "--follow"},
356		htmlStream{w: w, rc: rc}, s.until(r))
357	if r.Context().Err() != nil {
358		// The client left; nothing more to write.
359		return
360	}
361	if code == protocol.ExitDenied {
362		// The follow cap: the stored log once, and why it is not live.
363		log, _, _ := s.runControl(viewer, []string{"build", "log", path, n})
364		template.HTMLEscape(w, []byte(log))
365	}
366	io.WriteString(w, "</pre>")
367	switch {
368	case code == protocol.ExitOK:
369		var b control.BuildOut
370		if _, ok := s.runControlInto(viewer, []string{"build", "show", path, n}, &b); ok {
371			fmt.Fprintf(w, `<p class="notice" role="status">build finished: %s</p>`, template.HTMLEscapeString(b.Status))
372		}
373	case code == protocol.ExitDenied:
374		if viewer.ID == 0 {
375			msg = "Too many signed-out viewers are watching live builds. This is the log so far; reload to try again, or sign in."
376		}
377		fmt.Fprintf(w, `<p class="error" role="alert">%s</p>`, template.HTMLEscapeString(msg))
378	case code == protocol.ExitFailure && msg != "":
379		fmt.Fprintf(w, `<p class="notice" role="status">%s</p>`, template.HTMLEscapeString(msg))
380	}
381	io.WriteString(w, tail)
382}
383
384// htmlStream escapes each chunk of a streamed log into the page and
385// flushes it, so the browser draws it as it arrives.
386type htmlStream struct {
387	w  io.Writer
388	rc *http.ResponseController
389}
390
391func (h htmlStream) Write(p []byte) (int, error) {
392	var buf bytes.Buffer
393	template.HTMLEscape(&buf, p)
394	if _, err := h.w.Write(buf.Bytes()); err != nil {
395		return 0, err
396	}
397	if err := h.rc.Flush(); err != nil {
398		return 0, err
399	}
400	return len(p), nil
401}