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