Files
fleet/.github/scripts/stale-test-helpers.js
T
Victor Lyuboslavsky 1e9f3807a1 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 -->
2026-06-05 20:39:02 +01:00

127 lines
4.3 KiB
JavaScript

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