Files
bookorbit/scripts/stale-prs/run.test.mjs
T
neon a0477f0d0b 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.
2026-09-26 15:48:02 +00:00

186 lines
8.0 KiB
JavaScript

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 });
});