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