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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-04 14:09:03 +00:00
parent 5fdf5876ff
commit 42215b1979
3 changed files with 130 additions and 0 deletions

40
.github/workflows/auto-merge.yml vendored Normal file
View file

@ -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}`);

42
.github/workflows/pr-gate.yml vendored Normal file
View file

@ -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.`
);

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"regexp"
"strings" "strings"
"time" "time"
@ -147,6 +148,7 @@ type CheckResult struct {
Duration string `json:"duration"` Duration string `json:"duration"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Output string `json:"output,omitempty"` Output string `json:"output,omitempty"`
FixHint string `json:"fix_hint,omitempty"`
} }
func runGoQA(cmd *cli.Command, args []string) error { func runGoQA(cmd *cli.Command, args []string) error {
@ -218,6 +220,7 @@ func runGoQA(cmd *cli.Command, args []string) error {
if qaVerbose { if qaVerbose {
result.Output = output result.Output = output
} }
result.FixHint = fixHintFor(check.Name, output)
failed++ failed++
if !qaJSON && !qaQuiet { if !qaJSON && !qaQuiet {
@ -225,6 +228,9 @@ func runGoQA(cmd *cli.Command, args []string) error {
if qaVerbose && output != "" { if qaVerbose && output != "" {
cli.Text(output) cli.Text(output)
} }
if result.FixHint != "" {
cli.Hint("fix", result.FixHint)
}
} }
if qaFailFast { if qaFailFast {
@ -260,6 +266,7 @@ func runGoQA(cmd *cli.Command, args []string) error {
if !qaJSON && !qaQuiet { if !qaJSON && !qaQuiet {
cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n", cli.Print(" %s Coverage %.1f%% below threshold %.1f%%\n",
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold) 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 <TestName> -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/<Target>/."
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) { func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
// Handle internal checks // Handle internal checks
if check.Command == "_internal_" { if check.Command == "_internal_" {