Skip to content

Commit 922ce2b

Browse files
committed
feat: add cleanup & list-branches
1 parent ec20bfa commit 922ce2b

File tree

2 files changed

+358
-0
lines changed

2 files changed

+358
-0
lines changed

bin/cli.ts

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ PACKAGE INFORMATION:
6060
compare ⚖️ Compare two versions of a package
6161
search 🔍 Search for packages in the registry
6262
63+
BRANCH MANAGEMENT:
64+
cleanup 🧹 Clean up stale buddy-bot branches
65+
list-branches 📋 List all buddy-bot branches and their status
66+
6367
CONFIGURATION & SETUP:
6468
open-settings 🔧 Open GitHub repository and organization settings pages
6569
@@ -69,6 +73,8 @@ Examples:
6973
buddy-bot scan --verbose # Scan for updates (npm + Composer)
7074
buddy-bot rebase 17 # Rebase PR #17
7175
buddy-bot update-check # Auto-rebase checked PRs
76+
buddy-bot cleanup # Clean up stale branches
77+
buddy-bot list-branches # List all buddy-bot branches
7278
buddy-bot info laravel/framework # Get Composer package info
7379
buddy-bot info react # Get npm package info
7480
buddy-bot versions react --latest 5 # Show recent versions
@@ -1052,6 +1058,31 @@ This helps maintain the intended behavior of dynamic version indicators while pr
10521058
if (closedCount === 0 && rebasedCount === 0) {
10531059
logger.info('✅ No PRs need attention')
10541060
}
1061+
1062+
// Automatic cleanup of stale branches after processing PRs
1063+
if (!options.dryRun) {
1064+
logger.info('\n🧹 Checking for stale branches to clean up...')
1065+
try {
1066+
const cleanupResult = await gitProvider.cleanupStaleBranches(7, false) // Clean branches older than 7 days
1067+
1068+
if (cleanupResult.deleted.length > 0) {
1069+
logger.success(`🧹 Automatically cleaned up ${cleanupResult.deleted.length} stale branch(es)`)
1070+
if (options.verbose) {
1071+
cleanupResult.deleted.forEach(branch => logger.info(` ✅ Deleted: ${branch}`))
1072+
}
1073+
}
1074+
1075+
if (cleanupResult.failed.length > 0) {
1076+
logger.warn(`⚠️ Failed to clean up ${cleanupResult.failed.length} branch(es)`)
1077+
if (options.verbose) {
1078+
cleanupResult.failed.forEach(branch => logger.warn(` ❌ Failed: ${branch}`))
1079+
}
1080+
}
1081+
}
1082+
catch (cleanupError) {
1083+
logger.warn('⚠️ Branch cleanup failed:', cleanupError)
1084+
}
1085+
}
10551086
}
10561087
catch (error) {
10571088
logger.error('update-check failed:', error)
@@ -1756,6 +1787,229 @@ cli
17561787
}
17571788
})
17581789

