v0.13.0 dc42452

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	row.When, row.WhenAt = whenOf(now, run.CreatedAt)
238	return row
239}
240
241func stepViews(j ports.JobView, now time.Time) []StepView {
242	ranges := j.StepRanges()
243	out := make([]StepView, 0, len(j.Steps))
244	for i, st := range j.Steps {
245		status := domain.CIQueued
246		switch st.State {
247		case domain.CIStepInProgress:
248			status = domain.CIInProgress
249		case domain.CIStepCompleted:
250			status = domain.CICompleted
251		}
252		out = append(out, StepView{
253			Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
254			Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
255		})
256	}
257	return out
258}
259
260func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
261	return JobRow{
262		ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
263		State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
264		Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
265		Steps: stepViews(j, now), Attempt: j.Attempt,
266	}
267}
268
269func parseRunPath(r *http.Request) (int64, error) {
270	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
271	if err != nil || id < 1 {
272		return 0, errNotFound
273	}
274	return id, nil
275}
276
277// run is GET /<repo>/actions/runs/<id>.
278func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
279	d, err := s.loadRepo(ctx, r.PathValue("repo"))
280	if err != nil {
281		return nil, 0, err
282	}
283	id, err := parseRunPath(r)
284	if err != nil {
285		return nil, 0, err
286	}
287	var attempt int32
288	if a := r.URL.Query().Get("attempt"); a != "" {
289		n, perr := strconv.ParseInt(a, 10, 32)
290		if perr != nil || n < 1 {
291			return nil, 0, errNotFound
292		}
293		attempt = int32(n)
294	}
295	p, err := s.runPage(ctx, d, id, attempt)
296	if err != nil {
297		return nil, 0, err
298	}
299	// The summaries are part of the page, not of the live body: the page
300	// fetches itself again when the run completes (runBody), and they show then.
301	if p.Summaries, p.SummariesMore, err = s.jobSummaries(ctx, d, p.detail.Run, p.detail.Jobs, p.Attempt == p.Latest, maxSummaryPageBytes); err != nil {
302		return nil, 0, err
303	}
304	l := s.repoLayout(ctx, d, "actions")
305	l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
306	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
307	l.Crumbs[0].Current = false
308	l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
309	l.HasRail = true
310	return RunDetail(l, p), http.StatusOK, nil
311}
312
313//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
314func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
315	detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
316	if err != nil {
317		return RunPage{}, err
318	}
319	now := s.cfg.Now()
320	run := detail.Run
321	p := RunPage{
322		RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
323		Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted, detail: detail,
324	}
325	for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
326		p.Attempts = append(p.Attempts, AttemptLink{
327			Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
328		})
329	}
330	for _, j := range detail.Jobs {
331		row := s.jobRow(d.name(), j, now)
332		if run.Attempt == detail.LatestAttempt {
333			// An earlier attempt keeps the job as it ran then.
334			row.Href = jobHref(d.name(), id, j.ID)
335		}
336		p.Jobs = append(p.Jobs, row)
337	}
338	// The annotations of the attempt shown (the jobs kept from an earlier attempt
339	// by a re-run bring their own).
340	if p.Annotations, p.AnnotationsMore, err = s.runAnnotations(ctx, d, run, run.Attempt, detail.Jobs, run.Attempt == detail.LatestAttempt); err != nil {
341		return RunPage{}, err
342	}
343	if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
344		base := runHref(d.name(), id)
345		if p.Active {
346			p.CancelAction = base + "/cancel"
347		} else if len(detail.Jobs) > 0 {
348			p.RerunAction = base + "/rerun"
349			for _, j := range detail.Jobs {
350				if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled {
351					p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true
352					break
353				}
354			}
355		}
356	}
357	return p, nil
358}
359
360// job is GET /<repo>/actions/runs/<id>/job/<job id>.
361func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) {
362	d, err := s.loadRepo(ctx, r.PathValue("repo"))
363	if err != nil {
364		return nil, 0, err
365	}
366	id, err := parseRunPath(r)
367	if err != nil {
368		return nil, 0, err
369	}
370	jobID := r.PathValue("job")
371	if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id {
372		return nil, 0, errNotFound
373	}
374	var attempt int32
375	if a := r.URL.Query().Get("attempt"); a != "" {
376		n, perr := strconv.ParseInt(a, 10, 32)
377		if perr != nil || n < 1 {
378			return nil, 0, errNotFound
379		}
380		attempt = int32(n)
381	}
382	view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
383	if err != nil {
384		return nil, 0, err
385	}
386	run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0)
387	if err != nil {
388		return nil, 0, err
389	}
390	now := s.cfg.Now()
391	var from int64
392	if f := r.URL.Query().Get("from"); f != "" {
393		from, err = strconv.ParseInt(f, 10, 64)
394		if err != nil || from < 0 {
395			return nil, 0, errNotFound
396		}
397	}
398	p := JobPage{
399		JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName),
400		Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines,
401		RawHref: jobHref(d.name(), id, jobID) + "/raw",
402	}
403	if attempt > 0 {
404		p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
405	}
406	p.Follow = !view.Ended()
407	if p.Annotations, p.AnnotationsMore, err = s.jobAnnotations(ctx, d, id, run.Run.Sha, view, attempt); err != nil {
408		return nil, 0, err
409	}
410	p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
411	lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
412	if err != nil {
413		return nil, 0, err
414	}
415	p.Lines = lines
416	if next < view.Lines {
417		p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
418		p.Follow = false // more is waiting before the live end
419	}
420	if from > 0 {
421		p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
422	}
423	if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
424		p.Note = "the job has not started: no log yet."
425	} else if view.Lines == 0 && view.Ended() {
426		p.Note = "the job wrote no log."
427	}
428	l := s.repoLayout(ctx, d, "actions")
429	l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
430	l.Live = true
431	l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
432	l.Crumbs[0].Current = false
433	l.Crumbs = append(l.Crumbs,
434		Crumb{Label: "actions", Href: actionsBase(d.name())},
435		Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
436		Crumb{Label: p.Name, Current: true})
437	l.HasRail = true
438	return Job(l, p), http.StatusOK, nil
439}
440
441// ciAction wraps a POST of the actions pages: the form gate, the bounded
442// context and the mapping of a refusal to a page.
443func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
444	return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
445		to, err := h(ctx, r)
446		return to, actionsBase(r.PathValue("repo")), err
447	})
448}
449
450func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
451	name, id, err := s.runTarget(r)
452	if err != nil {
453		return "", err
454	}
455	if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
456		return "", err
457	}
458	return runHref(name.String(), id), nil
459}
460
461func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
462	return func(ctx context.Context, r *http.Request) (string, error) {
463		name, id, err := s.runTarget(r)
464		if err != nil {
465			return "", err
466		}
467		if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
468			return "", err
469		}
470		return runHref(name.String(), id), nil
471	}
472}
473
474func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
475	name, err := domain.ParseRepoName(r.PathValue("repo"))
476	if err != nil {
477		return domain.RepoName{}, 0, errNotFound
478	}
479	id, err := parseRunPath(r)
480	return name, id, err
481}
482
483func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
484
485// runnerText names the runner of a job and how it isolated it.
486func runnerText(j JobRow) string {
487	if j.Isolation == "" {
488		return j.Runner
489	}
490	return j.Runner + " (" + j.Isolation + ")"
491}
492
493func joinWords(ws []string) string { return strings.Join(ws, ", ") }

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