Files
OpenFrontIO/tests/PrGateRules.test.ts
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

282 lines
8.1 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
checkApprovedWork,
checkBypass,
checkOrgMember,
checkSmallFix,
evaluate,
parseLinkedIssues,
type IssueMetadata,
type PRMetadata,
} from "../scripts/pr-gate/rules";
describe("parseLinkedIssues", () => {
it("returns empty for null or empty body", () => {
expect(parseLinkedIssues(null)).toEqual([]);
expect(parseLinkedIssues("")).toEqual([]);
});
it("returns empty when no keyword is present", () => {
expect(parseLinkedIssues("Just a description")).toEqual([]);
expect(parseLinkedIssues("See #5 for details")).toEqual([]);
});
it("matches every standard closing keyword", () => {
const keywords = [
"close",
"closes",
"closed",
"fix",
"fixes",
"fixed",
"resolve",
"resolves",
"resolved",
];
keywords.forEach((kw, i) => {
const n = 10 + i;
expect(parseLinkedIssues(`${kw} #${n}`)).toEqual([n]);
});
});
it("is case-insensitive", () => {
expect(parseLinkedIssues("CLOSES #5")).toEqual([5]);
expect(parseLinkedIssues("Fixes #6")).toEqual([6]);
expect(parseLinkedIssues("rEsOlVeD #7")).toEqual([7]);
});
it("matches references anywhere in the body", () => {
expect(parseLinkedIssues("Some text\n\nFixes #42\n\nMore text")).toEqual([
42,
]);
});
it("returns multiple unique issue numbers", () => {
expect(parseLinkedIssues("Closes #1, fixes #2, resolves #3")).toEqual([
1, 2, 3,
]);
});
it("dedupes repeated references", () => {
expect(parseLinkedIssues("Closes #5 and fixes #5")).toEqual([5]);
});
it("ignores references inside fenced code blocks", () => {
const body = "```\nCloses #5\n```\nFixes #6";
expect(parseLinkedIssues(body)).toEqual([6]);
});
it("ignores references inside inline code", () => {
expect(parseLinkedIssues("`Closes #5` and Fixes #6")).toEqual([6]);
});
it("ignores cross-repo references", () => {
expect(parseLinkedIssues("Closes openfront/OpenFrontIO#5")).toEqual([]);
});
it("does not match keyword without # number", () => {
expect(parseLinkedIssues("This closes the discussion")).toEqual([]);
});
it("does not match keyword as substring of a larger word", () => {
expect(parseLinkedIssues("Disclosed #5")).toEqual([]);
expect(parseLinkedIssues("Prefixed #5")).toEqual([]);
});
});
const makePR = (overrides: Partial<PRMetadata> = {}): PRMetadata => ({
number: 1,
body: null,
user: { login: "alice" },
author_association: "CONTRIBUTOR",
labels: [],
...overrides,
});
describe("checkBypass", () => {
it("passes when bypass-pr-check label is present", () => {
const r = checkBypass(makePR({ labels: ["bypass-pr-check"] }));
expect(r.action).toBe("pass");
});
it("returns next when label is absent", () => {
expect(checkBypass(makePR()).action).toBe("next");
});
it("returns next when other labels present but not bypass", () => {
expect(checkBypass(makePR({ labels: ["bug", "small-fix"] })).action).toBe(
"next",
);
});
});
describe("checkOrgMember", () => {
it("passes for OWNER, MEMBER, COLLABORATOR", () => {
for (const assoc of ["OWNER", "MEMBER", "COLLABORATOR"]) {
expect(checkOrgMember(makePR({ author_association: assoc })).action).toBe(
"pass",
);
}
});
it("returns next for untrusted associations", () => {
for (const assoc of ["CONTRIBUTOR", "NONE", "FIRST_TIME_CONTRIBUTOR"]) {
expect(checkOrgMember(makePR({ author_association: assoc })).action).toBe(
"next",
);
}
});
});
describe("checkSmallFix", () => {
it("passes at exactly 50 lines and applies small-fix label", () => {
const r = checkSmallFix([{ additions: 25, deletions: 25 }]);
expect(r.action).toBe("pass");
if (r.action === "pass") expect(r.labelToAdd).toBe("small-fix");
});
it("passes for deletion-only diffs under the threshold", () => {
expect(checkSmallFix([{ additions: 0, deletions: 40 }]).action).toBe(
"pass",
);
});
it("returns next at 51 lines", () => {
expect(checkSmallFix([{ additions: 51, deletions: 0 }]).action).toBe(
"next",
);
});
it("sums additions and deletions across files", () => {
expect(
checkSmallFix([
{ additions: 30, deletions: 0 },
{ additions: 25, deletions: 0 },
]).action,
).toBe("next");
});
it("counts deletions toward the cap", () => {
expect(checkSmallFix([{ additions: 0, deletions: 60 }]).action).toBe(
"next",
);
});
it("passes for an empty diff", () => {
expect(checkSmallFix([]).action).toBe("pass");
});
});
const makeIssue = (overrides: Partial<IssueMetadata> = {}): IssueMetadata => ({
number: 5,
labels: ["approved"],
assignees: [{ login: "alice" }],
...overrides,
});
describe("checkApprovedWork", () => {
it("returns next when no issues are linked, without fetching", async () => {
const get = vi.fn();
const r = await checkApprovedWork(makePR({ body: null }), get);
expect(r.action).toBe("next");
expect(get).not.toHaveBeenCalled();
});
it("passes when issue has approved label and author is assigned", async () => {
const get = vi.fn(async () => makeIssue());
const r = await checkApprovedWork(makePR({ body: "Closes #5" }), get);
expect(r.action).toBe("pass");
});
it("returns next when issue lacks the approved label", async () => {
const get = vi.fn(async () =>
makeIssue({ labels: ["bug", "good-first-issue"] }),
);
expect(
(await checkApprovedWork(makePR({ body: "Closes #5" }), get)).action,
).toBe("next");
});
it("returns next when issue has no labels", async () => {
const get = vi.fn(async () => makeIssue({ labels: [] }));
expect(
(await checkApprovedWork(makePR({ body: "Closes #5" }), get)).action,
).toBe("next");
});
it("returns next when author is not in assignees", async () => {
const get = vi.fn(async () => makeIssue({ assignees: [{ login: "bob" }] }));
expect(
(await checkApprovedWork(makePR({ body: "Closes #5" }), get)).action,
).toBe("next");
});
it("returns next when issue fetch returns null (404)", async () => {
const get = vi.fn(async () => null);
expect(
(await checkApprovedWork(makePR({ body: "Closes #5" }), get)).action,
).toBe("next");
});
it("passes if any of multiple linked issues qualifies", async () => {
const get = vi.fn(async (n: number) => {
if (n === 5) return makeIssue({ number: 5, labels: [] });
if (n === 6) return makeIssue({ number: 6 });
return null;
});
const r = await checkApprovedWork(
makePR({ body: "Closes #5, fixes #6" }),
get,
);
expect(r.action).toBe("pass");
});
});
describe("evaluate (priority ordering)", () => {
it("rule 0 — bypass label overrides everything", async () => {
const r = await evaluate(
makePR({ labels: ["bypass-pr-check"] }),
[{ additions: 5000, deletions: 0 }],
async () => null,
);
expect(r.action).toBe("pass");
});
it("rule 1 — org member with a huge PR passes", async () => {
const r = await evaluate(
makePR({ author_association: "MEMBER" }),
[{ additions: 5000, deletions: 0 }],
async () => null,
);
expect(r.action).toBe("pass");
});
it("rule 3 — small PR from non-member passes with small-fix label", async () => {
const r = await evaluate(
makePR(),
[{ additions: 10, deletions: 5 }],
async () => null,
);
expect(r.action).toBe("pass");
if (r.action === "pass") expect(r.labelToAdd).toBe("small-fix");
});
it("rule 2 — non-member with approved-labelled issue + assignee passes despite large diff", async () => {
const r = await evaluate(
makePR({ body: "Closes #5" }),
[{ additions: 200, deletions: 50 }],
async () => makeIssue(),
);
expect(r.action).toBe("pass");
});
it("close — large diff, no qualifying issue", async () => {
const r = await evaluate(
makePR(),
[{ additions: 200, deletions: 50 }],
async () => null,
);
expect(r.action).toBe("close");
});
});