import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { GlobalConfig } from '../../../config/global.ts'; import type { RenovateConfig } from '../../../config/types.ts'; import { REPOSITORY_CHANGED } from '../../../constants/error-messages.ts'; import { logger } from '../../../logger/index.ts'; import { ensureComment } from '../../../modules/platform/comment.ts'; import { platform } from '../../../modules/platform/index.ts'; import { scm } from '../../../modules/platform/scm.ts'; import { getBranchList, setUserRepoConfig } from '../../../util/git/index.ts'; import { regEx } from '../../../util/regex.ts'; import { uniqueStrings } from '../../../util/string.ts'; import { isMultiBaseBranch } from '../process/index.ts'; import { getReconfigureBranchName } from '../reconfigure/utils.ts'; async function cleanUpBranches( config: RenovateConfig, remainingBranches: string[], ): Promise { if (!config.pruneStaleBranches) { logger.debug('Branch/PR pruning is disabled - skipping'); return; } // set Git author in case the repository is not initialized yet setUserRepoConfig(config); // in multi-base mode the base branch is encoded in the branch name const multiBase = isMultiBaseBranch(config); const baseBranchRe = multiBase ? calculateBaseBranchRegex(config) : null; for (const branchName of remainingBranches) { try { let baseBranch: string; if (multiBase) { baseBranch = baseBranchRe?.exec(branchName)?.[1] ?? config.defaultBranch!; } else { // single base branch: branch name doesn't encode it, use the configured one baseBranch = config.baseBranches?.[0] ?? config.defaultBranch!; } const pr = await platform.findPr({ branchName, state: 'open', targetBranch: baseBranch, }); const branchIsModified = await scm.isBranchModified( branchName, baseBranch, ); if (pr) { if (branchIsModified) { logger.debug( { prNo: pr.number, prTitle: pr.title }, 'Branch is modified - skipping PR autoclosing', ); if (GlobalConfig.get('dryRun')) { logger.info(`DRY-RUN: Would update PR title and ensure comment.`); } else { if (!pr.title.endsWith('- abandoned')) { const newPrTitle = `${pr.title} - abandoned`; await platform.updatePr({ number: pr.number, prTitle: newPrTitle, state: 'open', }); } await ensureComment({ number: pr.number, topic: 'Autoclosing Skipped', content: 'This PR has been flagged for autoclosing. However, it is being skipped due to the branch being already modified. Please close/delete it manually or report a bug if you think this is in error.', }); } } else if (GlobalConfig.get('dryRun')) { logger.info( { prNo: pr.number, prTitle: pr.title }, `DRY-RUN: Would autoclose PR`, ); } else { logger.info( { branchName, prNo: pr.number, prTitle: pr.title }, 'Autoclosing PR', ); let newPrTitle = pr.title; if (!pr.title.endsWith('- autoclosed')) { newPrTitle += ' - autoclosed'; } await platform.updatePr({ number: pr.number, prTitle: newPrTitle, state: 'closed', }); await scm.deleteBranch(branchName); } } else if (branchIsModified) { logger.debug( { branch: branchName }, 'Orphan Branch is modified - skipping branch deletion', ); } else if (GlobalConfig.get('dryRun')) { logger.info(`DRY-RUN: Would delete orphan branch ${branchName}`); } else { logger.info({ branch: branchName }, `Deleting orphan branch`); await scm.deleteBranch(branchName); } } catch (err) /* istanbul ignore next */ { if (err.message === 'config-validation') { logger.debug( 'Cannot prune branch due to collision between tags and branch names', ); } else if (err.message?.includes("bad revision 'origin/")) { logger.debug( { branchName }, 'Branch not found on origin when attempting to prune', ); } else if (err.message !== REPOSITORY_CHANGED) { logger.warn({ err, branch: branchName }, 'Error pruning branch'); } } } } /** * Calculates a {RegExp} to extract the base branch from a branch name if base branch patterns is configured. * @param config Renovate configuration */ function calculateBaseBranchRegex(config: RenovateConfig): RegExp | null { if (!config.baseBranchPatterns?.length || !config.baseBranches?.length) { return null; } // calculate possible branch prefixes and escape for regex const branchPrefixes = [config.branchPrefix, config.branchPrefixOld] .filter(isNonEmptyStringAndNotWhitespace) .filter(uniqueStrings) .map((prefix) => RegExp.escape(prefix)); const baseBranches = config.baseBranches.map((branch) => RegExp.escape(branch), ); // create regex to extract base branche from branch name const baseBranchRe = regEx( `^(?:${branchPrefixes.join('|')})(${baseBranches.join('|')})-`, ); return baseBranchRe; } export async function pruneStaleBranches( config: RenovateConfig, branchList: string[] | null | undefined, ): Promise { logger.debug('Removing any stale branches'); logger.trace({ config }, `pruneStaleBranches`); // TODO: types (#22198) logger.debug(`config.repoIsOnboarded=${config.repoIsOnboarded!}`); if (!branchList) { logger.debug('No branchList'); return; } if (!config.defaultBranch) { logger.debug('No defaultBranch set - skipping branch pruning'); return; } if (!isNonEmptyStringAndNotWhitespace(config.branchPrefix)) { // An empty branchPrefix matches every branch in the repo, so Renovate // cannot reliably tell its own branches apart from unrelated ones. Skip // pruning to avoid deleting non-Renovate branches as orphans. logger.warn( 'config.branchPrefix is empty - skipping branch pruning to avoid treating all branches as Renovate-managed', ); return; } // TODO: types (#22198) let renovateBranches = getBranchList().filter( (branchName) => branchName.startsWith(config.branchPrefix!) && branchName !== getReconfigureBranchName(config.branchPrefix!), ); if (!renovateBranches?.length) { logger.debug('No renovate branches found'); return; } logger.debug( { branchList: branchList?.sort(), renovateBranches: renovateBranches?.sort(), }, 'Branch lists', ); const lockFileBranch = `${config.branchPrefix}lock-file-maintenance`; renovateBranches = renovateBranches.filter( (branch) => branch !== lockFileBranch, ); const remainingBranches = renovateBranches.filter( (branch) => !branchList.includes(branch), ); logger.debug(`remainingBranches=${String(remainingBranches)}`); if (remainingBranches.length === 0) { logger.debug('No branches to clean up'); return; } await cleanUpBranches(config, remainingBranches); }