mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
ci: track meaningful pull request inactivity
Use human activity and observed head changes because GitHub can update PR timestamps without contributor activity. Audit every open PR before applying changes and start existing PRs from a safe baseline.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
name: Stale Pull Requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 3 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: Run without changes or apply the PR inactivity policy
|
||||
type: choice
|
||||
required: true
|
||||
default: dry-run
|
||||
options:
|
||||
- dry-run
|
||||
- apply
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: stale-pull-requests
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
stale-prs:
|
||||
name: Check Every Open PR
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Check PR inactivity
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
RUN_MODE: ${{ github.event_name == 'schedule' && 'apply' || inputs.mode }}
|
||||
run: node scripts/stale-prs/run.mjs "--${RUN_MODE}"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Stale Issues and PRs
|
||||
name: Stale Issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -7,11 +7,11 @@ on:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Mark and Close Stale
|
||||
name: Mark and Close Stale Issues
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -19,22 +19,15 @@ jobs:
|
||||
with:
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 14
|
||||
days-before-pr-stale: 30
|
||||
days-before-pr-close: 14
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
remove-pr-stale-when-updated: false
|
||||
exempt-issue-labels: "pinned,security"
|
||||
exempt-pr-labels: pinned
|
||||
stale-issue-message: >
|
||||
This issue has been inactive for 60 days. It will be closed in 14 days
|
||||
unless there is new activity. If this is still relevant, please leave a
|
||||
comment or update the issue.
|
||||
stale-pr-message: >
|
||||
This PR has been inactive for 30 days. It will be closed in 14 days
|
||||
unless there is new activity. If you are still working on this, please
|
||||
leave a comment or push an update.
|
||||
close-issue-message: >
|
||||
Closing due to inactivity. Feel free to reopen if this is still relevant.
|
||||
close-pr-message: >
|
||||
Closing due to inactivity. Feel free to reopen if you want to continue
|
||||
this work.
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@
|
||||
"format:check": "pnpm --filter server format:check && pnpm --filter client format:check",
|
||||
"test": "pnpm run test:charts && pnpm run test:pr-policy && pnpm run test:server && pnpm run test:client",
|
||||
"test:charts": "node --test scripts/star-history/generate.test.mjs scripts/crowdin-stats/generate.test.mjs",
|
||||
"test:pr-policy": "node --test scripts/validate-locale-pr.test.mjs",
|
||||
"test:pr-policy": "node --test scripts/validate-locale-pr.test.mjs scripts/stale-prs/*.test.mjs",
|
||||
"test:server": "pnpm --filter server test",
|
||||
"test:client": "pnpm --filter client test:unit --run",
|
||||
"e2e:run": "node ./scripts/e2e/run-suite.mjs",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Pull request inactivity policy
|
||||
|
||||
The scheduled workflow checks every open pull request daily. It does not use GitHub's pull request `updated_at` timestamp. It counts human comments, submitted reviews, review comments, reopening, and marking ready for review. It also records when it first sees each head commit, so a new push resets the clock even if that commit has an old author date. Bot comments and CI updates do not reset it.
|
||||
|
||||
The first run writes one automation state comment per open PR and starts its inactivity clock at that run. This conservative baseline avoids closing existing PRs from unverifiable historical push dates. Future head changes update that comment. After 30 full days without qualifying activity, the workflow adds `stale` and posts a warning. It closes the PR only after 14 more full days without qualifying activity, following a fresh API check. A `pinned` label exempts the PR.
|
||||
|
||||
Use the workflow's manual **dry-run** mode to audit decisions without writes. The log records one decision per PR and ends with the number inspected. An API or pagination error fails the run. The policy and API behavior are covered by `pnpm test:pr-policy`.
|
||||
@@ -0,0 +1,113 @@
|
||||
export const STATE_MARKER = "<!-- bookorbit-stale-pr-state:v1 ";
|
||||
export const WARNING_MARKER = "<!-- bookorbit-stale-pr-warning:v1 -->";
|
||||
|
||||
const STALE_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const CLOSE_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const BOT_LOGIN = "github-actions[bot]";
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
||||
|
||||
const isHuman = (actor) => actor?.type === "User";
|
||||
const isAutomation = (actor) => actor?.login === BOT_LOGIN && actor?.type === "Bot";
|
||||
|
||||
const timestamp = (value) => {
|
||||
const time = Date.parse(value);
|
||||
if (!Number.isFinite(time)) throw new Error("Invalid activity timestamp");
|
||||
return time;
|
||||
};
|
||||
|
||||
export const formatState = (state) =>
|
||||
`${STATE_MARKER}${JSON.stringify(state)} -->\nAutomation state for PR inactivity. This records when the current head commit was first seen.`;
|
||||
|
||||
export const findState = (comments) => {
|
||||
const matches = comments.filter((comment) => isAutomation(comment.user) && comment.body?.startsWith(STATE_MARKER));
|
||||
if (matches.length > 1) throw new Error("Multiple PR inactivity state comments");
|
||||
if (matches.length === 0) return null;
|
||||
|
||||
const raw = matches[0].body.slice(STATE_MARKER.length).split(" -->", 1)[0];
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Invalid PR inactivity state comment");
|
||||
}
|
||||
if (
|
||||
!state ||
|
||||
!SHA_PATTERN.test(state.headSha) ||
|
||||
!Number.isFinite(Date.parse(state.baselineAt)) ||
|
||||
!Number.isFinite(Date.parse(state.headSeenAt))
|
||||
) {
|
||||
throw new Error("Invalid PR inactivity state fields");
|
||||
}
|
||||
return { commentId: matches[0].id, state };
|
||||
};
|
||||
|
||||
const latestHumanActivity = ({ comments, reviews, reviewComments, events }) => {
|
||||
const activity = [];
|
||||
for (const comment of comments) {
|
||||
if (isHuman(comment.user)) activity.push({ at: comment.updated_at || comment.created_at, source: "comment" });
|
||||
}
|
||||
for (const review of reviews) {
|
||||
if (isHuman(review.user) && review.submitted_at) activity.push({ at: review.submitted_at, source: "review" });
|
||||
}
|
||||
for (const comment of reviewComments) {
|
||||
if (isHuman(comment.user)) activity.push({ at: comment.updated_at || comment.created_at, source: "review-comment" });
|
||||
}
|
||||
for (const event of events) {
|
||||
if (isHuman(event.actor) && ["reopened", "ready_for_review"].includes(event.event)) {
|
||||
activity.push({ at: event.created_at, source: event.event });
|
||||
}
|
||||
}
|
||||
return activity;
|
||||
};
|
||||
|
||||
export const evaluatePullRequest = (pull, history, nowIso) => {
|
||||
const now = timestamp(nowIso);
|
||||
const previous = findState(history.comments);
|
||||
const state = previous ? { ...previous.state } : { baselineAt: nowIso, headSha: pull.head.sha, headSeenAt: nowIso };
|
||||
let stateChanged = !previous;
|
||||
|
||||
if (state.headSha !== pull.head.sha) {
|
||||
state.headSha = pull.head.sha;
|
||||
state.headSeenAt = nowIso;
|
||||
stateChanged = true;
|
||||
}
|
||||
|
||||
const humanActivity = latestHumanActivity(history).sort((a, b) => timestamp(b.at) - timestamp(a.at))[0] ?? null;
|
||||
const activity = [
|
||||
{ at: state.baselineAt, source: "baseline" },
|
||||
{ at: state.headSeenAt, source: "head" },
|
||||
...(humanActivity ? [humanActivity] : []),
|
||||
].sort((a, b) => timestamp(b.at) - timestamp(a.at))[0];
|
||||
const lastActivity = timestamp(activity.at);
|
||||
if (lastActivity > now) throw new Error("PR activity is in the future");
|
||||
|
||||
const labels = new Set(pull.labels.map((label) => label.name));
|
||||
const isStale = labels.has("stale");
|
||||
const isPinned = labels.has("pinned");
|
||||
const warnings = history.comments
|
||||
.filter((comment) => isAutomation(comment.user) && comment.body?.startsWith(WARNING_MARKER))
|
||||
.map((comment) => timestamp(comment.created_at))
|
||||
.filter((created) => created > lastActivity);
|
||||
const warnedAt = warnings.length ? Math.max(...warnings) : null;
|
||||
|
||||
let action = "none";
|
||||
if (isPinned || now - lastActivity < STALE_AFTER_MS) {
|
||||
if (isStale) action = "unstale";
|
||||
} else if (!isStale) {
|
||||
action = "warn";
|
||||
} else if (warnedAt === null) {
|
||||
action = "complete-warning";
|
||||
} else if (now - warnedAt >= CLOSE_AFTER_MS) {
|
||||
action = "close";
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
activity,
|
||||
humanActivity,
|
||||
commentId: previous?.commentId ?? null,
|
||||
state,
|
||||
stateChanged,
|
||||
warnedAt: warnedAt === null ? null : new Date(warnedAt).toISOString(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { evaluatePullRequest, formatState, STATE_MARKER, WARNING_MARKER } from "./policy.mjs";
|
||||
|
||||
const human = { login: "contributor", type: "User" };
|
||||
const bot = { login: "github-actions[bot]", type: "Bot" };
|
||||
const headSha = "a".repeat(40);
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const at = (days) => new Date(Date.parse("2026-01-01T00:00:00Z") + days * day).toISOString();
|
||||
|
||||
const pull = ({ labels = [], sha = headSha, updatedAt = at(90) } = {}) => ({
|
||||
head: { sha },
|
||||
labels: labels.map((name) => ({ name })),
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
|
||||
const stateComment = ({ baselineAt = at(0), headSeenAt = at(0), sha = headSha } = {}) => ({
|
||||
id: 10,
|
||||
user: bot,
|
||||
body: formatState({ baselineAt, headSeenAt, headSha: sha }),
|
||||
created_at: at(0),
|
||||
});
|
||||
|
||||
const warning = (createdAt) => ({
|
||||
id: 11,
|
||||
user: bot,
|
||||
body: `${WARNING_MARKER}\nWarning`,
|
||||
created_at: createdAt,
|
||||
});
|
||||
|
||||
const history = ({ comments = [stateComment()], reviews = [], reviewComments = [], events = [] } = {}) => ({
|
||||
comments,
|
||||
reviews,
|
||||
reviewComments,
|
||||
events,
|
||||
});
|
||||
|
||||
test("starts existing PRs with a safe baseline instead of GitHub's updated_at", () => {
|
||||
const result = evaluatePullRequest(
|
||||
pull({ updatedAt: at(60) }),
|
||||
history({ comments: [{ user: human, created_at: at(20), updated_at: at(20) }] }),
|
||||
at(90),
|
||||
);
|
||||
assert.equal(result.action, "none");
|
||||
assert.equal(result.activity.source, "baseline");
|
||||
assert.equal(result.activity.at, at(90));
|
||||
assert.deepEqual(result.humanActivity, { at: at(20), source: "comment" });
|
||||
assert.equal(result.stateChanged, true);
|
||||
});
|
||||
|
||||
test("warns at 30 full days, then closes 14 full days after the warning", () => {
|
||||
assert.equal(evaluatePullRequest(pull(), history(), at(29.99)).action, "none");
|
||||
assert.equal(evaluatePullRequest(pull(), history(), at(30)).action, "warn");
|
||||
const warned = history({ comments: [stateComment(), warning(at(30))] });
|
||||
assert.equal(evaluatePullRequest(pull({ labels: ["stale"] }), warned, at(43.99)).action, "none");
|
||||
assert.equal(evaluatePullRequest(pull({ labels: ["stale"] }), warned, at(44)).action, "close");
|
||||
});
|
||||
|
||||
test("ignores bot comments and a mass PR update timestamp", () => {
|
||||
const result = evaluatePullRequest(
|
||||
pull({ labels: ["stale"], updatedAt: at(43) }),
|
||||
history({ comments: [stateComment(), warning(at(30)), { user: bot, body: "CI update", created_at: at(43) }] }),
|
||||
at(44),
|
||||
);
|
||||
assert.equal(result.action, "close");
|
||||
assert.equal(result.activity.source, "baseline");
|
||||
assert.equal(result.humanActivity, null);
|
||||
});
|
||||
|
||||
test("human comments and reviews clear a stale warning", () => {
|
||||
const comments = [stateComment(), warning(at(30)), { user: human, created_at: at(40), updated_at: at(40) }];
|
||||
assert.equal(evaluatePullRequest(pull({ labels: ["stale"] }), history({ comments }), at(44)).action, "unstale");
|
||||
|
||||
const reviews = [{ user: human, submitted_at: at(40) }];
|
||||
assert.equal(
|
||||
evaluatePullRequest(pull({ labels: ["stale"] }), history({ comments: [stateComment(), warning(at(30))], reviews }), at(44)).action,
|
||||
"unstale",
|
||||
);
|
||||
|
||||
const reviewComments = [{ user: human, created_at: at(40), updated_at: at(40) }];
|
||||
assert.equal(
|
||||
evaluatePullRequest(pull({ labels: ["stale"] }), history({ comments: [stateComment(), warning(at(30))], reviewComments }), at(44)).action,
|
||||
"unstale",
|
||||
);
|
||||
});
|
||||
|
||||
test("a new head commit resets the clock even if its commit date is old", () => {
|
||||
const result = evaluatePullRequest(
|
||||
pull({ labels: ["stale"], sha: "b".repeat(40) }),
|
||||
history({ comments: [stateComment(), warning(at(30))] }),
|
||||
at(44),
|
||||
);
|
||||
assert.equal(result.action, "unstale");
|
||||
assert.equal(result.activity.source, "head");
|
||||
assert.equal(result.state.headSeenAt, at(44));
|
||||
assert.equal(result.stateChanged, true);
|
||||
});
|
||||
|
||||
test("reopening, ready for review, and pinned labels prevent closure", () => {
|
||||
for (const event of ["reopened", "ready_for_review"]) {
|
||||
const events = [{ event, actor: human, created_at: at(40) }];
|
||||
assert.equal(
|
||||
evaluatePullRequest(pull({ labels: ["stale"] }), history({ comments: [stateComment(), warning(at(30))], events }), at(44)).action,
|
||||
"unstale",
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
evaluatePullRequest(pull({ labels: ["stale", "pinned"] }), history({ comments: [stateComment(), warning(at(30))] }), at(44)).action,
|
||||
"unstale",
|
||||
);
|
||||
});
|
||||
|
||||
test("a partial warning or removed stale label starts a fresh warning period", () => {
|
||||
assert.equal(evaluatePullRequest(pull({ labels: ["stale"] }), history(), at(44)).action, "complete-warning");
|
||||
assert.equal(evaluatePullRequest(pull(), history({ comments: [stateComment(), warning(at(30))] }), at(44)).action, "warn");
|
||||
});
|
||||
|
||||
test("rejects ambiguous or malformed automation state", () => {
|
||||
assert.throws(() => evaluatePullRequest(pull(), history({ comments: [stateComment(), stateComment()] }), at(44)), /Multiple/);
|
||||
assert.throws(() => evaluatePullRequest(pull(), history({ comments: [{ ...stateComment(), body: `${STATE_MARKER}bad -->` }] }), at(44)), /Invalid/);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { evaluatePullRequest, formatState, WARNING_MARKER } from "./policy.mjs";
|
||||
|
||||
const [mode, numberArg] = process.argv.slice(2);
|
||||
if (!["--dry-run", "--apply"].includes(mode) || (numberArg && !/^\d+$/.test(numberArg))) {
|
||||
console.error("Usage: node scripts/stale-prs/run.mjs <--dry-run|--apply> [pr-number]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (!/^[\w.-]+\/[\w.-]+$/.test(repository || "") || !token) {
|
||||
console.error("GITHUB_REPOSITORY and GITHUB_TOKEN are required");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const apiRoot = (process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "");
|
||||
const repoPath = `/repos/${repository}`;
|
||||
const dryRun = mode === "--dry-run";
|
||||
const pageSize = 100;
|
||||
|
||||
const request = async (method, path, body) => {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const response = await fetch(`${apiRoot}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (response.ok) return response.status === 204 ? null : response.json();
|
||||
if (method !== "GET" || ![429, 502, 503, 504].includes(response.status) || attempt === 2) {
|
||||
throw new Error(`GitHub API ${method} ${path} returned ${response.status}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
|
||||
}
|
||||
};
|
||||
|
||||
const pages = async (path) => {
|
||||
const results = [];
|
||||
for (let page = 1; page <= 1000; page++) {
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
const batch = await request("GET", `${path}${separator}per_page=${pageSize}&page=${page}`);
|
||||
if (!Array.isArray(batch)) throw new Error(`GitHub API ${path} did not return a list`);
|
||||
results.push(...batch);
|
||||
if (batch.length < pageSize) return results;
|
||||
}
|
||||
throw new Error(`GitHub API ${path} exceeded the pagination limit`);
|
||||
};
|
||||
|
||||
const listOpenPulls = async () => {
|
||||
const pulls = await pages(`${repoPath}/pulls?state=open&sort=created&direction=asc`);
|
||||
const numbers = pulls.map((pull) => pull.number);
|
||||
if (new Set(numbers).size !== numbers.length) throw new Error("Duplicate pull requests in API pagination");
|
||||
return numbers;
|
||||
};
|
||||
|
||||
const loadPull = async (number) => request("GET", `${repoPath}/pulls/${number}`);
|
||||
|
||||
const loadHistory = async (number) => {
|
||||
const issuePath = `${repoPath}/issues/${number}`;
|
||||
const pullPath = `${repoPath}/pulls/${number}`;
|
||||
const [comments, reviews, reviewComments, events] = await Promise.all([
|
||||
pages(`${issuePath}/comments`),
|
||||
pages(`${pullPath}/reviews`),
|
||||
pages(`${pullPath}/comments`),
|
||||
pages(`${issuePath}/events`),
|
||||
]);
|
||||
return { comments, reviews, reviewComments, events };
|
||||
};
|
||||
|
||||
const warningBody = `${WARNING_MARKER}\nThis PR has had no qualifying activity for 30 days. It will be closed in 14 days unless someone comments, reviews, or pushes an update. A maintainer can add \`pinned\` to exempt it.`;
|
||||
|
||||
const applyPlan = async (number, pull, plan) => {
|
||||
const issuePath = `${repoPath}/issues/${number}`;
|
||||
if (plan.stateChanged) {
|
||||
const path = plan.commentId ? `${repoPath}/issues/comments/${plan.commentId}` : `${issuePath}/comments`;
|
||||
await request(plan.commentId ? "PATCH" : "POST", path, { body: formatState(plan.state) });
|
||||
}
|
||||
|
||||
if (["warn", "complete-warning", "close"].includes(plan.action)) {
|
||||
const freshPull = await loadPull(number);
|
||||
if (freshPull.state !== "open" || freshPull.head.sha !== pull.head.sha) return "deferred";
|
||||
const freshPlan = evaluatePullRequest(freshPull, await loadHistory(number), new Date().toISOString());
|
||||
if (freshPlan.action !== plan.action || freshPlan.activity.at !== plan.activity.at || freshPlan.stateChanged) return "deferred";
|
||||
}
|
||||
|
||||
if (plan.action === "unstale") {
|
||||
await request("DELETE", `${issuePath}/labels/stale`);
|
||||
} else if (plan.action === "warn") {
|
||||
await request("POST", `${issuePath}/labels`, { labels: ["stale"] });
|
||||
await request("POST", `${issuePath}/comments`, { body: warningBody });
|
||||
} else if (plan.action === "complete-warning") {
|
||||
await request("POST", `${issuePath}/comments`, { body: warningBody });
|
||||
} else if (plan.action === "close") {
|
||||
await request("PATCH", `${repoPath}/pulls/${number}`, { state: "closed" });
|
||||
}
|
||||
return plan.action;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const numbers = numberArg ? [Number(numberArg)] : await listOpenPulls();
|
||||
const scans = [];
|
||||
for (const number of numbers) {
|
||||
const pull = await loadPull(number);
|
||||
if (pull.state !== "open") throw new Error(`PR #${number} changed state during the scan`);
|
||||
const history = await loadHistory(number);
|
||||
const plan = evaluatePullRequest(pull, history, new Date().toISOString());
|
||||
scans.push({ number, pull: { head: { sha: pull.head.sha } }, plan });
|
||||
}
|
||||
if (!numberArg) {
|
||||
const latestNumbers = await listOpenPulls();
|
||||
if (JSON.stringify(numbers) !== JSON.stringify(latestNumbers)) throw new Error("Open PR list changed during the scan");
|
||||
}
|
||||
|
||||
for (const { number, pull, plan } of scans) {
|
||||
const action = dryRun ? plan.action : await applyPlan(number, pull, plan);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
number,
|
||||
action,
|
||||
activityAt: plan.activity.at,
|
||||
activitySource: plan.activity.source,
|
||||
humanActivityAt: plan.humanActivity?.at ?? null,
|
||||
humanActivitySource: plan.humanActivity?.source ?? null,
|
||||
warnedAt: plan.warnedAt,
|
||||
state: plan.stateChanged ? "initialize-or-update" : "current",
|
||||
}),
|
||||
);
|
||||
}
|
||||
console.log(JSON.stringify({ inspected: scans.length, total: numbers.length, mode: dryRun ? "dry-run" : "apply" }));
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`PR inactivity check failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { formatState, WARNING_MARKER } from "./policy.mjs";
|
||||
|
||||
const script = fileURLToPath(new URL("./run.mjs", import.meta.url));
|
||||
const bot = { login: "github-actions[bot]", type: "Bot" };
|
||||
const sha = "a".repeat(40);
|
||||
|
||||
const startServer = async (handler) => {
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const body = await new Promise((resolve) => {
|
||||
let value = "";
|
||||
request.on("data", (chunk) => (value += chunk));
|
||||
request.on("end", () => resolve(value ? JSON.parse(value) : null));
|
||||
});
|
||||
const result = handler(request, body);
|
||||
response.writeHead(result.status || 200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify(result.body ?? null));
|
||||
} catch {
|
||||
response.writeHead(500);
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
return server;
|
||||
};
|
||||
|
||||
const run = (server, mode) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [script, mode], {
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_API_URL: `http://127.0.0.1:${server.address().port}`,
|
||||
GITHUB_REPOSITORY: "example/repo",
|
||||
GITHUB_TOKEN: "test-token",
|
||||
},
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => (stdout += chunk));
|
||||
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||
child.on("close", (status) => resolve({ status, stdout, stderr }));
|
||||
});
|
||||
|
||||
test("dry-run inspects every page and performs no writes", async (t) => {
|
||||
let writes = 0;
|
||||
const server = await startServer((request) => {
|
||||
if (request.method !== "GET") writes++;
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (url.pathname === "/repos/example/repo/pulls") {
|
||||
const page = Number(url.searchParams.get("page"));
|
||||
const numbers = page === 1 ? Array.from({ length: 100 }, (_, index) => index + 1) : [101];
|
||||
return { body: numbers.map((number) => ({ number })) };
|
||||
}
|
||||
const match = url.pathname.match(/^\/repos\/example\/repo\/pulls\/(\d+)$/);
|
||||
if (match) return { body: { number: Number(match[1]), state: "open", head: { sha }, labels: [] } };
|
||||
return { body: [] };
|
||||
});
|
||||
t.after(() => server.close());
|
||||
|
||||
const result = await run(server, "--dry-run");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(writes, 0);
|
||||
assert.match(result.stdout, /"inspected":101,"total":101,"mode":"dry-run"/);
|
||||
});
|
||||
|
||||
test("apply rechecks activity before closing a warned PR", async (t) => {
|
||||
const now = Date.now();
|
||||
const daysAgo = (days) => new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const comments = [
|
||||
{
|
||||
id: 1,
|
||||
user: bot,
|
||||
body: formatState({ baselineAt: daysAgo(60), headSeenAt: daysAgo(60), headSha: sha }),
|
||||
created_at: daysAgo(60),
|
||||
},
|
||||
{ id: 2, user: bot, body: WARNING_MARKER, created_at: daysAgo(15) },
|
||||
];
|
||||
const writes = [];
|
||||
const server = await startServer((request, body) => {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (request.method !== "GET") writes.push({ method: request.method, path: url.pathname, body });
|
||||
if (url.pathname === "/repos/example/repo/pulls") return { body: [{ number: 7 }] };
|
||||
if (url.pathname === "/repos/example/repo/pulls/7") {
|
||||
if (request.method === "PATCH") return { body: { state: "closed" } };
|
||||
return { body: { number: 7, state: "open", head: { sha }, labels: [{ name: "stale" }] } };
|
||||
}
|
||||
if (url.pathname === "/repos/example/repo/issues/7/comments") return { body: comments };
|
||||
return { body: [] };
|
||||
});
|
||||
t.after(() => server.close());
|
||||
|
||||
const result = await run(server, "--apply");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(writes, [{ method: "PATCH", path: "/repos/example/repo/pulls/7", body: { state: "closed" } }]);
|
||||
});
|
||||
|
||||
test("apply defers closure when a human comments during the final recheck", async (t) => {
|
||||
const now = Date.now();
|
||||
const daysAgo = (days) => new Date(now - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
let commentReads = 0;
|
||||
let closed = false;
|
||||
const server = await startServer((request) => {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (url.pathname === "/repos/example/repo/pulls") return { body: [{ number: 7 }] };
|
||||
if (url.pathname === "/repos/example/repo/pulls/7") {
|
||||
if (request.method === "PATCH") closed = true;
|
||||
return { body: { number: 7, state: "open", head: { sha }, labels: [{ name: "stale" }] } };
|
||||
}
|
||||
if (url.pathname === "/repos/example/repo/issues/7/comments") {
|
||||
commentReads++;
|
||||
return {
|
||||
body: [
|
||||
{ id: 1, user: bot, body: formatState({ baselineAt: daysAgo(60), headSeenAt: daysAgo(60), headSha: sha }), created_at: daysAgo(60) },
|
||||
{ id: 2, user: bot, body: WARNING_MARKER, created_at: daysAgo(15) },
|
||||
...(commentReads > 1
|
||||
? [{ id: 3, user: { login: "contributor", type: "User" }, created_at: daysAgo(0.01), updated_at: daysAgo(0.01) }]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
return { body: [] };
|
||||
});
|
||||
t.after(() => server.close());
|
||||
|
||||
const result = await run(server, "--apply");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(commentReads, 2);
|
||||
assert.equal(closed, false);
|
||||
assert.match(result.stdout, /"action":"deferred"/);
|
||||
});
|
||||
|
||||
test("apply labels and warns an inactive PR", async (t) => {
|
||||
const baselineAt = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const writes = [];
|
||||
const server = await startServer((request, body) => {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (request.method !== "GET") writes.push({ method: request.method, path: url.pathname, body });
|
||||
if (url.pathname === "/repos/example/repo/pulls") return { body: [{ number: 7 }] };
|
||||
if (url.pathname === "/repos/example/repo/pulls/7") return { body: { number: 7, state: "open", head: { sha }, labels: [] } };
|
||||
if (url.pathname === "/repos/example/repo/issues/7/comments") {
|
||||
return { body: [{ id: 1, user: bot, body: formatState({ baselineAt, headSeenAt: baselineAt, headSha: sha }), created_at: baselineAt }] };
|
||||
}
|
||||
return { body: [] };
|
||||
});
|
||||
t.after(() => server.close());
|
||||
|
||||
const result = await run(server, "--apply");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(writes.length, 2);
|
||||
assert.deepEqual(writes[0], { method: "POST", path: "/repos/example/repo/issues/7/labels", body: { labels: ["stale"] } });
|
||||
assert.equal(writes[1].method, "POST");
|
||||
assert.equal(writes[1].path, "/repos/example/repo/issues/7/comments");
|
||||
assert.match(writes[1].body.body, /bookorbit-stale-pr-warning:v1/);
|
||||
});
|
||||
|
||||
test("apply updates the recorded head and removes stale after a push", async (t) => {
|
||||
const baselineAt = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const writes = [];
|
||||
const server = await startServer((request, body) => {
|
||||
const url = new URL(request.url, "http://localhost");
|
||||
if (request.method !== "GET") writes.push({ method: request.method, path: url.pathname, body });
|
||||
if (url.pathname === "/repos/example/repo/pulls") return { body: [{ number: 7 }] };
|
||||
if (url.pathname === "/repos/example/repo/pulls/7") {
|
||||
return { body: { number: 7, state: "open", head: { sha: "b".repeat(40) }, labels: [{ name: "stale" }] } };
|
||||
}
|
||||
if (url.pathname === "/repos/example/repo/issues/7/comments") {
|
||||
return { body: [{ id: 31, user: bot, body: formatState({ baselineAt, headSeenAt: baselineAt, headSha: sha }), created_at: baselineAt }] };
|
||||
}
|
||||
return { body: [] };
|
||||
});
|
||||
t.after(() => server.close());
|
||||
|
||||
const result = await run(server, "--apply");
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(writes.length, 2);
|
||||
assert.equal(writes[0].method, "PATCH");
|
||||
assert.equal(writes[0].path, "/repos/example/repo/issues/comments/31");
|
||||
assert.match(writes[0].body.body, /b{40}/);
|
||||
assert.deepEqual(writes[1], { method: "DELETE", path: "/repos/example/repo/issues/7/labels/stale", body: null });
|
||||
});
|
||||
Reference in New Issue
Block a user