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 = 255
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}