v0.8.0 eaf4295

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	Active     bool
177	// Forms are the owner's actions; nil/empty when the visitor cannot.
178	CancelAction, RerunAction, RerunFailedAction string
179	CanRerunFailed                               bool
180}
181
182// AttemptLink switches between the attempts of a run.
183type AttemptLink struct {
184	Label, Href string
185	Current     bool
186}
187
188// actions is GET /<repo>/actions.
189func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
190	d, err := s.loadRepo(ctx, r.PathValue("repo"))
191	if err != nil {
192		return nil, 0, err
193	}
194	token := r.URL.Query().Get("page")
195	page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token})
196	if err != nil {
197		return nil, 0, err
198	}
199	now := s.cfg.Now()
200	p := ActionsPage{}
201	for _, run := range page.Runs {
202		p.Runs = append(p.Runs, s.runRow(d.name(), run, now))
203	}
204	if page.NextPageToken != "" {
205		p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
206	}
207	l := s.repoLayout(ctx, d, "actions")
208	l.Title = "actions · " + d.name() + " · " + s.instance
209	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
210	l.Crumbs[0].Current = false
211	l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true})
212	return Actions(l, p), http.StatusOK, nil
213}
214
215func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
216	row := RunRow{
217		ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
218		Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
219		Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title),
220		Ref:   visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
221		Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
222		Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
223	}
224	row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
225	row.When, row.WhenAt = whenOf(now, run.CreatedAt)
226	return row
227}
228
229func stepViews(j ports.JobView, now time.Time) []StepView {
230	ranges := j.StepRanges()
231	out := make([]StepView, 0, len(j.Steps))
232	for i, st := range j.Steps {
233		status := domain.CIQueued
234		switch st.State {
235		case domain.CIStepInProgress:
236			status = domain.CIInProgress
237		case domain.CIStepCompleted:
238			status = domain.CICompleted
239		}
240		out = append(out, StepView{
241			Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
242			Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
243		})
244	}
245	return out
246}
247
248func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
249	return JobRow{
250		ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
251		State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
252		Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
253		Steps: stepViews(j, now), Attempt: j.Attempt,
254	}
255}
256
257func parseRunPath(r *http.Request) (int64, error) {
258	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
259	if err != nil || id < 1 {
260		return 0, errNotFound
261	}
262	return id, nil
263}
264
265// run is GET /<repo>/actions/runs/<id>.
266func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
267	d, err := s.loadRepo(ctx, r.PathValue("repo"))
268	if err != nil {
269		return nil, 0, err
270	}
271	id, err := parseRunPath(r)
272	if err != nil {
273		return nil, 0, err
274	}
275	var attempt int32
276	if a := r.URL.Query().Get("attempt"); a != "" {
277		n, perr := strconv.ParseInt(a, 10, 32)
278		if perr != nil || n < 1 {
279			return nil, 0, errNotFound
280		}
281		attempt = int32(n)
282	}
283	p, err := s.runPage(ctx, d, id, attempt)
284	if err != nil {
285		return nil, 0, err
286	}
287	l := s.repoLayout(ctx, d, "actions")
288	l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
289	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
290	l.Crumbs[0].Current = false
291	l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
292	l.HasRail = true
293	return RunDetail(l, p), http.StatusOK, nil
294}
295
296//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
297func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
298	detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
299	if err != nil {
300		return RunPage{}, err
301	}
302	now := s.cfg.Now()
303	run := detail.Run
304	p := RunPage{
305		RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
306		Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted,
307	}
308	for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
309		p.Attempts = append(p.Attempts, AttemptLink{
310			Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
311		})
312	}
313	for _, j := range detail.Jobs {
314		row := s.jobRow(d.name(), j, now)
315		if run.Attempt == detail.LatestAttempt {
316			// An earlier attempt keeps the job as it ran then.
317			row.Href = jobHref(d.name(), id, j.ID)
318		}
319		p.Jobs = append(p.Jobs, row)
320	}
321	if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
322		base := runHref(d.name(), id)
323		if p.Active {
324			p.CancelAction = base + "/cancel"
325		} else if len(detail.Jobs) > 0 {
326			p.RerunAction = base + "/rerun"
327			for _, j := range detail.Jobs {
328				if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled {
329					p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true
330					break
331				}
332			}
333		}
334	}
335	return p, nil
336}
337
338// job is GET /<repo>/actions/runs/<id>/job/<job id>.
339func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) {
340	d, err := s.loadRepo(ctx, r.PathValue("repo"))
341	if err != nil {
342		return nil, 0, err
343	}
344	id, err := parseRunPath(r)
345	if err != nil {
346		return nil, 0, err
347	}
348	jobID := r.PathValue("job")
349	if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id {
350		return nil, 0, errNotFound
351	}
352	var attempt int32
353	if a := r.URL.Query().Get("attempt"); a != "" {
354		n, perr := strconv.ParseInt(a, 10, 32)
355		if perr != nil || n < 1 {
356			return nil, 0, errNotFound
357		}
358		attempt = int32(n)
359	}
360	view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
361	if err != nil {
362		return nil, 0, err
363	}
364	run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0)
365	if err != nil {
366		return nil, 0, err
367	}
368	now := s.cfg.Now()
369	var from int64
370	if f := r.URL.Query().Get("from"); f != "" {
371		from, err = strconv.ParseInt(f, 10, 64)
372		if err != nil || from < 0 {
373			return nil, 0, errNotFound
374		}
375	}
376	p := JobPage{
377		JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName),
378		Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines,
379		RawHref: jobHref(d.name(), id, jobID) + "/raw",
380	}
381	if attempt > 0 {
382		p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
383	}
384	p.Follow = !view.Ended()
385	p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
386	lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
387	if err != nil {
388		return nil, 0, err
389	}
390	p.Lines = lines
391	if next < view.Lines {
392		p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
393		p.Follow = false // more is waiting before the live end
394	}
395	if from > 0 {
396		p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
397	}
398	if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
399		p.Note = "the job has not started: no log yet."
400	} else if view.Lines == 0 && view.Ended() {
401		p.Note = "the job wrote no log."
402	}
403	l := s.repoLayout(ctx, d, "actions")
404	l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
405	l.Live = true
406	l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
407	l.Crumbs[0].Current = false
408	l.Crumbs = append(l.Crumbs,
409		Crumb{Label: "actions", Href: actionsBase(d.name())},
410		Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
411		Crumb{Label: p.Name, Current: true})
412	l.HasRail = true
413	return Job(l, p), http.StatusOK, nil
414}
415
416// ciAction wraps a POST of the actions pages: the form gate, the bounded
417// context and the mapping of a refusal to a page.
418func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
419	return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
420		to, err := h(ctx, r)
421		return to, actionsBase(r.PathValue("repo")), err
422	})
423}
424
425func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
426	name, id, err := s.runTarget(r)
427	if err != nil {
428		return "", err
429	}
430	if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
431		return "", err
432	}
433	return runHref(name.String(), id), nil
434}
435
436func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
437	return func(ctx context.Context, r *http.Request) (string, error) {
438		name, id, err := s.runTarget(r)
439		if err != nil {
440			return "", err
441		}
442		if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
443			return "", err
444		}
445		return runHref(name.String(), id), nil
446	}
447}
448
449func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
450	name, err := domain.ParseRepoName(r.PathValue("repo"))
451	if err != nil {
452		return domain.RepoName{}, 0, errNotFound
453	}
454	id, err := parseRunPath(r)
455	return name, id, err
456}
457
458func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
459
460// runnerText names the runner of a job and how it isolated it.
461func runnerText(j JobRow) string {
462	if j.Isolation == "" {
463		return j.Runner
464	}
465	return j.Runner + " (" + j.Isolation + ")"
466}
467
468func 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