v0.4.3 bdc41cd

issues.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"fmt"
7	"math"
8	"strconv"
9	"strings"
10	"sync"
11	"unicode/utf8"
12)
13
14// Annotations: a port of the issue handling of the official runner,
15// ExecutionContext.AddIssue (ExecutionContext.cs:796) and
16// IssueCommandExtension (ActionCommandManager.cs:660-770).
17
18const (
19	// maxCountPerIssueType is ExecutionContext._maxCountPerIssueType.
20	maxCountPerIssueType = 10
21	// Limits of the contract (runner.proto, Annotation): the server refuses a
22	// whole call that exceeds them, so the executor keeps within them.
23	maxAnnotationTitle = 4096
24	maxAnnotationFile  = 1024
25)
26
27// issue is what AddIssue receives (DTWebApi.Issue and the log options).
28type issue struct {
29	Level   AnnotationLevel
30	Message string
31	// Props are the Data of the issue: title, file, line, endline, col,
32	// endcolumn (keys in lower case), as the commands and matchers set them.
33	Props map[string]string
34	// LogText replaces the message in the log (ExecutionContextLogOptions
35	// .LogMessageOverride, set by problem matchers).
36	LogText string
37}
38
39func tagOf(l AnnotationLevel) string {
40	switch l {
41	case LevelFailure:
42		return "##[error]"
43	case LevelWarning:
44		return "##[warning]"
45	}
46	return "##[notice]"
47}
48
49func levelOfTag(tag string) (AnnotationLevel, bool) {
50	switch tag {
51	case "##[error]":
52		return LevelFailure, true
53	case "##[warning]":
54		return LevelWarning, true
55	case "##[notice]":
56		return LevelNotice, true
57	}
58	return "", false
59}
60
61// issueCollector keeps the annotations of one step of the workflow, which is
62// what the server stores them under. The steps inside a composite action
63// (embedded contexts) add to the collector of the step that uses the action:
64// like the runner, their issues are held back and only counted against the
65// limit of ten per level when the outer step completes
66// (ExecutionContext.Complete, :528-540).
67type issueCollector struct {
68	mu       sync.Mutex
69	own      []Annotation
70	counts   map[AnnotationLevel]int
71	embedded []Annotation
72}
73
74func newIssueCollector() *issueCollector {
75	return &issueCollector{counts: map[AnnotationLevel]int{}}
76}
77
78func (c *issueCollector) add(a Annotation, embedded bool) {
79	c.mu.Lock()
80	defer c.mu.Unlock()
81	if embedded {
82		c.embedded = append(c.embedded, a)
83		return
84	}
85	c.addLocked(a)
86}
87
88func (c *issueCollector) addLocked(a Annotation) {
89	if c.counts[a.Level] < maxCountPerIssueType {
90		c.counts[a.Level]++
91		c.own = append(c.own, a)
92	}
93}
94
95// result returns the annotations of the step: its own, then those of the
96// embedded steps within what is left of the limits.
97func (c *issueCollector) result() []Annotation {
98	c.mu.Lock()
99	defer c.mu.Unlock()
100	for _, a := range c.embedded {
101		c.addLocked(a)
102	}
103	c.embedded = nil
104	return append([]Annotation(nil), c.own...)
105}
106
107// collector returns the collector of a step of the workflow.
108func (j *job) collector(num int) *issueCollector {
109	j.issuesMu.Lock()
110	defer j.issuesMu.Unlock()
111	if j.collectors == nil {
112		j.collectors = map[int]*issueCollector{}
113	}
114	c := j.collectors[num]
115	if c == nil {
116		c = newIssueCollector()
117		j.collectors[num] = c
118	}
119	return c
120}
121
122// truncateUTF16 cuts s to n UTF-16 code units, the unit of .NET's
123// Substring(0, n), without splitting a character.
124func truncateUTF16(s string, n int) string {
125	units := 0
126	for i, r := range s {
127		w := 1
128		if r >= 0x10000 {
129			w = 2
130		}
131		if units+w > n {
132			return s[:i]
133		}
134		units += w
135	}
136	return s
137}
138
139func truncateRunes(s string, n int) string {
140	if utf8.RuneCountInString(s) <= n {
141		return s
142	}
143	i := 0
144	for k := range s {
145		if i == n {
146			return s[:k]
147		}
148		i++
149	}
150	return s
151}
152
153// addIssue is ExecutionContext.AddIssue: the message is masked and cut to
154// 4096 characters, the issue is kept for the annotations of the step (at most
155// ten per level) and written to the log.
156func (j *job) addIssue(num int, c *issueCollector, embedded bool, is issue) {
157	msg := j.mask(is.Message)
158	msg = truncateUTF16(msg, maxIssueMessageLength)
159	if c == nil {
160		c = j.collector(num)
161	}
162	logMsg := msg
163	if is.LogText != "" {
164		logMsg = j.mask(is.LogText)
165	}
166	// logFileLineNumber: the line of the step log that holds the tag, 0 when
167	// the issue is not logged (an empty message is not, :855).
168	logLine := 0
169	if logMsg != "" {
170		logLine = j.logTagged(num, tagOf(is.Level), logMsg)
171	}
172	if msg != "" {
173		c.add(j.annotationOf(is, msg, logLine), embedded)
174	}
175}
176
177// logTagged writes tag+message; only the first line carries the tag. It
178// returns the 1-based number, in the log of the step, of the first line
179// (ExecutionContext.Write, :1099-1118).
180func (j *job) logTagged(num int, tag, msg string) int {
181	lines := strings.Split(strings.ReplaceAll(msg, "\r\n", "\n"), "\n")
182	first := j.write(num, tag+lines[0])
183	for _, l := range lines[1:] {
184		j.write(num, l)
185	}
186	return first
187}
188
189// parseInt is int.TryParse(string, out int): white space around, an optional
190// sign, digits, within 32 bits.
191func parseInt(s string) (int, bool) {
192	n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 32)
193	return int(n), err == nil
194}
195
196// annotationOf converts an issue to the Annotation of the contract. The
197// runner puts the properties in Issue.Data and lets the service build the
198// check annotation; negative or non numeric numbers cannot be sent, and are
199// left out. The title and the file are masked too (the runner masks only the
200// message; on GitHub a secret in a title stays visible, recorded in
201// mask-add-mask, which ermite deliberately does not reproduce).
202//
203// Deliberate divergence: GitHub keeps negative and zero lines (int.TryParse,
204// ActionCommandManager.cs:723-725; ann-levels records start_line -1); ermite's
205// contract is positive lines, 0 = no line, and the host clamps.
206//
207// logLine is the number of the issue's own line in the step log (0 if it was
208// not logged). As in IssueExtensions.ToAnnotation (Sdk/RSWebApi/Contracts/
209// IssueExtensions.cs:25-29), an issue with no file and a line that is 0 or
210// absent takes it as start and end line, GitHub's way to point at the log from
211// an annotation that has no place in the code. Unlike GitHub, which then puts
212// the annotation on a placeholder file ".github", ermite sets no file.
213func (j *job) annotationOf(is issue, msg string, logLine int) Annotation {
214	a := Annotation{Level: is.Level, Message: msg}
215	p := is.Props
216	a.Title = truncateRunes(j.mask(p["title"]), maxAnnotationTitle)
217	// A blank file is no file (GitHub: path == null for a blank file), so the
218	// annotation is path-less and points at its log line below.
219	if f := j.mask(p["file"]); strings.TrimSpace(f) != "" && utf8.RuneCountInString(f) <= maxAnnotationFile {
220		a.File = f
221	}
222	num := func(k string) int32 {
223		if n, ok := parseInt(p[k]); ok && n > 0 {
224			return int32(n)
225		}
226		return 0
227	}
228	a.StartLine, a.EndLine = num("line"), num("endline")
229	a.StartColumn, a.EndColumn = num("col"), num("endcolumn")
230	if a.EndLine != 0 && a.EndLine < a.StartLine {
231		a.EndLine = 0
232	}
233	// GitHub's annotations end where they start when the command gave no end
234	// (recorded: ann-levels, match-basic).
235	if a.StartLine != 0 && a.EndLine == 0 {
236		a.EndLine = a.StartLine
237	}
238	if a.StartColumn != 0 && a.EndColumn == 0 {
239		a.EndColumn = a.StartColumn
240	}
241	// path == null && lineNumber == 0 && logLineNumber != 0. The raw line is
242	// the one that counts: a negative line is not 0 and keeps GitHub's value
243	// (which ermite cannot send, see above).
244	if n, ok := parseInt(p["line"]); strings.TrimSpace(p["file"]) == "" && (!ok || n == 0) && logLine > 0 && logLine <= math.MaxInt32 {
245		a.StartLine, a.EndLine = int32(logLine), int32(logLine)
246	}
247	return a
248}
249
250// validateLinesAndColumns is IssueCommandExtension.ValidateLinesAndColumns
251// (ActionCommandManager.cs:731-785). props has lower-case keys; command is the
252// name in the debug messages.
253func validateLinesAndColumns(props map[string]string, command string, debug func(string)) {
254	line, endLine, column, endColumn := props["line"], props["endline"], props["col"], props["endcolumn"]
255	lineNumber, hasStartLine := parseInt(line)
256	endLineNumber, hasEndLine := parseInt(endLine)
257	columnNumber, hasStartColumn := parseInt(column)
258	endColumnNumber, hasEndColumn := parseInt(endColumn)
259	hasColumn := hasStartColumn || hasEndColumn
260	msg := func(f string, a ...any) {
261		debug(fmt.Sprintf("Invalid %s command value. ", command) + fmt.Sprintf(f, a...))
262	}
263
264	if hasEndLine && !hasStartLine {
265		msg("'endLine' can only be set if 'line' is provided")
266		props["line"] = endLine
267		hasStartLine = true
268		line = endLine
269		lineNumber = endLineNumber
270	}
271	if hasEndColumn && !hasStartColumn {
272		msg("'endColumn' can only be set if 'col' is provided")
273		props["col"] = endColumn
274		hasStartColumn = true
275		column = endColumn
276		columnNumber = endColumnNumber
277	}
278	if !hasStartLine && hasColumn {
279		msg("'col' and 'endColumn' can only be set if 'line' value is provided.")
280		delete(props, "col")
281		delete(props, "endcolumn")
282	}
283	if hasEndLine && line != endLine && hasColumn {
284		msg("'col' and 'endColumn' cannot be set if 'line' and 'endLine' are different values.")
285		delete(props, "col")
286		delete(props, "endcolumn")
287	}
288	if hasStartLine && hasEndLine && endLineNumber < lineNumber {
289		msg("'endLine' cannot be less than 'line'.")
290		delete(props, "line")
291		delete(props, "endline")
292	}
293	if hasStartColumn && hasEndColumn && endColumnNumber < columnNumber {
294		msg("'endColumn' cannot be less than 'col'.")
295		delete(props, "col")
296		delete(props, "endcolumn")
297	}
298	_ = column
299}
300
301// makeRelative is IOUtil.MakeRelative on Linux: path relative to folder when
302// folder is a prefix of it at a directory boundary, else path unchanged.
303func makeRelative(path, folder string) string {
304	if !strings.HasPrefix(path, folder) {
305		return path
306	}
307	if len(path) == len(folder) {
308		return ""
309	}
310	if folder != "" && folder[len(folder)-1] == '/' {
311		return path[len(folder):]
312	}
313	if path[len(folder)] == '/' {
314		return path[len(folder)+1:]
315	}
316	return path
317}
318
319// commandIssue is IssueCommandExtension.ProcessCommand.
320func (m *commandManager) commandIssue(level AnnotationLevel, name string, c *actionCommand) {
321	h := m.host
322	props := map[string]string{}
323	for k, v := range c.Props {
324		props[k] = v
325	}
326	validateLinesAndColumns(props, name, h.Debug)
327	if file := props["file"]; file != "" {
328		// ActionCommandManager.cs:675-681: the path of a container is the host's.
329		if hp := h.HostPath(file); hp != file {
330			file = hp
331			props["file"] = hp
332		}
333		if rel := makeRelative(file, h.Workspace()); rel != file && rel != "" {
334			props["file"] = rel
335		}
336	}
337	h.AddIssue(issue{Level: level, Message: c.Data, Props: props})
338}

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 11

↑↓ moveenter openesc close