Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/actions/setup-go/action.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: "Setup Go"
description: |
Sets up the Go environment for tests, builds, etc.
inputs:
version:
description: "The Go version to use."
default: "1.26.2"
use-cache:
description: "Whether to use the cache."
default: "true"
runs:
using: "composite"
steps:
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: ${{ inputs.version }}
cache: ${{ inputs.use-cache }}

# It isn't necessary that we ever do this, but it helps separate the "setup"
# from the "run" times.
- name: go mod download
shell: bash
run: go mod download -x
68 changes: 68 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: quality

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

permissions:
contents: read

# Cancel in-progress runs for pull requests when developers push additional
# changes.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
fmt:
name: fmt
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: make fmt
run: make fmt

- name: Check unstaged
run: |
if [[ -n $(git ls-files --other --modified --exclude-standard) ]]; then
echo "Unexpected difference in directories after formatting. Run 'make fmt' and include the output in the commit."
exit 1
fi

lint:
name: lint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Setup Go
uses: ./.github/actions/setup-go

- name: make lint
run: make lint

test:
name: test
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

- name: Setup Go
uses: ./.github/actions/setup-go

- name: make test
run: make test
34 changes: 34 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Code coverage profiles and other test artifacts
*.out
coverage.*
*.coverprofile
profile.cov

# Go workspace file
go.work
go.work.sum

# env file
.env

# Editor/IDE
.idea/
.vscode/

# Key files
*.key
*.pub
*.pem

# Output directory
build/
28 changes: 28 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
version: "2"

linters:
exclusions:
rules:
- path: _test\.go
linters:
- gosec
text: "G304: Potential file inclusion via variable"
enable:
- goconst
- gocritic
- gosec
- misspell
- nakedret
- revive
- unconvert
- unparam
settings:
govet:
enable:
- shadow
misspell:
locale: US
revive:
rules:
- name: package-comments
disabled: true
14 changes: 14 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"printWidth": 120,
"semi": false,
"trailingComma": "all",
"overrides": [
{
"files": ["./*.md", "./**/*.md"],
"options": {
"printWidth": 80,
"proseWrap": "always"
}
}
]
}
27 changes: 27 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FIND_EXCLUSIONS= \
-not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path '*/.terraform/*' \) -prune \)
GO_SRC_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -not -name '*_test.go')
GO_FMT_FILES := $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go' -print0 | xargs -0 grep -E --null -L '^// Code generated .* DO NOT EDIT\.$$' | tr '\0' ' ')

default: build

build/whichtests: $(GO_SRC_FILES) go.mod go.sum
mkdir -p ./build
go build -o ./build/whichtests .

build: build/whichtests
.PHONY: build

fmt:
go mod tidy
go run golang.org/x/tools/cmd/goimports@v0.35.0 -w $(GO_FMT_FILES)
go run mvdan.cc/gofumpt@v0.8.0 -w -l $(GO_FMT_FILES)
.PHONY: fmt

lint:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.4.0 run ./...
.PHONY: lint

test:
go test -test.v -timeout 30s -cover ./...
.PHONY: test
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# whichtests

`whichtests` is the Go test-plan generator that drives the `flake-go` CI
workflow in `coder/coder`. Given a base/head git revision pair (or a
GitHub Actions event), it walks the diff, parses each changed test
file, picks the smallest set of tests to rerun, and emits a workflow
matrix plus a human-readable Markdown summary.

## Building and running

```sh
go build ./
./whichtests --help
```

Typical invocation against the local working tree:

```sh
./whichtests \
--repo-root . \
--base-sha origin/main \
--head-sha HEAD \
--out-matrix ./flake-matrix.json \
--out-summary -
```

In GitHub Actions:

```sh
go run ./ \
--repo-root . \
--github-actions \
--out-matrix "$RUNNER_TEMP/flake-matrix.json"
```

For `pull_request` events, checkout must use the PR head SHA, for example `github.event.pull_request.head.sha`. The default synthetic merge ref is rejected because the checked-out `HEAD` must match `pull_request.head.sha`.

The matrix JSON contains `include` rows with `package`, `run_regex`, and `test_count`. Each row represents one safe Go package pattern and a precise regex for the directly changed runnable tests in that package. The generator does not emit whole-package fallback rows.

## File layout

The binary is a single `package main`, split into focused files:

