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 generated file: collapsed
M internal/adapters/web/pages.templ +1 -0
@@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) {
128128 } else {
129129 { r.Short }
130130 }
131+ @CommitChecks(r.Checks)
131132 </td>
132133 <td class="c-sub">{ r.Subject }</td>
133134 <td class="c-when dim">
@@ -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