Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/scripts/interaction-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ const limits = {
issue: { count: 10, label: "issues" },
pull_request: { count: 4, label: "pull requests" },
};
// Must match the fromJSON list in the guard job's `if:` in
// .github/workflows/interaction-guard.yml.
const trustedAssociations = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);

export function isTrustedAuthor(authorAssociation) {
return trustedAssociations.has(authorAssociation);
}

export function closePayload(kind) {
// state_reason is only valid for issues; PATCHing it on a pull request
// fails with 403 "Insufficient permissions to update the state_reason".
return kind === "pull_request" ? { state: "closed" } : { state: "closed", state_reason: "not_planned" };
}

export function evaluateInteraction({ kind, userCreatedAt, now, recentCount }) {
const created = Date.parse(userCreatedAt);
Expand Down Expand Up @@ -41,13 +54,15 @@ function eventTarget(payload) {
kind: "pull_request",
number: payload.pull_request.number,
author: payload.pull_request.user.login,
authorAssociation: payload.pull_request.author_association,
};
}
if (payload.issue && !payload.issue.pull_request) {
return {
kind: "issue",
number: payload.issue.number,
author: payload.issue.user.login,
authorAssociation: payload.issue.author_association,
};
}
return null;
Expand Down Expand Up @@ -80,6 +95,7 @@ async function main() {
const payload = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8"));
const target = eventTarget(payload);
if (!target) return;
if (isTrustedAuthor(target.authorAssociation)) return;

const repo = process.env.GITHUB_REPOSITORY;
const now = new Date().toISOString();
Expand All @@ -103,7 +119,7 @@ async function main() {
});
await github(`/repos/${repo}/issues/${target.number}`, {
method: "PATCH",
body: JSON.stringify({ state: "closed", state_reason: "not_planned" }),
body: JSON.stringify(closePayload(target.kind)),
});
}

Expand Down
19 changes: 18 additions & 1 deletion .github/scripts/interaction-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";

import { evaluateInteraction } from "./interaction-guard.mjs";
import { closePayload, evaluateInteraction, isTrustedAuthor } from "./interaction-guard.mjs";

const now = "2026-07-08T00:00:00.000Z";

Expand Down Expand Up @@ -55,3 +55,20 @@ test("blocks the 5th pull request in 7 days", () => {
assert.equal(result.allowed, false);
assert.match(result.reason, /4 pull requests per 7 days/);
});

test("trusts maintainer associations", () => {
for (const authorAssociation of ["OWNER", "MEMBER", "COLLABORATOR"]) {
assert.equal(isTrustedAuthor(authorAssociation), true);
}
});

test("does not trust other associations", () => {
assert.equal(isTrustedAuthor("CONTRIBUTOR"), false);
assert.equal(isTrustedAuthor("NONE"), false);
assert.equal(isTrustedAuthor(undefined), false);
});

test("close payload omits state_reason for pull requests", () => {
assert.deepEqual(closePayload("pull_request"), { state: "closed" });
assert.deepEqual(closePayload("issue"), { state: "closed", state_reason: "not_planned" });
});
10 changes: 8 additions & 2 deletions .github/workflows/interaction-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ permissions:

jobs:
guard:
if: vars.CI_BUDGET_MODE != 'off'
# Skip maintainer-authored events entirely: no runner spin-up, no
# billed minutes. The guard only exists for untrusted authors.
# The association list must match trustedAssociations in
# .github/scripts/interaction-guard.mjs.
if: >-
vars.CI_BUDGET_MODE != 'off' &&
!contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'),
github.event.pull_request.author_association || github.event.issue.author_association)
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
- run: node --test .github/scripts/interaction-guard.test.mjs
- run: node .github/scripts/interaction-guard.mjs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,6 @@ jobs:

- name: Frontend type check / build
run: pnpm --dir apps/desktop-tauri run build

- name: Interaction guard script tests
run: node --test .github/scripts/interaction-guard.test.mjs
Loading