| File | Responsibility |
| --------------- | ------------------------------------------------------------------- |
| `cli.go` | `main`, flag parsing, command orchestration (`runCommand`). |
| `config.go` | `config` / `commandConfig` types and defaults. |
| `request.go` | `runRequest`, `diffRange`, revision validation. |
| `gitexec.go` | `gitRunner` / `gitFetcher` types and the real `exec.Command` impl. |
| `diff.go` | Reading and parsing `git diff`, change kinds, hunks, line ranges. |
| `snapshot.go` | AST snapshot parsing, `fileSnapshot`, and top-level test ranges. |
| `selection.go` | Per-change direct test selection logic (`selectChange`). |
| `inventory.go` | `inventoryCache` for package/directory test discovery. |
| `plan.go` | Plan construction, matrix and summary rendering (`buildExecutionPlan`, `selectTestPlan`). |
| `githubactions.go` | GitHub Actions request builder and history preparation. |
| `publish.go` | Single sink for matrix and summary outputs. |

## Testing

```sh
go test ./...
```
95 changes: 95 additions & 0 deletions cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Command whichtests produces deterministic Go test plans for the
// flake-go workflow.
package main

import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
)

func main() {
cfg := defaultCommandConfig()
flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
flags.StringVar(&cfg.RepoRoot, "repo-root", cfg.RepoRoot, "repository root")
flags.StringVar(&cfg.BaseSHA, "base-sha", cfg.BaseSHA, "base revision to diff against")
flags.StringVar(&cfg.HeadSHA, "head-sha", cfg.HeadSHA, "head revision to diff against")
flags.StringVar(&cfg.OutMatrix, "out-matrix", cfg.OutMatrix, "path to write workflow matrix JSON")
flags.StringVar(&cfg.OutSummary, "out-summary", cfg.OutSummary, "path to write Markdown summary, or - for stdout")
flags.BoolVar(&cfg.GitHubActions, "github-actions", cfg.GitHubActions, "read diff range and output paths from GitHub Actions environment")
if err := flags.Parse(os.Args[1:]); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if err := runCommand(context.Background(), cfg, os.Stdout, os.Stderr, execGit, execGitFetch); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func runCommand(ctx context.Context, cfg commandConfig, stdout, stderr io.Writer, git gitRunner, fetch gitFetcher) error {
var (
req runRequest
err error
)
if cfg.GitHubActions {
req, err = githubActionsRunRequest(ctx, cfg, git)
} else {
req, err = explicitRunRequest(cfg.config)
}
if err != nil {
return err
}
return executeRunRequest(ctx, req, stdout, stderr, git, fetch)
}

func explicitRunRequest(cfg config) (runRequest, error) {
cfg = cfg.withDefaults()
if cfg.BaseSHA == "" {
return runRequest{}, errors.New("--base-sha is required")
}
if cfg.OutMatrix == "" {
return runRequest{}, errors.New("--out-matrix is required")
}
if err := validateRevisionArg("--base-sha", cfg.BaseSHA); err != nil {
return runRequest{}, err
}
if err := validateRevisionArg("--head-sha", cfg.HeadSHA); err != nil {
return runRequest{}, err
}
return runRequest{
RepoRoot: cfg.RepoRoot,
Range: diffRange{
BaseSHA: cfg.BaseSHA,
HeadSHA: cfg.HeadSHA,
},
Sinks: outputSinks{
OutMatrix: cfg.OutMatrix,
OutSummary: cfg.OutSummary,
},
}, nil
}

func executeRunRequest(ctx context.Context, req runRequest, stdout, stderr io.Writer, git gitRunner, fetch gitFetcher) error {
if err := ensureRangeAvailable(ctx, &req, git, fetch); err != nil {
return err
}
selectorCfg := config{
RepoRoot: req.RepoRoot,
BaseSHA: req.Range.BaseSHA,
HeadSHA: req.Range.HeadSHA,
}
changedFiles, result, err := selectTestPlan(ctx, selectorCfg, git)
if err != nil {
return err
}
summary := renderSummary(changedFiles, result.Summary)
if err := publishPlan(req.Sinks, result.Matrix, summary, stdout); err != nil {
return err
}
_, _ = fmt.Fprintf(stderr, "selected %d package targets from %d changed test files\n", len(result.Matrix.Include), len(changedFiles))
return nil
}
Loading
Loading