ci: Add a self-rolled stale branch removal script (#31694)

This commit is contained in:
Matsu
2026-06-04 09:20:50 +00:00
committed by GitHub
parent 3fb0540dfb
commit 77a1b844b5
4 changed files with 784 additions and 12 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "workflow-scripts",
"scripts": {
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ../../scripts/licenses/*.test.mjs",
"test": "node --test --experimental-test-module-mocks ./*.test.mjs ./quality/*.test.mjs ./slack/*.test.mjs ./stale/*.test.mjs ../../scripts/licenses/*.test.mjs",
"generate-sbom": "FETCH_LICENSE=true cdxgen -t pnpm --no-install-deps --profile license-compliance -o ../../sbom-source.cdx.json ../../compiled/",
"render-licenses-md": "node ../../scripts/licenses/render-licenses-md.mjs ../../sbom-source.cdx.json ../../packages/cli/THIRD_PARTY_LICENSES.md ../../compiled/node_modules",
"generate-licenses": "pnpm generate-sbom && pnpm render-licenses-md"
@@ -0,0 +1,468 @@
#!/usr/bin/env node
/**
* Stale-branch cleanup for n8n-io/n8n.
*
* Decides, per branch, whether to KEEP or DELETE and prints the reasoning
* (age, ruleset protection, default branch). Dry-run is the DEFAULT — nothing
* is deleted unless you pass `--execute`.
*
* How protection is decided:
* We fetch the repo's rulesets, keep the ones that (a) target branches,
* (b) are actively enforced, and (c) contain a `deletion` rule, then match
* each branch ref against their ref-name include/exclude glob patterns.
* This mirrors what the GitHub UI shows as "affected branches" for a ruleset.
* GitHub's server-side ruleset enforcement remains the hard backstop: even in
* --execute mode a delete of a protected branch is rejected unless the token
* is a bypass actor.
*
* Auth: uses GH_TOKEN / GITHUB_TOKEN if set (CI), otherwise falls back to
* `gh auth token` (local). Repo: GITHUB_REPOSITORY env, else `gh repo view`.
*
* Usage:
* node .github/scripts/stale/clean-stale-branches.mjs # dry run (default)
* node .github/scripts/stale/clean-stale-branches.mjs --days=120
* node .github/scripts/stale/clean-stale-branches.mjs --execute # actually delete
*
* Requires Node 18+ (global fetch).
*/
import { execFileSync } from 'node:child_process';
import { parseArgs } from 'node:util';
import { minimatch } from 'minimatch';
const API = 'https://api.github.com';
const DAY_MS = 86_400_000; // 24 hours in millis
// --- pure logic (exported for tests) ---------------------------------------
// GitHub ruleset ref globs use fnmatch semantics: `*` matches within a path
// segment, `**` matches across `/`, `?` matches a single char. minimatch (the
// same glob matcher used by our other workflow scripts) implements exactly
// these rules — `dot` so leading-dot segments aren't skipped, `noext` to keep
// GitHub's plain fnmatch behaviour (no `+(...)` extglobs).
const GLOB_OPTIONS = { dot: true, noext: true };
export function refMatches(ref, pattern, defaultBranch) {
if (pattern === '~ALL') {
return true;
}
if (pattern === '~DEFAULT_BRANCH') {
return ref === `refs/heads/${defaultBranch}`;
}
return minimatch(ref, pattern, GLOB_OPTIONS);
}
// Returns the first keep-pattern that matches the branch name, else null.
// These are operator-supplied globs matched against the bare branch name
// (e.g. `release/*`, `1.x`) — an explicit safety net independent of rulesets,
// so stale cleanup stays safe even if a protecting ruleset is ever removed.
export function matchingExcludePattern(branchName, excludePatterns) {
return excludePatterns.find((pattern) => minimatch(branchName, pattern, GLOB_OPTIONS)) ?? null;
}
// Returns the protecting ruleset's name if `ref` is protected from deletion, else null.
export function protectingRuleset(ref, rulesets, defaultBranch) {
for (const rs of rulesets) {
const included = rs.include.some((p) => refMatches(ref, p, defaultBranch));
if (!included) {
continue;
}
const excluded = rs.exclude.some((p) => refMatches(ref, p, defaultBranch));
if (excluded) {
continue;
}
return rs.name;
}
return null;
}
/**
* Classify branches into keep/delete buckets with a reason for each.
* Pure: no network, no clock — `now` is injected so tests are deterministic.
*
* @param {{
* branches: Array<{ name: string, committedDate: string | null }>,
* rulesets: Array<{ name: string, include: string[], exclude: string[] }>,
* defaultBranch: string,
* staleDays: number,
* now: number,
* openPrRefs?: Map<string, number[]>,
* excludePatterns?: string[],
* }} input
* @returns {{ keep: Array<{name:string,ageDays:number|null,reason:string}>, remove: Array<{name:string,ageDays:number|null,reason:string}> }}
*/
export function classifyBranches({
branches,
rulesets,
defaultBranch,
staleDays,
now,
openPrRefs = new Map(),
excludePatterns = [],
}) {
const keep = [];
const remove = [];
for (const branch of branches) {
const ref = `refs/heads/${branch.name}`;
const ageDays =
branch.committedDate === null
? null
: Math.floor((now - new Date(branch.committedDate).getTime()) / DAY_MS);
const rulesetName = protectingRuleset(ref, rulesets, defaultBranch);
if (rulesetName) {
keep.push({ name: branch.name, ageDays, reason: `protected: ruleset "${rulesetName}" (deletion)` });
continue;
}
const excludePattern = matchingExcludePattern(branch.name, excludePatterns);
if (excludePattern) {
keep.push({ name: branch.name, ageDays, reason: `excluded: matches keep-pattern "${excludePattern}"` });
continue;
}
if (branch.name === defaultBranch) {
keep.push({ name: branch.name, ageDays, reason: 'protected: default branch' });
continue;
}
const prNumbers = openPrRefs.get(branch.name);
if (prNumbers && prNumbers.length > 0) {
const refs = prNumbers
.slice()
.sort((a, b) => a - b)
.map((n) => `#${n}`)
.join(', ');
keep.push({ name: branch.name, ageDays, reason: `open PR ${refs} (head or base)` });
continue;
}
if (ageDays === null) {
keep.push({ name: branch.name, ageDays, reason: 'kept: unknown last-commit date' });
continue;
}
if (ageDays < staleDays) {
keep.push({ name: branch.name, ageDays, reason: `active: last commit ${ageDays}d ago (< ${staleDays}d)` });
continue;
}
remove.push({
name: branch.name,
ageDays,
reason: `stale: last commit ${ageDays}d ago (>= ${staleDays}d), no deletion-protection ruleset`,
});
}
keep.sort((a, b) => a.name.localeCompare(b.name));
remove.sort((a, b) => (b.ageDays ?? 0) - (a.ageDays ?? 0)); // oldest first
return { keep, remove };
}
// --- environment / auth -----------------------------------------------------
function resolveToken() {
if (process.env.GH_TOKEN) return process.env.GH_TOKEN.trim();
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN.trim();
try {
return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim();
} catch {
throw new Error('No token found. Set GH_TOKEN/GITHUB_TOKEN or run `gh auth login`.');
}
}
function resolveRepo() {
if (process.env.GITHUB_REPOSITORY) {
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
return { owner, repo };
}
try {
const slug = execFileSync('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], {
encoding: 'utf8',
}).trim();
const [owner, repo] = slug.split('/');
return { owner, repo };
} catch {
throw new Error('Cannot resolve repo. Set GITHUB_REPOSITORY or run inside a gh-authenticated repo.');
}
}
function resolveDryRun(values) {
// Dry run is the default. --execute (or DRY_RUN=false) opts into real deletes.
let dryRun = true;
if (values.execute) {
dryRun = false;
}
if (process.env.DRY_RUN === 'false' || process.env.DRY_RUN === '0') {
dryRun = false;
}
if (process.env.DRY_RUN === 'true' || process.env.DRY_RUN === '1') {
dryRun = true;
}
if (values['dry-run']) {
dryRun = true; // explicit override always wins toward safety
}
return dryRun;
}
// Glob patterns of branch names to always keep. Sourced from repeated
// `--exclude` flags and/or the comma-separated EXCLUDE_BRANCHES env var; both
// accept comma-separated lists. Blank entries are dropped.
function resolveExcludePatterns(values) {
const raw = [...(values.exclude ?? []), process.env.EXCLUDE_BRANCHES ?? ''];
return raw
.flatMap((entry) => entry.split(','))
.map((pattern) => pattern.trim())
.filter(Boolean);
}
// --- data fetching ----------------------------------------------------------
async function rest(ctx, path) {
const res = await fetch(`${API}${path}`, { headers: ctx.headers });
if (!res.ok) {
throw new Error(`GET ${path} -> ${res.status} ${await res.text()}`);
}
return res.json();
}
async function graphql(ctx, query, variables) {
const res = await fetch(`${API}/graphql`, {
method: 'POST',
headers: { ...ctx.headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors) {
throw new Error(`GraphQL error: ${JSON.stringify(json.errors)}`);
}
return json.data;
}
async function fetchDeletionRulesets(ctx) {
const list = await rest(ctx, `/repos/${ctx.owner}/${ctx.repo}/rulesets?per_page=100`);
const detailed = [];
for (const summary of list) {
const rs = await rest(ctx, `/repos/${ctx.owner}/${ctx.repo}/rulesets/${summary.id}`);
if (rs.target !== 'branch') {
continue;
}
if (rs.enforcement !== 'active') {
continue;
}
const hasDeletionRule = (rs.rules ?? []).some((r) => r.type === 'deletion');
if (!hasDeletionRule) {
continue;
}
detailed.push({
name: rs.name,
include: rs.conditions?.ref_name?.include ?? [],
exclude: rs.conditions?.ref_name?.exclude ?? [],
});
}
return detailed;
}
async function fetchBranches(ctx) {
console.log('Fetching branches...');
const branches = [];
let cursor = null;
const query = `
query($owner:String!,$repo:String!,$cursor:String){
repository(owner:$owner,name:$repo){
defaultBranchRef{ name }
refs(refPrefix:"refs/heads/",first:100,after:$cursor){
pageInfo{ hasNextPage endCursor }
nodes{
name
target{ ... on Commit { committedDate } }
}
}
}
}`;
let defaultBranch = '';
do {
const data = await graphql(ctx, query, { owner: ctx.owner, repo: ctx.repo, cursor });
const repository = data.repository;
defaultBranch = repository.defaultBranchRef?.name ?? '';
for (const node of repository.refs.nodes) {
branches.push({
name: node.name,
committedDate: node.target?.committedDate ?? null,
});
}
cursor = repository.refs.pageInfo.hasNextPage ? repository.refs.pageInfo.endCursor : null;
} while (cursor);
return { branches, defaultBranch };
}
// Map of branch name -> open PR numbers that reference it as head or base.
// A branch in this map must never be deleted: deleting a PR's head closes the
// PR, and deleting a base orphans/closes PRs targeting it. Fork PR head refs
// live in the fork, so only count head refs from same-repo PRs; base refs are
// always in this repo.
async function fetchOpenPrRefs(ctx) {
console.log('Fetching open pull requests...');
const refs = new Map();
const sameRepo = `${ctx.owner}/${ctx.repo}`.toLowerCase();
const addRef = (ref, prNumber) => {
if (!ref) {
return;
}
if (!refs.has(ref)) {
refs.set(ref, []);
}
const arr = refs.get(ref);
if (!arr.includes(prNumber)) {
arr.push(prNumber);
}
};
let page = 1;
for (;;) {
const prs = await rest(ctx, `/repos/${ctx.owner}/${ctx.repo}/pulls?state=open&per_page=100&page=${page}`);
for (const pr of prs) {
if (pr.head?.repo?.full_name?.toLowerCase() === sameRepo) {
addRef(pr.head?.ref, pr.number);
}
addRef(pr.base?.ref, pr.number);
}
if (prs.length < 100) {
break;
}
page++;
}
return refs;
}
async function deleteBranch(ctx, name) {
const res = await fetch(`${API}/repos/${ctx.owner}/${ctx.repo}/git/refs/heads/${encodeURIComponent(name)}`, {
method: 'DELETE',
headers: ctx.headers,
});
return res;
}
// --- reporting --------------------------------------------------------------
function printReport({ owner, repo, defaultBranch, staleDays, dryRun, rulesets, excludePatterns, openPrCount, keep, remove }) {
console.log(`Repository: ${owner}/${repo}`);
console.log(`Default branch: ${defaultBranch}`);
console.log(`Stale threshold: ${staleDays} days`);
console.log(`Mode: ${dryRun ? 'DRY RUN (no deletions)' : 'EXECUTE (will delete)'}`);
console.log(`Deletion rulesets: ${rulesets.length ? rulesets.map((r) => `"${r.name}"`).join(', ') : '(none)'}`);
console.log(
`Keep patterns: ${excludePatterns?.length ? excludePatterns.map((p) => `"${p}"`).join(', ') : '(none)'}`,
);
console.log(`Open-PR refs kept: ${openPrCount ?? 0}`);
console.log('');
console.log(`Branches to keep (${keep.length}):`);
for (const b of keep) console.log(` KEEP ${b.name}${b.reason}`);
console.log('');
console.log(`Branches to delete (${remove.length}):`);
for (const b of remove) console.log(` DELETE ${b.name}${b.reason}`);
console.log('');
}
// --- main -------------------------------------------------------------------
async function main() {
const { values } = parseArgs({
options: {
execute: { type: 'boolean', default: false },
'dry-run': { type: 'boolean' },
days: { type: 'string' },
exclude: { type: 'string', multiple: true },
help: { type: 'boolean', default: false },
},
});
if (values.help) {
console.log(
'Usage: clean-stale-branches.mjs [--days=N] [--exclude=GLOB]... [--execute]\n' +
' --days=N Days of inactivity before a branch is stale (default 100, or STALE_DAYS env)\n' +
' --exclude=GLOB Branch-name glob to always keep; repeatable or comma-separated\n' +
' (e.g. --exclude="release/*,1.x", or EXCLUDE_BRANCHES env)\n' +
' --execute Actually delete stale branches (default is dry run)\n' +
'Auth via GH_TOKEN/GITHUB_TOKEN or `gh auth token`. Repo via GITHUB_REPOSITORY or `gh repo view`.',
);
return;
}
const dryRun = resolveDryRun(values);
const excludePatterns = resolveExcludePatterns(values);
const staleDays = Number(values.days ?? process.env.STALE_DAYS ?? 100);
if (!Number.isFinite(staleDays) || staleDays <= 0) {
throw new Error(`Invalid --days value: ${values.days ?? process.env.STALE_DAYS}`);
}
const token = resolveToken();
const { owner, repo } = resolveRepo();
const ctx = {
owner,
repo,
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'n8n-stale-branches',
},
};
const rulesets = await fetchDeletionRulesets(ctx);
const { branches, defaultBranch } = await fetchBranches(ctx);
const openPrRefs = await fetchOpenPrRefs(ctx);
const { keep, remove } = classifyBranches({
branches,
rulesets,
defaultBranch,
staleDays,
now: Date.now(),
openPrRefs,
excludePatterns,
});
printReport({
owner,
repo,
defaultBranch,
staleDays,
dryRun,
rulesets,
excludePatterns,
openPrCount: openPrRefs.size,
keep,
remove,
});
if (dryRun) {
console.log(`Dry run complete. ${remove.length} branch(es) would be deleted. Re-run with --execute to delete.`);
return;
}
let deleted = 0;
let blocked = 0;
for (const b of remove) {
const res = await deleteBranch(ctx, b.name);
if (res.status === 204) {
deleted++;
console.log(` deleted ${b.name}`);
} else {
blocked++;
const body = await res.text();
console.log(` SKIPPED ${b.name}${res.status} ${body}`);
}
}
console.log('');
console.log(`Done. Deleted ${deleted}, skipped/blocked ${blocked}.`);
}
// only run when executed directly, not when imported by tests
if (import.meta.url === `file://${process.argv[1]}`) {
await main();
}
@@ -0,0 +1,285 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { refMatches, matchingExcludePattern, protectingRuleset, classifyBranches } from './clean-stale-branches.mjs';
/**
* Run these tests with:
*
* node --test ./.github/scripts/stale/clean-stale-branches.test.mjs
* */
const DAY_MS = 86_400_000;
// Mirror of the repo's real deletion-protecting rulesets.
const RULESETS = [
{ name: 'master', include: ['refs/heads/master', '~DEFAULT_BRANCH'], exclude: [] },
{ name: '1.x Branch ruleset', include: ['refs/heads/1.x'], exclude: [] },
{ name: 'Release branch Ruleset', include: ['refs/heads/release/*'], exclude: [] },
{ name: 'Release Candidate branch Ruleset', include: ['refs/heads/release-candidate/*'], exclude: [] },
];
describe('refMatches', () => {
it('matches the ~ALL wildcard against any ref', () => {
assert.equal(refMatches('refs/heads/anything', '~ALL', 'master'), true);
});
it('resolves ~DEFAULT_BRANCH against the provided default branch', () => {
assert.equal(refMatches('refs/heads/master', '~DEFAULT_BRANCH', 'master'), true);
assert.equal(refMatches('refs/heads/develop', '~DEFAULT_BRANCH', 'master'), false);
});
it('matches a literal pattern exactly', () => {
assert.equal(refMatches('refs/heads/master', 'refs/heads/master', 'master'), true);
assert.equal(refMatches('refs/heads/master-2', 'refs/heads/master', 'master'), false);
assert.equal(refMatches('refs/heads/feature/master', 'refs/heads/master', 'master'), false);
});
it('treats * as within a single path segment', () => {
assert.equal(refMatches('refs/heads/release/1.50.1', 'refs/heads/release/*', 'master'), true);
// * does not cross a slash
assert.equal(refMatches('refs/heads/release/team/1.50.1', 'refs/heads/release/*', 'master'), false);
assert.equal(refMatches('refs/heads/release', 'refs/heads/release/*', 'master'), false);
assert.equal(refMatches('refs/heads/feature/x', 'refs/heads/release/*', 'master'), false);
});
it('treats ** as crossing path segments', () => {
assert.equal(refMatches('refs/heads/release/team/1.50.1', 'refs/heads/release/**', 'master'), true);
assert.equal(refMatches('refs/heads/release/1.50.1', 'refs/heads/release/**', 'master'), true);
});
it('treats metacharacters in the literal parts as literal', () => {
assert.equal(refMatches('refs/heads/1.x', 'refs/heads/1.x', 'master'), true);
// the dot must be literal, not "any char"
assert.equal(refMatches('refs/heads/1ax', 'refs/heads/1.x', 'master'), false);
});
});
describe('matchingExcludePattern', () => {
it('matches the bare branch name against keep-patterns', () => {
assert.equal(matchingExcludePattern('release/1.50.1', ['release/*']), 'release/*');
assert.equal(matchingExcludePattern('1.x', ['1.x']), '1.x');
});
it('returns the first matching pattern', () => {
assert.equal(matchingExcludePattern('release/1.50.1', ['1.x', 'release/*']), 'release/*');
});
it('returns null when nothing matches or the list is empty', () => {
assert.equal(matchingExcludePattern('some-feature', ['release/*', '1.x']), null);
assert.equal(matchingExcludePattern('some-feature', []), null);
});
it('supports ** crossing path segments', () => {
assert.equal(matchingExcludePattern('dependabot/npm/lodash', ['dependabot/**']), 'dependabot/**');
assert.equal(matchingExcludePattern('dependabot/npm/lodash', ['dependabot/*']), null);
});
});
describe('protectingRuleset', () => {
it('returns the ruleset name when an include pattern matches', () => {
assert.equal(protectingRuleset('refs/heads/release/1.50.1', RULESETS, 'master'), 'Release branch Ruleset');
assert.equal(protectingRuleset('refs/heads/1.x', RULESETS, 'master'), '1.x Branch ruleset');
assert.equal(protectingRuleset('refs/heads/master', RULESETS, 'master'), 'master');
});
it('returns null when no ruleset matches', () => {
assert.equal(protectingRuleset('refs/heads/some-old-feature', RULESETS, 'master'), null);
});
it('honors exclude patterns over include patterns', () => {
const rulesets = [{ name: 'releases', include: ['refs/heads/release/*'], exclude: ['refs/heads/release/0.*'] }];
assert.equal(protectingRuleset('refs/heads/release/1.50.1', rulesets, 'master'), 'releases');
assert.equal(protectingRuleset('refs/heads/release/0.236.1', rulesets, 'master'), null);
});
});
describe('classifyBranches', () => {
// Fixed clock so age math is deterministic.
const now = new Date('2026-06-04T00:00:00Z').getTime();
const daysAgo = (n) => new Date(now - n * DAY_MS).toISOString();
it('keeps ruleset-protected branches regardless of age', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'release/1.50.1', committedDate: daysAgo(800) }],
rulesets: RULESETS,
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.equal(remove.length, 0);
assert.equal(keep.length, 1);
assert.match(keep[0].reason, /protected: ruleset "Release branch Ruleset"/);
});
it('keeps the default branch even if no ruleset matched it', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'develop', committedDate: daysAgo(900) }],
rulesets: [],
defaultBranch: 'develop',
staleDays: 100,
now,
});
assert.equal(remove.length, 0);
assert.equal(keep[0].reason, 'protected: default branch');
});
it('keeps branches newer than the stale threshold', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'recent-feature', committedDate: daysAgo(10) }],
rulesets: RULESETS,
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.equal(remove.length, 0);
assert.equal(keep[0].reason, 'active: last commit 10d ago (< 100d)');
});
it('deletes unprotected branches older than the threshold', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'old-feature', committedDate: daysAgo(412) }],
rulesets: RULESETS,
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.equal(keep.length, 0);
assert.equal(remove.length, 1);
assert.equal(remove[0].name, 'old-feature');
assert.equal(remove[0].ageDays, 412);
assert.match(remove[0].reason, /stale: last commit 412d ago \(>= 100d\)/);
});
it('keeps branches with an unknown last-commit date', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'weird-branch', committedDate: null }],
rulesets: RULESETS,
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.equal(remove.length, 0);
assert.equal(keep[0].reason, 'kept: unknown last-commit date');
});
it('sorts deletions oldest-first and keeps alphabetically', () => {
const { keep, remove } = classifyBranches({
branches: [
{ name: 'stale-newer', committedDate: daysAgo(150) },
{ name: 'stale-older', committedDate: daysAgo(500) },
{ name: 'zeta-active', committedDate: daysAgo(1) },
{ name: 'alpha-active', committedDate: daysAgo(2) },
],
rulesets: RULESETS,
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.deepEqual(
remove.map((b) => b.name),
['stale-older', 'stale-newer'],
);
assert.deepEqual(
keep.map((b) => b.name),
['alpha-active', 'zeta-active'],
);
});
it('treats a branch exactly at the threshold as stale', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'edge', committedDate: daysAgo(100) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.equal(keep.length, 0);
assert.equal(remove.length, 1);
});
it('keeps a stale branch that is the head or base of an open PR', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'old-but-has-pr', committedDate: daysAgo(500) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
openPrRefs: new Map([['old-but-has-pr', [24878]]]),
});
assert.equal(remove.length, 0);
assert.equal(keep[0].reason, 'open PR #24878 (head or base)');
});
it('lists multiple referencing PRs sorted ascending', () => {
const { keep } = classifyBranches({
branches: [{ name: 'shared-base', committedDate: daysAgo(500) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
openPrRefs: new Map([['shared-base', [300, 42, 100]]]),
});
assert.equal(keep[0].reason, 'open PR #42, #100, #300 (head or base)');
});
it('still deletes stale branches with no open PR reference', () => {
const { remove } = classifyBranches({
branches: [{ name: 'no-pr', committedDate: daysAgo(500) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
openPrRefs: new Map([['some-other-branch', [1]]]),
});
assert.equal(remove.length, 1);
assert.equal(remove[0].name, 'no-pr');
});
it('defaults openPrRefs to empty when omitted', () => {
const { remove } = classifyBranches({
branches: [{ name: 'lonely', committedDate: daysAgo(500) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.equal(remove.length, 1);
});
it('keeps a stale branch matching an exclude pattern even with no protecting ruleset', () => {
const { keep, remove } = classifyBranches({
branches: [{ name: 'release/1.50.1', committedDate: daysAgo(800) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
excludePatterns: ['release/*'],
});
assert.equal(remove.length, 0);
assert.equal(keep[0].reason, 'excluded: matches keep-pattern "release/*"');
});
it('still deletes stale branches that match no exclude pattern', () => {
const { remove } = classifyBranches({
branches: [{ name: 'old-feature', committedDate: daysAgo(500) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
excludePatterns: ['release/*', '1.x'],
});
assert.equal(remove.length, 1);
assert.equal(remove[0].name, 'old-feature');
});
it('defaults excludePatterns to empty when omitted', () => {
const { remove } = classifyBranches({
branches: [{ name: 'lonely', committedDate: daysAgo(500) }],
rulesets: [],
defaultBranch: 'master',
staleDays: 100,
now,
});
assert.equal(remove.length, 1);
});
});