v0.26.0 df1db36

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

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