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