From 42215b19790fe8a873e65d178aaa9df353f0426e Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Feb 2026 14:09:03 +0000 Subject: [PATCH] feat(ci): auto-merge pipeline, org gate, and QA fix hints Add auto-merge workflow for org member PRs, external PR gate with label-based approval, and actionable fix instructions for QA failures. - auto-merge.yml: enable squash auto-merge for org member PRs - pr-gate.yml: org-gate check blocks external PRs without label - cmd_qa.go: add FixHint field, fixHintFor(), extractFailingTest() - Ruleset: thread resolution, stale review dismissal, 1min merge wait Co-Authored-By: Claude Opus 4.5 --- .github/workflows/auto-merge.yml | 40 ++++++++++++++++++++++++++ .github/workflows/pr-gate.yml | 42 ++++++++++++++++++++++++++++ internal/cmd/go/cmd_qa.go | 48 ++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 .github/workflows/auto-merge.yml create mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 00000000..ec3cf86b --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,40 @@ +name: Auto Merge + +on: + pull_request: + types: [opened, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + if: "!github.event.pull_request.draft" + runs-on: ubuntu-latest + steps: + - name: Check org membership and enable auto-merge + uses: actions/github-script@v7 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const { owner, repo } = context.repo; + const author = context.payload.pull_request.user.login; + + try { + await github.rest.orgs.checkMembershipForUser({ + org: owner, + username: author, + }); + } catch { + core.info(`${author} is not an org member — skipping auto-merge`); + return; + } + + await exec.exec('gh', [ + 'pr', 'merge', process.env.PR_NUMBER, + '--auto', '--squash', + ]); + core.info(`Auto-merge enabled for #${process.env.PR_NUMBER}`); diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..299f186b --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,42 @@ +name: PR Gate + +on: + pull_request_target: + types: [opened, synchronize, reopened, labeled] + +permissions: + contents: read + +jobs: + org-gate: + runs-on: ubuntu-latest + steps: + - name: Check org membership or approval label + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const author = context.payload.pull_request.user.login; + + // Check if author is an org member + try { + await github.rest.orgs.checkMembershipForUser({ + org: owner, + username: author, + }); + core.info(`${author} is an org member — gate passed`); + return; + } catch { + core.info(`${author} is not an org member — checking for label`); + } + + // Check for external-approved label + const labels = context.payload.pull_request.labels.map(l => l.name); + if (labels.includes('external-approved')) { + core.info('external-approved label present — gate passed'); + return; + } + + core.setFailed( + `External PR from ${author} requires an org member to add the "external-approved" label before merge.` + ); diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index 2ac1dfc5..527b6003 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "regexp" "strings" "time" @@ -147,6 +148,7 @@ type CheckResult struct { Duration string `json:"duration"` Error string `json:"error,omitempty"` Output string `json:"output,omitempty"` + FixHint string `json:"fix_hint,omitempty"` } func runGoQA(cmd *cli.Command, args []string) error { @@ -218,6 +220,7 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose { result.Output = output } + result.FixHint = fixHintFor(check.Name, output) failed++ if !qaJSON && !qaQuiet { @@ -225,6 +228,9 @@ func runGoQA(cmd *cli.Command, args []string) error { if qaVerbose && output != "" { cli.Text(output) } + if result.FixHint != "" { + cli.Hint("fix", result.FixHint) + } } if qaFailFast { @@ -260,6 +266,7 @@ func runGoQA(cmd *cli.Command, args []string) error { if !qaJSON && !qaQuiet { cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) + cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.") } } } @@ -436,6 +443,47 @@ func buildCheck(name string) QACheck { } } +// fixHintFor returns an actionable fix instruction for a given check failure. +func fixHintFor(checkName, output string) string { + switch checkName { + case "format", "fmt": + return "Run 'core go qa fmt --fix' to auto-format." + case "vet": + return "Fix the issues reported by go vet — typically genuine bugs." + case "lint": + return "Run 'core go qa lint --fix' for auto-fixable issues." + case "test": + if name := extractFailingTest(output); name != "" { + return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name) + } + return "Run 'go test -run -v ./path/' to debug." + case "race": + return "Data race detected. Add mutex, channel, or atomic to synchronise shared state." + case "bench": + return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce." + case "vuln": + return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'." + case "sec": + return "Review gosec findings. Common fixes: validate inputs, parameterised queries." + case "fuzz": + return "Add a regression test for the crashing input in testdata/fuzz//." + case "docblock": + return "Add doc comments to exported symbols: '// Name does X.' before each declaration." + default: + return "" + } +} + +var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`) + +// extractFailingTest parses the first failing test name from go test output. +func extractFailingTest(output string) string { + if m := failTestRe.FindStringSubmatch(output); len(m) > 1 { + return m[1] + } + return "" +} + func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) { // Handle internal checks if check.Command == "_internal_" {