Close stale fleetie-initiated issues. (#45530)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #45700 Not a product change. This PR will allow us to run the workflow manually. After ~2 weeks, if there are no issues, we'll make it automatic. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added automated workflow to identify and close stale issues created by Fleet team members, with dry-run capability and operation limits. * Added system to build and maintain a deduplicated list of Fleet team member handles from GitHub organization and repository history. * **Tests** * Added comprehensive test suites for stale issue management and handle list generation with mock GitHub API interactions and boundary condition coverage. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/fleetdm/fleet/pull/45530?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds a list of GitHub usernames belonging to current or former Fleeties.
|
||||
//
|
||||
// Sources:
|
||||
// 1. Current: GET /orgs/fleetdm/members (requires READ_ORG_TOKEN with read:org).
|
||||
// If READ_ORG_TOKEN is unset, this step is skipped and the script runs in handbook-only mode.
|
||||
// 2. Former: handles that have ever appeared in handbook files as `[@x](https://github.com/x)` links.
|
||||
// Collected by walking `git log -p --follow` over each handbook file.
|
||||
//
|
||||
// Output: newline-delimited lowercased handles, sorted, deduped, to stdout or to FLEETIE_HANDLES_OUT.
|
||||
|
||||
"use strict";
|
||||
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const https = require("node:https");
|
||||
const path = require("node:path");
|
||||
|
||||
const HANDBOOK_FILES = [
|
||||
"handbook/company/product-groups.md",
|
||||
"handbook/company/go-to-market-operations.md",
|
||||
"handbook/company/communications.md",
|
||||
"handbook/ceo/README.md",
|
||||
"handbook/customer-success/README.md",
|
||||
"handbook/engineering/README.md",
|
||||
"handbook/finance/README.md",
|
||||
"handbook/it/README.md",
|
||||
"handbook/marketing/README.md",
|
||||
"handbook/marketing/marketing-responsibilities.md",
|
||||
"handbook/people/README.md",
|
||||
"handbook/product-design/README.md",
|
||||
"handbook/sales/README.md",
|
||||
];
|
||||
|
||||
// Handles that the regex captures but that are not personal accounts.
|
||||
const DENYLIST = new Set([
|
||||
"fleetdm",
|
||||
"fleetdm-bot",
|
||||
"todo",
|
||||
"orgs",
|
||||
"issues",
|
||||
"pull",
|
||||
"pulls",
|
||||
"user-attachments",
|
||||
"apps",
|
||||
"features",
|
||||
"about",
|
||||
"sponsors",
|
||||
"marketplace",
|
||||
"enterprise",
|
||||
"topics",
|
||||
"collections",
|
||||
"login",
|
||||
"logout",
|
||||
"settings",
|
||||
"notifications",
|
||||
"security",
|
||||
"pricing",
|
||||
"contact",
|
||||
"open-source",
|
||||
"readme",
|
||||
"search",
|
||||
"explore",
|
||||
"trending",
|
||||
"mobile",
|
||||
"team",
|
||||
"customer-stories",
|
||||
"github",
|
||||
"organizations",
|
||||
"new",
|
||||
"edit",
|
||||
]);
|
||||
|
||||
// Match `github.com/<handle>)` to capture handles from markdown links like `[@x](https://github.com/x)`.
|
||||
// The trailing `)` ensures we only match inside such links, not URL path segments like `/orgs/...`.
|
||||
// GitHub handles: 1-39 chars, alphanumeric or hyphen, must not start with hyphen.
|
||||
const HANDLE_RE = /github\.com\/([A-Za-z0-9][A-Za-z0-9-]{0,38})\)/g;
|
||||
|
||||
function gitRoot() {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function extractHandles(text, into) {
|
||||
HANDLE_RE.lastIndex = 0;
|
||||
let match;
|
||||
while ((match = HANDLE_RE.exec(text)) !== null) {
|
||||
const handle = match[1].toLowerCase();
|
||||
if (handle.endsWith("-")) continue;
|
||||
if (DENYLIST.has(handle)) continue;
|
||||
into.add(handle);
|
||||
}
|
||||
}
|
||||
|
||||
function collectFromHead(file, into) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
extractHandles(fs.readFileSync(file, "utf8"), into);
|
||||
}
|
||||
|
||||
function collectFromGitHistory(file, into) {
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync(
|
||||
"git",
|
||||
["log", "-p", "--follow", "--no-color", "--pretty=format:", "--", file],
|
||||
{ encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(`warn: git log failed for ${file}: ${err.message}\n`);
|
||||
return;
|
||||
}
|
||||
extractHandles(stdout, into);
|
||||
}
|
||||
|
||||
function parseLinkNext(linkHeader) {
|
||||
if (!linkHeader) return null;
|
||||
for (const part of linkHeader.split(",")) {
|
||||
const match = part.match(/^\s*<([^>]+)>;\s*rel="next"\s*$/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function httpGet(url, token) {
|
||||
// Default 15s. Override via env for tests or slow networks.
|
||||
const timeoutMs =
|
||||
Number.parseInt(process.env.READ_ORG_HTTP_TIMEOUT_MS, 10) || 15000;
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = {
|
||||
headers: {
|
||||
"User-Agent": "fleetdm-build-fleetie-handles",
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
};
|
||||
const req = https.get(url, opts, (res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve({ body, linkNext: parseLinkNext(res.headers.link || "") });
|
||||
} else {
|
||||
reject(
|
||||
new Error(
|
||||
`HTTP ${res.statusCode} from ${url}: ${body.slice(0, 200)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
req.destroy(new Error(`request timeout after ${timeoutMs}ms: ${url}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOrgMembers(token, into) {
|
||||
let url = "https://api.github.com/orgs/fleetdm/members?per_page=100";
|
||||
while (url) {
|
||||
const { body, linkNext } = await httpGet(url, token);
|
||||
const arr = JSON.parse(body);
|
||||
if (!Array.isArray(arr)) {
|
||||
throw new Error(
|
||||
`org members API returned non-array: ${body.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
for (const member of arr) {
|
||||
if (member && typeof member.login === "string") {
|
||||
into.add(member.login.toLowerCase());
|
||||
}
|
||||
}
|
||||
url = linkNext;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
process.chdir(gitRoot());
|
||||
|
||||
const handles = new Set();
|
||||
const token = process.env.READ_ORG_TOKEN || "";
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await fetchOrgMembers(token, handles);
|
||||
process.stderr.write(
|
||||
`info: fetched fleetdm org members; running total ${handles.size}\n`
|
||||
);
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`warn: org members fetch failed (${err.message}); using handbook-only\n`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
process.stderr.write(
|
||||
"info: READ_ORG_TOKEN not set; using handbook-only sources\n"
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of HANDBOOK_FILES) {
|
||||
collectFromHead(file, handles);
|
||||
collectFromGitHistory(file, handles);
|
||||
}
|
||||
|
||||
// Re-apply the denylist after the union so we cannot leak `fleetdm` or `todo` even if a future source
|
||||
// emitted them in non-lowercased form.
|
||||
for (const denied of DENYLIST) {
|
||||
handles.delete(denied);
|
||||
}
|
||||
|
||||
const out = [...handles].sort().join("\n") + "\n";
|
||||
const outPath = process.env.FLEETIE_HANDLES_OUT;
|
||||
if (outPath) {
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
fs.writeFileSync(outPath, out);
|
||||
process.stderr.write(`info: wrote ${handles.size} handles to ${outPath}\n`);
|
||||
} else {
|
||||
process.stdout.write(out);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`error: ${err.stack || err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.join(__dirname, 'build-fleetie-handles.js');
|
||||
|
||||
function runScript() {
|
||||
// Force handbook-only mode so the test is hermetic (no network calls, no token required).
|
||||
const env = { ...process.env, READ_ORG_TOKEN: '' };
|
||||
delete env.FLEETIE_HANDLES_OUT;
|
||||
const out = execFileSync('node', [SCRIPT], {
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
});
|
||||
return out.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
let handlesCache = null;
|
||||
function getHandles() {
|
||||
if (!handlesCache) handlesCache = runScript();
|
||||
return handlesCache;
|
||||
}
|
||||
|
||||
test('produces a sane number of handles', () => {
|
||||
const handles = getHandles();
|
||||
assert.ok(handles.length >= 50, `expected at least 50 handles, got ${handles.length}`);
|
||||
});
|
||||
|
||||
test('output is sorted, lowercased, and deduplicated', () => {
|
||||
const handles = getHandles();
|
||||
const sorted = [...handles].sort();
|
||||
assert.deepStrictEqual(handles, sorted, 'output is not sorted');
|
||||
for (const handle of handles) {
|
||||
assert.strictEqual(handle, handle.toLowerCase(), `handle not lowercased: ${handle}`);
|
||||
}
|
||||
assert.strictEqual(new Set(handles).size, handles.length, 'output contains duplicates');
|
||||
});
|
||||
|
||||
test('includes known current Fleeties', () => {
|
||||
const set = new Set(getHandles());
|
||||
for (const handle of ['getvictor', 'lukeheath', 'mikermcneil', 'noahtalerman', 'eashaw']) {
|
||||
assert.ok(set.has(handle), `expected current Fleetie ${handle} in handle list`);
|
||||
}
|
||||
});
|
||||
|
||||
test('includes known former Fleeties from git history', () => {
|
||||
const set = new Set(getHandles());
|
||||
// These handles have all appeared in handbook/company/product-groups.md at some point in git history
|
||||
// but are not in the file at HEAD.
|
||||
for (const handle of ['iansltx', 'mna', 'roperzh', 'ghernandez345']) {
|
||||
assert.ok(set.has(handle), `expected former Fleetie ${handle} in handle list`);
|
||||
}
|
||||
});
|
||||
|
||||
test('excludes denylisted path segments and the org handle', () => {
|
||||
const set = new Set(getHandles());
|
||||
for (const denied of ['fleetdm', 'todo', 'user-attachments', 'orgs', 'issues', 'pull', 'apps']) {
|
||||
assert.ok(!set.has(denied), `expected ${denied} to be filtered out`);
|
||||
}
|
||||
});
|
||||
|
||||
test('all handles match the GitHub username format', () => {
|
||||
const handles = getHandles();
|
||||
const githubHandleRe = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
||||
for (const handle of handles) {
|
||||
assert.ok(githubHandleRe.test(handle), `invalid handle in output: ${handle}`);
|
||||
assert.ok(!handle.endsWith('-'), `handle ends with hyphen: ${handle}`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
// Marks open issues authored by current/former Fleeties as stale after 2y of inactivity, and closes them
|
||||
// after 14 more days. Exempts `bug`, `:product`, and `customer-*` labels. Invoked by
|
||||
// `actions/github-script` from `.github/workflows/close-stale-fleetie-initiated-issues.yml`.
|
||||
//
|
||||
// If a stale-labeled issue receives activity (e.g. a comment) after being labeled, the close phase
|
||||
// removes the stale label instead of closing, mirroring `actions/stale`'s `remove-stale-when-updated`
|
||||
// behavior. Detected by comparing `updated_at` against the most recent `labeled` event for `stale`.
|
||||
//
|
||||
// Inputs (env):
|
||||
// FLEETIE_HANDLES_FILE Path to newline-delimited lowercased GitHub usernames (built by `build-fleetie-handles.js`).
|
||||
// DRY_RUN 'true' to log candidates without writing.
|
||||
// MAX_OPERATIONS Cap on API write operations per run. Default 400. `0` disables writes.
|
||||
//
|
||||
// Exports: `async function run({ github, context, core })`. Returns a summary object for tests.
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
|
||||
const STALE_DAYS = 730;
|
||||
const CLOSE_DAYS = 14;
|
||||
const STALE_LABEL = "stale";
|
||||
const STALE_MSG =
|
||||
"This issue is stale because it was opened by a current or former Fleetie and has had " +
|
||||
"no activity for 2 years. Please update the issue if it is still relevant; otherwise it " +
|
||||
"will be closed in 14 days.";
|
||||
const CLOSE_MSG =
|
||||
"This issue was closed because it received no further activity for 14 days after being marked stale. " +
|
||||
"Any comment would have removed the stale label and prevented closure.";
|
||||
// Tolerance for our own label+comment landing milliseconds apart. Distinguishes the bot's own
|
||||
// activity bump from genuine user activity after labeling.
|
||||
const SELF_ACTIVITY_EPSILON_MS = 60 * 1000;
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
const isExempt = (name) => {
|
||||
const lower = (name || "").toLowerCase();
|
||||
return (
|
||||
lower === "bug" || lower === ":product" || lower.startsWith("customer-")
|
||||
);
|
||||
};
|
||||
|
||||
const parseMaxOps = (raw) => {
|
||||
const parsed = Number.parseInt(raw ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 400;
|
||||
};
|
||||
|
||||
async function run({ github, context, core }) {
|
||||
const handles = new Set(
|
||||
fs
|
||||
.readFileSync(process.env.FLEETIE_HANDLES_FILE, "utf8")
|
||||
.split("\n")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const dryRun = String(process.env.DRY_RUN).toLowerCase() === "true";
|
||||
const maxOps = parseMaxOps(process.env.MAX_OPERATIONS);
|
||||
|
||||
// All time math is in UTC-equivalent epoch milliseconds: Date.now() is UTC, and the GitHub
|
||||
// REST API returns ISO 8601 strings with a `Z` suffix, so timezone and DST cannot affect the
|
||||
// result. `daysSince` is a 24-hour-day count, not a calendar-day count.
|
||||
const now = Date.now();
|
||||
const daysSince = (iso) => (now - new Date(iso).getTime()) / MS_PER_DAY;
|
||||
|
||||
core.info(
|
||||
`Loaded ${handles.size} Fleetie handles. dry_run=${dryRun}, max_operations=${maxOps}`
|
||||
);
|
||||
|
||||
// Collect candidates by scanning all open issues. Two groups qualify:
|
||||
// 1. Idle >= CLOSE_DAYS — feeds the stale and close phases.
|
||||
// 2. Currently `stale`-labeled regardless of idle time — feeds the un-stale phase so a user
|
||||
// comment on a stale issue removes the label on the next run, not 14 days later.
|
||||
const candidates = [];
|
||||
const iterator = github.paginate.iterator(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: "open",
|
||||
sort: "updated",
|
||||
direction: "asc",
|
||||
per_page: 100,
|
||||
});
|
||||
for await (const { data } of iterator) {
|
||||
for (const issue of data) {
|
||||
if (issue.pull_request) continue;
|
||||
const idleDays = daysSince(issue.updated_at);
|
||||
const hasStaleLabel = (issue.labels || []).some(
|
||||
(l) =>
|
||||
(typeof l === "string" ? l : (l && l.name) || "").toLowerCase() ===
|
||||
STALE_LABEL
|
||||
);
|
||||
if (idleDays >= CLOSE_DAYS || hasStaleLabel) {
|
||||
candidates.push(issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
core.info(
|
||||
`Collected ${candidates.length} open candidate issues (idle >= ${CLOSE_DAYS}d or stale-labeled)`
|
||||
);
|
||||
|
||||
const staled = [];
|
||||
const closed = [];
|
||||
const unstaled = [];
|
||||
const errored = [];
|
||||
let skippedNonFleetie = 0;
|
||||
let skippedExempt = 0;
|
||||
let skippedNotStaleYet = 0;
|
||||
let skippedNotReadyToClose = 0;
|
||||
let writes = 0;
|
||||
let hitCap = false;
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
for (const issue of candidates) {
|
||||
// Conservative pre-check: each iteration may do up to 2 writes (stale or close phases). The
|
||||
// unstale path is 1 write, so this estimate is safe but slightly over-conservative on that path.
|
||||
if (!dryRun && writes + 2 > maxOps) {
|
||||
hitCap = true;
|
||||
core.warning(`Reached max_operations=${maxOps}; stopping.`);
|
||||
break;
|
||||
}
|
||||
|
||||
const author = (issue.user && issue.user.login
|
||||
? issue.user.login
|
||||
: ""
|
||||
).toLowerCase();
|
||||
if (!handles.has(author)) {
|
||||
skippedNonFleetie++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelNames = (issue.labels || []).map(
|
||||
(l) => (typeof l === "string" ? l : l.name) || ""
|
||||
);
|
||||
if (labelNames.some(isExempt)) {
|
||||
skippedExempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const idleDays = daysSince(issue.updated_at);
|
||||
const alreadyStale = labelNames.some(
|
||||
(n) => n.toLowerCase() === STALE_LABEL
|
||||
);
|
||||
|
||||
if (alreadyStale) {
|
||||
// Determine whether there's been activity after the stale label was applied. If so, remove the
|
||||
// stale label (mirroring actions/stale's remove-stale-when-updated) and skip the close.
|
||||
let events;
|
||||
try {
|
||||
events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (err) {
|
||||
core.warning(
|
||||
`listEvents failed for #${issue.number}: ${err.message}; skipping`
|
||||
);
|
||||
errored.push({
|
||||
number: issue.number,
|
||||
phase: "check-activity",
|
||||
message: err.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastStaleLabelEvent = null;
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
if (e.event === "labeled" && e.label && e.label.name === STALE_LABEL) {
|
||||
lastStaleLabelEvent = e;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastStaleLabelEvent) {
|
||||
core.warning(
|
||||
`#${issue.number}: has '${STALE_LABEL}' label but no labeling event in history; skipping`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const labeledAt = new Date(lastStaleLabelEvent.created_at).getTime();
|
||||
const updatedAt = new Date(issue.updated_at).getTime();
|
||||
const activityAfterLabel =
|
||||
updatedAt > labeledAt + SELF_ACTIVITY_EPSILON_MS;
|
||||
|
||||
if (activityAfterLabel) {
|
||||
const entry = {
|
||||
number: issue.number,
|
||||
url: issue.html_url,
|
||||
author,
|
||||
idleDays,
|
||||
};
|
||||
core.info(
|
||||
`unstale: #${issue.number} by @${author} (activity after label)`
|
||||
);
|
||||
if (dryRun) {
|
||||
unstaled.push(entry);
|
||||
} else {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
name: STALE_LABEL,
|
||||
});
|
||||
writes += 1;
|
||||
unstaled.push(entry);
|
||||
} catch (err) {
|
||||
if (err && err.status === 404) {
|
||||
// Label already gone (idempotent success).
|
||||
unstaled.push(entry);
|
||||
} else {
|
||||
core.warning(
|
||||
`unstale failed for #${issue.number}: ${err.message}`
|
||||
);
|
||||
errored.push({
|
||||
number: issue.number,
|
||||
phase: "unstale",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Close phase: stale label present, no activity since labeling, AND >= CLOSE_DAYS idle.
|
||||
// The idle gate matters because candidate collection accepts stale-labeled issues regardless
|
||||
// of idle time (so the un-stale path runs promptly on activity); without this check, a
|
||||
// freshly-staled issue with no activity would be closed on the very next run.
|
||||
if (idleDays < CLOSE_DAYS) {
|
||||
skippedNotReadyToClose++;
|
||||
continue;
|
||||
}
|
||||
const entry = {
|
||||
number: issue.number,
|
||||
url: issue.html_url,
|
||||
author,
|
||||
idleDays,
|
||||
};
|
||||
core.info(
|
||||
`close: #${issue.number} by @${author}, idle ${idleDays.toFixed(1)}d`
|
||||
);
|
||||
if (dryRun) {
|
||||
closed.push(entry);
|
||||
} else {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
body: CLOSE_MSG,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
writes += 2;
|
||||
closed.push(entry);
|
||||
} catch (err) {
|
||||
core.warning(`close failed for #${issue.number}: ${err.message}`);
|
||||
errored.push({
|
||||
number: issue.number,
|
||||
phase: "close",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (idleDays >= STALE_DAYS) {
|
||||
const entry = {
|
||||
number: issue.number,
|
||||
url: issue.html_url,
|
||||
author,
|
||||
idleDays,
|
||||
};
|
||||
core.info(
|
||||
`stale: #${issue.number} by @${author}, idle ${idleDays.toFixed(1)}d`
|
||||
);
|
||||
if (dryRun) {
|
||||
staled.push(entry);
|
||||
} else {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
body: STALE_MSG,
|
||||
});
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
labels: [STALE_LABEL],
|
||||
});
|
||||
writes += 2;
|
||||
staled.push(entry);
|
||||
} catch (err) {
|
||||
core.warning(`stale failed for #${issue.number}: ${err.message}`);
|
||||
errored.push({
|
||||
number: issue.number,
|
||||
phase: "stale",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
skippedNotStaleYet++;
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (list) =>
|
||||
list.length
|
||||
? list
|
||||
.map(
|
||||
(e) =>
|
||||
`- [#${e.number}](${e.url}) by @${
|
||||
e.author
|
||||
} (idle ${e.idleDays.toFixed(1)}d)`
|
||||
)
|
||||
.join("\n")
|
||||
: "_none_";
|
||||
const fmtErrors = (list) =>
|
||||
list.length
|
||||
? list.map((e) => `- #${e.number} (${e.phase}): ${e.message}`).join("\n")
|
||||
: "_none_";
|
||||
|
||||
await core.summary
|
||||
.addHeading("Fleetie stale-issue closer")
|
||||
.addRaw(`Mode: **${dryRun ? "dry-run" : "live"}**`)
|
||||
.addBreak()
|
||||
.addRaw(`Fleetie handles loaded: **${handles.size}**`)
|
||||
.addBreak()
|
||||
.addRaw(`Open issues considered: **${candidates.length}**`)
|
||||
.addList([
|
||||
`Skipped (non-Fleetie author): ${skippedNonFleetie}`,
|
||||
`Skipped (exempt label: bug, :product, customer-*): ${skippedExempt}`,
|
||||
`Skipped (Fleetie-authored but younger than ${STALE_DAYS} days): ${skippedNotStaleYet}`,
|
||||
`Skipped (stale-labeled but not yet ${CLOSE_DAYS}d idle): ${skippedNotReadyToClose}`,
|
||||
`Marked stale this run: ${staled.length}`,
|
||||
`Closed this run: ${closed.length}`,
|
||||
`Un-staled this run (activity after label): ${unstaled.length}`,
|
||||
`Errors: ${errored.length}`,
|
||||
])
|
||||
.addHeading("Marked stale", 3)
|
||||
.addRaw(fmt(staled))
|
||||
.addBreak()
|
||||
.addHeading("Closed", 3)
|
||||
.addRaw(fmt(closed))
|
||||
.addBreak()
|
||||
.addHeading("Un-staled (activity after label)", 3)
|
||||
.addRaw(fmt(unstaled))
|
||||
.addBreak()
|
||||
.addHeading("Errors", 3)
|
||||
.addRaw(fmtErrors(errored))
|
||||
.write();
|
||||
|
||||
// Returned for test assertions only. The production caller (the workflow) discards this and
|
||||
// reads `core.summary` instead.
|
||||
return {
|
||||
dryRun,
|
||||
candidates: candidates.length,
|
||||
staled,
|
||||
closed,
|
||||
unstaled,
|
||||
errored,
|
||||
skippedNonFleetie,
|
||||
skippedExempt,
|
||||
skippedNotStaleYet,
|
||||
skippedNotReadyToClose,
|
||||
hitCap,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = run;
|
||||
// Exported for test boundary assertions so a future change to STALE_DAYS / CLOSE_DAYS /
|
||||
// SELF_ACTIVITY_EPSILON_MS surfaces in the test that exercises the boundary, instead of silently
|
||||
// passing because the test hardcoded the old value.
|
||||
module.exports.STALE_DAYS = STALE_DAYS;
|
||||
module.exports.CLOSE_DAYS = CLOSE_DAYS;
|
||||
module.exports.SELF_ACTIVITY_EPSILON_MS = SELF_ACTIVITY_EPSILON_MS;
|
||||
@@ -0,0 +1,712 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const run = require('./stale-fleetie-issues.js');
|
||||
// Pull constants from the script so a future policy change (e.g. STALE_DAYS 730 -> 365) surfaces
|
||||
// in the boundary tests instead of silently passing because the test hardcoded the old value.
|
||||
const { STALE_DAYS, CLOSE_DAYS, SELF_ACTIVITY_EPSILON_MS } = run;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const daysAgoIso = (days) => new Date(Date.now() - days * DAY_MS).toISOString();
|
||||
|
||||
function makeIssue(overrides = {}) {
|
||||
return {
|
||||
number: 1,
|
||||
html_url: 'https://github.com/o/r/issues/1',
|
||||
user: { login: 'getvictor' },
|
||||
labels: [],
|
||||
updated_at: daysAgoIso(STALE_DAYS + 70),
|
||||
state: 'open',
|
||||
pull_request: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// `at` (ms-since-epoch) takes precedence over `daysAgo` so tests aligning an event to a specific
|
||||
// updated_at moment can share the same instant rather than two independent Date.now() reads.
|
||||
function makeStaleLabelEvent({ daysAgo = 100, at } = {}) {
|
||||
const created_at = at != null ? new Date(at).toISOString() : daysAgoIso(daysAgo);
|
||||
return { event: 'labeled', label: { name: 'stale' }, created_at };
|
||||
}
|
||||
|
||||
function makeContext() {
|
||||
return { repo: { owner: 'o', repo: 'r' } };
|
||||
}
|
||||
|
||||
function makeCore() {
|
||||
const infos = [];
|
||||
const warnings = [];
|
||||
const summaryCalls = [];
|
||||
const summary = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, prop) {
|
||||
if (prop === 'write') {
|
||||
return async () => {
|
||||
summaryCalls.push({ method: 'write' });
|
||||
};
|
||||
}
|
||||
return (...args) => {
|
||||
summaryCalls.push({ method: String(prop), args });
|
||||
return summary;
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
info: (msg) => infos.push(msg),
|
||||
warning: (msg) => warnings.push(msg),
|
||||
summary,
|
||||
_captured: { infos, warnings, summaryCalls },
|
||||
};
|
||||
}
|
||||
|
||||
// `issuesByPage`: array of pages (each page is an array of issues) returned by paginate.iterator.
|
||||
// `eventsByIssue`: map from issue.number -> array of event objects returned by paginate(listEvents).
|
||||
// `failOn`: optional fault-injection { createComment, addLabels, update, removeLabel, listEvents }
|
||||
// values can be 'always', an integer (fail until Nth call), or { status: 404 }.
|
||||
function makeGithub({ issuesByPage = [], eventsByIssue = {}, failOn = {} } = {}) {
|
||||
const createCommentCalls = [];
|
||||
const addLabelsCalls = [];
|
||||
const removeLabelCalls = [];
|
||||
const updateCalls = [];
|
||||
const listEventsCalls = [];
|
||||
|
||||
const counters = {};
|
||||
const shouldFail = (op) => {
|
||||
const cfg = failOn[op];
|
||||
if (!cfg) return null;
|
||||
counters[op] = (counters[op] || 0) + 1;
|
||||
if (cfg === 'always') return new Error(`${op} simulated failure`);
|
||||
if (typeof cfg === 'number' && counters[op] <= cfg) return new Error(`${op} simulated failure`);
|
||||
if (typeof cfg === 'object' && cfg.status && counters[op] === 1) {
|
||||
const err = new Error(`${op} simulated failure status ${cfg.status}`);
|
||||
err.status = cfg.status;
|
||||
return err;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const paginate = async (endpoint, params) => {
|
||||
if (endpoint === 'listEvents-sentinel') {
|
||||
listEventsCalls.push(params);
|
||||
const err = shouldFail('listEvents');
|
||||
if (err) throw err;
|
||||
return eventsByIssue[params.issue_number] || [];
|
||||
}
|
||||
if (endpoint === 'listForRepo-sentinel') {
|
||||
return issuesByPage.flat();
|
||||
}
|
||||
return [];
|
||||
};
|
||||
paginate.iterator = async function* iterator(endpoint) {
|
||||
if (endpoint === 'listForRepo-sentinel') {
|
||||
for (const page of issuesByPage) yield { data: page };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
paginate,
|
||||
rest: {
|
||||
issues: {
|
||||
listForRepo: 'listForRepo-sentinel',
|
||||
listEvents: 'listEvents-sentinel',
|
||||
createComment: async (params) => {
|
||||
const err = shouldFail('createComment');
|
||||
if (err) throw err;
|
||||
createCommentCalls.push(params);
|
||||
},
|
||||
addLabels: async (params) => {
|
||||
const err = shouldFail('addLabels');
|
||||
if (err) throw err;
|
||||
addLabelsCalls.push(params);
|
||||
},
|
||||
removeLabel: async (params) => {
|
||||
const err = shouldFail('removeLabel');
|
||||
if (err) throw err;
|
||||
removeLabelCalls.push(params);
|
||||
},
|
||||
update: async (params) => {
|
||||
const err = shouldFail('update');
|
||||
if (err) throw err;
|
||||
updateCalls.push(params);
|
||||
},
|
||||
},
|
||||
},
|
||||
_captured: { createCommentCalls, addLabelsCalls, removeLabelCalls, updateCalls, listEventsCalls },
|
||||
};
|
||||
}
|
||||
|
||||
const tmpFilesCreated = [];
|
||||
function writeHandlesFile(handles) {
|
||||
const file = path.join(os.tmpdir(), `fleeties-${process.pid}-${Math.random()}.txt`);
|
||||
fs.writeFileSync(file, handles.join('\n') + '\n');
|
||||
tmpFilesCreated.push(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
for (const f of tmpFilesCreated) {
|
||||
try { fs.unlinkSync(f); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Pass `issuesByPage` to simulate multi-page pagination. Otherwise `issues` becomes a single page.
|
||||
async function runWith({
|
||||
issues,
|
||||
issuesByPage,
|
||||
handles = ['getvictor'],
|
||||
dryRun = false,
|
||||
maxOps = 1000,
|
||||
eventsByIssue = {},
|
||||
failOn = {},
|
||||
} = {}) {
|
||||
process.env.FLEETIE_HANDLES_FILE = writeHandlesFile(handles);
|
||||
process.env.DRY_RUN = dryRun ? 'true' : 'false';
|
||||
process.env.MAX_OPERATIONS = String(maxOps);
|
||||
const pages = issuesByPage != null ? issuesByPage : [issues || []];
|
||||
const github = makeGithub({ issuesByPage: pages, eventsByIssue, failOn });
|
||||
const context = makeContext();
|
||||
const core = makeCore();
|
||||
const result = await run({ github, context, core });
|
||||
return { github, core, result };
|
||||
}
|
||||
|
||||
test('marks a Fleetie-authored issue idle >2y as stale', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [makeIssue({ number: 1, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) })],
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 1);
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 1);
|
||||
assert.deepStrictEqual(github._captured.addLabelsCalls[0].labels, ['stale']);
|
||||
assert.strictEqual(github._captured.updateCalls.length, 0);
|
||||
assert.strictEqual(result.staled.length, 1);
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
});
|
||||
|
||||
test('closes stale-labeled issue idle >14d with no activity after labeling', async () => {
|
||||
// Bot staled 20 days ago; updated_at is ~ same moment (within self-activity epsilon).
|
||||
const labeledAt = Date.now() - 20 * DAY_MS;
|
||||
const issue = makeIssue({
|
||||
number: 2,
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAt).toISOString(),
|
||||
});
|
||||
const { github, result } = await runWith({
|
||||
issues: [issue],
|
||||
eventsByIssue: { 2: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 1);
|
||||
assert.strictEqual(github._captured.updateCalls.length, 1);
|
||||
assert.strictEqual(github._captured.updateCalls[0].state, 'closed');
|
||||
assert.strictEqual(github._captured.updateCalls[0].state_reason, 'not_planned');
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 0);
|
||||
assert.strictEqual(github._captured.removeLabelCalls.length, 0);
|
||||
assert.strictEqual(result.closed.length, 1);
|
||||
assert.strictEqual(result.unstaled.length, 0);
|
||||
});
|
||||
|
||||
test('does not close a freshly-staled issue (idle < 14 days, no activity)', async () => {
|
||||
// Bot staled 5 days ago, no further activity. Issue is stale-labeled and idle 5 days. Should
|
||||
// remain open with the stale label (close phase requires >= 14 days idle).
|
||||
const labeledAt = Date.now() - 5 * DAY_MS;
|
||||
const issue = makeIssue({
|
||||
number: 50,
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAt).toISOString(),
|
||||
});
|
||||
const { github, result } = await runWith({
|
||||
issues: [issue],
|
||||
eventsByIssue: { 50: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(github._captured.updateCalls.length, 0);
|
||||
assert.strictEqual(github._captured.removeLabelCalls.length, 0);
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
assert.strictEqual(result.unstaled.length, 0);
|
||||
assert.strictEqual(result.skippedNotReadyToClose, 1);
|
||||
});
|
||||
|
||||
test('un-stales (removes label, no close) when activity is detected after stale labeling', async () => {
|
||||
// Bot staled 20 days ago; user commented 5 days ago, bumping updated_at.
|
||||
const issue = makeIssue({
|
||||
number: 3,
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: daysAgoIso(5),
|
||||
});
|
||||
const { github, result } = await runWith({
|
||||
issues: [issue],
|
||||
eventsByIssue: { 3: [makeStaleLabelEvent({ daysAgo: 20 })] },
|
||||
});
|
||||
assert.strictEqual(github._captured.removeLabelCalls.length, 1);
|
||||
assert.strictEqual(github._captured.removeLabelCalls[0].name, 'stale');
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(github._captured.updateCalls.length, 0);
|
||||
assert.strictEqual(result.unstaled.length, 1);
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
});
|
||||
|
||||
test('un-stale tolerates removeLabel 404 (label already gone) as success', async () => {
|
||||
const issue = makeIssue({
|
||||
number: 4,
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: daysAgoIso(5),
|
||||
});
|
||||
const { result } = await runWith({
|
||||
issues: [issue],
|
||||
eventsByIssue: { 4: [makeStaleLabelEvent({ daysAgo: 20 })] },
|
||||
failOn: { removeLabel: { status: 404 } },
|
||||
});
|
||||
assert.strictEqual(result.unstaled.length, 1);
|
||||
assert.strictEqual(result.errored.length, 0);
|
||||
});
|
||||
|
||||
test('skips stale-labeled issue when no labeling event is found in history', async () => {
|
||||
const issue = makeIssue({
|
||||
number: 5,
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: daysAgoIso(20),
|
||||
});
|
||||
const { github, core, result } = await runWith({
|
||||
issues: [issue],
|
||||
eventsByIssue: { 5: [] },
|
||||
});
|
||||
assert.strictEqual(github._captured.removeLabelCalls.length, 0);
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(github._captured.updateCalls.length, 0);
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
assert.strictEqual(result.unstaled.length, 0);
|
||||
assert.ok(core._captured.warnings.some((w) => w.includes('no labeling event in history')));
|
||||
});
|
||||
|
||||
test('listEvents failure records an error and continues to next issue', async () => {
|
||||
const issues = [
|
||||
makeIssue({ number: 6, user: { login: 'getvictor' }, labels: [{ name: 'stale' }], updated_at: daysAgoIso(20) }),
|
||||
makeIssue({ number: 7, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
];
|
||||
const { result } = await runWith({
|
||||
issues,
|
||||
eventsByIssue: { 6: [makeStaleLabelEvent({ daysAgo: 20 })] },
|
||||
failOn: { listEvents: 1 },
|
||||
});
|
||||
assert.strictEqual(result.errored.length, 1);
|
||||
assert.strictEqual(result.errored[0].phase, 'check-activity');
|
||||
assert.strictEqual(result.staled.length, 1, 'second issue should still be staled');
|
||||
});
|
||||
|
||||
test('skips non-Fleetie author', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [makeIssue({ user: { login: 'someoneelse' }, updated_at: daysAgoIso(STALE_DAYS + 70) })],
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(result.skippedNonFleetie, 1);
|
||||
});
|
||||
|
||||
test('Fleetie author match is case-insensitive', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [makeIssue({ user: { login: 'GetVictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) })],
|
||||
});
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 1);
|
||||
assert.strictEqual(result.staled.length, 1);
|
||||
});
|
||||
|
||||
test('skips issues exempted by bug, :product, or customer-* labels', async () => {
|
||||
for (const labelName of ['bug', ':product', 'customer-acme', 'customer-foo']) {
|
||||
const { github, result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: labelName }],
|
||||
updated_at: daysAgoIso(STALE_DAYS + 70),
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0, `expected no writes for label ${labelName}`);
|
||||
assert.strictEqual(result.skippedExempt, 1, `expected ${labelName} to be exempt`);
|
||||
}
|
||||
});
|
||||
|
||||
test('dry-run never writes', async () => {
|
||||
const labeledAt = Date.now() - 20 * DAY_MS;
|
||||
const { github, result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({ number: 1, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
makeIssue({
|
||||
number: 2,
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAt).toISOString(),
|
||||
}),
|
||||
],
|
||||
dryRun: true,
|
||||
eventsByIssue: { 2: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 0);
|
||||
assert.strictEqual(github._captured.updateCalls.length, 0);
|
||||
assert.strictEqual(github._captured.removeLabelCalls.length, 0);
|
||||
assert.strictEqual(result.dryRun, true);
|
||||
assert.strictEqual(result.staled.length, 1);
|
||||
assert.strictEqual(result.closed.length, 1);
|
||||
});
|
||||
|
||||
test('respects even max_operations cap (each modified issue costs 2 writes)', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({ number: 1, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
makeIssue({ number: 2, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 69) }),
|
||||
makeIssue({ number: 3, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 68) }),
|
||||
],
|
||||
maxOps: 2,
|
||||
});
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 1);
|
||||
assert.strictEqual(result.hitCap, true);
|
||||
});
|
||||
|
||||
test('does not exceed odd max_operations cap (CodeRabbit regression test)', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({ number: 1, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
makeIssue({ number: 2, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 69) }),
|
||||
],
|
||||
maxOps: 3,
|
||||
});
|
||||
const totalWrites =
|
||||
github._captured.createCommentCalls.length +
|
||||
github._captured.addLabelsCalls.length +
|
||||
github._captured.removeLabelCalls.length +
|
||||
github._captured.updateCalls.length;
|
||||
assert.ok(totalWrites <= 3, `expected <= 3 writes, got ${totalWrites}`);
|
||||
assert.strictEqual(result.hitCap, true);
|
||||
});
|
||||
|
||||
test('MAX_OPERATIONS=0 disables all writes', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [makeIssue({ user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) })],
|
||||
maxOps: 0,
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 0);
|
||||
assert.strictEqual(result.hitCap, true);
|
||||
assert.strictEqual(result.staled.length, 0);
|
||||
});
|
||||
|
||||
test('write failure in stale phase is recorded and run continues', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({ number: 1, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
makeIssue({ number: 2, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 69) }),
|
||||
],
|
||||
failOn: { createComment: 1 },
|
||||
});
|
||||
assert.strictEqual(result.errored.length, 1);
|
||||
assert.strictEqual(result.errored[0].phase, 'stale');
|
||||
assert.strictEqual(result.staled.length, 1);
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 1);
|
||||
});
|
||||
|
||||
test('excludes pull requests', async () => {
|
||||
const { github } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
user: { login: 'getvictor' },
|
||||
updated_at: daysAgoIso(STALE_DAYS + 70),
|
||||
pull_request: { url: 'x' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(github._captured.addLabelsCalls.length, 0);
|
||||
});
|
||||
|
||||
test('skips Fleetie issue idle between 14 and 730 days as "not stale yet"', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [makeIssue({ user: { login: 'getvictor' }, updated_at: daysAgoIso(100) })],
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 0);
|
||||
assert.strictEqual(result.skippedNotStaleYet, 1);
|
||||
});
|
||||
|
||||
test('candidates exclude recently-active issues without the stale label', async () => {
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({ number: 1, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
makeIssue({ number: 2, user: { login: 'getvictor' }, updated_at: daysAgoIso(5) }),
|
||||
makeIssue({ number: 3, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
],
|
||||
});
|
||||
// #1 and #3 qualify (idle >= 14d); #2 is < 14d idle and has no stale label.
|
||||
assert.strictEqual(result.candidates, 2);
|
||||
});
|
||||
|
||||
test('candidates include stale-labeled issues regardless of idle time', async () => {
|
||||
// A recently-active (5d idle) stale-labeled issue must still be collected so the un-stale
|
||||
// path can fire — otherwise it would be missed for 14 days after the user activity.
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 99,
|
||||
user: { login: 'getvictor' },
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: daysAgoIso(5),
|
||||
}),
|
||||
],
|
||||
eventsByIssue: { 99: [makeStaleLabelEvent({ daysAgo: 20 })] },
|
||||
});
|
||||
assert.strictEqual(result.candidates, 1);
|
||||
assert.strictEqual(result.unstaled.length, 1);
|
||||
});
|
||||
|
||||
test('handles string-form labels in addition to object-form labels', async () => {
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
user: { login: 'getvictor' },
|
||||
labels: ['bug'],
|
||||
updated_at: daysAgoIso(STALE_DAYS + 70),
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.strictEqual(result.skippedExempt, 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boundary tests: catch direction errors and wrong-constant regressions on
|
||||
// STALE_DAYS, CLOSE_DAYS, and SELF_ACTIVITY_EPSILON_MS.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('stale-phase boundary: issue just past STALE_DAYS is staled', async () => {
|
||||
const { result } = await runWith({
|
||||
issues: [makeIssue({ number: 200, updated_at: daysAgoIso(STALE_DAYS + 0.01) })],
|
||||
});
|
||||
assert.strictEqual(result.staled.length, 1);
|
||||
assert.strictEqual(result.skippedNotStaleYet, 0);
|
||||
});
|
||||
|
||||
test('stale-phase boundary: issue just under STALE_DAYS is skipped as not stale yet', async () => {
|
||||
const { result } = await runWith({
|
||||
issues: [makeIssue({ number: 201, updated_at: daysAgoIso(STALE_DAYS - 0.1) })],
|
||||
});
|
||||
assert.strictEqual(result.staled.length, 0);
|
||||
assert.strictEqual(result.skippedNotStaleYet, 1);
|
||||
});
|
||||
|
||||
test('close-phase boundary: stale-labeled issue just past CLOSE_DAYS is closed', async () => {
|
||||
// Bot staled CLOSE_DAYS + 0.01 days ago (~14.01 days), updated_at at the same instant so the
|
||||
// self-activity epsilon is satisfied and the close path's idle gate just barely passes.
|
||||
const labeledAt = Date.now() - (CLOSE_DAYS + 0.01) * DAY_MS;
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 202,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAt).toISOString(),
|
||||
}),
|
||||
],
|
||||
eventsByIssue: { 202: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
});
|
||||
assert.strictEqual(result.closed.length, 1);
|
||||
assert.strictEqual(result.skippedNotReadyToClose, 0);
|
||||
});
|
||||
|
||||
test('close-phase boundary: stale-labeled issue just under CLOSE_DAYS is not closed', async () => {
|
||||
const labeledAt = Date.now() - (CLOSE_DAYS - 0.1) * DAY_MS;
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 203,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAt).toISOString(),
|
||||
}),
|
||||
],
|
||||
eventsByIssue: { 203: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
});
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
assert.strictEqual(result.skippedNotReadyToClose, 1);
|
||||
});
|
||||
|
||||
test('self-activity epsilon: updated_at at the epsilon boundary is treated as self-activity (close)', async () => {
|
||||
// labeledAt 30 days ago; updated_at is exactly labeledAt + epsilon, which the script treats as
|
||||
// self-activity (the check is strict `>`). Idle is still >= CLOSE_DAYS, so close fires.
|
||||
const labeledAt = Date.now() - 30 * DAY_MS;
|
||||
const updatedAt = labeledAt + SELF_ACTIVITY_EPSILON_MS;
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 204,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
}),
|
||||
],
|
||||
eventsByIssue: { 204: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
});
|
||||
assert.strictEqual(result.closed.length, 1);
|
||||
assert.strictEqual(result.unstaled.length, 0);
|
||||
});
|
||||
|
||||
test('self-activity epsilon: updated_at 1ms past the epsilon boundary is treated as user activity (un-stale)', async () => {
|
||||
const labeledAt = Date.now() - 30 * DAY_MS;
|
||||
const updatedAt = labeledAt + SELF_ACTIVITY_EPSILON_MS + 1;
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 205,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(updatedAt).toISOString(),
|
||||
}),
|
||||
],
|
||||
eventsByIssue: { 205: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
});
|
||||
assert.strictEqual(result.unstaled.length, 1);
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error path tests: each write phase has its own try/catch and phase tag.
|
||||
// Confirm partial-write states are recorded and don't stop the rest of the run.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('stale-phase: addLabels failure after createComment success is recorded', async () => {
|
||||
const { github, result } = await runWith({
|
||||
issues: [makeIssue({ number: 300, updated_at: daysAgoIso(STALE_DAYS + 70) })],
|
||||
failOn: { addLabels: 1 },
|
||||
});
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 1, 'createComment ran first');
|
||||
assert.strictEqual(result.staled.length, 0);
|
||||
assert.strictEqual(result.errored.length, 1);
|
||||
assert.strictEqual(result.errored[0].phase, 'stale');
|
||||
});
|
||||
|
||||
test('close-phase: createComment failure is recorded and run continues', async () => {
|
||||
const labeledAt = Date.now() - 20 * DAY_MS;
|
||||
const { github, result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 400,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAt).toISOString(),
|
||||
}),
|
||||
makeIssue({ number: 401, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
],
|
||||
eventsByIssue: { 400: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
failOn: { createComment: 1 },
|
||||
});
|
||||
assert.strictEqual(github._captured.updateCalls.length, 0, 'update not called when createComment fails');
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
assert.strictEqual(result.errored.length, 1);
|
||||
assert.strictEqual(result.errored[0].phase, 'close');
|
||||
// Subsequent issue still processed (the close-phase failure only consumes the first
|
||||
// createComment; the second issue's stale-phase createComment succeeds).
|
||||
assert.strictEqual(result.staled.length, 1);
|
||||
});
|
||||
|
||||
test('close-phase: update failure after createComment success is recorded', async () => {
|
||||
const labeledAt = Date.now() - 20 * DAY_MS;
|
||||
const { github, result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 402,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAt).toISOString(),
|
||||
}),
|
||||
],
|
||||
eventsByIssue: { 402: [makeStaleLabelEvent({ at: labeledAt })] },
|
||||
failOn: { update: 1 },
|
||||
});
|
||||
// The mock records calls only on success, so updateCalls stays empty when update throws. The
|
||||
// behavior we care about: createComment succeeded (the script got past it), close didn't
|
||||
// complete, and the failure is attributed to the close phase.
|
||||
assert.strictEqual(github._captured.createCommentCalls.length, 1, 'createComment succeeded');
|
||||
assert.strictEqual(result.closed.length, 0);
|
||||
assert.strictEqual(result.errored.length, 1);
|
||||
assert.strictEqual(result.errored[0].phase, 'close');
|
||||
});
|
||||
|
||||
test('un-stale phase: non-404 removeLabel failure is recorded', async () => {
|
||||
// Distinct from "tolerates 404" — non-404 errors go to `errored`, not silently ignored.
|
||||
const { result } = await runWith({
|
||||
issues: [
|
||||
makeIssue({
|
||||
number: 500,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: daysAgoIso(5),
|
||||
}),
|
||||
],
|
||||
eventsByIssue: { 500: [makeStaleLabelEvent({ daysAgo: 20 })] },
|
||||
failOn: { removeLabel: 1 },
|
||||
});
|
||||
assert.strictEqual(result.unstaled.length, 0);
|
||||
assert.strictEqual(result.errored.length, 1);
|
||||
assert.strictEqual(result.errored[0].phase, 'unstale');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pagination: confirms issues across multiple iterator pages are all processed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('processes issues across multiple paginated pages', async () => {
|
||||
const { result } = await runWith({
|
||||
issuesByPage: [
|
||||
[makeIssue({ number: 600, updated_at: daysAgoIso(STALE_DAYS + 50) })],
|
||||
[makeIssue({ number: 601, updated_at: daysAgoIso(STALE_DAYS + 30) })],
|
||||
[makeIssue({ number: 602, updated_at: daysAgoIso(STALE_DAYS + 10) })],
|
||||
],
|
||||
});
|
||||
assert.strictEqual(result.candidates, 3);
|
||||
assert.strictEqual(result.staled.length, 3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixed-population integration: all four outcomes in one run, exercising the
|
||||
// interaction between the candidate iteration and the phase-specific branches.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('mixed population: stale + close + un-stale + error all in one run', async () => {
|
||||
const labeledAtClose = Date.now() - 20 * DAY_MS;
|
||||
// Order matters: #700 is staled-by-bot AND first in the list so its (failing) listEvents call
|
||||
// is the first one — failOn.listEvents=1 fires there, not on #701 or #702.
|
||||
const issues = [
|
||||
makeIssue({
|
||||
number: 700,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: daysAgoIso(20),
|
||||
}),
|
||||
makeIssue({ number: 701, updated_at: daysAgoIso(STALE_DAYS + 70) }),
|
||||
makeIssue({
|
||||
number: 702,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: new Date(labeledAtClose).toISOString(),
|
||||
}),
|
||||
makeIssue({
|
||||
number: 703,
|
||||
labels: [{ name: 'stale' }],
|
||||
updated_at: daysAgoIso(5),
|
||||
}),
|
||||
];
|
||||
const { result } = await runWith({
|
||||
issues,
|
||||
eventsByIssue: {
|
||||
// 700 has no listEvents entry because we make it fail anyway
|
||||
702: [makeStaleLabelEvent({ at: labeledAtClose })],
|
||||
703: [makeStaleLabelEvent({ daysAgo: 20 })],
|
||||
},
|
||||
failOn: { listEvents: 1 },
|
||||
});
|
||||
assert.strictEqual(result.candidates, 4);
|
||||
assert.strictEqual(result.staled.length, 1, '#701 staled');
|
||||
assert.strictEqual(result.closed.length, 1, '#702 closed');
|
||||
assert.strictEqual(result.unstaled.length, 1, '#703 un-staled');
|
||||
assert.strictEqual(result.errored.length, 1, '#700 errored on listEvents');
|
||||
assert.strictEqual(result.errored[0].phase, 'check-activity');
|
||||
assert.strictEqual(result.errored[0].number, 700);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
name: Close stale Fleetie-initiated issues
|
||||
|
||||
# Marks open issues authored by current or former Fleeties as stale after 2 years of no activity, then
|
||||
# closes them after 14 more days of inactivity once labeled stale. Activity (any comment or update) bumps
|
||||
# `updated_at` and resets both clocks. Issues with the `bug`, `:product`, or any `customer-*` label are
|
||||
# exempt.
|
||||
#
|
||||
# Why this isn't just `actions/stale`: that action filters by labels, not by author. We can't pre-tag
|
||||
# issues with a "fleetie-initiated" label and hand off to actions/stale either, because adding a label
|
||||
# bumps `updated_at` and resets the staleness clock for the entire backlog. The closest upstream PR is
|
||||
# https://github.com/actions/stale/pull/1181 (`anyOfAuthors` allowlist input). It has been open with no
|
||||
# review since October 2024. When it merges, we should be able to replace this whole flow with a small
|
||||
# `actions/stale` config that passes the handle list from `build-fleetie-handles.js` as `anyOfAuthors`
|
||||
# and delete `stale-fleetie-issues.js` and its tests.
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'If true, log candidates without writing labels, comments, or closing issues.'
|
||||
type: boolean
|
||||
default: true
|
||||
max_operations:
|
||||
description: 'Maximum GitHub API write operations per run. Each modified issue costs 2 writes (comment + label, or comment + close).'
|
||||
type: number
|
||||
default: 400
|
||||
|
||||
concurrency:
|
||||
# Scope by event_name + ref so a pull_request trigger doesn't preempt an in-flight
|
||||
# workflow_dispatch run, and vice versa. Same-event same-ref runs still cancel as expected.
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
close-stale-fleetie-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 4.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Build Fleetie handle list
|
||||
env:
|
||||
# Fine-scoped PAT with read:org on the fleetdm org. Provisioned by IT; without it the script
|
||||
# falls back to handbook-only sources and emits a warning.
|
||||
READ_ORG_TOKEN: ${{ secrets.FLEET_GITHUB_TOKEN_MEMBERS_READ }}
|
||||
FLEETIE_HANDLES_OUT: ${{ runner.temp }}/fleeties.txt
|
||||
run: node .github/scripts/build-fleetie-handles.js
|
||||
|
||||
- name: Stale and close Fleetie-authored issues
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
env:
|
||||
FLEETIE_HANDLES_FILE: ${{ runner.temp }}/fleeties.txt
|
||||
# Force dry-run for every event other than workflow_dispatch.
|
||||
DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == false) && 'false' || 'true' }}
|
||||
# parseMaxOps in the script applies the 400 default when the value is empty.
|
||||
MAX_OPERATIONS: ${{ inputs.max_operations }}
|
||||
with:
|
||||
script: |
|
||||
const run = require('./.github/scripts/stale-fleetie-issues.js');
|
||||
await run({ github, context, core });
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Test stale Fleetie-issue scripts
|
||||
|
||||
# Runs the unit tests for the stale-Fleetie-issue scripts on PRs that change either script (or its
|
||||
# tests), the closer workflow, or this workflow. Handbook edits are deliberately excluded: the
|
||||
# scripts are resilient to any handle-shaped content the handbook can realistically contain
|
||||
# (denylist, length cap, trailing-hyphen filter, format regex), and the handbook changes often
|
||||
# enough that triggering on it would mostly burn CI minutes with no signal.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/scripts/build-fleetie-handles.js'
|
||||
- '.github/scripts/build-fleetie-handles.test.js'
|
||||
- '.github/scripts/stale-fleetie-issues.js'
|
||||
- '.github/scripts/stale-fleetie-issues.test.js'
|
||||
- '.github/workflows/close-stale-fleetie-initiated-issues.yml'
|
||||
- '.github/workflows/test-stale-fleetie-issue-scripts.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/scripts/build-fleetie-handles.js'
|
||||
- '.github/scripts/build-fleetie-handles.test.js'
|
||||
- '.github/scripts/stale-fleetie-issues.js'
|
||||
- '.github/scripts/stale-fleetie-issues.test.js'
|
||||
- '.github/workflows/close-stale-fleetie-initiated-issues.yml'
|
||||
- '.github/workflows/test-stale-fleetie-issue-scripts.yml'
|
||||
workflow_dispatch: # Manual
|
||||
|
||||
# For PRs, github.head_ref groups by source branch. For pushes (e.g. main), head_ref is empty so
|
||||
# we fall back to github.ref so successive pushes to the same branch share a group and
|
||||
# cancellation actually applies.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 4.4.0
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Run tests
|
||||
run: node --test .github/scripts/build-fleetie-handles.test.js .github/scripts/stale-fleetie-issues.test.js
|
||||
Reference in New Issue
Block a user