internal/httpd/builds.go

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

267 lines · 8341 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).
132type buildRun struct {
133	SHA       string
134	Ref       string
135	CreatedAt string
136	Status    string
137	Builds    []control.BuildOut
138}
139
140// runStatusPriority orders combinedStatus's worst-first check: a run reads
141// as its least finished or least successful build.
142var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
143
144// combinedStatus is the run's status: the worst of its builds' statuses,
145// success only when every one of them is.
146func combinedStatus(builds []control.BuildOut) string {
147	statuses := make([]string, len(builds))
148	for i, b := range builds {
149		statuses[i] = b.Status
150	}
151	return worstStatus(statuses)
152}
153
154// worstStatus is combinedStatus's ordering rule, factored out so the
155// dashboard feed can apply the same worst-first precedence to a folded
156// build run (D04). A status outside runStatusPriority (a future state
157// such as "skipped") is still not "success": it is returned unchanged
158// rather than falling through and reading as green.
159func worstStatus(statuses []string) string {
160	has := map[string]bool{}
161	for _, s := range statuses {
162		has[s] = true
163	}
164	for _, s := range runStatusPriority {
165		if has[s] {
166			return s
167		}
168	}
169	for _, s := range statuses {
170		if s != "success" {
171			return s
172		}
173	}
174	return "success"
175}
176
177// groupRuns folds consecutive builds of the same commit into one run.
178// build list orders builds newest first, so one push's jobs are adjacent;
179// this does not sort or otherwise assume anything beyond that adjacency.
180func groupRuns(builds []control.BuildOut) []buildRun {
181	var runs []buildRun
182	for _, b := range builds {
183		if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA {
184			runs[n-1].Builds = append(runs[n-1].Builds, b)
185			continue
186		}
187		runs = append(runs, buildRun{SHA: b.SHA, Ref: b.Ref, CreatedAt: b.CreatedAt, Builds: []control.BuildOut{b}})
188	}
189	for i := range runs {
190		runs[i].Status = combinedStatus(runs[i].Builds)
191	}
192	return runs
193}
194
195func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
196	p, ok := s.repoFor(w, r, "")
197	if !ok {
198		return
199	}
200	p.Tab = "builds"
201	viewer := s.webViewer(r)
202
203	qv := r.URL.Query()
204	filter := buildFilter{Ref: qv.Get("ref"), Status: qv.Get("status"), Job: qv.Get("job")}
205	argv := []string{"build", "list", p.Repo.Path()}
206	if filter.Ref != "" {
207		argv = append(argv, "--ref", filter.Ref)
208	}
209	if filter.Status != "" {
210		argv = append(argv, "--status", filter.Status)
211	}
212	if filter.Job != "" {
213		argv = append(argv, "--job", filter.Job)
214	}
215
216	var builds []control.BuildOut
217	s.runControlInto(viewer, argv, &builds)
218
219	// The jobs a trigger can name. A repo without a CI config has none;
220	// that is not an error for this page.
221	var jobs []control.JobOut
222	s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs)
223
224	refs := distinctRefs(builds, filter.Ref)
225
226	s.render(w, "builds.html", struct {
227		repoPage
228		Builds   []control.BuildOut
229		Jobs     []control.JobOut
230		Runs     []buildRun
231		Filter   buildFilter
232		Facets   []facetGroup
233		Refs     []string
234		CanWrite bool
235		Notice   string
236	}{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs,
237		s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
238}
239
240func (s *Server) build(w http.ResponseWriter, r *http.Request) {
241	p, ok := s.repoFor(w, r, "")
242	if !ok {
243		return
244	}
245	p.Tab = "builds"
246	if _, err := strconv.ParseInt(r.PathValue("n"), 10, 64); err != nil {
247		s.notFound(w, r)
248		return
249	}
250	n := r.PathValue("n")
251	viewer := s.webViewer(r)
252
253	var b control.BuildOut
254	if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok {
255		s.notFound(w, r)
256		return
257	}
258	log, _, _ := s.runControl(viewer, []string{"build", "log", p.Repo.Path(), n})
259
260	s.render(w, "build.html", struct {
261		repoPage
262		Build    control.BuildOut
263		Log      string
264		CanWrite bool
265		Notice   string
266	}{p, b, log, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
267}