<!-- 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 -->
138 lines
5.9 KiB
JavaScript
138 lines
5.9 KiB
JavaScript
'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);
|
|
});
|