Files
OpenFrontIO/scripts/pr-gate/index.ts
T
Evan b56e9438d1 github PR gate (#4070)
## Description:

## Summary

Adds a GitHub Action that auto-closes PRs which don't follow the
contribution workflow, so maintainer review time goes to legitimate
contributions instead of off-roadmap or AI-generated submissions.

Triggered on `pull_request_target: [opened, reopened]` and **defaults to
dry-run** so it's safe to merge before flipping live.

## Gate logic (first match wins)

1. **Maintainer bypass** — PR carries `bypass-pr-check` label → pass.
2. **Org/repo member** — `author_association` is `OWNER` / `MEMBER` /
`COLLABORATOR` → pass.
3. **Approved work** — PR body links an issue (`Closes/Fixes/Resolves
#N`) that carries the `approved` label and the PR author is in the
issue's assignees → pass.
4. **Small fix** — `additions + deletions ≤ 50` → pass + apply
`small-fix` label.
5. **Otherwise** — apply `auto-closed-needs-issue` label, post rejection
comment, close.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan
2026-05-29 18:28:52 -07:00

98 lines
2.7 KiB
TypeScript

import { CLOSE_COMMENT, LABELS } from "./config";
import {
addLabel,
closePR,
ensureLabel,
getIssue,
getPR,
getPRFiles,
makeOctokit,
postComment,
} from "./github";
import { evaluate } from "./rules";
function parseArgs(argv: string[]): {
prNumber: number | null;
dryRunOverride: boolean | null;
} {
let prNumber: number | null = null;
let dryRunOverride: boolean | null = null;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--pr") {
const next = argv[i + 1];
if (next) prNumber = parseInt(next, 10);
i++;
} else if (arg === "--dry-run") {
dryRunOverride = true;
} else if (arg === "--no-dry-run") {
dryRunOverride = false;
}
}
return { prNumber, dryRunOverride };
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const cliMode = args.prNumber !== null;
const prNumber = args.prNumber ?? parseInt(process.env.PR_NUMBER ?? "", 10);
if (!prNumber || Number.isNaN(prNumber)) {
throw new Error(
"PR number missing — set PR_NUMBER env or pass --pr <number>",
);
}
const token = process.env.GITHUB_TOKEN;
if (!token) throw new Error("GITHUB_TOKEN env var is required");
// CLI mode: always dry-run unless --no-dry-run is explicitly passed.
// Workflow mode: read DRY_RUN env, default true.
const dryRun = cliMode
? args.dryRunOverride !== false
: (process.env.DRY_RUN ?? "true").toLowerCase() !== "false";
const octokit = makeOctokit(token);
const pr = await getPR(octokit, prNumber);
const files = await getPRFiles(octokit, prNumber);
const decision = await evaluate(pr, files, (n) => getIssue(octokit, n));
const prefix = `[pr-gate] PR #${prNumber}`;
if (decision.action === "pass") {
const labelNote = decision.labelToAdd
? ` — label: ${decision.labelToAdd}`
: "";
console.log(
`${prefix} → decision: pass — reason: ${decision.reason}${labelNote}`,
);
} else {
console.log(`${prefix} → decision: close — reason: ${decision.reason}`);
}
if (dryRun) {
console.log(`${prefix} → DRY_RUN=true, no action taken`);
return;
}
if (decision.action === "pass") {
if (decision.labelToAdd) {
await ensureLabel(octokit, decision.labelToAdd);
await addLabel(octokit, prNumber, decision.labelToAdd);
}
return;
}
// Comment must land on an open PR — post before closing.
await ensureLabel(octokit, LABELS.AUTO_CLOSED);
await addLabel(octokit, prNumber, LABELS.AUTO_CLOSED);
await postComment(octokit, prNumber, CLOSE_COMMENT(pr.user.login));
await closePR(octokit, prNumber);
}
main().catch((err) => {
console.error("[pr-gate] Unexpected error:", err);
process.exit(1);
});