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+}
@@ -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+}
@@ -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 generated file: collapsed
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
@@ -44,6 +44,8 @@ type CommitPage struct {
44 // Invisible is true when some line holds an invisible character, shown as a44 // Invisible is true when some line holds an invisible character, shown as a
45 // marker.45 // marker.
46 Invisible bool46 Invisible bool
47+ // Checks is the combined state of the CI checks of the commit.
48+ Checks ChecksSummary
47 }49 }
48 50
49 // DiffView is how a page lays its diff out: the layouts in the page and the51 // 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,
138 }140 }
139 }141 }
140 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)142 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))
141 144
142 lines, invisible := diffLines(p.Files)145 lines, invisible := diffLines(p.Files)
143 p.Invisible = invisible146 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")
@@ -73,7 +73,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
73 if token != "" {73 if token != "" {
74 p.Newest = base74 p.Newest = base
75 }75 }
76- s.fillDays(&p, d, cs)76+ s.fillDays(ctx, &p, d, cs)
77 if sel.path != "" && token == "" {77 if sel.path != "" && token == "" {
78 p.Open = s.pathLink(ctx, d, sel)78 p.Open = s.pathLink(ctx, d, sel)
79 }79 }
@@ -95,14 +95,21 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
95 return Commits(l, p), http.StatusOK, nil95 return Commits(l, p), http.StatusOK, nil
96 }96 }
97 97
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+
98 // fillDays groups the commits of a page by day and lists the refs among them.102 // 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) {
100 now := s.cfg.Now()104 now := s.cfg.Now()
101 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)105 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
102 for i, c := range cs {106 for i, c := range cs {
103 row := commitRow(d.name(), c, now)107 row := commitRow(d.name(), c, now)
104 row.ID, row.Cursor = "c-"+row.Short, i == 0108 row.ID, row.Cursor = "c-"+row.Short, i == 0
105 row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()]109 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+ }
106 day := "unknown date"113 day := "unknown date"
107 if t := c.Committer.Time; !t.IsZero() {114 if t := c.Committer.Time; !t.IsZero() {
108 day = t.UTC().Format("2006-01-02")115 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>
@@ -112,6 +112,7 @@ templ CommitRows(rows []CommitRow) {
112 <span class="pill tag"><span class="sr">tag </span>{ t }</span>112 <span class="pill tag"><span class="sr">tag </span>{ t }</span>
113 }113 }
114 </td>114 </td>
115+ <td class="c-ck">@CommitChecks(c.Checks)</td>
115 <td class="c-who dim">{ c.Author }</td>116 <td class="c-who dim">{ c.Author }</td>
116 <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td>117 <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td>
117 </tr>118 </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>
@@ -40,6 +40,10 @@ templ Commit(l Layout, p CommitPage) {
40 { " · diff against the first parent" }40 { " · diff against the first parent" }
41 }41 }
42 </dd>42 </dd>
43+ if p.Checks.Total > 0 {
44+ <dt>checks</dt>
45+ <dd>@CommitChecks(p.Checks)</dd>
46+ }
43 if len(p.Refs) > 0 {47 if len(p.Refs) > 0 {
44 <dt>refs</dt>48 <dt>refs</dt>
45 <dd>49 <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) {
@@ -31,6 +31,9 @@ import (
31 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>31 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
32 // GET /-/events?topic=repo:<name>&view=mrs32 // GET /-/events?topic=repo:<name>&view=mrs
33 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>33 // 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>
34 //37 //
35 // topic is the Watch topic. view says which fragments of a repository the38 // topic is the Watch topic. view says which fragments of a repository the
36 // page shows, so that the server renders only those (summary when absent).39 // page shows, so that the server renders only those (summary when absent).
@@ -50,6 +53,11 @@ import (
50 // commits-head data: <div id="commits-head">N new commits — reload</div>53 // commits-head data: <div id="commits-head">N new commits — reload</div>
51 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:54 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
52 // something changed, with the link that reloads the page55 // 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=
53 // gap data: gap; events were lost, or what the page shows is gone or61 // gap data: gap; events were lost, or what the page shows is gone or
54 // changed: the page fetches itself again (ermiteLive in ermite.js)62 // changed: the page fetches itself again (ermiteLive in ermite.js)
55 //63 //
@@ -194,7 +202,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
194 return202 return
195 }203 }
196 204
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")}
198 handle := func(m recvd) bool {206 handle := func(m recvd) bool {
199 if m.err != nil {207 if m.err != nil {
200 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {208 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+}
@@ -7,6 +7,7 @@ import (
7 "context"7 "context"
8 "errors"8 "errors"
9 "strconv"9 "strconv"
10+ "strings"
10 "sync"11 "sync"
11 "time"12 "time"
12 13
@@ -23,7 +24,8 @@ type liveStream struct {
23 view string // "summary", "refs" or "commits"24 view string // "summary", "refs" or "commits"
24 ref string // commits: the branch listed25 ref string // commits: the branch listed
25 head string // commits: the commit listed first26 head string // commits: the commit listed first
26- n string // mr: the number of the merge request shown27+ n string // mr: the number of the merge request shown; run: the run id
28+ job string // job: the id of the job shown
27 }29 }
28 30
29 // renderFragment renders a component to the data of an event.31 // renderFragment renders a component to the data of an event.
@@ -46,7 +48,8 @@ func liveEvent(we ports.WatchEvent) bool {
46 }48 }
47 switch we.Event.(type) {49 switch we.Event.(type) {
48 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,50 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:
50 return true53 return true
51 }54 }
52 return false55 return false
@@ -117,6 +120,10 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
117 // repoPage handles the events of the topic of one repository.120 // repoPage handles the events of the topic of one repository.
118 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {121 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
119 switch ev := we.Event.(type) {122 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)
120 case domain.RepoDeleted, domain.RepoCreated:127 case domain.RepoDeleted, domain.RepoCreated:
121 // Gone, or a new repository under the same name: whatever the page128 // Gone, or a new repository under the same name: whatever the page
122 // shows is not true any more.129 // shows is not true any more.
@@ -395,3 +402,103 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
395 f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))402 f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
396 return []fragment{f}, err403 return []fragment{f}, err
397 }404 }
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 }
@@ -101,7 +101,7 @@ templ Live(l Layout) {
101 htmx:sse:close, htmx:sse:error -> data.state = 'offline';101 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
102 htmx:sse:error -> ermiteLive.error(event.detail.status);102 htmx:sse:error -> ermiteLive.error(event.detail.status);
103 htmx:sse:before:connection -> ermiteLive.gate(event);103 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);
105 gap -> ermiteLive.reload()"105 gap -> ermiteLive.reload()"
106 ></span>106 ></span>
107 }107 }
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.
@@ -205,6 +205,8 @@ type MRPage struct {
205 CommitsNote string205 CommitsNote string
206 Modes []string206 Modes []string
207 ChecksBlock bool207 ChecksBlock bool
208+ // Checks are the CI checks of the head of the request.
209+ Checks ChecksSection
208 210
209 Box *MergeBox211 Box *MergeBox
210 // Live is true when the page announces changes.212 // 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
456 }458 }
457 }459 }
458 p.ChecksBlock = d.repo.Settings.FailingChecksBlock460 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+ }
459 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)467 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
460 468
461 // The commits of the request, newest first.469 // 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">
@@ -302,11 +302,7 @@ templ mrRail(p MRPage) {
302 if p.Box != nil {302 if p.Box != nil {
303 @mergeBox(p)303 @mergeBox(p)
304 }304 }
305- <section aria-labelledby="r-checks">305+ @MRChecks(p.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>
310 <section aria-labelledby="r-settings">306 <section aria-labelledby="r-settings">
311 <h2 id="r-settings">Settings</h2>307 <h2 id="r-settings">Settings</h2>
312 <dl class="kv">308 <dl class="kv">
M internal/adapters/web/mr_templ.go +56 -52
@@ -1703,14 +1703,18 @@ func mrRail(p MRPage) templ.Component {
17031703 return templ_7745c5c3_Err
17041704 }
17051705 }
1706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">0</span></h2><p class=\"dim\">no checks</p><p class=\"mute\">there is no CI yet: nothing blocks the merge.</p></section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
1706+ templ_7745c5c3_Err = MRChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
1707+ if templ_7745c5c3_Err != nil {
1708+ return templ_7745c5c3_Err
1709+ }
1710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
17071711 if templ_7745c5c3_Err != nil {
17081712 return templ_7745c5c3_Err
17091713 }
17101714 var templ_7745c5c3_Var86 string
17111715 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
17121716 if templ_7745c5c3_Err != nil {
1713- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 36}
1717+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 310, Col: 36}
17141718 }
17151719 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
17161720 if templ_7745c5c3_Err != nil {
@@ -1738,7 +1742,7 @@ func mrRail(p MRPage) templ.Component {
17381742 var templ_7745c5c3_Var87 string
17391743 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
17401744 if templ_7745c5c3_Err != nil {
1741- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 71}
1745+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 322, Col: 71}
17421746 }
17431747 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
17441748 if templ_7745c5c3_Err != nil {
@@ -1766,7 +1770,7 @@ func mrRail(p MRPage) templ.Component {
17661770 var templ_7745c5c3_Var88 templ.SafeURL
17671771 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
17681772 if templ_7745c5c3_Err != nil {
1769- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 36}
1773+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 36}
17701774 }
17711775 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
17721776 if templ_7745c5c3_Err != nil {
@@ -1779,7 +1783,7 @@ func mrRail(p MRPage) templ.Component {
17791783 var templ_7745c5c3_Var89 string
17801784 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
17811785 if templ_7745c5c3_Err != nil {
1782- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 48}
1786+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 48}
17831787 }
17841788 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
17851789 if templ_7745c5c3_Err != nil {
@@ -1792,7 +1796,7 @@ func mrRail(p MRPage) templ.Component {
17921796 var templ_7745c5c3_Var90 string
17931797 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
17941798 if templ_7745c5c3_Err != nil {
1795- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 32}
1799+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 32}
17961800 }
17971801 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
17981802 if templ_7745c5c3_Err != nil {
@@ -1805,7 +1809,7 @@ func mrRail(p MRPage) templ.Component {
18051809 var templ_7745c5c3_Var91 string
18061810 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
18071811 if templ_7745c5c3_Err != nil {
1808- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 16}
1812+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 329, Col: 16}
18091813 }
18101814 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
18111815 if templ_7745c5c3_Err != nil {
@@ -1819,7 +1823,7 @@ func mrRail(p MRPage) templ.Component {
18191823 var templ_7745c5c3_Var92 string
18201824 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
18211825 if templ_7745c5c3_Err != nil {
1822- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 15}
1826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 15}
18231827 }
18241828 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
18251829 if templ_7745c5c3_Err != nil {
@@ -1832,7 +1836,7 @@ func mrRail(p MRPage) templ.Component {
18321836 var templ_7745c5c3_Var93 string
18331837 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
18341838 if templ_7745c5c3_Err != nil {
1835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 42}
1839+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 42}
18361840 }
18371841 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)
18381842 if templ_7745c5c3_Err != nil {
@@ -1845,7 +1849,7 @@ func mrRail(p MRPage) templ.Component {
18451849 var templ_7745c5c3_Var94 string
18461850 templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
18471851 if templ_7745c5c3_Err != nil {
1848- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 53}
1852+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 53}
18491853 }
18501854 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
18511855 if templ_7745c5c3_Err != nil {
@@ -1860,7 +1864,7 @@ func mrRail(p MRPage) templ.Component {
18601864 var templ_7745c5c3_Var95 string
18611865 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
18621866 if templ_7745c5c3_Err != nil {
1863- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 18}
1867+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 18}
18641868 }
18651869 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
18661870 if templ_7745c5c3_Err != nil {
@@ -1890,7 +1894,7 @@ func mrRail(p MRPage) templ.Component {
18901894 var templ_7745c5c3_Var96 string
18911895 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
18921896 if templ_7745c5c3_Err != nil {
1893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 34}
1897+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 34}
18941898 }
18951899 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
18961900 if templ_7745c5c3_Err != nil {
@@ -1908,7 +1912,7 @@ func mrRail(p MRPage) templ.Component {
19081912 var templ_7745c5c3_Var97 string
19091913 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
19101914 if templ_7745c5c3_Err != nil {
1911- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 352, Col: 65}
1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 65}
19121916 }
19131917 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19141918 if templ_7745c5c3_Err != nil {
@@ -1967,7 +1971,7 @@ func mergeBox(p MRPage) templ.Component {
19671971 var templ_7745c5c3_Var99 string
19681972 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
19691973 if templ_7745c5c3_Err != nil {
1970- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 18}
1974+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 365, Col: 18}
19711975 }
19721976 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
19731977 if templ_7745c5c3_Err != nil {
@@ -1981,7 +1985,7 @@ func mergeBox(p MRPage) templ.Component {
19811985 var templ_7745c5c3_Var100 string
19821986 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
19831987 if templ_7745c5c3_Err != nil {
1984- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 17}
1988+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 17}
19851989 }
19861990 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
19871991 if templ_7745c5c3_Err != nil {
@@ -1999,7 +2003,7 @@ func mergeBox(p MRPage) templ.Component {
19992003 var templ_7745c5c3_Var101 templ.SafeURL
20002004 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
20012005 if templ_7745c5c3_Err != nil {
2002- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 376, Col: 79}
2006+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 372, Col: 79}
20032007 }
20042008 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
20052009 if templ_7745c5c3_Err != nil {
@@ -2012,7 +2016,7 @@ func mergeBox(p MRPage) templ.Component {
20122016 var templ_7745c5c3_Var102 string
20132017 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
20142018 if templ_7745c5c3_Err != nil {
2015- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 58}
2019+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 58}
20162020 }
20172021 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
20182022 if templ_7745c5c3_Err != nil {
@@ -2030,7 +2034,7 @@ func mergeBox(p MRPage) templ.Component {
20302034 var templ_7745c5c3_Var103 string
20312035 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
20322036 if templ_7745c5c3_Err != nil {
2033- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 29}
2037+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 29}
20342038 }
20352039 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var103)
20362040 if templ_7745c5c3_Err != nil {
@@ -2043,7 +2047,7 @@ func mergeBox(p MRPage) templ.Component {
20432047 var templ_7745c5c3_Var104 string
20442048 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
20452049 if templ_7745c5c3_Err != nil {
2046- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 41}
2050+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 41}
20472051 }
20482052 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
20492053 if templ_7745c5c3_Err != nil {
@@ -2066,7 +2070,7 @@ func mergeBox(p MRPage) templ.Component {
20662070 var templ_7745c5c3_Var105 string
20672071 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
20682072 if templ_7745c5c3_Err != nil {
2069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 385, Col: 101}
2073+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 101}
20702074 }
20712075 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
20722076 if templ_7745c5c3_Err != nil {
@@ -2094,7 +2098,7 @@ func mergeBox(p MRPage) templ.Component {
20942098 var templ_7745c5c3_Var106 templ.SafeURL
20952099 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
20962100 if templ_7745c5c3_Err != nil {
2097- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 64}
2101+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 390, Col: 64}
20982102 }
20992103 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
21002104 if templ_7745c5c3_Err != nil {
@@ -2107,7 +2111,7 @@ func mergeBox(p MRPage) templ.Component {
21072111 var templ_7745c5c3_Var107 string
21082112 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
21092113 if templ_7745c5c3_Err != nil {
2110- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 59}
2114+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 391, Col: 59}
21112115 }
21122116 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
21132117 if templ_7745c5c3_Err != nil {
@@ -2125,7 +2129,7 @@ func mergeBox(p MRPage) templ.Component {
21252129 var templ_7745c5c3_Var108 templ.SafeURL
21262130 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
21272131 if templ_7745c5c3_Err != nil {
2128- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 399, Col: 64}
2132+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 64}
21292133 }
21302134 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21312135 if templ_7745c5c3_Err != nil {
@@ -2195,7 +2199,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
21952199 var templ_7745c5c3_Var111 string
21962200 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
21972201 if templ_7745c5c3_Err != nil {
2198- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 17}
2202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 17}
21992203 }
22002204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
22012205 if templ_7745c5c3_Err != nil {
@@ -2208,7 +2212,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22082212 var templ_7745c5c3_Var112 string
22092213 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22102214 if templ_7745c5c3_Err != nil {
2211- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 34}
2215+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 34}
22122216 }
22132217 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
22142218 if templ_7745c5c3_Err != nil {
@@ -2221,7 +2225,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22212225 var templ_7745c5c3_Var113 templ.SafeURL
22222226 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
22232227 if templ_7745c5c3_Err != nil {
2224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 45}
2228+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 45}
22252229 }
22262230 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
22272231 if templ_7745c5c3_Err != nil {
@@ -2234,7 +2238,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22342238 var templ_7745c5c3_Var114 string
22352239 templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22362240 if templ_7745c5c3_Err != nil {
2237- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 58}
2241+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 58}
22382242 }
22392243 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
22402244 if templ_7745c5c3_Err != nil {
@@ -2247,7 +2251,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22472251 var templ_7745c5c3_Var115 templ.SafeURL
22482252 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
22492253 if templ_7745c5c3_Err != nil {
2250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 45}
2254+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 45}
22512255 }
22522256 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
22532257 if templ_7745c5c3_Err != nil {
@@ -2260,7 +2264,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22602264 var templ_7745c5c3_Var116 string
22612265 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22622266 if templ_7745c5c3_Err != nil {
2263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 58}
2267+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 58}
22642268 }
22652269 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
22662270 if templ_7745c5c3_Err != nil {
@@ -2273,7 +2277,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22732277 var templ_7745c5c3_Var117 string
22742278 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
22752279 if templ_7745c5c3_Err != nil {
2276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 422, Col: 21}
2280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 21}
22772281 }
22782282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
22792283 if templ_7745c5c3_Err != nil {
@@ -2291,7 +2295,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22912295 var templ_7745c5c3_Var118 string
22922296 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
22932297 if templ_7745c5c3_Err != nil {
2294- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 29}
2298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 422, Col: 29}
22952299 }
22962300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
22972301 if templ_7745c5c3_Err != nil {
@@ -2314,7 +2318,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23142318 var templ_7745c5c3_Var119 templ.SafeURL
23152319 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
23162320 if templ_7745c5c3_Err != nil {
2317- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 114}
2321+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 425, Col: 114}
23182322 }
23192323 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
23202324 if templ_7745c5c3_Err != nil {
@@ -2327,7 +2331,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23272331 var templ_7745c5c3_Var120 string
23282332 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
23292333 if templ_7745c5c3_Err != nil {
2330- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 135}
2334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 425, Col: 135}
23312335 }
23322336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
23332337 if templ_7745c5c3_Err != nil {
@@ -2350,7 +2354,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23502354 var templ_7745c5c3_Var121 templ.SafeURL
23512355 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
23522356 if templ_7745c5c3_Err != nil {
2353- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 83}
2357+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 428, Col: 83}
23542358 }
23552359 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
23562360 if templ_7745c5c3_Err != nil {
@@ -2363,7 +2367,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23632367 var templ_7745c5c3_Var122 string
23642368 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
23652369 if templ_7745c5c3_Err != nil {
2366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 62}
2370+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 62}
23672371 }
23682372 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
23692373 if templ_7745c5c3_Err != nil {
@@ -2376,7 +2380,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23762380 var templ_7745c5c3_Var123 string
23772381 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
23782382 if templ_7745c5c3_Err != nil {
2379- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 62}
2383+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 62}
23802384 }
23812385 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
23822386 if templ_7745c5c3_Err != nil {
@@ -2389,7 +2393,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23892393 var templ_7745c5c3_Var124 string
23902394 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
23912395 if templ_7745c5c3_Err != nil {
2392- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 436, Col: 97}
2396+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 97}
23932397 }
23942398 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
23952399 if templ_7745c5c3_Err != nil {
@@ -2412,7 +2416,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24122416 var templ_7745c5c3_Var125 string
24132417 templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
24142418 if templ_7745c5c3_Err != nil {
2415- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 57}
2419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 57}
24162420 }
24172421 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
24182422 if templ_7745c5c3_Err != nil {
@@ -2425,7 +2429,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24252429 var templ_7745c5c3_Var126 string
24262430 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
24272431 if templ_7745c5c3_Err != nil {
2428- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 97}
2432+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 97}
24292433 }
24302434 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
24312435 if templ_7745c5c3_Err != nil {
@@ -2438,7 +2442,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24382442 var templ_7745c5c3_Var127 string
24392443 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
24402444 if templ_7745c5c3_Err != nil {
2441- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 142}
2445+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 142}
24422446 }
24432447 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
24442448 if templ_7745c5c3_Err != nil {
@@ -2482,7 +2486,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24822486 var templ_7745c5c3_Var128 string
24832487 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
24842488 if templ_7745c5c3_Err != nil {
2485- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 23}
2489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 457, Col: 23}
24862490 }
24872491 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
24882492 if templ_7745c5c3_Err != nil {
@@ -2500,7 +2504,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
25002504 var templ_7745c5c3_Var129 string
25012505 templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
25022506 if templ_7745c5c3_Err != nil {
2503- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 23}
2507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 23}
25042508 }
25052509 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
25062510 if templ_7745c5c3_Err != nil {
@@ -2570,7 +2574,7 @@ func compareRail(p ComparePage) templ.Component {
25702574 var templ_7745c5c3_Var131 string
25712575 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
25722576 if templ_7745c5c3_Err != nil {
2573- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 67}
2577+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 475, Col: 67}
25742578 }
25752579 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
25762580 if templ_7745c5c3_Err != nil {
@@ -2588,7 +2592,7 @@ func compareRail(p ComparePage) templ.Component {
25882592 var templ_7745c5c3_Var132 templ.SafeURL
25892593 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
25902594 if templ_7745c5c3_Err != nil {
2591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 37}
2595+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 37}
25922596 }
25932597 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
25942598 if templ_7745c5c3_Err != nil {
@@ -2601,7 +2605,7 @@ func compareRail(p ComparePage) templ.Component {
26012605 var templ_7745c5c3_Var133 string
26022606 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
26032607 if templ_7745c5c3_Err != nil {
2604- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 49}
2608+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 49}
26052609 }
26062610 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
26072611 if templ_7745c5c3_Err != nil {
@@ -2614,7 +2618,7 @@ func compareRail(p ComparePage) templ.Component {
26142618 var templ_7745c5c3_Var134 string
26152619 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
26162620 if templ_7745c5c3_Err != nil {
2617- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 32}
2621+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 32}
26182622 }
26192623 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
26202624 if templ_7745c5c3_Err != nil {
@@ -2627,7 +2631,7 @@ func compareRail(p ComparePage) templ.Component {
26272631 var templ_7745c5c3_Var135 string
26282632 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
26292633 if templ_7745c5c3_Err != nil {
2630- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 16}
2634+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 16}
26312635 }
26322636 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
26332637 if templ_7745c5c3_Err != nil {
@@ -2641,7 +2645,7 @@ func compareRail(p ComparePage) templ.Component {
26412645 var templ_7745c5c3_Var136 string
26422646 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
26432647 if templ_7745c5c3_Err != nil {
2644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 15}
2648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 15}
26452649 }
26462650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
26472651 if templ_7745c5c3_Err != nil {
@@ -2654,7 +2658,7 @@ func compareRail(p ComparePage) templ.Component {
26542658 var templ_7745c5c3_Var137 string
26552659 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
26562660 if templ_7745c5c3_Err != nil {
2657- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 42}
2661+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 42}
26582662 }
26592663 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)
26602664 if templ_7745c5c3_Err != nil {
@@ -2667,7 +2671,7 @@ func compareRail(p ComparePage) templ.Component {
26672671 var templ_7745c5c3_Var138 string
26682672 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
26692673 if templ_7745c5c3_Err != nil {
2670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 53}
2674+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 53}
26712675 }
26722676 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
26732677 if templ_7745c5c3_Err != nil {
@@ -2695,7 +2699,7 @@ func compareRail(p ComparePage) templ.Component {
26952699 var templ_7745c5c3_Var139 string
26962700 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
26972701 if templ_7745c5c3_Err != nil {
2698- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 495, Col: 47}
2702+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 491, Col: 47}
26992703 }
27002704 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
27012705 if templ_7745c5c3_Err != nil {
@@ -2718,7 +2722,7 @@ func compareRail(p ComparePage) templ.Component {
27182722 var templ_7745c5c3_Var140 string
27192723 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
27202724 if templ_7745c5c3_Err != nil {
2721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 500, Col: 66}
2725+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 66}
27222726 }
27232727 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
27242728 if templ_7745c5c3_Err != nil {
@@ -1703,14 +1703,18 @@ func mrRail(p MRPage) templ.Component {
1703 return templ_7745c5c3_Err1703 return templ_7745c5c3_Err
1704 }1704 }
1705 }1705 }
1706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">0</span></h2><p class=\"dim\">no checks</p><p class=\"mute\">there is no CI yet: nothing blocks the merge.</p></section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")1706+ templ_7745c5c3_Err = MRChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
1707+ if templ_7745c5c3_Err != nil {
1708+ return templ_7745c5c3_Err
1709+ }
1710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
1707 if templ_7745c5c3_Err != nil {1711 if templ_7745c5c3_Err != nil {
1708 return templ_7745c5c3_Err1712 return templ_7745c5c3_Err
1709 }1713 }
1710 var templ_7745c5c3_Var86 string1714 var templ_7745c5c3_Var86 string
1711 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))1715 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
1712 if templ_7745c5c3_Err != nil {1716 if templ_7745c5c3_Err != nil {
1713- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 36}1717+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 310, Col: 36}
1714 }1718 }
1715 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))1719 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
1716 if templ_7745c5c3_Err != nil {1720 if templ_7745c5c3_Err != nil {
@@ -1738,7 +1742,7 @@ func mrRail(p MRPage) templ.Component {
1738 var templ_7745c5c3_Var87 string1742 var templ_7745c5c3_Var87 string
1739 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))1743 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
1740 if templ_7745c5c3_Err != nil {1744 if templ_7745c5c3_Err != nil {
1741- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 71}1745+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 322, Col: 71}
1742 }1746 }
1743 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))1747 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
1744 if templ_7745c5c3_Err != nil {1748 if templ_7745c5c3_Err != nil {
@@ -1766,7 +1770,7 @@ func mrRail(p MRPage) templ.Component {
1766 var templ_7745c5c3_Var88 templ.SafeURL1770 var templ_7745c5c3_Var88 templ.SafeURL
1767 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))1771 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
1768 if templ_7745c5c3_Err != nil {1772 if templ_7745c5c3_Err != nil {
1769- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 36}1773+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 36}
1770 }1774 }
1771 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))1775 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
1772 if templ_7745c5c3_Err != nil {1776 if templ_7745c5c3_Err != nil {
@@ -1779,7 +1783,7 @@ func mrRail(p MRPage) templ.Component {
1779 var templ_7745c5c3_Var89 string1783 var templ_7745c5c3_Var89 string
1780 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)1784 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
1781 if templ_7745c5c3_Err != nil {1785 if templ_7745c5c3_Err != nil {
1782- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 48}1786+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 48}
1783 }1787 }
1784 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))1788 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
1785 if templ_7745c5c3_Err != nil {1789 if templ_7745c5c3_Err != nil {
@@ -1792,7 +1796,7 @@ func mrRail(p MRPage) templ.Component {
1792 var templ_7745c5c3_Var90 string1796 var templ_7745c5c3_Var90 string
1793 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)1797 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
1794 if templ_7745c5c3_Err != nil {1798 if templ_7745c5c3_Err != nil {
1795- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 32}1799+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 32}
1796 }1800 }
1797 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))1801 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
1798 if templ_7745c5c3_Err != nil {1802 if templ_7745c5c3_Err != nil {
@@ -1805,7 +1809,7 @@ func mrRail(p MRPage) templ.Component {
1805 var templ_7745c5c3_Var91 string1809 var templ_7745c5c3_Var91 string
1806 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)1810 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
1807 if templ_7745c5c3_Err != nil {1811 if templ_7745c5c3_Err != nil {
1808- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 16}1812+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 329, Col: 16}
1809 }1813 }
1810 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))1814 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
1811 if templ_7745c5c3_Err != nil {1815 if templ_7745c5c3_Err != nil {
@@ -1819,7 +1823,7 @@ func mrRail(p MRPage) templ.Component {
1819 var templ_7745c5c3_Var92 string1823 var templ_7745c5c3_Var92 string
1820 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")1824 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
1821 if templ_7745c5c3_Err != nil {1825 if templ_7745c5c3_Err != nil {
1822- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 15}1826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 15}
1823 }1827 }
1824 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))1828 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
1825 if templ_7745c5c3_Err != nil {1829 if templ_7745c5c3_Err != nil {
@@ -1832,7 +1836,7 @@ func mrRail(p MRPage) templ.Component {
1832 var templ_7745c5c3_Var93 string1836 var templ_7745c5c3_Var93 string
1833 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)1837 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
1834 if templ_7745c5c3_Err != nil {1838 if templ_7745c5c3_Err != nil {
1835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 42}1839+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 42}
1836 }1840 }
1837 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)1841 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)
1838 if templ_7745c5c3_Err != nil {1842 if templ_7745c5c3_Err != nil {
@@ -1845,7 +1849,7 @@ func mrRail(p MRPage) templ.Component {
1845 var templ_7745c5c3_Var94 string1849 var templ_7745c5c3_Var94 string
1846 templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)1850 templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
1847 if templ_7745c5c3_Err != nil {1851 if templ_7745c5c3_Err != nil {
1848- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 53}1852+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 53}
1849 }1853 }
1850 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))1854 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
1851 if templ_7745c5c3_Err != nil {1855 if templ_7745c5c3_Err != nil {
@@ -1860,7 +1864,7 @@ func mrRail(p MRPage) templ.Component {
1860 var templ_7745c5c3_Var95 string1864 var templ_7745c5c3_Var95 string
1861 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")1865 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
1862 if templ_7745c5c3_Err != nil {1866 if templ_7745c5c3_Err != nil {
1863- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 18}1867+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 18}
1864 }1868 }
1865 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))1869 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
1866 if templ_7745c5c3_Err != nil {1870 if templ_7745c5c3_Err != nil {
@@ -1890,7 +1894,7 @@ func mrRail(p MRPage) templ.Component {
1890 var templ_7745c5c3_Var96 string1894 var templ_7745c5c3_Var96 string
1891 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)1895 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
1892 if templ_7745c5c3_Err != nil {1896 if templ_7745c5c3_Err != nil {
1893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 34}1897+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 34}
1894 }1898 }
1895 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))1899 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
1896 if templ_7745c5c3_Err != nil {1900 if templ_7745c5c3_Err != nil {
@@ -1908,7 +1912,7 @@ func mrRail(p MRPage) templ.Component {
1908 var templ_7745c5c3_Var97 string1912 var templ_7745c5c3_Var97 string
1909 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))1913 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
1910 if templ_7745c5c3_Err != nil {1914 if templ_7745c5c3_Err != nil {
1911- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 352, Col: 65}1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 65}
1912 }1916 }
1913 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))1917 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
1914 if templ_7745c5c3_Err != nil {1918 if templ_7745c5c3_Err != nil {
@@ -1967,7 +1971,7 @@ func mergeBox(p MRPage) templ.Component {
1967 var templ_7745c5c3_Var99 string1971 var templ_7745c5c3_Var99 string
1968 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)1972 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
1969 if templ_7745c5c3_Err != nil {1973 if templ_7745c5c3_Err != nil {
1970- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 18}1974+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 365, Col: 18}
1971 }1975 }
1972 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))1976 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
1973 if templ_7745c5c3_Err != nil {1977 if templ_7745c5c3_Err != nil {
@@ -1981,7 +1985,7 @@ func mergeBox(p MRPage) templ.Component {
1981 var templ_7745c5c3_Var100 string1985 var templ_7745c5c3_Var100 string
1982 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)1986 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
1983 if templ_7745c5c3_Err != nil {1987 if templ_7745c5c3_Err != nil {
1984- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 17}1988+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 17}
1985 }1989 }
1986 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))1990 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
1987 if templ_7745c5c3_Err != nil {1991 if templ_7745c5c3_Err != nil {
@@ -1999,7 +2003,7 @@ func mergeBox(p MRPage) templ.Component {
1999 var templ_7745c5c3_Var101 templ.SafeURL2003 var templ_7745c5c3_Var101 templ.SafeURL
2000 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))2004 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
2001 if templ_7745c5c3_Err != nil {2005 if templ_7745c5c3_Err != nil {
2002- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 376, Col: 79}2006+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 372, Col: 79}
2003 }2007 }
2004 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))2008 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
2005 if templ_7745c5c3_Err != nil {2009 if templ_7745c5c3_Err != nil {
@@ -2012,7 +2016,7 @@ func mergeBox(p MRPage) templ.Component {
2012 var templ_7745c5c3_Var102 string2016 var templ_7745c5c3_Var102 string
2013 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)2017 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2014 if templ_7745c5c3_Err != nil {2018 if templ_7745c5c3_Err != nil {
2015- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 58}2019+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 58}
2016 }2020 }
2017 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)2021 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
2018 if templ_7745c5c3_Err != nil {2022 if templ_7745c5c3_Err != nil {
@@ -2030,7 +2034,7 @@ func mergeBox(p MRPage) templ.Component {
2030 var templ_7745c5c3_Var103 string2034 var templ_7745c5c3_Var103 string
2031 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)2035 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
2032 if templ_7745c5c3_Err != nil {2036 if templ_7745c5c3_Err != nil {
2033- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 29}2037+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 29}
2034 }2038 }
2035 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var103)2039 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var103)
2036 if templ_7745c5c3_Err != nil {2040 if templ_7745c5c3_Err != nil {
@@ -2043,7 +2047,7 @@ func mergeBox(p MRPage) templ.Component {
2043 var templ_7745c5c3_Var104 string2047 var templ_7745c5c3_Var104 string
2044 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)2048 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
2045 if templ_7745c5c3_Err != nil {2049 if templ_7745c5c3_Err != nil {
2046- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 41}2050+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 41}
2047 }2051 }
2048 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))2052 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
2049 if templ_7745c5c3_Err != nil {2053 if templ_7745c5c3_Err != nil {
@@ -2066,7 +2070,7 @@ func mergeBox(p MRPage) templ.Component {
2066 var templ_7745c5c3_Var105 string2070 var templ_7745c5c3_Var105 string
2067 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)2071 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2068 if templ_7745c5c3_Err != nil {2072 if templ_7745c5c3_Err != nil {
2069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 385, Col: 101}2073+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 101}
2070 }2074 }
2071 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))2075 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
2072 if templ_7745c5c3_Err != nil {2076 if templ_7745c5c3_Err != nil {
@@ -2094,7 +2098,7 @@ func mergeBox(p MRPage) templ.Component {
2094 var templ_7745c5c3_Var106 templ.SafeURL2098 var templ_7745c5c3_Var106 templ.SafeURL
2095 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))2099 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
2096 if templ_7745c5c3_Err != nil {2100 if templ_7745c5c3_Err != nil {
2097- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 64}2101+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 390, Col: 64}
2098 }2102 }
2099 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))2103 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
2100 if templ_7745c5c3_Err != nil {2104 if templ_7745c5c3_Err != nil {
@@ -2107,7 +2111,7 @@ func mergeBox(p MRPage) templ.Component {
2107 var templ_7745c5c3_Var107 string2111 var templ_7745c5c3_Var107 string
2108 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)2112 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
2109 if templ_7745c5c3_Err != nil {2113 if templ_7745c5c3_Err != nil {
2110- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 59}2114+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 391, Col: 59}
2111 }2115 }
2112 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)2116 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
2113 if templ_7745c5c3_Err != nil {2117 if templ_7745c5c3_Err != nil {
@@ -2125,7 +2129,7 @@ func mergeBox(p MRPage) templ.Component {
2125 var templ_7745c5c3_Var108 templ.SafeURL2129 var templ_7745c5c3_Var108 templ.SafeURL
2126 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))2130 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
2127 if templ_7745c5c3_Err != nil {2131 if templ_7745c5c3_Err != nil {
2128- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 399, Col: 64}2132+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 64}
2129 }2133 }
2130 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))2134 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
2131 if templ_7745c5c3_Err != nil {2135 if templ_7745c5c3_Err != nil {
@@ -2195,7 +2199,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2195 var templ_7745c5c3_Var111 string2199 var templ_7745c5c3_Var111 string
2196 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)2200 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2197 if templ_7745c5c3_Err != nil {2201 if templ_7745c5c3_Err != nil {
2198- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 17}2202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 17}
2199 }2203 }
2200 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))2204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
2201 if templ_7745c5c3_Err != nil {2205 if templ_7745c5c3_Err != nil {
@@ -2208,7 +2212,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2208 var templ_7745c5c3_Var112 string2212 var templ_7745c5c3_Var112 string
2209 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2213 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2210 if templ_7745c5c3_Err != nil {2214 if templ_7745c5c3_Err != nil {
2211- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 34}2215+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 34}
2212 }2216 }
2213 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))2217 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
2214 if templ_7745c5c3_Err != nil {2218 if templ_7745c5c3_Err != nil {
@@ -2221,7 +2225,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2221 var templ_7745c5c3_Var113 templ.SafeURL2225 var templ_7745c5c3_Var113 templ.SafeURL
2222 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))2226 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
2223 if templ_7745c5c3_Err != nil {2227 if templ_7745c5c3_Err != nil {
2224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 45}2228+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 45}
2225 }2229 }
2226 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))2230 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
2227 if templ_7745c5c3_Err != nil {2231 if templ_7745c5c3_Err != nil {
@@ -2234,7 +2238,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2234 var templ_7745c5c3_Var114 string2238 var templ_7745c5c3_Var114 string
2235 templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)2239 templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
2236 if templ_7745c5c3_Err != nil {2240 if templ_7745c5c3_Err != nil {
2237- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 58}2241+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 58}
2238 }2242 }
2239 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))2243 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
2240 if templ_7745c5c3_Err != nil {2244 if templ_7745c5c3_Err != nil {
@@ -2247,7 +2251,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2247 var templ_7745c5c3_Var115 templ.SafeURL2251 var templ_7745c5c3_Var115 templ.SafeURL
2248 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))2252 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
2249 if templ_7745c5c3_Err != nil {2253 if templ_7745c5c3_Err != nil {
2250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 45}2254+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 45}
2251 }2255 }
2252 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))2256 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
2253 if templ_7745c5c3_Err != nil {2257 if templ_7745c5c3_Err != nil {
@@ -2260,7 +2264,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2260 var templ_7745c5c3_Var116 string2264 var templ_7745c5c3_Var116 string
2261 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2265 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2262 if templ_7745c5c3_Err != nil {2266 if templ_7745c5c3_Err != nil {
2263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 58}2267+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 58}
2264 }2268 }
2265 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))2269 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
2266 if templ_7745c5c3_Err != nil {2270 if templ_7745c5c3_Err != nil {
@@ -2273,7 +2277,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2273 var templ_7745c5c3_Var117 string2277 var templ_7745c5c3_Var117 string
2274 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)2278 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
2275 if templ_7745c5c3_Err != nil {2279 if templ_7745c5c3_Err != nil {
2276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 422, Col: 21}2280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 21}
2277 }2281 }
2278 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))2282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
2279 if templ_7745c5c3_Err != nil {2283 if templ_7745c5c3_Err != nil {
@@ -2291,7 +2295,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2291 var templ_7745c5c3_Var118 string2295 var templ_7745c5c3_Var118 string
2292 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)2296 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
2293 if templ_7745c5c3_Err != nil {2297 if templ_7745c5c3_Err != nil {
2294- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 29}2298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 422, Col: 29}
2295 }2299 }
2296 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))2300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
2297 if templ_7745c5c3_Err != nil {2301 if templ_7745c5c3_Err != nil {
@@ -2314,7 +2318,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2314 var templ_7745c5c3_Var119 templ.SafeURL2318 var templ_7745c5c3_Var119 templ.SafeURL
2315 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))2319 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
2316 if templ_7745c5c3_Err != nil {2320 if templ_7745c5c3_Err != nil {
2317- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 114}2321+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 425, Col: 114}
2318 }2322 }
2319 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))2323 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
2320 if templ_7745c5c3_Err != nil {2324 if templ_7745c5c3_Err != nil {
@@ -2327,7 +2331,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2327 var templ_7745c5c3_Var120 string2331 var templ_7745c5c3_Var120 string
2328 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)2332 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
2329 if templ_7745c5c3_Err != nil {2333 if templ_7745c5c3_Err != nil {
2330- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 135}2334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 425, Col: 135}
2331 }2335 }
2332 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))2336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
2333 if templ_7745c5c3_Err != nil {2337 if templ_7745c5c3_Err != nil {
@@ -2350,7 +2354,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2350 var templ_7745c5c3_Var121 templ.SafeURL2354 var templ_7745c5c3_Var121 templ.SafeURL
2351 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))2355 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
2352 if templ_7745c5c3_Err != nil {2356 if templ_7745c5c3_Err != nil {
2353- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 83}2357+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 428, Col: 83}
2354 }2358 }
2355 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))2359 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
2356 if templ_7745c5c3_Err != nil {2360 if templ_7745c5c3_Err != nil {
@@ -2363,7 +2367,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2363 var templ_7745c5c3_Var122 string2367 var templ_7745c5c3_Var122 string
2364 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)2368 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
2365 if templ_7745c5c3_Err != nil {2369 if templ_7745c5c3_Err != nil {
2366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 62}2370+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 62}
2367 }2371 }
2368 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)2372 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
2369 if templ_7745c5c3_Err != nil {2373 if templ_7745c5c3_Err != nil {
@@ -2376,7 +2380,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2376 var templ_7745c5c3_Var123 string2380 var templ_7745c5c3_Var123 string
2377 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)2381 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
2378 if templ_7745c5c3_Err != nil {2382 if templ_7745c5c3_Err != nil {
2379- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 62}2383+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 62}
2380 }2384 }
2381 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)2385 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
2382 if templ_7745c5c3_Err != nil {2386 if templ_7745c5c3_Err != nil {
@@ -2389,7 +2393,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2389 var templ_7745c5c3_Var124 string2393 var templ_7745c5c3_Var124 string
2390 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)2394 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
2391 if templ_7745c5c3_Err != nil {2395 if templ_7745c5c3_Err != nil {
2392- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 436, Col: 97}2396+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 97}
2393 }2397 }
2394 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)2398 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
2395 if templ_7745c5c3_Err != nil {2399 if templ_7745c5c3_Err != nil {
@@ -2412,7 +2416,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2412 var templ_7745c5c3_Var125 string2416 var templ_7745c5c3_Var125 string
2413 templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))2417 templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
2414 if templ_7745c5c3_Err != nil {2418 if templ_7745c5c3_Err != nil {
2415- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 57}2419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 57}
2416 }2420 }
2417 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))2421 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
2418 if templ_7745c5c3_Err != nil {2422 if templ_7745c5c3_Err != nil {
@@ -2425,7 +2429,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2425 var templ_7745c5c3_Var126 string2429 var templ_7745c5c3_Var126 string
2426 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))2430 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
2427 if templ_7745c5c3_Err != nil {2431 if templ_7745c5c3_Err != nil {
2428- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 97}2432+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 97}
2429 }2433 }
2430 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))2434 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
2431 if templ_7745c5c3_Err != nil {2435 if templ_7745c5c3_Err != nil {
@@ -2438,7 +2442,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2438 var templ_7745c5c3_Var127 string2442 var templ_7745c5c3_Var127 string
2439 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))2443 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
2440 if templ_7745c5c3_Err != nil {2444 if templ_7745c5c3_Err != nil {
2441- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 142}2445+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 142}
2442 }2446 }
2443 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))2447 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
2444 if templ_7745c5c3_Err != nil {2448 if templ_7745c5c3_Err != nil {
@@ -2482,7 +2486,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2482 var templ_7745c5c3_Var128 string2486 var templ_7745c5c3_Var128 string
2483 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)2487 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2484 if templ_7745c5c3_Err != nil {2488 if templ_7745c5c3_Err != nil {
2485- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 23}2489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 457, Col: 23}
2486 }2490 }
2487 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)2491 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
2488 if templ_7745c5c3_Err != nil {2492 if templ_7745c5c3_Err != nil {
@@ -2500,7 +2504,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
2500 var templ_7745c5c3_Var129 string2504 var templ_7745c5c3_Var129 string
2501 templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)2505 templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
2502 if templ_7745c5c3_Err != nil {2506 if templ_7745c5c3_Err != nil {
2503- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 23}2507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 23}
2504 }2508 }
2505 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)2509 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
2506 if templ_7745c5c3_Err != nil {2510 if templ_7745c5c3_Err != nil {
@@ -2570,7 +2574,7 @@ func compareRail(p ComparePage) templ.Component {
2570 var templ_7745c5c3_Var131 string2574 var templ_7745c5c3_Var131 string
2571 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))2575 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
2572 if templ_7745c5c3_Err != nil {2576 if templ_7745c5c3_Err != nil {
2573- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 67}2577+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 475, Col: 67}
2574 }2578 }
2575 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))2579 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
2576 if templ_7745c5c3_Err != nil {2580 if templ_7745c5c3_Err != nil {
@@ -2588,7 +2592,7 @@ func compareRail(p ComparePage) templ.Component {
2588 var templ_7745c5c3_Var132 templ.SafeURL2592 var templ_7745c5c3_Var132 templ.SafeURL
2589 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))2593 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
2590 if templ_7745c5c3_Err != nil {2594 if templ_7745c5c3_Err != nil {
2591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 37}2595+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 37}
2592 }2596 }
2593 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))2597 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
2594 if templ_7745c5c3_Err != nil {2598 if templ_7745c5c3_Err != nil {
@@ -2601,7 +2605,7 @@ func compareRail(p ComparePage) templ.Component {
2601 var templ_7745c5c3_Var133 string2605 var templ_7745c5c3_Var133 string
2602 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)2606 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
2603 if templ_7745c5c3_Err != nil {2607 if templ_7745c5c3_Err != nil {
2604- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 49}2608+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 49}
2605 }2609 }
2606 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))2610 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
2607 if templ_7745c5c3_Err != nil {2611 if templ_7745c5c3_Err != nil {
@@ -2614,7 +2618,7 @@ func compareRail(p ComparePage) templ.Component {
2614 var templ_7745c5c3_Var134 string2618 var templ_7745c5c3_Var134 string
2615 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)2619 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
2616 if templ_7745c5c3_Err != nil {2620 if templ_7745c5c3_Err != nil {
2617- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 32}2621+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 32}
2618 }2622 }
2619 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))2623 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
2620 if templ_7745c5c3_Err != nil {2624 if templ_7745c5c3_Err != nil {
@@ -2627,7 +2631,7 @@ func compareRail(p ComparePage) templ.Component {
2627 var templ_7745c5c3_Var135 string2631 var templ_7745c5c3_Var135 string
2628 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)2632 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
2629 if templ_7745c5c3_Err != nil {2633 if templ_7745c5c3_Err != nil {
2630- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 16}2634+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 16}
2631 }2635 }
2632 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))2636 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
2633 if templ_7745c5c3_Err != nil {2637 if templ_7745c5c3_Err != nil {
@@ -2641,7 +2645,7 @@ func compareRail(p ComparePage) templ.Component {
2641 var templ_7745c5c3_Var136 string2645 var templ_7745c5c3_Var136 string
2642 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")2646 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
2643 if templ_7745c5c3_Err != nil {2647 if templ_7745c5c3_Err != nil {
2644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 15}2648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 15}
2645 }2649 }
2646 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))2650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
2647 if templ_7745c5c3_Err != nil {2651 if templ_7745c5c3_Err != nil {
@@ -2654,7 +2658,7 @@ func compareRail(p ComparePage) templ.Component {
2654 var templ_7745c5c3_Var137 string2658 var templ_7745c5c3_Var137 string
2655 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)2659 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
2656 if templ_7745c5c3_Err != nil {2660 if templ_7745c5c3_Err != nil {
2657- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 42}2661+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 42}
2658 }2662 }
2659 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)2663 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)
2660 if templ_7745c5c3_Err != nil {2664 if templ_7745c5c3_Err != nil {
@@ -2667,7 +2671,7 @@ func compareRail(p ComparePage) templ.Component {
2667 var templ_7745c5c3_Var138 string2671 var templ_7745c5c3_Var138 string
2668 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)2672 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
2669 if templ_7745c5c3_Err != nil {2673 if templ_7745c5c3_Err != nil {
2670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 53}2674+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 53}
2671 }2675 }
2672 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))2676 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
2673 if templ_7745c5c3_Err != nil {2677 if templ_7745c5c3_Err != nil {
@@ -2695,7 +2699,7 @@ func compareRail(p ComparePage) templ.Component {
2695 var templ_7745c5c3_Var139 string2699 var templ_7745c5c3_Var139 string
2696 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)2700 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
2697 if templ_7745c5c3_Err != nil {2701 if templ_7745c5c3_Err != nil {
2698- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 495, Col: 47}2702+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 491, Col: 47}
2699 }2703 }
2700 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))2704 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
2701 if templ_7745c5c3_Err != nil {2705 if templ_7745c5c3_Err != nil {
@@ -2718,7 +2722,7 @@ func compareRail(p ComparePage) templ.Component {
2718 var templ_7745c5c3_Var140 string2722 var templ_7745c5c3_Var140 string
2719 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))2723 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
2720 if templ_7745c5c3_Err != nil {2724 if templ_7745c5c3_Err != nil {
2721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 500, Col: 66}2725+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 66}
2722 }2726 }
2723 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))2727 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
2724 if templ_7745c5c3_Err != nil {2728 if templ_7745c5c3_Err != nil {
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">
@@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) {
128 } else {128 } else {
129 { r.Short }129 { r.Short }
130 }130 }
131+ @CommitChecks(r.Checks)
131 </td>132 </td>
132 <td class="c-sub">{ r.Subject }</td>133 <td class="c-sub">{ r.Subject }</td>
133 <td class="c-when dim">134 <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 }
@@ -43,14 +43,14 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
43 return a && !b43 return a && !b
44 })44 })
45 for i, b := range branches {45 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))
47 }47 }
48 tags := append([]domain.Ref(nil), d.tags...)48 tags := append([]domain.Ref(nil), d.tags...)
49 sort.SliceStable(tags, func(i, j int) bool {49 sort.SliceStable(tags, func(i, j int) bool {
50 return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at50 return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at
51 })51 })
52 for i, t := range tags {52 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))
54 }54 }
55 if len(tags) > 0 {55 if len(tags) > 0 {
56 p.Latest = tags[0].ShortName56 p.Latest = tags[0].ShortName
@@ -61,7 +61,7 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
61 return p61 return p
62 }62 }
63 63
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 {
65 id := r.CommitID.String()65 id := r.CommitID.String()
66 c := commits[id]66 c := commits[id]
67 row := RefRow{67 row := RefRow{
@@ -71,6 +71,7 @@ func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit,
71 }71 }
72 if id != "" {72 if id != "" {
73 row.CHref = repoHref(d.name(), "commit", id)73 row.CHref = repoHref(d.name(), "commit", id)
74+ row.Checks = checksSummary(d.repo.Name, id, s.checksFor(ctx, d.repo.Name, id))
74 }75 }
75 return row76 return row
76 }77 }
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"
@@ -135,6 +135,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
135 if s.cfg.MergeRequests != nil {135 if s.cfg.MergeRequests != nil {
136 l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))136 l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))
137 }137 }
138+ l.Sections = s.withActions(l.Sections, d.name(), section)
138 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}139 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
139 140
140 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})141 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
161 return l162 return l
162 }163 }
163 164
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+
164 // cloneURL is the HTTPS clone URL of a repository.173 // cloneURL is the HTTPS clone URL of a repository.
165 func (s *Server) cloneURL(name string) string {174 func (s *Server) cloneURL(name string) string {
166 return s.cfg.PublicURL + "/" + name + ".git"175 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+}
@@ -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+})();
@@ -363,3 +363,26 @@ window.ermiteLive = (() => {
363 },363 },
364 };364 };
365 })();365 })();
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.
@@ -135,6 +135,9 @@ type CommitRow struct {
135 Branches []string135 Branches []string
136 Tags []string136 Tags []string
137 Cursor bool137 Cursor bool
138+ // Checks is the combined state of the CI checks of the commit; empty when
139+ // it has none.
140+ Checks ChecksSummary
138 }141 }
139 142
140 // SummaryPage is the repository summary.143 // SummaryPage is the repository summary.
@@ -162,6 +165,8 @@ type LatestTag struct {
162 type RefRow struct {165 type RefRow struct {
163 Name, Href, Short, CHref, Subject, When, WhenAt string166 Name, Href, Short, CHref, Subject, When, WhenAt string
164 Default, Cursor bool167 Default, Cursor bool
168+ // Checks is the combined state of the CI checks of the commit.
169+ Checks ChecksSummary
165 }170 }
166 171
167 // RefsPage lists the branches and tags of a repository.172 // 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 }
@@ -54,6 +54,10 @@ type Config struct {
54 // there are no such pages, nor section for them. Leave it unset rather54 // there are no such pages, nor section for them. Leave it unset rather
55 // than assigning a typed nil pointer.55 // than assigning a typed nil pointer.
56 MergeRequests ports.MergeRequestService56 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
57 // Events is the source of the event stream behind /-/events. Without61 // Events is the source of the event stream behind /-/events. Without
58 // it the pages are not live and the route answers 404. Leave it unset62 // it the pages are not live and the route answers 404. Leave it unset
59 // rather than assigning a typed nil pointer.63 // rather than assigning a typed nil pointer.
@@ -161,6 +165,17 @@ func (s *Server) routes() *http.ServeMux {
161 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))165 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
162 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))166 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
163 }167 }
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+ }
164 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))179 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
165 return mux180 return mux
166 }181 }
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+}
@@ -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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -308,6 +312,12 @@
308 <span class="k">section</span>312 <span class="k">section</span>
309 </a>313 </a>
310 </li>314 </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>
311 <li data-k="branch feature/x">321 <li data-k="branch feature/x">
312 <a href="/files/tree/feature/x" tabindex="-1">322 <a href="/files/tree/feature/x" tabindex="-1">
313 <span class="lbl">feature/x</span>323 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -378,6 +382,12 @@
378 <span class="k">section</span>382 <span class="k">section</span>
379 </a>383 </a>
380 </li>384 </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>
381 <li data-k="branch feature/x">391 <li data-k="branch feature/x">
382 <a href="/files/tree/feature/x" tabindex="-1">392 <a href="/files/tree/feature/x" tabindex="-1">
383 <span class="lbl">feature/x</span>393 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -308,6 +312,12 @@
308 <span class="k">section</span>312 <span class="k">section</span>
309 </a>313 </a>
310 </li>314 </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>
311 <li data-k="branch feature/x">321 <li data-k="branch feature/x">
312 <a href="/files/tree/feature/x" tabindex="-1">322 <a href="/files/tree/feature/x" tabindex="-1">
313 <span class="lbl">feature/x</span>323 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -310,6 +314,12 @@
310 <span class="k">section</span>314 <span class="k">section</span>
311 </a>315 </a>
312 </li>316 </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>
313 <li data-k="branch feature/x">323 <li data-k="branch feature/x">
314 <a href="/files/tree/feature/x" tabindex="-1">324 <a href="/files/tree/feature/x" tabindex="-1">
315 <span class="lbl">feature/x</span>325 <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>
@@ -86,6 +86,10 @@
86 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>86 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87 </a>87 </a>
88 </li>88 </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>
89 </ul>93 </ul>
90 </nav>94 </nav>
91 </header>95 </header>
@@ -375,6 +379,12 @@
375 <span class="k">section</span>379 <span class="k">section</span>
376 </a>380 </a>
377 </li>381 </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>
378 <li data-k="branch feature/x">388 <li data-k="branch feature/x">
379 <a href="/files/tree/feature/x" tabindex="-1">389 <a href="/files/tree/feature/x" tabindex="-1">
380 <span class="lbl">feature/x</span>390 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -333,6 +337,12 @@
333 <span class="k">section</span>337 <span class="k">section</span>
334 </a>338 </a>
335 </li>339 </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>
336 <li data-k="branch feature/x">346 <li data-k="branch feature/x">
337 <a href="/files/tree/feature/x" tabindex="-1">347 <a href="/files/tree/feature/x" tabindex="-1">
338 <span class="lbl">feature/x</span>348 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -328,6 +332,12 @@
328 <span class="k">section</span>332 <span class="k">section</span>
329 </a>333 </a>
330 </li>334 </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>
331 <li data-k="branch feature/x">341 <li data-k="branch feature/x">
332 <a href="/files/tree/feature/x" tabindex="-1">342 <a href="/files/tree/feature/x" tabindex="-1">
333 <span class="lbl">feature/x</span>343 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -308,6 +312,12 @@
308 <span class="k">section</span>312 <span class="k">section</span>
309 </a>313 </a>
310 </li>314 </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>
311 <li data-k="branch feature/x">321 <li data-k="branch feature/x">
312 <a href="/files/tree/feature/x" tabindex="-1">322 <a href="/files/tree/feature/x" tabindex="-1">
313 <span class="lbl">feature/x</span>323 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -375,6 +379,12 @@
375 <span class="k">section</span>379 <span class="k">section</span>
376 </a>380 </a>
377 </li>381 </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>
378 <li data-k="branch feature/x">388 <li data-k="branch feature/x">
379 <a href="/files/tree/feature/x" tabindex="-1">389 <a href="/files/tree/feature/x" tabindex="-1">
380 <span class="lbl">feature/x</span>390 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -345,6 +349,12 @@
345 <span class="k">section</span>349 <span class="k">section</span>
346 </a>350 </a>
347 </li>351 </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>
348 <li data-k="branch main">358 <li data-k="branch main">
349 <a href="/diffs/tree/main" tabindex="-1">359 <a href="/diffs/tree/main" tabindex="-1">
350 <span class="lbl">main</span>360 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -340,6 +344,12 @@
340 <span class="k">section</span>344 <span class="k">section</span>
341 </a>345 </a>
342 </li>346 </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>
343 <li data-k="branch main">353 <li data-k="branch main">
344 <a href="/diffs/tree/main" tabindex="-1">354 <a href="/diffs/tree/main" tabindex="-1">
345 <span class="lbl">main</span>355 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -379,6 +383,12 @@
379 <span class="k">section</span>383 <span class="k">section</span>
380 </a>384 </a>
381 </li>385 </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>
382 <li data-k="branch main">392 <li data-k="branch main">
383 <a href="/diffs/tree/main" tabindex="-1">393 <a href="/diffs/tree/main" tabindex="-1">
384 <span class="lbl">main</span>394 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -500,6 +504,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500 <span class="k">section</span>504 <span class="k">section</span>
501 </a>505 </a>
502 </li>506 </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>
503 <li data-k="branch main">513 <li data-k="branch main">
504 <a href="/diffs/tree/main" tabindex="-1">514 <a href="/diffs/tree/main" tabindex="-1">
505 <span class="lbl">main</span>515 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -500,6 +504,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500 <span class="k">section</span>504 <span class="k">section</span>
501 </a>505 </a>
502 </li>506 </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>
503 <li data-k="branch main">513 <li data-k="branch main">
504 <a href="/diffs/tree/main" tabindex="-1">514 <a href="/diffs/tree/main" tabindex="-1">
505 <span class="lbl">main</span>515 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -405,6 +409,12 @@
405 <span class="k">section</span>409 <span class="k">section</span>
406 </a>410 </a>
407 </li>411 </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>
408 <li data-k="branch main">418 <li data-k="branch main">
409 <a href="/diffs/tree/main" tabindex="-1">419 <a href="/diffs/tree/main" tabindex="-1">
410 <span class="lbl">main</span>420 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -721,6 +725,12 @@
721 <span class="k">section</span>725 <span class="k">section</span>
722 </a>726 </a>
723 </li>727 </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>
724 <li data-k="branch main">734 <li data-k="branch main">
725 <a href="/diffs/tree/main" tabindex="-1">735 <a href="/diffs/tree/main" tabindex="-1">
726 <span class="lbl">main</span>736 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -514,6 +518,12 @@
514 <span class="k">section</span>518 <span class="k">section</span>
515 </a>519 </a>
516 </li>520 </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>
517 <li data-k="branch main">527 <li data-k="branch main">
518 <a href="/diffs/tree/main" tabindex="-1">528 <a href="/diffs/tree/main" tabindex="-1">
519 <span class="lbl">main</span>529 <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>
@@ -80,6 +80,10 @@
80 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -103,6 +107,8 @@
103 <td class="c-subject">107 <td class="c-subject">
104 <a href="/long/commit/SHA40">commit number 1</a>108 <a href="/long/commit/SHA40">commit number 1</a>
105 </td>109 </td>
110+<td class="c-ck">
111+</td>
106 <td class="c-who dim">T</td>112 <td class="c-who dim">T</td>
107 <td class="c-when nowrap dim">113 <td class="c-when nowrap dim">
108 <time datetime="STAMP" title="STAMP">AGO</time>114 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -115,6 +121,8 @@
115 <td class="c-subject">121 <td class="c-subject">
116 <a href="/long/commit/SHA40">commit number 0</a>122 <a href="/long/commit/SHA40">commit number 0</a>
117 </td>123 </td>
124+<td class="c-ck">
125+</td>
118 <td class="c-who dim">T</td>126 <td class="c-who dim">T</td>
119 <td class="c-when nowrap dim">127 <td class="c-when nowrap dim">
120 <time datetime="STAMP" title="STAMP">AGO</time>128 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -327,6 +335,12 @@
327 <span class="k">section</span>335 <span class="k">section</span>
328 </a>336 </a>
329 </li>337 </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>
330 <li data-k="branch main">344 <li data-k="branch main">
331 <a href="/long/tree/main" tabindex="-1">345 <a href="/long/tree/main" tabindex="-1">
332 <span class="lbl">main</span>346 <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>
@@ -83,6 +83,10 @@
83 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -107,6 +111,8 @@
107 <td class="c-subject">111 <td class="c-subject">
108 <a href="/long/commit/SHA40">commit number 30</a>112 <a href="/long/commit/SHA40">commit number 30</a>
109 </td>113 </td>
114+<td class="c-ck">
115+</td>
110 <td class="c-who dim">T</td>116 <td class="c-who dim">T</td>
111 <td class="c-when nowrap dim">117 <td class="c-when nowrap dim">
112 <time datetime="STAMP" title="STAMP">AGO</time>118 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -128,6 +134,8 @@
128 <td class="c-subject">134 <td class="c-subject">
129 <a href="/long/commit/SHA40">commit number 3</a>135 <a href="/long/commit/SHA40">commit number 3</a>
130 </td>136 </td>
137+<td class="c-ck">
138+</td>
131 <td class="c-who dim">T</td>139 <td class="c-who dim">T</td>
132 <td class="c-when nowrap dim">140 <td class="c-when nowrap dim">
133 <time datetime="STAMP" title="STAMP">AGO</time>141 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -342,6 +350,12 @@
342 <span class="k">section</span>350 <span class="k">section</span>
343 </a>351 </a>
344 </li>352 </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>
345 <li data-k="branch main">359 <li data-k="branch main">
346 <a href="/long/tree/main" tabindex="-1">360 <a href="/long/tree/main" tabindex="-1">
347 <span class="lbl">main</span>361 <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>
@@ -80,6 +80,10 @@
80 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -106,6 +110,8 @@
106 <a href="/long/commit/SHA40">commit number 51</a>110 <a href="/long/commit/SHA40">commit number 51</a>
107 <span class="pill">main</span>111 <span class="pill">main</span>
108 </td>112 </td>
113+<td class="c-ck">
114+</td>
109 <td class="c-who dim">T</td>115 <td class="c-who dim">T</td>
110 <td class="c-when nowrap dim">116 <td class="c-when nowrap dim">
111 <time datetime="STAMP" title="STAMP">AGO</time>117 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -118,6 +124,8 @@
118 <td class="c-subject">124 <td class="c-subject">
119 <a href="/long/commit/SHA40">commit number 50</a>125 <a href="/long/commit/SHA40">commit number 50</a>
120 </td>126 </td>
127+<td class="c-ck">
128+</td>
121 <td class="c-who dim">T</td>129 <td class="c-who dim">T</td>
122 <td class="c-when nowrap dim">130 <td class="c-when nowrap dim">
123 <time datetime="STAMP" title="STAMP">AGO</time>131 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -139,6 +147,8 @@
139 <td class="c-subject">147 <td class="c-subject">
140 <a href="/long/commit/SHA40">commit number 49</a>148 <a href="/long/commit/SHA40">commit number 49</a>
141 </td>149 </td>
150+<td class="c-ck">
151+</td>
142 <td class="c-who dim">T</td>152 <td class="c-who dim">T</td>
143 <td class="c-when nowrap dim">153 <td class="c-when nowrap dim">
144 <time datetime="STAMP" title="STAMP">AGO</time>154 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -151,6 +161,8 @@
151 <td class="c-subject">161 <td class="c-subject">
152 <a href="/long/commit/SHA40">commit number 48</a>162 <a href="/long/commit/SHA40">commit number 48</a>
153 </td>163 </td>
164+<td class="c-ck">
165+</td>
154 <td class="c-who dim">T</td>166 <td class="c-who dim">T</td>
155 <td class="c-when nowrap dim">167 <td class="c-when nowrap dim">
156 <time datetime="STAMP" title="STAMP">AGO</time>168 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -172,6 +184,8 @@
172 <td class="c-subject">184 <td class="c-subject">
173 <a href="/long/commit/SHA40">commit number 47</a>185 <a href="/long/commit/SHA40">commit number 47</a>
174 </td>186 </td>
187+<td class="c-ck">
188+</td>
175 <td class="c-who dim">T</td>189 <td class="c-who dim">T</td>
176 <td class="c-when nowrap dim">190 <td class="c-when nowrap dim">
177 <time datetime="STAMP" title="STAMP">AGO</time>191 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -184,6 +198,8 @@
184 <td class="c-subject">198 <td class="c-subject">
185 <a href="/long/commit/SHA40">commit number 46</a>199 <a href="/long/commit/SHA40">commit number 46</a>
186 </td>200 </td>
201+<td class="c-ck">
202+</td>
187 <td class="c-who dim">T</td>203 <td class="c-who dim">T</td>
188 <td class="c-when nowrap dim">204 <td class="c-when nowrap dim">
189 <time datetime="STAMP" title="STAMP">AGO</time>205 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -205,6 +221,8 @@
205 <td class="c-subject">221 <td class="c-subject">
206 <a href="/long/commit/SHA40">commit number 45</a>222 <a href="/long/commit/SHA40">commit number 45</a>
207 </td>223 </td>
224+<td class="c-ck">
225+</td>
208 <td class="c-who dim">T</td>226 <td class="c-who dim">T</td>
209 <td class="c-when nowrap dim">227 <td class="c-when nowrap dim">
210 <time datetime="STAMP" title="STAMP">AGO</time>228 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -217,6 +235,8 @@
217 <td class="c-subject">235 <td class="c-subject">
218 <a href="/long/commit/SHA40">commit number 44</a>236 <a href="/long/commit/SHA40">commit number 44</a>
219 </td>237 </td>
238+<td class="c-ck">
239+</td>
220 <td class="c-who dim">T</td>240 <td class="c-who dim">T</td>
221 <td class="c-when nowrap dim">241 <td class="c-when nowrap dim">
222 <time datetime="STAMP" title="STAMP">AGO</time>242 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -238,6 +258,8 @@
238 <td class="c-subject">258 <td class="c-subject">
239 <a href="/long/commit/SHA40">commit number 43</a>259 <a href="/long/commit/SHA40">commit number 43</a>
240 </td>260 </td>
261+<td class="c-ck">
262+</td>
241 <td class="c-who dim">T</td>263 <td class="c-who dim">T</td>
242 <td class="c-when nowrap dim">264 <td class="c-when nowrap dim">
243 <time datetime="STAMP" title="STAMP">AGO</time>265 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -250,6 +272,8 @@
250 <td class="c-subject">272 <td class="c-subject">
251 <a href="/long/commit/SHA40">commit number 42</a>273 <a href="/long/commit/SHA40">commit number 42</a>
252 </td>274 </td>
275+<td class="c-ck">
276+</td>
253 <td class="c-who dim">T</td>277 <td class="c-who dim">T</td>
254 <td class="c-when nowrap dim">278 <td class="c-when nowrap dim">
255 <time datetime="STAMP" title="STAMP">AGO</time>279 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -271,6 +295,8 @@
271 <td class="c-subject">295 <td class="c-subject">
272 <a href="/long/commit/SHA40">commit number 41</a>296 <a href="/long/commit/SHA40">commit number 41</a>
273 </td>297 </td>
298+<td class="c-ck">
299+</td>
274 <td class="c-who dim">T</td>300 <td class="c-who dim">T</td>
275 <td class="c-when nowrap dim">301 <td class="c-when nowrap dim">
276 <time datetime="STAMP" title="STAMP">AGO</time>302 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -283,6 +309,8 @@
283 <td class="c-subject">309 <td class="c-subject">
284 <a href="/long/commit/SHA40">commit number 40</a>310 <a href="/long/commit/SHA40">commit number 40</a>
285 </td>311 </td>
312+<td class="c-ck">
313+</td>
286 <td class="c-who dim">T</td>314 <td class="c-who dim">T</td>
287 <td class="c-when nowrap dim">315 <td class="c-when nowrap dim">
288 <time datetime="STAMP" title="STAMP">AGO</time>316 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,6 +332,8 @@
304 <td class="c-subject">332 <td class="c-subject">
305 <a href="/long/commit/SHA40">commit number 39</a>333 <a href="/long/commit/SHA40">commit number 39</a>
306 </td>334 </td>
335+<td class="c-ck">
336+</td>
307 <td class="c-who dim">T</td>337 <td class="c-who dim">T</td>
308 <td class="c-when nowrap dim">338 <td class="c-when nowrap dim">
309 <time datetime="STAMP" title="STAMP">AGO</time>339 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -316,6 +346,8 @@
316 <td class="c-subject">346 <td class="c-subject">
317 <a href="/long/commit/SHA40">commit number 38</a>347 <a href="/long/commit/SHA40">commit number 38</a>
318 </td>348 </td>
349+<td class="c-ck">
350+</td>
319 <td class="c-who dim">T</td>351 <td class="c-who dim">T</td>
320 <td class="c-when nowrap dim">352 <td class="c-when nowrap dim">
321 <time datetime="STAMP" title="STAMP">AGO</time>353 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -337,6 +369,8 @@
337 <td class="c-subject">369 <td class="c-subject">
338 <a href="/long/commit/SHA40">commit number 37</a>370 <a href="/long/commit/SHA40">commit number 37</a>
339 </td>371 </td>
372+<td class="c-ck">
373+</td>
340 <td class="c-who dim">T</td>374 <td class="c-who dim">T</td>
341 <td class="c-when nowrap dim">375 <td class="c-when nowrap dim">
342 <time datetime="STAMP" title="STAMP">AGO</time>376 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -349,6 +383,8 @@
349 <td class="c-subject">383 <td class="c-subject">
350 <a href="/long/commit/SHA40">commit number 36</a>384 <a href="/long/commit/SHA40">commit number 36</a>
351 </td>385 </td>
386+<td class="c-ck">
387+</td>
352 <td class="c-who dim">T</td>388 <td class="c-who dim">T</td>
353 <td class="c-when nowrap dim">389 <td class="c-when nowrap dim">
354 <time datetime="STAMP" title="STAMP">AGO</time>390 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -370,6 +406,8 @@
370 <td class="c-subject">406 <td class="c-subject">
371 <a href="/long/commit/SHA40">commit number 35</a>407 <a href="/long/commit/SHA40">commit number 35</a>
372 </td>408 </td>
409+<td class="c-ck">
410+</td>
373 <td class="c-who dim">T</td>411 <td class="c-who dim">T</td>
374 <td class="c-when nowrap dim">412 <td class="c-when nowrap dim">
375 <time datetime="STAMP" title="STAMP">AGO</time>413 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -382,6 +420,8 @@
382 <td class="c-subject">420 <td class="c-subject">
383 <a href="/long/commit/SHA40">commit number 34</a>421 <a href="/long/commit/SHA40">commit number 34</a>
384 </td>422 </td>
423+<td class="c-ck">
424+</td>
385 <td class="c-who dim">T</td>425 <td class="c-who dim">T</td>
386 <td class="c-when nowrap dim">426 <td class="c-when nowrap dim">
387 <time datetime="STAMP" title="STAMP">AGO</time>427 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -403,6 +443,8 @@
403 <td class="c-subject">443 <td class="c-subject">
404 <a href="/long/commit/SHA40">commit number 33</a>444 <a href="/long/commit/SHA40">commit number 33</a>
405 </td>445 </td>
446+<td class="c-ck">
447+</td>
406 <td class="c-who dim">T</td>448 <td class="c-who dim">T</td>
407 <td class="c-when nowrap dim">449 <td class="c-when nowrap dim">
408 <time datetime="STAMP" title="STAMP">AGO</time>450 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -415,6 +457,8 @@
415 <td class="c-subject">457 <td class="c-subject">
416 <a href="/long/commit/SHA40">commit number 32</a>458 <a href="/long/commit/SHA40">commit number 32</a>
417 </td>459 </td>
460+<td class="c-ck">
461+</td>
418 <td class="c-who dim">T</td>462 <td class="c-who dim">T</td>
419 <td class="c-when nowrap dim">463 <td class="c-when nowrap dim">
420 <time datetime="STAMP" title="STAMP">AGO</time>464 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -436,6 +480,8 @@
436 <td class="c-subject">480 <td class="c-subject">
437 <a href="/long/commit/SHA40">commit number 31</a>481 <a href="/long/commit/SHA40">commit number 31</a>
438 </td>482 </td>
483+<td class="c-ck">
484+</td>
439 <td class="c-who dim">T</td>485 <td class="c-who dim">T</td>
440 <td class="c-when nowrap dim">486 <td class="c-when nowrap dim">
441 <time datetime="STAMP" title="STAMP">AGO</time>487 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -448,6 +494,8 @@
448 <td class="c-subject">494 <td class="c-subject">
449 <a href="/long/commit/SHA40">commit number 30</a>495 <a href="/long/commit/SHA40">commit number 30</a>
450 </td>496 </td>
497+<td class="c-ck">
498+</td>
451 <td class="c-who dim">T</td>499 <td class="c-who dim">T</td>
452 <td class="c-when nowrap dim">500 <td class="c-when nowrap dim">
453 <time datetime="STAMP" title="STAMP">AGO</time>501 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -469,6 +517,8 @@
469 <td class="c-subject">517 <td class="c-subject">
470 <a href="/long/commit/SHA40">commit number 29</a>518 <a href="/long/commit/SHA40">commit number 29</a>
471 </td>519 </td>
520+<td class="c-ck">
521+</td>
472 <td class="c-who dim">T</td>522 <td class="c-who dim">T</td>
473 <td class="c-when nowrap dim">523 <td class="c-when nowrap dim">
474 <time datetime="STAMP" title="STAMP">AGO</time>524 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -481,6 +531,8 @@
481 <td class="c-subject">531 <td class="c-subject">
482 <a href="/long/commit/SHA40">commit number 28</a>532 <a href="/long/commit/SHA40">commit number 28</a>
483 </td>533 </td>
534+<td class="c-ck">
535+</td>
484 <td class="c-who dim">T</td>536 <td class="c-who dim">T</td>
485 <td class="c-when nowrap dim">537 <td class="c-when nowrap dim">
486 <time datetime="STAMP" title="STAMP">AGO</time>538 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -502,6 +554,8 @@
502 <td class="c-subject">554 <td class="c-subject">
503 <a href="/long/commit/SHA40">commit number 27</a>555 <a href="/long/commit/SHA40">commit number 27</a>
504 </td>556 </td>
557+<td class="c-ck">
558+</td>
505 <td class="c-who dim">T</td>559 <td class="c-who dim">T</td>
506 <td class="c-when nowrap dim">560 <td class="c-when nowrap dim">
507 <time datetime="STAMP" title="STAMP">AGO</time>561 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -514,6 +568,8 @@
514 <td class="c-subject">568 <td class="c-subject">
515 <a href="/long/commit/SHA40">commit number 26</a>569 <a href="/long/commit/SHA40">commit number 26</a>
516 </td>570 </td>
571+<td class="c-ck">
572+</td>
517 <td class="c-who dim">T</td>573 <td class="c-who dim">T</td>
518 <td class="c-when nowrap dim">574 <td class="c-when nowrap dim">
519 <time datetime="STAMP" title="STAMP">AGO</time>575 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -535,6 +591,8 @@
535 <td class="c-subject">591 <td class="c-subject">
536 <a href="/long/commit/SHA40">commit number 25</a>592 <a href="/long/commit/SHA40">commit number 25</a>
537 </td>593 </td>
594+<td class="c-ck">
595+</td>
538 <td class="c-who dim">T</td>596 <td class="c-who dim">T</td>
539 <td class="c-when nowrap dim">597 <td class="c-when nowrap dim">
540 <time datetime="STAMP" title="STAMP">AGO</time>598 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -547,6 +605,8 @@
547 <td class="c-subject">605 <td class="c-subject">
548 <a href="/long/commit/SHA40">commit number 24</a>606 <a href="/long/commit/SHA40">commit number 24</a>
549 </td>607 </td>
608+<td class="c-ck">
609+</td>
550 <td class="c-who dim">T</td>610 <td class="c-who dim">T</td>
551 <td class="c-when nowrap dim">611 <td class="c-when nowrap dim">
552 <time datetime="STAMP" title="STAMP">AGO</time>612 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -568,6 +628,8 @@
568 <td class="c-subject">628 <td class="c-subject">
569 <a href="/long/commit/SHA40">commit number 23</a>629 <a href="/long/commit/SHA40">commit number 23</a>
570 </td>630 </td>
631+<td class="c-ck">
632+</td>
571 <td class="c-who dim">T</td>633 <td class="c-who dim">T</td>
572 <td class="c-when nowrap dim">634 <td class="c-when nowrap dim">
573 <time datetime="STAMP" title="STAMP">AGO</time>635 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -580,6 +642,8 @@
580 <td class="c-subject">642 <td class="c-subject">
581 <a href="/long/commit/SHA40">commit number 22</a>643 <a href="/long/commit/SHA40">commit number 22</a>
582 </td>644 </td>
645+<td class="c-ck">
646+</td>
583 <td class="c-who dim">T</td>647 <td class="c-who dim">T</td>
584 <td class="c-when nowrap dim">648 <td class="c-when nowrap dim">
585 <time datetime="STAMP" title="STAMP">AGO</time>649 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -601,6 +665,8 @@
601 <td class="c-subject">665 <td class="c-subject">
602 <a href="/long/commit/SHA40">commit number 21</a>666 <a href="/long/commit/SHA40">commit number 21</a>
603 </td>667 </td>
668+<td class="c-ck">
669+</td>
604 <td class="c-who dim">T</td>670 <td class="c-who dim">T</td>
605 <td class="c-when nowrap dim">671 <td class="c-when nowrap dim">
606 <time datetime="STAMP" title="STAMP">AGO</time>672 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -613,6 +679,8 @@
613 <td class="c-subject">679 <td class="c-subject">
614 <a href="/long/commit/SHA40">commit number 20</a>680 <a href="/long/commit/SHA40">commit number 20</a>
615 </td>681 </td>
682+<td class="c-ck">
683+</td>
616 <td class="c-who dim">T</td>684 <td class="c-who dim">T</td>
617 <td class="c-when nowrap dim">685 <td class="c-when nowrap dim">
618 <time datetime="STAMP" title="STAMP">AGO</time>686 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -634,6 +702,8 @@
634 <td class="c-subject">702 <td class="c-subject">
635 <a href="/long/commit/SHA40">commit number 19</a>703 <a href="/long/commit/SHA40">commit number 19</a>
636 </td>704 </td>
705+<td class="c-ck">
706+</td>
637 <td class="c-who dim">T</td>707 <td class="c-who dim">T</td>
638 <td class="c-when nowrap dim">708 <td class="c-when nowrap dim">
639 <time datetime="STAMP" title="STAMP">AGO</time>709 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -646,6 +716,8 @@
646 <td class="c-subject">716 <td class="c-subject">
647 <a href="/long/commit/SHA40">commit number 18</a>717 <a href="/long/commit/SHA40">commit number 18</a>
648 </td>718 </td>
719+<td class="c-ck">
720+</td>
649 <td class="c-who dim">T</td>721 <td class="c-who dim">T</td>
650 <td class="c-when nowrap dim">722 <td class="c-when nowrap dim">
651 <time datetime="STAMP" title="STAMP">AGO</time>723 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -667,6 +739,8 @@
667 <td class="c-subject">739 <td class="c-subject">
668 <a href="/long/commit/SHA40">commit number 17</a>740 <a href="/long/commit/SHA40">commit number 17</a>
669 </td>741 </td>
742+<td class="c-ck">
743+</td>
670 <td class="c-who dim">T</td>744 <td class="c-who dim">T</td>
671 <td class="c-when nowrap dim">745 <td class="c-when nowrap dim">
672 <time datetime="STAMP" title="STAMP">AGO</time>746 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -679,6 +753,8 @@
679 <td class="c-subject">753 <td class="c-subject">
680 <a href="/long/commit/SHA40">commit number 16</a>754 <a href="/long/commit/SHA40">commit number 16</a>
681 </td>755 </td>
756+<td class="c-ck">
757+</td>
682 <td class="c-who dim">T</td>758 <td class="c-who dim">T</td>
683 <td class="c-when nowrap dim">759 <td class="c-when nowrap dim">
684 <time datetime="STAMP" title="STAMP">AGO</time>760 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -700,6 +776,8 @@
700 <td class="c-subject">776 <td class="c-subject">
701 <a href="/long/commit/SHA40">commit number 15</a>777 <a href="/long/commit/SHA40">commit number 15</a>
702 </td>778 </td>
779+<td class="c-ck">
780+</td>
703 <td class="c-who dim">T</td>781 <td class="c-who dim">T</td>
704 <td class="c-when nowrap dim">782 <td class="c-when nowrap dim">
705 <time datetime="STAMP" title="STAMP">AGO</time>783 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -712,6 +790,8 @@
712 <td class="c-subject">790 <td class="c-subject">
713 <a href="/long/commit/SHA40">commit number 14</a>791 <a href="/long/commit/SHA40">commit number 14</a>
714 </td>792 </td>
793+<td class="c-ck">
794+</td>
715 <td class="c-who dim">T</td>795 <td class="c-who dim">T</td>
716 <td class="c-when nowrap dim">796 <td class="c-when nowrap dim">
717 <time datetime="STAMP" title="STAMP">AGO</time>797 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -733,6 +813,8 @@
733 <td class="c-subject">813 <td class="c-subject">
734 <a href="/long/commit/SHA40">commit number 13</a>814 <a href="/long/commit/SHA40">commit number 13</a>
735 </td>815 </td>
816+<td class="c-ck">
817+</td>
736 <td class="c-who dim">T</td>818 <td class="c-who dim">T</td>
737 <td class="c-when nowrap dim">819 <td class="c-when nowrap dim">
738 <time datetime="STAMP" title="STAMP">AGO</time>820 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -745,6 +827,8 @@
745 <td class="c-subject">827 <td class="c-subject">
746 <a href="/long/commit/SHA40">commit number 12</a>828 <a href="/long/commit/SHA40">commit number 12</a>
747 </td>829 </td>
830+<td class="c-ck">
831+</td>
748 <td class="c-who dim">T</td>832 <td class="c-who dim">T</td>
749 <td class="c-when nowrap dim">833 <td class="c-when nowrap dim">
750 <time datetime="STAMP" title="STAMP">AGO</time>834 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -766,6 +850,8 @@
766 <td class="c-subject">850 <td class="c-subject">
767 <a href="/long/commit/SHA40">commit number 11</a>851 <a href="/long/commit/SHA40">commit number 11</a>
768 </td>852 </td>
853+<td class="c-ck">
854+</td>
769 <td class="c-who dim">T</td>855 <td class="c-who dim">T</td>
770 <td class="c-when nowrap dim">856 <td class="c-when nowrap dim">
771 <time datetime="STAMP" title="STAMP">AGO</time>857 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -778,6 +864,8 @@
778 <td class="c-subject">864 <td class="c-subject">
779 <a href="/long/commit/SHA40">commit number 10</a>865 <a href="/long/commit/SHA40">commit number 10</a>
780 </td>866 </td>
867+<td class="c-ck">
868+</td>
781 <td class="c-who dim">T</td>869 <td class="c-who dim">T</td>
782 <td class="c-when nowrap dim">870 <td class="c-when nowrap dim">
783 <time datetime="STAMP" title="STAMP">AGO</time>871 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -799,6 +887,8 @@
799 <td class="c-subject">887 <td class="c-subject">
800 <a href="/long/commit/SHA40">commit number 9</a>888 <a href="/long/commit/SHA40">commit number 9</a>
801 </td>889 </td>
890+<td class="c-ck">
891+</td>
802 <td class="c-who dim">T</td>892 <td class="c-who dim">T</td>
803 <td class="c-when nowrap dim">893 <td class="c-when nowrap dim">
804 <time datetime="STAMP" title="STAMP">AGO</time>894 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -811,6 +901,8 @@
811 <td class="c-subject">901 <td class="c-subject">
812 <a href="/long/commit/SHA40">commit number 8</a>902 <a href="/long/commit/SHA40">commit number 8</a>
813 </td>903 </td>
904+<td class="c-ck">
905+</td>
814 <td class="c-who dim">T</td>906 <td class="c-who dim">T</td>
815 <td class="c-when nowrap dim">907 <td class="c-when nowrap dim">
816 <time datetime="STAMP" title="STAMP">AGO</time>908 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -832,6 +924,8 @@
832 <td class="c-subject">924 <td class="c-subject">
833 <a href="/long/commit/SHA40">commit number 7</a>925 <a href="/long/commit/SHA40">commit number 7</a>
834 </td>926 </td>
927+<td class="c-ck">
928+</td>
835 <td class="c-who dim">T</td>929 <td class="c-who dim">T</td>
836 <td class="c-when nowrap dim">930 <td class="c-when nowrap dim">
837 <time datetime="STAMP" title="STAMP">AGO</time>931 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -844,6 +938,8 @@
844 <td class="c-subject">938 <td class="c-subject">
845 <a href="/long/commit/SHA40">commit number 6</a>939 <a href="/long/commit/SHA40">commit number 6</a>
846 </td>940 </td>
941+<td class="c-ck">
942+</td>
847 <td class="c-who dim">T</td>943 <td class="c-who dim">T</td>
848 <td class="c-when nowrap dim">944 <td class="c-when nowrap dim">
849 <time datetime="STAMP" title="STAMP">AGO</time>945 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -865,6 +961,8 @@
865 <td class="c-subject">961 <td class="c-subject">
866 <a href="/long/commit/SHA40">commit number 5</a>962 <a href="/long/commit/SHA40">commit number 5</a>
867 </td>963 </td>
964+<td class="c-ck">
965+</td>
868 <td class="c-who dim">T</td>966 <td class="c-who dim">T</td>
869 <td class="c-when nowrap dim">967 <td class="c-when nowrap dim">
870 <time datetime="STAMP" title="STAMP">AGO</time>968 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -877,6 +975,8 @@
877 <td class="c-subject">975 <td class="c-subject">
878 <a href="/long/commit/SHA40">commit number 4</a>976 <a href="/long/commit/SHA40">commit number 4</a>
879 </td>977 </td>
978+<td class="c-ck">
979+</td>
880 <td class="c-who dim">T</td>980 <td class="c-who dim">T</td>
881 <td class="c-when nowrap dim">981 <td class="c-when nowrap dim">
882 <time datetime="STAMP" title="STAMP">AGO</time>982 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -898,6 +998,8 @@
898 <td class="c-subject">998 <td class="c-subject">
899 <a href="/long/commit/SHA40">commit number 3</a>999 <a href="/long/commit/SHA40">commit number 3</a>
900 </td>1000 </td>
1001+<td class="c-ck">
1002+</td>
901 <td class="c-who dim">T</td>1003 <td class="c-who dim">T</td>
902 <td class="c-when nowrap dim">1004 <td class="c-when nowrap dim">
903 <time datetime="STAMP" title="STAMP">AGO</time>1005 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -910,6 +1012,8 @@
910 <td class="c-subject">1012 <td class="c-subject">
911 <a href="/long/commit/SHA40">commit number 2</a>1013 <a href="/long/commit/SHA40">commit number 2</a>
912 </td>1014 </td>
1015+<td class="c-ck">
1016+</td>
913 <td class="c-who dim">T</td>1017 <td class="c-who dim">T</td>
914 <td class="c-when nowrap dim">1018 <td class="c-when nowrap dim">
915 <time datetime="STAMP" title="STAMP">AGO</time>1019 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -959,7 +1063,7 @@
959 htmx:sse:close, htmx:sse:error -> data.state = 'offline';1063 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
960 htmx:sse:error -> ermiteLive.error(event.detail.status);1064 htmx:sse:error -> ermiteLive.error(event.detail.status);
961 htmx:sse:before:connection -> ermiteLive.gate(event);1065 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);
963 gap -> ermiteLive.reload()">1067 gap -> ermiteLive.reload()">
964 </span>1068 </span>
965 </div>1069 </div>
@@ -1131,6 +1235,12 @@
1131 <span class="k">section</span>1235 <span class="k">section</span>
1132 </a>1236 </a>
1133 </li>1237 </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>
1134 <li data-k="branch main">1244 <li data-k="branch main">
1135 <a href="/long/tree/main" tabindex="-1">1245 <a href="/long/tree/main" tabindex="-1">
1136 <span class="lbl">main</span>1246 <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>
@@ -80,6 +80,10 @@
80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -106,6 +110,8 @@
106 <a href="/diffs/commit/SHA40">add a very large file</a>110 <a href="/diffs/commit/SHA40">add a very large file</a>
107 <span class="pill">main</span>111 <span class="pill">main</span>
108 </td>112 </td>
113+<td class="c-ck">
114+</td>
109 <td class="c-who dim">T</td>115 <td class="c-who dim">T</td>
110 <td class="c-when nowrap dim">116 <td class="c-when nowrap dim">
111 <time datetime="STAMP" title="STAMP">AGO</time>117 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -127,6 +133,8 @@
127 <td class="c-subject">133 <td class="c-subject">
128 <a href="/diffs/commit/SHA40">add evil.go</a>134 <a href="/diffs/commit/SHA40">add evil.go</a>
129 </td>135 </td>
136+<td class="c-ck">
137+</td>
130 <td class="c-who dim">T</td>138 <td class="c-who dim">T</td>
131 <td class="c-when nowrap dim">139 <td class="c-when nowrap dim">
132 <time datetime="STAMP" title="STAMP">AGO</time>140 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -149,6 +157,8 @@
149 <a href="/diffs/commit/SHA40">merge the side branch</a>157 <a href="/diffs/commit/SHA40">merge the side branch</a>
150 <span class="pill">merge</span>158 <span class="pill">merge</span>
151 </td>159 </td>
160+<td class="c-ck">
161+</td>
152 <td class="c-who dim">T</td>162 <td class="c-who dim">T</td>
153 <td class="c-when nowrap dim">163 <td class="c-when nowrap dim">
154 <time datetime="STAMP" title="STAMP">AGO</time>164 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -171,6 +181,8 @@
171 <a href="/diffs/commit/SHA40">work on the side</a>181 <a href="/diffs/commit/SHA40">work on the side</a>
172 <span class="pill">side</span>182 <span class="pill">side</span>
173 </td>183 </td>
184+<td class="c-ck">
185+</td>
174 <td class="c-who dim">T</td>186 <td class="c-who dim">T</td>
175 <td class="c-when nowrap dim">187 <td class="c-when nowrap dim">
176 <time datetime="STAMP" title="STAMP">AGO</time>188 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -192,6 +204,8 @@
192 <td class="c-subject">204 <td class="c-subject">
193 <a href="/diffs/commit/SHA40">change a binary file</a>205 <a href="/diffs/commit/SHA40">change a binary file</a>
194 </td>206 </td>
207+<td class="c-ck">
208+</td>
195 <td class="c-who dim">T</td>209 <td class="c-who dim">T</td>
196 <td class="c-when nowrap dim">210 <td class="c-when nowrap dim">
197 <time datetime="STAMP" title="STAMP">AGO</time>211 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -213,6 +227,8 @@
213 <td class="c-subject">227 <td class="c-subject">
214 <a href="/diffs/commit/SHA40">rename a file and delete another</a>228 <a href="/diffs/commit/SHA40">rename a file and delete another</a>
215 </td>229 </td>
230+<td class="c-ck">
231+</td>
216 <td class="c-who dim">T</td>232 <td class="c-who dim">T</td>
217 <td class="c-when nowrap dim">233 <td class="c-when nowrap dim">
218 <time datetime="STAMP" title="STAMP">AGO</time>234 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -236,6 +252,8 @@
236 <span class="pill tag">252 <span class="pill tag">
237 <span class="sr">tag </span>v1.0.0</span>253 <span class="sr">tag </span>v1.0.0</span>
238 </td>254 </td>
255+<td class="c-ck">
256+</td>
239 <td class="c-who dim">T</td>257 <td class="c-who dim">T</td>
240 <td class="c-when nowrap dim">258 <td class="c-when nowrap dim">
241 <time datetime="STAMP" title="STAMP">AGO</time>259 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -257,6 +275,8 @@
257 <td class="c-subject">275 <td class="c-subject">
258 <a href="/diffs/commit/SHA40">root: the first files</a>276 <a href="/diffs/commit/SHA40">root: the first files</a>
259 </td>277 </td>
278+<td class="c-ck">
279+</td>
260 <td class="c-who dim">T</td>280 <td class="c-who dim">T</td>
261 <td class="c-when nowrap dim">281 <td class="c-when nowrap dim">
262 <time datetime="STAMP" title="STAMP">AGO</time>282 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,7 +324,7 @@
304 htmx:sse:close, htmx:sse:error -> data.state = 'offline';324 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
305 htmx:sse:error -> ermiteLive.error(event.detail.status);325 htmx:sse:error -> ermiteLive.error(event.detail.status);
306 htmx:sse:before:connection -> ermiteLive.gate(event);326 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);
308 gap -> ermiteLive.reload()">328 gap -> ermiteLive.reload()">
309 </span>329 </span>
310 </div>330 </div>
@@ -484,6 +504,12 @@
484 <span class="k">section</span>504 <span class="k">section</span>
485 </a>505 </a>
486 </li>506 </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>
487 <li data-k="branch main">513 <li data-k="branch main">
488 <a href="/diffs/tree/main" tabindex="-1">514 <a href="/diffs/tree/main" tabindex="-1">
489 <span class="lbl">main</span>515 <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>
@@ -113,7 +113,7 @@
113 htmx:sse:close, htmx:sse:error -> data.state = 'offline';113 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
114 htmx:sse:error -> ermiteLive.error(event.detail.status);114 htmx:sse:error -> ermiteLive.error(event.detail.status);
115 htmx:sse:before:connection -> ermiteLive.gate(event);115 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);
117 gap -> ermiteLive.reload()">117 gap -> ermiteLive.reload()">
118 </span>118 </span>
119 </div>119 </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>
@@ -77,6 +77,10 @@
77 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>77 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
78 </a>78 </a>
79 </li>79 </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>
80 </ul>84 </ul>
81 </nav>85 </nav>
82 </header>86 </header>
@@ -196,7 +200,7 @@
196 htmx:sse:close, htmx:sse:error -> data.state = 'offline';200 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
197 htmx:sse:error -> ermiteLive.error(event.detail.status);201 htmx:sse:error -> ermiteLive.error(event.detail.status);
198 htmx:sse:before:connection -> ermiteLive.gate(event);202 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);
200 gap -> ermiteLive.reload()">204 gap -> ermiteLive.reload()">
201 </span>205 </span>
202 </div>206 </div>
@@ -380,6 +384,12 @@
380 <span class="k">section</span>384 <span class="k">section</span>
381 </a>385 </a>
382 </li>386 </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>
383 <li data-k="branch feature/x">393 <li data-k="branch feature/x">
384 <a href="/demo/tree/feature/x" tabindex="-1">394 <a href="/demo/tree/feature/x" tabindex="-1">
385 <span class="lbl">feature/x</span>395 <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>
@@ -74,6 +74,10 @@
74 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>74 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
75 </a>75 </a>
76 </li>76 </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>
77 </ul>81 </ul>
78 </nav>82 </nav>
79 </header>83 </header>
@@ -166,6 +170,8 @@
166 <span class="pill tag">170 <span class="pill tag">
167 <span class="sr">tag </span>v0.2.0</span>171 <span class="sr">tag </span>v0.2.0</span>
168 </td>172 </td>
173+<td class="c-ck">
174+</td>
169 <td class="c-who dim">T</td>175 <td class="c-who dim">T</td>
170 <td class="c-when nowrap dim">176 <td class="c-when nowrap dim">
171 <time datetime="STAMP" title="STAMP">AGO</time>177 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -180,6 +186,8 @@
180 <span class="pill tag">186 <span class="pill tag">
181 <span class="sr">tag </span>v0.1.0</span>187 <span class="sr">tag </span>v0.1.0</span>
182 </td>188 </td>
189+<td class="c-ck">
190+</td>
183 <td class="c-who dim">T</td>191 <td class="c-who dim">T</td>
184 <td class="c-when nowrap dim">192 <td class="c-when nowrap dim">
185 <time datetime="STAMP" title="STAMP">AGO</time>193 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -215,7 +223,7 @@
215 htmx:sse:close, htmx:sse:error -> data.state = 'offline';223 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
216 htmx:sse:error -> ermiteLive.error(event.detail.status);224 htmx:sse:error -> ermiteLive.error(event.detail.status);
217 htmx:sse:before:connection -> ermiteLive.gate(event);225 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);
219 gap -> ermiteLive.reload()">227 gap -> ermiteLive.reload()">
220 </span>228 </span>
221 </div>229 </div>
@@ -399,6 +407,12 @@
399 <span class="k">section</span>407 <span class="k">section</span>
400 </a>408 </a>
401 </li>409 </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>
402 <li data-k="branch feature/x">416 <li data-k="branch feature/x">
403 <a href="/demo/tree/feature/x" tabindex="-1">417 <a href="/demo/tree/feature/x" tabindex="-1">
404 <span class="lbl">feature/x</span>418 <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>
@@ -86,6 +86,10 @@
86 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>86 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87 </a>87 </a>
88 </li>88 </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>
89 </ul>93 </ul>
90 </nav>94 </nav>
91 </header>95 </header>
@@ -328,6 +332,12 @@
328 <span class="k">section</span>332 <span class="k">section</span>
329 </a>333 </a>
330 </li>334 </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>
331 <li data-k="branch feature/x">341 <li data-k="branch feature/x">
332 <a href="/files/tree/feature/x" tabindex="-1">342 <a href="/files/tree/feature/x" tabindex="-1">
333 <span class="lbl">feature/x</span>343 <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>
@@ -80,6 +80,10 @@
80 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>80 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81 </a>81 </a>
82 </li>82 </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>
83 </ul>87 </ul>
84 </nav>88 </nav>
85 </header>89 </header>
@@ -555,6 +559,12 @@
555 <span class="k">section</span>559 <span class="k">section</span>
556 </a>560 </a>
557 </li>561 </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>
558 <li data-k="branch feature/x">568 <li data-k="branch feature/x">
559 <a href="/files/tree/feature/x" tabindex="-1">569 <a href="/files/tree/feature/x" tabindex="-1">
560 <span class="lbl">feature/x</span>570 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -325,6 +329,12 @@
325 <span class="k">section</span>329 <span class="k">section</span>
326 </a>330 </a>
327 </li>331 </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>
328 <li data-k="branch feature/x">338 <li data-k="branch feature/x">
329 <a href="/files/tree/feature/x" tabindex="-1">339 <a href="/files/tree/feature/x" tabindex="-1">
330 <span class="lbl">feature/x</span>340 <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>
@@ -83,6 +83,10 @@
83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>83 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84 </a>84 </a>
85 </li>85 </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>
86 </ul>90 </ul>
87 </nav>91 </nav>
88 </header>92 </header>
@@ -350,6 +354,12 @@
350 <span class="k">section</span>354 <span class="k">section</span>
351 </a>355 </a>
352 </li>356 </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>
353 <li data-k="branch feature/x">363 <li data-k="branch feature/x">
354 <a href="/files/tree/feature/x" tabindex="-1">364 <a href="/files/tree/feature/x" tabindex="-1">
355 <span class="lbl">feature/x</span>365 <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,
@@ -33,6 +33,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
33 Repos: f.Repos,33 Repos: f.Repos,
34 Browse: f.Browse,34 Browse: f.Browse,
35 MergeRequests: f.MergeRequests,35 MergeRequests: f.MergeRequests,
36+ Runs: f.CI,
36 Events: f.EventLog,37 Events: f.EventLog,
37 PublicURL: cfg.PublicURL,38 PublicURL: cfg.PublicURL,
38 SourceURL: cfg.SourceURL,39 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