From 1e9f3807a1d9e8ce1f7cde78866f5ef4e8263c6b Mon Sep 17 00:00:00 2001
From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
Date: Fri, 5 Jun 2026 14:39:02 -0500
Subject: [PATCH] Add author mention to stale issue bots (#46787)
**Related issue:** Resolves #46790
Example live run off this branch:
https://github.com/fleetdm/fleet/actions/runs/27005120262
Example stale issue with comment:
https://github.com/fleetdm/fleet/issues/18421
- Added `@author` mention when marking issue as stale
- Refactored so that both Fleetie and eng-initiated stale issue bots use
the same core JS code
- Run Fleetie-initiated workflow on a schedule
## Summary by CodeRabbit
* **New Features**
* Automated stale-issue workflows for engineering-initiated and Fleetie
issues with configurable dry-run, max-operations, manual triggers, and
scheduled runs.
* **Tests**
* Added shared test helpers and expanded, tightened test suites covering
staleness, closing, unstale, and error/boundary behaviors.
* **Refactor**
* Introduced a shared stale-issue engine used by thin,
author/label-based wrappers for consistent behavior and messaging.
* **Chores**
* Updated workflow triggers, permissions, and CI test matrix to include
the new core and wrappers.
---
.github/scripts/stale-eng-issues.js | 66 +++
.github/scripts/stale-eng-issues.test.js | 137 ++++++
.github/scripts/stale-fleetie-issues.js | 368 ++--------------
.github/scripts/stale-fleetie-issues.test.js | 157 +------
.github/scripts/stale-issues-core.js | 403 ++++++++++++++++++
.github/scripts/stale-test-helpers.js | 126 ++++++
.../close-stale-eng-initiated-issues.yml | 47 +-
.../close-stale-fleetie-initiated-issues.yml | 8 +-
.../test-stale-fleetie-issue-scripts.yml | 23 +-
9 files changed, 845 insertions(+), 490 deletions(-)
create mode 100644 .github/scripts/stale-eng-issues.js
create mode 100644 .github/scripts/stale-eng-issues.test.js
create mode 100644 .github/scripts/stale-issues-core.js
create mode 100644 .github/scripts/stale-test-helpers.js
diff --git a/.github/scripts/stale-eng-issues.js b/.github/scripts/stale-eng-issues.js
new file mode 100644
index 0000000000..8b918a60eb
--- /dev/null
+++ b/.github/scripts/stale-eng-issues.js
@@ -0,0 +1,66 @@
+// Marks open engineering-initiated issues (label `~engineering-initiated`) as stale after 1y of
+// inactivity, and closes them after 14 more days. Invoked by `actions/github-script` from
+// `.github/workflows/close-stale-eng-initiated-issues.yml`.
+//
+// This is a thin wrapper over `stale-issues-core.js`: it provides label-based eligibility and the
+// eng-initiated thresholds and wording. The scanning, labeling, closing, and un-staling logic all
+// live in the core. The sibling `stale-fleetie-issues.js` wraps the same core with author-based
+// eligibility.
+//
+// Why this replaced `actions/stale`: that action posts a single static stale comment with no way to
+// template the issue author, so it could not @-mention the author. The core lets us build the
+// comment per issue. Behavior otherwise matches the previous `actions/stale` config (365d to stale,
+// 14d to close, remove-stale-when-updated).
+//
+// Inputs (env):
+// DRY_RUN 'true' to log candidates without writing (read by the core).
+// MAX_OPERATIONS Cap on API write operations per run. Default 400. `0` disables writes / dry-runs (read by the core).
+//
+// Exports: `async function run({ github, context, core })`. Returns a summary object for tests.
+
+"use strict";
+
+const core_run = require("./stale-issues-core.js");
+
+const STALE_DAYS = 365;
+const CLOSE_DAYS = 14;
+const STALE_LABEL = "stale";
+const ELIGIBLE_LABEL = "~engineering-initiated";
+
+const staleMessage = (author) =>
+ `@${author} this issue is stale because it has been open for 365 days with no activity. ` +
+ "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 has been inactive for 14 days since being marked as stale.";
+
+async function run({ github, context, core }) {
+ const result = await core_run({
+ github,
+ context,
+ core,
+ config: {
+ title: "Eng-initiated stale-issue closer",
+ staleDays: STALE_DAYS,
+ closeDays: CLOSE_DAYS,
+ staleLabel: STALE_LABEL,
+ isEligible: (issue) =>
+ (issue.labels || []).some(
+ (l) =>
+ (typeof l === "string" ? l : (l && l.name) || "").toLowerCase() ===
+ ELIGIBLE_LABEL.toLowerCase()
+ ),
+ staleMessage,
+ closeMessage: () => CLOSE_MSG,
+ ineligibleSummaryLabel: `Skipped (no ${ELIGIBLE_LABEL} label)`,
+ },
+ });
+ return result;
+}
+
+module.exports = run;
+// Exported for test boundary assertions so a future policy change surfaces in the boundary tests
+// instead of silently passing on a hardcoded old value.
+module.exports.STALE_DAYS = STALE_DAYS;
+module.exports.CLOSE_DAYS = CLOSE_DAYS;
+module.exports.ELIGIBLE_LABEL = ELIGIBLE_LABEL;
+module.exports.SELF_ACTIVITY_EPSILON_MS = core_run.SELF_ACTIVITY_EPSILON_MS;
diff --git a/.github/scripts/stale-eng-issues.test.js b/.github/scripts/stale-eng-issues.test.js
new file mode 100644
index 0000000000..cb7807c342
--- /dev/null
+++ b/.github/scripts/stale-eng-issues.test.js
@@ -0,0 +1,137 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+const run = require('./stale-eng-issues.js');
+// Pull constants from the script so the boundary tests keep exercising the real boundary if a
+// future policy change (e.g. STALE_DAYS 365 -> 730) moves it.
+const { STALE_DAYS, CLOSE_DAYS, ELIGIBLE_LABEL } = run;
+const { DAY_MS, daysAgoIso, makeStaleLabelEvent, makeContext, makeCore, makeGithub } = require('./stale-test-helpers.js');
+
+// Eng-initiated issues qualify by carrying the `~engineering-initiated` label, so the default
+// issue includes it. Tests that exercise the ineligible path drop it explicitly.
+function makeIssue(overrides = {}) {
+ return {
+ number: 1,
+ html_url: 'https://github.com/o/r/issues/1',
+ user: { login: 'getvictor' },
+ labels: [{ name: ELIGIBLE_LABEL }],
+ updated_at: daysAgoIso(STALE_DAYS + 70),
+ state: 'open',
+ pull_request: undefined,
+ ...overrides,
+ };
+}
+
+async function runWith({ issues, issuesByPage, dryRun = false, maxOps = 1000, eventsByIssue = {}, failOn = {} } = {}) {
+ 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 };
+}
+
+// Core engine behavior (caps, error paths, pagination, un-stale epsilon, etc.) is covered through
+// the Fleetie wrapper in stale-fleetie-issues.test.js. These tests pin what the eng wrapper
+// configures differently: label-based eligibility, no exempt labels, the 1-year threshold, and the
+// eng-specific message wording.
+
+test('marks an eng-initiated issue idle >1y as stale and @-mentions the author', 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);
+ const staleComment = github._captured.createCommentCalls[0].body;
+ assert.match(staleComment, /^@getvictor /, 'stale comment @-mentions the author');
+ assert.ok(staleComment.includes('365 days'), 'stale comment uses the eng wording, not the Fleetie template');
+ assert.strictEqual(github._captured.addLabelsCalls.length, 1);
+ assert.deepStrictEqual(github._captured.addLabelsCalls[0].labels, ['stale']);
+ assert.strictEqual(result.staled.length, 1);
+});
+
+test('skips issues without the ~engineering-initiated label', async () => {
+ const { github, result } = await runWith({
+ issues: [makeIssue({ labels: [], updated_at: daysAgoIso(STALE_DAYS + 70) })],
+ });
+ assert.strictEqual(github._captured.createCommentCalls.length, 0);
+ assert.strictEqual(result.skippedIneligible, 1);
+ assert.strictEqual(result.staled.length, 0);
+});
+
+test('eligible-label match is case-insensitive', async () => {
+ const { result } = await runWith({
+ issues: [makeIssue({ labels: [{ name: ELIGIBLE_LABEL.toUpperCase() }], updated_at: daysAgoIso(STALE_DAYS + 70) })],
+ });
+ assert.strictEqual(result.staled.length, 1);
+});
+
+test('does not exempt bug-labeled eng issues (unlike the Fleetie closer)', async () => {
+ const { result } = await runWith({
+ issues: [makeIssue({ labels: [{ name: ELIGIBLE_LABEL }, { name: 'bug' }], updated_at: daysAgoIso(STALE_DAYS + 70) })],
+ });
+ assert.strictEqual(result.skippedExempt, 0);
+ assert.strictEqual(result.staled.length, 1);
+});
+
+test('closes a stale-labeled eng issue idle >14d with no activity after labeling', async () => {
+ const labeledAt = Date.now() - (CLOSE_DAYS + 6) * DAY_MS;
+ const issue = makeIssue({
+ number: 2,
+ labels: [{ name: ELIGIBLE_LABEL }, { 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, 'close comment posted');
+ 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(result.closed.length, 1);
+});
+
+test('un-stales an eng issue with activity after labeling', async () => {
+ const issue = makeIssue({
+ number: 3,
+ labels: [{ name: ELIGIBLE_LABEL }, { 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, 'un-stale writes no comment');
+ assert.strictEqual(github._captured.updateCalls.length, 0, 'un-stale does not close');
+ assert.strictEqual(result.unstaled.length, 1);
+});
+
+test('skips eng issue idle between 14 and 365 days as "not stale yet"', async () => {
+ const { result } = await runWith({
+ issues: [makeIssue({ updated_at: daysAgoIso(100) })],
+ });
+ assert.strictEqual(result.skippedNotStaleYet, 1);
+ assert.strictEqual(result.staled.length, 0);
+});
+
+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);
+});
diff --git a/.github/scripts/stale-fleetie-issues.js b/.github/scripts/stale-fleetie-issues.js
index d9a26fc78e..1108314527 100644
--- a/.github/scripts/stale-fleetie-issues.js
+++ b/.github/scripts/stale-fleetie-issues.js
@@ -1,36 +1,35 @@
-// 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
+// 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`.
+// This is a thin wrapper over `stale-issues-core.js`: it provides the author-based eligibility check
+// (issue author is a current/former Fleetie) and the Fleetie-specific thresholds and wording. The
+// scanning, labeling, closing, and un-staling logic all live in the core. The sibling
+// `stale-eng-issues.js` wraps the same core with label-based eligibility.
//
// 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.
+// DRY_RUN 'true' to log candidates without writing (read by the core).
+// MAX_OPERATIONS Cap on API write operations per run. Default 400. `0` disables writes / dry-runs (read by the core).
//
// Exports: `async function run({ github, context, core })`. Returns a summary object for tests.
"use strict";
const fs = require("node:fs");
+const core_run = require("./stale-issues-core.js");
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 " +
+
+const staleMessage = (author) =>
+ `@${author} 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();
@@ -39,11 +38,6 @@ const isExempt = (name) => {
);
};
-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
@@ -52,326 +46,34 @@ async function run({ github, context, core }) {
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
);
+ core.info(`Loaded ${handles.size} Fleetie handles.`);
- 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,
+ const result = await core_run({
+ github,
+ context,
+ core,
+ config: {
+ title: "Fleetie stale-issue closer",
+ staleDays: STALE_DAYS,
+ closeDays: CLOSE_DAYS,
+ staleLabel: STALE_LABEL,
+ isEligible: (issue) =>
+ handles.has(((issue.user && issue.user.login) || "").toLowerCase()),
+ isExempt,
+ staleMessage,
+ closeMessage: () => CLOSE_MSG,
+ ineligibleSummaryLabel: "Skipped (non-Fleetie author)",
+ summaryLines: [`Fleetie handles loaded: **${handles.size}**`],
+ },
});
- 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++;
- }
- }
-
- // Each entry is an HTML
so it renders correctly inside . We use raw tags rather
- // than markdown links because GitHub Actions summary surrounds list content with HTML blocks
- // (from addHeading / addList), which suspends markdown parsing for child content — markdown
- // bullets via addRaw collapse onto one line in that context.
- const fmtItem = (e) =>
- `#${e.number} by @${e.author} (idle ${e.idleDays.toFixed(1)}d)`;
- const fmtErrorItem = (e) => `#${e.number} (${e.phase}): ${e.message}`;
- const appendList = (s, items, fn) =>
- items.length ? s.addList(items.map(fn)) : s.addRaw("_none_").addEOL();
-
- let summary = 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);
- summary = appendList(summary, staled, fmtItem);
- summary = summary.addHeading("Closed", 3);
- summary = appendList(summary, closed, fmtItem);
- summary = summary.addHeading("Un-staled (activity after label)", 3);
- summary = appendList(summary, unstaled, fmtItem);
- summary = summary.addHeading("Errors", 3);
- summary = appendList(summary, errored, fmtErrorItem);
- await summary.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,
- };
+ // Preserve the historical field name for callers/tests that predate the shared core.
+ return { ...result, skippedNonFleetie: result.skippedIneligible };
}
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.
+// Exported for test boundary assertions so a future policy change (e.g. STALE_DAYS 730 -> 365)
+// surfaces in the boundary tests instead of silently passing on a hardcoded old value.
module.exports.STALE_DAYS = STALE_DAYS;
module.exports.CLOSE_DAYS = CLOSE_DAYS;
-module.exports.SELF_ACTIVITY_EPSILON_MS = SELF_ACTIVITY_EPSILON_MS;
+module.exports.SELF_ACTIVITY_EPSILON_MS = core_run.SELF_ACTIVITY_EPSILON_MS;
diff --git a/.github/scripts/stale-fleetie-issues.test.js b/.github/scripts/stale-fleetie-issues.test.js
index b496f2ddfc..61893e6251 100644
--- a/.github/scripts/stale-fleetie-issues.test.js
+++ b/.github/scripts/stale-fleetie-issues.test.js
@@ -7,12 +7,10 @@ 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.
+// Pull constants from the script so the boundary tests keep exercising the real boundary if a
+// future policy change (e.g. STALE_DAYS 730 -> 365) moves it.
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();
+const { DAY_MS, daysAgoIso, makeStaleLabelEvent, makeContext, makeCore, makeGithub } = require('./stale-test-helpers.js');
function makeIssue(overrides = {}) {
return {
@@ -27,121 +25,6 @@ function makeIssue(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`);
@@ -182,6 +65,7 @@ test('marks a Fleetie-authored issue idle >2y as stale', async () => {
issues: [makeIssue({ number: 1, user: { login: 'getvictor' }, updated_at: daysAgoIso(STALE_DAYS + 70) })],
});
assert.strictEqual(github._captured.createCommentCalls.length, 1);
+ assert.match(github._captured.createCommentCalls[0].body, /^@getvictor /, 'stale comment @-mentions the author');
assert.strictEqual(github._captured.addLabelsCalls.length, 1);
assert.deepStrictEqual(github._captured.addLabelsCalls[0].labels, ['stale']);
assert.strictEqual(github._captured.updateCalls.length, 0);
@@ -390,15 +274,16 @@ test('does not exceed odd max_operations cap (CodeRabbit regression test)', asyn
assert.strictEqual(result.hitCap, true);
});
-test('MAX_OPERATIONS=0 disables all writes', async () => {
+test('MAX_OPERATIONS=0 disables all writes (treated as dry-run, still reports would-be actions)', 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);
+ assert.strictEqual(result.dryRun, true);
+ // Unlike a hard cap, the kill switch still surfaces what would have happened.
+ assert.strictEqual(result.staled.length, 1);
});
test('write failure in stale phase is recorded and run continues', async () => {
@@ -412,7 +297,8 @@ test('write failure in stale phase is recorded and run continues', async () => {
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);
+ // Labels land before comments, so both issues are labeled even though #1's comment failed.
+ assert.strictEqual(github._captured.addLabelsCalls.length, 2);
});
test('excludes pull requests', async () => {
@@ -576,18 +462,20 @@ test('self-activity epsilon: updated_at 1ms past the epsilon boundary is treated
// 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 () => {
+test('stale-phase: addLabels failure is recorded and skips the comment', async () => {
+ // The label is written first so a partial failure cannot bump updated_at without applying the
+ // label (which would silently reset the staleness clock).
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(github._captured.createCommentCalls.length, 0, 'no comment when labeling fails');
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 () => {
+test('close-phase: createComment failure after a successful close is recorded and run continues', async () => {
const labeledAt = Date.now() - 20 * DAY_MS;
const { github, result } = await runWith({
issues: [
@@ -601,8 +489,10 @@ test('close-phase: createComment failure is recorded and run continues', async (
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);
+ // The close is written first, so the issue is closed even though its comment failed. The benign
+ // leftover is a closed issue without a comment, never a "closed" comment on an open issue.
+ assert.strictEqual(github._captured.updateCalls.length, 1, 'issue closed before the comment failed');
+ assert.strictEqual(result.closed.length, 0, 'partial failure reported as error, not success');
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
@@ -610,7 +500,9 @@ test('close-phase: createComment failure is recorded and run continues', async (
assert.strictEqual(result.staled.length, 1);
});
-test('close-phase: update failure after createComment success is recorded', async () => {
+test('close-phase: update failure is recorded and skips the comment', async () => {
+ // The close is written first so a failed close cannot leave a "this issue was closed" comment on
+ // a still-open issue (which would bump updated_at and un-stale it on the next run).
const labeledAt = Date.now() - 20 * DAY_MS;
const { github, result } = await runWith({
issues: [
@@ -623,10 +515,7 @@ test('close-phase: update failure after createComment success is recorded', asyn
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(github._captured.createCommentCalls.length, 0, 'no comment when the close fails');
assert.strictEqual(result.closed.length, 0);
assert.strictEqual(result.errored.length, 1);
assert.strictEqual(result.errored[0].phase, 'close');
diff --git a/.github/scripts/stale-issues-core.js b/.github/scripts/stale-issues-core.js
new file mode 100644
index 0000000000..dd9e633646
--- /dev/null
+++ b/.github/scripts/stale-issues-core.js
@@ -0,0 +1,403 @@
+// Generic stale-issue engine shared by the eng-initiated and Fleetie-initiated closers. It scans
+// open issues, marks eligible idle ones stale (comment + label), closes stale ones after a further
+// idle period, and removes the stale label when an issue receives activity after being labeled.
+//
+// The two callers differ only in how an issue qualifies (label-based vs author-based), the idle
+// thresholds, the wording of the comments, and the summary labels. Those differences are passed in
+// via `config`; everything below is identical between them. See `stale-eng-issues.js` and
+// `stale-fleetie-issues.js` for the two wrappers.
+//
+// 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, read here so both wrappers share the same operator controls):
+// DRY_RUN 'true' to log candidates without writing.
+// MAX_OPERATIONS Cap on API write operations per run. Default 400. `0` disables writes (treated as a dry run).
+//
+// config:
+// title Heading for the job summary.
+// staleDays Idle days before an issue is marked stale.
+// closeDays Further idle days (after labeling) before a stale issue is closed.
+// staleLabel Label applied to mark an issue stale (default "stale").
+// isEligible(issue) Returns true if the issue is in scope for this closer.
+// isExempt(labelName) Returns true if a label exempts the issue from staleness.
+// staleMessage(author) Comment body posted when marking stale (mentions the author).
+// closeMessage(author) Comment body posted when closing.
+// ineligibleSummaryLabel Summary line text for the "skipped, out of scope" counter.
+// summaryLines Extra summary lines (array of strings) injected near the top.
+//
+// Exports: `async function run({ github, context, core, config })`. Returns a summary object for tests.
+
+"use strict";
+
+const STALE_LABEL_DEFAULT = "stale";
+// 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;
+// Opt in to the 2026-03-10 REST API version on every call.
+const GH_API_HEADERS = { "x-github-api-version": "2026-03-10" };
+
+const parseMaxOps = (raw) => {
+ const parsed = Number.parseInt(raw ?? "", 10);
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : 400;
+};
+
+async function run({ github, context, core, config }) {
+ const {
+ title,
+ staleDays,
+ closeDays,
+ staleLabel = STALE_LABEL_DEFAULT,
+ isEligible,
+ isExempt = () => false,
+ staleMessage,
+ closeMessage,
+ ineligibleSummaryLabel = "Skipped (out of scope)",
+ summaryLines = [],
+ } = config;
+
+ const maxOps = parseMaxOps(process.env.MAX_OPERATIONS);
+ // MAX_OPERATIONS=0 is the kill switch: scan and report what would happen, but make no writes.
+ // Treating it as dry-run (rather than breaking on the first cap check) keeps the summary complete.
+ const dryRun =
+ String(process.env.DRY_RUN).toLowerCase() === "true" || maxOps === 0;
+
+ // 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(
+ `${title}: dry_run=${dryRun}, max_operations=${maxOps}, stale_days=${staleDays}, close_days=${closeDays}`
+ );
+
+ // Collect candidates by scanning all open issues. Two groups qualify:
+ // 1. Idle >= closeDays — 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 closeDays 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,
+ headers: GH_API_HEADERS,
+ });
+ 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() ===
+ staleLabel.toLowerCase()
+ );
+ if (idleDays >= closeDays || hasStaleLabel) {
+ candidates.push(issue);
+ }
+ }
+ }
+ core.info(
+ `Collected ${candidates.length} open candidate issues (idle >= ${closeDays}d or ${staleLabel}-labeled)`
+ );
+
+ const staled = [];
+ const closed = [];
+ const unstaled = [];
+ const errored = [];
+ let skippedIneligible = 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;
+ }
+
+ if (!isEligible(issue)) {
+ skippedIneligible++;
+ continue;
+ }
+
+ const author = (issue.user && issue.user.login) || "";
+
+ 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() === staleLabel.toLowerCase()
+ );
+
+ 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,
+ headers: GH_API_HEADERS,
+ });
+ } 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) || "").toLowerCase() ===
+ staleLabel.toLowerCase()
+ ) {
+ lastStaleLabelEvent = e;
+ break;
+ }
+ }
+
+ if (!lastStaleLabelEvent) {
+ core.warning(
+ `#${issue.number}: has '${staleLabel}' 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: staleLabel,
+ headers: GH_API_HEADERS,
+ });
+ 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 >= closeDays 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 < closeDays) {
+ 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 {
+ // Close before commenting. A close-comment that lands without the close would bump
+ // updated_at, and the next run would misread the bot's own comment as user activity and
+ // un-stale a still-open issue, dropping it from the cycle for another staleDays.
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: issue.number,
+ state: "closed",
+ state_reason: "not_planned",
+ headers: GH_API_HEADERS,
+ });
+ writes += 1;
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issue.number,
+ body: closeMessage(author),
+ headers: GH_API_HEADERS,
+ });
+ writes += 1;
+ 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 >= staleDays) {
+ 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 {
+ // Label before commenting. A stale-comment that lands without the label would bump
+ // updated_at and reset the staleness clock for another staleDays with nothing to show for
+ // it; a label without the comment still closes on schedule, just without the warning.
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number: issue.number,
+ labels: [staleLabel],
+ headers: GH_API_HEADERS,
+ });
+ writes += 1;
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issue.number,
+ body: staleMessage(author),
+ headers: GH_API_HEADERS,
+ });
+ writes += 1;
+ 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++;
+ }
+ }
+
+ // Each entry is an HTML - so it renders correctly inside
. We use raw tags rather
+ // than markdown links because GitHub Actions summary surrounds list content with HTML blocks
+ // (from addHeading / addList), which suspends markdown parsing for child content — markdown
+ // bullets via addRaw collapse onto one line in that context.
+ const fmtItem = (e) =>
+ `#${e.number} by @${
+ e.author
+ } (idle ${e.idleDays.toFixed(1)}d)`;
+ const fmtErrorItem = (e) => `#${e.number} (${e.phase}): ${e.message}`;
+ const appendList = (s, items, fn) =>
+ items.length ? s.addList(items.map(fn)) : s.addRaw("_none_").addEOL();
+
+ let summary = core.summary
+ .addHeading(title)
+ .addRaw(`Mode: **${dryRun ? "dry-run" : "live"}**`)
+ .addBreak();
+ for (const line of summaryLines) {
+ summary = summary.addRaw(line).addBreak();
+ }
+ summary = summary
+ .addRaw(`Open issues considered: **${candidates.length}**`)
+ .addList([
+ `${ineligibleSummaryLabel}: ${skippedIneligible}`,
+ `Skipped (exempt label): ${skippedExempt}`,
+ `Skipped (in scope but younger than ${staleDays} days): ${skippedNotStaleYet}`,
+ `Skipped (stale-labeled but not yet ${closeDays}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);
+ summary = appendList(summary, staled, fmtItem);
+ summary = summary.addHeading("Closed", 3);
+ summary = appendList(summary, closed, fmtItem);
+ summary = summary.addHeading("Un-staled (activity after label)", 3);
+ summary = appendList(summary, unstaled, fmtItem);
+ summary = summary.addHeading("Errors", 3);
+ summary = appendList(summary, errored, fmtErrorItem);
+ await summary.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,
+ skippedIneligible,
+ skippedExempt,
+ skippedNotStaleYet,
+ skippedNotReadyToClose,
+ hitCap,
+ };
+}
+
+module.exports = run;
+// Exported for test boundary assertions so a future change surfaces in the tests that exercise the
+// boundary, instead of silently passing because the test hardcoded the old value.
+module.exports.SELF_ACTIVITY_EPSILON_MS = SELF_ACTIVITY_EPSILON_MS;
diff --git a/.github/scripts/stale-test-helpers.js b/.github/scripts/stale-test-helpers.js
new file mode 100644
index 0000000000..e5f4aea21c
--- /dev/null
+++ b/.github/scripts/stale-test-helpers.js
@@ -0,0 +1,126 @@
+// Shared test harness for the stale-issue closer tests (`stale-eng-issues.test.js` and
+// `stale-fleetie-issues.test.js`). Provides time helpers and mocks for the `github`, `context`,
+// and `core` objects that `actions/github-script` passes to the scripts. Issue factories stay in
+// the individual test files because each wrapper has a different notion of a default-eligible issue.
+
+'use strict';
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+const daysAgoIso = (days) => new Date(Date.now() - days * DAY_MS).toISOString();
+
+// `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 },
+ };
+}
+
+module.exports = { DAY_MS, daysAgoIso, makeStaleLabelEvent, makeContext, makeCore, makeGithub };
diff --git a/.github/workflows/close-stale-eng-initiated-issues.yml b/.github/workflows/close-stale-eng-initiated-issues.yml
index f828c2092b..53fb2120db 100644
--- a/.github/workflows/close-stale-eng-initiated-issues.yml
+++ b/.github/workflows/close-stale-eng-initiated-issues.yml
@@ -1,13 +1,27 @@
name: Close stale eng-initiated issues
-# This action will mark old engineering-initiated issues as stale.
-# If stale issues don't have activity after 14 days, they will be closed.
+# Marks open engineering-initiated issues (label `~engineering-initiated`) as stale after 365 days 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, and removes the stale label.
+#
+# Runs the shared `stale-issues-core.js` engine via `stale-eng-issues.js`. This replaced the
+# off-the-shelf `actions/stale` action so the stale comment can @-mention the issue author, which
+# `actions/stale`'s static `stale-issue-message` cannot do.
on:
schedule:
# Daily at 8:10pm CDT (1:10am UTC) -- run during off-hours to prevent hitting GitHub API rate limit
- cron: "10 1 * * *"
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
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
@@ -26,23 +40,26 @@ jobs:
close-stale-issues:
runs-on: ubuntu-latest
permissions:
+ contents: read
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
with:
egress-policy: audit
- - name: Close issues
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
+
+ - name: Checkout repo
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Stale and close eng-initiated issues
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ # Schedule runs always run live. Manual runs honor the dry_run input (default true).
+ DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == true) && 'true' || 'false' }}
+ # Forwarded verbatim so an explicit `0` (the no-writes kill switch) survives. On schedule
+ # runs the input is empty and parseMaxOps in the script applies the 400 default.
+ MAX_OPERATIONS: ${{ inputs.max_operations }}
with:
- only-issue-labels: "~engineering-initiated" # comma separated labels that must ALL be present
- days-before-issue-stale: 365
- days-before-issue-close: 14
- stale-issue-label: "stale"
- stale-issue-message: "This issue is stale because it has been open for 365 days with no activity. Please update the issue if it is still relevant."
- close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
- days-before-pr-stale: -1 # Stale PRs not checked
- days-before-pr-close: -1
- repo-token: ${{ secrets.GITHUB_TOKEN }}
- debug-only: false
- operations-per-run: 200 # This number has to be high enough to capture all the recent issues we want to process
+ script: |
+ const run = require('./.github/scripts/stale-eng-issues.js');
+ await run({ github, context, core });
diff --git a/.github/workflows/close-stale-fleetie-initiated-issues.yml b/.github/workflows/close-stale-fleetie-initiated-issues.yml
index af8cb30226..18fc0bb80b 100644
--- a/.github/workflows/close-stale-fleetie-initiated-issues.yml
+++ b/.github/workflows/close-stale-fleetie-initiated-issues.yml
@@ -14,6 +14,10 @@ name: Close stale Fleetie-initiated issues
# and delete `stale-fleetie-issues.js` and its tests.
on:
+ schedule:
+ # Daily at 9:10pm CDT (2:10am UTC) -- off-hours to prevent hitting the GitHub API rate limit,
+ # and one hour after the eng-initiated closer so the two bots don't compete for the same budget.
+ - cron: "10 2 * * *"
workflow_dispatch: # Manual
inputs:
dry_run:
@@ -73,8 +77,8 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
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' }}
+ # Schedule runs always run live. Manual runs honor the dry_run input (default true).
+ DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == true) && 'true' || 'false' }}
# parseMaxOps in the script applies the 400 default when the value is empty.
MAX_OPERATIONS: ${{ inputs.max_operations }}
with:
diff --git a/.github/workflows/test-stale-fleetie-issue-scripts.yml b/.github/workflows/test-stale-fleetie-issue-scripts.yml
index 295681d930..d3aef8e732 100644
--- a/.github/workflows/test-stale-fleetie-issue-scripts.yml
+++ b/.github/workflows/test-stale-fleetie-issue-scripts.yml
@@ -1,10 +1,11 @@
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.
+# Runs the unit tests for the stale-issue scripts (shared core plus the eng-initiated and
+# Fleetie-initiated wrappers) on PRs that change any script (or its tests), a 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:
@@ -13,16 +14,26 @@ on:
paths:
- '.github/scripts/build-fleetie-handles.js'
- '.github/scripts/build-fleetie-handles.test.js'
+ - '.github/scripts/stale-issues-core.js'
+ - '.github/scripts/stale-test-helpers.js'
+ - '.github/scripts/stale-eng-issues.js'
+ - '.github/scripts/stale-eng-issues.test.js'
- '.github/scripts/stale-fleetie-issues.js'
- '.github/scripts/stale-fleetie-issues.test.js'
+ - '.github/workflows/close-stale-eng-initiated-issues.yml'
- '.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-issues-core.js'
+ - '.github/scripts/stale-test-helpers.js'
+ - '.github/scripts/stale-eng-issues.js'
+ - '.github/scripts/stale-eng-issues.test.js'
- '.github/scripts/stale-fleetie-issues.js'
- '.github/scripts/stale-fleetie-issues.test.js'
+ - '.github/workflows/close-stale-eng-initiated-issues.yml'
- '.github/workflows/close-stale-fleetie-initiated-issues.yml'
- '.github/workflows/test-stale-fleetie-issue-scripts.yml'
workflow_dispatch: # Manual
@@ -62,4 +73,4 @@ jobs:
node-version: '24'
- name: Run tests
- run: node --test .github/scripts/build-fleetie-handles.test.js .github/scripts/stale-fleetie-issues.test.js
+ run: node --test .github/scripts/build-fleetie-handles.test.js .github/scripts/stale-eng-issues.test.js .github/scripts/stale-fleetie-issues.test.js