internal/httpd/builds.go
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}