From b56e9438d1f9c62c3880549896384dca246d5390 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 29 May 2026 18:28:52 -0700 Subject: [PATCH] github PR gate (#4070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .github/PULL_REQUEST_TEMPLATE.md | 6 +- .github/workflows/pr-gate.yml | 36 ++ scripts/pr-gate/README.md | 43 ++ scripts/pr-gate/config.ts | 47 ++ scripts/pr-gate/github.ts | 124 ++++++ scripts/pr-gate/index.ts | 97 ++++ scripts/pr-gate/package-lock.json | 717 ++++++++++++++++++++++++++++++ scripts/pr-gate/package.json | 11 + scripts/pr-gate/rules.ts | 128 ++++++ scripts/pr-gate/tsconfig.json | 8 + tests/PrGateRules.test.ts | 281 ++++++++++++ 11 files changed, 1496 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pr-gate.yml create mode 100644 scripts/pr-gate/README.md create mode 100644 scripts/pr-gate/config.ts create mode 100644 scripts/pr-gate/github.ts create mode 100644 scripts/pr-gate/index.ts create mode 100644 scripts/pr-gate/package-lock.json create mode 100644 scripts/pr-gate/package.json create mode 100644 scripts/pr-gate/rules.ts create mode 100644 scripts/pr-gate/tsconfig.json create mode 100644 tests/PrGateRules.test.ts diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5950482d6..dc9668f89 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,7 @@ -If this PR fixes an issue, link it below. If not, delete these two lines. +> **Before opening a PR:** discuss new features on [Discord](https://discord.gg/K9zernJB5z) first, and file bugs or small improvements as [issues](https://github.com/openfrontio/OpenFrontIO/issues/new/choose). You must be assigned to an `approved` issue — unsolicited PRs will be auto-closed. + +**Add approved & assigned issue number here:** + Resolves #(issue number) ## Description: @@ -10,7 +13,6 @@ Describe the PR. - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory -- [ ] 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: diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 000000000..f844c42a4 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,36 @@ +name: 🚪 PR Gate + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + issues: write + contents: read + +jobs: + gate: + name: Evaluate gate + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout base + uses: actions/checkout@v6 + with: + submodules: false + persist-credentials: false + - name: Setup node + uses: actions/setup-node@v6 + with: + node-version: 24 + - name: Install gate deps + working-directory: scripts/pr-gate + run: npm install --no-audit --no-fund --ignore-scripts + - name: Run gate + working-directory: scripts/pr-gate + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + DRY_RUN: ${{ vars.PR_GATE_DRY_RUN || 'true' }} + run: npx tsx index.ts diff --git a/scripts/pr-gate/README.md b/scripts/pr-gate/README.md new file mode 100644 index 000000000..4cb97a74d --- /dev/null +++ b/scripts/pr-gate/README.md @@ -0,0 +1,43 @@ +# PR Gate + +Deterministic GitHub Action that auto-closes PRs that don't follow the project's contribution workflow. Trigger: `pull_request_target: [opened, reopened]`. + +## 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. + +## Local testing + +```bash +cd scripts/pr-gate +npm install +export GITHUB_TOKEN=ghp_... # PAT with repo scope +npx tsx index.ts --pr 1234 # always dry-run unless --no-dry-run +``` + +The CLI prints the decision and exits without touching the PR. + +## Toggling dry-run in production + +1. Go to repo **Settings → Secrets and variables → Actions → Variables**. +2. Edit `PR_GATE_DRY_RUN`. +3. Set to `false` to make the Action take real action; any other value (or unset) keeps it in dry-run mode. + +The default is `true` — the gate logs decisions but does not act until the maintainer flips the variable. + +## Tweaking rules + +- Thresholds, labels, comment text → [config.ts](./config.ts) +- Rule logic (pure functions) → [rules.ts](./rules.ts) +- GitHub API calls → [github.ts](./github.ts) +- Orchestration → [index.ts](./index.ts) + +## Known limitations + +- Runs only on PR open/reopen — not on `synchronize`. A PR that grows past 50 lines after being passed will not be re-gated. +- Cross-repo issue references (`owner/repo#N`) are not honored. +- No LLM is called. This Action is fully deterministic. diff --git a/scripts/pr-gate/config.ts b/scripts/pr-gate/config.ts new file mode 100644 index 000000000..3779dabb9 --- /dev/null +++ b/scripts/pr-gate/config.ts @@ -0,0 +1,47 @@ +export const REPO = { owner: "openfrontio", repo: "OpenFrontIO" } as const; + +export const TRUSTED_AUTHOR_ASSOCIATIONS = [ + "OWNER", + "MEMBER", + "COLLABORATOR", +] as const; + +export const SMALL_FIX_LINE_THRESHOLD = 50; + +export const APPROVED_ISSUE_LABEL = "approved"; + +export const LABELS = { + BYPASS: "bypass-pr-check", + SMALL_FIX: "small-fix", + AUTO_CLOSED: "auto-closed-needs-issue", +} as const; + +export const LABEL_COLORS: Record = { + [LABELS.SMALL_FIX]: "0E8A16", + [LABELS.AUTO_CLOSED]: "B60205", +}; + +export const LABEL_DESCRIPTIONS: Record = { + [LABELS.SMALL_FIX]: `Small fix (≤ ${SMALL_FIX_LINE_THRESHOLD} lines) — auto-applied by PR gate`, + [LABELS.AUTO_CLOSED]: "PR closed by gate — see comment for next steps", +}; + +export const CLOSE_COMMENT = (author: string): string => + `Hi @${author}, thanks for the contribution. + +This PR was automatically closed because it doesn't fit our contribution workflow: + +- You aren't currently assigned to an issue labelled \`${APPROVED_ISSUE_LABEL}\`, **and** +- The change is larger than ${SMALL_FIX_LINE_THRESHOLD} lines (our cap for unsolicited contributions). + +**To contribute to OpenFront:** + +1. **For bugs or small quality-of-life improvements:** open an [issue](https://github.com/${REPO.owner}/${REPO.repo}/issues/new/choose). A maintainer will label it \`${APPROVED_ISSUE_LABEL}\` if it's something we'll work on. +2. **For feature ideas:** discuss in the [dev Discord](https://discord.gg/K9zernJB5z) first. We don't accept unsolicited feature PRs — even if they're good ideas, every merged feature is a permanent maintenance burden. +3. **Once an issue is labelled \`${APPROVED_ISSUE_LABEL}\`**, comment asking to be assigned. After you're assigned, you can open a PR referencing that issue. + +If you believe this was closed in error, please reach out on our [Discord](https://discord.gg/K9zernJB5z) or comment below. + +See [CONTRIBUTING.md](https://github.com/${REPO.owner}/${REPO.repo}/blob/main/CONTRIBUTING.md) for the full contribution process. + +— *Automated PR gate. [Source](https://github.com/${REPO.owner}/${REPO.repo}/tree/main/scripts/pr-gate).*`; diff --git a/scripts/pr-gate/github.ts b/scripts/pr-gate/github.ts new file mode 100644 index 000000000..3d589b603 --- /dev/null +++ b/scripts/pr-gate/github.ts @@ -0,0 +1,124 @@ +import { Octokit } from "@octokit/rest"; +import { LABEL_COLORS, LABEL_DESCRIPTIONS, REPO } from "./config"; +import type { IssueMetadata, PRFile, PRMetadata } from "./rules"; + +export function makeOctokit(token: string): Octokit { + return new Octokit({ auth: token }); +} + +export async function getPR( + octokit: Octokit, + prNumber: number, +): Promise { + const { data } = await octokit.rest.pulls.get({ + ...REPO, + pull_number: prNumber, + }); + return { + number: data.number, + body: data.body ?? null, + user: { login: data.user?.login ?? "" }, + author_association: data.author_association, + labels: (data.labels ?? []) + .map((l) => l.name ?? "") + .filter((name) => name.length > 0), + }; +} + +export async function getPRFiles( + octokit: Octokit, + prNumber: number, +): Promise { + const files = await octokit.paginate(octokit.rest.pulls.listFiles, { + ...REPO, + pull_number: prNumber, + per_page: 100, + }); + return files.map((f) => ({ additions: f.additions, deletions: f.deletions })); +} + +export async function getIssue( + octokit: Octokit, + issueNumber: number, +): Promise { + try { + const { data } = await octokit.rest.issues.get({ + ...REPO, + issue_number: issueNumber, + }); + return { + number: data.number, + labels: (data.labels ?? []) + .map((l) => (typeof l === "string" ? l : (l.name ?? ""))) + .filter((name) => name.length > 0), + assignees: (data.assignees ?? []).map((a) => ({ login: a.login })), + }; + } catch (err) { + if (isStatus(err, 404)) { + console.warn(`[pr-gate] Issue #${issueNumber} not found, skipping`); + return null; + } + throw err; + } +} + +export async function ensureLabel( + octokit: Octokit, + name: string, +): Promise { + try { + await octokit.rest.issues.getLabel({ ...REPO, name }); + } catch (err) { + if (!isStatus(err, 404)) throw err; + await octokit.rest.issues.createLabel({ + ...REPO, + name, + color: LABEL_COLORS[name], + description: LABEL_DESCRIPTIONS[name], + }); + } +} + +export async function addLabel( + octokit: Octokit, + prNumber: number, + label: string, +): Promise { + await octokit.rest.issues.addLabels({ + ...REPO, + issue_number: prNumber, + labels: [label], + }); +} + +export async function postComment( + octokit: Octokit, + prNumber: number, + body: string, +): Promise { + await octokit.rest.issues.createComment({ + ...REPO, + issue_number: prNumber, + body, + }); +} + +export async function closePR( + octokit: Octokit, + prNumber: number, +): Promise { + await octokit.rest.pulls.update({ + ...REPO, + pull_number: prNumber, + state: "closed", + }); +} + +function isStatus(err: unknown, status: number): boolean { + return ( + typeof err === "object" && + err !== null && + "status" in err && + (err as { status: unknown }).status === status + ); +} diff --git a/scripts/pr-gate/index.ts b/scripts/pr-gate/index.ts new file mode 100644 index 000000000..981c61939 --- /dev/null +++ b/scripts/pr-gate/index.ts @@ -0,0 +1,97 @@ +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 { + 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 ", + ); + } + + 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); +}); diff --git a/scripts/pr-gate/package-lock.json b/scripts/pr-gate/package-lock.json new file mode 100644 index 000000000..b1284736f --- /dev/null +++ b/scripts/pr-gate/package-lock.json @@ -0,0 +1,717 @@ +{ + "name": "openfrontio-pr-gate", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openfrontio-pr-gate", + "version": "0.0.0", + "dependencies": { + "@octokit/rest": "^21.0.2", + "tsx": "^4.21.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@octokit/auth-token": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", + "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", + "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^5.0.0", + "@octokit/graphql": "^8.2.2", + "@octokit/request": "^9.2.3", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "before-after-hook": "^3.0.2", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^9.2.3", + "@octokit/types": "^14.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", + "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.5.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", + "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.10.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^10.1.4", + "@octokit/request-error": "^6.1.8", + "@octokit/types": "^14.0.0", + "fast-content-type-parse": "^2.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^14.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", + "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^6.1.4", + "@octokit/plugin-paginate-rest": "^11.4.2", + "@octokit/plugin-request-log": "^5.3.1", + "@octokit/plugin-rest-endpoint-methods": "^13.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^25.1.0" + } + }, + "node_modules/before-after-hook": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "license": "Apache-2.0" + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/fast-content-type-parse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + } + } +} diff --git a/scripts/pr-gate/package.json b/scripts/pr-gate/package.json new file mode 100644 index 000000000..a24b04e16 --- /dev/null +++ b/scripts/pr-gate/package.json @@ -0,0 +1,11 @@ +{ + "name": "openfrontio-pr-gate", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Deterministic GitHub Action that gates incoming PRs. See README.md.", + "dependencies": { + "@octokit/rest": "^21.0.2", + "tsx": "^4.21.0" + } +} diff --git a/scripts/pr-gate/rules.ts b/scripts/pr-gate/rules.ts new file mode 100644 index 000000000..229c73934 --- /dev/null +++ b/scripts/pr-gate/rules.ts @@ -0,0 +1,128 @@ +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; + +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(); + 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 { + 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 { + 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`, + }; +} diff --git a/scripts/pr-gate/tsconfig.json b/scripts/pr-gate/tsconfig.json new file mode 100644 index 000000000..9da0f8638 --- /dev/null +++ b/scripts/pr-gate/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": {} + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/tests/PrGateRules.test.ts b/tests/PrGateRules.test.ts new file mode 100644 index 000000000..b0316df39 --- /dev/null +++ b/tests/PrGateRules.test.ts @@ -0,0 +1,281 @@ +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 => ({ + 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 => ({ + 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"); + }); +});