v0.4.0 76a5504

outputmgr.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6	"fmt"
7	"os"
8	"path/filepath"
9	"strconv"
10	"strings"
11	"sync"
12	"time"
13	"unicode"
14
15	"github.com/dlclark/regexp2"
16)
17
18// outputManager is OutputManager (Runner.Worker/Handlers/OutputManager.cs): it
19// receives the lines a step prints, runs the workflow commands and the
20// problem matchers on them, and logs what is left. One per process of a step.
21
22const (
23	// maxMatcherAttempts is OutputManager._maxAttempts.
24	maxMatcherAttempts = 3
25	// matcherTimeoutKey is OutputManager._timeoutKey.
26	matcherTimeoutKey = "GITHUB_ACTIONS_RUNNER_ISSUE_MATCHER_TIMEOUT"
27	// repoPathFailsafe is OutputManager._failsafe.
28	repoPathFailsafe = 50
29)
30
31// colorCodeRegex is OutputManager._colorCodeRegex, @"\x0033\[[0-9;]*m?". In
32// .NET "\x00" takes exactly two hex digits, so the pattern is NUL, "33", "["
33// and not ESC: the runner strips nothing from real terminal colors (its own
34// test writes "\033", which C# reads as NUL followed by "33"). Ported as is.
35var colorCodeRegex = regexp2.MustCompile("\x0033\\[[0-9;]*m?", 0)
36
37// colorCodePrefix is OutputManager._colorCodePrefix, "\033[": NUL, "33[".
38const colorCodePrefix = "\x0033["
39
40// parseTimeSpan is TimeSpan.TryParse with the invariant culture: "d" days,
41// "hh:mm", "hh:mm:ss[.fffffff]" and "d.hh:mm:ss[.fffffff]", with an optional
42// leading minus.
43func parseTimeSpan(s string) (time.Duration, bool) {
44	s = strings.TrimSpace(s)
45	if s == "" {
46		return 0, false
47	}
48	neg := strings.HasPrefix(s, "-")
49	s = strings.TrimPrefix(s, "-")
50	var days int64
51	parts := strings.Split(s, ":")
52	if len(parts) == 1 {
53		n, err := strconv.ParseInt(s, 10, 32)
54		if err != nil {
55			return 0, false
56		}
57		d := time.Duration(n) * 24 * time.Hour
58		if neg {
59			d = -d
60		}
61		return d, true
62	}
63	if len(parts) > 3 {
64		return 0, false
65	}
66	if d, rest, ok := strings.Cut(parts[0], "."); ok {
67		n, err := strconv.ParseInt(d, 10, 32)
68		if err != nil {
69			return 0, false
70		}
71		days, parts[0] = n, rest
72	}
73	num := func(x string) (int64, bool) {
74		n, err := strconv.ParseInt(x, 10, 32)
75		return n, err == nil && n >= 0
76	}
77	h, ok := num(parts[0])
78	if !ok {
79		return 0, false
80	}
81	m, ok := num(parts[1])
82	if !ok || m > 59 {
83		return 0, false
84	}
85	var sec float64
86	if len(parts) == 3 {
87		f, err := strconv.ParseFloat(parts[2], 64)
88		if err != nil || f < 0 || f >= 60 || strings.ContainsAny(parts[2], "eE+-") {
89			return 0, false
90		}
91		sec = f
92	}
93	if h > 23 {
94		return 0, false
95	}
96	d := time.Duration(days)*24*time.Hour + time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(sec*float64(time.Second))
97	if neg {
98		d = -d
99	}
100	return d, true
101}
102
103// matcherTimeout is the constructor of OutputManager (:43-60): the variable of
104// the job, then the environment of the runner, then one second. ermite has no
105// job variables; the environment is the executor's own, as the runner's is its
106// worker's.
107func matcherTimeout(getenv func(string) string) time.Duration {
108	if v := getenv(matcherTimeoutKey); v != "" {
109		if d, ok := parseTimeSpan(v); ok && d > 0 {
110			return d
111		}
112	}
113	return time.Second
114}
115
116type outputManager struct {
117	s *stepRun
118	// cancel is closed when the step is cancelled or times out: the matchers
119	// then stop (see stopped). Nil means never.
120	cancel  <-chan struct{}
121	root    *matcherRoot
122	timeout time.Duration
123
124	mu       sync.Mutex
125	matchers []*issueMatcher
126	dirMap   map[string]string // directory -> repository path ("" = none)
127	hasDir   map[string]bool
128}
129
130// newOutputManager subscribes to the matchers of the job.
131//
132// cancel is the cancellation channel of the step (the one the process gets):
133// once it is closed no matcher runs any more. The runner has no such rule, but
134// its worker waits for the output of a step with the step's cancellation
135// token, and a pile of lines against catastrophic patterns (1 s each, 3
136// attempts, per matcher) would hold a step past its timeout for minutes.
137func newOutputManager(s *stepRun, cancel <-chan struct{}) *outputManager {
138	o := &outputManager{s: s, cancel: cancel, root: s.j.matchers, timeout: matcherTimeout(s.j.getenv), dirMap: map[string]string{}, hasDir: map[string]bool{}}
139	o.mu.Lock()
140	o.root.subscribe(o)
141	for _, c := range o.root.snapshot() {
142		if m, err := newIssueMatcher(c, o.timeout); err == nil {
143			o.matchers = append(o.matchers, m)
144		}
145	}
146	o.mu.Unlock()
147	return o
148}
149
150// errStopped is the result of a match that was abandoned because the step was
151// cancelled.
152var errStopped = msgError("matching stopped: the step was cancelled")
153
154// runMatch is m.match, abandoned when the step is cancelled while it runs: the
155// engine cannot be interrupted, so it goes on in its goroutine until its own
156// timeout, but the step does not wait for it and the matcher is not used again
157// (stopped is true from then on).
158func (o *outputManager) runMatch(m *issueMatcher, line string) (*issueMatch, error) {
159	if o.cancel == nil {
160		return m.match(line)
161	}
162	type result struct {
163		m   *issueMatch
164		err error
165	}
166	ch := make(chan result, 1)
167	go func() {
168		r, err := m.match(line)
169		ch <- result{r, err}
170	}()
171	select {
172	case r := <-ch:
173		return r.m, r.err
174	case <-o.cancel:
175		return nil, errStopped
176	}
177}
178
179// stopped reports whether the step was cancelled.
180func (o *outputManager) stopped() bool {
181	if o.cancel == nil {
182		return false
183	}
184	select {
185	case <-o.cancel:
186		return true
187	default:
188		return false
189	}
190}
191
192func (o *outputManager) dispose() { o.root.unsubscribe(o) }
193
194// matcherChanged is OnMatcherChanged: the matcher goes first, the others of
195// the same owner (ignoring case) go. A config without patterns only removes.
196func (o *outputManager) matcherChanged(c *matcherConfig) {
197	o.mu.Lock()
198	defer o.mu.Unlock()
199	var next []*issueMatcher
200	if len(c.Patterns) > 0 {
201		if m, err := newIssueMatcher(c, o.timeout); err == nil {
202			next = append(next, m)
203		}
204	}
205	for _, m := range o.matchers {
206		if !strings.EqualFold(m.owner, c.Owner) {
207			next = append(next, m)
208		}
209	}
210	o.matchers = next
211}
212
213func (o *outputManager) removeMatcher(m *issueMatcher) {
214	o.mu.Lock()
215	defer o.mu.Unlock()
216	var next []*issueMatcher
217	for _, x := range o.matchers {
218		if x != m {
219			next = append(next, x)
220		}
221	}
222	o.matchers = next
223}
224
225// onData is OutputManager.OnDataReceived.
226func (o *outputManager) onData(line string) {
227	s := o.s
228	if s.cmds.fatal != nil {
229		return // the handler threw: ExecuteAsync is over, nothing more is read
230	}
231	// ## commands
232	if line != "" && (strings.Contains(line, "##[") || strings.Contains(line, "::")) {
233		if s.cmds.TryProcess(line) {
234			return
235		}
236	}
237	// Strip runner-controlled markers from user output to prevent injection.
238	if strings.Contains(line, "##[start-action") || strings.Contains(line, "##[end-action") {
239		line = strings.ReplaceAll(strings.ReplaceAll(line, "##[start-action", `##[\start-action`), "##[end-action", `##[\end-action`)
240	}
241	// Problem matchers.
242	o.mu.Lock()
243	matchers := append([]*issueMatcher(nil), o.matchers...)
244	o.mu.Unlock()
245	if len(matchers) > 0 && !o.stopped() {
246		stripped := line
247		if strings.Contains(line, colorCodePrefix) {
248			stripped, _ = colorCodeRegex.Replace(line, "", -1, -1)
249		}
250		for _, m := range matchers {
251			var match *issueMatch
252			for attempt := 1; attempt <= maxMatcherAttempts; attempt++ {
253				if o.stopped() {
254					break // the lines that are left are only logged
255				}
256				mt, err := o.runMatch(m, stripped)
257				if err == nil {
258					match = mt
259					break
260				}
261				if o.stopped() {
262					break // a timeout that the cancellation caused counts for nothing
263				}
264				if attempt < maxMatcherAttempts {
265					s.Debug(fmt.Sprintf("Timeout processing issue matcher '%s' against line '%s'. Exception: System.Text.RegularExpressions.RegexMatchTimeoutException: %s", m.owner, stripped, err.Error()))
266				} else {
267					s.j.addIssueFor(s, issue{Level: LevelWarning, Message: fmt.Sprintf("Removing issue matcher '%s'. Matcher failed %d times. Error: %s", m.owner, maxMatcherAttempts, err.Error())})
268					o.removeMatcher(m)
269				}
270			}
271			if match != nil {
272				// Reset other matchers.
273				for _, other := range matchers {
274					if other != m {
275						other.reset()
276					}
277				}
278				if is, ok := o.convertToIssue(match); ok {
279					is.LogText = stripped
280					s.j.addIssueFor(s, is)
281					return
282				}
283			}
284		}
285	}
286	// Regular output.
287	s.Out(line)
288}
289
290// convertToIssue is OutputManager.ConvertToIssue (:196-327).
291func (o *outputManager) convertToIssue(m *issueMatch) (issue, bool) {
292	s := o.s
293	if strings.TrimFunc(m.message.s, unicode.IsSpace) == "" {
294		s.Debug("Skipping logging an issue for the matched line because the message is empty.")
295		return issue{}, false
296	}
297	var level AnnotationLevel
298	switch strings.ToLower(m.severity.s) {
299	case "", "error":
300		level = LevelFailure
301	case "warning":
302		level = LevelWarning
303	case "notice":
304		level = LevelNotice
305	default:
306		s.Debug(fmt.Sprintf("Skipped logging an issue for the matched line because the severity '%s' is not supported.", m.severity.s))
307		return issue{}, false
308	}
309	is := issue{Level: level, Message: m.message.s, Props: map[string]string{}}
310	// unsigned parses like int.TryParse(NumberStyles.None): digits only.
311	unsigned := func(v string) (string, bool) {
312		for _, r := range v {
313			if r < '0' || r > '9' {
314				return "", false
315			}
316		}
317		n, err := strconv.ParseInt(v, 10, 32)
318		if err != nil || v == "" {
319			return "", false
320		}
321		return strconv.FormatInt(n, 10), true
322	}
323	if m.line.s != "" {
324		if n, ok := unsigned(m.line.s); ok {
325			is.Props["line"] = n
326		} else {
327			s.Debug(fmt.Sprintf("Unable to parse line number '%s'", m.line.s))
328		}
329	}
330	if m.column.s != "" {
331		if n, ok := unsigned(m.column.s); ok {
332			is.Props["col"] = n
333		} else {
334			s.Debug(fmt.Sprintf("Unable to parse column number '%s'", m.column.s))
335		}
336	}
337	if c := strings.TrimFunc(m.code.s, unicode.IsSpace); c != "" {
338		is.Props["code"] = c
339	}
340	// File.
341	if strings.TrimFunc(m.file.s, unicode.IsSpace) != "" {
342		file := m.file.s
343		// Root using fromPath.
344		if strings.TrimFunc(m.fromPath.s, unicode.IsSpace) != "" && !filepath.IsAbs(file) {
345			if dir := filepath.Dir(m.fromPath.s); dir != "." && dir != "" {
346				file = filepath.Join(dir, file)
347			}
348		}
349		// Root using workspace; an absolute path is the container's and is
350		// translated to the host's (OutputManager.cs:281-311).
351		if !filepath.IsAbs(file) {
352			file = filepath.Join(s.Workspace(), file)
353		} else {
354			file = filepath.Clean(file)
355			file = filepath.Clean(s.HostPath(file))
356		}
357		file = filepath.Clean(file)
358		if st, err := os.Stat(file); err == nil && !st.IsDir() {
359			if repo := o.repositoryPath(file, 0); repo != "" {
360				rel := strings.TrimLeft(file[len(repo):], "/")
361				is.Props["file"] = rel
362			} else {
363				s.Debug(fmt.Sprintf("Dropping file value '%s'. Path is not under the workflow repo.", file))
364			}
365		} else {
366			s.Debug(fmt.Sprintf("Dropping file value '%s'. Path does not exist", file))
367		}
368	}
369	return is, true
370}
371
372// repositoryPath is OutputManager.GetRepositoryPath: the closest directory
373// above filePath that has a .git/config naming the repository of the job.
374func (o *outputManager) repositoryPath(filePath string, recursion int) string {
375	if len(o.dirMap) > 100 {
376		o.dirMap, o.hasDir = map[string]string{}, map[string]bool{}
377	}
378	dir := filepath.Dir(filePath)
379	if dir == filePath || dir == "." || recursion > repoPathFailsafe {
380		return ""
381	}
382	if o.hasDir[dir] {
383		return o.dirMap[dir]
384	}
385	repo := ""
386	gitConfig := filepath.Join(dir, ".git", "config")
387	if st, err := os.Stat(gitConfig); err == nil && !st.IsDir() {
388		serverURL := o.s.j.githubValue("server_url")
389		if serverURL == "" {
390			serverURL = "https://github.com"
391		}
392		host := serverURL
393		if i := strings.Index(host, "://"); i >= 0 {
394			host = host[i+3:]
395		}
396		if i := strings.IndexAny(host, "/:"); i >= 0 {
397			host = host[:i]
398		}
399		nameWithOwner := o.s.j.githubValue("repository")
400		patterns := []string{"url = " + serverURL + "/" + nameWithOwner, "url = git@" + host + ":" + nameWithOwner + ".git"}
401		if b, err := os.ReadFile(gitConfig); err == nil {
402			for _, l := range strings.Split(string(b), "\n") {
403				l = strings.TrimFunc(l, unicode.IsSpace)
404				for _, p := range patterns {
405					if strings.EqualFold(l, p) {
406						repo = dir
407					}
408				}
409			}
410		} else {
411			o.s.Debug(fmt.Sprintf("Error when attempting to determine whether the path '%s' is under the workflow repository: %v", filePath, err))
412		}
413	} else {
414		repo = o.repositoryPath(dir, recursion+1)
415	}
416	o.dirMap[dir], o.hasDir[dir] = repo, true
417	return repo
418}

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