mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 19:54:36 +00:00
c63bfb6d94
## What Adds a trusted-bot exception to the PR gate so Dependabot's PRs are no longer auto-closed. ## Why The PR gate (`scripts/pr-gate/`, run by `.github/workflows/pr-gate.yml`) auto-closes PRs that don't fit the contribution workflow. Dependabot PRs were getting closed because the bot: - has no repo permission, - links no `approved` issue, and - opens dependency bumps that often exceed the 50-line small-fix cap. ## How - `config.ts` — new `TRUSTED_BOT_AUTHORS` constant (currently `["dependabot[bot]"]`), so the allowlist is easy to extend. - `rules.ts` — new `checkTrustedBot()` rule, wired into `evaluate()` right after the maintainer bypass and before the repo-access check. - `tests/PrGateRules.test.ts` — unit tests for the rule plus an `evaluate()`-level test proving a 5000-line Dependabot PR now passes instead of closing. - `README.md` — documented the new rule in the gate-logic ordering. The match is exact, so a lookalike login (e.g. `not-dependabot[bot]`) won't slip through. Add more bots (Renovate, etc.) to `TRUSTED_BOT_AUTHORS` as needed. ## Testing `npx vitest tests/PrGateRules.test.ts --run` → 39 passed. Lint + prettier clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
3.9 KiB
TypeScript
148 lines
3.9 KiB
TypeScript
import {
|
|
APPROVED_ISSUE_LABEL,
|
|
LABELS,
|
|
SMALL_FIX_LINE_THRESHOLD,
|
|
TRUSTED_BOT_AUTHORS,
|
|
TRUSTED_REPO_PERMISSIONS,
|
|
} from "./config";
|
|
|
|
export type PRMetadata = {
|
|
number: number;
|
|
body: string | null;
|
|
user: { login: string };
|
|
labels: string[];
|
|
};
|
|
|
|
export type GetRepoPermission = (username: string) => Promise<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 checkTrustedBot(pr: PRMetadata): RuleResult {
|
|
if ((TRUSTED_BOT_AUTHORS as readonly string[]).includes(pr.user.login)) {
|
|
return {
|
|
action: "pass",
|
|
reason: `Author "${pr.user.login}" is a trusted bot`,
|
|
};
|
|
}
|
|
return { action: "next" };
|
|
}
|
|
|
|
export async function checkRepoAccess(
|
|
pr: PRMetadata,
|
|
getRepoPermission: GetRepoPermission,
|
|
): Promise<RuleResult> {
|
|
const permission = await getRepoPermission(pr.user.login);
|
|
if ((TRUSTED_REPO_PERMISSIONS as readonly string[]).includes(permission)) {
|
|
return {
|
|
action: "pass",
|
|
reason: `Author has "${permission}" permission on the repo`,
|
|
};
|
|
}
|
|
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,
|
|
getRepoPermission: GetRepoPermission,
|
|
): Promise<RuleResult> {
|
|
const r0 = checkBypass(pr);
|
|
if (r0.action !== "next") return r0;
|
|
|
|
const rBot = checkTrustedBot(pr);
|
|
if (rBot.action !== "next") return rBot;
|
|
|
|
const r1 = await checkRepoAccess(pr, getRepoPermission);
|
|
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`,
|
|
};
|
|
}
|