1790+
cli
1791+
.command('cleanup', 'Clean up stale buddy-bot branches that don\'t have associated open PRs')
1792+
.option('--verbose, -v', 'Enable verbose logging')
1793+
.option('--dry-run', 'Show what would be deleted without actually deleting')
1794+
.option('--days <number>', 'Delete branches older than N days (default: 7)', { default: '7' })
1795+
.option('--force', 'Force cleanup without confirmation prompt')
1796+
.example('buddy-bot cleanup')
1797+
.example('buddy-bot cleanup --dry-run')
1798+
.example('buddy-bot cleanup --days 14')
1799+
.example('buddy-bot cleanup --force')
1800+
.action(async (options: CLIOptions & { dryRun?: boolean, days?: string, force?: boolean }) => {
1801+
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
1802+
1803+
try {
1804+
logger.info('🧹 Starting buddy-bot branch cleanup...')
1805+
1806+
// Check if repository is configured
1807+
if (!config.repository) {
1808+
logger.error('❌ Repository configuration required for branch cleanup')
1809+
logger.info('Configure repository.provider, repository.owner, repository.name in buddy-bot.config.ts')
1810+
process.exit(1)
1811+
}
1812+
1813+
// Get GitHub token from environment
1814+
const token = process.env.BUDDY_BOT_TOKEN || process.env.GITHUB_TOKEN
1815+
if (!token) {
1816+
logger.error('❌ GITHUB_TOKEN or BUDDY_BOT_TOKEN environment variable required for branch operations')
1817+
process.exit(1)
1818+
}
1819+
1820+
const { GitHubProvider } = await import('../src/git/github-provider')
1821+
const hasWorkflowPermissions = !!process.env.BUDDY_BOT_TOKEN
1822+
const gitProvider = new GitHubProvider(
1823+
token,
1824+
config.repository.owner,
1825+
config.repository.name,
1826+
hasWorkflowPermissions,
1827+
)
1828+
1829+
const days = Number.parseInt(options.days || '7', 10)
1830+
if (Number.isNaN(days) || days < 1) {
1831+
logger.error('❌ Invalid days value. Must be a positive number.')
1832+
process.exit(1)
1833+
}
1834+
1835+
logger.info(`🔍 Looking for buddy-bot branches older than ${days} days...`)
1836+
1837+
// Get all orphaned branches
1838+
const orphanedBranches = await gitProvider.getOrphanedBuddyBotBranches()
1839+
const cutoffDate = new Date()
1840+
cutoffDate.setDate(cutoffDate.getDate() - days)
1841+
const staleBranches = orphanedBranches.filter(branch => branch.lastCommitDate < cutoffDate)
1842+
1843+
if (staleBranches.length === 0) {
1844+
logger.success('✅ No stale branches found!')
1845+
logger.info(`📊 Total buddy-bot branches: ${orphanedBranches.length}`)
1846+
logger.info(`📊 Stale branches (>${days} days): 0`)
1847+
return
1848+
}
1849+
1850+
logger.info(`📊 Found ${staleBranches.length} stale branches to clean up:`)
1851+
staleBranches.forEach(branch => {
1852+
const daysOld = Math.floor((Date.now() - branch.lastCommitDate.getTime()) / (1000 * 60 * 60 * 24))
1853+
logger.info(` - ${branch.name} (${daysOld} days old)`)
1854+
})
1855+
1856+
if (options.dryRun) {
1857+
logger.info('\n🔍 [DRY RUN] These branches would be deleted')
1858+
logger.info('💡 Run without --dry-run to actually delete them')
1859+
return
1860+
}
1861+
1862+
// Confirmation prompt (unless --force is used)
1863+
if (!options.force) {
1864+
const response = await prompts({
1865+
type: 'confirm',
1866+
name: 'confirmed',
1867+
message: `Are you sure you want to delete ${staleBranches.length} stale branches?`,
1868+
initial: false,
1869+
})
1870+
1871+
if (!response.confirmed) {
1872+
logger.info('❌ Cleanup cancelled')
1873+
return
1874+
}
1875+
}
1876+
1877+
// Perform cleanup
1878+
const result = await gitProvider.cleanupStaleBranches(days, false)
1879+
1880+
if (result.deleted.length > 0) {
1881+
logger.success(`✅ Successfully deleted ${result.deleted.length} stale branches`)
1882+
}
1883+
1884+
if (result.failed.length > 0) {
1885+
logger.warn(`⚠️ Failed to delete ${result.failed.length} branches`)
1886+
result.failed.forEach(branch => logger.warn(` - ${branch}`))
1887+
}
1888+
1889+
logger.info(`\n📊 Cleanup Summary:`)
1890+
logger.info(` ✅ Deleted: ${result.deleted.length}`)
1891+
logger.info(` ❌ Failed: ${result.failed.length}`)
1892+
logger.info(` 📊 Total processed: ${staleBranches.length}`)
1893+
}
1894+
catch (error) {
1895+
logger.error('Branch cleanup failed:', error)
1896+
process.exit(1)
1897+
}
1898+
})
1899+
1900+
cli
1901+
.command('list-branches', 'List all buddy-bot branches and their status')
1902+
.option('--verbose, -v', 'Enable verbose logging')
1903+
.option('--orphaned-only', 'Show only branches without associated open PRs')
1904+
.option('--stale-only', 'Show only stale branches (older than 7 days)')
1905+
.option('--days <number>', 'Define stale threshold in days (default: 7)', { default: '7' })
1906+
.example('buddy-bot list-branches')
1907+
.example('buddy-bot list-branches --orphaned-only')
1908+
.example('buddy-bot list-branches --stale-only --days 14')
1909+
.action(async (options: CLIOptions & { orphanedOnly?: boolean, staleOnly?: boolean, days?: string }) => {
1910+
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
1911+
1912+
try {
1913+
logger.info('📋 Listing buddy-bot branches...')
1914+
1915+
// Check if repository is configured
1916+
if (!config.repository) {
1917+
logger.error('❌ Repository configuration required for branch listing')
1918+
logger.info('Configure repository.provider, repository.owner, repository.name in buddy-bot.config.ts')
1919+
process.exit(1)
1920+
}
1921+
1922+
// Get GitHub token from environment
1923+
const token = process.env.BUDDY_BOT_TOKEN || process.env.GITHUB_TOKEN
1924+
if (!token) {
1925+
logger.error('❌ GITHUB_TOKEN or BUDDY_BOT_TOKEN environment variable required for branch operations')
1926+
process.exit(1)
1927+
}
1928+
1929+
const { GitHubProvider } = await import('../src/git/github-provider')
1930+
const hasWorkflowPermissions = !!process.env.BUDDY_BOT_TOKEN
1931+
const gitProvider = new GitHubProvider(
1932+
token,
1933+
config.repository.owner,
1934+
config.repository.name,
1935+
hasWorkflowPermissions,
1936+
)
1937+
1938+
const days = Number.parseInt(options.days || '7', 10)
1939+
const cutoffDate = new Date()
1940+
cutoffDate.setDate(cutoffDate.getDate() - days)
1941+
1942+
// Get all branches and PRs
1943+
const [allBuddyBranches, openPRs] = await Promise.all([
1944+
gitProvider.getBuddyBotBranches(),
1945+
gitProvider.getPullRequests('open'),
1946+
])
1947+
1948+
const prBranches = new Set(openPRs.map(pr => pr.head))
1949+
1950+
// Filter branches based on options
1951+
let branches = allBuddyBranches
1952+
if (options.orphanedOnly) {
1953+
branches = branches.filter(branch => !prBranches.has(branch.name))
1954+
}
1955+
if (options.staleOnly) {
1956+
branches = branches.filter(branch => branch.lastCommitDate < cutoffDate && !prBranches.has(branch.name))
1957+
}
1958+
1959+
if (branches.length === 0) {
1960+
if (options.orphanedOnly && options.staleOnly) {
1961+
logger.success('✅ No stale orphaned branches found!')
1962+
}
1963+
else if (options.orphanedOnly) {
1964+
logger.success('✅ No orphaned branches found!')
1965+
}
1966+
else if (options.staleOnly) {
1967+
logger.success('✅ No stale branches found!')
1968+
}
1969+
else {
1970+
logger.info('📋 No buddy-bot branches found')
1971+
}
1972+
return
1973+
}
1974+
1975+
console.log(`\n📊 Found ${branches.length} buddy-bot branch${branches.length !== 1 ? 'es' : ''}:\n`)
1976+
1977+
// Sort by last commit date (newest first)
1978+
branches.sort((a, b) => b.lastCommitDate.getTime() - a.lastCommitDate.getTime())
1979+
1980+
branches.forEach(branch => {
1981+
const hasOpenPR = prBranches.has(branch.name)
1982+
const daysOld = Math.floor((Date.now() - branch.lastCommitDate.getTime()) / (1000 * 60 * 60 * 24))
1983+
const isStale = branch.lastCommitDate < cutoffDate
1984+
1985+
const status = hasOpenPR ? '🔴 Open PR' : (isStale ? '🟡 Stale' : '🟢 Recent')
1986+
const shortSha = branch.sha.substring(0, 7)
1987+
1988+
console.log(`${status} ${branch.name}`)
1989+
console.log(` 📅 ${daysOld} days old | 📝 ${shortSha} | 🗓️ ${branch.lastCommitDate.toISOString().split('T')[0]}`)
1990+
console.log()
1991+
})
1992+
1993+
// Summary
1994+
const orphanedCount = branches.filter(branch => !prBranches.has(branch.name)).length
1995+
const staleCount = branches.filter(branch => branch.lastCommitDate < cutoffDate && !prBranches.has(branch.name)).length
1996+
1997+
console.log('📊 Summary:')
1998+
console.log(` 📋 Total buddy-bot branches: ${allBuddyBranches.length}`)
1999+
console.log(` 🔴 With open PRs: ${allBuddyBranches.length - orphanedCount}`)
2000+
console.log(` 🟡 Orphaned: ${orphanedCount}`)
2001+
console.log(` 🗑️ Stale (>${days} days): ${staleCount}`)
2002+
2003+
if (staleCount > 0) {
2004+
console.log(`\n💡 Run 'buddy-bot cleanup' to clean up stale branches`)
2005+
}
2006+
}
2007+
catch (error) {
2008+
logger.error('Branch listing failed:', error)
2009+
process.exit(1)
2010+
}
2011+
})
2012+
17592013
cli
17602014
.command('open-settings', 'Open GitHub repository and organization settings pages')
17612015
.option('--verbose, -v', 'Enable verbose logging')

