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