Files
OpenFrontIO/scripts/pr-gate/rules.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

129 lines
3.3 KiB
TypeScript

import {
APPROVED_ISSUE_LABEL,
LABELS,
SMALL_FIX_LINE_THRESHOLD,
TRUSTED_AUTHOR_ASSOCIATIONS,
} from "./config";
export type PRMetadata = {
number: number;
body: string | null;
user: { login: string };
author_association: string;
labels: string[];
};
export type PRFile = {
additions: number;
deletions: number;
};
export type IssueMetadata = {
number: number;
labels: string[];
assignees: { login: string }[];
};
export type GetIssue = (issueNumber: number) => Promise<IssueMetadata | null>;
export type RuleResult =
| { action: "pass"; reason: string; labelToAdd?: string }
| { action: "close"; reason: string }
| { action: "next" };
const LINKED_ISSUE_RE =
/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/gi;
export function parseLinkedIssues(body: string | null): number[] {
if (!body) return [];
const stripped = body
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`\n]*`/g, "");
const result = new Set<number>();
for (const m of stripped.matchAll(LINKED_ISSUE_RE)) {
result.add(parseInt(m[1], 10));
}
return [...result];
}
export function checkBypass(pr: PRMetadata): RuleResult {
if (pr.labels.includes(LABELS.BYPASS)) {
return { action: "pass", reason: `PR has "${LABELS.BYPASS}" label` };
}
return { action: "next" };
}
export function checkOrgMember(pr: PRMetadata): RuleResult {
if (
(TRUSTED_AUTHOR_ASSOCIATIONS as readonly string[]).includes(
pr.author_association,
)
) {
return { action: "pass", reason: `Author is ${pr.author_association}` };
}
return { action: "next" };
}
export async function checkApprovedWork(
pr: PRMetadata,
getIssue: GetIssue,
): Promise<RuleResult> {
const issueNumbers = parseLinkedIssues(pr.body);
if (issueNumbers.length === 0) return { action: "next" };
for (const issueNumber of issueNumbers) {
const issue = await getIssue(issueNumber);
if (!issue) continue;
if (!issue.labels.includes(APPROVED_ISSUE_LABEL)) continue;
const assigneeLogins = issue.assignees.map((a) => a.login);
if (!assigneeLogins.includes(pr.user.login)) continue;
return {
action: "pass",
reason: `Linked to #${issueNumber} (labelled "${APPROVED_ISSUE_LABEL}"), author is assigned`,
};
}
return { action: "next" };
}
export function checkSmallFix(files: PRFile[]): RuleResult {
const totalLines = files.reduce(
(sum, f) => sum + f.additions + f.deletions,
0,
);
if (totalLines <= SMALL_FIX_LINE_THRESHOLD) {
return {
action: "pass",
reason: `Diff is ${totalLines} lines (≤ ${SMALL_FIX_LINE_THRESHOLD})`,
labelToAdd: LABELS.SMALL_FIX,
};
}
return { action: "next" };
}
export async function evaluate(
pr: PRMetadata,
files: PRFile[],
getIssue: GetIssue,
): Promise<RuleResult> {
const r0 = checkBypass(pr);
if (r0.action !== "next") return r0;
const r1 = checkOrgMember(pr);
if (r1.action !== "next") return r1;
const r2 = await checkApprovedWork(pr, getIssue);
if (r2.action !== "next") return r2;
const r3 = checkSmallFix(files);
if (r3.action !== "next") return r3;
const totalLines = files.reduce(
(sum, f) => sum + f.additions + f.deletions,
0,
);
return {
action: "close",
reason: `No linked "${APPROVED_ISSUE_LABEL}" issue with author assigned, diff is ${totalLines} lines`,
};
}