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