cmd/test2json2gha/github.go (209 lines of code) (raw):
package main
import (
"bufio"
"errors"
"fmt"
"io"
"iter"
"log/slog"
"math"
"os"
"strconv"
"strings"
)
const (
groupHeader = "::group::"
groupFooter = "::endgroup::\n"
)
// consoleFormatter writes annotations using the github actions console format
// It creates a gruop for each test.
// Note, the console format does not support nested groupings, so subtests are in their own group.
//
// See https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions
type consoleFormatter struct {
modName string
verbose bool
}
func (c *consoleFormatter) FormatResults(results iter.Seq[*TestResult], out io.Writer) error {
var rdrs []io.Reader
for tr := range results {
if tr.name == "" {
continue
}
if !c.verbose && !tr.failed {
// Skip non-failed tests if not verbose
continue
}
pkg := strings.TrimPrefix(tr.pkg, c.modName)
pkg = strings.TrimPrefix(pkg, "/")
group := pkg
if group != "" {
group += "."
}
group += tr.name
hdr := strings.NewReader(groupHeader + group + "\n")
output := tr.Reader()
footer := strings.NewReader(groupFooter)
rdrs = append(rdrs, io.MultiReader(hdr, output, footer))
}
_, err := io.Copy(out, io.MultiReader(rdrs...))
if err != nil {
return fmt.Errorf("failed to write console results: %w", err)
}
return nil
}
type errorAnnotationFormatter struct{}
func (c *errorAnnotationFormatter) FormatResults(results iter.Seq[*TestResult], out io.Writer) error {
var rdrs []io.Reader
for tr := range results {
if !tr.failed || tr.name == "" {
continue
}
rdrs = append(rdrs, asErrorAnnotations(tr))
}
_, err := io.Copy(out, io.MultiReader(rdrs...))
if err != nil {
return fmt.Errorf("failed to write error annotations: %w", err)
}
return nil
}
func asErrorAnnotations(tr *TestResult) io.Reader {
return &errorAnnotationReader{
tr: tr,
}
}
type errorAnnotationReader struct {
tr *TestResult
rdr io.Reader
}
func (a *errorAnnotationReader) Read(p []byte) (n int, err error) {
if a.rdr == nil {
// First get the last file and line number from the output
file, line, err := getLastFileLine(a.tr.Reader())
if err != nil {
return -1, err
}
// Create the header
hdr := strings.NewReader(fmt.Sprintf("::error file=%s,line=%d::", file, line))
footer := strings.NewReader("\n")
// Setup the underlying reader
rdr := bufio.NewReader(filterBuildLogs(a.tr.Reader()))
a.rdr = io.MultiReader(hdr, &urlEncodeNewlineReader{rdr}, footer)
}
return a.rdr.Read(p)
}
type nullReader struct{}
func (n *nullReader) Read(p []byte) (int, error) {
return 0, io.EOF
}
func newSectionReader(rdr io.ReaderAt) *io.SectionReader {
if sr, ok := rdr.(*io.SectionReader); ok {
return io.NewSectionReader(sr, 0, sr.Size())
}
return io.NewSectionReader(rdr, 0, math.MaxInt64)
}
func filterBuildLogs(rdr io.ReaderAt) io.Reader {
var out io.Reader = &nullReader{}
// Create a temporary reader to scan through the content
scanner := bufio.NewScanner(newSectionReader(rdr))
var (
pos int64
)
for scanner.Scan() {
line := scanner.Text()
lineLength := int64(len(line)) + 1 // +1 for the newline character
pos += lineLength
file, _, ok := getTestOutputLoc(line)
if !ok {
continue
}
if !strings.HasSuffix(file, "_test.go") {
continue
}
out = io.MultiReader(out, io.NewSectionReader(rdr, pos-lineLength, lineLength))
}
return out
}
// urlEncodeNewlineReader is a reader that replaces newlines with %0A
// (the url encoded version of a newline) in the output.
// This is used to format the output for GitHub Actions annotations.
// This is a workaround for the fact that GitHub Actions does not support
// newlines in annotations, so we need to encode them.
type urlEncodeNewlineReader struct {
rdr *bufio.Reader
}
func (r *urlEncodeNewlineReader) Read(p []byte) (n int, err error) {
if len(p) == 0 {
return 0, nil
}
peekSize := len(p)
if peekSize > r.rdr.Size() {
peekSize = r.rdr.Size()
}
peeked, err := r.rdr.Peek(peekSize)
if err != nil {
if !errors.Is(err, io.EOF) {
return 0, fmt.Errorf("failed to peek: %w", err)
}
if len(peeked) == 0 {
return 0, io.EOF
}
}
// Pre-allocate output with the best-case size (same as peeked length)
output := make([]byte, 0, len(peeked))
consumed := 0 // Track the number of bytes consumed from peeked
for i := range peeked {
if peeked[i] == '\n' {
output = append(output, '%', '0', 'A')
} else {
output = append(output, peeked[i])
}
consumed++
if len(output) >= len(p) {
break
}
}
// Consume the bytes we processed from the underlying reader
_, err = r.rdr.Discard(consumed)
if err != nil {
return 0, err
}
return copy(p, output), nil
}
func getLastFileLine(rdr io.Reader) (file string, line int, retErr error) {
scanner := bufio.NewScanner(rdr)
defer func() {
if retErr == nil {
return
}
slog.Error("failed to get last file and line", "error", retErr, "scanner data", scanner.Text())
}()
for scanner.Scan() {
txt := scanner.Text()
f, l, ok := getTestOutputLoc(txt)
if !ok {
continue
}
if !strings.HasSuffix(f, "_test.go") {
continue
}
file = f
ll, err := strconv.Atoi(l)
if err != nil {
slog.Error("failed to parse line number", "line", l, "err", err)
continue
}
line = ll
}
return file, line, scanner.Err()
}
func getTestOutputLoc(s string) (file string, line string, match bool) {
file, extra, ok := strings.Cut(s, ":")
if !ok {
return "", "", false
}
if !strings.HasPrefix(file, " ") {
// There should be whitespace before the file name
return "", "", false
}
file = strings.TrimSpace(file)
line, _, ok = strings.Cut(extra, ":")
if !ok {
return "", "", false
}
return file, line, true
}
func getSummaryFile() io.WriteCloser {
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary
v := os.Getenv("GITHUB_STEP_SUMMARY")
if v == "" {
return &nopWriteCloser{io.Discard}
}
f, err := os.OpenFile(v, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
slog.Error("Error opening step summary file", "error", err)
return &nopWriteCloser{io.Discard}
}
return f
}