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 if n, ok := pullNumber(run.Ref); ok {
238 // A pull_request run is for refs/pull/<n>/merge: it shows the merge request.
239 row.Ref, row.RefHref = "!"+strconv.FormatInt(n, 10), mrHref(repo, n)
240 }
241 row.When, row.WhenAt = whenOf(now, run.CreatedAt)
242 return row
243}
244
245func stepViews(j ports.JobView, now time.Time) []StepView {
246 ranges := j.StepRanges()
247 out := make([]StepView, 0, len(j.Steps))
248 for i, st := range j.Steps {
249 status := domain.CIQueued
250 switch st.State {
251 case domain.CIStepInProgress:
252 status = domain.CIInProgress
253 case domain.CIStepCompleted:
254 status = domain.CICompleted
255 }
256 out = append(out, StepView{
257 Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
258 Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
259 })
260 }
261 return out
262}
263
264func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
265 return JobRow{
266 ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
267 State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
268 Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
269 Steps: stepViews(j, now), Attempt: j.Attempt,
270 }
271}
272
273func parseRunPath(r *http.Request) (int64, error) {
274 id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
275 if err != nil || id < 1 {
276 return 0, errNotFound
277 }
278 return id, nil
279}
280
281// run is GET /<repo>/actions/runs/<id>.
282func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
283 d, err := s.loadRepo(ctx, r.PathValue("repo"))
284 if err != nil {
285 return nil, 0, err
286 }
287 id, err := parseRunPath(r)
288 if err != nil {
289 return nil, 0, err
290 }
291 var attempt int32
292 if a := r.URL.Query().Get("attempt"); a != "" {
293 n, perr := strconv.ParseInt(a, 10, 32)
294 if perr != nil || n < 1 {
295 return nil, 0, errNotFound
296 }
297 attempt = int32(n)
298 }
299 p, err := s.runPage(ctx, d, id, attempt)
300 if err != nil {
301 return nil, 0, err
302 }
303 // The summaries are part of the page, not of the live body: the page
304 // fetches itself again when the run completes (runBody), and they show then.
305 if p.Summaries, p.SummariesMore, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest, maxSummaryPageBytes); err != nil {
306 return nil, 0, err
307 }
308 l := s.repoLayout(ctx, d, "actions")
309 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
310 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
311 l.Crumbs[0].Current = false
312 l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
313 l.HasRail = true
314 return RunDetail(l, p), http.StatusOK, nil
315}
316
317//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
318func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
319 detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
320 if err != nil {
321 return RunPage{}, err
322 }
323 now := s.cfg.Now()
324 run := detail.Run
325 p := RunPage{
326 RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
327 Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, detail: detail,
328 }
329 for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
330 p.Attempts = append(p.Attempts, AttemptLink{
331 Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
332 })
333 }
334 for _, j := range detail.Jobs {
335 row := s.jobRow(d.name(), j, now)
336 if run.Attempt == detail.LatestAttempt {
337 // An earlier attempt keeps the job as it ran then.
338 row.Href = jobHref(d.name(), id, j.ID)
339 }
340 p.Jobs = append(p.Jobs, row)
341 }
342 // The annotations of the attempt shown (the jobs kept from an earlier attempt
343 // by a re-run bring their own).
344 if p.Annotations, p.AnnotationsMore, err = s.runAnnotations(ctx, d, run, run.Attempt, detail.Jobs, run.Attempt == detail.LatestAttempt); err != nil {
345 return RunPage{}, err
346 }
347 if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
348 base := runHref(d.name(), id)
349 if p.Active {
350 p.CancelAction = base + "/cancel"
351 } else if len(detail.Jobs) > 0 {
352 p.RerunAction = base + "/rerun"
353 for _, j := range detail.Jobs {
354 if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled {
355 p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true
356 break
357 }
358 }
359 }
360 }
361 return p, nil
362}
363
364// job is GET /<repo>/actions/runs/<id>/job/<job id>.
365func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) {
366 d, err := s.loadRepo(ctx, r.PathValue("repo"))
367 if err != nil {
368 return nil, 0, err
369 }
370 id, err := parseRunPath(r)
371 if err != nil {
372 return nil, 0, err
373 }
374 jobID := r.PathValue("job")
375 if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id {
376 return nil, 0, errNotFound
377 }
378 var attempt int32
379 if a := r.URL.Query().Get("attempt"); a != "" {
380 n, perr := strconv.ParseInt(a, 10, 32)
381 if perr != nil || n < 1 {
382 return nil, 0, errNotFound
383 }
384 attempt = int32(n)
385 }
386 view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
387 if err != nil {
388 return nil, 0, err
389 }
390 run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0)
391 if err != nil {
392 return nil, 0, err
393 }
394 now := s.cfg.Now()
395 var from int64
396 if f := r.URL.Query().Get("from"); f != "" {
397 from, err = strconv.ParseInt(f, 10, 64)
398 if err != nil || from < 0 {
399 return nil, 0, errNotFound
400 }
401 }
402 p := JobPage{
403 JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName),
404 Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines,
405 RawHref: jobHref(d.name(), id, jobID) + "/raw",
406 }
407 if attempt > 0 {
408 p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
409 }
410 p.Follow = !view.Ended()
411 if p.Annotations, p.AnnotationsMore, err = s.jobAnnotations(ctx, d, id, run.Run.Sha, view, attempt); err != nil {
412 return nil, 0, err
413 }
414 p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
415 lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
416 if err != nil {
417 return nil, 0, err
418 }
419 p.Lines = lines
420 if next < view.Lines {
421 p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
422 p.Follow = false // more is waiting before the live end
423 }
424 if from > 0 {
425 p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
426 }
427 if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
428 p.Note = "the job has not started: no log yet."
429 } else if view.Lines == 0 && view.Ended() {
430 p.Note = "the job wrote no log."
431 }
432 l := s.repoLayout(ctx, d, "actions")
433 l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
434 l.Live = true
435 l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
436 l.Crumbs[0].Current = false
437 l.Crumbs = append(l.Crumbs,
438 Crumb{Label: "actions", Href: actionsBase(d.name())},
439 Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
440 Crumb{Label: p.Name, Current: true})
441 l.HasRail = true
442 return Job(l, p), http.StatusOK, nil
443}
444
445// ciAction wraps a POST of the actions pages: the form gate, the bounded
446// context and the mapping of a refusal to a page.
447func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
448 return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
449 to, err := h(ctx, r)
450 return to, actionsBase(r.PathValue("repo")), err
451 })
452}
453
454func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
455 name, id, err := s.runTarget(r)
456 if err != nil {
457 return "", err
458 }
459 if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
460 return "", err
461 }
462 return runHref(name.String(), id), nil
463}
464
465func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
466 return func(ctx context.Context, r *http.Request) (string, error) {
467 name, id, err := s.runTarget(r)
468 if err != nil {
469 return "", err
470 }
471 if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
472 return "", err
473 }
474 return runHref(name.String(), id), nil
475 }
476}
477
478func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
479 name, err := domain.ParseRepoName(r.PathValue("repo"))
480 if err != nil {
481 return domain.RepoName{}, 0, errNotFound
482 }
483 id, err := parseRunPath(r)
484 return name, id, err
485}
486
487func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
488
489// runnerText names the runner of a job and how it isolated it.
490func runnerText(j JobRow) string {
491 if j.Isolation == "" {
492 return j.Runner
493 }
494 return j.Runner + " (" + j.Isolation + ")"
495}
496
497func joinWords(ws []string) string { return strings.Join(ws, ", ") }
498
499// pullNumber reads the merge request number of refs/pull/<n>/merge or /head.
500func pullNumber(ref string) (int64, bool) {
501 if !domain.IsPullRef(ref) {
502 return 0, false
503 }
504 num, _, _ := strings.Cut(strings.TrimPrefix(ref, domain.PullRefPrefix), "/")
505 n, err := strconv.ParseInt(num, 10, 64)
506 return n, err == nil
507}