v0.36.0 1fb2421

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

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