1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "fmt"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/a-h/templ"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19// The CI pages (ADR 0025): the runs of a repository, one run with its jobs,
20// one job with its steps and its log. They read through ports.RunService and
21// follow the events RunUpdated and JobUpdated of the repository topic.
22//
23// GET /<repo>/actions the runs, newest first
24// GET /<repo>/actions/runs/<id> one run: jobs and steps
25// GET /<repo>/actions/runs/<id>/job/<job id> one job: steps and live log
26// GET /<repo>/actions/runs/<id>/job/<job id>/raw the log as text
27// POST /<repo>/actions/runs/<id>/cancel | rerun | rerun-failed (the owner)
28
29const (
30 // actionsPageSize is the number of runs one page lists.
31 actionsPageSize = 30
32 // jobLogPageLines is the number of log lines a job page shows at once; the
33 // rest is reached with ?from=.
34 jobLogPageLines = 2000
35 // rawLogMaxBytes bounds the raw log the server writes to one response.
36 rawLogMaxBytes = 64 << 20
37)
38
39func actionsBase(repo string) string { return repoHref(repo, "actions") }
40func runHref(repo string, id int64) string {
41 return repoHref(repo, "actions", "runs", strconv.FormatInt(id, 10))
42}
43func jobHref(repo string, runID int64, jobID string) string {
44 return runHref(repo, runID) + "/job/" + jobID
45}
46
47// ciState is the one word shown for a status and a conclusion: what is going
48// on, or how it ended.
49func ciState(status domain.CIStatus, c domain.CIConclusion) string {
50 switch status {
51 case domain.CIQueued:
52 return "queued"
53 case domain.CIPending:
54 return "pending"
55 case domain.CIInProgress:
56 return "running"
57 }
58 switch c {
59 case domain.CISuccess:
60 return "success"
61 case domain.CIFailure:
62 return "failure"
63 case domain.CICancelled:
64 return "cancelled"
65 case domain.CISkipped:
66 return "skipped"
67 case domain.CITimedOut:
68 return "timed out"
69 case domain.CINeutral:
70 return "neutral"
71 case domain.CIActionRequired:
72 return "action required"
73 }
74 return "done"
75}
76
77// ciKind groups the states into the few that have an icon and a colour.
78func ciKind(status domain.CIStatus, c domain.CIConclusion) string {
79 switch {
80 case status == domain.CIQueued, status == domain.CIPending:
81 return "queued"
82 case status == domain.CIInProgress:
83 return "running"
84 case c == domain.CISuccess, c == domain.CINeutral:
85 return "ok"
86 case c == domain.CISkipped:
87 return "skipped"
88 case c == domain.CICancelled:
89 return "cancelled"
90 }
91 return "bad"
92}
93
94// duration is a short elapsed time between two instants; empty when the
95// start is unknown, running up to now when the end is not.
96func duration(start, end, now time.Time) string {
97 if start.IsZero() {
98 return ""
99 }
100 if end.IsZero() {
101 end = now
102 }
103 d := end.Sub(start).Round(time.Second)
104 switch {
105 case d < 0:
106 return ""
107 case d < time.Minute:
108 return fmt.Sprintf("%ds", int(d.Seconds()))
109 case d < time.Hour:
110 return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
111 }
112 return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60)
113}
114
115// RunRow is a run of the list.
116type RunRow struct {
117 ID string
118 Href string
119 Kind string // ciKind
120 State string // ciState
121 Workflow string
122 Title string
123 Ref string
124 RefHref string
125 Short string
126 CHref string
127 Actor string
128 When string
129 WhenAt string
130 Duration string
131 Number string
132 Attempt int32
133}
134
135// ActionsPage lists the runs of a repository.
136type ActionsPage struct {
137 Runs []RunRow
138 // More is the link to the next page, "" on the last.
139 More string
140 // NoRunner says that CI has no runner to run on yet.
141 NoRunner bool
142}
143
144// StepView is a step of a job.
145type StepView struct {
146 Number int32
147 Name string
148 Kind, State string
149 Duration string
150 // LogHref jumps to the lines of the step in the job log.
151 LogStart, LogEnd int64
152}
153
154// JobRow is a job of a run.
155type JobRow struct {
156 ID, Name, Href string
157 Kind, State string
158 Runner string
159 Isolation string
160 Duration string
161 Error string
162 Needs []string
163 Steps []StepView
164 Attempt int32
165 // Waiting says that the job is queued for a runner that no registered
166 // runner can be.
167 Waiting bool
168}
169
170// RunPage is one run with its jobs.
171type RunPage struct {
172 RunRow
173 Sha, Error string
174 Attempt int32
175 Latest int32
176 Attempts []AttemptLink
177 Jobs []JobRow
178 // Annotations are those of the jobs of the run, AnnotationsMore says that
179 // the server holds more than it returned.
180 Annotations []AnnotationView
181 AnnotationsMore bool
182 // Summaries are the job summaries, rendered; only the full page has them.
183 Summaries []JobSummaries
184 // SummariesMore says that some summaries are not shown (the page budget).
185 SummariesMore bool
186 Active bool
187 // detail is what the page was built from.
188 detail ports.RunDetail
189 // Forms are the owner's actions; nil/empty when the visitor cannot.
190 CancelAction, RerunAction, RerunFailedAction string
191 CanRerunFailed bool
192}
193
194// AttemptLink switches between the attempts of a run.
195type AttemptLink struct {
196 Label, Href string
197 Current bool
198}
199
200// actions is GET /<repo>/actions.
201func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
202 d, err := s.loadRepo(ctx, r.PathValue("repo"))
203 if err != nil {
204 return nil, 0, err
205 }
206 token := r.URL.Query().Get("page")
207 page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token})
208 if err != nil {
209 return nil, 0, err
210 }
211 now := s.cfg.Now()
212 p := ActionsPage{}
213 for _, run := range page.Runs {
214 p.Runs = append(p.Runs, s.runRow(d.name(), run, now))
215 }
216 if page.NextPageToken != "" {
217 p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
218 }
219 l := s.repoLayout(ctx, d, "actions")
220 l.Title = "actions · " + d.name() + " · " + s.instance
221 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
222 l.Crumbs[0].Current = false
223 l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true})
224 return Actions(l, p), http.StatusOK, nil
225}
226
227func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
228 row := RunRow{
229 ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
230 Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
231 Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title),
232 Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
233 Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
234 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
235 }
236 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
237 row.When, row.WhenAt = whenOf(now, run.CreatedAt)
238 return row
239}
240
241func stepViews(j ports.JobView, now time.Time) []StepView {
242 ranges := j.StepRanges()
243 out := make([]StepView, 0, len(j.Steps))
244 for i, st := range j.Steps {
245 status := domain.CIQueued
246 switch st.State {
247 case domain.CIStepInProgress:
248 status = domain.CIInProgress
249 case domain.CIStepCompleted:
250 status = domain.CICompleted
251 }
252 out = append(out, StepView{
253 Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
254 Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
255 })
256 }
257 return out
258}
259
260func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
261 return JobRow{
262 ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
263 State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
264 Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
265 Steps: stepViews(j, now), Attempt: j.Attempt,
266 }
267}
268
269func parseRunPath(r *http.Request) (int64, error) {
270 id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
271 if err != nil || id < 1 {
272 return 0, errNotFound
273 }
274 return id, nil
275}
276
277// run is GET /<repo>/actions/runs/<id>.
278func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
279 d, err := s.loadRepo(ctx, r.PathValue("repo"))
280 if err != nil {
281 return nil, 0, err
282 }
283 id, err := parseRunPath(r)
284 if err != nil {
285 return nil, 0, err
286 }
287 var attempt int32
288 if a := r.URL.Query().Get("attempt"); a != "" {
289 n, perr := strconv.ParseInt(a, 10, 32)
290 if perr != nil || n < 1 {
291 return nil, 0, errNotFound
292 }
293 attempt = int32(n)
294 }
295 p, err := s.runPage(ctx, d, id, attempt)
296 if err != nil {
297 return nil, 0, err
298 }
299 // The summaries are part of the page, not of the live body: the page
300 // fetches itself again when the run completes (runBody), and they show then.
301 if p.Summaries, p.SummariesMore, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest, maxSummaryPageBytes); err != nil {
302 return nil, 0, err
303 }
304 l := s.repoLayout(ctx, d, "actions")
305 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
306 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
307 l.Crumbs[0].Current = false
308 l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
309 l.HasRail = true
310 return RunDetail(l, p), http.StatusOK, nil
311}
312
313//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
314func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
315 detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
316 if err != nil {
317 return RunPage{}, err
318 }
319 now := s.cfg.Now()
320 run := detail.Run
321 p := RunPage{
322 RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
323 Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, detail: detail,
324 }
325 for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
326 p.Attempts = append(p.Attempts, AttemptLink{
327 Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
328 })
329 }
330 for _, j := range detail.Jobs {
331 row := s.jobRow(d.name(), j, now)
332 if run.Attempt == detail.LatestAttempt {
333 // An earlier attempt keeps the job as it ran then.
334 row.Href = jobHref(d.name(), id, j.ID)
335 }
336 p.Jobs = append(p.Jobs, row)
337 }
338 // The annotations of the attempt shown (the jobs kept from an earlier attempt
339 // by a re-run bring their own).
340 if p.Annotations, p.AnnotationsMore, err = s.runAnnotations(ctx, d, run, run.Attempt, detail.Jobs, run.Attempt == detail.LatestAttempt); err != nil {
341 return RunPage{}, err
342 }
343 if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
344 base := runHref(d.name(), id)
345 if p.Active {
346 p.CancelAction = base + "/cancel"
347 } else if len(detail.Jobs) > 0 {
348 p.RerunAction = base + "/rerun"
349 for _, j := range detail.Jobs {
350 if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled {
351 p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true
352 break
353 }
354 }
355 }
356 }
357 return p, nil
358}
359
360// job is GET /<repo>/actions/runs/<id>/job/<job id>.
361func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) {
362 d, err := s.loadRepo(ctx, r.PathValue("repo"))
363 if err != nil {
364 return nil, 0, err
365 }
366 id, err := parseRunPath(r)
367 if err != nil {
368 return nil, 0, err
369 }
370 jobID := r.PathValue("job")
371 if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id {
372 return nil, 0, errNotFound
373 }
374 var attempt int32
375 if a := r.URL.Query().Get("attempt"); a != "" {
376 n, perr := strconv.ParseInt(a, 10, 32)
377 if perr != nil || n < 1 {
378 return nil, 0, errNotFound
379 }
380 attempt = int32(n)
381 }
382 view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
383 if err != nil {
384 return nil, 0, err
385 }
386 run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0)
387 if err != nil {
388 return nil, 0, err
389 }
390 now := s.cfg.Now()
391 var from int64
392 if f := r.URL.Query().Get("from"); f != "" {
393 from, err = strconv.ParseInt(f, 10, 64)
394 if err != nil || from < 0 {
395 return nil, 0, errNotFound
396 }
397 }
398 p := JobPage{
399 JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName),
400 Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines,
401 RawHref: jobHref(d.name(), id, jobID) + "/raw",
402 }
403 if attempt > 0 {
404 p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
405 }
406 p.Follow = !view.Ended()
407 if p.Annotations, p.AnnotationsMore, err = s.jobAnnotations(ctx, d, id, run.Run.Sha, view, attempt); err != nil {
408 return nil, 0, err
409 }
410 p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
411 lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
412 if err != nil {
413 return nil, 0, err
414 }
415 p.Lines = lines
416 if next < view.Lines {
417 p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
418 p.Follow = false // more is waiting before the live end
419 }
420 if from > 0 {
421 p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
422 }
423 if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
424 p.Note = "the job has not started: no log yet."
425 } else if view.Lines == 0 && view.Ended() {
426 p.Note = "the job wrote no log."
427 }
428 l := s.repoLayout(ctx, d, "actions")
429 l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
430 l.Live = true
431 l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
432 l.Crumbs[0].Current = false
433 l.Crumbs = append(l.Crumbs,
434 Crumb{Label: "actions", Href: actionsBase(d.name())},
435 Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
436 Crumb{Label: p.Name, Current: true})
437 l.HasRail = true
438 return Job(l, p), http.StatusOK, nil
439}
440
441// ciAction wraps a POST of the actions pages: the form gate, the bounded
442// context and the mapping of a refusal to a page.
443func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
444 return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
445 to, err := h(ctx, r)
446 return to, actionsBase(r.PathValue("repo")), err
447 })
448}
449
450func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
451 name, id, err := s.runTarget(r)
452 if err != nil {
453 return "", err
454 }
455 if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
456 return "", err
457 }
458 return runHref(name.String(), id), nil
459}
460
461func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
462 return func(ctx context.Context, r *http.Request) (string, error) {
463 name, id, err := s.runTarget(r)
464 if err != nil {
465 return "", err
466 }
467 if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
468 return "", err
469 }
470 return runHref(name.String(), id), nil
471 }
472}
473
474func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
475 name, err := domain.ParseRepoName(r.PathValue("repo"))
476 if err != nil {
477 return domain.RepoName{}, 0, errNotFound
478 }
479 id, err := parseRunPath(r)
480 return name, id, err
481}
482
483func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
484
485// runnerText names the runner of a job and how it isolated it.
486func runnerText(j JobRow) string {
487 if j.Isolation == "" {
488 return j.Runner
489 }
490 return j.Runner + " (" + j.Isolation + ")"
491}
492
493func joinWords(ws []string) string { return strings.Join(ws, ", ") }