v0.15.0 8a6cfec

actions_pages.go RawHistoryFolder

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

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 2

Tags 45

↑↓ moveenter openesc close