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 Active bool
177 // Forms are the owner's actions; nil/empty when the visitor cannot.
178 CancelAction, RerunAction, RerunFailedAction string
179 CanRerunFailed bool
180}
181
182// AttemptLink switches between the attempts of a run.
183type AttemptLink struct {
184 Label, Href string
185 Current bool
186}
187
188// actions is GET /<repo>/actions.
189func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
190 d, err := s.loadRepo(ctx, r.PathValue("repo"))
191 if err != nil {
192 return nil, 0, err
193 }
194 token := r.URL.Query().Get("page")
195 page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token})
196 if err != nil {
197 return nil, 0, err
198 }
199 now := s.cfg.Now()
200 p := ActionsPage{}
201 for _, run := range page.Runs {
202 p.Runs = append(p.Runs, s.runRow(d.name(), run, now))
203 }
204 if page.NextPageToken != "" {
205 p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
206 }
207 l := s.repoLayout(ctx, d, "actions")
208 l.Title = "actions · " + d.name() + " · " + s.instance
209 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
210 l.Crumbs[0].Current = false
211 l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true})
212 return Actions(l, p), http.StatusOK, nil
213}
214
215func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
216 row := RunRow{
217 ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
218 Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
219 Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title),
220 Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
221 Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
222 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
223 }
224 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
225 row.When, row.WhenAt = whenOf(now, run.CreatedAt)
226 return row
227}
228
229func stepViews(j ports.JobView, now time.Time) []StepView {
230 ranges := j.StepRanges()
231 out := make([]StepView, 0, len(j.Steps))
232 for i, st := range j.Steps {
233 status := domain.CIQueued
234 switch st.State {
235 case domain.CIStepInProgress:
236 status = domain.CIInProgress
237 case domain.CIStepCompleted:
238 status = domain.CICompleted
239 }
240 out = append(out, StepView{
241 Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
242 Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
243 })
244 }
245 return out
246}
247
248func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
249 return JobRow{
250 ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
251 State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
252 Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
253 Steps: stepViews(j, now), Attempt: j.Attempt,
254 }
255}
256
257func parseRunPath(r *http.Request) (int64, error) {
258 id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
259 if err != nil || id < 1 {
260 return 0, errNotFound
261 }
262 return id, nil
263}
264
265// run is GET /<repo>/actions/runs/<id>.
266func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
267 d, err := s.loadRepo(ctx, r.PathValue("repo"))
268 if err != nil {
269 return nil, 0, err
270 }
271 id, err := parseRunPath(r)
272 if err != nil {
273 return nil, 0, err
274 }
275 var attempt int32
276 if a := r.URL.Query().Get("attempt"); a != "" {
277 n, perr := strconv.ParseInt(a, 10, 32)
278 if perr != nil || n < 1 {
279 return nil, 0, errNotFound
280 }
281 attempt = int32(n)
282 }
283 p, err := s.runPage(ctx, d, id, attempt)
284 if err != nil {
285 return nil, 0, err
286 }
287 l := s.repoLayout(ctx, d, "actions")
288 l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
289 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
290 l.Crumbs[0].Current = false
291 l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
292 l.HasRail = true
293 return RunDetail(l, p), http.StatusOK, nil
294}
295
296//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
297func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
298 detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
299 if err != nil {
300 return RunPage{}, err
301 }
302 now := s.cfg.Now()
303 run := detail.Run
304 p := RunPage{
305 RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
306 Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted,
307 }
308 for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
309 p.Attempts = append(p.Attempts, AttemptLink{
310 Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
311 })
312 }
313 for _, j := range detail.Jobs {
314 row := s.jobRow(d.name(), j, now)
315 if run.Attempt == detail.LatestAttempt {
316 // An earlier attempt keeps the job as it ran then.
317 row.Href = jobHref(d.name(), id, j.ID)
318 }
319 p.Jobs = append(p.Jobs, row)
320 }
321 if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
322 base := runHref(d.name(), id)
323 if p.Active {
324 p.CancelAction = base + "/cancel"
325 } else if len(detail.Jobs) > 0 {
326 p.RerunAction = base + "/rerun"
327 for _, j := range detail.Jobs {
328 if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled {
329 p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true
330 break
331 }
332 }
333 }
334 }
335 return p, nil
336}
337
338// job is GET /<repo>/actions/runs/<id>/job/<job id>.
339func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) {
340 d, err := s.loadRepo(ctx, r.PathValue("repo"))
341 if err != nil {
342 return nil, 0, err
343 }
344 id, err := parseRunPath(r)
345 if err != nil {
346 return nil, 0, err
347 }
348 jobID := r.PathValue("job")
349 if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id {
350 return nil, 0, errNotFound
351 }
352 var attempt int32
353 if a := r.URL.Query().Get("attempt"); a != "" {
354 n, perr := strconv.ParseInt(a, 10, 32)
355 if perr != nil || n < 1 {
356 return nil, 0, errNotFound
357 }
358 attempt = int32(n)
359 }
360 view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
361 if err != nil {
362 return nil, 0, err
363 }
364 run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0)
365 if err != nil {
366 return nil, 0, err
367 }
368 now := s.cfg.Now()
369 var from int64
370 if f := r.URL.Query().Get("from"); f != "" {
371 from, err = strconv.ParseInt(f, 10, 64)
372 if err != nil || from < 0 {
373 return nil, 0, errNotFound
374 }
375 }
376 p := JobPage{
377 JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName),
378 Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines,
379 RawHref: jobHref(d.name(), id, jobID) + "/raw",
380 }
381 if attempt > 0 {
382 p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
383 }
384 p.Follow = !view.Ended()
385 p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
386 lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
387 if err != nil {
388 return nil, 0, err
389 }
390 p.Lines = lines
391 if next < view.Lines {
392 p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
393 p.Follow = false // more is waiting before the live end
394 }
395 if from > 0 {
396 p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
397 }
398 if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
399 p.Note = "the job has not started: no log yet."
400 } else if view.Lines == 0 && view.Ended() {
401 p.Note = "the job wrote no log."
402 }
403 l := s.repoLayout(ctx, d, "actions")
404 l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
405 l.Live = true
406 l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
407 l.Crumbs[0].Current = false
408 l.Crumbs = append(l.Crumbs,
409 Crumb{Label: "actions", Href: actionsBase(d.name())},
410 Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
411 Crumb{Label: p.Name, Current: true})
412 l.HasRail = true
413 return Job(l, p), http.StatusOK, nil
414}
415
416// ciAction wraps a POST of the actions pages: the form gate, the bounded
417// context and the mapping of a refusal to a page.
418func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
419 return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
420 to, err := h(ctx, r)
421 return to, actionsBase(r.PathValue("repo")), err
422 })
423}
424
425func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
426 name, id, err := s.runTarget(r)
427 if err != nil {
428 return "", err
429 }
430 if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
431 return "", err
432 }
433 return runHref(name.String(), id), nil
434}
435
436func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
437 return func(ctx context.Context, r *http.Request) (string, error) {
438 name, id, err := s.runTarget(r)
439 if err != nil {
440 return "", err
441 }
442 if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
443 return "", err
444 }
445 return runHref(name.String(), id), nil
446 }
447}
448
449func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
450 name, err := domain.ParseRepoName(r.PathValue("repo"))
451 if err != nil {
452 return domain.RepoName{}, 0, errNotFound
453 }
454 id, err := parseRunPath(r)
455 return name, id, err
456}
457
458func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
459
460// runnerText names the runner of a job and how it isolated it.
461func runnerText(j JobRow) string {
462 if j.Isolation == "" {
463 return j.Runner
464 }
465 return j.Runner + " (" + j.Isolation + ")"
466}
467
468func joinWords(ws []string) string { return strings.Join(ws, ", ") }