Add author mention to stale issue bots (#46787)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 <li> so it renders correctly inside <ul>. We use raw <a> 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) =>
|
||||
`<a href="${e.url}">#${e.number}</a> 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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 <li> so it renders correctly inside <ul>. We use raw <a> 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) =>
|
||||
`<a href="${e.url}">#${e.number}</a> 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;
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user