v0.10.0 ce48ee3

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

Tags 45

↑↓ moveenter openesc close