mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 08:20:50 +00:00
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
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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<string, string> = {
|
||||
[LABELS.SMALL_FIX]: "0E8A16",
|
||||
[LABELS.AUTO_CLOSED]: "B60205",
|
||||
};
|
||||
|
||||
export const LABEL_DESCRIPTIONS: Record<string, string> = {
|
||||
[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).*`;
|
||||
@@ -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<PRMetadata> {
|
||||
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<PRFile[]> {
|
||||
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<IssueMetadata | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await octokit.rest.issues.addLabels({
|
||||
...REPO,
|
||||
issue_number: prNumber,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
export async function postComment(
|
||||
octokit: Octokit,
|
||||
prNumber: number,
|
||||
body: string,
|
||||
): Promise<void> {
|
||||
await octokit.rest.issues.createComment({
|
||||
...REPO,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function closePR(
|
||||
octokit: Octokit,
|
||||
prNumber: number,
|
||||
): Promise<void> {
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 <number>",
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
Generated
+717
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<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`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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> = {}): 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user