detached 1769e4f

feat(ci): add the Actions pages, live job logs and check marks

/<repo>/actions lists the runs, /<repo>/actions/runs/<id> shows a run
with its jobs and steps (attempt switcher, cancel and re-run for the
owner), and the job page shows the steps and the log with folded groups,
a raw log link and a live feed of the lines over Server-Sent Events. The
repository event stream gets the views actions, run and job: they carry
states only, never log lines. The commit page, the commit and refs lists
and the merge request page show the checks of their commit, and the merge
request rail says when a job waits for a runner. The web reads and writes
through ports.RunService only.
commit
1769e4f987d0ec226c85f70fe6f2e07a0d129f39
author
brice · · 2026-10-12 18:39 UTC
parent
1bd4fe7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
54 files · +4219 -261
A internal/adapters/web/actions.go +824 -0
@@ -0,0 +1,824 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "github.com/a-h/templ"
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/clientlimit"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// The CI pages (ADR 0025): the runs of a repository, one run with its jobs,
24+// one job with its steps and its log. They read through ports.RunService and
25+// follow the events RunUpdated and JobUpdated of the repository topic.
26+//
27+// GET /<repo>/actions the runs, newest first
28+// GET /<repo>/actions/runs/<id> one run: jobs and steps
29+// GET /<repo>/actions/runs/<id>/job/<job id> one job: steps and live log
30+// GET /<repo>/actions/runs/<id>/job/<job id>/raw the log as text
31+// POST /<repo>/actions/runs/<id>/cancel | rerun | rerun-failed (the owner)
32+
33+const (
34+ // actionsPageSize is the number of runs one page lists.
35+ actionsPageSize = 30
36+ // jobLogPageLines is the number of log lines a job page shows at once; the
37+ // rest is reached with ?from=.
38+ jobLogPageLines = 2000
39+ // rawLogMaxBytes bounds the raw log the server writes to one response.
40+ rawLogMaxBytes = 64 << 20
41+)
42+
43+func actionsBase(repo string) string { return repoHref(repo, "actions") }
44+func runHref(repo string, id int64) string {
45+ return repoHref(repo, "actions", "runs", strconv.FormatInt(id, 10))
46+}
47+func jobHref(repo string, runID int64, jobID string) string {
48+ return runHref(repo, runID) + "/job/" + jobID
49+}
50+
51+// ciState is the one word shown for a status and a conclusion: what is going
52+// on, or how it ended.
53+func ciState(status domain.CIStatus, c domain.CIConclusion) string {
54+ switch status {
55+ case domain.CIQueued:
56+ return "queued"
57+ case domain.CIInProgress:
58+ return "running"
59+ }
60+ switch c {
61+ case domain.CISuccess:
62+ return "success"
63+ case domain.CIFailure:
64+ return "failure"
65+ case domain.CICancelled:
66+ return "cancelled"
67+ case domain.CISkipped:
68+ return "skipped"
69+ case domain.CITimedOut:
70+ return "timed out"
71+ case domain.CINeutral:
72+ return "neutral"
73+ case domain.CIActionRequired:
74+ return "action required"
75+ }
76+ return "done"
77+}
78+
79+// ciKind groups the states into the few that have an icon and a colour.
80+func ciKind(status domain.CIStatus, c domain.CIConclusion) string {
81+ switch {
82+ case status == domain.CIQueued:
83+ return "queued"
84+ case status == domain.CIInProgress:
85+ return "running"
86+ case c == domain.CISuccess, c == domain.CINeutral:
87+ return "ok"
88+ case c == domain.CISkipped:
89+ return "skipped"
90+ case c == domain.CICancelled:
91+ return "cancelled"
92+ }
93+ return "bad"
94+}
95+
96+// duration is a short elapsed time between two instants; empty when the
97+// start is unknown, running up to now when the end is not.
98+func duration(start, end, now time.Time) string {
99+ if start.IsZero() {
100+ return ""
101+ }
102+ if end.IsZero() {
103+ end = now
104+ }
105+ d := end.Sub(start).Round(time.Second)
106+ switch {
107+ case d < 0:
108+ return ""
109+ case d < time.Minute:
110+ return fmt.Sprintf("%ds", int(d.Seconds()))
111+ case d < time.Hour:
112+ return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
113+ }
114+ return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60)
115+}
116+
117+// RunRow is a run of the list.
118+type RunRow struct {
119+ ID string
120+ Href string
121+ Kind string // ciKind
122+ State string // ciState
123+ Workflow string
124+ Title string
125+ Ref string
126+ RefHref string
127+ Short string
128+ CHref string
129+ Actor string
130+ When string
131+ WhenAt string
132+ Duration string
133+ Number string
134+ Attempt int32
135+}
136+
137+// ActionsPage lists the runs of a repository.
138+type ActionsPage struct {
139+ Runs []RunRow
140+ // More is the link to the next page, "" on the last.
141+ More string
142+ // NoRunner says that CI has no runner to run on yet.
143+ NoRunner bool
144+}
145+
146+// StepView is a step of a job.
147+type StepView struct {
148+ Number int32
149+ Name string
150+ Kind, State string
151+ Duration string
152+ // LogHref jumps to the lines of the step in the job log.
153+ LogStart, LogEnd int64
154+}
155+
156+// JobRow is a job of a run.
157+type JobRow struct {
158+ ID, Name, Href string
159+ Kind, State string
160+ Runner string
161+ Isolation string
162+ Duration string
163+ Error string
164+ Needs []string
165+ Steps []StepView
166+ Attempt int32
167+ // Waiting says that the job is queued for a runner that no registered
168+ // runner can be.
169+ Waiting bool
170+}
171+
172+// RunPage is one run with its jobs.
173+type RunPage struct {
174+ RunRow
175+ Sha, Error string
176+ Attempt int32
177+ Latest int32
178+ Attempts []AttemptLink
179+ Jobs []JobRow
180+ Active bool
181+ // Forms are the owner's actions; nil/empty when the visitor cannot.
182+ CancelAction, RerunAction, RerunFailedAction string
183+ CanRerunFailed bool
184+}
185+
186+// AttemptLink switches between the attempts of a run.
187+type AttemptLink struct {
188+ Label, Href string
189+ Current bool
190+}
191+
192+// LogLineView is a line of a job log, as shown.
193+type LogLineView struct {
194+ N int64
195+ Step int32
196+ Text string
197+ Time string
198+ // Group marks "##[group]" and "##[endgroup]" lines and Error the
199+ // "##[error]" ones; the text is shown without the marker.
200+ Group, End, Error bool
201+}
202+
203+// JobPage is one job with its steps and its log.
204+type JobPage struct {
205+ JobRow
206+ RunHref string
207+ RunName string
208+ Run string
209+ Steps []StepView
210+ Lines []LogLineView
211+ // From is the first line shown; Next is the link to the lines after the
212+ // last shown, "" when it is the end; Follow says the log is still being
213+ // written, so the page follows it live.
214+ From int64
215+ Next string
216+ Prev string
217+ Total int64
218+ Follow bool
219+ RawHref string
220+ // StreamHref is the live feed of the lines (logStream).
221+ StreamHref string
222+ Note string
223+}
224+
225+// --- handlers ---
226+
227+// actions is GET /<repo>/actions.
228+func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
229+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
230+ if err != nil {
231+ return nil, 0, err
232+ }
233+ token := r.URL.Query().Get("page")
234+ page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token})
235+ if err != nil {
236+ return nil, 0, err
237+ }
238+ now := s.cfg.Now()
239+ p := ActionsPage{}
240+ for _, run := range page.Runs {
241+ p.Runs = append(p.Runs, s.runRow(d.name(), run, now))
242+ }
243+ if page.NextPageToken != "" {
244+ p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
245+ }
246+ l := s.repoLayout(ctx, d, "actions")
247+ l.Title = "actions · " + d.name() + " · " + s.instance
248+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
249+ l.Crumbs[0].Current = false
250+ l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true})
251+ return Actions(l, p), http.StatusOK, nil
252+}
253+
254+func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
255+ row := RunRow{
256+ ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
257+ Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
258+ Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title),
259+ Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
260+ Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
261+ Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
262+ }
263+ row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
264+ row.When, row.WhenAt = whenOf(now, run.CreatedAt)
265+ return row
266+}
267+
268+func stepViews(j ports.JobView, now time.Time) []StepView {
269+ ranges := j.StepRanges()
270+ out := make([]StepView, 0, len(j.Steps))
271+ for i, st := range j.Steps {
272+ status := domain.CIQueued
273+ switch st.State {
274+ case domain.CIStepInProgress:
275+ status = domain.CIInProgress
276+ case domain.CIStepCompleted:
277+ status = domain.CICompleted
278+ }
279+ out = append(out, StepView{
280+ Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
281+ Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
282+ })
283+ }
284+ return out
285+}
286+
287+func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
288+ return JobRow{
289+ ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
290+ State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
291+ Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
292+ Steps: stepViews(j, now), Attempt: j.Attempt,
293+ }
294+}
295+
296+func parseRunPath(r *http.Request) (int64, error) {
297+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
298+ if err != nil || id < 1 {
299+ return 0, errNotFound
300+ }
301+ return id, nil
302+}
303+
304+// run is GET /<repo>/actions/runs/<id>.
305+func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
306+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
307+ if err != nil {
308+ return nil, 0, err
309+ }
310+ id, err := parseRunPath(r)
311+ if err != nil {
312+ return nil, 0, err
313+ }
314+ var attempt int32
315+ if a := r.URL.Query().Get("attempt"); a != "" {
316+ n, perr := strconv.ParseInt(a, 10, 32)
317+ if perr != nil || n < 1 {
318+ return nil, 0, errNotFound
319+ }
320+ attempt = int32(n)
321+ }
322+ p, err := s.runPage(ctx, d, id, attempt)
323+ if err != nil {
324+ return nil, 0, err
325+ }
326+ l := s.repoLayout(ctx, d, "actions")
327+ l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
328+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
329+ l.Crumbs[0].Current = false
330+ l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
331+ l.HasRail = true
332+ return RunDetail(l, p), http.StatusOK, nil
333+}
334+
335+//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
336+func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
337+ detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
338+ if err != nil {
339+ return RunPage{}, err
340+ }
341+ now := s.cfg.Now()
342+ run := detail.Run
343+ p := RunPage{
344+ RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
345+ Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted,
346+ }
347+ for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
348+ p.Attempts = append(p.Attempts, AttemptLink{
349+ Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
350+ })
351+ }
352+ for _, j := range detail.Jobs {
353+ row := s.jobRow(d.name(), j, now)
354+ if run.Attempt == detail.LatestAttempt {
355+ // An earlier attempt keeps the job as it ran then.
356+ row.Href = jobHref(d.name(), id, j.ID)
357+ }
358+ p.Jobs = append(p.Jobs, row)
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>.
378+func (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+ p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
425+ lines, next, err := s.readLog(ctx, d.repo.Name, view, from, jobLogPageLines)
426+ if err != nil {
427+ return nil, 0, err
428+ }
429+ p.Lines = lines
430+ if next < view.Lines {
431+ p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
432+ p.Follow = false // more is waiting before the live end
433+ }
434+ if from > 0 {
435+ p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
436+ }
437+ if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
438+ p.Note = "the job has not started: no log yet."
439+ } else if view.Lines == 0 && view.Ended() {
440+ p.Note = "the job wrote no log."
441+ }
442+ l := s.repoLayout(ctx, d, "actions")
443+ l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
444+ l.Live = true
445+ l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
446+ l.Crumbs[0].Current = false
447+ l.Crumbs = append(l.Crumbs,
448+ Crumb{Label: "actions", Href: actionsBase(d.name())},
449+ Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
450+ Crumb{Label: p.Name, Current: true})
451+ l.HasRail = true
452+ return Job(l, p), http.StatusOK, nil
453+}
454+
455+// readLog reads up to max lines from a line number, through the log stream of
456+// the use case, and returns the line after the last read.
457+func (s *Server) readLog(ctx context.Context, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
458+ if from > j.Lines || j.Lines == 0 {
459+ return nil, from, nil
460+ }
461+ var out []LogLineView
462+ next := from
463+ ranges := j.StepRanges()
464+ stepOf := func(n int64) int32 {
465+ for i, r := range ranges {
466+ if n >= r.Start && n < r.End {
467+ return j.Steps[i].Number
468+ }
469+ }
470+ return 0
471+ }
472+ stop := errors.New("enough")
473+ err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from}, func(b ports.LogBatch) error {
474+ for i, l := range b.Lines {
475+ if len(out) >= max {
476+ return stop
477+ }
478+ n := b.Offset + int64(i)
479+ text := l.Text
480+ v := LogLineView{N: n, Step: stepOf(n), Time: l.Time.UTC().Format("15:04:05")}
481+ switch {
482+ case strings.HasPrefix(text, "##[group]"):
483+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
484+ case strings.HasPrefix(text, "##[endgroup]"):
485+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
486+ case strings.HasPrefix(text, "##[error]"):
487+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
488+ }
489+ if l.Continued && len(out) > 0 {
490+ out[len(out)-1].Text += visibleText(text)
491+ next = n + 1
492+ continue
493+ }
494+ v.Text = visibleText(text)
495+ out = append(out, v)
496+ next = n + 1
497+ }
498+ return nil
499+ })
500+ if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
501+ return nil, from, err
502+ }
503+ return out, next, nil
504+}
505+
506+// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
507+// one line per line with its time, joined where the runner cut a long line.
508+func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
509+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
510+ defer cancel()
511+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
512+ if err != nil {
513+ s.fail(ctx, w, r, err)
514+ return
515+ }
516+ id, err := parseRunPath(r)
517+ jobID := r.PathValue("job")
518+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
519+ s.fail(ctx, w, r, errNotFound)
520+ return
521+ }
522+ var attempt int32
523+ if a := r.URL.Query().Get("attempt"); a != "" {
524+ n, perr := strconv.ParseInt(a, 10, 32)
525+ if perr != nil || n < 1 {
526+ s.fail(ctx, w, r, errNotFound)
527+ return
528+ }
529+ attempt = int32(n)
530+ }
531+ view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
532+ if err != nil {
533+ s.fail(ctx, w, r, err)
534+ return
535+ }
536+ h := w.Header()
537+ h.Set("Content-Type", "text/plain; charset=utf-8")
538+ h.Set("X-Content-Type-Options", "nosniff")
539+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
540+ h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
541+ cacheHeaders(ctx, h, "no-cache", sessionCache)
542+ written := 0
543+ open := false
544+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt}, func(b ports.LogBatch) error {
545+ for _, l := range b.Lines {
546+ if written > rawLogMaxBytes {
547+ return errors.New("log too large")
548+ }
549+ var line string
550+ switch {
551+ case l.Continued && open:
552+ line = l.Text
553+ case open:
554+ line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
555+ default:
556+ line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
557+ }
558+ open = true
559+ n, werr := fmt.Fprint(w, line)
560+ written += n
561+ if werr != nil {
562+ return werr
563+ }
564+ }
565+ return nil
566+ })
567+ if open {
568+ _, _ = fmt.Fprintln(w)
569+ }
570+ if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
571+ _ = view
572+ w.WriteHeader(http.StatusNotFound)
573+ }
574+}
575+
576+// --- the owner's actions ---
577+
578+// ciAction wraps a POST of the actions pages: the form gate, the bounded
579+// context and the mapping of a refusal to a page.
580+func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
581+ return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
582+ to, err := h(ctx, r)
583+ return to, actionsBase(r.PathValue("repo")), err
584+ })
585+}
586+
587+func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
588+ name, id, err := s.runTarget(r)
589+ if err != nil {
590+ return "", err
591+ }
592+ if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
593+ return "", err
594+ }
595+ return runHref(name.String(), id), nil
596+}
597+
598+func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
599+ return func(ctx context.Context, r *http.Request) (string, error) {
600+ name, id, err := s.runTarget(r)
601+ if err != nil {
602+ return "", err
603+ }
604+ if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
605+ return "", err
606+ }
607+ return runHref(name.String(), id), nil
608+ }
609+}
610+
611+func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
612+ name, err := domain.ParseRepoName(r.PathValue("repo"))
613+ if err != nil {
614+ return domain.RepoName{}, 0, errNotFound
615+ }
616+ id, err := parseRunPath(r)
617+ return name, id, err
618+}
619+
620+func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
621+
622+// runnerText names the runner of a job and how it isolated it.
623+func runnerText(j JobRow) string {
624+ if j.Isolation == "" {
625+ return j.Runner
626+ }
627+ return j.Runner + " (" + j.Isolation + ")"
628+}
629+
630+func joinWords(ws []string) string { return strings.Join(ws, ", ") }
631+
632+// logClass is the class of a line of the log.
633+func logClass(ln LogLineView) string {
634+ switch {
635+ case ln.Group:
636+ return "lg group"
637+ case ln.End:
638+ return "lg endgroup"
639+ case ln.Error:
640+ return "lg error"
641+ }
642+ return "lg"
643+}
644+
645+// ChecksSummary is the combined state of the checks of a commit.
646+type ChecksSummary struct {
647+ Total int
648+ Kind, State string
649+ Label, Title string
650+ Href string
651+}
652+
653+// CheckRow is a check of a commit or a merge request.
654+type CheckRow struct {
655+ Name, Kind, State, Href string
656+ Waiting bool
657+}
658+
659+// ChecksSection is the checks of the head of a merge request.
660+type ChecksSection struct {
661+ Rows []CheckRow
662+ // Note explains the rule that applies to them.
663+ Note string
664+}
665+
666+// checksFor reads the checks of a commit as the caller of the request. A page
667+// shows them when it can: a failure of the read is logged and the page goes on
668+// without, since the code and the review do not depend on CI.
669+func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView {
670+ if s.cfg.Runs == nil || sha == "" {
671+ return nil
672+ }
673+ res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha)
674+ if err != nil {
675+ if !errors.Is(err, domain.ErrNotFound) {
676+ log := zerowrap.FromCtx(ctx)
677+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit")
678+ }
679+ return nil
680+ }
681+ return res.Checks
682+}
683+
684+// checksSummary reduces checks to the mark a list shows next to a commit.
685+func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
686+ if len(checks) == 0 {
687+ return ChecksSummary{}
688+ }
689+ plain := make([]domain.CICheck, 0, len(checks))
690+ passed := 0
691+ for _, c := range checks {
692+ plain = append(plain, c.CICheck)
693+ if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() {
694+ passed++
695+ }
696+ }
697+ sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))}
698+ switch domain.CombineChecks(plain) {
699+ case domain.CheckStateSuccess:
700+ sum.Kind, sum.State = "ok", "checks passed"
701+ case domain.CheckStateFailure:
702+ sum.Kind, sum.State = "bad", "checks failed"
703+ default:
704+ sum.Kind, sum.State = "running", "checks pending"
705+ }
706+ sum.Title = sum.State + ": " + sum.Label
707+ return sum
708+}
709+
710+// checksSection is the checks of the rail of a merge request, with the rule of
711+// the repository that applies to them.
712+func checksSection(repo domain.Repo, checks []ports.CheckView, now time.Time) ChecksSection {
713+ sec := ChecksSection{}
714+ for _, c := range checks {
715+ row := CheckRow{
716+ Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
717+ Waiting: c.WaitingForRunner,
718+ }
719+ if c.JobID != "" {
720+ row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
721+ } else {
722+ row.Href = runHref(repo.Name.String(), c.RunID)
723+ }
724+ sec.Rows = append(sec.Rows, row)
725+ }
726+ switch {
727+ case len(checks) == 0:
728+ sec.Note = "no check on this commit: nothing blocks the merge."
729+ case repo.Settings.FailingChecksBlock:
730+ sec.Note = "a failing or unfinished check blocks the merge."
731+ default:
732+ sec.Note = "checks do not block the merge in this repository."
733+ }
734+ _ = now
735+ return sec
736+}
737+
738+// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
739+// lines of a job log from line N as Server-Sent Events, following the job while
740+// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
741+// the number of the next line as its id, so that a reconnection (Last-Event-ID)
742+// goes on where it stopped. "ci-end" tells the job is over and every line was
743+// sent. The job events of the repository stream carry no line (see events.go).
744+func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
745+ ctx, cancel := context.WithCancel(r.Context())
746+ defer cancel()
747+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
748+ if err != nil {
749+ s.denyEvents(ctx, w, err)
750+ return
751+ }
752+ id, err := parseRunPath(r)
753+ jobID := r.PathValue("job")
754+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
755+ s.denyEvents(ctx, w, errNotFound)
756+ return
757+ }
758+ from := int64(0)
759+ if v := r.Header.Get("Last-Event-ID"); v != "" {
760+ from, err = strconv.ParseInt(v, 10, 64)
761+ } else if v := r.URL.Query().Get("from"); v != "" {
762+ from, err = strconv.ParseInt(v, 10, 64)
763+ }
764+ if err != nil || from < 0 {
765+ s.denyEvents(ctx, w, errNotFound)
766+ return
767+ }
768+ view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0)
769+ if err != nil {
770+ s.denyEvents(ctx, w, err)
771+ return
772+ }
773+
774+ rc := http.NewResponseController(w)
775+ hdr := w.Header()
776+ hdr.Set("Content-Type", "text/event-stream")
777+ cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
778+ hdr.Set("X-Accel-Buffering", "no")
779+ w.WriteHeader(http.StatusOK)
780+ out := &sseWriter{w: w, rc: rc}
781+ if out.raw(": connected\n\n") != nil {
782+ return
783+ }
784+ ranges := view.StepRanges()
785+ _ = ranges
786+ n := from
787+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
788+ JobID: jobID, Offset: from, Follow: true,
789+ Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
790+ }, func(b ports.LogBatch) error {
791+ var html strings.Builder
792+ for i, l := range b.Lines {
793+ v := LogLineView{N: b.Offset + int64(i), Time: l.Time.UTC().Format("15:04:05"), Step: l.Step}
794+ text := l.Text
795+ switch {
796+ case strings.HasPrefix(text, "##[group]"):
797+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
798+ case strings.HasPrefix(text, "##[endgroup]"):
799+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
800+ case strings.HasPrefix(text, "##[error]"):
801+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
802+ }
803+ v.Text = visibleText(text)
804+ if l.Continued {
805+ continue // joined to its line by the page reload; rare and harmless
806+ }
807+ var b2 strings.Builder
808+ if err := LogLine(v).Render(ctx, &b2); err != nil {
809+ return err
810+ }
811+ html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
812+ }
813+ n = b.Offset + int64(len(b.Lines))
814+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
815+ _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
816+ if werr != nil {
817+ return werr
818+ }
819+ return rc.Flush()
820+ })
821+ if err == nil {
822+ _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
823+ }
824+}
A internal/adapters/web/actions.templ +278 -0
@@ -0,0 +1,278 @@
1+package web
2+
3+// The CI pages: the runs of a repository, a run with its jobs, a job with its
4+// steps and its log (actions.go). A state is always a word as well as a
5+// symbol and a colour.
6+
7+// CheckIcon is the symbol of a state: a mark in the colour of its kind, with
8+// the state as its text for a screen reader.
9+templ CheckIcon(kind, state string) {
10+ <span class={ "ck", "ck-" + kind } role="img" aria-label={ state } title={ state }></span>
11+}
12+
13+templ Actions(l Layout, p ActionsPage) {
14+ @Shell(l, nil) {
15+ if len(p.Runs) == 0 {
16+ <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>
17+ } else {
18+ <table class="list runs" aria-label="Runs">
19+ <thead>
20+ <tr><th class="c-st"></th><th class="c-name">workflow</th><th class="c-sub">commit</th><th class="c-ref">ref</th><th class="c-who">actor</th><th class="c-dur">time</th><th class="c-when">created</th></tr>
21+ </thead>
22+ <tbody id="run-rows" data-rows>
23+ for _, r := range p.Runs {
24+ @RunListRow(r)
25+ }
26+ </tbody>
27+ </table>
28+ }
29+ if p.More != "" {
30+ <p class="more"><a href={ templ.SafeURL(p.More) } rel="next">older runs</a></p>
31+ }
32+ }
33+}
34+
35+// RunListRow is a row of the list of runs; its id is the target of the live
36+// updates.
37+templ RunListRow(r RunRow) {
38+ <tr id={ "run-" + r.ID }>
39+ <td class="c-st">@CheckIcon(r.Kind, r.State)</td>
40+ <td class="c-name">
41+ <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a>
42+ <span class="mute">{ r.Number }</span>
43+ if r.Attempt > 1 {
44+ <span class="pill">attempt { itoa(int(r.Attempt)) }</span>
45+ }
46+ </td>
47+ <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td>
48+ <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td>
49+ <td class="c-who dim">{ r.Actor }</td>
50+ <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td>
51+ <td class="c-when dim">
52+ if r.WhenAt != "" {
53+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
54+ }
55+ </td>
56+ </tr>
57+}
58+
59+templ RunDetail(l Layout, p RunPage) {
60+ @Shell(l, runRail(p)) {
61+ @LiveSlot("run-notice")
62+ <div id="run-body">
63+ @RunBody(p)
64+ </div>
65+ }
66+}
67+
68+// runBodyFragment is the body of the run page as a live fragment.
69+templ runBodyFragment(p RunPage) {
70+ <div id="run-body">
71+ @RunBody(p)
72+ </div>
73+}
74+
75+// RunBody is the jobs of a run, replaced by the live updates.
76+templ RunBody(p RunPage) {
77+ <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2>
78+ if p.Error != "" {
79+ <pre class="run-err">{ p.Error }</pre>
80+ }
81+ <ul class="jobs">
82+ for _, j := range p.Jobs {
83+ <li id={ "job-" + j.ID }>
84+ <a class="job-h" href={ templ.SafeURL(j.Href) }>
85+ @CheckIcon(j.Kind, j.State)
86+ <span class="j-name">{ j.Name }</span>
87+ <span class="dim">{ j.State }{ " " }{ j.Duration }</span>
88+ </a>
89+ if j.Runner != "" {
90+ <span class="mute">on { runnerText(j) }</span>
91+ }
92+ if len(j.Needs) > 0 {
93+ <span class="mute">needs { joinWords(j.Needs) }</span>
94+ }
95+ if j.Error != "" {
96+ <p class="job-err">{ j.Error }</p>
97+ }
98+ if len(j.Steps) > 0 {
99+ <ol class="steps">
100+ for _, s := range j.Steps {
101+ <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li>
102+ }
103+ </ol>
104+ }
105+ </li>
106+ }
107+ </ul>
108+}
109+
110+templ runRail(p RunPage) {
111+ <section aria-labelledby="r-run">
112+ <h2 id="r-run">Run</h2>
113+ <dl class="kv">
114+ <dt>result</dt>
115+ <dd>{ p.State }</dd>
116+ <dt>commit</dt>
117+ <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd>
118+ <dt>ref</dt>
119+ <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd>
120+ <dt>actor</dt>
121+ <dd>{ p.Actor }</dd>
122+ <dt>attempt</dt>
123+ <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd>
124+ </dl>
125+ if len(p.Attempts) > 0 {
126+ <ul class="attempts">
127+ for _, a := range p.Attempts {
128+ <li>
129+ if a.Current {
130+ <span aria-current="page">{ a.Label }</span>
131+ } else {
132+ <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a>
133+ }
134+ </li>
135+ }
136+ </ul>
137+ }
138+ </section>
139+ if p.CancelAction != "" || p.RerunAction != "" {
140+ <section aria-labelledby="r-act" class="decide">
141+ <h2 id="r-act">Actions</h2>
142+ <div class="d-btns">
143+ if p.CancelAction != "" {
144+ <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form>
145+ }
146+ if p.RerunAction != "" {
147+ <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form>
148+ }
149+ if p.CanRerunFailed {
150+ <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form>
151+ }
152+ </div>
153+ </section>
154+ }
155+}
156+
157+// JobHead is the title of the job page with its state and its error, replaced
158+// by the live updates. total is the number of lines the log holds.
159+templ JobHead(j JobRow, total int64) {
160+ <div id="job-head" data-total={ itoa64(total) }>
161+ <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2>
162+ if j.Error != "" {
163+ <p class="job-err">{ j.Error }</p>
164+ }
165+ </div>
166+}
167+
168+// JobSteps is the steps of the job in the rail, replaced by the live updates.
169+templ JobSteps(steps []StepView) {
170+ <ol class="steps" id="job-steps">
171+ for _, s := range steps {
172+ <li>
173+ @CheckIcon(s.Kind, s.State)
174+ if s.LogEnd > s.LogStart {
175+ <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a>
176+ } else {
177+ <span>{ s.Name }</span>
178+ }
179+ <span class="dim">{ s.Duration }</span>
180+ </li>
181+ }
182+ </ol>
183+}
184+
185+templ Job(l Layout, p JobPage) {
186+ @Shell(l, jobRail(p)) {
187+ @LiveSlot("job-notice")
188+ @JobHead(p.JobRow, p.Total)
189+ <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }>
190+ if p.Note != "" {
191+ <p class="dim">{ p.Note }</p>
192+ }
193+ if p.Prev != "" {
194+ <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p>
195+ }
196+ <ol class="log" start={ itoa64(p.From + 1) }>
197+ for _, ln := range p.Lines {
198+ @LogLine(ln)
199+ }
200+ </ol>
201+ if p.Next != "" {
202+ <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p>
203+ }
204+ </div>
205+ }
206+}
207+
208+// LogLine is a line of the log. Group and error markers of the runner show as
209+// headings and errors, never as raw text.
210+templ LogLine(ln LogLineView) {
211+ <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>
212+}
213+
214+templ jobRail(p JobPage) {
215+ <section aria-labelledby="r-job">
216+ <h2 id="r-job">Steps</h2>
217+ @JobSteps(p.Steps)
218+ </section>
219+ <section aria-labelledby="r-jobf">
220+ <h2 id="r-jobf">Job</h2>
221+ <dl class="kv">
222+ <dt>run</dt>
223+ <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd>
224+ if p.Runner != "" {
225+ <dt>runner</dt>
226+ <dd>{ p.Runner }</dd>
227+ }
228+ if p.Isolation != "" {
229+ <dt>isolation</dt>
230+ <dd>{ p.Isolation }</dd>
231+ }
232+ <dt>lines</dt>
233+ <dd>{ itoa64(p.Total) }</dd>
234+ </dl>
235+ <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p>
236+ </section>
237+}
238+
239+// CommitChecks is the combined state of the checks of a commit, as a mark
240+// with a count, linking to the page that explains it.
241+templ CommitChecks(c ChecksSummary) {
242+ if c.Total > 0 {
243+ <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }>
244+ @CheckIcon(c.Kind, c.State)
245+ <span class="mute">{ c.Label }</span>
246+ </a>
247+ }
248+}
249+
250+// MRChecks is the checks section of the rail of a merge request.
251+templ MRChecks(c ChecksSection) {
252+ <section aria-labelledby="r-checks" id="mr-checks">
253+ <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
254+ if len(c.Rows) == 0 {
255+ <p class="dim">no checks</p>
256+ } else {
257+ <ul class="checks">
258+ for _, r := range c.Rows {
259+ <li>
260+ @CheckIcon(r.Kind, r.State)
261+ if r.Href != "" {
262+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
263+ } else {
264+ <span>{ r.Name }</span>
265+ }
266+ <span class="dim">{ r.State }</span>
267+ if r.Waiting {
268+ <span class="mute">waiting for a runner</span>
269+ }
270+ </li>
271+ }
272+ </ul>
273+ }
274+ if c.Note != "" {
275+ <p class="mute">{ c.Note }</p>
276+ }
277+ </section>
278+}
A internal/adapters/web/actions_templ.go +2012 -0
@@ -0,0 +1,2012 @@
1+// Code generated by templ - DO NOT EDIT.
2+
3+// templ: version: v0.3.1020
4+package web
5+
6+//lint:file-ignore SA4006 This context is only used if a nested component is present.
7+
8+import "github.com/a-h/templ"
9+import templruntime "github.com/a-h/templ/runtime"
10+
11+// The CI pages: the runs of a repository, a run with its jobs, a job with its
12+// steps and its log (actions.go). A state is always a word as well as a
13+// symbol and a colour.
14+
15+// CheckIcon is the symbol of a state: a mark in the colour of its kind, with
16+// the state as its text for a screen reader.
17+func CheckIcon(kind, state string) templ.Component {
18+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
19+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
20+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
21+ return templ_7745c5c3_CtxErr
22+ }
23+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
24+ if !templ_7745c5c3_IsBuffer {
25+ defer func() {
26+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
27+ if templ_7745c5c3_Err == nil {
28+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
29+ }
30+ }()
31+ }
32+ ctx = templ.InitializeContext(ctx)
33+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
34+ if templ_7745c5c3_Var1 == nil {
35+ templ_7745c5c3_Var1 = templ.NopComponent
36+ }
37+ ctx = templ.ClearChildren(ctx)
38+ var templ_7745c5c3_Var2 = []any{"ck", "ck-" + kind}
39+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
40+ if templ_7745c5c3_Err != nil {
41+ return templ_7745c5c3_Err
42+ }
43+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<span class=\"")
44+ if templ_7745c5c3_Err != nil {
45+ return templ_7745c5c3_Err
46+ }
47+ var templ_7745c5c3_Var3 string
48+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
49+ if templ_7745c5c3_Err != nil {
50+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0}
51+ }
52+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
53+ if templ_7745c5c3_Err != nil {
54+ return templ_7745c5c3_Err
55+ }
56+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" role=\"img\" aria-label=\"")
57+ if templ_7745c5c3_Err != nil {
58+ return templ_7745c5c3_Err
59+ }
60+ var templ_7745c5c3_Var4 string
61+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(state)
62+ if templ_7745c5c3_Err != nil {
63+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 10, Col: 65}
64+ }
65+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
66+ if templ_7745c5c3_Err != nil {
67+ return templ_7745c5c3_Err
68+ }
69+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" title=\"")
70+ if templ_7745c5c3_Err != nil {
71+ return templ_7745c5c3_Err
72+ }
73+ var templ_7745c5c3_Var5 string
74+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(state)
75+ if templ_7745c5c3_Err != nil {
76+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 10, Col: 81}
77+ }
78+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
79+ if templ_7745c5c3_Err != nil {
80+ return templ_7745c5c3_Err
81+ }
82+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"></span>")
83+ if templ_7745c5c3_Err != nil {
84+ return templ_7745c5c3_Err
85+ }
86+ return nil
87+ })
88+}
89+
90+func Actions(l Layout, p ActionsPage) templ.Component {
91+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
92+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
93+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
94+ return templ_7745c5c3_CtxErr
95+ }
96+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
97+ if !templ_7745c5c3_IsBuffer {
98+ defer func() {
99+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
100+ if templ_7745c5c3_Err == nil {
101+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
102+ }
103+ }()
104+ }
105+ ctx = templ.InitializeContext(ctx)
106+ templ_7745c5c3_Var6 := templ.GetChildren(ctx)
107+ if templ_7745c5c3_Var6 == nil {
108+ templ_7745c5c3_Var6 = templ.NopComponent
109+ }
110+ ctx = templ.ClearChildren(ctx)
111+ templ_7745c5c3_Var7 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
112+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
113+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
114+ if !templ_7745c5c3_IsBuffer {
115+ defer func() {
116+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
117+ if templ_7745c5c3_Err == nil {
118+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
119+ }
120+ }()
121+ }
122+ ctx = templ.InitializeContext(ctx)
123+ if len(p.Runs) == 0 {
124+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p class=\"dim\">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>")
125+ if templ_7745c5c3_Err != nil {
126+ return templ_7745c5c3_Err
127+ }
128+ } else {
129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<table class=\"list runs\" aria-label=\"Runs\"><thead><tr><th class=\"c-st\"></th><th class=\"c-name\">workflow</th><th class=\"c-sub\">commit</th><th class=\"c-ref\">ref</th><th class=\"c-who\">actor</th><th class=\"c-dur\">time</th><th class=\"c-when\">created</th></tr></thead> <tbody id=\"run-rows\" data-rows>")
130+ if templ_7745c5c3_Err != nil {
131+ return templ_7745c5c3_Err
132+ }
133+ for _, r := range p.Runs {
134+ templ_7745c5c3_Err = RunListRow(r).Render(ctx, templ_7745c5c3_Buffer)
135+ if templ_7745c5c3_Err != nil {
136+ return templ_7745c5c3_Err
137+ }
138+ }
139+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</tbody></table>")
140+ if templ_7745c5c3_Err != nil {
141+ return templ_7745c5c3_Err
142+ }
143+ }
144+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " ")
145+ if templ_7745c5c3_Err != nil {
146+ return templ_7745c5c3_Err
147+ }
148+ if p.More != "" {
149+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<p class=\"more\"><a href=\"")
150+ if templ_7745c5c3_Err != nil {
151+ return templ_7745c5c3_Err
152+ }
153+ var templ_7745c5c3_Var8 templ.SafeURL
154+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.More))
155+ if templ_7745c5c3_Err != nil {
156+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 30, Col: 50}
157+ }
158+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
159+ if templ_7745c5c3_Err != nil {
160+ return templ_7745c5c3_Err
161+ }
162+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" rel=\"next\">older runs</a></p>")
163+ if templ_7745c5c3_Err != nil {
164+ return templ_7745c5c3_Err
165+ }
166+ }
167+ return nil
168+ })
169+ templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var7), templ_7745c5c3_Buffer)
170+ if templ_7745c5c3_Err != nil {
171+ return templ_7745c5c3_Err
172+ }
173+ return nil
174+ })
175+}
176+
177+// RunListRow is a row of the list of runs; its id is the target of the live
178+// updates.
179+func RunListRow(r RunRow) templ.Component {
180+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
181+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
182+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
183+ return templ_7745c5c3_CtxErr
184+ }
185+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
186+ if !templ_7745c5c3_IsBuffer {
187+ defer func() {
188+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
189+ if templ_7745c5c3_Err == nil {
190+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
191+ }
192+ }()
193+ }
194+ ctx = templ.InitializeContext(ctx)
195+ templ_7745c5c3_Var9 := templ.GetChildren(ctx)
196+ if templ_7745c5c3_Var9 == nil {
197+ templ_7745c5c3_Var9 = templ.NopComponent
198+ }
199+ ctx = templ.ClearChildren(ctx)
200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<tr id=\"")
201+ if templ_7745c5c3_Err != nil {
202+ return templ_7745c5c3_Err
203+ }
204+ var templ_7745c5c3_Var10 string
205+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("run-" + r.ID)
206+ if templ_7745c5c3_Err != nil {
207+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 38, Col: 23}
208+ }
209+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
210+ if templ_7745c5c3_Err != nil {
211+ return templ_7745c5c3_Err
212+ }
213+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><td class=\"c-st\">")
214+ if templ_7745c5c3_Err != nil {
215+ return templ_7745c5c3_Err
216+ }
217+ templ_7745c5c3_Err = CheckIcon(r.Kind, r.State).Render(ctx, templ_7745c5c3_Buffer)
218+ if templ_7745c5c3_Err != nil {
219+ return templ_7745c5c3_Err
220+ }
221+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</td><td class=\"c-name\"><a href=\"")
222+ if templ_7745c5c3_Err != nil {
223+ return templ_7745c5c3_Err
224+ }
225+ var templ_7745c5c3_Var11 templ.SafeURL
226+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
227+ if templ_7745c5c3_Err != nil {
228+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 41, Col: 34}
229+ }
230+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
231+ if templ_7745c5c3_Err != nil {
232+ return templ_7745c5c3_Err
233+ }
234+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">")
235+ if templ_7745c5c3_Err != nil {
236+ return templ_7745c5c3_Err
237+ }
238+ var templ_7745c5c3_Var12 string
239+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Workflow)
240+ if templ_7745c5c3_Err != nil {
241+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 41, Col: 49}
242+ }
243+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
244+ if templ_7745c5c3_Err != nil {
245+ return templ_7745c5c3_Err
246+ }
247+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a> <span class=\"mute\">")
248+ if templ_7745c5c3_Err != nil {
249+ return templ_7745c5c3_Err
250+ }
251+ var templ_7745c5c3_Var13 string
252+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Number)
253+ if templ_7745c5c3_Err != nil {
254+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 42, Col: 32}
255+ }
256+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
257+ if templ_7745c5c3_Err != nil {
258+ return templ_7745c5c3_Err
259+ }
260+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ")
261+ if templ_7745c5c3_Err != nil {
262+ return templ_7745c5c3_Err
263+ }
264+ if r.Attempt > 1 {
265+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"pill\">attempt ")
266+ if templ_7745c5c3_Err != nil {
267+ return templ_7745c5c3_Err
268+ }
269+ var templ_7745c5c3_Var14 string
270+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(r.Attempt)))
271+ if templ_7745c5c3_Err != nil {
272+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 44, Col: 53}
273+ }
274+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
275+ if templ_7745c5c3_Err != nil {
276+ return templ_7745c5c3_Err
277+ }
278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span>")
279+ if templ_7745c5c3_Err != nil {
280+ return templ_7745c5c3_Err
281+ }
282+ }
283+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"c-sub\"><a href=\"")
284+ if templ_7745c5c3_Err != nil {
285+ return templ_7745c5c3_Err
286+ }
287+ var templ_7745c5c3_Var15 templ.SafeURL
288+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref))
289+ if templ_7745c5c3_Err != nil {
290+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 52}
291+ }
292+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
293+ if templ_7745c5c3_Err != nil {
294+ return templ_7745c5c3_Err
295+ }
296+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">")
297+ if templ_7745c5c3_Err != nil {
298+ return templ_7745c5c3_Err
299+ }
300+ var templ_7745c5c3_Var16 string
301+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.Short)
302+ if templ_7745c5c3_Err != nil {
303+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 64}
304+ }
305+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
306+ if templ_7745c5c3_Err != nil {
307+ return templ_7745c5c3_Err
308+ }
309+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</a> ")
310+ if templ_7745c5c3_Err != nil {
311+ return templ_7745c5c3_Err
312+ }
313+ var templ_7745c5c3_Var17 string
314+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Title)
315+ if templ_7745c5c3_Err != nil {
316+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 47, Col: 80}
317+ }
318+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
319+ if templ_7745c5c3_Err != nil {
320+ return templ_7745c5c3_Err
321+ }
322+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"c-ref\"><a href=\"")
323+ if templ_7745c5c3_Err != nil {
324+ return templ_7745c5c3_Err
325+ }
326+ var templ_7745c5c3_Var18 templ.SafeURL
327+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.RefHref))
328+ if templ_7745c5c3_Err != nil {
329+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 48, Col: 54}
330+ }
331+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
332+ if templ_7745c5c3_Err != nil {
333+ return templ_7745c5c3_Err
334+ }
335+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
336+ if templ_7745c5c3_Err != nil {
337+ return templ_7745c5c3_Err
338+ }
339+ var templ_7745c5c3_Var19 string
340+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(r.Ref)
341+ if templ_7745c5c3_Err != nil {
342+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 48, Col: 64}
343+ }
344+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
345+ if templ_7745c5c3_Err != nil {
346+ return templ_7745c5c3_Err
347+ }
348+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</a></td><td class=\"c-who dim\">")
349+ if templ_7745c5c3_Err != nil {
350+ return templ_7745c5c3_Err
351+ }
352+ var templ_7745c5c3_Var20 string
353+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(r.Actor)
354+ if templ_7745c5c3_Err != nil {
355+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 49, Col: 33}
356+ }
357+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
358+ if templ_7745c5c3_Err != nil {
359+ return templ_7745c5c3_Err
360+ }
361+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</td><td class=\"c-dur dim\">")
362+ if templ_7745c5c3_Err != nil {
363+ return templ_7745c5c3_Err
364+ }
365+ var templ_7745c5c3_Var21 string
366+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
367+ if templ_7745c5c3_Err != nil {
368+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 33}
369+ }
370+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
371+ if templ_7745c5c3_Err != nil {
372+ return templ_7745c5c3_Err
373+ }
374+ var templ_7745c5c3_Var22 string
375+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
376+ if templ_7745c5c3_Err != nil {
377+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 40}
378+ }
379+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
380+ if templ_7745c5c3_Err != nil {
381+ return templ_7745c5c3_Err
382+ }
383+ var templ_7745c5c3_Var23 string
384+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(r.Duration)
385+ if templ_7745c5c3_Err != nil {
386+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 50, Col: 54}
387+ }
388+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
389+ if templ_7745c5c3_Err != nil {
390+ return templ_7745c5c3_Err
391+ }
392+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-when dim\">")
393+ if templ_7745c5c3_Err != nil {
394+ return templ_7745c5c3_Err
395+ }
396+ if r.WhenAt != "" {
397+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<time datetime=\"")
398+ if templ_7745c5c3_Err != nil {
399+ return templ_7745c5c3_Err
400+ }
401+ var templ_7745c5c3_Var24 string
402+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
403+ if templ_7745c5c3_Err != nil {
404+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 29}
405+ }
406+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
407+ if templ_7745c5c3_Err != nil {
408+ return templ_7745c5c3_Err
409+ }
410+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" title=\"")
411+ if templ_7745c5c3_Err != nil {
412+ return templ_7745c5c3_Err
413+ }
414+ var templ_7745c5c3_Var25 string
415+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt)
416+ if templ_7745c5c3_Err != nil {
417+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 48}
418+ }
419+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
420+ if templ_7745c5c3_Err != nil {
421+ return templ_7745c5c3_Err
422+ }
423+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
424+ if templ_7745c5c3_Err != nil {
425+ return templ_7745c5c3_Err
426+ }
427+ var templ_7745c5c3_Var26 string
428+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.When)
429+ if templ_7745c5c3_Err != nil {
430+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 53, Col: 59}
431+ }
432+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
433+ if templ_7745c5c3_Err != nil {
434+ return templ_7745c5c3_Err
435+ }
436+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</time>")
437+ if templ_7745c5c3_Err != nil {
438+ return templ_7745c5c3_Err
439+ }
440+ }
441+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</td></tr>")
442+ if templ_7745c5c3_Err != nil {
443+ return templ_7745c5c3_Err
444+ }
445+ return nil
446+ })
447+}
448+
449+func RunDetail(l Layout, p RunPage) templ.Component {
450+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
451+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
452+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
453+ return templ_7745c5c3_CtxErr
454+ }
455+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
456+ if !templ_7745c5c3_IsBuffer {
457+ defer func() {
458+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
459+ if templ_7745c5c3_Err == nil {
460+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
461+ }
462+ }()
463+ }
464+ ctx = templ.InitializeContext(ctx)
465+ templ_7745c5c3_Var27 := templ.GetChildren(ctx)
466+ if templ_7745c5c3_Var27 == nil {
467+ templ_7745c5c3_Var27 = templ.NopComponent
468+ }
469+ ctx = templ.ClearChildren(ctx)
470+ templ_7745c5c3_Var28 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
471+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
472+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
473+ if !templ_7745c5c3_IsBuffer {
474+ defer func() {
475+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
476+ if templ_7745c5c3_Err == nil {
477+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
478+ }
479+ }()
480+ }
481+ ctx = templ.InitializeContext(ctx)
482+ templ_7745c5c3_Err = LiveSlot("run-notice").Render(ctx, templ_7745c5c3_Buffer)
483+ if templ_7745c5c3_Err != nil {
484+ return templ_7745c5c3_Err
485+ }
486+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " <div id=\"run-body\">")
487+ if templ_7745c5c3_Err != nil {
488+ return templ_7745c5c3_Err
489+ }
490+ templ_7745c5c3_Err = RunBody(p).Render(ctx, templ_7745c5c3_Buffer)
491+ if templ_7745c5c3_Err != nil {
492+ return templ_7745c5c3_Err
493+ }
494+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div>")
495+ if templ_7745c5c3_Err != nil {
496+ return templ_7745c5c3_Err
497+ }
498+ return nil
499+ })
500+ templ_7745c5c3_Err = Shell(l, runRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var28), templ_7745c5c3_Buffer)
501+ if templ_7745c5c3_Err != nil {
502+ return templ_7745c5c3_Err
503+ }
504+ return nil
505+ })
506+}
507+
508+// runBodyFragment is the body of the run page as a live fragment.
509+func runBodyFragment(p RunPage) templ.Component {
510+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
511+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
512+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
513+ return templ_7745c5c3_CtxErr
514+ }
515+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
516+ if !templ_7745c5c3_IsBuffer {
517+ defer func() {
518+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
519+ if templ_7745c5c3_Err == nil {
520+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
521+ }
522+ }()
523+ }
524+ ctx = templ.InitializeContext(ctx)
525+ templ_7745c5c3_Var29 := templ.GetChildren(ctx)
526+ if templ_7745c5c3_Var29 == nil {
527+ templ_7745c5c3_Var29 = templ.NopComponent
528+ }
529+ ctx = templ.ClearChildren(ctx)
530+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div id=\"run-body\">")
531+ if templ_7745c5c3_Err != nil {
532+ return templ_7745c5c3_Err
533+ }
534+ templ_7745c5c3_Err = RunBody(p).Render(ctx, templ_7745c5c3_Buffer)
535+ if templ_7745c5c3_Err != nil {
536+ return templ_7745c5c3_Err
537+ }
538+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</div>")
539+ if templ_7745c5c3_Err != nil {
540+ return templ_7745c5c3_Err
541+ }
542+ return nil
543+ })
544+}
545+
546+// RunBody is the jobs of a run, replaced by the live updates.
547+func RunBody(p RunPage) templ.Component {
548+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
549+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
550+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
551+ return templ_7745c5c3_CtxErr
552+ }
553+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
554+ if !templ_7745c5c3_IsBuffer {
555+ defer func() {
556+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
557+ if templ_7745c5c3_Err == nil {
558+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
559+ }
560+ }()
561+ }
562+ ctx = templ.InitializeContext(ctx)
563+ templ_7745c5c3_Var30 := templ.GetChildren(ctx)
564+ if templ_7745c5c3_Var30 == nil {
565+ templ_7745c5c3_Var30 = templ.NopComponent
566+ }
567+ ctx = templ.ClearChildren(ctx)
568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<h2 class=\"run-h\">")
569+ if templ_7745c5c3_Err != nil {
570+ return templ_7745c5c3_Err
571+ }
572+ templ_7745c5c3_Err = CheckIcon(p.Kind, p.State).Render(ctx, templ_7745c5c3_Buffer)
573+ if templ_7745c5c3_Err != nil {
574+ return templ_7745c5c3_Err
575+ }
576+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>")
577+ if templ_7745c5c3_Err != nil {
578+ return templ_7745c5c3_Err
579+ }
580+ var templ_7745c5c3_Var31 string
581+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(p.Workflow)
582+ if templ_7745c5c3_Err != nil {
583+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 77, Col: 64}
584+ }
585+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
586+ if templ_7745c5c3_Err != nil {
587+ return templ_7745c5c3_Err
588+ }
589+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <span class=\"mute\">")
590+ if templ_7745c5c3_Err != nil {
591+ return templ_7745c5c3_Err
592+ }
593+ var templ_7745c5c3_Var32 string
594+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(p.Number)
595+ if templ_7745c5c3_Err != nil {
596+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 77, Col: 103}
597+ }
598+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
599+ if templ_7745c5c3_Err != nil {
600+ return templ_7745c5c3_Err
601+ }
602+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span></h2>")
603+ if templ_7745c5c3_Err != nil {
604+ return templ_7745c5c3_Err
605+ }
606+ if p.Error != "" {
607+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<pre class=\"run-err\">")
608+ if templ_7745c5c3_Err != nil {
609+ return templ_7745c5c3_Err
610+ }
611+ var templ_7745c5c3_Var33 string
612+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
613+ if templ_7745c5c3_Err != nil {
614+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 79, Col: 32}
615+ }
616+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
617+ if templ_7745c5c3_Err != nil {
618+ return templ_7745c5c3_Err
619+ }
620+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</pre>")
621+ if templ_7745c5c3_Err != nil {
622+ return templ_7745c5c3_Err
623+ }
624+ }
625+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<ul class=\"jobs\">")
626+ if templ_7745c5c3_Err != nil {
627+ return templ_7745c5c3_Err
628+ }
629+ for _, j := range p.Jobs {
630+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<li id=\"")
631+ if templ_7745c5c3_Err != nil {
632+ return templ_7745c5c3_Err
633+ }
634+ var templ_7745c5c3_Var34 string
635+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue("job-" + j.ID)
636+ if templ_7745c5c3_Err != nil {
637+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 83, Col: 25}
638+ }
639+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
640+ if templ_7745c5c3_Err != nil {
641+ return templ_7745c5c3_Err
642+ }
643+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"><a class=\"job-h\" href=\"")
644+ if templ_7745c5c3_Err != nil {
645+ return templ_7745c5c3_Err
646+ }
647+ var templ_7745c5c3_Var35 templ.SafeURL
648+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(j.Href))
649+ if templ_7745c5c3_Err != nil {
650+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 84, Col: 49}
651+ }
652+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
653+ if templ_7745c5c3_Err != nil {
654+ return templ_7745c5c3_Err
655+ }
656+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\">")
657+ if templ_7745c5c3_Err != nil {
658+ return templ_7745c5c3_Err
659+ }
660+ templ_7745c5c3_Err = CheckIcon(j.Kind, j.State).Render(ctx, templ_7745c5c3_Buffer)
661+ if templ_7745c5c3_Err != nil {
662+ return templ_7745c5c3_Err
663+ }
664+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"j-name\">")
665+ if templ_7745c5c3_Err != nil {
666+ return templ_7745c5c3_Err
667+ }
668+ var templ_7745c5c3_Var36 string
669+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name)
670+ if templ_7745c5c3_Err != nil {
671+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 86, Col: 34}
672+ }
673+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
674+ if templ_7745c5c3_Err != nil {
675+ return templ_7745c5c3_Err
676+ }
677+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span> <span class=\"dim\">")
678+ if templ_7745c5c3_Err != nil {
679+ return templ_7745c5c3_Err
680+ }
681+ var templ_7745c5c3_Var37 string
682+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(j.State)
683+ if templ_7745c5c3_Err != nil {
684+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 32}
685+ }
686+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
687+ if templ_7745c5c3_Err != nil {
688+ return templ_7745c5c3_Err
689+ }
690+ var templ_7745c5c3_Var38 string
691+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
692+ if templ_7745c5c3_Err != nil {
693+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 39}
694+ }
695+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38))
696+ if templ_7745c5c3_Err != nil {
697+ return templ_7745c5c3_Err
698+ }
699+ var templ_7745c5c3_Var39 string
700+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration)
701+ if templ_7745c5c3_Err != nil {
702+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 87, Col: 53}
703+ }
704+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
705+ if templ_7745c5c3_Err != nil {
706+ return templ_7745c5c3_Err
707+ }
708+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span></a> ")
709+ if templ_7745c5c3_Err != nil {
710+ return templ_7745c5c3_Err
711+ }
712+ if j.Runner != "" {
713+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<span class=\"mute\">on ")
714+ if templ_7745c5c3_Err != nil {
715+ return templ_7745c5c3_Err
716+ }
717+ var templ_7745c5c3_Var40 string
718+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(runnerText(j))
719+ if templ_7745c5c3_Err != nil {
720+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 90, Col: 42}
721+ }
722+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
723+ if templ_7745c5c3_Err != nil {
724+ return templ_7745c5c3_Err
725+ }
726+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span> ")
727+ if templ_7745c5c3_Err != nil {
728+ return templ_7745c5c3_Err
729+ }
730+ }
731+ if len(j.Needs) > 0 {
732+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"mute\">needs ")
733+ if templ_7745c5c3_Err != nil {
734+ return templ_7745c5c3_Err
735+ }
736+ var templ_7745c5c3_Var41 string
737+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(joinWords(j.Needs))
738+ if templ_7745c5c3_Err != nil {
739+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 93, Col: 50}
740+ }
741+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
742+ if templ_7745c5c3_Err != nil {
743+ return templ_7745c5c3_Err
744+ }
745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> ")
746+ if templ_7745c5c3_Err != nil {
747+ return templ_7745c5c3_Err
748+ }
749+ }
750+ if j.Error != "" {
751+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<p class=\"job-err\">")
752+ if templ_7745c5c3_Err != nil {
753+ return templ_7745c5c3_Err
754+ }
755+ var templ_7745c5c3_Var42 string
756+ templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error)
757+ if templ_7745c5c3_Err != nil {
758+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 96, Col: 33}
759+ }
760+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
761+ if templ_7745c5c3_Err != nil {
762+ return templ_7745c5c3_Err
763+ }
764+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</p>")
765+ if templ_7745c5c3_Err != nil {
766+ return templ_7745c5c3_Err
767+ }
768+ }
769+ if len(j.Steps) > 0 {
770+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<ol class=\"steps\">")
771+ if templ_7745c5c3_Err != nil {
772+ return templ_7745c5c3_Err
773+ }
774+ for _, s := range j.Steps {
775+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<li>")
776+ if templ_7745c5c3_Err != nil {
777+ return templ_7745c5c3_Err
778+ }
779+ templ_7745c5c3_Err = CheckIcon(s.Kind, s.State).Render(ctx, templ_7745c5c3_Buffer)
780+ if templ_7745c5c3_Err != nil {
781+ return templ_7745c5c3_Err
782+ }
783+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<span>")
784+ if templ_7745c5c3_Err != nil {
785+ return templ_7745c5c3_Err
786+ }
787+ var templ_7745c5c3_Var43 string
788+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
789+ if templ_7745c5c3_Err != nil {
790+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 101, Col: 53}
791+ }
792+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
793+ if templ_7745c5c3_Err != nil {
794+ return templ_7745c5c3_Err
795+ }
796+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</span> <span class=\"dim\">")
797+ if templ_7745c5c3_Err != nil {
798+ return templ_7745c5c3_Err
799+ }
800+ var templ_7745c5c3_Var44 string
801+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration)
802+ if templ_7745c5c3_Err != nil {
803+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 101, Col: 93}
804+ }
805+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
806+ if templ_7745c5c3_Err != nil {
807+ return templ_7745c5c3_Err
808+ }
809+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></li>")
810+ if templ_7745c5c3_Err != nil {
811+ return templ_7745c5c3_Err
812+ }
813+ }
814+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</ol>")
815+ if templ_7745c5c3_Err != nil {
816+ return templ_7745c5c3_Err
817+ }
818+ }
819+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "</li>")
820+ if templ_7745c5c3_Err != nil {
821+ return templ_7745c5c3_Err
822+ }
823+ }
824+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</ul>")
825+ if templ_7745c5c3_Err != nil {
826+ return templ_7745c5c3_Err
827+ }
828+ return nil
829+ })
830+}
831+
832+func runRail(p RunPage) templ.Component {
833+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
834+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
835+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
836+ return templ_7745c5c3_CtxErr
837+ }
838+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
839+ if !templ_7745c5c3_IsBuffer {
840+ defer func() {
841+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
842+ if templ_7745c5c3_Err == nil {
843+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
844+ }
845+ }()
846+ }
847+ ctx = templ.InitializeContext(ctx)
848+ templ_7745c5c3_Var45 := templ.GetChildren(ctx)
849+ if templ_7745c5c3_Var45 == nil {
850+ templ_7745c5c3_Var45 = templ.NopComponent
851+ }
852+ ctx = templ.ClearChildren(ctx)
853+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<section aria-labelledby=\"r-run\"><h2 id=\"r-run\">Run</h2><dl class=\"kv\"><dt>result</dt><dd>")
854+ if templ_7745c5c3_Err != nil {
855+ return templ_7745c5c3_Err
856+ }
857+ var templ_7745c5c3_Var46 string
858+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(p.State)
859+ if templ_7745c5c3_Err != nil {
860+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 115, Col: 16}
861+ }
862+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
863+ if templ_7745c5c3_Err != nil {
864+ return templ_7745c5c3_Err
865+ }
866+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</dd><dt>commit</dt><dd><a href=\"")
867+ if templ_7745c5c3_Err != nil {
868+ return templ_7745c5c3_Err
869+ }
870+ var templ_7745c5c3_Var47 templ.SafeURL
871+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CHref))
872+ if templ_7745c5c3_Err != nil {
873+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 117, Col: 39}
874+ }
875+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
876+ if templ_7745c5c3_Err != nil {
877+ return templ_7745c5c3_Err
878+ }
879+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">")
880+ if templ_7745c5c3_Err != nil {
881+ return templ_7745c5c3_Err
882+ }
883+ var templ_7745c5c3_Var48 string
884+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(p.Short)
885+ if templ_7745c5c3_Err != nil {
886+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 117, Col: 51}
887+ }
888+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
889+ if templ_7745c5c3_Err != nil {
890+ return templ_7745c5c3_Err
891+ }
892+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</a></dd><dt>ref</dt><dd><a href=\"")
893+ if templ_7745c5c3_Err != nil {
894+ return templ_7745c5c3_Err
895+ }
896+ var templ_7745c5c3_Var49 templ.SafeURL
897+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RefHref))
898+ if templ_7745c5c3_Err != nil {
899+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 119, Col: 41}
900+ }
901+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
902+ if templ_7745c5c3_Err != nil {
903+ return templ_7745c5c3_Err
904+ }
905+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">")
906+ if templ_7745c5c3_Err != nil {
907+ return templ_7745c5c3_Err
908+ }
909+ var templ_7745c5c3_Var50 string
910+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(p.Ref)
911+ if templ_7745c5c3_Err != nil {
912+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 119, Col: 51}
913+ }
914+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
915+ if templ_7745c5c3_Err != nil {
916+ return templ_7745c5c3_Err
917+ }
918+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "</a></dd><dt>actor</dt><dd>")
919+ if templ_7745c5c3_Err != nil {
920+ return templ_7745c5c3_Err
921+ }
922+ var templ_7745c5c3_Var51 string
923+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(p.Actor)
924+ if templ_7745c5c3_Err != nil {
925+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 121, Col: 16}
926+ }
927+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
928+ if templ_7745c5c3_Err != nil {
929+ return templ_7745c5c3_Err
930+ }
931+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</dd><dt>attempt</dt><dd>")
932+ if templ_7745c5c3_Err != nil {
933+ return templ_7745c5c3_Err
934+ }
935+ var templ_7745c5c3_Var52 string
936+ templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Attempt)))
937+ if templ_7745c5c3_Err != nil {
938+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 123, Col: 29}
939+ }
940+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
941+ if templ_7745c5c3_Err != nil {
942+ return templ_7745c5c3_Err
943+ }
944+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " of ")
945+ if templ_7745c5c3_Err != nil {
946+ return templ_7745c5c3_Err
947+ }
948+ var templ_7745c5c3_Var53 string
949+ templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(int(p.Latest)))
950+ if templ_7745c5c3_Err != nil {
951+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 123, Col: 56}
952+ }
953+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
954+ if templ_7745c5c3_Err != nil {
955+ return templ_7745c5c3_Err
956+ }
957+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</dd></dl>")
958+ if templ_7745c5c3_Err != nil {
959+ return templ_7745c5c3_Err
960+ }
961+ if len(p.Attempts) > 0 {
962+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<ul class=\"attempts\">")
963+ if templ_7745c5c3_Err != nil {
964+ return templ_7745c5c3_Err
965+ }
966+ for _, a := range p.Attempts {
967+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<li>")
968+ if templ_7745c5c3_Err != nil {
969+ return templ_7745c5c3_Err
970+ }
971+ if a.Current {
972+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<span aria-current=\"page\">")
973+ if templ_7745c5c3_Err != nil {
974+ return templ_7745c5c3_Err
975+ }
976+ var templ_7745c5c3_Var54 string
977+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label)
978+ if templ_7745c5c3_Err != nil {
979+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 130, Col: 42}
980+ }
981+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
982+ if templ_7745c5c3_Err != nil {
983+ return templ_7745c5c3_Err
984+ }
985+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span>")
986+ if templ_7745c5c3_Err != nil {
987+ return templ_7745c5c3_Err
988+ }
989+ } else {
990+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<a href=\"")
991+ if templ_7745c5c3_Err != nil {
992+ return templ_7745c5c3_Err
993+ }
994+ var templ_7745c5c3_Var55 templ.SafeURL
995+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(a.Href))
996+ if templ_7745c5c3_Err != nil {
997+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 38}
998+ }
999+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1000+ if templ_7745c5c3_Err != nil {
1001+ return templ_7745c5c3_Err
1002+ }
1003+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\">")
1004+ if templ_7745c5c3_Err != nil {
1005+ return templ_7745c5c3_Err
1006+ }
1007+ var templ_7745c5c3_Var56 string
1008+ templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(a.Label)
1009+ if templ_7745c5c3_Err != nil {
1010+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 132, Col: 50}
1011+ }
1012+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1013+ if templ_7745c5c3_Err != nil {
1014+ return templ_7745c5c3_Err
1015+ }
1016+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</a>")
1017+ if templ_7745c5c3_Err != nil {
1018+ return templ_7745c5c3_Err
1019+ }
1020+ }
1021+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</li>")
1022+ if templ_7745c5c3_Err != nil {
1023+ return templ_7745c5c3_Err
1024+ }
1025+ }
1026+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</ul>")
1027+ if templ_7745c5c3_Err != nil {
1028+ return templ_7745c5c3_Err
1029+ }
1030+ }
1031+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</section>")
1032+ if templ_7745c5c3_Err != nil {
1033+ return templ_7745c5c3_Err
1034+ }
1035+ if p.CancelAction != "" || p.RerunAction != "" {
1036+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<section aria-labelledby=\"r-act\" class=\"decide\"><h2 id=\"r-act\">Actions</h2><div class=\"d-btns\">")
1037+ if templ_7745c5c3_Err != nil {
1038+ return templ_7745c5c3_Err
1039+ }
1040+ if p.CancelAction != "" {
1041+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<form method=\"post\" action=\"")
1042+ if templ_7745c5c3_Err != nil {
1043+ return templ_7745c5c3_Err
1044+ }
1045+ var templ_7745c5c3_Var57 templ.SafeURL
1046+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.CancelAction))
1047+ if templ_7745c5c3_Err != nil {
1048+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 144, Col: 63}
1049+ }
1050+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
1051+ if templ_7745c5c3_Err != nil {
1052+ return templ_7745c5c3_Err
1053+ }
1054+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\"><button type=\"submit\" class=\"btn\">Cancel run</button></form>")
1055+ if templ_7745c5c3_Err != nil {
1056+ return templ_7745c5c3_Err
1057+ }
1058+ }
1059+ if p.RerunAction != "" {
1060+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<form method=\"post\" action=\"")
1061+ if templ_7745c5c3_Err != nil {
1062+ return templ_7745c5c3_Err
1063+ }
1064+ var templ_7745c5c3_Var58 templ.SafeURL
1065+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunAction))
1066+ if templ_7745c5c3_Err != nil {
1067+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 147, Col: 62}
1068+ }
1069+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
1070+ if templ_7745c5c3_Err != nil {
1071+ return templ_7745c5c3_Err
1072+ }
1073+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\"><button type=\"submit\" class=\"btn primary\">Re-run all jobs</button></form>")
1074+ if templ_7745c5c3_Err != nil {
1075+ return templ_7745c5c3_Err
1076+ }
1077+ }
1078+ if p.CanRerunFailed {
1079+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<form method=\"post\" action=\"")
1080+ if templ_7745c5c3_Err != nil {
1081+ return templ_7745c5c3_Err
1082+ }
1083+ var templ_7745c5c3_Var59 templ.SafeURL
1084+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RerunFailedAction))
1085+ if templ_7745c5c3_Err != nil {
1086+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 150, Col: 68}
1087+ }
1088+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
1089+ if templ_7745c5c3_Err != nil {
1090+ return templ_7745c5c3_Err
1091+ }
1092+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\"><button type=\"submit\" class=\"btn\">Re-run failed jobs</button></form>")
1093+ if templ_7745c5c3_Err != nil {
1094+ return templ_7745c5c3_Err
1095+ }
1096+ }
1097+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div></section>")
1098+ if templ_7745c5c3_Err != nil {
1099+ return templ_7745c5c3_Err
1100+ }
1101+ }
1102+ return nil
1103+ })
1104+}
1105+
1106+// JobHead is the title of the job page with its state and its error, replaced
1107+// by the live updates. total is the number of lines the log holds.
1108+func JobHead(j JobRow, total int64) templ.Component {
1109+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1110+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1111+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1112+ return templ_7745c5c3_CtxErr
1113+ }
1114+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1115+ if !templ_7745c5c3_IsBuffer {
1116+ defer func() {
1117+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1118+ if templ_7745c5c3_Err == nil {
1119+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1120+ }
1121+ }()
1122+ }
1123+ ctx = templ.InitializeContext(ctx)
1124+ templ_7745c5c3_Var60 := templ.GetChildren(ctx)
1125+ if templ_7745c5c3_Var60 == nil {
1126+ templ_7745c5c3_Var60 = templ.NopComponent
1127+ }
1128+ ctx = templ.ClearChildren(ctx)
1129+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div id=\"job-head\" data-total=\"")
1130+ if templ_7745c5c3_Err != nil {
1131+ return templ_7745c5c3_Err
1132+ }
1133+ var templ_7745c5c3_Var61 string
1134+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(total))
1135+ if templ_7745c5c3_Err != nil {
1136+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 160, Col: 46}
1137+ }
1138+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
1139+ if templ_7745c5c3_Err != nil {
1140+ return templ_7745c5c3_Err
1141+ }
1142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\"><h2 class=\"run-h\">")
1143+ if templ_7745c5c3_Err != nil {
1144+ return templ_7745c5c3_Err
1145+ }
1146+ templ_7745c5c3_Err = CheckIcon(j.Kind, j.State).Render(ctx, templ_7745c5c3_Buffer)
1147+ if templ_7745c5c3_Err != nil {
1148+ return templ_7745c5c3_Err
1149+ }
1150+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<span>")
1151+ if templ_7745c5c3_Err != nil {
1152+ return templ_7745c5c3_Err
1153+ }
1154+ var templ_7745c5c3_Var62 string
1155+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(j.Name)
1156+ if templ_7745c5c3_Err != nil {
1157+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 61}
1158+ }
1159+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62))
1160+ if templ_7745c5c3_Err != nil {
1161+ return templ_7745c5c3_Err
1162+ }
1163+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</span> <span class=\"dim\">")
1164+ if templ_7745c5c3_Err != nil {
1165+ return templ_7745c5c3_Err
1166+ }
1167+ var templ_7745c5c3_Var63 string
1168+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(j.State)
1169+ if templ_7745c5c3_Err != nil {
1170+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 98}
1171+ }
1172+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63))
1173+ if templ_7745c5c3_Err != nil {
1174+ return templ_7745c5c3_Err
1175+ }
1176+ var templ_7745c5c3_Var64 string
1177+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(" ")
1178+ if templ_7745c5c3_Err != nil {
1179+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 105}
1180+ }
1181+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64))
1182+ if templ_7745c5c3_Err != nil {
1183+ return templ_7745c5c3_Err
1184+ }
1185+ var templ_7745c5c3_Var65 string
1186+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(j.Duration)
1187+ if templ_7745c5c3_Err != nil {
1188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 161, Col: 119}
1189+ }
1190+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
1191+ if templ_7745c5c3_Err != nil {
1192+ return templ_7745c5c3_Err
1193+ }
1194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span></h2>")
1195+ if templ_7745c5c3_Err != nil {
1196+ return templ_7745c5c3_Err
1197+ }
1198+ if j.Error != "" {
1199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<p class=\"job-err\">")
1200+ if templ_7745c5c3_Err != nil {
1201+ return templ_7745c5c3_Err
1202+ }
1203+ var templ_7745c5c3_Var66 string
1204+ templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(j.Error)
1205+ if templ_7745c5c3_Err != nil {
1206+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 163, Col: 31}
1207+ }
1208+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66))
1209+ if templ_7745c5c3_Err != nil {
1210+ return templ_7745c5c3_Err
1211+ }
1212+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</p>")
1213+ if templ_7745c5c3_Err != nil {
1214+ return templ_7745c5c3_Err
1215+ }
1216+ }
1217+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</div>")
1218+ if templ_7745c5c3_Err != nil {
1219+ return templ_7745c5c3_Err
1220+ }
1221+ return nil
1222+ })
1223+}
1224+
1225+// JobSteps is the steps of the job in the rail, replaced by the live updates.
1226+func JobSteps(steps []StepView) templ.Component {
1227+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1228+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1229+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1230+ return templ_7745c5c3_CtxErr
1231+ }
1232+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1233+ if !templ_7745c5c3_IsBuffer {
1234+ defer func() {
1235+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1236+ if templ_7745c5c3_Err == nil {
1237+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1238+ }
1239+ }()
1240+ }
1241+ ctx = templ.InitializeContext(ctx)
1242+ templ_7745c5c3_Var67 := templ.GetChildren(ctx)
1243+ if templ_7745c5c3_Var67 == nil {
1244+ templ_7745c5c3_Var67 = templ.NopComponent
1245+ }
1246+ ctx = templ.ClearChildren(ctx)
1247+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<ol class=\"steps\" id=\"job-steps\">")
1248+ if templ_7745c5c3_Err != nil {
1249+ return templ_7745c5c3_Err
1250+ }
1251+ for _, s := range steps {
1252+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<li>")
1253+ if templ_7745c5c3_Err != nil {
1254+ return templ_7745c5c3_Err
1255+ }
1256+ templ_7745c5c3_Err = CheckIcon(s.Kind, s.State).Render(ctx, templ_7745c5c3_Buffer)
1257+ if templ_7745c5c3_Err != nil {
1258+ return templ_7745c5c3_Err
1259+ }
1260+ if s.LogEnd > s.LogStart {
1261+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<a href=\"")
1262+ if templ_7745c5c3_Err != nil {
1263+ return templ_7745c5c3_Err
1264+ }
1265+ var templ_7745c5c3_Var68 templ.SafeURL
1266+ templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#L" + itoa64(s.LogStart)))
1267+ if templ_7745c5c3_Err != nil {
1268+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 55}
1269+ }
1270+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
1271+ if templ_7745c5c3_Err != nil {
1272+ return templ_7745c5c3_Err
1273+ }
1274+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\">")
1275+ if templ_7745c5c3_Err != nil {
1276+ return templ_7745c5c3_Err
1277+ }
1278+ var templ_7745c5c3_Var69 string
1279+ templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
1280+ if templ_7745c5c3_Err != nil {
1281+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 175, Col: 66}
1282+ }
1283+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69))
1284+ if templ_7745c5c3_Err != nil {
1285+ return templ_7745c5c3_Err
1286+ }
1287+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</a> ")
1288+ if templ_7745c5c3_Err != nil {
1289+ return templ_7745c5c3_Err
1290+ }
1291+ } else {
1292+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<span>")
1293+ if templ_7745c5c3_Err != nil {
1294+ return templ_7745c5c3_Err
1295+ }
1296+ var templ_7745c5c3_Var70 string
1297+ templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
1298+ if templ_7745c5c3_Err != nil {
1299+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 177, Col: 19}
1300+ }
1301+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70))
1302+ if templ_7745c5c3_Err != nil {
1303+ return templ_7745c5c3_Err
1304+ }
1305+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</span> ")
1306+ if templ_7745c5c3_Err != nil {
1307+ return templ_7745c5c3_Err
1308+ }
1309+ }
1310+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<span class=\"dim\">")
1311+ if templ_7745c5c3_Err != nil {
1312+ return templ_7745c5c3_Err
1313+ }
1314+ var templ_7745c5c3_Var71 string
1315+ templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(s.Duration)
1316+ if templ_7745c5c3_Err != nil {
1317+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 179, Col: 34}
1318+ }
1319+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71))
1320+ if templ_7745c5c3_Err != nil {
1321+ return templ_7745c5c3_Err
1322+ }
1323+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</span></li>")
1324+ if templ_7745c5c3_Err != nil {
1325+ return templ_7745c5c3_Err
1326+ }
1327+ }
1328+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</ol>")
1329+ if templ_7745c5c3_Err != nil {
1330+ return templ_7745c5c3_Err
1331+ }
1332+ return nil
1333+ })
1334+}
1335+
1336+func Job(l Layout, p JobPage) templ.Component {
1337+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1338+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1339+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1340+ return templ_7745c5c3_CtxErr
1341+ }
1342+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1343+ if !templ_7745c5c3_IsBuffer {
1344+ defer func() {
1345+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1346+ if templ_7745c5c3_Err == nil {
1347+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1348+ }
1349+ }()
1350+ }
1351+ ctx = templ.InitializeContext(ctx)
1352+ templ_7745c5c3_Var72 := templ.GetChildren(ctx)
1353+ if templ_7745c5c3_Var72 == nil {
1354+ templ_7745c5c3_Var72 = templ.NopComponent
1355+ }
1356+ ctx = templ.ClearChildren(ctx)
1357+ templ_7745c5c3_Var73 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1358+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1359+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1360+ if !templ_7745c5c3_IsBuffer {
1361+ defer func() {
1362+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1363+ if templ_7745c5c3_Err == nil {
1364+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1365+ }
1366+ }()
1367+ }
1368+ ctx = templ.InitializeContext(ctx)
1369+ templ_7745c5c3_Err = LiveSlot("job-notice").Render(ctx, templ_7745c5c3_Buffer)
1370+ if templ_7745c5c3_Err != nil {
1371+ return templ_7745c5c3_Err
1372+ }
1373+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, " ")
1374+ if templ_7745c5c3_Err != nil {
1375+ return templ_7745c5c3_Err
1376+ }
1377+ templ_7745c5c3_Err = JobHead(p.JobRow, p.Total).Render(ctx, templ_7745c5c3_Buffer)
1378+ if templ_7745c5c3_Err != nil {
1379+ return templ_7745c5c3_Err
1380+ }
1381+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " <div id=\"job-log\" class=\"joblog\" data-total=\"")
1382+ if templ_7745c5c3_Err != nil {
1383+ return templ_7745c5c3_Err
1384+ }
1385+ var templ_7745c5c3_Var74 string
1386+ templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.Total))
1387+ if templ_7745c5c3_Err != nil {
1388+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 63}
1389+ }
1390+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var74)
1391+ if templ_7745c5c3_Err != nil {
1392+ return templ_7745c5c3_Err
1393+ }
1394+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\"")
1395+ if templ_7745c5c3_Err != nil {
1396+ return templ_7745c5c3_Err
1397+ }
1398+ if p.Follow {
1399+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " data-follow")
1400+ if templ_7745c5c3_Err != nil {
1401+ return templ_7745c5c3_Err
1402+ }
1403+ }
1404+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " data-stream=\"")
1405+ if templ_7745c5c3_Err != nil {
1406+ return templ_7745c5c3_Err
1407+ }
1408+ var templ_7745c5c3_Var75 string
1409+ templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.StreamHref)
1410+ if templ_7745c5c3_Err != nil {
1411+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 118}
1412+ }
1413+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var75)
1414+ if templ_7745c5c3_Err != nil {
1415+ return templ_7745c5c3_Err
1416+ }
1417+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" data-next=\"")
1418+ if templ_7745c5c3_Err != nil {
1419+ return templ_7745c5c3_Err
1420+ }
1421+ var templ_7745c5c3_Var76 string
1422+ templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + int64(len(p.Lines))))
1423+ if templ_7745c5c3_Err != nil {
1424+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 189, Col: 169}
1425+ }
1426+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var76)
1427+ if templ_7745c5c3_Err != nil {
1428+ return templ_7745c5c3_Err
1429+ }
1430+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\">")
1431+ if templ_7745c5c3_Err != nil {
1432+ return templ_7745c5c3_Err
1433+ }
1434+ if p.Note != "" {
1435+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<p class=\"dim\">")
1436+ if templ_7745c5c3_Err != nil {
1437+ return templ_7745c5c3_Err
1438+ }
1439+ var templ_7745c5c3_Var77 string
1440+ templ_7745c5c3_Var77, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
1441+ if templ_7745c5c3_Err != nil {
1442+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 191, Col: 27}
1443+ }
1444+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var77))
1445+ if templ_7745c5c3_Err != nil {
1446+ return templ_7745c5c3_Err
1447+ }
1448+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</p>")
1449+ if templ_7745c5c3_Err != nil {
1450+ return templ_7745c5c3_Err
1451+ }
1452+ }
1453+ if p.Prev != "" {
1454+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "<p class=\"more\"><a href=\"")
1455+ if templ_7745c5c3_Err != nil {
1456+ return templ_7745c5c3_Err
1457+ }
1458+ var templ_7745c5c3_Var78 templ.SafeURL
1459+ templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Prev))
1460+ if templ_7745c5c3_Err != nil {
1461+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 194, Col: 51}
1462+ }
1463+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78))
1464+ if templ_7745c5c3_Err != nil {
1465+ return templ_7745c5c3_Err
1466+ }
1467+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "\" rel=\"prev\">earlier lines</a></p>")
1468+ if templ_7745c5c3_Err != nil {
1469+ return templ_7745c5c3_Err
1470+ }
1471+ }
1472+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<ol class=\"log\" start=\"")
1473+ if templ_7745c5c3_Err != nil {
1474+ return templ_7745c5c3_Err
1475+ }
1476+ var templ_7745c5c3_Var79 string
1477+ templ_7745c5c3_Var79, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa64(p.From + 1))
1478+ if templ_7745c5c3_Err != nil {
1479+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 196, Col: 45}
1480+ }
1481+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var79)
1482+ if templ_7745c5c3_Err != nil {
1483+ return templ_7745c5c3_Err
1484+ }
1485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "\">")
1486+ if templ_7745c5c3_Err != nil {
1487+ return templ_7745c5c3_Err
1488+ }
1489+ for _, ln := range p.Lines {
1490+ templ_7745c5c3_Err = LogLine(ln).Render(ctx, templ_7745c5c3_Buffer)
1491+ if templ_7745c5c3_Err != nil {
1492+ return templ_7745c5c3_Err
1493+ }
1494+ }
1495+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</ol>")
1496+ if templ_7745c5c3_Err != nil {
1497+ return templ_7745c5c3_Err
1498+ }
1499+ if p.Next != "" {
1500+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "<p class=\"more\"><a href=\"")
1501+ if templ_7745c5c3_Err != nil {
1502+ return templ_7745c5c3_Err
1503+ }
1504+ var templ_7745c5c3_Var80 templ.SafeURL
1505+ templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Next))
1506+ if templ_7745c5c3_Err != nil {
1507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 202, Col: 51}
1508+ }
1509+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var80))
1510+ if templ_7745c5c3_Err != nil {
1511+ return templ_7745c5c3_Err
1512+ }
1513+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" rel=\"next\">more lines</a></p>")
1514+ if templ_7745c5c3_Err != nil {
1515+ return templ_7745c5c3_Err
1516+ }
1517+ }
1518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</div>")
1519+ if templ_7745c5c3_Err != nil {
1520+ return templ_7745c5c3_Err
1521+ }
1522+ return nil
1523+ })
1524+ templ_7745c5c3_Err = Shell(l, jobRail(p)).Render(templ.WithChildren(ctx, templ_7745c5c3_Var73), templ_7745c5c3_Buffer)
1525+ if templ_7745c5c3_Err != nil {
1526+ return templ_7745c5c3_Err
1527+ }
1528+ return nil
1529+ })
1530+}
1531+
1532+// LogLine is a line of the log. Group and error markers of the runner show as
1533+// headings and errors, never as raw text.
1534+func LogLine(ln LogLineView) templ.Component {
1535+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1536+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1537+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1538+ return templ_7745c5c3_CtxErr
1539+ }
1540+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1541+ if !templ_7745c5c3_IsBuffer {
1542+ defer func() {
1543+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1544+ if templ_7745c5c3_Err == nil {
1545+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1546+ }
1547+ }()
1548+ }
1549+ ctx = templ.InitializeContext(ctx)
1550+ templ_7745c5c3_Var81 := templ.GetChildren(ctx)
1551+ if templ_7745c5c3_Var81 == nil {
1552+ templ_7745c5c3_Var81 = templ.NopComponent
1553+ }
1554+ ctx = templ.ClearChildren(ctx)
1555+ var templ_7745c5c3_Var82 = []any{logClass(ln)}
1556+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var82...)
1557+ if templ_7745c5c3_Err != nil {
1558+ return templ_7745c5c3_Err
1559+ }
1560+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<li id=\"")
1561+ if templ_7745c5c3_Err != nil {
1562+ return templ_7745c5c3_Err
1563+ }
1564+ var templ_7745c5c3_Var83 string
1565+ templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.ResolveAttributeValue("L" + itoa64(ln.N))
1566+ if templ_7745c5c3_Err != nil {
1567+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 28}
1568+ }
1569+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var83)
1570+ if templ_7745c5c3_Err != nil {
1571+ return templ_7745c5c3_Err
1572+ }
1573+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" class=\"")
1574+ if templ_7745c5c3_Err != nil {
1575+ return templ_7745c5c3_Err
1576+ }
1577+ var templ_7745c5c3_Var84 string
1578+ templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var82).String())
1579+ if templ_7745c5c3_Err != nil {
1580+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 1, Col: 0}
1581+ }
1582+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var84)
1583+ if templ_7745c5c3_Err != nil {
1584+ return templ_7745c5c3_Err
1585+ }
1586+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "\" data-step=\"")
1587+ if templ_7745c5c3_Err != nil {
1588+ return templ_7745c5c3_Err
1589+ }
1590+ var templ_7745c5c3_Var85 string
1591+ templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(int(ln.Step)))
1592+ if templ_7745c5c3_Err != nil {
1593+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 84}
1594+ }
1595+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85)
1596+ if templ_7745c5c3_Err != nil {
1597+ return templ_7745c5c3_Err
1598+ }
1599+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\"><time>")
1600+ if templ_7745c5c3_Err != nil {
1601+ return templ_7745c5c3_Err
1602+ }
1603+ var templ_7745c5c3_Var86 string
1604+ templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Time)
1605+ if templ_7745c5c3_Err != nil {
1606+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 102}
1607+ }
1608+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1609+ if templ_7745c5c3_Err != nil {
1610+ return templ_7745c5c3_Err
1611+ }
1612+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</time><span class=\"t\">")
1613+ if templ_7745c5c3_Err != nil {
1614+ return templ_7745c5c3_Err
1615+ }
1616+ var templ_7745c5c3_Var87 string
1617+ templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Text)
1618+ if templ_7745c5c3_Err != nil {
1619+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 211, Col: 136}
1620+ }
1621+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
1622+ if templ_7745c5c3_Err != nil {
1623+ return templ_7745c5c3_Err
1624+ }
1625+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</span></li>")
1626+ if templ_7745c5c3_Err != nil {
1627+ return templ_7745c5c3_Err
1628+ }
1629+ return nil
1630+ })
1631+}
1632+
1633+func jobRail(p JobPage) templ.Component {
1634+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1635+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1636+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1637+ return templ_7745c5c3_CtxErr
1638+ }
1639+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1640+ if !templ_7745c5c3_IsBuffer {
1641+ defer func() {
1642+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1643+ if templ_7745c5c3_Err == nil {
1644+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1645+ }
1646+ }()
1647+ }
1648+ ctx = templ.InitializeContext(ctx)
1649+ templ_7745c5c3_Var88 := templ.GetChildren(ctx)
1650+ if templ_7745c5c3_Var88 == nil {
1651+ templ_7745c5c3_Var88 = templ.NopComponent
1652+ }
1653+ ctx = templ.ClearChildren(ctx)
1654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<section aria-labelledby=\"r-job\"><h2 id=\"r-job\">Steps</h2>")
1655+ if templ_7745c5c3_Err != nil {
1656+ return templ_7745c5c3_Err
1657+ }
1658+ templ_7745c5c3_Err = JobSteps(p.Steps).Render(ctx, templ_7745c5c3_Buffer)
1659+ if templ_7745c5c3_Err != nil {
1660+ return templ_7745c5c3_Err
1661+ }
1662+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</section><section aria-labelledby=\"r-jobf\"><h2 id=\"r-jobf\">Job</h2><dl class=\"kv\"><dt>run</dt><dd><a href=\"")
1663+ if templ_7745c5c3_Err != nil {
1664+ return templ_7745c5c3_Err
1665+ }
1666+ var templ_7745c5c3_Var89 templ.SafeURL
1667+ templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RunHref))
1668+ if templ_7745c5c3_Err != nil {
1669+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 223, Col: 41}
1670+ }
1671+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1672+ if templ_7745c5c3_Err != nil {
1673+ return templ_7745c5c3_Err
1674+ }
1675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "\">")
1676+ if templ_7745c5c3_Err != nil {
1677+ return templ_7745c5c3_Err
1678+ }
1679+ var templ_7745c5c3_Var90 string
1680+ templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(p.RunName)
1681+ if templ_7745c5c3_Err != nil {
1682+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 223, Col: 55}
1683+ }
1684+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
1685+ if templ_7745c5c3_Err != nil {
1686+ return templ_7745c5c3_Err
1687+ }
1688+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</a></dd>")
1689+ if templ_7745c5c3_Err != nil {
1690+ return templ_7745c5c3_Err
1691+ }
1692+ if p.Runner != "" {
1693+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "<dt>runner</dt><dd>")
1694+ if templ_7745c5c3_Err != nil {
1695+ return templ_7745c5c3_Err
1696+ }
1697+ var templ_7745c5c3_Var91 string
1698+ templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(p.Runner)
1699+ if templ_7745c5c3_Err != nil {
1700+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 226, Col: 18}
1701+ }
1702+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1703+ if templ_7745c5c3_Err != nil {
1704+ return templ_7745c5c3_Err
1705+ }
1706+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "</dd>")
1707+ if templ_7745c5c3_Err != nil {
1708+ return templ_7745c5c3_Err
1709+ }
1710+ }
1711+ if p.Isolation != "" {
1712+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "<dt>isolation</dt><dd>")
1713+ if templ_7745c5c3_Err != nil {
1714+ return templ_7745c5c3_Err
1715+ }
1716+ var templ_7745c5c3_Var92 string
1717+ templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(p.Isolation)
1718+ if templ_7745c5c3_Err != nil {
1719+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 230, Col: 21}
1720+ }
1721+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1722+ if templ_7745c5c3_Err != nil {
1723+ return templ_7745c5c3_Err
1724+ }
1725+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</dd>")
1726+ if templ_7745c5c3_Err != nil {
1727+ return templ_7745c5c3_Err
1728+ }
1729+ }
1730+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<dt>lines</dt><dd>")
1731+ if templ_7745c5c3_Err != nil {
1732+ return templ_7745c5c3_Err
1733+ }
1734+ var templ_7745c5c3_Var93 string
1735+ templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.JoinStringErrs(itoa64(p.Total))
1736+ if templ_7745c5c3_Err != nil {
1737+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 233, Col: 24}
1738+ }
1739+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var93))
1740+ if templ_7745c5c3_Err != nil {
1741+ return templ_7745c5c3_Err
1742+ }
1743+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</dd></dl><p><a href=\"")
1744+ if templ_7745c5c3_Err != nil {
1745+ return templ_7745c5c3_Err
1746+ }
1747+ var templ_7745c5c3_Var94 templ.SafeURL
1748+ templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.RawHref))
1749+ if templ_7745c5c3_Err != nil {
1750+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 235, Col: 39}
1751+ }
1752+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
1753+ if templ_7745c5c3_Err != nil {
1754+ return templ_7745c5c3_Err
1755+ }
1756+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "\">raw log</a></p></section>")
1757+ if templ_7745c5c3_Err != nil {
1758+ return templ_7745c5c3_Err
1759+ }
1760+ return nil
1761+ })
1762+}
1763+
1764+// CommitChecks is the combined state of the checks of a commit, as a mark
1765+// with a count, linking to the page that explains it.
1766+func CommitChecks(c ChecksSummary) templ.Component {
1767+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1768+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1769+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1770+ return templ_7745c5c3_CtxErr
1771+ }
1772+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1773+ if !templ_7745c5c3_IsBuffer {
1774+ defer func() {
1775+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1776+ if templ_7745c5c3_Err == nil {
1777+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1778+ }
1779+ }()
1780+ }
1781+ ctx = templ.InitializeContext(ctx)
1782+ templ_7745c5c3_Var95 := templ.GetChildren(ctx)
1783+ if templ_7745c5c3_Var95 == nil {
1784+ templ_7745c5c3_Var95 = templ.NopComponent
1785+ }
1786+ ctx = templ.ClearChildren(ctx)
1787+ if c.Total > 0 {
1788+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<a class=\"ck-sum\" href=\"")
1789+ if templ_7745c5c3_Err != nil {
1790+ return templ_7745c5c3_Err
1791+ }
1792+ var templ_7745c5c3_Var96 templ.SafeURL
1793+ templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
1794+ if templ_7745c5c3_Err != nil {
1795+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 48}
1796+ }
1797+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1798+ if templ_7745c5c3_Err != nil {
1799+ return templ_7745c5c3_Err
1800+ }
1801+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "\" title=\"")
1802+ if templ_7745c5c3_Err != nil {
1803+ return templ_7745c5c3_Err
1804+ }
1805+ var templ_7745c5c3_Var97 string
1806+ templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title)
1807+ if templ_7745c5c3_Err != nil {
1808+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 243, Col: 66}
1809+ }
1810+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var97)
1811+ if templ_7745c5c3_Err != nil {
1812+ return templ_7745c5c3_Err
1813+ }
1814+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "\">")
1815+ if templ_7745c5c3_Err != nil {
1816+ return templ_7745c5c3_Err
1817+ }
1818+ templ_7745c5c3_Err = CheckIcon(c.Kind, c.State).Render(ctx, templ_7745c5c3_Buffer)
1819+ if templ_7745c5c3_Err != nil {
1820+ return templ_7745c5c3_Err
1821+ }
1822+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "<span class=\"mute\">")
1823+ if templ_7745c5c3_Err != nil {
1824+ return templ_7745c5c3_Err
1825+ }
1826+ var templ_7745c5c3_Var98 string
1827+ templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label)
1828+ if templ_7745c5c3_Err != nil {
1829+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 245, Col: 31}
1830+ }
1831+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98))
1832+ if templ_7745c5c3_Err != nil {
1833+ return templ_7745c5c3_Err
1834+ }
1835+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</span></a>")
1836+ if templ_7745c5c3_Err != nil {
1837+ return templ_7745c5c3_Err
1838+ }
1839+ }
1840+ return nil
1841+ })
1842+}
1843+
1844+// MRChecks is the checks section of the rail of a merge request.
1845+func MRChecks(c ChecksSection) templ.Component {
1846+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
1847+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
1848+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
1849+ return templ_7745c5c3_CtxErr
1850+ }
1851+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
1852+ if !templ_7745c5c3_IsBuffer {
1853+ defer func() {
1854+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
1855+ if templ_7745c5c3_Err == nil {
1856+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
1857+ }
1858+ }()
1859+ }
1860+ ctx = templ.InitializeContext(ctx)
1861+ templ_7745c5c3_Var99 := templ.GetChildren(ctx)
1862+ if templ_7745c5c3_Var99 == nil {
1863+ templ_7745c5c3_Var99 = templ.NopComponent
1864+ }
1865+ ctx = templ.ClearChildren(ctx)
1866+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">")
1867+ if templ_7745c5c3_Err != nil {
1868+ return templ_7745c5c3_Err
1869+ }
1870+ var templ_7745c5c3_Var100 string
1871+ templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows)))
1872+ if templ_7745c5c3_Err != nil {
1873+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 253, Col: 66}
1874+ }
1875+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
1876+ if templ_7745c5c3_Err != nil {
1877+ return templ_7745c5c3_Err
1878+ }
1879+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "</span></h2>")
1880+ if templ_7745c5c3_Err != nil {
1881+ return templ_7745c5c3_Err
1882+ }
1883+ if len(c.Rows) == 0 {
1884+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "<p class=\"dim\">no checks</p>")
1885+ if templ_7745c5c3_Err != nil {
1886+ return templ_7745c5c3_Err
1887+ }
1888+ } else {
1889+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<ul class=\"checks\">")
1890+ if templ_7745c5c3_Err != nil {
1891+ return templ_7745c5c3_Err
1892+ }
1893+ for _, r := range c.Rows {
1894+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "<li>")
1895+ if templ_7745c5c3_Err != nil {
1896+ return templ_7745c5c3_Err
1897+ }
1898+ templ_7745c5c3_Err = CheckIcon(r.Kind, r.State).Render(ctx, templ_7745c5c3_Buffer)
1899+ if templ_7745c5c3_Err != nil {
1900+ return templ_7745c5c3_Err
1901+ }
1902+ if r.Href != "" {
1903+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "<a href=\"")
1904+ if templ_7745c5c3_Err != nil {
1905+ return templ_7745c5c3_Err
1906+ }
1907+ var templ_7745c5c3_Var101 templ.SafeURL
1908+ templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
1909+ if templ_7745c5c3_Err != nil {
1910+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 262, Col: 38}
1911+ }
1912+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
1913+ if templ_7745c5c3_Err != nil {
1914+ return templ_7745c5c3_Err
1915+ }
1916+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "\">")
1917+ if templ_7745c5c3_Err != nil {
1918+ return templ_7745c5c3_Err
1919+ }
1920+ var templ_7745c5c3_Var102 string
1921+ templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1922+ if templ_7745c5c3_Err != nil {
1923+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 262, Col: 49}
1924+ }
1925+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102))
1926+ if templ_7745c5c3_Err != nil {
1927+ return templ_7745c5c3_Err
1928+ }
1929+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "</a> ")
1930+ if templ_7745c5c3_Err != nil {
1931+ return templ_7745c5c3_Err
1932+ }
1933+ } else {
1934+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "<span>")
1935+ if templ_7745c5c3_Err != nil {
1936+ return templ_7745c5c3_Err
1937+ }
1938+ var templ_7745c5c3_Var103 string
1939+ templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
1940+ if templ_7745c5c3_Err != nil {
1941+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 264, Col: 21}
1942+ }
1943+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var103))
1944+ if templ_7745c5c3_Err != nil {
1945+ return templ_7745c5c3_Err
1946+ }
1947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 156, "</span> ")
1948+ if templ_7745c5c3_Err != nil {
1949+ return templ_7745c5c3_Err
1950+ }
1951+ }
1952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 157, "<span class=\"dim\">")
1953+ if templ_7745c5c3_Err != nil {
1954+ return templ_7745c5c3_Err
1955+ }
1956+ var templ_7745c5c3_Var104 string
1957+ templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(r.State)
1958+ if templ_7745c5c3_Err != nil {
1959+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 266, Col: 33}
1960+ }
1961+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
1962+ if templ_7745c5c3_Err != nil {
1963+ return templ_7745c5c3_Err
1964+ }
1965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 158, "</span> ")
1966+ if templ_7745c5c3_Err != nil {
1967+ return templ_7745c5c3_Err
1968+ }
1969+ if r.Waiting {
1970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 159, "<span class=\"mute\">waiting for a runner</span>")
1971+ if templ_7745c5c3_Err != nil {
1972+ return templ_7745c5c3_Err
1973+ }
1974+ }
1975+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 160, "</li>")
1976+ if templ_7745c5c3_Err != nil {
1977+ return templ_7745c5c3_Err
1978+ }
1979+ }
1980+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 161, "</ul>")
1981+ if templ_7745c5c3_Err != nil {
1982+ return templ_7745c5c3_Err
1983+ }
1984+ }
1985+ if c.Note != "" {
1986+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 162, "<p class=\"mute\">")
1987+ if templ_7745c5c3_Err != nil {
1988+ return templ_7745c5c3_Err
1989+ }
1990+ var templ_7745c5c3_Var105 string
1991+ templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note)
1992+ if templ_7745c5c3_Err != nil {
1993+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 275, Col: 27}
1994+ }
1995+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
1996+ if templ_7745c5c3_Err != nil {
1997+ return templ_7745c5c3_Err
1998+ }
1999+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 163, "</p>")
2000+ if templ_7745c5c3_Err != nil {
2001+ return templ_7745c5c3_Err
2002+ }
2003+ }
2004+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 164, "</section>")
2005+ if templ_7745c5c3_Err != nil {
2006+ return templ_7745c5c3_Err
2007+ }
2008+ return nil
2009+ })
2010+}
2011+
2012+var _ = templruntime.GeneratedTemplate
M internal/adapters/web/commit.go +3 -0
@@ -44,6 +44,8 @@ type CommitPage struct {
4444 // Invisible is true when some line holds an invisible character, shown as a
4545 // marker.
4646 Invisible bool
47+ // Checks is the combined state of the CI checks of the commit.
48+ Checks ChecksSummary
4749 }
4850
4951 // DiffView is how a page lays its diff out: the layouts in the page and the
@@ -138,6 +140,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
138140 }
139141 }
140142 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
143+ p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
141144
142145 lines, invisible := diffLines(p.Files)
143146 p.Invisible = invisible
M internal/adapters/web/commits.go +9 -2
@@ -73,7 +73,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
7373 if token != "" {
7474 p.Newest = base
7575 }
76- s.fillDays(&p, d, cs)
76+ s.fillDays(ctx, &p, d, cs)
7777 if sel.path != "" && token == "" {
7878 p.Open = s.pathLink(ctx, d, sel)
7979 }
@@ -95,14 +95,21 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
9595 return Commits(l, p), http.StatusOK, nil
9696 }
9797
98+// maxCheckLookups bounds the commits of one page whose checks are read: each
99+// is one read of the store.
100+const maxCheckLookups = 100
101+
98102 // fillDays groups the commits of a page by day and lists the refs among them.
99-func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) {
103+func (s *Server) fillDays(ctx context.Context, p *CommitsPage, d *repoData, cs []domain.Commit) {
100104 now := s.cfg.Now()
101105 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
102106 for i, c := range cs {
103107 row := commitRow(d.name(), c, now)
104108 row.ID, row.Cursor = "c-"+row.Short, i == 0
105109 row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()]
110+ if i < maxCheckLookups {
111+ row.Checks = checksSummary(d.repo.Name, c.ID.String(), s.checksFor(ctx, d.repo.Name, c.ID.String()))
112+ }
106113 day := "unknown date"
107114 if t := c.Committer.Time; !t.IsZero() {
108115 day = t.UTC().Format("2006-01-02")
M internal/adapters/web/components.templ +1 -0
@@ -112,6 +112,7 @@ templ CommitRows(rows []CommitRow) {
112112 <span class="pill tag"><span class="sr">tag </span>{ t }</span>
113113 }
114114 </td>
115+ <td class="c-ck">@CommitChecks(c.Checks)</td>
115116 <td class="c-who dim">{ c.Author }</td>
116117 <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td>
117118 </tr>
M internal/adapters/web/components_templ.go +18 -10 generated file: collapsed
M internal/adapters/web/diff.templ +4 -0
@@ -40,6 +40,10 @@ templ Commit(l Layout, p CommitPage) {
4040 { " · diff against the first parent" }
4141 }
4242 </dd>
43+ if p.Checks.Total > 0 {
44+ <dt>checks</dt>
45+ <dd>@CommitChecks(p.Checks)</dd>
46+ }
4347 if len(p.Refs) > 0 {
4448 <dt>refs</dt>
4549 <dd>
M internal/adapters/web/diff_templ.go +180 -166 generated file: collapsed
M internal/adapters/web/events.go +9 -1
@@ -31,6 +31,9 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3232 // GET /-/events?topic=repo:<name>&view=mrs
3333 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34+// GET /-/events?topic=repo:<name>&view=actions
35+// GET /-/events?topic=repo:<name>&view=run&n=<run id>
36+// GET /-/events?topic=repo:<name>&view=job&job=<job id>
3437 //
3538 // topic is the Watch topic. view says which fragments of a repository the
3639 // page shows, so that the server renders only those (summary when absent).
@@ -50,6 +53,11 @@ import (
5053 // commits-head data: <div id="commits-head">N new commits — reload</div>
5154 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
5255 // something changed, with the link that reloads the page
56+// ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or
57+// <div id="run-body">…</div> (one run)
58+// ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59+// itself is never sent in the stream, the page reads the lines after
60+// the last it holds from /raw?from=
5361 // gap data: gap; events were lost, or what the page shows is gone or
5462 // changed: the page fetches itself again (ermiteLive in ermite.js)
5563 //
@@ -194,7 +202,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
194202 return
195203 }
196204
197- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")}
205+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")}
198206 handle := func(m recvd) bool {
199207 if m.err != nil {
200208 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
M internal/adapters/web/fragments.go +109 -2
@@ -7,6 +7,7 @@ import (
77 "context"
88 "errors"
99 "strconv"
10+ "strings"
1011 "sync"
1112 "time"
1213
@@ -23,7 +24,8 @@ type liveStream struct {
2324 view string // "summary", "refs" or "commits"
2425 ref string // commits: the branch listed
2526 head string // commits: the commit listed first
26- n string // mr: the number of the merge request shown
27+ n string // mr: the number of the merge request shown; run: the run id
28+ job string // job: the id of the job shown
2729 }
2830
2931 // renderFragment renders a component to the data of an event.
@@ -46,7 +48,8 @@ func liveEvent(we ports.WatchEvent) bool {
4648 }
4749 switch we.Event.(type) {
4850 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
49- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded:
51+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded,
52+ domain.RunUpdated, domain.JobUpdated:
5053 return true
5154 }
5255 return false
@@ -117,6 +120,10 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
117120 // repoPage handles the events of the topic of one repository.
118121 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
119122 switch ev := we.Event.(type) {
123+ case domain.RunUpdated:
124+ return l.runEvent(ctx, ev)
125+ case domain.JobUpdated:
126+ return l.jobEvent(ctx, ev)
120127 case domain.RepoDeleted, domain.RepoCreated:
121128 // Gone, or a new repository under the same name: whatever the page
122129 // shows is not true any more.
@@ -395,3 +402,103 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
395402 f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
396403 return []fragment{f}, err
397404 }
405+
406+// runEvent updates the pages of CI that a RunUpdated changes: the row of the
407+// list of runs, or the body of the run page shown. Only a state is sent: the
408+// events carry no log and no text of the workflow.
409+func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
410+ if l.s.cfg.Runs == nil {
411+ return nil, nil
412+ }
413+ switch l.view {
414+ case "actions":
415+ f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now())))
416+ if err != nil {
417+ return nil, err
418+ }
419+ if id, ok := rowIsNew(ev.Run); ok {
420+ f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
421+ _ = id
422+ }
423+ return []fragment{f}, nil
424+ case "run":
425+ if strconv.FormatInt(ev.Run.ID, 10) != l.n {
426+ return nil, nil
427+ }
428+ return l.runBody(ctx)
429+ }
430+ return nil, nil
431+}
432+
433+// rowIsNew is true for a run created a moment ago: the list adds its row.
434+func rowIsNew(run domain.CIRun) (int64, bool) { return run.ID, run.Status == domain.CIQueued }
435+
436+// jobEvent updates the body of the run page and the steps of the job page.
437+func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
438+ if l.s.cfg.Runs == nil {
439+ return nil, nil
440+ }
441+ switch l.view {
442+ case "run":
443+ if strconv.FormatInt(ev.Job.RunID, 10) != l.n {
444+ return nil, nil
445+ }
446+ return l.runBody(ctx)
447+ case "job":
448+ if ev.Job.ID != l.job {
449+ return nil, nil
450+ }
451+ return l.jobState(ctx)
452+ }
453+ return nil, nil
454+}
455+
456+// runBody renders again the jobs of the run page.
457+func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) {
458+ id, err := strconv.ParseInt(l.n, 10, 64)
459+ if err != nil {
460+ return nil, nil
461+ }
462+ d, err := l.s.loadRepo(ctx, l.repo)
463+ if err != nil {
464+ return nil, err
465+ }
466+ p, err := l.s.runPage(ctx, d, id, 0)
467+ if err != nil {
468+ return nil, err
469+ }
470+ body, err := renderFragment(ctx, "ci-run", runBodyFragment(p))
471+ if err != nil {
472+ return nil, err
473+ }
474+ // The buttons depend on the state: the page fetches itself again when the
475+ // run completed, so that the owner gets the right ones.
476+ if !p.Active {
477+ return []fragment{body, {event: "gap", html: "gap"}}, nil
478+ }
479+ return []fragment{body}, nil
480+}
481+
482+// jobState tells the job page that its state or its steps changed: the page
483+// reloads the part of the log after the last line it holds (ermite.js).
484+func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
485+ d, err := l.s.loadRepo(ctx, l.repo)
486+ if err != nil {
487+ return nil, err
488+ }
489+ view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0)
490+ if err != nil {
491+ return nil, err
492+ }
493+ now := l.s.cfg.Now()
494+ row := l.s.jobRow(d.name(), view, now)
495+ f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines))
496+ if err != nil {
497+ return nil, err
498+ }
499+ steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now)))
500+ if err != nil {
501+ return nil, err
502+ }
503+ return []fragment{f, steps}, nil
504+}
M internal/adapters/web/fragments.templ +1 -1
@@ -101,7 +101,7 @@ templ Live(l Layout) {
101101 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
102102 htmx:sse:error -> ermiteLive.error(event.detail.status);
103103 htmx:sse:before:connection -> ermiteLive.gate(event);
104- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
104+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
105105 gap -> ermiteLive.reload()"
106106 ></span>
107107 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +8 -0
@@ -205,6 +205,8 @@ type MRPage struct {
205205 CommitsNote string
206206 Modes []string
207207 ChecksBlock bool
208+ // Checks are the CI checks of the head of the request.
209+ Checks ChecksSection
208210
209211 Box *MergeBox
210212 // Live is true when the page announces changes.
@@ -456,6 +458,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
456458 }
457459 }
458460 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
461+ if open && head != "" {
462+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)
463+ } else if head != "" {
464+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head), now)
465+ p.Checks.Note = ""
466+ }
459467 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
460468
461469 // The commits of the request, newest first.
M internal/adapters/web/mr.templ +1 -5
@@ -302,11 +302,7 @@ templ mrRail(p MRPage) {
302302 if p.Box != nil {
303303 @mergeBox(p)
304304 }
305- <section aria-labelledby="r-checks">
306- <h2 id="r-checks">Checks <span class="count">0</span></h2>
307- <p class="dim">no checks</p>
308- <p class="mute">there is no CI yet: nothing blocks the merge.</p>
309- </section>
305+ @MRChecks(p.Checks)
310306 <section aria-labelledby="r-settings">
311307 <h2 id="r-settings">Settings</h2>
312308 <dl class="kv">
M internal/adapters/web/mr_templ.go +56 -52 generated file: collapsed
M internal/adapters/web/pages.templ +1 -0
@@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) {
128128 } else {
129129 { r.Short }
130130 }
131+ @CommitChecks(r.Checks)
131132 </td>
132133 <td class="c-sub">{ r.Subject }</td>
133134 <td class="c-when dim">
M internal/adapters/web/pages_templ.go +17 -13 generated file: collapsed
M internal/adapters/web/refspage.go +4 -3
@@ -43,14 +43,14 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
4343 return a && !b
4444 })
4545 for i, b := range branches {
46- p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0))
46+ p.Branches = append(p.Branches, s.refRow(ctx, d, b, commits, b.ShortName == def, i == 0))
4747 }
4848 tags := append([]domain.Ref(nil), d.tags...)
4949 sort.SliceStable(tags, func(i, j int) bool {
5050 return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at
5151 })
5252 for i, t := range tags {
53- p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0))
53+ p.Tags = append(p.Tags, s.refRow(ctx, d, t, commits, false, len(p.Branches) == 0 && i == 0))
5454 }
5555 if len(tags) > 0 {
5656 p.Latest = tags[0].ShortName
@@ -61,7 +61,7 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
6161 return p
6262 }
6363
64-func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
64+func (s *Server) refRow(ctx context.Context, d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
6565 id := r.CommitID.String()
6666 c := commits[id]
6767 row := RefRow{
@@ -71,6 +71,7 @@ func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit,
7171 }
7272 if id != "" {
7373 row.CHref = repoHref(d.name(), "commit", id)
74+ row.Checks = checksSummary(d.repo.Name, id, s.checksFor(ctx, d.repo.Name, id))
7475 }
7576 return row
7677 }
M internal/adapters/web/repo.go +9 -0
@@ -135,6 +135,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
135135 if s.cfg.MergeRequests != nil {
136136 l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))
137137 }
138+ l.Sections = s.withActions(l.Sections, d.name(), section)
138139 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
139140
140141 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
@@ -161,6 +162,14 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
161162 return l
162163 }
163164
165+// withActions adds the Actions tab when the server runs CI.
166+func (s *Server) withActions(sections []Section, repo, current string) []Section {
167+ if s.cfg.Runs == nil {
168+ return sections
169+ }
170+ return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"})
171+}
172+
164173 // cloneURL is the HTTPS clone URL of a repository.
165174 func (s *Server) cloneURL(name string) string {
166175 return s.cfg.PublicURL + "/" + name + ".git"
A internal/adapters/web/static/css/65-ci.css +53 -0
@@ -0,0 +1,53 @@
1+@layer components {
2+ /* CI: runs, jobs, steps, checks and the log. Hue only where it carries a
3+ verdict, and every mark has its word beside it or in its label. */
4+ .ck {
5+ display: inline-block; width: 14px; height: 14px; margin-right: var(--s1); vertical-align: -2px;
6+ border: 2px solid var(--ink-dim); border-radius: 50%; box-sizing: border-box; flex: none;
7+ }
8+ .ck-ok { background: var(--teal); border-color: var(--teal); }
9+ .ck-bad { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); }
10+ .ck-cancelled { border-style: dashed; }
11+ .ck-skipped { border-color: var(--border-strong); background: var(--bg-raised); }
12+ .ck-queued { border-style: dotted; }
13+ .ck-running { border-color: var(--teal); border-top-color: transparent; animation: ck-spin 1s linear infinite; }
14+ @keyframes ck-spin { to { transform: rotate(360deg); } }
15+ @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }
16+
17+ .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; }
18+ .c-ck { width: 1%; white-space: nowrap; }
19+
20+ .runs .c-st { width: 1%; }
21+ .runs .c-dur, .runs .c-who { white-space: nowrap; }
22+ .runs tr[data-new] { background: var(--bg-raised); }
23+
24+ .run-h { display: flex; align-items: center; gap: var(--s2); margin: 0 0 var(--s4); }
25+ .run-err, .job-err {
26+ max-width: var(--measure); padding: var(--s3) var(--s4); margin: 0 0 var(--s4); white-space: pre-wrap; overflow-wrap: anywhere;
27+ background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--red); border-radius: var(--r-s);
28+ font: inherit; font-size: var(--fs-s);
29+ }
30+
31+ .jobs { display: grid; gap: var(--s4); padding: 0; list-style: none; }
32+ .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
33+ .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; }
34+ .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
35+ .steps li { display: flex; align-items: center; gap: var(--s1); }
36+ .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
37+ .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
38+ .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
39+
40+ .joblog { margin-top: var(--s4); }
41+ .log {
42+ margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto;
43+ font: var(--fs-s) / 1.5 var(--mono); background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s);
44+ }
45+ .log .lg { display: flex; gap: var(--s3); padding: 0 var(--s3); white-space: pre-wrap; overflow-wrap: anywhere; }
46+ .log .lg:target { background: var(--bg-raised); }
47+ .log .lg time { flex: none; color: var(--ink-dim); user-select: none; }
48+ .log .lg .t { min-width: 0; }
49+ .log .group .t { font-weight: 700; }
50+ .log .endgroup { display: none; }
51+ .log .error .t { color: var(--red); }
52+ .joblog .more { margin: var(--s2) 0; font-size: var(--fs-s); }
53+}
M internal/adapters/web/static/js/ermite.js +23 -0
@@ -363,3 +363,26 @@ window.ermiteLive = (() => {
363363 },
364364 };
365365 })();
366+
367+// ermiteLog follows the log of a running job on its page: the lines arrive as
368+// "ci-log" events (internal/adapters/web/actions.go, logStream) and are
369+// appended to the list. The browser reconnects by itself with Last-Event-ID,
370+// the number of the next line; "ci-end" closes the feed when the job is over.
371+// The page scrolls with the log only while the reader is at its end.
372+(() => {
373+ const box = document.getElementById('job-log');
374+ if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return;
375+ const list = box.querySelector('ol.log');
376+ if (!list) return;
377+ const url = box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0');
378+ const es = new EventSource(url);
379+ es.addEventListener('ci-log', (e) => {
380+ const t = document.createElement('template');
381+ t.innerHTML = e.data;
382+ const atEnd = window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
383+ list.append(...t.content.children);
384+ if (atEnd) window.scrollTo(0, document.body.scrollHeight);
385+ });
386+ es.addEventListener('ci-end', () => es.close());
387+ es.onerror = () => { if (es.readyState === EventSource.CLOSED) es.close(); };
388+})();
M internal/adapters/web/view.go +5 -0
@@ -135,6 +135,9 @@ type CommitRow struct {
135135 Branches []string
136136 Tags []string
137137 Cursor bool
138+ // Checks is the combined state of the CI checks of the commit; empty when
139+ // it has none.
140+ Checks ChecksSummary
138141 }
139142
140143 // SummaryPage is the repository summary.
@@ -162,6 +165,8 @@ type LatestTag struct {
162165 type RefRow struct {
163166 Name, Href, Short, CHref, Subject, When, WhenAt string
164167 Default, Cursor bool
168+ // Checks is the combined state of the CI checks of the commit.
169+ Checks ChecksSummary
165170 }
166171
167172 // RefsPage lists the branches and tags of a repository.
M internal/adapters/web/web.go +15 -0
@@ -54,6 +54,10 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58+ // commits and merge requests. Without it there are no such pages. Leave it
59+ // unset rather than assigning a typed nil pointer.
60+ Runs ports.RunService
5761 // Events is the source of the event stream behind /-/events. Without
5862 // it the pages are not live and the route answers 404. Leave it unset
5963 // rather than assigning a typed nil pointer.
@@ -161,6 +165,17 @@ func (s *Server) routes() *http.ServeMux {
161165 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
162166 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
163167 }
168+ if s.cfg.Runs != nil {
169+ mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
170+ mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
171+ mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run))
172+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job))
173+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog)
174+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream)
175+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel))
176+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
177+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
178+ }
164179 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
165180 return mux
166181 }
A internal/app/ci_web_test.go +174 -0
@@ -0,0 +1,174 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/url"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+ "google.golang.org/protobuf/types/known/fieldmaskpb"
15+
16+ pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page,
21+// raw and stream), the owner's buttons, and the checks on the commit, the
22+// branch list, the commit list and the merge request.
23+func TestWebActionsPages(t *testing.T) {
24+ ci := newCIHarness(t, nil)
25+ c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
26+ b := browser{ci.harness}
27+ runner := ci.registerRunner("alpha", "ubuntu-latest")
28+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
29+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
30+ }))
31+ require.NoError(t, err)
32+ ci.push("main", map[string]string{"README": "x\n"}, "")
33+ sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci")
34+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
35+
36+ // Queued: the list shows it, with a word for its state.
37+ list := b.get("/proj/actions", nil)
38+ require.Equal(t, 200, list.status, list.body)
39+ assert.Contains(t, list.body, "queued")
40+ assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)
41+ assert.Contains(t, list.body, `aria-label="queued"`)
42+ assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&amp;view=actions", "the page follows the repository stream")
43+ assert.Contains(t, list.body, ">Actions<", "the tab")
44+
45+ // The MR page says "waiting for a runner" only when no runner matches.
46+ mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
47+ require.NoError(t, err)
48+ mrPage := b.get("/proj/merge-requests/1", nil)
49+ require.Equal(t, 200, mrPage.status)
50+ assert.Contains(t, mrPage.body, `id="mr-checks"`)
51+ assert.Contains(t, mrPage.body, "build")
52+ assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies")
53+ assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches")
54+ _ = mr
55+
56+ job := runner.await()
57+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]")
58+ next := runner.await()
59+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.")
60+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
61+
62+ run := b.get("/proj/actions/runs/1", nil)
63+ require.Equal(t, 200, run.status, run.body)
64+ assert.Contains(t, run.body, "build")
65+ assert.Contains(t, run.body, "test")
66+ assert.Contains(t, run.body, "failure")
67+ assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named")
68+ assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor")
69+
70+ jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
71+ require.Equal(t, 200, jobPage.status, jobPage.body)
72+ assert.Contains(t, jobPage.body, "Run echo hello")
73+ assert.Contains(t, jobPage.body, "hello &lt;b&gt;bold&lt;/b&gt;", "log text is escaped, never markup")
74+ assert.NotContains(t, jobPage.body, "<b>bold</b>")
75+ assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure")
76+ assert.Contains(t, jobPage.body, `class="lg group"`)
77+ failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil)
78+ assert.Contains(t, failed.body, `class="lg error"`)
79+
80+ raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil)
81+ require.Equal(t, 200, raw.status)
82+ assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type"))
83+ assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options"))
84+ assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox")
85+ assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text")
86+ assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body)
87+
88+ // The stream of a finished job sends its lines then ends.
89+ stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil)
90+ require.Equal(t, 200, stream.status)
91+ assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type"))
92+ assert.Contains(t, stream.body, "event: ci-log")
93+ assert.Contains(t, stream.body, "event: ci-end")
94+ assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line")
95+
96+ // Checks next to the commit, the commits list and the branch list.
97+ commit := b.get("/proj/commit/"+sha, nil)
98+ require.Equal(t, 200, commit.status)
99+ assert.Contains(t, commit.body, "checks failed")
100+ assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed")
101+ assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed")
102+
103+ // Private repository: nothing to anonymous.
104+ _, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
105+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
106+ }))
107+ require.NoError(t, err)
108+ for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} {
109+ assert.Equal(t, 404, b.get(p, nil).status, p)
110+ }
111+
112+ // The owner sees the buttons and re-runs the failed job.
113+ token := c.signIn(t)
114+ owner := b.get("/proj/actions/runs/1", as(token))
115+ require.Equal(t, 200, owner.status)
116+ assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`)
117+ assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`)
118+ assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`)
119+ // A POST needs the owner's session and the origin.
120+ assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous")
121+ res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{})
122+ require.Equal(t, 303, res.status, res.body)
123+ ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 })
124+ again := b.get("/proj/actions/runs/1?attempt=1", as(token))
125+ assert.Contains(t, again.body, "attempt 1")
126+ assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button")
127+
128+ cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{})
129+ require.Equal(t, 303, cancel.status, cancel.body)
130+ ci.waitFor("cancelled", func() bool {
131+ return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED
132+ })
133+ assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled")
134+}
135+
136+// The live updates of the pages: the run page gets the body of the run when a
137+// job changes, and the job page its state, never a line of the log.
138+func TestWebActionsLiveFragments(t *testing.T) {
139+ ci := newCIHarness(t, nil)
140+ c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
141+ runner := ci.registerRunner("alpha", "ubuntu-latest")
142+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
143+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
144+ }))
145+ require.NoError(t, err)
146+ ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
147+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
148+ token := c.signIn(t)
149+
150+ run := ci.sse("topic=repo:proj&view=run&n=1", "")
151+ list := ci.sse("topic=repo:proj&view=actions", "")
152+ job := runner.await()
153+ jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
154+ _ = token
155+
156+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")
157+ deadline := time.After(10 * time.Second)
158+ var sawRun, sawList, sawJob bool
159+ for !sawRun || !sawList || !sawJob {
160+ select {
161+ case ev := <-run.events:
162+ sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))
163+ assert.NotContains(t, ev.data, "a secret value in the log")
164+ case ev := <-list.events:
165+ sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`))
166+ case ev := <-jobStream.events:
167+ sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))
168+ assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")
169+ case <-deadline:
170+ t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob)
171+ }
172+ }
173+ _ = domain.CISuccess
174+}
M internal/app/testdata/web/blob-binary.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -308,6 +312,12 @@
308312 <span class="k">section</span>
309313 </a>
310314 </li>
315+<li data-k="section actions">
316+<a href="/files/actions" tabindex="-1">
317+<span class="lbl">actions</span>
318+<span class="k">section</span>
319+</a>
320+</li>
311321 <li data-k="branch feature/x">
312322 <a href="/files/tree/feature/x" tabindex="-1">
313323 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-code.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -378,6 +382,12 @@
378382 <span class="k">section</span>
379383 </a>
380384 </li>
385+<li data-k="section actions">
386+<a href="/files/actions" tabindex="-1">
387+<span class="lbl">actions</span>
388+<span class="k">section</span>
389+</a>
390+</li>
381391 <li data-k="branch feature/x">
382392 <a href="/files/tree/feature/x" tabindex="-1">
383393 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-empty.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -308,6 +312,12 @@
308312 <span class="k">section</span>
309313 </a>
310314 </li>
315+<li data-k="section actions">
316+<a href="/files/actions" tabindex="-1">
317+<span class="lbl">actions</span>
318+<span class="k">section</span>
319+</a>
320+</li>
311321 <li data-k="branch feature/x">
312322 <a href="/files/tree/feature/x" tabindex="-1">
313323 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-image.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -310,6 +314,12 @@
310314 <span class="k">section</span>
311315 </a>
312316 </li>
317+<li data-k="section actions">
318+<a href="/files/actions" tabindex="-1">
319+<span class="lbl">actions</span>
320+<span class="k">section</span>
321+</a>
322+</li>
313323 <li data-k="branch feature/x">
314324 <a href="/files/tree/feature/x" tabindex="-1">
315325 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-markdown.html +10 -0
@@ -86,6 +86,10 @@
8686 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8787 </a>
8888 </li>
89+<li>
90+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+</a>
92+</li>
8993 </ul>
9094 </nav>
9195 </header>
@@ -375,6 +379,12 @@
375379 <span class="k">section</span>
376380 </a>
377381 </li>
382+<li data-k="section actions">
383+<a href="/files/actions" tabindex="-1">
384+<span class="lbl">actions</span>
385+<span class="k">section</span>
386+</a>
387+</li>
378388 <li data-k="branch feature/x">
379389 <a href="/files/tree/feature/x" tabindex="-1">
380390 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-svg.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -333,6 +337,12 @@
333337 <span class="k">section</span>
334338 </a>
335339 </li>
340+<li data-k="section actions">
341+<a href="/files/actions" tabindex="-1">
342+<span class="lbl">actions</span>
343+<span class="k">section</span>
344+</a>
345+</li>
336346 <li data-k="branch feature/x">
337347 <a href="/files/tree/feature/x" tabindex="-1">
338348 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-text.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -328,6 +332,12 @@
328332 <span class="k">section</span>
329333 </a>
330334 </li>
335+<li data-k="section actions">
336+<a href="/files/actions" tabindex="-1">
337+<span class="lbl">actions</span>
338+<span class="k">section</span>
339+</a>
340+</li>
331341 <li data-k="branch feature/x">
332342 <a href="/files/tree/feature/x" tabindex="-1">
333343 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-too-large.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -308,6 +312,12 @@
308312 <span class="k">section</span>
309313 </a>
310314 </li>
315+<li data-k="section actions">
316+<a href="/files/actions" tabindex="-1">
317+<span class="lbl">actions</span>
318+<span class="k">section</span>
319+</a>
320+</li>
311321 <li data-k="branch feature/x">
312322 <a href="/files/tree/feature/x" tabindex="-1">
313323 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-trojan.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -375,6 +379,12 @@
375379 <span class="k">section</span>
376380 </a>
377381 </li>
382+<li data-k="section actions">
383+<a href="/files/actions" tabindex="-1">
384+<span class="lbl">actions</span>
385+<span class="k">section</span>
386+</a>
387+</li>
378388 <li data-k="branch feature/x">
379389 <a href="/files/tree/feature/x" tabindex="-1">
380390 <span class="lbl">feature/x</span>
M internal/app/testdata/web/commit-big.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -345,6 +349,12 @@
345349 <span class="k">section</span>
346350 </a>
347351 </li>
352+<li data-k="section actions">
353+<a href="/diffs/actions" tabindex="-1">
354+<span class="lbl">actions</span>
355+<span class="k">section</span>
356+</a>
357+</li>
348358 <li data-k="branch main">
349359 <a href="/diffs/tree/main" tabindex="-1">
350360 <span class="lbl">main</span>
M internal/app/testdata/web/commit-binary.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -340,6 +344,12 @@
340344 <span class="k">section</span>
341345 </a>
342346 </li>
347+<li data-k="section actions">
348+<a href="/diffs/actions" tabindex="-1">
349+<span class="lbl">actions</span>
350+<span class="k">section</span>
351+</a>
352+</li>
343353 <li data-k="branch main">
344354 <a href="/diffs/tree/main" tabindex="-1">
345355 <span class="lbl">main</span>
M internal/app/testdata/web/commit-merge.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -379,6 +383,12 @@
379383 <span class="k">section</span>
380384 </a>
381385 </li>
386+<li data-k="section actions">
387+<a href="/diffs/actions" tabindex="-1">
388+<span class="lbl">actions</span>
389+<span class="k">section</span>
390+</a>
391+</li>
382392 <li data-k="branch main">
383393 <a href="/diffs/tree/main" tabindex="-1">
384394 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify-split.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -500,6 +504,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500504 <span class="k">section</span>
501505 </a>
502506 </li>
507+<li data-k="section actions">
508+<a href="/diffs/actions" tabindex="-1">
509+<span class="lbl">actions</span>
510+<span class="k">section</span>
511+</a>
512+</li>
503513 <li data-k="branch main">
504514 <a href="/diffs/tree/main" tabindex="-1">
505515 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -500,6 +504,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500504 <span class="k">section</span>
501505 </a>
502506 </li>
507+<li data-k="section actions">
508+<a href="/diffs/actions" tabindex="-1">
509+<span class="lbl">actions</span>
510+<span class="k">section</span>
511+</a>
512+</li>
503513 <li data-k="branch main">
504514 <a href="/diffs/tree/main" tabindex="-1">
505515 <span class="lbl">main</span>
M internal/app/testdata/web/commit-rename.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -405,6 +409,12 @@
405409 <span class="k">section</span>
406410 </a>
407411 </li>
412+<li data-k="section actions">
413+<a href="/diffs/actions" tabindex="-1">
414+<span class="lbl">actions</span>
415+<span class="k">section</span>
416+</a>
417+</li>
408418 <li data-k="branch main">
409419 <a href="/diffs/tree/main" tabindex="-1">
410420 <span class="lbl">main</span>
M internal/app/testdata/web/commit-root.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -721,6 +725,12 @@
721725 <span class="k">section</span>
722726 </a>
723727 </li>
728+<li data-k="section actions">
729+<a href="/diffs/actions" tabindex="-1">
730+<span class="lbl">actions</span>
731+<span class="k">section</span>
732+</a>
733+</li>
724734 <li data-k="branch main">
725735 <a href="/diffs/tree/main" tabindex="-1">
726736 <span class="lbl">main</span>
M internal/app/testdata/web/commit-trojan.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -514,6 +518,12 @@
514518 <span class="k">section</span>
515519 </a>
516520 </li>
521+<li data-k="section actions">
522+<a href="/diffs/actions" tabindex="-1">
523+<span class="lbl">actions</span>
524+<span class="k">section</span>
525+</a>
526+</li>
517527 <li data-k="branch main">
518528 <a href="/diffs/tree/main" tabindex="-1">
519529 <span class="lbl">main</span>
M internal/app/testdata/web/commits-page2.html +14 -0
@@ -80,6 +80,10 @@
8080 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -103,6 +107,8 @@
103107 <td class="c-subject">
104108 <a href="/long/commit/SHA40">commit number 1</a>
105109 </td>
110+<td class="c-ck">
111+</td>
106112 <td class="c-who dim">T</td>
107113 <td class="c-when nowrap dim">
108114 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -115,6 +121,8 @@
115121 <td class="c-subject">
116122 <a href="/long/commit/SHA40">commit number 0</a>
117123 </td>
124+<td class="c-ck">
125+</td>
118126 <td class="c-who dim">T</td>
119127 <td class="c-when nowrap dim">
120128 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -327,6 +335,12 @@
327335 <span class="k">section</span>
328336 </a>
329337 </li>
338+<li data-k="section actions">
339+<a href="/long/actions" tabindex="-1">
340+<span class="lbl">actions</span>
341+<span class="k">section</span>
342+</a>
343+</li>
330344 <li data-k="branch main">
331345 <a href="/long/tree/main" tabindex="-1">
332346 <span class="lbl">main</span>
M internal/app/testdata/web/commits-path.html +14 -0
@@ -83,6 +83,10 @@
8383 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -107,6 +111,8 @@
107111 <td class="c-subject">
108112 <a href="/long/commit/SHA40">commit number 30</a>
109113 </td>
114+<td class="c-ck">
115+</td>
110116 <td class="c-who dim">T</td>
111117 <td class="c-when nowrap dim">
112118 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -128,6 +134,8 @@
128134 <td class="c-subject">
129135 <a href="/long/commit/SHA40">commit number 3</a>
130136 </td>
137+<td class="c-ck">
138+</td>
131139 <td class="c-who dim">T</td>
132140 <td class="c-when nowrap dim">
133141 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -342,6 +350,12 @@
342350 <span class="k">section</span>
343351 </a>
344352 </li>
353+<li data-k="section actions">
354+<a href="/long/actions" tabindex="-1">
355+<span class="lbl">actions</span>
356+<span class="k">section</span>
357+</a>
358+</li>
345359 <li data-k="branch main">
346360 <a href="/long/tree/main" tabindex="-1">
347361 <span class="lbl">main</span>
M internal/app/testdata/web/commits-root.html +111 -1
@@ -80,6 +80,10 @@
8080 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -106,6 +110,8 @@
106110 <a href="/long/commit/SHA40">commit number 51</a>
107111 <span class="pill">main</span>
108112 </td>
113+<td class="c-ck">
114+</td>
109115 <td class="c-who dim">T</td>
110116 <td class="c-when nowrap dim">
111117 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -118,6 +124,8 @@
118124 <td class="c-subject">
119125 <a href="/long/commit/SHA40">commit number 50</a>
120126 </td>
127+<td class="c-ck">
128+</td>
121129 <td class="c-who dim">T</td>
122130 <td class="c-when nowrap dim">
123131 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -139,6 +147,8 @@
139147 <td class="c-subject">
140148 <a href="/long/commit/SHA40">commit number 49</a>
141149 </td>
150+<td class="c-ck">
151+</td>
142152 <td class="c-who dim">T</td>
143153 <td class="c-when nowrap dim">
144154 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -151,6 +161,8 @@
151161 <td class="c-subject">
152162 <a href="/long/commit/SHA40">commit number 48</a>
153163 </td>
164+<td class="c-ck">
165+</td>
154166 <td class="c-who dim">T</td>
155167 <td class="c-when nowrap dim">
156168 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -172,6 +184,8 @@
172184 <td class="c-subject">
173185 <a href="/long/commit/SHA40">commit number 47</a>
174186 </td>
187+<td class="c-ck">
188+</td>
175189 <td class="c-who dim">T</td>
176190 <td class="c-when nowrap dim">
177191 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -184,6 +198,8 @@
184198 <td class="c-subject">
185199 <a href="/long/commit/SHA40">commit number 46</a>
186200 </td>
201+<td class="c-ck">
202+</td>
187203 <td class="c-who dim">T</td>
188204 <td class="c-when nowrap dim">
189205 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -205,6 +221,8 @@
205221 <td class="c-subject">
206222 <a href="/long/commit/SHA40">commit number 45</a>
207223 </td>
224+<td class="c-ck">
225+</td>
208226 <td class="c-who dim">T</td>
209227 <td class="c-when nowrap dim">
210228 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -217,6 +235,8 @@
217235 <td class="c-subject">
218236 <a href="/long/commit/SHA40">commit number 44</a>
219237 </td>
238+<td class="c-ck">
239+</td>
220240 <td class="c-who dim">T</td>
221241 <td class="c-when nowrap dim">
222242 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -238,6 +258,8 @@
238258 <td class="c-subject">
239259 <a href="/long/commit/SHA40">commit number 43</a>
240260 </td>
261+<td class="c-ck">
262+</td>
241263 <td class="c-who dim">T</td>
242264 <td class="c-when nowrap dim">
243265 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -250,6 +272,8 @@
250272 <td class="c-subject">
251273 <a href="/long/commit/SHA40">commit number 42</a>
252274 </td>
275+<td class="c-ck">
276+</td>
253277 <td class="c-who dim">T</td>
254278 <td class="c-when nowrap dim">
255279 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -271,6 +295,8 @@
271295 <td class="c-subject">
272296 <a href="/long/commit/SHA40">commit number 41</a>
273297 </td>
298+<td class="c-ck">
299+</td>
274300 <td class="c-who dim">T</td>
275301 <td class="c-when nowrap dim">
276302 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -283,6 +309,8 @@
283309 <td class="c-subject">
284310 <a href="/long/commit/SHA40">commit number 40</a>
285311 </td>
312+<td class="c-ck">
313+</td>
286314 <td class="c-who dim">T</td>
287315 <td class="c-when nowrap dim">
288316 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,6 +332,8 @@
304332 <td class="c-subject">
305333 <a href="/long/commit/SHA40">commit number 39</a>
306334 </td>
335+<td class="c-ck">
336+</td>
307337 <td class="c-who dim">T</td>
308338 <td class="c-when nowrap dim">
309339 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -316,6 +346,8 @@
316346 <td class="c-subject">
317347 <a href="/long/commit/SHA40">commit number 38</a>
318348 </td>
349+<td class="c-ck">
350+</td>
319351 <td class="c-who dim">T</td>
320352 <td class="c-when nowrap dim">
321353 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -337,6 +369,8 @@
337369 <td class="c-subject">
338370 <a href="/long/commit/SHA40">commit number 37</a>
339371 </td>
372+<td class="c-ck">
373+</td>
340374 <td class="c-who dim">T</td>
341375 <td class="c-when nowrap dim">
342376 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -349,6 +383,8 @@
349383 <td class="c-subject">
350384 <a href="/long/commit/SHA40">commit number 36</a>
351385 </td>
386+<td class="c-ck">
387+</td>
352388 <td class="c-who dim">T</td>
353389 <td class="c-when nowrap dim">
354390 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -370,6 +406,8 @@
370406 <td class="c-subject">
371407 <a href="/long/commit/SHA40">commit number 35</a>
372408 </td>
409+<td class="c-ck">
410+</td>
373411 <td class="c-who dim">T</td>
374412 <td class="c-when nowrap dim">
375413 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -382,6 +420,8 @@
382420 <td class="c-subject">
383421 <a href="/long/commit/SHA40">commit number 34</a>
384422 </td>
423+<td class="c-ck">
424+</td>
385425 <td class="c-who dim">T</td>
386426 <td class="c-when nowrap dim">
387427 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -403,6 +443,8 @@
403443 <td class="c-subject">
404444 <a href="/long/commit/SHA40">commit number 33</a>
405445 </td>
446+<td class="c-ck">
447+</td>
406448 <td class="c-who dim">T</td>
407449 <td class="c-when nowrap dim">
408450 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -415,6 +457,8 @@
415457 <td class="c-subject">
416458 <a href="/long/commit/SHA40">commit number 32</a>
417459 </td>
460+<td class="c-ck">
461+</td>
418462 <td class="c-who dim">T</td>
419463 <td class="c-when nowrap dim">
420464 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -436,6 +480,8 @@
436480 <td class="c-subject">
437481 <a href="/long/commit/SHA40">commit number 31</a>
438482 </td>
483+<td class="c-ck">
484+</td>
439485 <td class="c-who dim">T</td>
440486 <td class="c-when nowrap dim">
441487 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -448,6 +494,8 @@
448494 <td class="c-subject">
449495 <a href="/long/commit/SHA40">commit number 30</a>
450496 </td>
497+<td class="c-ck">
498+</td>
451499 <td class="c-who dim">T</td>
452500 <td class="c-when nowrap dim">
453501 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -469,6 +517,8 @@
469517 <td class="c-subject">
470518 <a href="/long/commit/SHA40">commit number 29</a>
471519 </td>
520+<td class="c-ck">
521+</td>
472522 <td class="c-who dim">T</td>
473523 <td class="c-when nowrap dim">
474524 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -481,6 +531,8 @@
481531 <td class="c-subject">
482532 <a href="/long/commit/SHA40">commit number 28</a>
483533 </td>
534+<td class="c-ck">
535+</td>
484536 <td class="c-who dim">T</td>
485537 <td class="c-when nowrap dim">
486538 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -502,6 +554,8 @@
502554 <td class="c-subject">
503555 <a href="/long/commit/SHA40">commit number 27</a>
504556 </td>
557+<td class="c-ck">
558+</td>
505559 <td class="c-who dim">T</td>
506560 <td class="c-when nowrap dim">
507561 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -514,6 +568,8 @@
514568 <td class="c-subject">
515569 <a href="/long/commit/SHA40">commit number 26</a>
516570 </td>
571+<td class="c-ck">
572+</td>
517573 <td class="c-who dim">T</td>
518574 <td class="c-when nowrap dim">
519575 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -535,6 +591,8 @@
535591 <td class="c-subject">
536592 <a href="/long/commit/SHA40">commit number 25</a>
537593 </td>
594+<td class="c-ck">
595+</td>
538596 <td class="c-who dim">T</td>
539597 <td class="c-when nowrap dim">
540598 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -547,6 +605,8 @@
547605 <td class="c-subject">
548606 <a href="/long/commit/SHA40">commit number 24</a>
549607 </td>
608+<td class="c-ck">
609+</td>
550610 <td class="c-who dim">T</td>
551611 <td class="c-when nowrap dim">
552612 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -568,6 +628,8 @@
568628 <td class="c-subject">
569629 <a href="/long/commit/SHA40">commit number 23</a>
570630 </td>
631+<td class="c-ck">
632+</td>
571633 <td class="c-who dim">T</td>
572634 <td class="c-when nowrap dim">
573635 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -580,6 +642,8 @@
580642 <td class="c-subject">
581643 <a href="/long/commit/SHA40">commit number 22</a>
582644 </td>
645+<td class="c-ck">
646+</td>
583647 <td class="c-who dim">T</td>
584648 <td class="c-when nowrap dim">
585649 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -601,6 +665,8 @@
601665 <td class="c-subject">
602666 <a href="/long/commit/SHA40">commit number 21</a>
603667 </td>
668+<td class="c-ck">
669+</td>
604670 <td class="c-who dim">T</td>
605671 <td class="c-when nowrap dim">
606672 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -613,6 +679,8 @@
613679 <td class="c-subject">
614680 <a href="/long/commit/SHA40">commit number 20</a>
615681 </td>
682+<td class="c-ck">
683+</td>
616684 <td class="c-who dim">T</td>
617685 <td class="c-when nowrap dim">
618686 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -634,6 +702,8 @@
634702 <td class="c-subject">
635703 <a href="/long/commit/SHA40">commit number 19</a>
636704 </td>
705+<td class="c-ck">
706+</td>
637707 <td class="c-who dim">T</td>
638708 <td class="c-when nowrap dim">
639709 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -646,6 +716,8 @@
646716 <td class="c-subject">
647717 <a href="/long/commit/SHA40">commit number 18</a>
648718 </td>
719+<td class="c-ck">
720+</td>
649721 <td class="c-who dim">T</td>
650722 <td class="c-when nowrap dim">
651723 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -667,6 +739,8 @@
667739 <td class="c-subject">
668740 <a href="/long/commit/SHA40">commit number 17</a>
669741 </td>
742+<td class="c-ck">
743+</td>
670744 <td class="c-who dim">T</td>
671745 <td class="c-when nowrap dim">
672746 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -679,6 +753,8 @@
679753 <td class="c-subject">
680754 <a href="/long/commit/SHA40">commit number 16</a>
681755 </td>
756+<td class="c-ck">
757+</td>
682758 <td class="c-who dim">T</td>
683759 <td class="c-when nowrap dim">
684760 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -700,6 +776,8 @@
700776 <td class="c-subject">
701777 <a href="/long/commit/SHA40">commit number 15</a>
702778 </td>
779+<td class="c-ck">
780+</td>
703781 <td class="c-who dim">T</td>
704782 <td class="c-when nowrap dim">
705783 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -712,6 +790,8 @@
712790 <td class="c-subject">
713791 <a href="/long/commit/SHA40">commit number 14</a>
714792 </td>
793+<td class="c-ck">
794+</td>
715795 <td class="c-who dim">T</td>
716796 <td class="c-when nowrap dim">
717797 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -733,6 +813,8 @@
733813 <td class="c-subject">
734814 <a href="/long/commit/SHA40">commit number 13</a>
735815 </td>
816+<td class="c-ck">
817+</td>
736818 <td class="c-who dim">T</td>
737819 <td class="c-when nowrap dim">
738820 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -745,6 +827,8 @@
745827 <td class="c-subject">
746828 <a href="/long/commit/SHA40">commit number 12</a>
747829 </td>
830+<td class="c-ck">
831+</td>
748832 <td class="c-who dim">T</td>
749833 <td class="c-when nowrap dim">
750834 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -766,6 +850,8 @@
766850 <td class="c-subject">
767851 <a href="/long/commit/SHA40">commit number 11</a>
768852 </td>
853+<td class="c-ck">
854+</td>
769855 <td class="c-who dim">T</td>
770856 <td class="c-when nowrap dim">
771857 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -778,6 +864,8 @@
778864 <td class="c-subject">
779865 <a href="/long/commit/SHA40">commit number 10</a>
780866 </td>
867+<td class="c-ck">
868+</td>
781869 <td class="c-who dim">T</td>
782870 <td class="c-when nowrap dim">
783871 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -799,6 +887,8 @@
799887 <td class="c-subject">
800888 <a href="/long/commit/SHA40">commit number 9</a>
801889 </td>
890+<td class="c-ck">
891+</td>
802892 <td class="c-who dim">T</td>
803893 <td class="c-when nowrap dim">
804894 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -811,6 +901,8 @@
811901 <td class="c-subject">
812902 <a href="/long/commit/SHA40">commit number 8</a>
813903 </td>
904+<td class="c-ck">
905+</td>
814906 <td class="c-who dim">T</td>
815907 <td class="c-when nowrap dim">
816908 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -832,6 +924,8 @@
832924 <td class="c-subject">
833925 <a href="/long/commit/SHA40">commit number 7</a>
834926 </td>
927+<td class="c-ck">
928+</td>
835929 <td class="c-who dim">T</td>
836930 <td class="c-when nowrap dim">
837931 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -844,6 +938,8 @@
844938 <td class="c-subject">
845939 <a href="/long/commit/SHA40">commit number 6</a>
846940 </td>
941+<td class="c-ck">
942+</td>
847943 <td class="c-who dim">T</td>
848944 <td class="c-when nowrap dim">
849945 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -865,6 +961,8 @@
865961 <td class="c-subject">
866962 <a href="/long/commit/SHA40">commit number 5</a>
867963 </td>
964+<td class="c-ck">
965+</td>
868966 <td class="c-who dim">T</td>
869967 <td class="c-when nowrap dim">
870968 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -877,6 +975,8 @@
877975 <td class="c-subject">
878976 <a href="/long/commit/SHA40">commit number 4</a>
879977 </td>
978+<td class="c-ck">
979+</td>
880980 <td class="c-who dim">T</td>
881981 <td class="c-when nowrap dim">
882982 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -898,6 +998,8 @@
898998 <td class="c-subject">
899999 <a href="/long/commit/SHA40">commit number 3</a>
9001000 </td>
1001+<td class="c-ck">
1002+</td>
9011003 <td class="c-who dim">T</td>
9021004 <td class="c-when nowrap dim">
9031005 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -910,6 +1012,8 @@
9101012 <td class="c-subject">
9111013 <a href="/long/commit/SHA40">commit number 2</a>
9121014 </td>
1015+<td class="c-ck">
1016+</td>
9131017 <td class="c-who dim">T</td>
9141018 <td class="c-when nowrap dim">
9151019 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -959,7 +1063,7 @@
9591063 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
9601064 htmx:sse:error -> ermiteLive.error(event.detail.status);
9611065 htmx:sse:before:connection -> ermiteLive.gate(event);
962- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
1066+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
9631067 gap -> ermiteLive.reload()">
9641068 </span>
9651069 </div>
@@ -1131,6 +1235,12 @@
11311235 <span class="k">section</span>
11321236 </a>
11331237 </li>
1238+<li data-k="section actions">
1239+<a href="/long/actions" tabindex="-1">
1240+<span class="lbl">actions</span>
1241+<span class="k">section</span>
1242+</a>
1243+</li>
11341244 <li data-k="branch main">
11351245 <a href="/long/tree/main" tabindex="-1">
11361246 <span class="lbl">main</span>
M internal/app/testdata/web/commits-small.html +27 -1
@@ -80,6 +80,10 @@
8080 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -106,6 +110,8 @@
106110 <a href="/diffs/commit/SHA40">add a very large file</a>
107111 <span class="pill">main</span>
108112 </td>
113+<td class="c-ck">
114+</td>
109115 <td class="c-who dim">T</td>
110116 <td class="c-when nowrap dim">
111117 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -127,6 +133,8 @@
127133 <td class="c-subject">
128134 <a href="/diffs/commit/SHA40">add evil.go</a>
129135 </td>
136+<td class="c-ck">
137+</td>
130138 <td class="c-who dim">T</td>
131139 <td class="c-when nowrap dim">
132140 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -149,6 +157,8 @@
149157 <a href="/diffs/commit/SHA40">merge the side branch</a>
150158 <span class="pill">merge</span>
151159 </td>
160+<td class="c-ck">
161+</td>
152162 <td class="c-who dim">T</td>
153163 <td class="c-when nowrap dim">
154164 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -171,6 +181,8 @@
171181 <a href="/diffs/commit/SHA40">work on the side</a>
172182 <span class="pill">side</span>
173183 </td>
184+<td class="c-ck">
185+</td>
174186 <td class="c-who dim">T</td>
175187 <td class="c-when nowrap dim">
176188 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -192,6 +204,8 @@
192204 <td class="c-subject">
193205 <a href="/diffs/commit/SHA40">change a binary file</a>
194206 </td>
207+<td class="c-ck">
208+</td>
195209 <td class="c-who dim">T</td>
196210 <td class="c-when nowrap dim">
197211 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -213,6 +227,8 @@
213227 <td class="c-subject">
214228 <a href="/diffs/commit/SHA40">rename a file and delete another</a>
215229 </td>
230+<td class="c-ck">
231+</td>
216232 <td class="c-who dim">T</td>
217233 <td class="c-when nowrap dim">
218234 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -236,6 +252,8 @@
236252 <span class="pill tag">
237253 <span class="sr">tag </span>v1.0.0</span>
238254 </td>
255+<td class="c-ck">
256+</td>
239257 <td class="c-who dim">T</td>
240258 <td class="c-when nowrap dim">
241259 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -257,6 +275,8 @@
257275 <td class="c-subject">
258276 <a href="/diffs/commit/SHA40">root: the first files</a>
259277 </td>
278+<td class="c-ck">
279+</td>
260280 <td class="c-who dim">T</td>
261281 <td class="c-when nowrap dim">
262282 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,7 +324,7 @@
304324 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
305325 htmx:sse:error -> ermiteLive.error(event.detail.status);
306326 htmx:sse:before:connection -> ermiteLive.gate(event);
307- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
327+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
308328 gap -> ermiteLive.reload()">
309329 </span>
310330 </div>
@@ -484,6 +504,12 @@
484504 <span class="k">section</span>
485505 </a>
486506 </li>
507+<li data-k="section actions">
508+<a href="/diffs/actions" tabindex="-1">
509+<span class="lbl">actions</span>
510+<span class="k">section</span>
511+</a>
512+</li>
487513 <li data-k="branch main">
488514 <a href="/diffs/tree/main" tabindex="-1">
489515 <span class="lbl">main</span>
M internal/app/testdata/web/home.html +1 -1
@@ -113,7 +113,7 @@
113113 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
114114 htmx:sse:error -> ermiteLive.error(event.detail.status);
115115 htmx:sse:before:connection -> ermiteLive.gate(event);
116- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
116+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
117117 gap -> ermiteLive.reload()">
118118 </span>
119119 </div>
M internal/app/testdata/web/refs.html +11 -1
@@ -77,6 +77,10 @@
7777 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
7878 </a>
7979 </li>
80+<li>
81+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
82+</a>
83+</li>
8084 </ul>
8185 </nav>
8286 </header>
@@ -196,7 +200,7 @@
196200 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
197201 htmx:sse:error -> ermiteLive.error(event.detail.status);
198202 htmx:sse:before:connection -> ermiteLive.gate(event);
199- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
203+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
200204 gap -> ermiteLive.reload()">
201205 </span>
202206 </div>
@@ -380,6 +384,12 @@
380384 <span class="k">section</span>
381385 </a>
382386 </li>
387+<li data-k="section actions">
388+<a href="/demo/actions" tabindex="-1">
389+<span class="lbl">actions</span>
390+<span class="k">section</span>
391+</a>
392+</li>
383393 <li data-k="branch feature/x">
384394 <a href="/demo/tree/feature/x" tabindex="-1">
385395 <span class="lbl">feature/x</span>
M internal/app/testdata/web/summary.html +15 -1
@@ -74,6 +74,10 @@
7474 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
7575 </a>
7676 </li>
77+<li>
78+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
79+</a>
80+</li>
7781 </ul>
7882 </nav>
7983 </header>
@@ -166,6 +170,8 @@
166170 <span class="pill tag">
167171 <span class="sr">tag </span>v0.2.0</span>
168172 </td>
173+<td class="c-ck">
174+</td>
169175 <td class="c-who dim">T</td>
170176 <td class="c-when nowrap dim">
171177 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -180,6 +186,8 @@
180186 <span class="pill tag">
181187 <span class="sr">tag </span>v0.1.0</span>
182188 </td>
189+<td class="c-ck">
190+</td>
183191 <td class="c-who dim">T</td>
184192 <td class="c-when nowrap dim">
185193 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -215,7 +223,7 @@
215223 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
216224 htmx:sse:error -> ermiteLive.error(event.detail.status);
217225 htmx:sse:before:connection -> ermiteLive.gate(event);
218- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
226+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
219227 gap -> ermiteLive.reload()">
220228 </span>
221229 </div>
@@ -399,6 +407,12 @@
399407 <span class="k">section</span>
400408 </a>
401409 </li>
410+<li data-k="section actions">
411+<a href="/demo/actions" tabindex="-1">
412+<span class="lbl">actions</span>
413+<span class="k">section</span>
414+</a>
415+</li>
402416 <li data-k="branch feature/x">
403417 <a href="/demo/tree/feature/x" tabindex="-1">
404418 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-nested.html +10 -0
@@ -86,6 +86,10 @@
8686 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8787 </a>
8888 </li>
89+<li>
90+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+</a>
92+</li>
8993 </ul>
9094 </nav>
9195 </header>
@@ -328,6 +332,12 @@
328332 <span class="k">section</span>
329333 </a>
330334 </li>
335+<li data-k="section actions">
336+<a href="/files/actions" tabindex="-1">
337+<span class="lbl">actions</span>
338+<span class="k">section</span>
339+</a>
340+</li>
331341 <li data-k="branch feature/x">
332342 <a href="/files/tree/feature/x" tabindex="-1">
333343 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-root.html +10 -0
@@ -80,6 +80,10 @@
8080 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8181 </a>
8282 </li>
83+<li>
84+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+</a>
86+</li>
8387 </ul>
8488 </nav>
8589 </header>
@@ -555,6 +559,12 @@
555559 <span class="k">section</span>
556560 </a>
557561 </li>
562+<li data-k="section actions">
563+<a href="/files/actions" tabindex="-1">
564+<span class="lbl">actions</span>
565+<span class="k">section</span>
566+</a>
567+</li>
558568 <li data-k="branch feature/x">
559569 <a href="/files/tree/feature/x" tabindex="-1">
560570 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-slash-ref.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -325,6 +329,12 @@
325329 <span class="k">section</span>
326330 </a>
327331 </li>
332+<li data-k="section actions">
333+<a href="/files/actions" tabindex="-1">
334+<span class="lbl">actions</span>
335+<span class="k">section</span>
336+</a>
337+</li>
328338 <li data-k="branch feature/x">
329339 <a href="/files/tree/feature/x" tabindex="-1">
330340 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-subdir.html +10 -0
@@ -83,6 +83,10 @@
8383 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8484 </a>
8585 </li>
86+<li>
87+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+</a>
89+</li>
8690 </ul>
8791 </nav>
8892 </header>
@@ -350,6 +354,12 @@
350354 <span class="k">section</span>
351355 </a>
352356 </li>
357+<li data-k="section actions">
358+<a href="/files/actions" tabindex="-1">
359+<span class="lbl">actions</span>
360+<span class="k">section</span>
361+</a>
362+</li>
353363 <li data-k="branch feature/x">
354364 <a href="/files/tree/feature/x" tabindex="-1">
355365 <span class="lbl">feature/x</span>
M internal/app/web.go +1 -0
@@ -33,6 +33,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3333 Repos: f.Repos,
3434 Browse: f.Browse,
3535 MergeRequests: f.MergeRequests,
36+ Runs: f.CI,
3637 Events: f.EventLog,
3738 PublicURL: cfg.PublicURL,
3839 SourceURL: cfg.SourceURL,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close