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:
parent
5fdf5876ff
commit
42215b1979
3 changed files with 130 additions and 0 deletions
40
.github/workflows/auto-merge.yml
vendored
Normal file
40
.github/workflows/auto-merge.yml
vendored
Normal 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
42
.github/workflows/pr-gate.yml
vendored
Normal 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.`
|
||||||
|
);
|
||||||
|
|
@ -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_" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue