mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 09:51:59 +08:00
ci: Add a self-rolled stale branch removal script (#31694)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user