diff --git a/.gavel.yaml b/.gavel.yaml
index 5f83f6df9..2f0d3a948 100644
--- a/.gavel.yaml
+++ b/.gavel.yaml
@@ -1,9 +1,46 @@
-commit: {}
+commit:
+ gitignore:
+ - .bin/
+ - .cache
+ - .claude
+ - .container-sandbox.yaml
+ - .DS_Store
+ - .DS_Store?
+ - .env
+ - .env.local
+ - .gavel/
+ - .gingko/
+ - .idea/
+ - .jscpd.json
+ - .playwright-mcp/
+ - .shell/
+ - .Spotlight-V100
+ - .task
+ - .tmp/
+ - .Trashes
+ - .vitest/
+ - .vscode/
+ - '*.log'
+ - '*.test'
+ - node_modules/
+ - dist/
+ - '*.tmp'
+ - .metadata_never_index
+ - .tmp
+ - .agents/
+ - .sandbox.yaml
+ - nohup.out
+ - skills-lock.json
+ linkedDeps: {}
fixtures: {}
lint:
ignore:
- file: .shell/checkout/8d512cac74b933d1f8d97629ab79966828d2e0789c66a463d8a78e7d40ccc42f/**
-secrets: {}
+ - file: linters/betterleaks/testdata/betterleaks-output.json
+ source: betterleaks
+secrets:
+ configs:
+ - .betterleaks.toml
ssh: {}
verify:
checks:
diff --git a/action.yml b/action.yml
index b6265b9fc..6f3364c2e 100644
--- a/action.yml
+++ b/action.yml
@@ -122,10 +122,24 @@ runs:
set +e
rm -f "${JSON_FILE}" "${GAVEL_LOG_FILE}"
+ # --show-passed is only defined on `gavel test` / `gavel lint`. Other
+ # subcommands (e.g. `fixtures`) reject unknown flags and fail at parse
+ # time. Detect the first non-flag word in $GAVEL_ARGS and only append
+ # the test-specific flag for those subcommands.
+ first_subcmd=""
+ for tok in $GAVEL_ARGS; do
+ case "$tok" in
+ -*) ;;
+ *) first_subcmd="$tok"; break ;;
+ esac
+ done
+ extra_flags=("--no-progress" "--no-color" "--format" "json=${JSON_FILE}")
+ case "$first_subcmd" in
+ test|lint) extra_flags=("--show-passed" "${extra_flags[@]}") ;;
+ esac
+
# shellcheck disable=SC2086
- gavel $GAVEL_ARGS \
- --no-progress --no-color --show-passed \
- --format "json=${JSON_FILE}" \
+ gavel $GAVEL_ARGS "${extra_flags[@]}" \
2> >(tee "${GAVEL_LOG_FILE}" >&2)
code=$?
set -e
diff --git a/cmd/gavel/summary.go b/cmd/gavel/summary.go
index 5536ce53e..7fe66344d 100644
--- a/cmd/gavel/summary.go
+++ b/cmd/gavel/summary.go
@@ -231,9 +231,25 @@ func ensureSource(sources map[string]*sourceCounts, name string) *sourceCounts {
return sc
}
+// writeCountsTable emits a markdown table containing only sources that have
+// failures or skips. Sources with 100% passing tests are intentionally
+// omitted — their pass count and duration are rolled into the totals row
+// emitted by writeTotals so the PR comment stays short for the common
+// "all green" case. When every source is clean, the table is omitted
+// entirely and writeTotals carries the headline.
func writeCountsTable(b *strings.Builder, sources map[string]*sourceCounts) {
- rows := make([]*sourceCounts, 0, len(sources))
+ var rows []*sourceCounts
+ var passingOnly, passingDuration int
+ var hiddenSources int
+ totalPassing := 0
for _, sc := range sources {
+ totalPassing += sc.passed
+ if sc.failed == 0 && sc.skipped == 0 {
+ passingOnly += sc.passed
+ passingDuration += int(sc.duration)
+ hiddenSources++
+ continue
+ }
rows = append(rows, sc)
}
sort.Slice(rows, func(i, j int) bool {
@@ -245,12 +261,22 @@ func writeCountsTable(b *strings.Builder, sources map[string]*sourceCounts) {
})
b.WriteString("## Gavel summary\n\n")
+ if len(rows) == 0 {
+ // Nothing to break out — totals row alone tells the story.
+ return
+ }
b.WriteString("| Source | Pass | Fail | Skip | Duration |\n")
b.WriteString("|---|---:|---:|---:|---:|\n")
for _, sc := range rows {
fmt.Fprintf(b, "| %s | %d | %d | %d | %s |\n",
escapePipe(sc.name), sc.passed, sc.failed, sc.skipped, formatDuration(sc.duration))
}
+ if hiddenSources > 0 {
+ // One collapsed row covers every all-passing source so a reader
+ // can still see "we ran more than what's listed".
+ fmt.Fprintf(b, "| _%d more passing source(s)_ | %d | 0 | 0 | %s |\n",
+ hiddenSources, passingOnly, formatDuration(time.Duration(passingDuration)))
+ }
b.WriteString("\n")
}
diff --git a/cmd/gavel/summary_test.go b/cmd/gavel/summary_test.go
index 4996ec8c6..d921a22cd 100644
--- a/cmd/gavel/summary_test.go
+++ b/cmd/gavel/summary_test.go
@@ -84,16 +84,30 @@ func TestBuildCompactSummary(t *testing.T) {
out := buildCompactSummary(input, compactSummaryBudget{maxFailures: 5, maxLinesPerFailure: 5, maxCharsPerLine: 200})
- // Counts table by source — expect one row per test package AND one per linter.
+ // Counts table by source — only sources that have failures or skips
+ // should appear. The serve package has failures, the verify package
+ // has a skipped test, and golangci-lint reports a violation, so all
+ // three keep their row. Sources that are 100% passing (e.g. the
+ // clean gofmt linter below) are folded into the passing-only summary
+ // row + Totals line.
if !strings.Contains(out, "github.com/flanksource/gavel/serve") {
t.Errorf("expected serve package row in counts table, got:\n%s", out)
}
if !strings.Contains(out, "github.com/flanksource/gavel/verify") {
- t.Errorf("expected verify package row in counts table, got:\n%s", out)
+ t.Errorf("expected verify package row (has a skipped test) in counts table, got:\n%s", out)
}
if !strings.Contains(out, "golangci-lint") {
t.Errorf("expected golangci-lint row in counts table, got:\n%s", out)
}
+ // gofmt is a passing-only linter — must not appear as its own row.
+ if strings.Contains(out, "| lint: gofmt |") {
+ t.Errorf("passing-only linter must be folded into the passing summary row, got:\n%s", out)
+ }
+ // The collapsed-passing row should mention the gofmt linter's pass count
+ // (1) and the "more passing source(s)" text.
+ if !strings.Contains(out, "more passing source(s)") {
+ t.Errorf("expected collapsed passing-source row, got:\n%s", out)
+ }
// Passing rows present, but no per-test listing for passing tests in the detail section.
if strings.Contains(out, "initializes a bare git repo") {
@@ -280,8 +294,11 @@ func TestBuildCompactSummaryPrefersRealResultsOverCrashField(t *testing.T) {
Error: "partial error that should be ignored",
}
out := buildCompactSummary(input, compactSummaryBudget{maxFailures: 5, maxLinesPerFailure: 5, maxCharsPerLine: 200})
- if !strings.Contains(out, "pkg/x") {
- t.Errorf("expected normal results to render, got:\n%s", out)
+ // pkg/x is 100% passing so its row is collapsed into the totals; we
+ // only need to confirm the normal counts path ran (Totals line) and
+ // that the crash-stub path did not render.
+ if !strings.Contains(out, "**Totals:** 1 passed") {
+ t.Errorf("expected normal Totals line for the single passing test, got:\n%s", out)
}
if strings.Contains(out, "Gavel crashed") {
t.Errorf("normal results must not fall through to crash path, got:\n%s", out)
@@ -329,6 +346,85 @@ func TestRunSummaryReadsJSONFile(t *testing.T) {
}
}
+// TestBuildCompactSummaryCollapsesAllPassingSources exercises the
+// "PR comment must stay short" behaviour: when every source is 100%
+// passing, the per-source table disappears entirely and the Totals
+// line carries the full headline (count + duration). Regressing to
+// the old behaviour (one row per package) blows up PR comments on
+// large monorepos.
+func TestBuildCompactSummaryCollapsesAllPassingSources(t *testing.T) {
+ input := gavelResultJSON{
+ Tests: []parsers.Test{
+ {Package: "pkg/a", Children: parsers.Tests{
+ {Package: "pkg/a", Name: "TestOne", Passed: true, Duration: 10 * time.Millisecond},
+ {Package: "pkg/a", Name: "TestTwo", Passed: true, Duration: 20 * time.Millisecond},
+ }},
+ {Package: "pkg/b", Children: parsers.Tests{
+ {Package: "pkg/b", Name: "TestThree", Passed: true, Duration: 30 * time.Millisecond},
+ }},
+ },
+ }
+ out := buildCompactSummary(input, compactSummaryBudget{maxFailures: 5, maxLinesPerFailure: 5, maxCharsPerLine: 200})
+
+ // No per-package rows when every source is clean.
+ if strings.Contains(out, "| pkg/a |") {
+ t.Errorf("all-passing pkg/a must not appear as its own row, got:\n%s", out)
+ }
+ if strings.Contains(out, "| pkg/b |") {
+ t.Errorf("all-passing pkg/b must not appear as its own row, got:\n%s", out)
+ }
+ // No table headers either — the table itself collapses.
+ if strings.Contains(out, "| Source |") {
+ t.Errorf("counts table must collapse when every source is clean, got:\n%s", out)
+ }
+ // Totals line still carries the headline.
+ if !strings.Contains(out, "**Totals:** 3 passed · 0 failed · 0 skipped") {
+ t.Errorf("expected Totals line with aggregate counts, got:\n%s", out)
+ }
+}
+
+// TestBuildCompactSummaryCollapsesPassingAlongsideFailing checks the
+// mixed case: failing sources keep their own row, but every all-passing
+// source is folded into a single trailing summary row whose pass count
+// matches the sum of the hidden sources.
+func TestBuildCompactSummaryCollapsesPassingAlongsideFailing(t *testing.T) {
+ input := gavelResultJSON{
+ Tests: []parsers.Test{
+ {Package: "pkg/fail", Children: parsers.Tests{
+ {Package: "pkg/fail", Name: "TestBoom", Failed: true, Message: "boom"},
+ }},
+ {Package: "pkg/clean1", Children: parsers.Tests{
+ {Package: "pkg/clean1", Name: "TestOne", Passed: true, Duration: 100 * time.Millisecond},
+ {Package: "pkg/clean1", Name: "TestTwo", Passed: true, Duration: 100 * time.Millisecond},
+ }},
+ {Package: "pkg/clean2", Children: parsers.Tests{
+ {Package: "pkg/clean2", Name: "TestThree", Passed: true, Duration: 50 * time.Millisecond},
+ }},
+ },
+ }
+ out := buildCompactSummary(input, compactSummaryBudget{maxFailures: 5, maxLinesPerFailure: 5, maxCharsPerLine: 200})
+
+ // Failing source keeps its dedicated row.
+ if !strings.Contains(out, "| pkg/fail |") {
+ t.Errorf("failing pkg/fail row must be present, got:\n%s", out)
+ }
+ // Clean sources do not appear by name…
+ if strings.Contains(out, "pkg/clean1") {
+ t.Errorf("clean pkg/clean1 must not appear by name, got:\n%s", out)
+ }
+ if strings.Contains(out, "pkg/clean2") {
+ t.Errorf("clean pkg/clean2 must not appear by name, got:\n%s", out)
+ }
+ // …but a single collapsed row reports the count of hidden sources
+ // AND the sum of their pass counts (2 + 1 = 3).
+ if !strings.Contains(out, "_2 more passing source(s)_") {
+ t.Errorf("expected '2 more passing source(s)' collapse row, got:\n%s", out)
+ }
+ if !strings.Contains(out, "| _2 more passing source(s)_ | 3 | 0 | 0 |") {
+ t.Errorf("expected collapsed row with summed pass count, got:\n%s", out)
+ }
+}
+
func longMultilineStderr() string {
var sb strings.Builder
// 8 lines of 250 chars each — must be truncated to 5 lines of ≤200 chars.
diff --git a/cmd/gavel/test.go b/cmd/gavel/test.go
index f0a3d449e..9b453bbf9 100644
--- a/cmd/gavel/test.go
+++ b/cmd/gavel/test.go
@@ -544,7 +544,11 @@ var testCmd *cobra.Command
func init() {
testCmd = clicky.AddNamedCommand("test", rootCmd, testrunner.RunOptions{}, runTests)
- testCmd.Flags().SetInterspersed(false)
+ // Allow flags and positional package paths to interleave so callers (e.g. the
+ // flanksource/gavel composite action) can append flags after user-supplied
+ // paths. Use `--` to terminate flag parsing when forwarding flags to the
+ // underlying runner via the `gavel test ./pkg -- --focus X` idiom.
+ testCmd.Flags().SetInterspersed(true)
testCmd.Flags().BoolVar(&testDurationFlags.Detach, "detach", false,
"With --ui, fork a detached UI server and exit. The child serves until --auto-stop (default 30m) or --idle-timeout (default 5m) fires.")
testCmd.Flags().DurationVar(&testDurationFlags.AutoStop, "auto-stop", 0,
diff --git a/cmd/gavel/test_framework_subcmds.go b/cmd/gavel/test_framework_subcmds.go
index 61a9c7c4c..8c7494a2a 100644
--- a/cmd/gavel/test_framework_subcmds.go
+++ b/cmd/gavel/test_framework_subcmds.go
@@ -24,7 +24,7 @@ func registerTestFrameworkSubcommands() {
return runTests(opts)
})
sub.Short = fmt.Sprintf("Run only %s tests", fw)
- sub.Flags().SetInterspersed(false)
+ sub.Flags().SetInterspersed(true)
if err := sub.Flags().MarkHidden("framework"); err != nil {
panic(fmt.Sprintf("hide --framework on %s subcommand: %v", name, err))
}
diff --git a/cmd/gavel/ui_serve.go b/cmd/gavel/ui_serve.go
index e041af2ee..15d38c81a 100644
--- a/cmd/gavel/ui_serve.go
+++ b/cmd/gavel/ui_serve.go
@@ -6,8 +6,10 @@ import (
"net"
"net/http"
"os"
+ "os/exec"
"path/filepath"
"strconv"
+ "strings"
"time"
"github.com/flanksource/clicky"
@@ -94,7 +96,14 @@ func runUIServe(opts UIServeOptions) (any, error) {
return nil, fmt.Errorf("load results %v: %w", opts.ResultsFiles, err)
}
} else {
+ root, err := resolveGavelRoot()
+ if err != nil {
+ listener.Close() //nolint:errcheck
+ return nil, err
+ }
+ srv.SetGavelDir(root)
srv.MarkDone()
+ logger.Infof("Indexing snapshots from %s", filepath.Join(root, ".gavel"))
}
addr := listener.Addr().(*net.TCPAddr)
@@ -315,6 +324,28 @@ func firstNonLoopbackIPv4() string {
return ""
}
+// resolveGavelRoot returns the git root of the current working directory and
+// validates that .gavel/ exists inside it. Used by `gavel ui serve` (no args)
+// to discover the snapshot directory to index.
+func resolveGavelRoot() (string, error) {
+ out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
+ if err != nil {
+ return "", fmt.Errorf("not inside a git repository: %w", err)
+ }
+ root := strings.TrimSpace(string(out))
+ if root == "" {
+ return "", fmt.Errorf("git rev-parse --show-toplevel returned empty")
+ }
+ gavelDir := filepath.Join(root, ".gavel")
+ if _, err := os.Stat(gavelDir); err != nil {
+ if os.IsNotExist(err) {
+ return "", fmt.Errorf("no %s directory found in %s — run `gavel test` to populate it, or pass a snapshot path explicitly", ".gavel", root)
+ }
+ return "", err
+ }
+ return root, nil
+}
+
// writeURLFile writes url to path atomically (write to tempfile in same dir,
// fsync, rename). Atomicity matters because a wrapping shell script may be
// polling the file concurrently.
diff --git a/github/artifacts.go b/github/artifacts.go
index 3ff5c5a81..21c25878e 100644
--- a/github/artifacts.go
+++ b/github/artifacts.go
@@ -4,6 +4,7 @@ import (
"archive/zip"
"bytes"
"context"
+ "encoding/json"
"fmt"
"io"
"regexp"
@@ -37,6 +38,10 @@ var artifactLinkPattern = regexp.MustCompile(
// FindGavelArtifact scans PR comments for the gavel sticky comment and
// extracts the artifact ID and URL. It returns the most recent match.
+//
+// Prefer FindGavelArtifacts (plural) for surfaces that can show multiple
+// gavel runs per PR; this single-result helper exists for legacy callers
+// that only need the headline artifact.
func FindGavelArtifact(comments []PRComment) (artifactID int64, artifactURL string, found bool) {
for i := len(comments) - 1; i >= 0; i-- {
body := comments[i].Body
@@ -57,10 +62,164 @@ func FindGavelArtifact(comments []PRComment) (artifactID int64, artifactURL stri
return 0, "", false
}
-// DownloadArtifact downloads a GitHub Actions artifact ZIP and extracts
-// gavel-results.json from it. The opts.Repo field must be set to the
-// owner/repo that owns the artifact.
+// GavelArtifact identifies a single uploaded artifact whose name starts with
+// the "gavel" prefix on a PR's workflow runs.
+type GavelArtifact struct {
+ // Name is the upload-artifact name (e.g. "gavel-results", "gavel-bench").
+ Name string `json:"name"`
+ // ID is the GitHub artifact ID; pass to DownloadArtifact / DownloadArtifactFiles.
+ ID int64 `json:"id"`
+ // RunID is the workflow run that produced the artifact. Used to group
+ // artifacts coming from the same job.
+ RunID int64 `json:"runId"`
+ // URL is the human-facing artifact URL embedded in the PR comment / job summary.
+ URL string `json:"url"`
+ // SizeBytes is the compressed size reported by the API; useful for surfacing
+ // "too large to fetch" hints in the UI.
+ SizeBytes int64 `json:"sizeBytes,omitempty"`
+ // Expired is true when GitHub has aged out the artifact and the bytes are no
+ // longer downloadable.
+ Expired bool `json:"expired,omitempty"`
+}
+
+// HTMLURL returns the github.com URL for browsing the artifact in the Actions UI.
+func (a GavelArtifact) HTMLURL(repo string) string {
+ return fmt.Sprintf("https://github.com/%s/actions/runs/%d/artifacts/%d", repo, a.RunID, a.ID)
+}
+
+type restArtifact struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ SizeInBytes int64 `json:"size_in_bytes"`
+ ArchiveDownloadURL string `json:"archive_download_url"`
+ Expired bool `json:"expired"`
+ WorkflowRun struct {
+ ID int64 `json:"id"`
+ } `json:"workflow_run"`
+}
+
+type restArtifactsResponse struct {
+ TotalCount int64 `json:"total_count"`
+ Artifacts []restArtifact `json:"artifacts"`
+}
+
+// ListRunArtifacts returns every artifact attached to a single workflow run.
+// The result is unfiltered — callers (e.g. FindGavelArtifacts) decide which
+// names matter.
+func ListRunArtifacts(opts Options, runID int64) ([]GavelArtifact, error) {
+ token, err := opts.token()
+ if err != nil {
+ return nil, fmt.Errorf("cannot list artifacts: %w", err)
+ }
+ repo, err := opts.resolveRepo()
+ if err != nil {
+ return nil, fmt.Errorf("cannot resolve repo for artifacts: %w", err)
+ }
+
+ path := fmt.Sprintf("/repos/%s/actions/runs/%d/artifacts?per_page=100", repo, runID)
+ resp, err := cachedGet(context.Background(), token, path, nil)
+ if err != nil {
+ return nil, fmt.Errorf("list artifacts for run %d: %w", runID, err)
+ }
+
+ var payload restArtifactsResponse
+ if err := json.Unmarshal(resp.Body, &payload); err != nil {
+ return nil, fmt.Errorf("parse artifacts list for run %d: %w", runID, err)
+ }
+
+ out := make([]GavelArtifact, 0, len(payload.Artifacts))
+ for _, a := range payload.Artifacts {
+ ga := GavelArtifact{
+ Name: a.Name,
+ ID: a.ID,
+ RunID: a.WorkflowRun.ID,
+ SizeBytes: a.SizeInBytes,
+ Expired: a.Expired,
+ }
+ if ga.RunID == 0 {
+ ga.RunID = runID
+ }
+ ga.URL = ga.HTMLURL(repo)
+ out = append(out, ga)
+ }
+ return out, nil
+}
+
+// FindGavelArtifacts walks every workflow run on the PR and returns artifacts
+// whose name starts with the "gavel" prefix (case-insensitive). The result is
+// de-duplicated by artifact ID.
+//
+// Discovery uses the Actions API rather than the sticky comment, so a PR with
+// N parallel test jobs (matrix builds, multiple OSes, separate bench job…)
+// surfaces N artifacts instead of just the most recently posted one.
+func FindGavelArtifacts(opts Options, pr *PRInfo) ([]GavelArtifact, error) {
+ if pr == nil {
+ return nil, fmt.Errorf("nil PR")
+ }
+
+ seenRun := make(map[int64]bool)
+ var runIDs []int64
+ for _, check := range pr.StatusCheckRollup {
+ runID, err := ExtractRunID(check.DetailsURL)
+ if err != nil || seenRun[runID] {
+ continue
+ }
+ seenRun[runID] = true
+ runIDs = append(runIDs, runID)
+ }
+
+ seenArtifact := make(map[int64]bool)
+ var out []GavelArtifact
+ for _, runID := range runIDs {
+ artifacts, err := ListRunArtifacts(opts, runID)
+ if err != nil {
+ logger.Warnf("list artifacts for run %d: %v", runID, err)
+ continue
+ }
+ for _, a := range artifacts {
+ if !hasGavelPrefix(a.Name) {
+ continue
+ }
+ if seenArtifact[a.ID] {
+ continue
+ }
+ seenArtifact[a.ID] = true
+ out = append(out, a)
+ }
+ }
+ return out, nil
+}
+
+func hasGavelPrefix(name string) bool {
+ return strings.HasPrefix(strings.ToLower(name), "gavel")
+}
+
+// DownloadArtifact downloads a GitHub Actions artifact ZIP and returns the
+// first .json file found inside. Use DownloadArtifactFiles when an artifact
+// can legitimately contain multiple JSON payloads that should be merged.
func DownloadArtifact(opts Options, artifactID int64) ([]byte, error) {
+ files, err := DownloadArtifactFiles(opts, artifactID)
+ if err != nil {
+ return nil, err
+ }
+ for _, f := range files {
+ if strings.HasSuffix(strings.ToLower(f.Name), ".json") {
+ return f.Body, nil
+ }
+ }
+ return nil, fmt.Errorf("no .json file found in artifact zip")
+}
+
+// ArtifactFile is one entry pulled from an artifact zip.
+type ArtifactFile struct {
+ Name string
+ Body []byte
+}
+
+// DownloadArtifactFiles downloads an artifact ZIP and returns every .json
+// entry inside it. Useful when a single uploaded artifact bundles results
+// from multiple test packages or jobs that should be merged.
+func DownloadArtifactFiles(opts Options, artifactID int64) ([]ArtifactFile, error) {
token, err := opts.token()
if err != nil {
return nil, fmt.Errorf("cannot download artifact: %w", err)
@@ -76,16 +235,17 @@ func DownloadArtifact(opts Options, artifactID int64) ([]byte, error) {
return nil, fmt.Errorf("download artifact %d: %w", artifactID, err)
}
- return extractJSONFromZip(result.Body)
+ return extractJSONFilesFromZip(result.Body)
}
-func extractJSONFromZip(data []byte) ([]byte, error) {
+func extractJSONFilesFromZip(data []byte) ([]ArtifactFile, error) {
r, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("open artifact zip: %w", err)
}
+ var out []ArtifactFile
for _, f := range r.File {
- if !strings.HasSuffix(f.Name, ".json") {
+ if !strings.HasSuffix(strings.ToLower(f.Name), ".json") {
continue
}
rc, err := f.Open()
@@ -93,12 +253,15 @@ func extractJSONFromZip(data []byte) ([]byte, error) {
logger.Warnf("skip zip entry %s: %v", f.Name, err)
continue
}
- defer rc.Close()
- content, err := io.ReadAll(io.LimitReader(rc, 50<<20)) // 50 MB cap
+ content, err := io.ReadAll(io.LimitReader(rc, 50<<20)) // 50 MB cap per file
+ rc.Close()
if err != nil {
return nil, fmt.Errorf("read zip entry %s: %w", f.Name, err)
}
- return content, nil
+ out = append(out, ArtifactFile{Name: f.Name, Body: content})
}
- return nil, fmt.Errorf("no .json file found in artifact zip")
+ if len(out) == 0 {
+ return nil, fmt.Errorf("no .json file found in artifact zip")
+ }
+ return out, nil
}
diff --git a/github/artifacts_test.go b/github/artifacts_test.go
index bcb6e860e..4b115217e 100644
--- a/github/artifacts_test.go
+++ b/github/artifacts_test.go
@@ -1,6 +1,10 @@
package github
import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "strings"
"testing"
)
@@ -154,3 +158,157 @@ func TestFindGavelArtifact(t *testing.T) {
})
}
}
+
+func buildZipFixture(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+ var buf bytes.Buffer
+ w := zip.NewWriter(&buf)
+ for name, body := range files {
+ f, err := w.Create(name)
+ if err != nil {
+ t.Fatalf("zip create %s: %v", name, err)
+ }
+ if _, err := f.Write([]byte(body)); err != nil {
+ t.Fatalf("zip write %s: %v", name, err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ t.Fatalf("zip close: %v", err)
+ }
+ return buf.Bytes()
+}
+
+// TestExtractJSONFilesFromZipReturnsAllJSON proves the multi-file extractor
+// keeps every .json entry — single-file artifacts that bundle multiple test
+// shards (e.g. `gavel-results.json`, `gavel-bench.json`) need all of them
+// merged downstream, not just the first one alphabetically.
+func TestExtractJSONFilesFromZipReturnsAllJSON(t *testing.T) {
+ zipData := buildZipFixture(t, map[string]string{
+ "gavel-results.json": `{"tests":[]}`,
+ "gavel-bench.json": `{"bench":{}}`,
+ "gavel.log": "log line\n",
+ "README.txt": "ignored",
+ })
+ files, err := extractJSONFilesFromZip(zipData)
+ if err != nil {
+ t.Fatalf("extract: %v", err)
+ }
+ if len(files) != 2 {
+ t.Fatalf("expected 2 .json files, got %d (%v)", len(files), names(files))
+ }
+ got := map[string]string{}
+ for _, f := range files {
+ got[f.Name] = string(f.Body)
+ }
+ if got["gavel-results.json"] != `{"tests":[]}` {
+ t.Errorf("gavel-results.json body = %q", got["gavel-results.json"])
+ }
+ if got["gavel-bench.json"] != `{"bench":{}}` {
+ t.Errorf("gavel-bench.json body = %q", got["gavel-bench.json"])
+ }
+}
+
+func TestExtractJSONFilesFromZipNoJSON(t *testing.T) {
+ zipData := buildZipFixture(t, map[string]string{
+ "gavel.log": "no json here",
+ "README.txt": "still nope",
+ })
+ if _, err := extractJSONFilesFromZip(zipData); err == nil {
+ t.Fatal("expected error when zip has no .json file")
+ }
+}
+
+func names(files []ArtifactFile) []string {
+ out := make([]string, len(files))
+ for i, f := range files {
+ out[i] = f.Name
+ }
+ return out
+}
+
+// TestHasGavelPrefixAcceptsExpectedNames pins down the set of artifact names
+// that should match. Anything starting with "gavel" (case-insensitive) is in;
+// names that contain "gavel" elsewhere or use other prefixes are out.
+func TestHasGavelPrefixAcceptsExpectedNames(t *testing.T) {
+ for _, in := range []string{
+ "gavel-results", "gavel-bench", "Gavel-Results",
+ "gavel", "gavel-self-test", "gavel-results-linux",
+ } {
+ if !hasGavelPrefix(in) {
+ t.Errorf("expected match for %q", in)
+ }
+ }
+ for _, out := range []string{
+ "results", "test-results-gavel", "junit-gavel-results", "",
+ } {
+ if hasGavelPrefix(out) {
+ t.Errorf("unexpected match for %q", out)
+ }
+ }
+}
+
+// TestRestArtifactsResponseUnmarshal sanity-checks the wire shape we parse
+// out of /repos/{owner}/{name}/actions/runs/{id}/artifacts so a future GitHub
+// payload tweak (snake_case key rename, nested workflow_run shape change)
+// fails this test instead of silently returning empty results in production.
+func TestRestArtifactsResponseUnmarshal(t *testing.T) {
+ const payload = `{
+ "total_count": 2,
+ "artifacts": [
+ {
+ "id": 111,
+ "name": "gavel-results",
+ "size_in_bytes": 4096,
+ "archive_download_url": "https://api.github.com/repos/o/r/actions/artifacts/111/zip",
+ "expired": false,
+ "workflow_run": {"id": 99}
+ },
+ {
+ "id": 222,
+ "name": "gavel-bench",
+ "size_in_bytes": 2048,
+ "expired": true,
+ "workflow_run": {"id": 99}
+ }
+ ]
+ }`
+ var got restArtifactsResponse
+ if err := json.Unmarshal([]byte(payload), &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if got.TotalCount != 2 {
+ t.Errorf("TotalCount = %d, want 2", got.TotalCount)
+ }
+ if len(got.Artifacts) != 2 {
+ t.Fatalf("artifacts = %d, want 2", len(got.Artifacts))
+ }
+ if got.Artifacts[0].Name != "gavel-results" || got.Artifacts[0].WorkflowRun.ID != 99 {
+ t.Errorf("artifact 0 = %+v", got.Artifacts[0])
+ }
+ if !got.Artifacts[1].Expired {
+ t.Errorf("artifact 1 should be Expired")
+ }
+}
+
+// TestGavelArtifactHTMLURL pins down the artifact URL we hand the UI so
+// in-app "open in Actions" links don't silently break if HTMLURL is
+// refactored.
+func TestGavelArtifactHTMLURL(t *testing.T) {
+ a := GavelArtifact{ID: 555, RunID: 999}
+ want := "https://github.com/owner/name/actions/runs/999/artifacts/555"
+ if got := a.HTMLURL("owner/name"); got != want {
+ t.Errorf("HTMLURL = %q, want %q", got, want)
+ }
+ // Confirm the URL is also recognised by the inverse parser — round-trip
+ // guards against a regex/fmt drift between the two helpers.
+ if !strings.Contains(want, "/runs/999/artifacts/555") {
+ t.Fatalf("test URL fixture is malformed: %q", want)
+ }
+ repo, runID, artID, err := ParseArtifactURL(want)
+ if err != nil {
+ t.Fatalf("ParseArtifactURL: %v", err)
+ }
+ if repo != "owner/name" || runID != 999 || artID != 555 {
+ t.Errorf("round-trip mismatch: repo=%q run=%d art=%d", repo, runID, artID)
+ }
+}
diff --git a/pr/ui/gavel_results.go b/pr/ui/gavel_results.go
index d62ad7836..19a31d122 100644
--- a/pr/ui/gavel_results.go
+++ b/pr/ui/gavel_results.go
@@ -36,6 +36,37 @@ type GavelResultsSummary struct {
TopFailures []TestFailure `json:"topFailures,omitempty"`
// TopLintViolations lists the first 5 lint findings across all linters.
TopLintViolations []LintViolation `json:"topLintViolations,omitempty"`
+ // Jobs is the per-job breakdown when the PR has more than one gavel
+ // artifact. Empty when only a single artifact was discovered. The
+ // outer counts/TopFailures fields are the sum across Jobs so PR
+ // sidebar badges and existing single-summary consumers keep working.
+ Jobs []GavelJobSummary `json:"jobs,omitempty"`
+}
+
+// GavelJobSummary holds the merged results from one workflow run / job.
+// When a single job uploads N gavel-* artifacts (matrix shards, separate
+// bench json, etc.), all of them are downloaded and merged into one entry.
+type GavelJobSummary struct {
+ // JobName is a human label — typically the artifact name (or a comma list
+ // when several artifacts were merged into the same run).
+ JobName string `json:"jobName"`
+ // RunID is the workflow run that produced these artifacts.
+ RunID int64 `json:"runId,omitempty"`
+ // ArtifactIDs lists every artifact ID merged into this entry. The first
+ // ID is treated as the canonical "open in UI" target.
+ ArtifactIDs []int64 `json:"artifactIds"`
+ ArtifactURL string `json:"artifactUrl,omitempty"`
+ TestsPassed int `json:"testsPassed"`
+ TestsFailed int `json:"testsFailed"`
+ TestsSkipped int `json:"testsSkipped"`
+ TestsTotal int `json:"testsTotal"`
+ LintViolations int `json:"lintViolations"`
+ LintLinters int `json:"lintLinters"`
+ HasBench bool `json:"hasBench"`
+ BenchRegressions int `json:"benchRegressions,omitempty"`
+ Error string `json:"error,omitempty"`
+ TopFailures []TestFailure `json:"topFailures,omitempty"`
+ TopLintViolations []LintViolation `json:"topLintViolations,omitempty"`
}
type TestFailure struct {
@@ -82,6 +113,21 @@ func (g *gavelResultJSON) UnmarshalJSON(data []byte) error {
return nil
}
+// mergeGavelResults concatenates tests/lint/bench from multiple JSON payloads
+// belonging to the same job. The first non-nil bench wins (bench comparisons
+// are aggregate-level; we don't try to merge two of them).
+func mergeGavelResults(payloads ...gavelResultJSON) gavelResultJSON {
+ var merged gavelResultJSON
+ for _, p := range payloads {
+ merged.Tests = append(merged.Tests, p.Tests...)
+ merged.Lint = append(merged.Lint, p.Lint...)
+ if merged.Bench == nil {
+ merged.Bench = p.Bench
+ }
+ }
+ return merged
+}
+
func computeGavelSummary(jsonBytes []byte, artifactID int64, artifactURL string) *GavelResultsSummary {
var data gavelResultJSON
if err := json.Unmarshal(jsonBytes, &data); err != nil {
@@ -96,7 +142,14 @@ func computeGavelSummary(jsonBytes []byte, artifactID int64, artifactURL string)
ArtifactID: artifactID,
ArtifactURL: artifactURL,
}
+ applyResultsToSummary(data, summary)
+ return summary
+}
+// applyResultsToSummary populates the count/top fields of a GavelResultsSummary
+// from a (possibly-merged) gavelResultJSON payload. Used both by the
+// single-artifact path and by the multi-artifact merge path.
+func applyResultsToSummary(data gavelResultJSON, summary *GavelResultsSummary) {
for _, root := range data.Tests {
walkTestCounts(root, summary)
}
@@ -129,8 +182,149 @@ func computeGavelSummary(jsonBytes []byte, artifactID int64, artifactURL string)
}
}
}
+}
- return summary
+// computeGavelJobSummary downloads every artifact in `arts`, merges all of
+// their .json payloads into one logical result set, and returns a single
+// GavelJobSummary representing the job.
+//
+// `arts` MUST share a workflow run — typically the slice produced by grouping
+// FindGavelArtifacts() output by RunID. Download errors on individual
+// artifacts are recorded in the Error field rather than aborting the merge,
+// so a partially-broken job still surfaces its successful files.
+func computeGavelJobSummary(opts github.Options, arts []github.GavelArtifact) GavelJobSummary {
+ if len(arts) == 0 {
+ return GavelJobSummary{Error: "no artifacts"}
+ }
+
+ job := GavelJobSummary{
+ RunID: arts[0].RunID,
+ JobName: jobNameFromArtifacts(arts),
+ ArtifactURL: arts[0].URL,
+ }
+ job.ArtifactIDs = make([]int64, 0, len(arts))
+
+ var payloads []gavelResultJSON
+ var partialErrors []string
+ for _, a := range arts {
+ job.ArtifactIDs = append(job.ArtifactIDs, a.ID)
+ if a.Expired {
+ partialErrors = append(partialErrors, fmt.Sprintf("artifact %s (%d) expired", a.Name, a.ID))
+ continue
+ }
+ files, err := github.DownloadArtifactFiles(opts, a.ID)
+ if err != nil {
+ partialErrors = append(partialErrors, fmt.Sprintf("download %s (%d): %v", a.Name, a.ID, err))
+ continue
+ }
+ for _, f := range files {
+ var p gavelResultJSON
+ if err := json.Unmarshal(f.Body, &p); err != nil {
+ partialErrors = append(partialErrors, fmt.Sprintf("parse %s/%s: %v", a.Name, f.Name, err))
+ continue
+ }
+ payloads = append(payloads, p)
+ }
+ }
+
+ merged := mergeGavelResults(payloads...)
+
+ // Reuse the summary-population logic via a transient summary, then copy
+ // the count fields onto the job struct. Keeps a single source of truth
+ // for "how do we count tests".
+ tmp := &GavelResultsSummary{}
+ applyResultsToSummary(merged, tmp)
+
+ job.TestsPassed = tmp.TestsPassed
+ job.TestsFailed = tmp.TestsFailed
+ job.TestsSkipped = tmp.TestsSkipped
+ job.TestsTotal = tmp.TestsTotal
+ job.LintViolations = tmp.LintViolations
+ job.LintLinters = tmp.LintLinters
+ job.HasBench = tmp.HasBench
+ job.BenchRegressions = tmp.BenchRegressions
+ job.TopFailures = tmp.TopFailures
+ job.TopLintViolations = tmp.TopLintViolations
+
+ if len(partialErrors) > 0 && len(payloads) == 0 {
+ // Total failure: surface the first error verbatim so the UI shows
+ // it instead of a misleading "0 tests" panel.
+ job.Error = partialErrors[0]
+ } else if len(partialErrors) > 0 {
+ job.Error = fmt.Sprintf("%d of %d artifact(s) failed to load: %s",
+ len(partialErrors), len(arts), strings.Join(partialErrors, "; "))
+ }
+ return job
+}
+
+// jobNameFromArtifacts produces a human label for a job entry. When all
+// artifacts share a name we use that; when names differ we join them so the
+// UI can show "gavel-results, gavel-bench".
+func jobNameFromArtifacts(arts []github.GavelArtifact) string {
+ if len(arts) == 0 {
+ return ""
+ }
+ seen := make(map[string]bool, len(arts))
+ var names []string
+ for _, a := range arts {
+ if seen[a.Name] {
+ continue
+ }
+ seen[a.Name] = true
+ names = append(names, a.Name)
+ }
+ return strings.Join(names, ", ")
+}
+
+// summarizeGavelArtifacts groups the discovered artifacts by workflow run,
+// downloads + merges each group, then folds the per-job results into a
+// single PR-level summary. The PR-level summary's Jobs field carries the
+// per-job breakdown for surfaces that want detail.
+func summarizeGavelArtifacts(opts github.Options, arts []github.GavelArtifact) *GavelResultsSummary {
+ if len(arts) == 0 {
+ return nil
+ }
+
+ byRun := make(map[int64][]github.GavelArtifact)
+ var runOrder []int64
+ for _, a := range arts {
+ if _, ok := byRun[a.RunID]; !ok {
+ runOrder = append(runOrder, a.RunID)
+ }
+ byRun[a.RunID] = append(byRun[a.RunID], a)
+ }
+
+ pr := &GavelResultsSummary{
+ ArtifactID: arts[0].ID,
+ ArtifactURL: arts[0].URL,
+ }
+ for _, runID := range runOrder {
+ job := computeGavelJobSummary(opts, byRun[runID])
+ pr.Jobs = append(pr.Jobs, job)
+ pr.TestsPassed += job.TestsPassed
+ pr.TestsFailed += job.TestsFailed
+ pr.TestsSkipped += job.TestsSkipped
+ pr.TestsTotal += job.TestsTotal
+ pr.LintViolations += job.LintViolations
+ pr.LintLinters += job.LintLinters
+ if job.HasBench {
+ pr.HasBench = true
+ }
+ pr.BenchRegressions += job.BenchRegressions
+ for _, f := range job.TopFailures {
+ if len(pr.TopFailures) >= 5 {
+ break
+ }
+ pr.TopFailures = append(pr.TopFailures, f)
+ }
+ for _, v := range job.TopLintViolations {
+ if len(pr.TopLintViolations) >= 5 {
+ break
+ }
+ pr.TopLintViolations = append(pr.TopLintViolations, v)
+ }
+ }
+ return pr
}
func walkTestCounts(t parsers.Test, s *GavelResultsSummary) {
@@ -231,29 +425,39 @@ func (s *Server) getOrCreateArtifact(artifactID int64, repo string) (*artifactEn
opts := s.ghOpts
opts.Repo = repo
- jsonBytes, err := github.DownloadArtifact(opts, artifactID)
+ files, err := github.DownloadArtifactFiles(opts, artifactID)
if err != nil {
return nil, err
}
- summary := computeGavelSummary(jsonBytes, artifactID, "")
+ // Merge every JSON entry in the zip into a single payload before
+ // computing summary / loading the snapshot. Single-file artifacts hit
+ // the same path with one payload, so behaviour is unchanged for them.
+ var payloads []gavelResultJSON
+ for _, f := range files {
+ var p gavelResultJSON
+ if err := json.Unmarshal(f.Body, &p); err != nil {
+ logger.Warnf("artifact %d: parse %s: %v", artifactID, f.Name, err)
+ continue
+ }
+ payloads = append(payloads, p)
+ }
+ if len(payloads) == 0 {
+ return nil, fmt.Errorf("artifact %d: no parseable .json file in zip", artifactID)
+ }
+ merged := mergeGavelResults(payloads...)
+
+ summary := &GavelResultsSummary{ArtifactID: artifactID}
+ applyResultsToSummary(merged, summary)
srv := testui.NewServer()
- var snap testui.Snapshot
- if err := json.Unmarshal(jsonBytes, &snap); err != nil {
- logger.Warnf("artifact %d: unmarshal as snapshot: %v, trying legacy format", artifactID, err)
- var data gavelResultJSON
- if err := json.Unmarshal(jsonBytes, &data); err != nil {
- return nil, fmt.Errorf("parse artifact %d: %w", artifactID, err)
- }
- snap = testui.Snapshot{
- Tests: data.Tests,
- Lint: data.Lint,
- Bench: data.Bench,
- Status: testui.SnapshotStatus{
- LintRun: len(data.Lint) > 0,
- },
- }
+ snap := testui.Snapshot{
+ Tests: merged.Tests,
+ Lint: merged.Lint,
+ Bench: merged.Bench,
+ Status: testui.SnapshotStatus{
+ LintRun: len(merged.Lint) > 0,
+ },
}
srv.LoadSnapshot(snap)
srv.MarkDone()
diff --git a/pr/ui/gavel_results_test.go b/pr/ui/gavel_results_test.go
index 80b669835..e0556705c 100644
--- a/pr/ui/gavel_results_test.go
+++ b/pr/ui/gavel_results_test.go
@@ -228,3 +228,127 @@ func TestSnapshotIncludesGavelResults(t *testing.T) {
t.Errorf("marshaled snapshot missing gavelResults field: %s", b)
}
}
+
+// TestMergeGavelResultsConcatenates exercises the per-job merge: when one job
+// uploads multiple gavel-* artifacts (e.g. matrix shards or a separate bench
+// JSON), all of their tests/lint/bench data must aggregate into one logical
+// payload. Pass + Fail + Skip counts must equal the sum across inputs.
+func TestMergeGavelResultsConcatenates(t *testing.T) {
+ a := gavelResultJSON{}
+ if err := json.Unmarshal([]byte(`{"tests":[{"name":"A1","passed":true},{"name":"A2","failed":true}]}`), &a); err != nil {
+ t.Fatal(err)
+ }
+ b := gavelResultJSON{}
+ if err := json.Unmarshal([]byte(`{"tests":[{"name":"B1","passed":true}],"lint":[{"linter":"lint1","success":false,"violations":[{"file":"x.go","line":1,"message":"x"}]}]}`), &b); err != nil {
+ t.Fatal(err)
+ }
+
+ merged := mergeGavelResults(a, b)
+ if len(merged.Tests) != 3 {
+ t.Errorf("merged tests = %d, want 3", len(merged.Tests))
+ }
+ if len(merged.Lint) != 1 {
+ t.Errorf("merged lint = %d, want 1", len(merged.Lint))
+ }
+
+ summary := &GavelResultsSummary{}
+ applyResultsToSummary(merged, summary)
+ if summary.TestsPassed != 2 || summary.TestsFailed != 1 || summary.TestsTotal != 3 {
+ t.Errorf("summary counts wrong: passed=%d failed=%d total=%d",
+ summary.TestsPassed, summary.TestsFailed, summary.TestsTotal)
+ }
+ if summary.LintViolations != 1 {
+ t.Errorf("LintViolations = %d, want 1", summary.LintViolations)
+ }
+}
+
+// TestMergeGavelResultsKeepsFirstBench documents that bench comparisons are
+// not summed across artifacts — the first non-nil one wins. Two artifacts
+// each carrying their own bench struct is rare, but if it happens we want a
+// deterministic answer (no double-counting regressions, no panic).
+func TestMergeGavelResultsKeepsFirstBench(t *testing.T) {
+ a := gavelResultJSON{}
+ b := gavelResultJSON{}
+ if err := json.Unmarshal([]byte(`{"bench":{"threshold":1.05}}`), &a); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal([]byte(`{"bench":{"threshold":2.0}}`), &b); err != nil {
+ t.Fatal(err)
+ }
+ merged := mergeGavelResults(a, b)
+ if merged.Bench == nil || merged.Bench.Threshold != 1.05 {
+ t.Errorf("expected first bench to win (threshold=1.05), got %+v", merged.Bench)
+ }
+}
+
+// TestJobNameFromArtifactsDistinct asserts that distinct artifact names from
+// the same job are joined into a comma-separated label so the UI can show
+// "gavel-results, gavel-bench" instead of just "gavel-results".
+func TestJobNameFromArtifactsDistinct(t *testing.T) {
+ got := jobNameFromArtifacts([]github.GavelArtifact{
+ {Name: "gavel-results"},
+ {Name: "gavel-bench"},
+ {Name: "gavel-results"}, // duplicate must be deduped
+ })
+ if got != "gavel-results, gavel-bench" {
+ t.Errorf("jobNameFromArtifacts = %q, want %q", got, "gavel-results, gavel-bench")
+ }
+}
+
+// TestSummarizeGavelArtifactsGroupsByRun verifies that the PR-level rollup
+// groups artifacts from the same workflow run into a single GavelJobSummary
+// entry. We can't exercise the network-fetch path here (DownloadArtifactFiles
+// would call out to GitHub), so we use an empty artifact set per run — the
+// grouping/RunID handling is what matters.
+func TestSummarizeGavelArtifactsGroupsByRun(t *testing.T) {
+ // Fake out the github layer by passing only metadata: the function will
+ // try to download and fail, recording an error on each job. That's
+ // enough to assert the grouping shape.
+ arts := []github.GavelArtifact{
+ {Name: "gavel-results", ID: 1, RunID: 100, URL: "u1"},
+ {Name: "gavel-bench", ID: 2, RunID: 100, URL: "u2"},
+ {Name: "gavel-results", ID: 3, RunID: 200, URL: "u3"},
+ }
+ // Without a token, DownloadArtifactFiles errors immediately at
+ // opts.token() — no network call. The merge logic still has to walk
+ // every group and produce the right number of Job entries.
+ t.Setenv("GITHUB_TOKEN", "")
+ t.Setenv("GH_TOKEN", "")
+ pr := summarizeGavelArtifacts(github.Options{Repo: "o/r"}, arts)
+ if pr == nil {
+ t.Fatal("summarizeGavelArtifacts returned nil")
+ }
+ if len(pr.Jobs) != 2 {
+ t.Fatalf("Jobs = %d, want 2 (one per RunID)", len(pr.Jobs))
+ }
+ // Job for RunID 100 must merge both artifact IDs.
+ var run100 *GavelJobSummary
+ for i := range pr.Jobs {
+ if pr.Jobs[i].RunID == 100 {
+ run100 = &pr.Jobs[i]
+ }
+ }
+ if run100 == nil {
+ t.Fatalf("missing RunID 100 in Jobs: %+v", pr.Jobs)
+ }
+ if len(run100.ArtifactIDs) != 2 {
+ t.Errorf("RunID 100 ArtifactIDs = %v, want 2 entries", run100.ArtifactIDs)
+ }
+ if run100.JobName != "gavel-results, gavel-bench" {
+ t.Errorf("RunID 100 JobName = %q", run100.JobName)
+ }
+ // Each job recorded an error (no token); the PR-level counts should
+ // therefore be zero rather than misleading partials.
+ if pr.TestsTotal != 0 {
+ t.Errorf("expected 0 tests (all downloads failed), got %d", pr.TestsTotal)
+ }
+}
+
+// TestSummarizeGavelArtifactsEmpty asserts that an empty input returns nil
+// so the caller can short-circuit emit("gavel", ...) and not show an empty
+// "Gavel results" section in the UI.
+func TestSummarizeGavelArtifactsEmpty(t *testing.T) {
+ if got := summarizeGavelArtifacts(github.Options{}, nil); got != nil {
+ t.Errorf("expected nil for empty input, got %+v", got)
+ }
+}
diff --git a/pr/ui/handler.go b/pr/ui/handler.go
index 654d44abf..0441891a3 100644
--- a/pr/ui/handler.go
+++ b/pr/ui/handler.go
@@ -917,26 +917,42 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
runIDs = append(runIDs, runID)
}
- // Start gavel results fetch in parallel
+ // Start gavel results fetch in parallel. Discovery first walks every
+ // workflow run on the PR for artifacts whose name starts with "gavel"
+ // (matrix shards, separate bench jobs, etc. all show up). Sticky
+ // comment is a fallback for cases where the runs API turns up empty —
+ // e.g. an artifact uploaded by a workflow whose run isn't in
+ // StatusCheckRollup.
type gavelResult struct {
summary *GavelResultsSummary
}
gavelCh := make(chan gavelResult, 1)
allComments := append(pr.Comments, pr.ReviewThreads...)
- artifactID, artifactURL, hasArtifact := github.FindGavelArtifact(allComments)
- if hasArtifact {
+ artifacts, _ := github.FindGavelArtifacts(opts, pr)
+ hasArtifact := len(artifacts) > 0
+ var stickyID int64
+ var stickyURL string
+ var hasSticky bool
+ if !hasArtifact {
+ stickyID, stickyURL, hasSticky = github.FindGavelArtifact(allComments)
+ }
+ if hasArtifact || hasSticky {
go func() {
- jsonBytes, err := github.DownloadArtifact(opts, artifactID)
+ if hasArtifact {
+ gavelCh <- gavelResult{summarizeGavelArtifacts(opts, artifacts)}
+ return
+ }
+ jsonBytes, err := github.DownloadArtifact(opts, stickyID)
if err != nil {
- logger.Warnf("artifact %d download failed: %v", artifactID, err)
+ logger.Warnf("artifact %d download failed: %v", stickyID, err)
gavelCh <- gavelResult{&GavelResultsSummary{
- ArtifactID: artifactID,
- ArtifactURL: artifactURL,
+ ArtifactID: stickyID,
+ ArtifactURL: stickyURL,
Error: err.Error(),
}}
- } else {
- gavelCh <- gavelResult{computeGavelSummary(jsonBytes, artifactID, artifactURL)}
+ return
}
+ gavelCh <- gavelResult{computeGavelSummary(jsonBytes, stickyID, stickyURL)}
}()
}
@@ -967,7 +983,7 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
// Wait for gavel results
var gavelSummary *GavelResultsSummary
- if hasArtifact {
+ if hasArtifact || hasSticky {
gr := <-gavelCh
gavelSummary = gr.summary
emit("gavel", map[string]any{"gavelResults": gavelSummary})
@@ -1019,8 +1035,15 @@ func (s *Server) fetchPRDetail(repo string, number int) prDetail {
result.Runs = runs
result.Comments = prwatch.MergeAndFilter(pr.Comments, pr.ReviewThreads)
- // Scan all comments (including general issue comments, not just review
- // threads) for a gavel sticky comment with an artifact link.
+ // Discover gavel artifacts via the workflow runs API first, then fall
+ // back to the sticky-comment URL if nothing turned up. The runs API
+ // catches matrix shards / multiple gavel-* uploads that the single
+ // sticky-comment URL can't represent.
+ if artifacts, _ := github.FindGavelArtifacts(opts, pr); len(artifacts) > 0 {
+ result.GavelResults = summarizeGavelArtifacts(opts, artifacts)
+ s.setGavelSummary(repo, number, result.GavelResults)
+ return result
+ }
allComments := append(pr.Comments, pr.ReviewThreads...)
if artifactID, artifactURL, found := github.FindGavelArtifact(allComments); found {
jsonBytes, err := github.DownloadArtifact(opts, artifactID)
diff --git a/testrunner/path_filtering.go b/testrunner/path_filtering.go
new file mode 100644
index 000000000..a12c25e28
--- /dev/null
+++ b/testrunner/path_filtering.go
@@ -0,0 +1,152 @@
+package testrunner
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+// filterIgnoredGroups drops testGroups whose workDir is matched by an
+// ignore pattern relative to baseWorkDir. This is needed because nested-go-mod
+// expansion spawns independent groups (each with its own WorkDir) before
+// per-package discovery happens, so package-level filtering would never see
+// them. baseWorkDir is the original (top-level) workdir; patterns are
+// resolved against it.
+func filterIgnoredGroups(baseWorkDir string, groups []testGroup, ignore []string) []testGroup {
+ if len(ignore) == 0 || len(groups) == 0 {
+ return groups
+ }
+ normalized := make([]string, 0, len(ignore))
+ for _, p := range ignore {
+ if n := normalizeIgnorePattern(p); n != "" {
+ normalized = append(normalized, n)
+ }
+ }
+ if len(normalized) == 0 {
+ return groups
+ }
+ baseAbs, err := filepath.Abs(baseWorkDir)
+ if err != nil {
+ return groups
+ }
+ out := groups[:0:0]
+ for _, g := range groups {
+ gAbs, err := filepath.Abs(g.workDir)
+ if err != nil {
+ out = append(out, g)
+ continue
+ }
+ rel, err := filepath.Rel(baseAbs, gAbs)
+ if err != nil || strings.HasPrefix(rel, "..") {
+ // Group lives outside the base workdir — leave it alone.
+ out = append(out, g)
+ continue
+ }
+ if rel == "." {
+ out = append(out, g)
+ continue
+ }
+ pkgLike := "./" + filepath.ToSlash(rel)
+ if !ignoreMatches(pkgLike, normalized) {
+ out = append(out, g)
+ }
+ }
+ return out
+}
+
+// expandRecursiveWildcards translates Go-style recursive wildcards
+// ("./...", "...") into the empty starting-path convention, which downstream
+// code already interprets as "discover from WorkDir recursively". Any path
+// of the form "./
/..." is rewritten to "./" so the directory is
+// preserved but the wildcard suffix is dropped (DiscoverPackages walks
+// recursively by default).
+//
+// Returns a new slice; input is not mutated. An input that contains only
+// "./..." (or equivalents) becomes nil.
+func expandRecursiveWildcards(paths []string) []string {
+ if len(paths) == 0 {
+ return paths
+ }
+ out := make([]string, 0, len(paths))
+ for _, p := range paths {
+ switch p {
+ case "./...", "...", "./", ".":
+ // Equivalent to "no starting path" — let the orchestrator
+ // discover from WorkDir.
+ continue
+ }
+ p = strings.TrimSuffix(p, "/...")
+ out = append(out, p)
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
+// applyIgnorePatterns returns the subset of pkgs that does not match any
+// pattern in ignore. Patterns are repo-relative package paths. A bare
+// directory pattern matches that directory and every package below it
+// ("./bench" hides "./bench" and "./bench/sub"). Patterns may also use the
+// Go-style "./bench/..." or "./bench/**" suffixes; both are treated as
+// recursive directory matches. Empty ignore returns pkgs unchanged.
+func applyIgnorePatterns(pkgs, ignore []string) []string {
+ if len(ignore) == 0 || len(pkgs) == 0 {
+ return pkgs
+ }
+ normalized := make([]string, 0, len(ignore))
+ for _, p := range ignore {
+ if n := normalizeIgnorePattern(p); n != "" {
+ normalized = append(normalized, n)
+ }
+ }
+ if len(normalized) == 0 {
+ return pkgs
+ }
+ out := pkgs[:0:0]
+ for _, pkg := range pkgs {
+ if !ignoreMatches(pkg, normalized) {
+ out = append(out, pkg)
+ }
+ }
+ return out
+}
+
+// normalizeIgnorePattern strips trailing /... and /** suffixes and collapses
+// the path through filepath.Clean. The returned string is a directory
+// prefix used as both an exact match and a path-prefix match.
+func normalizeIgnorePattern(p string) string {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ return ""
+ }
+ for _, suffix := range []string{"/...", "/**", "/*"} {
+ p = strings.TrimSuffix(p, suffix)
+ }
+ p = filepath.ToSlash(filepath.Clean(p))
+ if p == "." {
+ return ""
+ }
+ if !strings.HasPrefix(p, "./") && !filepath.IsAbs(p) {
+ p = "./" + p
+ }
+ return p
+}
+
+// ignoreMatches reports whether pkg matches any normalized ignore prefix.
+// pkg is expected to look like "./foo/bar" (from getRelativePath). Match is
+// either equality or a path-prefix check ("./bench" matches "./bench/sub").
+func ignoreMatches(pkg string, normalized []string) bool {
+ clean := filepath.ToSlash(filepath.Clean(pkg))
+ if !strings.HasPrefix(clean, "./") && !filepath.IsAbs(clean) {
+ clean = "./" + clean
+ }
+ for _, pat := range normalized {
+ if clean == pat {
+ return true
+ }
+ if strings.HasPrefix(clean, pat+"/") {
+ return true
+ }
+ }
+ return false
+}
diff --git a/testrunner/path_filtering_unit_test.go b/testrunner/path_filtering_unit_test.go
new file mode 100644
index 000000000..dbda92149
--- /dev/null
+++ b/testrunner/path_filtering_unit_test.go
@@ -0,0 +1,176 @@
+package testrunner
+
+import (
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestApplyIgnorePatterns(t *testing.T) {
+ tests := []struct {
+ name string
+ pkgs []string
+ ignore []string
+ want []string
+ }{
+ {
+ name: "empty ignore returns input unchanged",
+ pkgs: []string{"./api", "./bench", "./bench/sub"},
+ ignore: nil,
+ want: []string{"./api", "./bench", "./bench/sub"},
+ },
+ {
+ name: "bare directory ignore is recursive",
+ pkgs: []string{"./api", "./bench", "./bench/sub", "./bench/deep/inner"},
+ ignore: []string{"./bench"},
+ want: []string{"./api"},
+ },
+ {
+ name: "trailing /... is treated the same as bare dir",
+ pkgs: []string{"./api", "./hack", "./hack/foo"},
+ ignore: []string{"./hack/..."},
+ want: []string{"./api"},
+ },
+ {
+ name: "trailing /** is treated the same as bare dir",
+ pkgs: []string{"./api", "./hack", "./hack/foo"},
+ ignore: []string{"./hack/**"},
+ want: []string{"./api"},
+ },
+ {
+ name: "non-matching pattern is a no-op",
+ pkgs: []string{"./api", "./db"},
+ ignore: []string{"./does-not-exist"},
+ want: []string{"./api", "./db"},
+ },
+ {
+ name: "multiple patterns compose",
+ pkgs: []string{"./api", "./bench", "./hack/x", "./specs", "./tests/e2e", "./tests/unit"},
+ ignore: []string{"./bench", "./hack", "./specs", "./tests/e2e"},
+ want: []string{"./api", "./tests/unit"},
+ },
+ {
+ name: "exact-match without recursion does not over-match siblings",
+ pkgs: []string{"./bench", "./benchmarks"},
+ ignore: []string{"./bench"},
+ want: []string{"./benchmarks"},
+ },
+ {
+ name: "leading ./ is normalized when missing",
+ pkgs: []string{"./bench", "./api"},
+ ignore: []string{"bench"},
+ want: []string{"./api"},
+ },
+ {
+ name: "blank pattern is ignored",
+ pkgs: []string{"./api"},
+ ignore: []string{" "},
+ want: []string{"./api"},
+ },
+ {
+ name: "root pattern is rejected (would match everything)",
+ pkgs: []string{"./api", "./db"},
+ ignore: []string{"."},
+ want: []string{"./api", "./db"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := applyIgnorePatterns(tt.pkgs, tt.ignore)
+ // applyIgnorePatterns may return a nil slice for fully filtered input;
+ // normalize for comparison.
+ if got == nil {
+ got = []string{}
+ }
+ want := tt.want
+ if want == nil {
+ want = []string{}
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("applyIgnorePatterns(%v, %v) = %v, want %v", tt.pkgs, tt.ignore, got, want)
+ }
+ })
+ }
+}
+
+func TestExpandRecursiveWildcards(t *testing.T) {
+ tests := []struct {
+ name string
+ in []string
+ want []string
+ }{
+ {name: "empty input", in: nil, want: nil},
+ {name: "./... becomes empty (whole-workdir discovery)", in: []string{"./..."}, want: nil},
+ {name: "... becomes empty", in: []string{"..."}, want: nil},
+ {name: ". becomes empty", in: []string{"."}, want: nil},
+ {
+ name: "./pkg/... is preserved without the suffix",
+ in: []string{"./pkg/..."},
+ want: []string{"./pkg"},
+ },
+ {
+ name: "mixed wildcards and concrete paths",
+ in: []string{"./...", "./api", "./pkg/..."},
+ want: []string{"./api", "./pkg"},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := expandRecursiveWildcards(tt.in)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("expandRecursiveWildcards(%v) = %v, want %v", tt.in, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFilterIgnoredGroups(t *testing.T) {
+ base := filepath.FromSlash("/repo")
+ groups := []testGroup{
+ {workDir: filepath.FromSlash("/repo")},
+ {workDir: filepath.FromSlash("/repo/hack/migrate")},
+ {workDir: filepath.FromSlash("/repo/hack/generate-schemas")},
+ {workDir: filepath.FromSlash("/repo/sub")},
+ }
+
+ tests := []struct {
+ name string
+ ignore []string
+ wantDirs []string
+ }{
+ {
+ name: "no ignore returns all",
+ ignore: nil,
+ wantDirs: []string{"/repo", "/repo/hack/migrate", "/repo/hack/generate-schemas", "/repo/sub"},
+ },
+ {
+ name: "bare dir ignore strips nested-module groups",
+ ignore: []string{"./hack"},
+ wantDirs: []string{"/repo", "/repo/sub"},
+ },
+ {
+ name: "ignore that matches the base does not strip the base group",
+ ignore: []string{"./sub"},
+ wantDirs: []string{"/repo", "/repo/hack/migrate", "/repo/hack/generate-schemas"},
+ },
+ {
+ name: "non-matching ignore is a no-op",
+ ignore: []string{"./does-not-exist"},
+ wantDirs: []string{"/repo", "/repo/hack/migrate", "/repo/hack/generate-schemas", "/repo/sub"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := filterIgnoredGroups(base, groups, tt.ignore)
+ gotDirs := make([]string, 0, len(got))
+ for _, g := range got {
+ gotDirs = append(gotDirs, filepath.ToSlash(g.workDir))
+ }
+ if !reflect.DeepEqual(gotDirs, tt.wantDirs) {
+ t.Errorf("filterIgnoredGroups dirs = %v, want %v", gotDirs, tt.wantDirs)
+ }
+ })
+ }
+}
diff --git a/testrunner/runner.go b/testrunner/runner.go
index d02fa46d9..fe84e6f12 100644
--- a/testrunner/runner.go
+++ b/testrunner/runner.go
@@ -105,6 +105,8 @@ type TestOrchestrator struct {
type RunOptions struct {
SyncTodos bool `json:"sync_todos,omitempty" flag:"sync-todos"` // Whether to sync test failures to TODOs
StartingPaths []string `json:"starting_paths,omitempty" args:"true"` // Package paths to test (e.g., ["./pkg/testrunner"]). If empty, all packages are discovered.
+ Ignore []string `json:"ignore,omitempty" flag:"ignore"` // Package paths to exclude from discovery. Bare directories are recursive (e.g. ./bench excludes ./bench and ./bench/sub). Trailing /... is also accepted.
+ NoGitignore bool `json:"no_gitignore,omitempty" flag:"no-gitignore"` // Disable .gitignore-based pruning during package discovery. By default gitignored directories are skipped.
ExtraArgs []string `json:"extra_args,omitempty" flag:"extra-args"` // Additional arguments to pass to test runners (e.g., ["--focus", "TestName"])
ShowPassed bool `json:"show_passed,omitempty" flag:"show-passed"` // Whether to show passed tests in output
ShowStdout OutputMode `json:"show_stdout,omitempty" flag:"show-stdout" default:"OnFailure"` // When to show stdout: false|Never, OnFailure (default), true|Always
@@ -147,6 +149,12 @@ func (opts RunOptions) Pretty() api.Text {
if len(opts.StartingPaths) > 0 {
text = text.Space().Append("StartingPaths: ", "text-muted").Append(clicky.CompactList(opts.StartingPaths), "text-blue-500")
}
+ if len(opts.Ignore) > 0 {
+ text = text.Space().Append("Ignore: ", "text-muted").Append(clicky.CompactList(opts.Ignore), "text-blue-500")
+ }
+ if opts.NoGitignore {
+ text = text.Space().Append("NoGitignore: ", "text-muted").Append(icons.Check, "text-green-500")
+ }
if len(opts.ExtraArgs) > 0 {
text = text.Space().Append("ExtraArgs: ", "text-muted").Append(clicky.CompactList(opts.ExtraArgs), "text-blue-500")
}
@@ -245,6 +253,8 @@ type testGroup struct {
func groupPathsByGitRoot(workDir string, startingPaths []string) ([]testGroup, error) {
workDir, _ = filepath.Abs(workDir)
+ startingPaths = expandRecursiveWildcards(startingPaths)
+
if len(startingPaths) == 0 {
return expandNestedModuleGroups([]testGroup{{workDir: workDir}})
}
@@ -381,12 +391,23 @@ func Run(opts RunOptions) (any, error) {
opts.WorkDir, _ = os.Getwd()
}
+ // --no-gitignore: short-circuit gitignore-aware walking process-wide. The
+ // flag is package-global because the Runner interface does not thread
+ // per-call options through to discovery. Acceptable for a single-shot
+ // CLI process.
+ prevDisable := utils.SetGitignoreDisabled(opts.NoGitignore)
+ defer utils.SetGitignoreDisabled(prevDisable)
+
// Split starting paths by execution root so each group runs with the
// correct WorkDir. Nested Go modules get their own groups.
groups, err := groupPathsByGitRoot(opts.WorkDir, opts.StartingPaths)
if err != nil {
return nil, err
}
+ groups = filterIgnoredGroups(opts.WorkDir, groups, opts.Ignore)
+ if len(groups) == 0 {
+ return parsers.TestSuiteResults{}, nil
+ }
if len(groups) > 1 {
return runMultiRoot(opts, groups)
}
@@ -662,6 +683,7 @@ func (o *TestOrchestrator) detectAndRun(frameworks []Framework, startingPaths []
packages, err = o.discoverPackagesInPaths(runner, startingPaths)
} else {
packages, err = runner.DiscoverPackages(o.WorkDir, o.Recursive)
+ packages = applyIgnorePatterns(packages, o.Ignore)
}
if err != nil {
@@ -1213,7 +1235,7 @@ func (o *TestOrchestrator) discoverPackagesInPaths(runner runners.Runner, starti
}
- return lo.Uniq(allPackages), nil
+ return applyIgnorePatterns(lo.Uniq(allPackages), o.Ignore), nil
}
// parseTestResults parses test results from either stdout (go test) or report file (Ginkgo).
diff --git a/testrunner/runners/buildtags.go b/testrunner/runners/buildtags.go
new file mode 100644
index 000000000..c4440655e
--- /dev/null
+++ b/testrunner/runners/buildtags.go
@@ -0,0 +1,27 @@
+package runners
+
+import (
+ "go/build"
+ "path/filepath"
+)
+
+// matchesBuildContext reports whether file (a single _test.go path) is
+// compiled under the default Go build context (current GOOS/GOARCH and the
+// caller's build tags). Files excluded by //go:build constraints return
+// false; matching, malformed, or unreadable files return true so we keep
+// the previous permissive behavior on edge cases.
+//
+// Used by package discovery so directories whose only test files are
+// excluded by build constraints (e.g. //go:build never) are not surfaced
+// as runnable packages.
+func matchesBuildContext(file string) bool {
+ dir, name := filepath.Split(file)
+ if dir == "" {
+ dir = "."
+ }
+ matched, err := build.Default.MatchFile(dir, name)
+ if err != nil {
+ return true
+ }
+ return matched
+}
diff --git a/testrunner/runners/buildtags_test.go b/testrunner/runners/buildtags_test.go
new file mode 100644
index 000000000..b8601f92b
--- /dev/null
+++ b/testrunner/runners/buildtags_test.go
@@ -0,0 +1,59 @@
+package runners
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestMatchesBuildContext(t *testing.T) {
+ dir := t.TempDir()
+
+ cases := []struct {
+ name string
+ filename string
+ body string
+ want bool
+ }{
+ {
+ name: "plain test file matches",
+ filename: "plain_test.go",
+ body: "package x\n",
+ want: true,
+ },
+ {
+ name: "//go:build never excludes the file",
+ filename: "never_test.go",
+ body: "//go:build never\n\npackage x\n",
+ want: false,
+ },
+ {
+ name: "//go:build linux on darwin runners would not match — use a tag we do not pass",
+ filename: "tagged_test.go",
+ body: "//go:build buildtag_that_definitely_isnt_set\n\npackage x\n",
+ want: false,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ path := filepath.Join(dir, tc.filename)
+ if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil {
+ t.Fatalf("write: %v", err)
+ }
+ got := matchesBuildContext(path)
+ if got != tc.want {
+ t.Errorf("matchesBuildContext(%s) = %v, want %v", tc.filename, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestMatchesBuildContextOnUnreadableReturnsTrue(t *testing.T) {
+ // Nonexistent file: MatchFile errors out. We treat error as "permissive"
+ // so the file isn't silently skipped on edge cases.
+ got := matchesBuildContext("/does/not/exist/foo_test.go")
+ if !got {
+ t.Errorf("matchesBuildContext on missing file = false, want true (permissive default)")
+ }
+}
diff --git a/testrunner/runners/ginkgo.go b/testrunner/runners/ginkgo.go
index 8547abfe6..4c18a4b22 100644
--- a/testrunner/runners/ginkgo.go
+++ b/testrunner/runners/ginkgo.go
@@ -48,7 +48,7 @@ func (r *Ginkgo) Detect(workDir string) (bool, error) {
if err != nil {
return err
}
- if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") && hasGinkgoImports(path) {
+ if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") && matchesBuildContext(path) && hasGinkgoImports(path) {
return errGinkgoDetected
}
return nil
@@ -81,7 +81,7 @@ func (r *Ginkgo) DiscoverPackages(workDir string, recursive bool) ([]string, err
return err
}
- if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") {
+ if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") && matchesBuildContext(path) {
if hasGinkgoImports(path) {
pkgDir := filepath.Dir(path)
if !seen[pkgDir] {
diff --git a/testrunner/runners/gotest.go b/testrunner/runners/gotest.go
index 1e919c61e..4e623da27 100644
--- a/testrunner/runners/gotest.go
+++ b/testrunner/runners/gotest.go
@@ -56,7 +56,7 @@ func (r *GoTest) Detect(workDir string) (bool, error) {
if d.IsDir() {
return nil
}
- if strings.HasSuffix(d.Name(), "_test.go") {
+ if strings.HasSuffix(d.Name(), "_test.go") && matchesBuildContext(path) {
return errGoTestDetected
}
return nil
@@ -81,6 +81,9 @@ func (r *GoTest) packageHasNonGinkgoTests(pkgDir string) bool {
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), "_test.go") {
path := filepath.Join(pkgDir, entry.Name())
+ if !matchesBuildContext(path) {
+ continue
+ }
if !hasGinkgoImports(path) {
return true
}
@@ -103,6 +106,9 @@ func (r *GoTest) inspectPackage(pkgDir string) (hasTests bool, hasBench bool) {
continue
}
path := filepath.Join(pkgDir, entry.Name())
+ if !matchesBuildContext(path) {
+ continue
+ }
if hasGinkgoImports(path) {
continue
}
@@ -162,6 +168,9 @@ func (r *GoTest) hasTestsBeyondTestMain(pkgDir string) bool {
continue
}
path := filepath.Join(pkgDir, entry.Name())
+ if !matchesBuildContext(path) {
+ continue
+ }
if hasGinkgoImports(path) {
continue
}
@@ -198,7 +207,7 @@ func (r *GoTest) DiscoverPackages(workDir string, recursive bool) ([]string, err
return err
}
- if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") {
+ if !d.IsDir() && strings.HasSuffix(d.Name(), "_test.go") && matchesBuildContext(path) {
pkgDir := filepath.Dir(path)
if !seen[pkgDir] {
seen[pkgDir] = true
diff --git a/testrunner/ui/handler.go b/testrunner/ui/handler.go
index 8652d4246..7a4b164c5 100644
--- a/testrunner/ui/handler.go
+++ b/testrunner/ui/handler.go
@@ -26,6 +26,7 @@ type Server struct {
embeddedDiagnostics *DiagnosticsSnapshot
updated chan struct{}
gitRoot string
+ gavelDir string
diag *DiagnosticsManager
rerunMu sync.Mutex
@@ -206,6 +207,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/api/rerun/stream", s.handleRerunStream)
mux.HandleFunc("/api/lint/ignore", s.handleLintIgnore)
mux.HandleFunc("/api/benchmarks", s.handleBenchJSON)
+ mux.HandleFunc("/api/runs", s.handleRunsIndex)
+ mux.HandleFunc("/api/runs/", s.handleRunSnapshot)
return mux
}
diff --git a/testrunner/ui/routes.go b/testrunner/ui/routes.go
index 5c43fd761..bc18ba533 100644
--- a/testrunner/ui/routes.go
+++ b/testrunner/ui/routes.go
@@ -216,6 +216,23 @@ func parseRouteRequest(r *http.Request) (routeRequest, bool) {
}
segments := strings.Split(path, "/")
+ // `/run/` is a frontend-only prefix that selects which saved
+ // snapshot to load. Strip it so the rest of the route parses the same
+ // way as the bare `//...` form.
+ if segments[0] == "run" {
+ if len(segments) < 2 {
+ // `/run` with no name behaves like the index page.
+ req.TestFilters = parseTestFilters(r)
+ req.LintFilters = parseLintFilters(r)
+ return req, true
+ }
+ segments = segments[2:]
+ if len(segments) == 0 {
+ req.TestFilters = parseTestFilters(r)
+ req.LintFilters = parseLintFilters(r)
+ return req, true
+ }
+ }
tabSeg := segments[0]
pathFormat := ""
if base, format := stripKnownFormat(tabSeg); format != "" {
diff --git a/testrunner/ui/runs_index.go b/testrunner/ui/runs_index.go
new file mode 100644
index 000000000..ed5ad9434
--- /dev/null
+++ b/testrunner/ui/runs_index.go
@@ -0,0 +1,263 @@
+package testui
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/flanksource/gavel/testrunner/parsers"
+)
+
+// gavelDir is the conventional snapshot directory written by snapshots.Save.
+// Duplicated here (rather than imported from the snapshots package) because
+// snapshots already imports this package.
+const gavelDir = ".gavel"
+
+// pointerJSON mirrors snapshots.Pointer's wire shape. Re-declared locally to
+// avoid importing the snapshots package, which would create an import cycle.
+type pointerJSON struct {
+ Path string `json:"path"`
+ SHA string `json:"sha"`
+ Uncommitted string `json:"uncommitted,omitempty"`
+}
+
+type RunCounts struct {
+ Total int `json:"total"`
+ Passed int `json:"passed"`
+ Failed int `json:"failed"`
+ Skipped int `json:"skipped"`
+ Pending int `json:"pending"`
+}
+
+type RunIndexEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Pointer string `json:"pointer,omitempty"`
+ Modified time.Time `json:"modified"`
+ SHA string `json:"sha,omitempty"`
+ Started *time.Time `json:"started,omitempty"`
+ Ended *time.Time `json:"ended,omitempty"`
+ Counts *RunCounts `json:"counts,omitempty"`
+ Lint int `json:"lint,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+// SetGavelDir tells the server which directory holds previously-saved snapshots.
+// When set, /api/runs scans this directory and /api/runs/{name} loads a single
+// snapshot from it. Empty string disables both endpoints.
+func (s *Server) SetGavelDir(root string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if root == "" {
+ s.gavelDir = ""
+ return
+ }
+ s.gavelDir = filepath.Join(root, gavelDir)
+}
+
+// GavelDir returns the directory backing /api/runs, or "" if no-arg index mode
+// is disabled.
+func (s *Server) GavelDir() string {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.gavelDir
+}
+
+func (s *Server) handleRunsIndex(w http.ResponseWriter, _ *http.Request) {
+ dir := s.GavelDir()
+ if dir == "" {
+ http.NotFound(w, nil)
+ return
+ }
+ entries, err := buildRunIndex(dir)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(entries) //nolint:errcheck
+}
+
+func (s *Server) handleRunSnapshot(w http.ResponseWriter, r *http.Request) {
+ dir := s.GavelDir()
+ if dir == "" {
+ http.NotFound(w, r)
+ return
+ }
+ name := strings.TrimPrefix(r.URL.Path, "/api/runs/")
+ if name == "" || strings.ContainsRune(name, '/') || strings.Contains(name, "..") {
+ http.Error(w, "invalid run name", http.StatusBadRequest)
+ return
+ }
+ if !strings.HasSuffix(name, ".json") {
+ name += ".json"
+ }
+ path := filepath.Join(dir, name)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ // If this is a pointer, dereference once.
+ if resolved, ok := tryResolvePointer(dir, data); ok {
+ data = resolved
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(data) //nolint:errcheck
+}
+
+// tryResolvePointer detects pointer files (the small {path,sha} shape written
+// by snapshots.Save) and returns the bytes of the snapshot they reference.
+// Returns (nil, false) when raw is not a pointer or the pointed file cannot be
+// read — caller falls back to the original bytes.
+func tryResolvePointer(dir string, raw []byte) ([]byte, bool) {
+ var p pointerJSON
+ if err := json.Unmarshal(raw, &p); err != nil {
+ return nil, false
+ }
+ if p.Path == "" || p.SHA == "" {
+ return nil, false
+ }
+ target := p.Path
+ if !filepath.IsAbs(target) {
+ target = filepath.Join(filepath.Dir(dir), target)
+ }
+ data, err := os.ReadFile(target)
+ if err != nil {
+ return nil, false
+ }
+ return data, true
+}
+
+// buildRunIndex scans dir, returns one entry per .json file with summary
+// metadata. Pointers are resolved (stats come from the underlying snapshot)
+// and rendered as labeled rows pointing to the resolved snapshot.
+func buildRunIndex(dir string) ([]RunIndexEntry, error) {
+ infos, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", dir, err)
+ }
+
+ pointers := make([]RunIndexEntry, 0)
+ snapshots := make([]RunIndexEntry, 0)
+
+ for _, fi := range infos {
+ if fi.IsDir() {
+ continue
+ }
+ name := fi.Name()
+ if !strings.HasSuffix(name, ".json") {
+ continue
+ }
+ full := filepath.Join(dir, name)
+ stat, err := fi.Info()
+ if err != nil {
+ continue
+ }
+ entry := RunIndexEntry{
+ Name: strings.TrimSuffix(name, ".json"),
+ Path: filepath.Join(gavelDir, name),
+ Modified: stat.ModTime(),
+ }
+ raw, err := os.ReadFile(full)
+ if err != nil {
+ entry.Error = err.Error()
+ snapshots = append(snapshots, entry)
+ continue
+ }
+
+ if resolved, ok := tryResolvePointer(dir, raw); ok {
+ var ptr pointerJSON
+ _ = json.Unmarshal(raw, &ptr)
+ entry.Pointer = entry.Name
+ entry.SHA = shortSHA(ptr.SHA)
+ // Path points at the resolved snapshot so click-through deep links
+ // to the same URL as a direct snapshot row.
+ entry.Path = ptr.Path
+ fillFromSnapshot(&entry, resolved)
+ pointers = append(pointers, entry)
+ continue
+ }
+
+ fillFromSnapshot(&entry, raw)
+ snapshots = append(snapshots, entry)
+ }
+
+ sort.SliceStable(pointers, func(i, j int) bool {
+ return pointerOrder(pointers[i].Pointer) < pointerOrder(pointers[j].Pointer) ||
+ (pointerOrder(pointers[i].Pointer) == pointerOrder(pointers[j].Pointer) &&
+ pointers[i].Pointer < pointers[j].Pointer)
+ })
+ sort.SliceStable(snapshots, func(i, j int) bool {
+ return snapshots[i].Modified.After(snapshots[j].Modified)
+ })
+
+ out := make([]RunIndexEntry, 0, len(pointers)+len(snapshots))
+ out = append(out, pointers...)
+ out = append(out, snapshots...)
+ return out, nil
+}
+
+func pointerOrder(name string) int {
+ switch name {
+ case "last":
+ return 0
+ case "main", "master":
+ return 2
+ default:
+ return 1
+ }
+}
+
+func fillFromSnapshot(entry *RunIndexEntry, raw []byte) {
+ var snap Snapshot
+ if err := json.Unmarshal(raw, &snap); err != nil {
+ entry.Error = err.Error()
+ return
+ }
+ if snap.Git != nil && entry.SHA == "" {
+ entry.SHA = shortSHA(snap.Git.SHA)
+ }
+ if snap.Metadata != nil {
+ if !snap.Metadata.Started.IsZero() {
+ t := snap.Metadata.Started
+ entry.Started = &t
+ }
+ if !snap.Metadata.Ended.IsZero() {
+ t := snap.Metadata.Ended
+ entry.Ended = &t
+ }
+ }
+ sum := parsers.Tests(snap.Tests).Sum()
+ if sum.Total > 0 || sum.Failed > 0 || sum.Skipped > 0 || sum.Pending > 0 {
+ entry.Counts = &RunCounts{
+ Total: sum.Total,
+ Passed: sum.Passed,
+ Failed: sum.Failed,
+ Skipped: sum.Skipped,
+ Pending: sum.Pending,
+ }
+ }
+ for _, lr := range snap.Lint {
+ if lr == nil {
+ continue
+ }
+ entry.Lint += len(lr.Violations)
+ }
+}
+
+func shortSHA(sha string) string {
+ if len(sha) > 7 {
+ return sha[:7]
+ }
+ return sha
+}
diff --git a/testrunner/ui/runs_index_test.go b/testrunner/ui/runs_index_test.go
new file mode 100644
index 000000000..803ce6a77
--- /dev/null
+++ b/testrunner/ui/runs_index_test.go
@@ -0,0 +1,177 @@
+package testui_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/flanksource/gavel/testrunner/parsers"
+ testui "github.com/flanksource/gavel/testrunner/ui"
+)
+
+func writeTestSnapshot(t *testing.T, path string, snap testui.Snapshot, mtime time.Time) {
+ t.Helper()
+ data, err := json.MarshalIndent(snap, "", " ")
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatalf("write %s: %v", path, err)
+ }
+ if err := os.Chtimes(path, mtime, mtime); err != nil {
+ t.Fatalf("chtimes %s: %v", path, err)
+ }
+}
+
+func writeTestPointer(t *testing.T, path string, pointerPath, sha string) {
+ t.Helper()
+ body := map[string]string{"path": pointerPath, "sha": sha}
+ data, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatalf("write pointer %s: %v", path, err)
+ }
+}
+
+func TestRunsIndexEndpointListsSnapshotsAndPointers(t *testing.T) {
+ root := t.TempDir()
+ gavelDir := filepath.Join(root, ".gavel")
+ if err := os.MkdirAll(gavelDir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+
+ older := time.Now().Add(-2 * time.Hour)
+ newer := time.Now().Add(-30 * time.Minute)
+
+ snapOld := testui.Snapshot{
+ Metadata: &testui.SnapshotMetadata{Started: older, Ended: older.Add(time.Minute)},
+ Git: &testui.SnapshotGit{SHA: "abcdef1234567890"},
+ Tests: []parsers.Test{
+ {Name: "TestA", Passed: true},
+ {Name: "TestB", Failed: true},
+ },
+ }
+ snapNew := testui.Snapshot{
+ Metadata: &testui.SnapshotMetadata{Started: newer, Ended: newer.Add(2 * time.Minute)},
+ Git: &testui.SnapshotGit{SHA: "1111111deadbeef"},
+ Tests: []parsers.Test{
+ {Name: "TestC", Passed: true},
+ {Name: "TestD", Skipped: true},
+ {Name: "TestE", Passed: true},
+ },
+ }
+
+ oldPath := filepath.Join(gavelDir, "sha-abcdef1234567890.json")
+ newPath := filepath.Join(gavelDir, "sha-1111111deadbeef.json")
+ writeTestSnapshot(t, oldPath, snapOld, older)
+ writeTestSnapshot(t, newPath, snapNew, newer)
+ // Pointer that resolves to the new snapshot.
+ writeTestPointer(t, filepath.Join(gavelDir, "last.json"),
+ filepath.Join(".gavel", "sha-1111111deadbeef.json"), "1111111deadbeef")
+
+ srv := testui.NewServer()
+ srv.SetGavelDir(root)
+ handler := srv.Handler()
+
+ resp := doRequest(t, handler, http.MethodGet, "/api/runs", nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+ }
+
+ var entries []testui.RunIndexEntry
+ if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ if len(entries) != 3 {
+ t.Fatalf("got %d entries, want 3: %+v", len(entries), entries)
+ }
+
+ // Pointer first.
+ if entries[0].Pointer != "last" {
+ t.Fatalf("entries[0].Pointer = %q, want last", entries[0].Pointer)
+ }
+ if entries[0].SHA != "1111111" {
+ t.Fatalf("entries[0].SHA = %q, want 1111111", entries[0].SHA)
+ }
+ if entries[0].Counts == nil || entries[0].Counts.Total != 3 {
+ t.Fatalf("entries[0].Counts = %+v, want Total=3", entries[0].Counts)
+ }
+
+ // Snapshots after, newest first by mtime.
+ if entries[1].Pointer != "" {
+ t.Fatalf("entries[1].Pointer = %q, want empty", entries[1].Pointer)
+ }
+ if entries[1].Name != "sha-1111111deadbeef" {
+ t.Fatalf("entries[1].Name = %q, want sha-1111111deadbeef", entries[1].Name)
+ }
+ if entries[2].Name != "sha-abcdef1234567890" {
+ t.Fatalf("entries[2].Name = %q, want sha-abcdef1234567890", entries[2].Name)
+ }
+ if entries[2].Counts == nil || entries[2].Counts.Failed != 1 || entries[2].Counts.Passed != 1 {
+ t.Fatalf("entries[2].Counts = %+v, want Failed=1 Passed=1", entries[2].Counts)
+ }
+}
+
+func TestRunsIndexEndpoint404WhenDirNotSet(t *testing.T) {
+ srv := testui.NewServer()
+ handler := srv.Handler()
+ resp := doRequest(t, handler, http.MethodGet, "/api/runs", nil)
+ if resp.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", resp.Code)
+ }
+}
+
+func TestRunSnapshotEndpointResolvesPointer(t *testing.T) {
+ root := t.TempDir()
+ gavelDir := filepath.Join(root, ".gavel")
+ if err := os.MkdirAll(gavelDir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ snap := testui.Snapshot{
+ Tests: []parsers.Test{{Name: "TestX", Passed: true}},
+ }
+ target := filepath.Join(gavelDir, "sha-deadbeef.json")
+ writeTestSnapshot(t, target, snap, time.Now())
+ writeTestPointer(t, filepath.Join(gavelDir, "last.json"),
+ filepath.Join(".gavel", "sha-deadbeef.json"), "deadbeef")
+
+ srv := testui.NewServer()
+ srv.SetGavelDir(root)
+ handler := srv.Handler()
+
+ resp := doRequest(t, handler, http.MethodGet, "/api/runs/last.json", nil)
+ if resp.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
+ }
+ var got testui.Snapshot
+ if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(got.Tests) != 1 || got.Tests[0].Name != "TestX" {
+ t.Fatalf("got tests %+v, want [TestX]", got.Tests)
+ }
+}
+
+func TestRunSnapshotEndpointRejectsTraversal(t *testing.T) {
+ root := t.TempDir()
+ gavelDir := filepath.Join(root, ".gavel")
+ if err := os.MkdirAll(gavelDir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ srv := testui.NewServer()
+ srv.SetGavelDir(root)
+ handler := srv.Handler()
+
+ for _, name := range []string{"..", "../etc/passwd", "foo/bar.json"} {
+ resp := doRequest(t, handler, http.MethodGet, "/api/runs/"+name, nil)
+ if resp.Code == http.StatusOK {
+ t.Fatalf("traversal %q returned 200; body=%s", name, resp.Body.String())
+ }
+ }
+}
diff --git a/testrunner/ui/src/App.tsx b/testrunner/ui/src/App.tsx
index 2ce375747..f249e3c6b 100644
--- a/testrunner/ui/src/App.tsx
+++ b/testrunner/ui/src/App.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'preact/hooks';
-import type { Test, Snapshot, LinterResult, BenchComparison, DiagnosticsSnapshot, ProcessNode, ProcessDetails, RunMeta } from './types';
+import type { Test, Snapshot, LinterResult, BenchComparison, DiagnosticsSnapshot, ProcessNode, ProcessDetails, RunMeta, RunIndexEntry } from './types';
import { Summary } from './components/Summary';
import { TestNode } from './components/TestNode';
import { DetailPanel, type IgnoreRequest } from './components/DetailPanel';
@@ -10,6 +10,7 @@ import { LintFilterBar, type LintGrouping, type LintFilters } from './components
import { LintView } from './components/LintView';
import { BenchView } from './components/BenchView';
import { RerunDialog } from './components/RerunDialog';
+import { RunIndex } from './components/RunIndex';
import { SplitPane } from './components/SplitPane';
import { copyCurrentViewForAgent, downloadCurrentView } from './export';
import {
@@ -80,13 +81,15 @@ function applySnapshot(
}
function currentRouteState(
+ view: 'index' | 'run',
+ runName: string,
tab: TabKey,
selectedPath: string,
filters: Filters,
lintGrouping: LintGrouping,
lintFilters: LintFilters,
): RouteState {
- return { tab, selectedPath, filters, lintGrouping, lintFilters };
+ return { view, runName, tab, selectedPath, filters, lintGrouping, lintFilters };
}
function mergeProcessDetails(root: ProcessNode | undefined, details: ProcessDetails): ProcessNode | undefined {
@@ -105,7 +108,9 @@ function mergeProcessDetails(root: ProcessNode | undefined, details: ProcessDeta
}
export function App() {
- const initialRoute = typeof window !== 'undefined' ? parseRoute(window.location) : {
+ const initialRoute: RouteState = typeof window !== 'undefined' ? parseRoute(window.location) : {
+ view: 'run',
+ runName: '',
tab: 'tests' as TabKey,
selectedPath: '',
filters: { status: defaultStatusFilter(), framework: new Map() },
@@ -124,6 +129,8 @@ export function App() {
const [status, setStatus] = useState('Loading...');
const [expandAll, setExpandAll] = useState(null);
const [filters, setFilters] = useState(initialRoute.filters);
+ const [activeView, setActiveView] = useState<'index' | 'run'>(initialRoute.view);
+ const [runName, setRunName] = useState(initialRoute.runName);
const [activeTab, setActiveTab] = useState(initialRoute.tab);
const [lintGrouping, setLintGrouping] = useState(initialRoute.lintGrouping);
const [lintFilters, setLintFilters] = useState(initialRoute.lintFilters);
@@ -142,11 +149,13 @@ export function App() {
const copyResetTimer = useRef(null);
const routeState = useMemo(
- () => currentRouteState(activeTab, selectedPath, filters, lintGrouping, lintFilters),
- [activeTab, selectedPath, filters, lintGrouping, lintFilters],
+ () => currentRouteState(activeView, runName, activeTab, selectedPath, filters, lintGrouping, lintFilters),
+ [activeView, runName, activeTab, selectedPath, filters, lintGrouping, lintFilters],
);
const commitRoute = useCallback((next: RouteState, mode: 'push' | 'replace' = 'push') => {
+ setActiveView(next.view);
+ setRunName(next.runName);
setActiveTab(next.tab);
setSelectedPath(next.selectedPath);
setFilters(next.filters);
@@ -163,6 +172,8 @@ export function App() {
useEffect(() => {
const onPopState = () => {
const next = parseRoute(window.location);
+ setActiveView(next.view);
+ setRunName(next.runName);
setActiveTab(next.tab);
setSelectedPath(next.selectedPath);
setFilters(next.filters);
@@ -183,6 +194,27 @@ export function App() {
}, []);
useEffect(() => {
+ if (activeView === 'index') return;
+
+ // Static replay mode: fetch the named snapshot once, no SSE.
+ if (runName) {
+ fetch(apiUrl(`/api/runs/${encodeURIComponent(runName)}`))
+ .then(async r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${(await r.text()).trim()}`);
+ return r.json() as Promise;
+ })
+ .then(snap => {
+ applySnapshot(snap, startTime, endTime, doneRef, setTests, setLint, setLintRun, setBench, setDiagnosticsAvailable, setDiagnostics, setRunMeta, setDone, setStatus);
+ doneRef.current = true;
+ setDone(true);
+ setStatus('Snapshot loaded');
+ })
+ .catch(e => {
+ setStatus(`Failed to load snapshot: ${e?.message || e}`);
+ });
+ return;
+ }
+
if (streamToken === 0) {
fetch(apiUrl('/api/tests'))
.then(r => r.json())
@@ -217,7 +249,7 @@ export function App() {
}, 1000);
return () => { es.close(); clearInterval(timer); };
- }, [streamToken]);
+ }, [streamToken, activeView, runName]);
const fetchDiagnostics = useCallback(async () => {
const res = await fetch(apiUrl('/api/diagnostics'));
@@ -323,6 +355,41 @@ export function App() {
});
}, [routeState, commitRoute]);
+ const onIndexSelect = useCallback((entry: RunIndexEntry) => {
+ // Reset transient run-state so the new snapshot loads cleanly.
+ startTime.current = null;
+ endTime.current = null;
+ doneRef.current = false;
+ setTests([]);
+ setLint(undefined);
+ setLintRun(false);
+ setBench(undefined);
+ setDiagnosticsAvailable(false);
+ setDiagnostics(undefined);
+ setStatus('Loading snapshot...');
+ commitRoute({
+ view: 'run',
+ runName: entry.name,
+ tab: 'tests',
+ selectedPath: '',
+ filters: { status: defaultStatusFilter(), framework: new Map() },
+ lintGrouping: 'linter-rule-file',
+ lintFilters: { severity: new Map(), linter: new Map() },
+ });
+ }, [commitRoute]);
+
+ const onBackToIndex = useCallback(() => {
+ commitRoute({
+ view: 'index',
+ runName: '',
+ tab: 'tests',
+ selectedPath: '',
+ filters: { status: defaultStatusFilter(), framework: new Map() },
+ lintGrouping: 'linter-rule-file',
+ lintFilters: { severity: new Map(), linter: new Map() },
+ });
+ }, [commitRoute]);
+
const onSelect = useCallback((test: Test) => {
const nextPath = test.route_path === selectedPath ? '' : (test.route_path || '');
commitRoute({
@@ -612,6 +679,22 @@ export function App() {
const backTo = typeof window !== 'undefined' ? (window as any).__gavelBackTo as string | undefined : undefined;
+ if (activeView === 'index') {
+ return (
+
+ {backTo && (
+
+ )}
+
+
+ );
+ }
+
return (
{backTo && (
@@ -622,6 +705,16 @@ export function App() {
)}
+ {runName && (
+
+
+ /
+ {runName}
+
+ )}
diff --git a/testrunner/ui/src/components/RunIndex.tsx b/testrunner/ui/src/components/RunIndex.tsx
new file mode 100644
index 000000000..22344e783
--- /dev/null
+++ b/testrunner/ui/src/components/RunIndex.tsx
@@ -0,0 +1,180 @@
+import { useEffect, useState } from 'preact/hooks';
+import type { RunIndexEntry } from '../types';
+import { apiUrl } from '../config';
+
+interface Props {
+ onSelect: (entry: RunIndexEntry) => void;
+}
+
+function relativeTime(iso: string): string {
+ const t = Date.parse(iso);
+ if (Number.isNaN(t)) return '';
+ const diff = Date.now() - t;
+ if (diff < 60_000) return 'just now';
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} min ago`;
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} h ago`;
+ if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} d ago`;
+ return new Date(t).toISOString().slice(0, 16).replace('T', ' ');
+}
+
+function formatDuration(startedISO: string | undefined, endedISO: string | undefined): string {
+ if (!startedISO || !endedISO) return '';
+ const start = Date.parse(startedISO);
+ const end = Date.parse(endedISO);
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) return '';
+ const ms = end - start;
+ if (ms < 1000) return `${ms} ms`;
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
+ const m = Math.floor(ms / 60_000);
+ const s = Math.floor((ms % 60_000) / 1000);
+ return `${m}m ${s}s`;
+}
+
+function entryKey(entry: RunIndexEntry): string {
+ return entry.pointer ? `pointer:${entry.pointer}` : `name:${entry.name}`;
+}
+
+export function RunIndex({ onSelect }: Props) {
+ const [entries, setEntries] = useState
(null);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ fetch(apiUrl('/api/runs'))
+ .then(async r => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}: ${(await r.text()).trim()}`);
+ return r.json() as Promise;
+ })
+ .then(data => { if (!cancelled) setEntries(data); })
+ .catch(e => { if (!cancelled) setError(e?.message || String(e)); });
+ return () => { cancelled = true; };
+ }, []);
+
+ return (
+
+
+
+
+
+ Saved Runs
+
+ {entries && (
+
+ {entries.length} {entries.length === 1 ? 'snapshot' : 'snapshots'} in .gavel/
+
+ )}
+
+
+ {error && (
+
+ Failed to load run index: {error}
+
+ )}
+
+ {!entries && !error && (
+
+ )}
+
+ {entries && entries.length === 0 && (
+
+ No snapshots found in .gavel/. Run gavel test to populate it.
+
+ )}
+
+ {entries && entries.length > 0 && (
+
+
+
+
+ | Run |
+ Modified |
+ Tests |
+ Lint |
+ Duration |
+
+
+
+ {entries.map(entry => (
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
+
+interface RowProps {
+ entry: RunIndexEntry;
+ onSelect: (entry: RunIndexEntry) => void;
+}
+
+function RunIndexRow({ entry, onSelect }: RowProps) {
+ const counts = entry.counts;
+ const failed = counts?.failed ?? 0;
+ const passed = counts?.passed ?? 0;
+ const skipped = counts?.skipped ?? 0;
+ const pending = counts?.pending ?? 0;
+ const total = counts?.total ?? 0;
+ const lint = entry.lint ?? 0;
+
+ return (
+ onSelect(entry)}
+ >
+ |
+
+ {entry.pointer ? (
+
+
+ {entry.pointer}
+
+ ) : (
+
+ )}
+
+ {entry.pointer ? entry.name : entry.name}
+
+ {entry.sha && (
+ @{entry.sha}
+ )}
+
+ {entry.error && (
+ {entry.error}
+ )}
+ |
+
+ {relativeTime(entry.modified)}
+ |
+
+ {total > 0 ? (
+
+ {failed > 0 && {failed} failed}
+ {failed > 0 && passed > 0 && ·}
+ {passed > 0 && {passed} passed}
+ {skipped > 0 && <>·{skipped} skipped>}
+ {pending > 0 && <>·{pending} pending>}
+
+ ) : (
+ —
+ )}
+ |
+
+ {lint > 0 ? (
+ {lint}
+ ) : (
+ —
+ )}
+ |
+
+ {formatDuration(entry.started, entry.ended) || —}
+ |
+
+ );
+}
diff --git a/testrunner/ui/src/routes.test.ts b/testrunner/ui/src/routes.test.ts
index 701423b6e..9844145a4 100644
--- a/testrunner/ui/src/routes.test.ts
+++ b/testrunner/ui/src/routes.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { buildRoute, defaultStatusFilter, parseRoute } from './routes';
+
+// vitest runs in node by default; routes.ts -> config.ts touches `window`
+// at import time. Stub it before importing.
+(globalThis as any).window = (globalThis as any).window || {};
+
+const { buildRoute, defaultStatusFilter, parseRoute } = await import('./routes');
describe('lint route grouping', () => {
it('defaults lint routes to linter-rule-file grouping', () => {
@@ -17,6 +22,8 @@ describe('lint route grouping', () => {
it('omits the default grouping from lint URLs and keeps legacy grouping params', () => {
const baseState = {
+ view: 'run' as const,
+ runName: '',
tab: 'lint' as const,
selectedPath: '',
filters: { status: defaultStatusFilter(), framework: new Map() },
@@ -34,3 +41,58 @@ describe('lint route grouping', () => {
})).toBe('/lint?grouping=linter-file');
});
});
+
+describe('run index routes', () => {
+ it('parses / as the index view', () => {
+ const route = parseRoute(new URL('http://example.test/') as unknown as Location);
+ expect(route.view).toBe('index');
+ expect(route.runName).toBe('');
+ });
+
+ it('parses /run/ as the run view with default tab', () => {
+ const route = parseRoute(new URL('http://example.test/run/sha-abc123') as unknown as Location);
+ expect(route.view).toBe('run');
+ expect(route.runName).toBe('sha-abc123');
+ expect(route.tab).toBe('tests');
+ });
+
+ it('parses /run//lint/?status=failed', () => {
+ const route = parseRoute(new URL('http://example.test/run/last/lint/golangci?status=failed') as unknown as Location);
+ expect(route.view).toBe('run');
+ expect(route.runName).toBe('last');
+ expect(route.tab).toBe('lint');
+ expect(route.selectedPath).toBe('golangci');
+ });
+
+ it('builds /run// for run-detail URLs', () => {
+ expect(buildRoute({
+ view: 'run',
+ runName: 'sha-abc123',
+ tab: 'tests',
+ selectedPath: '',
+ filters: { status: defaultStatusFilter(), framework: new Map() },
+ lintGrouping: 'linter-rule-file',
+ lintFilters: { severity: new Map(), linter: new Map() },
+ })).toBe('/run/sha-abc123/tests');
+ });
+
+ it('builds / for the index view regardless of other state', () => {
+ expect(buildRoute({
+ view: 'index',
+ runName: '',
+ tab: 'tests',
+ selectedPath: '',
+ filters: { status: defaultStatusFilter(), framework: new Map() },
+ lintGrouping: 'linter-rule-file',
+ lintFilters: { severity: new Map(), linter: new Map() },
+ })).toBe('/');
+ });
+
+ it('keeps backward-compat: /tests still parses as a run view with empty runName', () => {
+ const route = parseRoute(new URL('http://example.test/tests/foo') as unknown as Location);
+ expect(route.view).toBe('run');
+ expect(route.runName).toBe('');
+ expect(route.tab).toBe('tests');
+ expect(route.selectedPath).toBe('foo');
+ });
+});
diff --git a/testrunner/ui/src/routes.ts b/testrunner/ui/src/routes.ts
index 216191b6f..051cba4c5 100644
--- a/testrunner/ui/src/routes.ts
+++ b/testrunner/ui/src/routes.ts
@@ -22,8 +22,14 @@ function isDefaultStatusFilter(state: FilterState): boolean {
}
export type TabKey = 'tests' | 'lint' | 'bench' | 'diagnostics';
+export type ViewKey = 'index' | 'run';
export interface RouteState {
+ view: ViewKey;
+ // runName identifies which .gavel/.json the detail view should fetch.
+ // Empty string means "use the in-memory snapshot" — the case when `gavel ui
+ // serve ` was launched with an explicit path.
+ runName: string;
tab: TabKey;
selectedPath: string;
filters: Filters;
@@ -45,12 +51,27 @@ export function parseRoute(location: Location): RouteState {
}
const trimmed = pathname.replace(/^\/+|\/+$/g, '');
const segments = trimmed ? trimmed.split('/').map(decodeURIComponent) : [];
+ let view: ViewKey = 'run';
+ let runName = '';
let tab: TabKey = 'tests';
let selectedPath = '';
+ let tabSegments = segments;
+
+ if (segments.length === 0) {
+ view = 'index';
+ tabSegments = [];
+ } else if (segments[0] === 'run') {
+ runName = segments[1] || '';
+ if (!runName) {
+ // /run with no name behaves like the index — defensive against half-typed URLs.
+ view = 'index';
+ }
+ tabSegments = segments.slice(2);
+ }
- if (segments[0] === 'tests' || segments[0] === 'lint' || segments[0] === 'bench' || segments[0] === 'diagnostics') {
- tab = segments[0];
- selectedPath = segments.slice(1).join('/');
+ if (tabSegments[0] === 'tests' || tabSegments[0] === 'lint' || tabSegments[0] === 'bench' || tabSegments[0] === 'diagnostics') {
+ tab = tabSegments[0];
+ selectedPath = tabSegments.slice(1).join('/');
}
const params = new URLSearchParams(location.search);
@@ -64,6 +85,8 @@ export function parseRoute(location: Location): RouteState {
status = decodeFilterState(splitCSV(rawStatus));
}
return {
+ view,
+ runName,
tab,
selectedPath,
filters: {
@@ -79,7 +102,13 @@ export function parseRoute(location: Location): RouteState {
}
export function buildRoute(state: RouteState): string {
- const segments: string[] = [state.tab];
+ if (state.view === 'index') {
+ return `${basePath}/`;
+ }
+
+ const segments: string[] = [];
+ if (state.runName) segments.push('run', encodeURIComponent(state.runName));
+ segments.push(state.tab);
if (state.selectedPath) segments.push(...state.selectedPath.split('/').map(encodeURIComponent));
const params = new URLSearchParams();
diff --git a/testrunner/ui/src/types.ts b/testrunner/ui/src/types.ts
index 2ed6cb33e..458794631 100644
--- a/testrunner/ui/src/types.ts
+++ b/testrunner/ui/src/types.ts
@@ -188,3 +188,24 @@ export interface FixtureContext {
expected?: any;
actual?: any;
}
+
+export interface RunCounts {
+ total: number;
+ passed: number;
+ failed: number;
+ skipped: number;
+ pending: number;
+}
+
+export interface RunIndexEntry {
+ name: string;
+ path: string;
+ pointer?: string;
+ modified: string;
+ sha?: string;
+ started?: string;
+ ended?: string;
+ counts?: RunCounts;
+ lint?: number;
+ error?: string;
+}
diff --git a/utils/walk.go b/utils/walk.go
index b8c4eebb3..b18b540cd 100644
--- a/utils/walk.go
+++ b/utils/walk.go
@@ -13,6 +13,27 @@ import (
type walkStopFn func(root, path string, d fs.DirEntry) (bool, error)
+// disableGitignoreWalk, when true, makes WalkGitIgnored / WalkGitIgnoredBounded
+// behave like a plain filepath.WalkDir (still skipping .git and respecting the
+// stopAtNestedProjectRoot bound, but ignoring .gitignore patterns).
+//
+// This is set at most once per CLI invocation from the testrunner orchestrator
+// when the user passes --no-gitignore. It is process-global because the Runner
+// interface threads no per-call options through to discovery, and propagating
+// one would ripple through every runner implementation. Acceptable trade-off
+// for a CLI binary.
+var disableGitignoreWalk bool
+
+// SetGitignoreDisabled toggles the global gitignore-aware walking flag.
+// Pass true to make subsequent WalkGitIgnored / WalkGitIgnoredBounded calls
+// skip .gitignore filtering. Returns the previous value so callers can
+// restore it (useful in tests).
+func SetGitignoreDisabled(v bool) bool {
+ prev := disableGitignoreWalk
+ disableGitignoreWalk = v
+ return prev
+}
+
func FindGitRoot(dir string) string {
dir, _ = filepath.Abs(dir)
for {
@@ -135,7 +156,7 @@ func WalkGitIgnoredBounded(root string, fn fs.WalkDirFunc, allowList ...string)
func walkGitIgnored(root string, fn fs.WalkDirFunc, stop walkStopFn, allowList ...string) error {
root, _ = filepath.Abs(root)
gitRoot := FindGitRoot(root)
- if gitRoot == "" {
+ if gitRoot == "" || disableGitignoreWalk {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return fn(path, d, err)
@@ -243,8 +264,12 @@ func stopAtNestedProjectRoot(root, path string, d fs.DirEntry) (bool, error) {
// FilterGitIgnored returns the subset of absolute paths that are not matched
// by .gitignore patterns. Uses go-git's ReadPatterns to recursively load all
-// .gitignore files. If no git root is found, all paths are returned.
+// .gitignore files. If no git root is found or the global disable flag is
+// set (see SetGitignoreDisabled), all paths are returned.
func FilterGitIgnored(paths []string, dir string) []string {
+ if disableGitignoreWalk {
+ return paths
+ }
dir, _ = filepath.Abs(dir)
gitRoot := FindGitRoot(dir)
if gitRoot == "" {
diff --git a/utils/walk_test.go b/utils/walk_test.go
index c942d5e63..b504c33ab 100644
--- a/utils/walk_test.go
+++ b/utils/walk_test.go
@@ -381,3 +381,66 @@ var _ = Describe("FilterGitIgnored", func() {
Expect(result).To(BeEmpty())
})
})
+
+var _ = Describe("SetGitignoreDisabled", func() {
+ var root string
+
+ BeforeEach(func() {
+ root = GinkgoT().TempDir()
+ })
+
+ AfterEach(func() {
+ // Always restore the default; test isolation depends on this.
+ SetGitignoreDisabled(false)
+ })
+
+ It("WalkGitIgnored honors gitignored paths when enabled (default)", func() {
+ setupGitRepo(root)
+ os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored/\n"), 0644)
+ os.MkdirAll(filepath.Join(root, "ignored"), 0755)
+ os.WriteFile(filepath.Join(root, "ignored", "x.go"), nil, 0644)
+ os.WriteFile(filepath.Join(root, "main.go"), nil, 0644)
+
+ paths, err := collectPaths(root)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(paths).To(ContainElement("main.go"))
+ Expect(paths).NotTo(ContainElement("ignored"))
+ })
+
+ It("WalkGitIgnored ignores .gitignore once disabled", func() {
+ setupGitRepo(root)
+ os.WriteFile(filepath.Join(root, ".gitignore"), []byte("ignored/\n"), 0644)
+ os.MkdirAll(filepath.Join(root, "ignored"), 0755)
+ os.WriteFile(filepath.Join(root, "ignored", "x.go"), nil, 0644)
+ os.WriteFile(filepath.Join(root, "main.go"), nil, 0644)
+
+ SetGitignoreDisabled(true)
+
+ paths, err := collectPaths(root)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(paths).To(ContainElement("main.go"))
+ Expect(paths).To(ContainElement("ignored"))
+ Expect(paths).To(ContainElement("ignored/x.go"))
+ })
+
+ It("FilterGitIgnored is bypassed once disabled", func() {
+ setupGitRepo(root)
+ os.WriteFile(filepath.Join(root, ".gitignore"), []byte("*.log\n"), 0644)
+
+ paths := []string{
+ filepath.Join(root, "main.go"),
+ filepath.Join(root, "debug.log"),
+ }
+
+ SetGitignoreDisabled(true)
+ result := FilterGitIgnored(paths, root)
+ Expect(result).To(ConsistOf(paths))
+ })
+
+ It("returns the previous value so callers can restore it", func() {
+ prev := SetGitignoreDisabled(true)
+ Expect(prev).To(BeFalse())
+ prev2 := SetGitignoreDisabled(false)
+ Expect(prev2).To(BeTrue())
+ })
+})