diff --git a/scripts/pr-gate/README.md b/scripts/pr-gate/README.md index 4cb97a74d..0601a9b09 100644 --- a/scripts/pr-gate/README.md +++ b/scripts/pr-gate/README.md @@ -5,10 +5,11 @@ Deterministic GitHub Action that auto-closes PRs that don't follow the project's ## Gate logic (first match wins) 1. **Maintainer bypass** — PR carries the `bypass-pr-check` label → pass. Apply this label and reopen if the gate closed something you wanted through. -2. **Org/repo member bypass** — `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR` → pass. -3. **Approved-work bypass** — PR body links an issue (via `Closes #N` / `Fixes #N` / `Resolves #N`) that carries the `approved` label, and the PR author is in the issue's assignees → pass. -4. **Small-fix bypass** — `additions + deletions ≤ 50` → pass + apply `small-fix` label. -5. **Otherwise** — apply `auto-closed-needs-issue` label, post rejection comment, close. +2. **Trusted-bot bypass** — PR author is a trusted bot (e.g. `dependabot[bot]`) → pass. List is in `TRUSTED_BOT_AUTHORS`. +3. **Org/repo member bypass** — `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR` → pass. +4. **Approved-work bypass** — PR body links an issue (via `Closes #N` / `Fixes #N` / `Resolves #N`) that carries the `approved` label, and the PR author is in the issue's assignees → pass. +5. **Small-fix bypass** — `additions + deletions ≤ 50` → pass + apply `small-fix` label. +6. **Otherwise** — apply `auto-closed-needs-issue` label, post rejection comment, close. ## Local testing diff --git a/scripts/pr-gate/config.ts b/scripts/pr-gate/config.ts index b92e52515..c3de83186 100644 --- a/scripts/pr-gate/config.ts +++ b/scripts/pr-gate/config.ts @@ -2,6 +2,9 @@ export const REPO = { owner: "openfrontio", repo: "OpenFrontIO" } as const; export const TRUSTED_REPO_PERMISSIONS = ["admin", "maintain", "write"] as const; +// Bot authors whose PRs are exempt from the gate (e.g. Dependabot dependency bumps). +export const TRUSTED_BOT_AUTHORS = ["dependabot[bot]"] as const; + export const SMALL_FIX_LINE_THRESHOLD = 50; export const APPROVED_ISSUE_LABEL = "approved"; diff --git a/scripts/pr-gate/rules.ts b/scripts/pr-gate/rules.ts index 7c5385c60..84c06968d 100644 --- a/scripts/pr-gate/rules.ts +++ b/scripts/pr-gate/rules.ts @@ -2,6 +2,7 @@ import { APPROVED_ISSUE_LABEL, LABELS, SMALL_FIX_LINE_THRESHOLD, + TRUSTED_BOT_AUTHORS, TRUSTED_REPO_PERMISSIONS, } from "./config"; @@ -54,6 +55,16 @@ export function checkBypass(pr: PRMetadata): RuleResult { 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, @@ -113,6 +124,9 @@ export async function evaluate( 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; diff --git a/tests/PrGateRules.test.ts b/tests/PrGateRules.test.ts index 3a6b83693..baf6e38af 100644 --- a/tests/PrGateRules.test.ts +++ b/tests/PrGateRules.test.ts @@ -4,6 +4,7 @@ import { checkBypass, checkRepoAccess, checkSmallFix, + checkTrustedBot, evaluate, parseLinkedIssues, type IssueMetadata, @@ -109,6 +110,26 @@ describe("checkBypass", () => { }); }); +describe("checkTrustedBot", () => { + it("passes for dependabot[bot]", () => { + const r = checkTrustedBot(makePR({ user: { login: "dependabot[bot]" } })); + expect(r.action).toBe("pass"); + }); + + it("returns next for a regular author", () => { + expect(checkTrustedBot(makePR({ user: { login: "alice" } })).action).toBe( + "next", + ); + }); + + it("returns next for a lookalike bot author", () => { + expect( + checkTrustedBot(makePR({ user: { login: "not-dependabot[bot]" } })) + .action, + ).toBe("next"); + }); +}); + describe("checkRepoAccess", () => { it("passes for admin, maintain, write permissions", async () => { for (const permission of ["admin", "maintain", "write"]) { @@ -273,6 +294,16 @@ describe("evaluate (priority ordering)", () => { expect(r.action).toBe("pass"); }); + it("trusted bot — large Dependabot PR passes without an issue", async () => { + const r = await evaluate( + makePR({ user: { login: "dependabot[bot]" } }), + [{ additions: 5000, deletions: 0 }], + async () => null, + async () => "none", + ); + expect(r.action).toBe("pass"); + }); + it("close — large diff, no qualifying issue", async () => { const r = await evaluate( makePR(),