v0.16.0 924eaca

actions_results.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"html/template"
8	"path"
9	"strconv"
10	"strings"
11
12	"bnema.dev/ermite/internal/domain"
13	"bnema.dev/ermite/internal/ports"
14)
15
16// The results of a job on the CI pages: the annotations (the "Annotations" box
17// of the run page, and at their step on the job page) and the job summaries
18// under the run. Both are untrusted text of the workflow: annotations are
19// escaped by the templates, summaries go through the Markdown renderer of the
20// READMEs (raw HTML dropped, URLs filtered, images shown as their alt text).
21
22// maxSummaryPageBytes bounds the Markdown rendered in one page, all summaries
23// together; the ones past it are not shown, with a note.
24const maxSummaryPageBytes int64 = 4 << 20
25
26// AnnotationView is an annotation as shown.
27type AnnotationView struct {
28	// Kind is the colour class (notice, warning, failure) and Level its word.
29	Kind, Level  string
30	Job, JobHref string
31	// Step is the name of the step and StepHref jumps to its lines in the log
32	// of the job.
33	Step, StepHref string
34	Title, Message string
35	// Where is "file:line", WhereHref the file at the commit of the run, "" when
36	// the path is not one of the repository.
37	Where, WhereHref string
38	// StepHead is set on the first annotation of a step in a list grouped by
39	// step (the job page).
40	StepHead bool
41}
42
43// stepInfo is what the views need of the step an annotation belongs to.
44type stepInfo struct {
45	name       string
46	start, end int64
47}
48
49// jobInfo is what the annotation views need of a job.
50type jobInfo struct {
51	name, href string
52	steps      map[int32]stepInfo
53}
54
55func jobInfoOf(repo string, runID int64, j ports.JobView, linkJob bool) jobInfo {
56	info := jobInfo{name: visibleText(j.Name), href: jobHref(repo, runID, j.ID), steps: map[int32]stepInfo{}}
57	if !linkJob {
58		info.href = ""
59	}
60	for i, st := range j.Steps {
61		r := j.StepRanges()[i]
62		info.steps[st.Number] = stepInfo{name: visibleText(st.Name), start: r.Start, end: r.End}
63	}
64	return info
65}
66
67func annotationKind(l domain.AnnotationLevel) (kind, word string) {
68	switch l {
69	case domain.AnnotationFailure:
70		return "failure", "error"
71	case domain.AnnotationWarning:
72		return "warning", "warning"
73	}
74	return "notice", "notice"
75}
76
77// annotationWhere is the "file:line" of an annotation, and a link to the file
78// at the commit when the path is a path of the repository.
79func annotationWhere(repo, sha string, a domain.CIAnnotation) (text, href string) {
80	if a.File == "" {
81		return "", ""
82	}
83	text = visibleText(a.File)
84	if a.StartLine > 0 {
85		text += ":" + strconv.Itoa(int(a.StartLine))
86		if a.EndLine > a.StartLine {
87			text += "-" + strconv.Itoa(int(a.EndLine))
88		}
89	}
90	if !repoPath(a.File) {
91		return text, ""
92	}
93	href = repoHref(repo, "blob", sha, a.File)
94	if a.StartLine > 0 {
95		href += "#L" + strconv.Itoa(int(a.StartLine))
96		if a.EndLine > a.StartLine {
97			href += "-L" + strconv.Itoa(int(a.EndLine))
98		}
99	}
100	return text, href
101}
102
103// repoPath reports whether a path of an annotation is a clean path inside the
104// repository: relative, without "." or ".." segments, backslashes or control
105// characters.
106func repoPath(p string) bool {
107	if p == "" || strings.HasPrefix(p, "/") || strings.ContainsAny(p, "\\\x00\r\n\t") || path.Clean(p) != p {
108		return false
109	}
110	return !strings.HasPrefix(p, "../") && p != ".." && p != "."
111}
112
113// annotationViews builds the views of annotations, in their order. jobs maps a
114// job id to what is known of it; an annotation of an unknown job is skipped.
115func annotationViews(repo, sha string, list ports.AnnotationList, jobs map[string]jobInfo) []AnnotationView {
116	out := make([]AnnotationView, 0, len(list.Annotations))
117	for _, ja := range list.Annotations {
118		job, ok := jobs[ja.JobID]
119		if !ok {
120			continue
121		}
122		a := ja.Annotation
123		v := AnnotationView{
124			Job: job.name, JobHref: job.href, Title: visibleText(a.Title), Message: visibleText(a.Message),
125		}
126		v.Kind, v.Level = annotationKind(a.Level)
127		if st, ok := job.steps[a.Step]; ok {
128			v.Step = st.name
129			if st.end > st.start && job.href != "" {
130				v.StepHref = job.href + "#L" + strconv.FormatInt(st.start, 10)
131			}
132		}
133		if v.Step == "" {
134			v.Step = "step " + strconv.Itoa(int(a.Step))
135		}
136		v.Where, v.WhereHref = annotationWhere(repo, sha, a)
137		out = append(out, v)
138	}
139	return out
140}
141
142// SummaryView is the summary of one step, rendered.
143type SummaryView struct {
144	Step string
145	HTML template.HTML
146	// Note replaces the content when it is not shown.
147	Note string
148}
149
150// JobSummaries are the summaries of one job.
151type JobSummaries struct {
152	Job, JobHref string
153	Steps        []SummaryView
154	// More says that the job has more summaries than the 20 shown.
155	More bool
156}
157
158// jobSummaries reads and renders the summaries of the jobs of a run, in the
159// order of the jobs, within budget bytes of Markdown. The budget goes down as
160// summaries are read and is handed to the use case, which sizes the files
161// before it reads them: a page view reads at most budget bytes. Once it is used
162// up no further job is asked, and more is true: some summaries are not shown.
163func (s *Server) jobSummaries(ctx context.Context, d *repoData, run domain.CIRun, jobs []ports.JobView, linkJobs bool, budget int64) (out []JobSummaries, more bool, err error) {
164	for _, j := range jobs {
165		if j.Phase != domain.CIPhaseRunning && !j.Ended() {
166			continue // no step has run
167		}
168		if budget <= 0 {
169			return out, true, nil
170		}
171		res, err := s.cfg.Runs.GetStepSummaries(ctx, principal(ctx), d.repo.Name, j.ID, j.Attempt, budget)
172		if err != nil {
173			return nil, false, err
174		}
175		if len(res.Summaries) == 0 {
176			continue
177		}
178		info := jobInfoOf(d.name(), run.ID, j, linkJobs)
179		js := JobSummaries{Job: info.name, JobHref: info.href, More: res.Truncated}
180		for _, sm := range res.Summaries {
181			name := "step " + strconv.Itoa(int(sm.Step))
182			if st, ok := info.steps[sm.Step]; ok && st.name != "" {
183				name = st.name
184			}
185			v := SummaryView{Step: name}
186			if sm.Omitted {
187				v.Note = "this summary is not shown: the summaries of the page are over " + humanSize(maxSummaryPageBytes) + "."
188				more = true
189				budget = 0 // everything after an omitted summary is omitted too
190				js.Steps = append(js.Steps, v)
191				continue
192			}
193			budget -= int64(len(sm.Markdown))
194			prefix := headingIDPrefix + "s" + j.ID + "-" + strconv.Itoa(int(sm.Step)) + "-"
195			h, _, err := s.md.renderWithIDs([]byte(sm.Markdown), d.name(), run.Sha, "", prefix)
196			if err != nil {
197				v.Note = "this summary cannot be shown."
198			} else {
199				v.HTML = h
200			}
201			js.Steps = append(js.Steps, v)
202		}
203		out = append(out, js)
204	}
205	return out, more, nil
206}
207
208// jobRunSha is the commit of the run a job belongs to, for the live fragments of
209// its page. A job id that is not one, or a run that cannot be read, is not found.
210func (s *Server) jobRunSha(ctx context.Context, repo, jobID string) (string, error) {
211	name, err := domain.ParseRepoName(repo)
212	if err != nil {
213		return "", domain.ErrNotFound
214	}
215	runID, _, _, ok := domain.ParseJobID(jobID)
216	if !ok {
217		return "", domain.ErrNotFound
218	}
219	d, err := s.cfg.Runs.GetRun(ctx, principal(ctx), name, runID, 0)
220	if err != nil {
221		return "", err
222	}
223	return d.Run.Sha, nil
224}
225
226// runAnnotations reads the annotations of a run attempt and builds their views.
227func (s *Server) runAnnotations(ctx context.Context, d *repoData, run domain.CIRun, attempt int32, jobs []ports.JobView, linkJobs bool) (annotations []AnnotationView, truncated bool, err error) {
228	list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, run.ID, attempt, "")
229	if err != nil {
230		return nil, false, err
231	}
232	infos := make(map[string]jobInfo, len(jobs))
233	for _, j := range jobs {
234		infos[j.ID] = jobInfoOf(d.name(), run.ID, j, linkJobs)
235	}
236	return annotationViews(d.name(), run.Sha, list, infos), list.Truncated, nil
237}
238
239// jobAnnotations reads the annotations of one job and builds their views,
240// grouped by step.
241func (s *Server) jobAnnotations(ctx context.Context, d *repoData, runID int64, sha string, view ports.JobView, attempt int32) ([]AnnotationView, bool, error) {
242	list, err := s.cfg.Runs.ListAnnotations(ctx, principal(ctx), d.repo.Name, runID, attempt, view.ID)
243	if err != nil {
244		return nil, false, err
245	}
246	views := annotationViews(d.name(), sha, list, map[string]jobInfo{view.ID: jobInfoOf(d.name(), runID, view, true)})
247	for i := range views {
248		views[i].StepHead = i == 0 || views[i-1].Step != views[i].Step
249	}
250	return views, list.Truncated, nil
251}

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