branch-remover
Version:
A small application for quickly removing unnecessary branches from GitHub.
287 lines (248 loc) • 7.37 kB
text/typescript
import chalk from 'chalk';
import {
BranchRemoveCancelationReason,
BranchRemoverContext,
BranchRemoverOptions,
BranchRemoverOptionsIgnoreArgs,
BranchRemoverOptionsIgnoreFunction,
BranchRemoverOptionsIgnoreType,
IBranchesProvider,
IBranchRemover,
ILogger,
Logger,
} from './Core';
import { branchRemoveCancelationReasonFormatter } from './Formatters';
export class BranchRemover implements IBranchRemover {
private readonly _provider: IBranchesProvider;
public get provider(): IBranchesProvider {
return this._provider;
}
constructor(provider: IBranchesProvider) {
this._provider = provider;
this.execute = this.execute.bind(this);
}
// TODO: I forgot why I made test a separate parameter from the options,
// it probably makes sense to combine
public async execute(options: BranchRemoverOptions, test?: boolean): Promise<void> {
// TODO: PackageInfo service
const {
name: packageName,
version: packageVersion,
} = require('../package.json');
const logger = options.logger || new Logger();
const cache = options.cache?.provider;
const cacheTimeout = options.cache?.timeout ?? 0;
const isCacheAvailable = cache && cacheTimeout > 0;
logger.info(
'{package} v{version}',
{
package: packageName,
version: packageVersion,
}
);
if (isCacheAvailable) {
cache.load();
}
try {
await this.processBranches(
logger,
options,
test ?? false
);
} catch (error) {
// TODO: Output the number of processed branches and the elapsed time
throw new Error(error.message);
} finally {
if (isCacheAvailable) {
cache.save();
}
}
}
private buildIgnoreFunction(value: BranchRemoverOptionsIgnoreType): BranchRemoverOptionsIgnoreFunction {
if (typeof value === 'function') {
return value;
}
if (typeof value === 'string') {
return (e: BranchRemoverOptionsIgnoreArgs): Promise<boolean> => {
return Promise.resolve(
e.branchName === value
);
};
}
if (value instanceof RegExp) {
return (e: BranchRemoverOptionsIgnoreArgs): Promise<boolean> => {
return Promise.resolve(
value.test(e.branchName)
);
};
}
if (Array.isArray(value)) {
return (e: BranchRemoverOptionsIgnoreArgs): Promise<boolean> => {
return Promise.resolve(
value.includes(e.branchName)
);
};
}
return (): Promise<boolean> => Promise.resolve(false);
}
private async processBranches(
logger: ILogger,
options: Omit<BranchRemoverOptions, 'logger'>,
test: boolean
): Promise<void> {
const start = new Date();
const ignore = this.buildIgnoreFunction(options.ignore);
const remove = options.remove;
const dummy = (): Promise<boolean> => Promise.resolve(true);
const beforeRemove = options.beforeRemove || dummy;
const afterRemove = options.afterRemove || dummy;
const context: BranchRemoverContext = {
test,
logger,
};
const cache = options.cache?.provider;
const cacheTimeout = options.cache?.timeout ?? 0;
const isCacheAvailable = cache && cacheTimeout > 0;
const addCache = (key: string, reason: BranchRemoveCancelationReason): void => {
if (isCacheAvailable) {
cache.add(
key,
reason,
cacheTimeout
);
}
};
logger.info(
'Processing branches using {provider} provider in {mode} mode.',
{
provider: chalk.yellow(this._provider.name),
mode: test ? chalk.yellow('test') : chalk.bold(chalk.red('REMOVE')),
}
);
const branches = await this._provider.getListBranches();
let totalRemoved = 0;
for (let i = 0, ic = branches.length; i < ic; ++i) {
const { name, lastCommitHash } = branches[i];
const cacheKey = `branch-${name}`;
logger.info(
'Processing branch {index} of {count} - {branch}',
{
index: chalk.blue(i + 1),
count: chalk.blue(ic),
branch: name,
}
);
if (isCacheAvailable) {
if (cache.has(cacheKey)) {
logger.info(
'Skip cached branch "{branch}" until {ttl}: {reason}.',
{
branch: name,
ttl: new Date(cache.getTtl(cacheKey)),
reason: branchRemoveCancelationReasonFormatter(
cache.get(cacheKey)
),
}
);
continue;
}
}
// NOTE: we have to check before getting branch details
// because, depending on the provider, there may be limits on the number of requests;
// for example, GitHub currently allows no more than 5,000 requests per hour for free
const ignored = await ignore({
branchName: name,
context,
});
if (ignored) {
addCache(
cacheKey,
BranchRemoveCancelationReason.Ignored
);
logger.info(
'Skip branch "{branch}", because it matches an ignored value.',
{
branch: name,
}
);
continue;
}
const branch = await this._provider.getBranch(
name,
lastCommitHash
);
if (!branch) {
addCache(
cacheKey,
BranchRemoveCancelationReason.BranchNotFound
);
logger.info(
'Skip branch "{branch}", because no detailed information.',
{
branch: name,
}
);
continue;
}
const args = {
branch,
context,
};
const canRemove = await remove(args);
if (canRemove === true) {
logger.info(
'Removing "{branch}"...',
{
branch: name,
}
);
if (test) {
logger.info('Removing is not applicable in test mode.');
} else {
// TODO: not sure if we need additional checking,
// this is probably redundant,
// need to think about this question
const canStillRemove = await beforeRemove(args);
if (canStillRemove) {
await this._provider.removeBranch(name);
++totalRemoved;
logger.info(
'Successfully removed "{branch}".',
{
branch: name,
}
);
await afterRemove(args);
} else {
addCache(
cacheKey,
BranchRemoveCancelationReason.CanceledByBeforeHandler
);
logger.info(
'Removing branch "{branch}" canceled by the result of the {handler} handler.',
{
branch: name,
handler: 'beforeRemove',
}
);
}
}
} else {
addCache(
cacheKey,
typeof canRemove === 'boolean'
? BranchRemoveCancelationReason.Other
: canRemove
);
}
}
logger.info(
'Removed {totalRemoved} out of {totalCount} branches in {duration} seconds.',
{
totalRemoved,
totalCount: branches.length,
duration: (new Date().getTime() - start.getTime()) / 1000,
}
);
}
}