src/git/github-provider.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,110 @@ export class GitHubProvider implements GitProvider {
728728
}
729729
}
730730

731+
/**
732+
* Get all buddy-bot branches from the repository
733+
*/
734+
async getBuddyBotBranches(): Promise<Array<{ name: string, sha: string, lastCommitDate: Date }>> {
735+
try {
736+
const branches = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/branches?per_page=100`)
737+
const buddyBranches = branches.filter((branch: any) => branch.name.startsWith('buddy-bot/'))
738+
739+
// Get detailed info for each branch including last commit date
740+
const branchDetails = await Promise.all(
741+
buddyBranches.map(async (branch: any) => {
742+
try {
743+
const commit = await this.apiRequest(`GET /repos/${this.owner}/${this.repo}/commits/${branch.commit.sha}`)
744+
return {
745+
name: branch.name,
746+
sha: branch.commit.sha,
747+
lastCommitDate: new Date(commit.commit.committer.date),
748+
}
749+
}
750+
catch (error) {
751+
console.warn(`⚠️ Failed to get commit info for branch ${branch.name}:`, error)
752+
return {
753+
name: branch.name,
754+
sha: branch.commit.sha,
755+
lastCommitDate: new Date(0), // Fallback to epoch
756+
}
757+
}
758+
}),
759+
)
760+
761+
return branchDetails
762+
}
763+
catch (error) {
764+
console.warn('⚠️ Failed to fetch buddy-bot branches:', error)
765+
return []
766+
}
767+
}
768+
769+
/**
770+
* Get all buddy-bot branches that don't have associated open PRs
771+
*/
772+
async getOrphanedBuddyBotBranches(): Promise<Array<{ name: string, sha: string, lastCommitDate: Date }>> {
773+
try {
774+
const [buddyBranches, openPRs] = await Promise.all([
775+
this.getBuddyBotBranches(),
776+
this.getPullRequests('open'),
777+
])
778+
779+
// Filter out branches that have active PRs
780+
const prBranches = new Set(openPRs.map(pr => pr.head))
781+
const orphanedBranches = buddyBranches.filter(branch => !prBranches.has(branch.name))
782+
783+
return orphanedBranches
784+
}
785+
catch (error) {
786+
console.warn('⚠️ Failed to identify orphaned branches:', error)
787+
return []
788+
}
789+
}
790+
791+
/**
792+
* Clean up stale buddy-bot branches
793+
*/
794+
async cleanupStaleBranches(olderThanDays = 7, dryRun = false): Promise<{ deleted: string[], failed: string[] }> {
795+
const cutoffDate = new Date()
796+
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays)
797+
798+
const orphanedBranches = await this.getOrphanedBuddyBotBranches()
799+
const staleBranches = orphanedBranches.filter(branch => branch.lastCommitDate < cutoffDate)
800+
801+
console.log(`🔍 Found ${staleBranches.length} stale buddy-bot branches (older than ${olderThanDays} days)`)
802+
803+
if (staleBranches.length === 0) {
804+
return { deleted: [], failed: [] }
805+
}
806+
807+
if (dryRun) {
808+
console.log('🔍 [DRY RUN] Would delete the following branches:')
809+
staleBranches.forEach(branch => {
810+
console.log(` - ${branch.name} (last commit: ${branch.lastCommitDate.toISOString()})`)
811+
})
812+
return { deleted: staleBranches.map(b => b.name), failed: [] }
813+
}
814+
815+
const deleted: string[] = []
816+
const failed: string[] = []
817+
818+
console.log(`🧹 Cleaning up ${staleBranches.length} stale branches...`)
819+
820+
for (const branch of staleBranches) {
821+
try {
822+
await this.deleteBranch(branch.name)
823+
deleted.push(branch.name)
824+
console.log(`✅ Deleted stale branch: ${branch.name}`)
825+
}
826+
catch (error) {
827+
failed.push(branch.name)
828+
console.warn(`❌ Failed to delete branch ${branch.name}:`, error)
829+
}
830+
}
831+
832+
return { deleted, failed }
833+
}
834+
731835
/**
732836
* Make authenticated API request to GitHub
733837
*/

0 commit comments

Comments
 (0)