internal/httpd/builds.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/httpd/builds.go history · blame · raw

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