cmd/greplogs: copy from github.com/aclements/go-misc/greplogs

This copies in my fork of greplogs from
github.com/bcmills/go-misc v0.0.0-20220527140618-59e2ae99cbec
along with a copy of the "loganal" package renamed to
"golang.org/x/build/cmd/greplogs/internal/logparse" and trimmed
to remove the Classify function (which greplogs does not use).

This code was originally written by Austin Clements (with some
contributions from me and others), and was already published
under the Go license with copyright assigned to the Go Authors.

Change-Id: Ia9a1cb166693ede39613620f0330c165c639b232
Reviewed-on: https://go-review.googlesource.com/c/build/+/408935
Run-TryBot: Bryan Mills <bcmills@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Alex Rakoczy <alex@golang.org>
Auto-Submit: Bryan Mills <bcmills@google.com>
diff --git a/cmd/greplogs/_embed/broken.go b/cmd/greplogs/_embed/broken.go
new file mode 100644
index 0000000..b2fb5ca
--- /dev/null
+++ b/cmd/greplogs/_embed/broken.go
@@ -0,0 +1,27 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Command broken lists the current Go builders with known issues.
+//
+// To test this program, cd to its directory and run:
+//
+//	go mod init
+//	go get golang.org/x/build/dashboard@HEAD
+//	go run .
+//	rm go.mod go.sum
+package main
+
+import (
+	"fmt"
+
+	"golang.org/x/build/dashboard"
+)
+
+func main() {
+	for _, b := range dashboard.Builders {
+		if len(b.KnownIssues) > 0 {
+			fmt.Println(b.Name)
+		}
+	}
+}
diff --git a/cmd/greplogs/broken.go b/cmd/greplogs/broken.go
new file mode 100644
index 0000000..15166cd
--- /dev/null
+++ b/cmd/greplogs/broken.go
@@ -0,0 +1,110 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	_ "embed"
+	"errors"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"sort"
+	"strings"
+)
+
+//go:embed _embed/broken.go
+var brokenScript []byte
+
+// listBrokenBuilders returns the builders that are marked
+// as broken in golang.org/x/build/dashboard at HEAD.
+func listBrokenBuilders() (broken []string, err error) {
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("identifying broken builders: %v", err)
+		}
+	}()
+
+	// Though this be madness, yet there is method in 't.
+	//
+	// Our goals here are:
+	//
+	// 	1. Always use the most up-to-date information about broken builders, even
+	// 	   if the user hasn't recently updated the greplogs binary.
+	//
+	// 	2. Avoid the need to massively refactor the builder configuration right
+	// 	   now. (Currently, the Go builders are configured programmatically in the
+	// 	   x/build/dashboard package, not in external configuration files.)
+	//
+	// 	3. Avoid the need to redeploy a production x/build/cmd/coordinator or
+	// 	   x/build/devapp to pick up changes. (A user triaging test failures might
+	// 	   not have access to deploy the coordinator, or might not want to disrupt
+	// 	   running tests or active gomotes by pushing it.)
+	//
+	// Goals (2) and (3) imply that we must use x/build/dashboard, not fetch the
+	// list from build.golang.org or dev.golang.org. Since that is a Go package,
+	// we must run it as a Go program in order to evaluate it.
+	//
+	// Goal (1) implies that we must use x/build at HEAD, not (say) at whatever
+	// version of x/build this command was built with. We could perhaps relax that
+	// constraint if we move greplogs itself into x/build and consistently triage
+	// using 'go run golang.org/x/build/cmd/greplogs@HEAD' instead of an installed
+	// 'greplogs'.
+
+	if os.Getenv("GO111MODULE") == "off" {
+		return nil, errors.New("operation requires GO111MODULE=on or auto")
+	}
+
+	modDir, err := os.MkdirTemp("", "greplogs")
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		removeErr := os.RemoveAll(modDir)
+		if err == nil {
+			err = removeErr
+		}
+	}()
+
+	runCommand := func(name string, args ...string) ([]byte, error) {
+		cmd := exec.Command(name, args...)
+		cmd.Dir = modDir
+		cmd.Env = append(os.Environ(),
+			"PWD="+modDir,                  // match cmd.Dir
+			"GOPRIVATE=golang.org/x/build", // avoid proxy cache; see https://go.dev/issue/38065
+		)
+		cmd.Stderr = new(strings.Builder)
+
+		out, err := cmd.Output()
+		if err != nil {
+			return out, fmt.Errorf("%s: %w\nstderr:\n%s", strings.Join(cmd.Args, " "), err, cmd.Stderr)
+		}
+		return out, nil
+	}
+
+	_, err = runCommand("go", "mod", "init", "github.com/aclements/go-misc/greplogs/_embed")
+	if err != nil {
+		return nil, err
+	}
+
+	_, err = runCommand("go", "get", "golang.org/x/build/dashboard@HEAD")
+	if err != nil {
+		return nil, err
+	}
+
+	err = os.WriteFile(filepath.Join(modDir, "broken.go"), brokenScript, 0644)
+	if err != nil {
+		return nil, err
+	}
+
+	out, err := runCommand("go", "run", "broken.go")
+	if err != nil {
+		return nil, err
+	}
+
+	broken = strings.Split(strings.TrimSpace(string(out)), "\n")
+	sort.Strings(broken)
+	return broken, nil
+}
diff --git a/cmd/greplogs/color.go b/cmd/greplogs/color.go
new file mode 100644
index 0000000..d8a7d29
--- /dev/null
+++ b/cmd/greplogs/color.go
@@ -0,0 +1,63 @@
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"math/bits"
+	"os"
+	"strconv"
+
+	"golang.org/x/term"
+)
+
+func canColor() bool {
+	if os.Getenv("TERM") == "" || os.Getenv("TERM") == "dumb" {
+		return false
+	}
+	if !term.IsTerminal(1) {
+		return false
+	}
+	return true
+}
+
+type colorizer struct {
+	enabled bool
+}
+
+func newColorizer(enabled bool) *colorizer {
+	return &colorizer{enabled}
+}
+
+type colorFlags uint64
+
+const (
+	colorBold      colorFlags = 1 << 1
+	colorFgBlack              = 1 << 30
+	colorFgRed                = 1 << 31
+	colorFgGreen              = 1 << 32
+	colorFgYellow             = 1 << 33
+	colorFgBlue               = 1 << 34
+	colorFgMagenta            = 1 << 35
+	colorFgCyan               = 1 << 36
+	colorFgWhite              = 1 << 37
+)
+
+func (c colorizer) color(s string, f colorFlags) string {
+	if !c.enabled || f == 0 {
+		return s
+	}
+	pfx := make([]byte, 0, 16)
+	pfx = append(pfx, 0x1b, '[')
+	for f != 0 {
+		flag := uint64(bits.TrailingZeros64(uint64(f)))
+		f &^= 1 << flag
+		if len(pfx) > 2 {
+			pfx = append(pfx, ';')
+		}
+		pfx = strconv.AppendUint(pfx, flag, 10)
+	}
+	pfx = append(pfx, 'm')
+	return string(pfx) + s + "\x1b[0m"
+}
diff --git a/cmd/greplogs/flags.go b/cmd/greplogs/flags.go
new file mode 100644
index 0000000..d07d05a
--- /dev/null
+++ b/cmd/greplogs/flags.go
@@ -0,0 +1,60 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import "regexp"
+
+type regexpList []*regexp.Regexp
+
+func (x *regexpList) String() string {
+	s := ""
+	for i, r := range *x {
+		if i != 0 {
+			s += ","
+		}
+		s += r.String()
+	}
+	return s
+}
+
+func (x *regexpList) Set(s string) error {
+	re, err := regexp.Compile("(?m)" + s)
+	if err != nil {
+		// Get an error without our modifications.
+		_, err2 := regexp.Compile(s)
+		if err2 != nil {
+			err = err2
+		}
+		return err
+	}
+	*x = append(*x, re)
+	return nil
+}
+
+func (x *regexpList) AllMatch(data []byte) bool {
+	for _, r := range *x {
+		if !r.Match(data) {
+			return false
+		}
+	}
+	return true
+}
+
+func (x *regexpList) AnyMatchString(data string) bool {
+	for _, r := range *x {
+		if r.MatchString(data) {
+			return true
+		}
+	}
+	return false
+}
+
+func (x *regexpList) Matches(data []byte) [][]int {
+	matches := [][]int{}
+	for _, r := range *x {
+		matches = append(matches, r.FindAllSubmatchIndex(data, -1)...)
+	}
+	return matches
+}
diff --git a/cmd/greplogs/internal/logparse/doc.go b/cmd/greplogs/internal/logparse/doc.go
new file mode 100644
index 0000000..c7eb9db
--- /dev/null
+++ b/cmd/greplogs/internal/logparse/doc.go
@@ -0,0 +1,7 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package logparsew contains functions for parsing and analyzing build and test
+// logs produced by all.bash.
+package logparse
diff --git a/cmd/greplogs/internal/logparse/failure.go b/cmd/greplogs/internal/logparse/failure.go
new file mode 100644
index 0000000..19ee4ae
--- /dev/null
+++ b/cmd/greplogs/internal/logparse/failure.go
@@ -0,0 +1,553 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package logparse
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+	"sync"
+)
+
+// Failure records a failure extracted from an all.bash log.
+type Failure struct {
+	// Package is the Go package of this failure. In the case of a
+	// testing.T failure, this will be the package of the test.
+	Package string
+
+	// Test identifies the failed test function. If this is not a
+	// testing.T failure, this will be "".
+	Test string
+
+	// Message is the summarized failure message. This will be one
+	// line of text.
+	Message string
+
+	// FullMessage is a substring of the log that captures the
+	// entire failure message. It may be many lines long.
+	FullMessage string
+
+	// Function is the fully qualified name of the function where
+	// this failure happened, if known. This helps distinguish
+	// between generic errors like "out of bounds" and is more
+	// stable for matching errors than file/line.
+	Function string
+
+	// File is the source file where this failure happened, if
+	// known.
+	File string
+
+	// Line is the source line where this failure happened, if
+	// known.
+	Line int
+
+	// OS and Arch are the GOOS and GOARCH of this failure.
+	OS, Arch string
+}
+
+func (f Failure) String() string {
+	s := f.Package
+	if f.Test != "" {
+		s += "." + f.Test
+	}
+	if f.Function != "" || f.File != "" {
+		if s != "" {
+			s += " "
+		}
+		if f.Function != "" {
+			s += "at " + f.Function
+		} else {
+			s += "at " + f.File
+			if f.Line != 0 {
+				s += fmt.Sprintf(":%d", f.Line)
+			}
+		}
+	}
+	if s != "" {
+		s += ": "
+	}
+	s += f.Message
+	return s
+}
+
+func (f *Failure) canonicalMessage() string {
+	// Do we need to do anything to the message?
+	for _, c := range f.Message {
+		if '0' <= c && c <= '9' {
+			goto rewrite
+		}
+	}
+	return f.Message
+
+rewrite:
+	// Canonicalize any "word" of the message containing numbers.
+	//
+	// TODO: "Escape" any existing … to make this safe as a key
+	// for later use with canonicalFields (direct use is
+	// unimportant).
+	return numberWords.ReplaceAllString(f.Message, "…")
+}
+
+// numberWords matches words that consist of both letters and
+// digits. Since this is meant to canonicalize numeric fields
+// of error messages, we accept any Unicode letter, but only
+// digits 0-9. We match the whole word to catch things like
+// hexadecimal and temporary file names.
+var numberWords = regexp.MustCompile(`\pL*[0-9][\pL0-9]*`)
+
+var (
+	linesStar = `(?:.*\n)*?`
+	linesPlus = `(?:.*\n)+?`
+
+	// failPkg matches the FAIL line for a package.
+	//
+	// In case of failure the Android wrapper prints "exitcode=1" without a newline,
+	// so for logs prior to the fix for https://golang.org/issue/49317 we need to
+	// strip that from the beginning of the line.
+	failPkg = `(?m:^(?:exitcode=1)?FAIL[ \t]+(\S+))`
+
+	// logTruncated matches the "log truncated" line injected by the coordinator.
+	logTruncated = `(?:\n\.\.\. log truncated \.\.\.)`
+
+	endOfTest = `(?:` + failPkg + `|` + logTruncated + `)`
+
+	canonLine = regexp.MustCompile(`\r+\n`)
+
+	// testingHeader matches the beginning of the go test std
+	// section. On Plan 9 there used to be just one #.
+	testingHeader = regexp.MustCompile(`^#+ Testing packages`)
+
+	// sectionHeader matches the header of each testing section
+	// printed by go tool dist test.
+	sectionHeader = regexp.MustCompile(`^##### (.*)`)
+
+	// testingFailed matches a testing.T failure. This may be a
+	// T.Error or a recovered panic. There was a time when the
+	// test name included GOMAXPROCS (like how benchmark names
+	// do), so we strip that out.
+	testingFailed = regexp.MustCompile(`^--- FAIL: ([^-\s]+).*\n(` + linesStar + `)` + endOfTest)
+
+	// testingError matches the file name and message of the last
+	// T.Error in a testingFailed log.
+	testingError = regexp.MustCompile(`(?:.*\n)*\t([^:]+):([0-9]+): (.*)\n`)
+
+	// testingPanic matches a recovered panic in a testingFailed
+	// log.
+	testingPanic = regexp.MustCompile(`panic: (.*?)(?: \[recovered\])`)
+
+	// gotestFailed matches a $GOROOT/test failure.
+	gotestFailed = regexp.MustCompile(`^# go run run\.go.*\n(` + linesPlus + `)` + endOfTest)
+
+	// buildFailed matches build failures from the testing package.
+	buildFailed = regexp.MustCompile(`^` + failPkg + `\s+\[build failed\]`)
+
+	// timeoutPanic1 matches a test timeout detected by the testing package.
+	timeoutPanic1 = regexp.MustCompile(`^panic: test timed out after .*\n(` + linesStar + `)` + endOfTest)
+
+	// timeoutPanic2 matches a test timeout detected by go test.
+	timeoutPanic2 = regexp.MustCompile(`^\*\*\* Test killed.*ran too long\n` + endOfTest)
+
+	// coordinatorTimeout matches a test timeout detected by the
+	// coordinator, for both non-sharded and sharded tests.
+	coordinatorTimeout = regexp.MustCompile(`(?m)^Build complete.*Result: error: timed out|^Test "[^"]+" ran over [0-9a-z]+ limit`)
+
+	// tbEntry is a regexp string that matches a single
+	// function/line number entry in a traceback. Group 1 matches
+	// the fully qualified function name. Groups 2 and 3 match the
+	// file name and line number.
+	// Most entries have trailing stack metadata for each frame,
+	// but inlined calls, lacking a frame, may omit that metadata.
+	tbEntry = `(\S+)\(.*\)\n\t(.*):([0-9]+)(?: .*)?\n`
+
+	// runtimeFailed matches a runtime throw or testing package
+	// panic. Matching the panic is fairly loose because in some
+	// cases a "fatal error:" can be preceded by a "panic:" if
+	// we've started the panic and then realize we can't (e.g.,
+	// sigpanic). Also gather up the "runtime:" prints preceding a
+	// throw.
+	runtimeFailed        = regexp.MustCompile(`^(?:runtime:.*\n)*.*(?:panic: |fatal error: )(.*)`)
+	runtimeLiterals      = []string{"runtime:", "panic:", "fatal error:"}
+	runtimeFailedTrailer = regexp.MustCompile(`^(?:exit status.*\n)?(?:\*\*\* Test killed.*\n)?` + endOfTest + `?`)
+
+	// apiCheckerFailed matches an API checker failure.
+	apiCheckerFailed = regexp.MustCompile(`^Error running API checker: (.*)`)
+
+	// goodLine matches known-good lines so we can ignore them
+	// before doing more aggressive/fuzzy failure extraction.
+	goodLine = regexp.MustCompile(`^#|^ok\s|^\?\s|^Benchmark|^PASS|^=== |^--- `)
+
+	// testingUnknownFailed matches the last line of some unknown
+	// failure detected by the testing package.
+	testingUnknownFailed = regexp.MustCompile(`^` + endOfTest)
+
+	// miscFailed matches the log.Fatalf in go tool dist test when
+	// a test fails. We use this as a last resort, mostly to pick
+	// up failures in sections that don't use the testing package.
+	miscFailed = regexp.MustCompile(`^.*Failed: (?:exit status|test failed)`)
+)
+
+// An extractCache speeds up failure extraction from multiple logs by
+// caching known lines. It is *not* thread-safe, so we track it in a
+// sync.Pool.
+type extractCache struct {
+	boringLines map[string]bool
+}
+
+var extractCachePool sync.Pool
+
+func init() {
+	extractCachePool.New = func() interface{} {
+		return &extractCache{make(map[string]bool)}
+	}
+}
+
+// Extract parses the failures from all.bash log m.
+func Extract(m string, os, arch string) ([]*Failure, error) {
+	fs := []*Failure{}
+	testingStarted := false
+	section := ""
+	sectionHeaderFailures := 0 // # failures at section start
+	unknown := []string{}
+	cache := extractCachePool.Get().(*extractCache)
+	defer extractCachePool.Put(cache)
+
+	// Canonicalize line endings. Note that some logs have a mix
+	// of line endings and some somehow have multiple \r's.
+	m = canonLine.ReplaceAllString(m, "\n")
+
+	var s []string
+	matcher := newMatcher(m)
+	consume := func(r *regexp.Regexp) bool {
+		matched := matcher.consume(r)
+		s = matcher.groups
+		if matched && !strings.HasSuffix(s[0], "\n") {
+			// Consume the rest of the line.
+			matcher.line()
+		}
+		return matched
+	}
+	firstBadLine := func() string {
+		for _, u := range unknown {
+			if len(u) > 0 {
+				return u
+			}
+		}
+		return ""
+	}
+
+	for !matcher.done() {
+		// Check for a cached result.
+		line, nextLinePos := matcher.peekLine()
+		isGoodLine, cached := cache.boringLines[line]
+
+		// Process the line.
+		isKnown := true
+		switch {
+		case cached:
+			matcher.pos = nextLinePos
+			if !isGoodLine {
+				// This line is known to not match any
+				// regexps. Follow the default case.
+				isKnown = false
+				unknown = append(unknown, line)
+			}
+
+		case consume(testingHeader):
+			testingStarted = true
+
+		case consume(sectionHeader):
+			section = s[1]
+			sectionHeaderFailures = len(fs)
+
+		case consume(testingFailed):
+			f := &Failure{
+				Test:        s[1],
+				Package:     s[3],
+				FullMessage: s[0],
+				Message:     "unknown testing.T failure",
+			}
+
+			// TODO: Can have multiple errors per FAIL:
+			// ../fetchlogs/rev/2015-03-24T19:51:21-41f9c43/linux-arm64-canonical
+
+			sError := testingError.FindStringSubmatch(s[2])
+			sPanic := testingPanic.FindStringSubmatch(s[2])
+			if sError != nil {
+				f.File, f.Line, f.Message = sError[1], atoi(sError[2]), sError[3]
+			} else if sPanic != nil {
+				f.Function, f.File, f.Line = panicWhere(s[2])
+				f.Message = sPanic[1]
+			}
+
+			fs = append(fs, f)
+
+		case consume(gotestFailed):
+			fs = append(fs, &Failure{
+				Package:     "test/" + s[2],
+				FullMessage: s[0],
+				Message:     firstLine(s[1]),
+			})
+
+		case consume(buildFailed):
+			// This may have an accompanying compiler
+			// crash, but it's interleaved with other "ok"
+			// lines, so it's hard to find.
+			fs = append(fs, &Failure{
+				FullMessage: s[0],
+				Message:     "build failed",
+				Package:     s[1],
+			})
+
+		case consume(timeoutPanic1):
+			fs = append(fs, &Failure{
+				Test:        testFromTraceback(s[1]),
+				FullMessage: s[0],
+				Message:     "test timed out",
+				Package:     s[2],
+			})
+
+		case consume(timeoutPanic2):
+			tb := strings.Join(unknown, "\n")
+			fs = append(fs, &Failure{
+				Test:        testFromTraceback(tb),
+				FullMessage: tb + "\n" + s[0],
+				Message:     "test timed out",
+				Package:     s[1],
+			})
+
+		case matcher.lineHasLiteral(runtimeLiterals...) && consume(runtimeFailed):
+			start := matcher.matchPos
+			msg := s[1]
+			pkg := "testing"
+			if strings.Contains(s[0], "fatal error:") {
+				pkg = "runtime"
+			}
+			traceback := consumeTraceback(matcher)
+			matcher.consume(runtimeFailedTrailer)
+			fn, file, line := panicWhere(traceback)
+			fs = append(fs, &Failure{
+				Package:     pkg,
+				FullMessage: matcher.str[start:matcher.pos],
+				Message:     msg,
+				Function:    fn,
+				File:        file,
+				Line:        line,
+			})
+
+		case consume(apiCheckerFailed):
+			fs = append(fs, &Failure{
+				Package:     "API checker",
+				FullMessage: s[0],
+				Message:     s[1],
+			})
+
+		case consume(goodLine):
+			// Ignore. Just cache and clear unknown.
+			cache.boringLines[line] = true
+
+		case consume(testingUnknownFailed):
+			fs = append(fs, &Failure{
+				Package:     s[1],
+				FullMessage: s[0],
+				Message:     "unknown failure: " + firstBadLine(),
+			})
+
+		case len(fs) == sectionHeaderFailures && consume(miscFailed):
+			fs = append(fs, &Failure{
+				Package:     section,
+				FullMessage: s[0],
+				Message:     "unknown failure: " + firstBadLine(),
+			})
+
+		default:
+			isKnown = false
+			unknown = append(unknown, line)
+			cache.boringLines[line] = false
+			matcher.pos = nextLinePos
+		}
+
+		// Clear unknown lines on any known line.
+		if isKnown {
+			unknown = unknown[:0]
+		}
+	}
+
+	// TODO: FullMessages for these.
+	if len(fs) == 0 && strings.Contains(m, "no space left on device") {
+		fs = append(fs, &Failure{
+			Message: "build failed (no space left on device)",
+		})
+	}
+	if len(fs) == 0 && coordinatorTimeout.MatchString(m) {
+		// all.bash was killed by coordinator.
+		fs = append(fs, &Failure{
+			Message: "build failed (timed out)",
+		})
+	}
+	if len(fs) == 0 && strings.Contains(m, "Failed to schedule") {
+		// Test sharding failed.
+		fs = append(fs, &Failure{
+			Message: "build failed (failed to schedule)",
+		})
+	}
+	if len(fs) == 0 && strings.Contains(m, "nosplit stack overflow") {
+		fs = append(fs, &Failure{
+			Message: "build failed (nosplit stack overflow)",
+		})
+	}
+
+	// If the same (message, where) shows up in more than five
+	// packages, it's probably a systemic issue, so collapse it
+	// down to one failure with no package.
+	type dedup struct {
+		packages map[string]bool
+		kept     bool
+	}
+	msgDedup := map[Failure]*dedup{}
+	failureMap := map[*Failure]*dedup{}
+	maxCount := 0
+	for _, f := range fs {
+		key := Failure{
+			Message:  f.canonicalMessage(),
+			Function: f.Function,
+			File:     f.File,
+			Line:     f.Line,
+		}
+
+		d := msgDedup[key]
+		if d == nil {
+			d = &dedup{packages: map[string]bool{}}
+			msgDedup[key] = d
+		}
+		d.packages[f.Package] = true
+		if len(d.packages) > maxCount {
+			maxCount = len(d.packages)
+		}
+		failureMap[f] = d
+	}
+	if maxCount >= 5 {
+		fsn := []*Failure{}
+		for _, f := range fs {
+			d := failureMap[f]
+			if len(d.packages) < 5 {
+				fsn = append(fsn, f)
+			} else if !d.kept {
+				d.kept = true
+				f.Test, f.Package = "", ""
+				fsn = append(fsn, f)
+			}
+		}
+		fs = fsn
+	}
+
+	// Check if we even got as far as testing. Note that there was
+	// a period when we didn't print the "testing" header, so as
+	// long as we found failures, we don't care if we found the
+	// header.
+	if !testingStarted && len(fs) == 0 {
+		fs = append(fs, &Failure{
+			Message: "toolchain build failed",
+		})
+	}
+
+	for _, f := range fs {
+		f.OS, f.Arch = os, arch
+
+		// Clean up package. For misc/cgo tests, this will be
+		// something like
+		// _/tmp/buildlet-scatch825855615/go/misc/cgo/test.
+		if strings.HasPrefix(f.Package, "_/tmp/") {
+			f.Package = strings.SplitN(f.Package, "/", 4)[3]
+		}
+
+		// Trim trailing newlines from FullMessage.
+		f.FullMessage = strings.TrimRight(f.FullMessage, "\n")
+	}
+	return fs, nil
+}
+
+func atoi(s string) int {
+	v, err := strconv.Atoi(s)
+	if err != nil {
+		panic("expected number, got " + s)
+	}
+	return v
+}
+
+// firstLine returns the first line from s, not including the line
+// terminator.
+func firstLine(s string) string {
+	if i := strings.Index(s, "\n"); i >= 0 {
+		return s[:i]
+	}
+	return s
+}
+
+var (
+	tracebackStart = regexp.MustCompile(`^(goroutine [0-9]+.*:|runtime stack:)\n`)
+	tracebackEntry = regexp.MustCompile(`^` + tbEntry)
+)
+
+// consumeTraceback consumes a traceback from m.
+func consumeTraceback(m *matcher) string {
+	// Find the beginning of the traceback.
+	for !m.done() && !m.peek(tracebackStart) {
+		m.line()
+	}
+
+	start := m.pos
+loop:
+	for !m.done() {
+		switch {
+		case m.hasPrefix("\n") || m.hasPrefix("\t") ||
+			m.hasPrefix("goroutine ") || m.hasPrefix("runtime stack:") ||
+			m.hasPrefix("created by "):
+			m.line()
+
+		case m.consume(tracebackEntry):
+			// Do nothing.
+
+		default:
+			break loop
+		}
+	}
+	return m.str[start:m.pos]
+}
+
+var (
+	// testFromTracebackRe matches a traceback entry from a
+	// function named Test* in a file named *_test.go. It ignores
+	// "created by" lines.
+	testFromTracebackRe = regexp.MustCompile(`\.(Test[^(\n]+)\(.*\n.*_test\.go`)
+
+	panicWhereRe = regexp.MustCompile(`(?m:^)` + tbEntry)
+)
+
+// testFromTraceback attempts to return the test name from a
+// traceback.
+func testFromTraceback(tb string) string {
+	s := testFromTracebackRe.FindStringSubmatch(tb)
+	if s == nil {
+		return ""
+	}
+	return s[1]
+}
+
+// panicWhere attempts to return the fully qualified name, source
+// file, and line number of the panicking function in traceback tb.
+func panicWhere(tb string) (fn string, file string, line int) {
+	m := matcher{str: tb}
+	for m.consume(panicWhereRe) {
+		fn := m.groups[1]
+
+		// Ignore functions involved in panic handling.
+		if strings.HasPrefix(fn, "runtime.panic") || fn == "runtime.throw" || fn == "runtime.sigpanic" {
+			continue
+		}
+		return fn, m.groups[2], atoi(m.groups[3])
+	}
+	return "", "", 0
+}
diff --git a/cmd/greplogs/internal/logparse/matcher.go b/cmd/greplogs/internal/logparse/matcher.go
new file mode 100644
index 0000000..e162bd8
--- /dev/null
+++ b/cmd/greplogs/internal/logparse/matcher.go
@@ -0,0 +1,129 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package logparse
+
+import (
+	"regexp"
+	"strings"
+)
+
+// A matcher implements incrementally consuming a string using
+// regexps.
+type matcher struct {
+	str    string // string being matched
+	pos    int
+	groups []string // match groups
+
+	// matchPos is the byte position of the beginning of the
+	// match in str.
+	matchPos int
+
+	// literals maps from literal strings to the index of the
+	// next occurrence of that string.
+	literals map[string]int
+}
+
+func newMatcher(str string) *matcher {
+	return &matcher{str: str, literals: map[string]int{}}
+}
+
+func (m *matcher) done() bool {
+	return m.pos >= len(m.str)
+}
+
+// consume searches for r in the remaining text. If found, it consumes
+// up to the end of the match, fills m.groups with the matched groups,
+// and returns true.
+func (m *matcher) consume(r *regexp.Regexp) bool {
+	idx := r.FindStringSubmatchIndex(m.str[m.pos:])
+	if idx == nil {
+		m.groups = m.groups[:0]
+		return false
+	}
+	if len(idx)/2 <= cap(m.groups) {
+		m.groups = m.groups[:len(idx)/2]
+	} else {
+		m.groups = make([]string, len(idx)/2, len(idx))
+	}
+	for i := range m.groups {
+		if idx[i*2] >= 0 {
+			m.groups[i] = m.str[m.pos+idx[i*2] : m.pos+idx[i*2+1]]
+		} else {
+			m.groups[i] = ""
+		}
+	}
+	m.matchPos = m.pos + idx[0]
+	m.pos += idx[1]
+	return true
+}
+
+// peek returns whether r matches the remaining text.
+func (m *matcher) peek(r *regexp.Regexp) bool {
+	return r.MatchString(m.str[m.pos:])
+}
+
+// lineHasLiteral returns whether any of literals is found before the
+// end of the current line.
+func (m *matcher) lineHasLiteral(literals ...string) bool {
+	// Find the position of the next literal.
+	nextLiteral := len(m.str)
+	for _, literal := range literals {
+		next, ok := m.literals[literal]
+
+		if !ok || next < m.pos {
+			// Update the literal position.
+			i := strings.Index(m.str[m.pos:], literal)
+			if i < 0 {
+				next = len(m.str)
+			} else {
+				next = m.pos + i
+			}
+			m.literals[literal] = next
+		}
+
+		if next < nextLiteral {
+			nextLiteral = next
+		}
+	}
+	// If the next literal comes after this line, this line
+	// doesn't have any of literals.
+	if nextLiteral != len(m.str) {
+		eol := strings.Index(m.str[m.pos:], "\n")
+		if eol >= 0 && eol+m.pos < nextLiteral {
+			return false
+		}
+	}
+	return true
+}
+
+// hasPrefix returns whether the remaining text begins with s.
+func (m *matcher) hasPrefix(s string) bool {
+	return strings.HasPrefix(m.str[m.pos:], s)
+}
+
+// line consumes and returns the remainder of the current line, not
+// including the line terminator.
+func (m *matcher) line() string {
+	if i := strings.Index(m.str[m.pos:], "\n"); i >= 0 {
+		line := m.str[m.pos : m.pos+i]
+		m.pos += i + 1
+		return line
+	} else {
+		line := m.str[m.pos:]
+		m.pos = len(m.str)
+		return line
+	}
+}
+
+// peekLine returns the remainder of the current line, not including
+// the line terminator, and the position of the beginning of the next
+// line.
+func (m *matcher) peekLine() (string, int) {
+	if i := strings.Index(m.str[m.pos:], "\n"); i >= 0 {
+		return m.str[m.pos : m.pos+i], m.pos + i + 1
+	} else {
+		return m.str[m.pos:], len(m.str)
+	}
+}
diff --git a/cmd/greplogs/main.go b/cmd/greplogs/main.go
new file mode 100644
index 0000000..3df4261
--- /dev/null
+++ b/cmd/greplogs/main.go
@@ -0,0 +1,343 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Command greplogs searches Go builder logs.
+//
+//	greplogs [flags] (-e regexp|-E regexp) paths...
+//	greplogs [flags] (-e regexp|-E regexp) -dashboard
+//
+// greplogs finds builder logs matching a given set of regular
+// expressions in Go syntax (godoc.org/regexp/syntax) and extracts
+// failures from them.
+//
+// greplogs can search an arbitrary set of files just like grep.
+// Alternatively, the -dashboard flag causes it to search the logs
+// saved locally by fetchlogs (golang.org/x/build/cmd/fetchlogs).
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"regexp"
+	"runtime/debug"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/kballard/go-shellquote"
+	"golang.org/x/build/cmd/greplogs/internal/logparse"
+)
+
+// TODO: If searching dashboard logs, optionally print to builder URLs
+// instead of local file names.
+
+// TODO: Optionally extract failures and show only those.
+
+// TODO: Optionally classify matched logs by failure (and show either
+// file name or extracted failure).
+
+// TODO: Option to print failure summary versus full failure message.
+
+var (
+	fileRegexps regexpList
+	failRegexps regexpList
+	omit        regexpList
+
+	flagDashboard = flag.Bool("dashboard", true, "search dashboard logs from fetchlogs")
+	flagMD        = flag.Bool("md", true, "output in Markdown")
+	flagTriage    = flag.Bool("triage", false, "adjust Markdown output for failure triage")
+	flagDetails   = flag.Bool("details", false, "surround Markdown results in a <details> tag")
+	flagFilesOnly = flag.Bool("l", false, "print only names of matching files")
+	flagColor     = flag.String("color", "auto", "highlight output in color: `mode` is never, always, or auto")
+
+	color         *colorizer
+	since, before timeFlag
+)
+
+const (
+	colorPath      = colorFgMagenta
+	colorPathColon = colorFgCyan
+	colorMatch     = colorBold | colorFgRed
+)
+
+var brokenBuilders []string
+
+func main() {
+	// XXX What I want right now is just to point it at a bunch of
+	// logs and have it extract the failures.
+	flag.Var(&fileRegexps, "e", "show files matching `regexp`; if provided multiple times, files must match all regexps")
+	flag.Var(&failRegexps, "E", "show only errors matching `regexp`; if provided multiple times, an error must match all regexps")
+	flag.Var(&omit, "omit", "omit results for builder names and/or revisions matching `regexp`; if provided multiple times, logs matching any regexp are omitted")
+	flag.Var(&since, "since", "list only failures on revisions since this date, as an RFC-3339 date or date-time")
+	flag.Var(&before, "before", "list only failures on revisions before this date, in the same format as -since")
+	flag.Parse()
+
+	// Validate flags.
+	if *flagDashboard && flag.NArg() > 0 {
+		fmt.Fprintf(os.Stderr, "-dashboard and paths are incompatible\n")
+		os.Exit(2)
+	}
+	switch *flagColor {
+	case "never":
+		color = newColorizer(false)
+	case "always":
+		color = newColorizer(true)
+	case "auto":
+		color = newColorizer(canColor())
+	default:
+		fmt.Fprintf(os.Stderr, "-color must be one of never, always, or auto")
+		os.Exit(2)
+	}
+
+	if *flagTriage {
+		*flagFilesOnly = true
+		if len(failRegexps) == 0 && len(fileRegexps) == 0 {
+			failRegexps.Set(".")
+		}
+
+		if before.Time.IsZero() {
+			year, month, day := time.Now().UTC().Date()
+			before = timeFlag{Time: time.Date(year, month, day, 0, 0, 0, 0, time.UTC)}
+		}
+
+		var err error
+		brokenBuilders, err = listBrokenBuilders()
+		if err != nil {
+			fmt.Fprintln(os.Stderr, err)
+			os.Exit(1)
+		}
+		if len(brokenBuilders) > 0 {
+			fmt.Fprintf(os.Stderr, "omitting builders with known issues:\n\t%s\n\n", strings.Join(brokenBuilders, "\n\t"))
+		}
+	}
+
+	status := 1
+	defer func() { os.Exit(status) }()
+
+	numMatching := 0
+	if *flagMD {
+		args := append([]string{filepath.Base(os.Args[0])}, os.Args[1:]...)
+		fmt.Printf("`%s`\n", shellquote.Join(args...))
+
+		defer func() {
+			if numMatching == 0 || *flagTriage || *flagDetails {
+				fmt.Printf("\n(%d matching logs)\n", numMatching)
+			}
+		}()
+		if *flagDetails {
+			os.Stdout.WriteString("<details>\n\n")
+			defer os.Stdout.WriteString("\n</details>\n")
+		}
+	}
+
+	// Gather paths.
+	var paths []string
+	var stripDir string
+	if *flagDashboard {
+		revDir := filepath.Join(xdgCacheDir(), "fetchlogs", "rev")
+		fis, err := ioutil.ReadDir(revDir)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "%s: %s\n", revDir, err)
+			os.Exit(1)
+		}
+		for _, fi := range fis {
+			if !fi.IsDir() {
+				continue
+			}
+			paths = append(paths, filepath.Join(revDir, fi.Name()))
+		}
+		sort.Sort(sort.Reverse(sort.StringSlice(paths)))
+		stripDir = revDir + "/"
+	} else {
+		paths = flag.Args()
+	}
+
+	// Process files
+	for _, path := range paths {
+		filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				status = 2
+				fmt.Fprintf(os.Stderr, "%s: %v\n", path, err)
+				return nil
+			}
+			if info.IsDir() || strings.HasPrefix(filepath.Base(path), ".") {
+				return nil
+			}
+
+			nicePath := path
+			if stripDir != "" && strings.HasPrefix(path, stripDir) {
+				nicePath = path[len(stripDir):]
+			}
+
+			found, err := process(path, nicePath)
+			if err != nil {
+				status = 2
+				fmt.Fprintf(os.Stderr, "%s: %v\n", path, err)
+			} else if found {
+				numMatching++
+				if status == 1 {
+					status = 0
+				}
+			}
+			return nil
+		})
+	}
+}
+
+var pathDateRE = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})-([0-9a-f]+(?:-[0-9a-f]+)?)$`)
+
+func process(path, nicePath string) (found bool, err error) {
+	// If this is from the dashboard, filter by builder and date and get the builder URL.
+	builder := filepath.Base(nicePath)
+	for _, b := range brokenBuilders {
+		if builder == b {
+			return false, nil
+		}
+	}
+	if omit.AnyMatchString(builder) {
+		return false, nil
+	}
+
+	if !since.Time.IsZero() || !before.Time.IsZero() {
+		revDir := filepath.Dir(nicePath)
+		revDirBase := filepath.Base(revDir)
+		match := pathDateRE.FindStringSubmatch(revDirBase)
+		if len(match) != 3 {
+			// Without a valid log date we can't filter by it.
+			return false, fmt.Errorf("timestamp not found in rev dir name: %q", revDirBase)
+		}
+		if omit.AnyMatchString(match[2]) {
+			return false, nil
+		}
+		revTime, err := time.Parse("2006-01-02T15:04:05", match[1])
+		if err != nil {
+			return false, err
+		}
+		if !since.Time.IsZero() && revTime.Before(since.Time) {
+			return false, nil
+		}
+		if !before.Time.IsZero() && !revTime.Before(before.Time) {
+			return false, nil
+		}
+	}
+
+	// TODO: Get the URL from the rev.json metadata
+	var logURL string
+	if link, err := os.Readlink(path); err == nil {
+		hash := filepath.Base(link)
+		logURL = "https://build.golang.org/log/" + hash
+	}
+
+	// TODO: Use streaming if possible.
+	data, err := ioutil.ReadFile(path)
+	if err != nil {
+		return false, err
+	}
+
+	// Check regexp match.
+	if !fileRegexps.AllMatch(data) || !failRegexps.AllMatch(data) {
+		return false, nil
+	}
+
+	printPath := nicePath
+	if *flagMD && logURL != "" {
+		prefix := ""
+		if *flagTriage {
+			prefix = "- [ ] "
+		}
+		printPath = fmt.Sprintf("%s[%s](%s)", prefix, nicePath, logURL)
+	}
+
+	if *flagFilesOnly {
+		fmt.Printf("%s\n", color.color(printPath, colorPath))
+		return true, nil
+	}
+
+	timer := time.AfterFunc(30*time.Second, func() {
+		debug.SetTraceback("all")
+		panic("stuck in extracting " + path)
+	})
+
+	// Extract failures.
+	failures, err := logparse.Extract(string(data), "", "")
+	if err != nil {
+		return false, err
+	}
+
+	timer.Stop()
+
+	// Print failures.
+	for _, failure := range failures {
+		var msg []byte
+		if failure.FullMessage != "" {
+			msg = []byte(failure.FullMessage)
+		} else {
+			msg = []byte(failure.Message)
+		}
+
+		if len(failRegexps) > 0 && !failRegexps.AllMatch(msg) {
+			continue
+		}
+
+		fmt.Printf("%s%s\n", color.color(printPath, colorPath), color.color(":", colorPathColon))
+		if *flagMD {
+			fmt.Printf("```\n")
+		}
+		if !color.enabled {
+			fmt.Printf("%s", msg)
+		} else {
+			// Find specific matches and highlight them.
+			matches := mergeMatches(append(fileRegexps.Matches(msg),
+				failRegexps.Matches(msg)...))
+			printed := 0
+			for _, m := range matches {
+				fmt.Printf("%s%s", msg[printed:m[0]], color.color(string(msg[m[0]:m[1]]), colorMatch))
+				printed = m[1]
+			}
+			fmt.Printf("%s", msg[printed:])
+		}
+		if *flagMD {
+			fmt.Printf("\n```")
+		}
+		fmt.Printf("\n\n")
+	}
+	return true, nil
+}
+
+func mergeMatches(matches [][]int) [][]int {
+	sort.Slice(matches, func(i, j int) bool { return matches[i][0] < matches[j][0] })
+	for i := 0; i < len(matches); {
+		m := matches[i]
+
+		// Combine with later matches.
+		j := i + 1
+		for ; j < len(matches); j++ {
+			m2 := matches[j]
+			if m[1] <= m2[0] {
+				// Overlapping or exactly adjacent.
+				if m2[1] > m[1] {
+					m[1] = m2[1]
+				}
+				m2[0], m2[1] = 0, 0
+			} else {
+				break
+			}
+		}
+		i = j
+	}
+
+	// Clear out combined matches.
+	j := 0
+	for _, m := range matches {
+		if m[0] == 0 && m[1] == 0 {
+			continue
+		}
+		matches[j] = m
+		j++
+	}
+	return matches[:j]
+}
diff --git a/cmd/greplogs/timeflag.go b/cmd/greplogs/timeflag.go
new file mode 100644
index 0000000..9527bd5
--- /dev/null
+++ b/cmd/greplogs/timeflag.go
@@ -0,0 +1,55 @@
+// Copyright 2021 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"flag"
+	"time"
+)
+
+const (
+	rfc3339Date     = "2006-01-02"
+	rfc3339DateTime = "2006-01-02T15:04:05"
+)
+
+// A timeFlag is a flag.Getter that parses a time.Time
+// from either an RFC-3339 date or an RFC-3339 date and time.
+//
+// Fractional seconds and explicit time zones are not allowed.
+type timeFlag struct {
+	Time time.Time
+}
+
+var _ = flag.Getter((*timeFlag)(nil))
+
+func (tf *timeFlag) Set(s string) error {
+	if s == "" {
+		tf.Time = time.Time{}
+		return nil
+	}
+
+	t, err := time.Parse(rfc3339Date, s)
+	if err != nil {
+		t, err = time.Parse(rfc3339DateTime, s)
+	}
+	if err == nil {
+		tf.Time = t
+	}
+	return err
+}
+
+func (tf *timeFlag) String() string {
+	if tf.Time.IsZero() {
+		return ""
+	}
+	if tf.Time.Hour() == 0 && tf.Time.Minute() == 0 && tf.Time.Second() == 0 {
+		return tf.Time.Format(rfc3339Date)
+	}
+	return tf.Time.Format(rfc3339DateTime)
+}
+
+func (tf *timeFlag) Get() interface{} {
+	return tf.Time
+}
diff --git a/cmd/greplogs/xdg.go b/cmd/greplogs/xdg.go
new file mode 100644
index 0000000..8beb10b
--- /dev/null
+++ b/cmd/greplogs/xdg.go
@@ -0,0 +1,39 @@
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"os"
+	"os/user"
+	"path/filepath"
+	"runtime"
+)
+
+// xdgCacheDir returns the XDG Base Directory Specification cache
+// directory.
+func xdgCacheDir() string {
+	cache := os.Getenv("XDG_CACHE_HOME")
+	if cache == "" {
+		home := os.Getenv("HOME")
+		if home == "" {
+			u, err := user.Current()
+			if err != nil {
+				home = u.HomeDir
+			}
+		}
+		// Not XDG but standard for OS X.
+		if runtime.GOOS == "darwin" {
+			return filepath.Join(home, "Library/Caches")
+		}
+		cache = filepath.Join(home, ".cache")
+	}
+	return cache
+}
+
+// xdgCreateDir creates a directory and its parents in accordance with
+// the XDG Base Directory Specification.
+func xdgCreateDir(path string) error {
+	return os.MkdirAll(path, 0700)
+}
diff --git a/go.mod b/go.mod
index b98cbb1..f7dc190 100644
--- a/go.mod
+++ b/go.mod
@@ -36,6 +36,7 @@
 	github.com/jackc/pgconn v1.11.0
 	github.com/jackc/pgx/v4 v4.13.0
 	github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1
+	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
 	github.com/mattn/go-sqlite3 v1.14.6
 	github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
 	go.opencensus.io v0.23.0
@@ -47,6 +48,7 @@
 	golang.org/x/perf v0.0.0-20220408152633-b570d1a8e7a7
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
 	golang.org/x/sys v0.0.0-20220209214540-3681064d5158
+	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
 	golang.org/x/text v0.3.7
 	golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac
 	google.golang.org/api v0.70.0
diff --git a/go.sum b/go.sum
index 1191746..1e61b13 100644
--- a/go.sum
+++ b/go.sum
@@ -600,6 +600,7 @@
 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
 github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
 github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -1119,8 +1120,9 @@
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=