v0.14.0 815fb98

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

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