internal/httpd/builds.go

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

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}