@@ -1088,16 +1088,7 @@ export class GitHubProvider implements GitProvider {
10881088 try {
10891089 const buddyBranches = await this . getBuddyBotBranches ( )
10901090
1091- // Try to get PR branches using local git first
1092- let prBranches : Set < string >
1093- try {
1094- prBranches = await this . getOpenPRBranchesViaGit ( )
1095- }
1096- catch ( error ) {
1097- console . warn ( '⚠️ Failed to get PR branches via git, falling back to API:' , error )
1098- const openPRs = await this . getPullRequests ( 'open' )
1099- prBranches = new Set ( openPRs . map ( pr => pr . head ) )
1100- }
1091+ const prBranches = await this . getOpenPRBranches ( )
11011092
11021093 // Filter out branches that have active PRs
11031094 const orphanedBranches = buddyBranches . filter ( branch => ! prBranches . has ( branch . name ) )
@@ -1111,173 +1102,39 @@ export class GitHubProvider implements GitProvider {
11111102 }
11121103
11131104 /**
1114- * Check if a PR is open by making HTTP request to GitHub PR page (no API auth needed)
1105+ * Tracks whether the most recent open-PR detection succeeded authoritatively
1106+ * (via the GitHub API). Consumed by cleanupStaleBranches to decide between
1107+ * "delete every orphan" and "fall back to age-based protection".
11151108 */
1116- private async isPROpen ( prNumber : number ) : Promise < boolean > {
1117- try {
1118- const url = `https://github.com/${ this . owner } /${ this . repo } /pull/${ prNumber } `
1119- const response = await fetch ( url , {
1120- method : 'GET' ,
1121- headers : {
1122- 'User-Agent' : 'buddy-bot/1.0' ,
1123- } ,
1124- } )
1125-
1126- if ( ! response . ok ) {
1127- // If we can't fetch the PR page, assume it might be open (be conservative)
1128- return true
1129- }
1130-
1131- const html = await response . text ( )
1132-
1133- // Look for PR status indicators in the HTML
1134- // Open PRs have: class="State State--open"
1135- // Closed PRs have: class="State State--closed" or class="State State--merged"
1136- const isOpen = html . includes ( 'State--open' ) && ! html . includes ( 'State--closed' ) && ! html . includes ( 'State--merged' )
1137-
1138- return isOpen
1139- }
1140- catch ( error ) {
1141- // If HTTP request fails, be conservative and assume PR might be open
1142- console . warn ( `⚠️ Could not check PR #${ prNumber } status via HTTP:` , error )
1143- return true
1144- }
1145- }
1109+ private prDetectionSuccessful = false
11461110
11471111 /**
1148- * Get branches that have open PRs using HTTP requests to GitHub (no API auth needed)
1112+ * Get the set of buddy-bot branches that currently have open PRs.
1113+ *
1114+ * Uses the GitHub REST API as the authoritative source. Earlier versions
1115+ * scraped the PR HTML page for `State--open` CSS classes, which silently
1116+ * broke when GitHub removed those classes — every open PR was misreported
1117+ * as closed, causing cleanupStaleBranches to delete the live PR's branch
1118+ * (which in turn auto-closed the PR).
11491119 */
1150- private httpDetectionSuccessful = false
1120+ private async getOpenPRBranches ( ) : Promise < Set < string > > {
1121+ this . prDetectionSuccessful = false
11511122
1152- private async getOpenPRBranchesViaGit ( ) : Promise < Set < string > > {
11531123 try {
1154- const protectedBranches = new Set < string > ( )
1155- this . httpDetectionSuccessful = false
1156-
1157- console . log ( '🔍 Using HTTP requests to check actual PR status (no API auth needed)...' )
1158-
1159- // Method 1: Get PR numbers from GitHub PR refs and check their status via HTTP
1160- try {
1161- const prRefsOutput = await this . runCommand ( 'git' , [ 'ls-remote' , 'origin' , 'refs/pull/*/head' ] )
1162- const prNumbers : number [ ] = [ ]
1163- const prBranchMap = new Map < number , string [ ] > ( ) // PR number -> branch names
1164-
1165- for ( const line of prRefsOutput . split ( '\n' ) ) {
1166- if ( line . trim ( ) ) {
1167- // Extract PR number from ref: "sha refs/pull/123/head"
1168- const parts = line . trim ( ) . split ( '\t' )
1169- if ( parts . length === 2 ) {
1170- const ref = parts [ 1 ] // refs/pull/123/head
1171- const sha = parts [ 0 ]
1172- const prMatch = ref . match ( / r e f s \/ p u l l \/ ( \d + ) \/ h e a d / )
1173-
1174- if ( prMatch ) {
1175- const prNumber = Number . parseInt ( prMatch [ 1 ] )
1176- prNumbers . push ( prNumber )
1177-
1178- // Find which buddy-bot branch has this SHA
1179- try {
1180- const branchOutput = await this . runCommand ( 'git' , [ 'branch' , '-r' , '--contains' , sha ] )
1181- const branches : string [ ] = [ ]
1182-
1183- for ( const branchLine of branchOutput . split ( '\n' ) ) {
1184- const branchName = branchLine . trim ( ) . replace ( / ^ o r i g i n \/ / , '' )
1185- if ( branchName . startsWith ( 'buddy-bot/' ) ) {
1186- branches . push ( branchName )
1187- }
1188- }
1189-
1190- if ( branches . length > 0 ) {
1191- prBranchMap . set ( prNumber , branches )
1192- }
1193- }
1194- catch {
1195- // Ignore errors finding branches for specific SHAs
1196- }
1197- }
1198- }
1199- }
1200- }
1201-
1202- // Only check PRs that have associated buddy-bot branches
1203- const prNumbersToCheck = Array . from ( prBranchMap . keys ( ) )
1204- console . log ( `📋 Found ${ prNumbers . length } PR refs, ${ prNumbersToCheck . length } have buddy-bot branches` )
1205- console . log ( `🔍 Checking ${ prNumbersToCheck . length } PRs via HTTP (skipping ${ prNumbers . length - prNumbersToCheck . length } non-buddy-bot PRs)...` )
1206-
1207- // Check each PR's status via HTTP (in batches to be nice to GitHub)
1208- const batchSize = 5
1209- let checkedCount = 0
1210- let openCount = 0
1211-
1212- for ( let i = 0 ; i < prNumbersToCheck . length ; i += batchSize ) {
1213- const batch = prNumbersToCheck . slice ( i , i + batchSize )
1214-
1215- // Process batch with small delay between requests
1216- const batchPromises = batch . map ( async ( prNumber , index ) => {
1217- // Small delay to avoid overwhelming GitHub
1218- await new Promise ( resolve => setTimeout ( resolve , index * 100 ) )
1219-
1220- const isOpen = await this . isPROpen ( prNumber )
1221- checkedCount ++
1222-
1223- if ( isOpen ) {
1224- openCount ++
1225- // Protect all branches associated with this open PR
1226- const branches = prBranchMap . get ( prNumber ) || [ ]
1227- for ( const branch of branches ) {
1228- protectedBranches . add ( branch )
1229- }
1230- }
1231-
1232- return { prNumber, isOpen }
1233- } )
1234-
1235- await Promise . all ( batchPromises )
1236-
1237- // Small delay between batches
1238- if ( i + batchSize < prNumbers . length ) {
1239- await new Promise ( resolve => setTimeout ( resolve , 500 ) )
1240- }
1241- }
1242-
1243- console . log ( `✅ Checked ${ checkedCount } buddy-bot PRs via HTTP: ${ openCount } open, ${ checkedCount - openCount } closed` )
1244- console . log ( ` (Skipped ${ prNumbers . length - prNumbersToCheck . length } non-buddy-bot PRs)` )
1245- console . log ( `🛡️ Protected ${ protectedBranches . size } branches with confirmed open PRs` )
1246- console . log ( `🎯 HTTP detection successful - no age-based protection needed` )
1247- this . httpDetectionSuccessful = true
1248- }
1249- catch ( error ) {
1250- console . warn ( '⚠️ Could not check PR status via HTTP, applying conservative fallback:' , error )
1251-
1252- // Only apply fallback protection if HTTP detection completely failed
1253- try {
1254- const allBuddyBranches = await this . getBuddyBotBranches ( )
1255- const oneDayAgo = new Date ( )
1256- oneDayAgo . setDate ( oneDayAgo . getDate ( ) - 1 )
1257-
1258- let fallbackCount = 0
1259- for ( const branch of allBuddyBranches ) {
1260- if ( branch . lastCommitDate > oneDayAgo && ! protectedBranches . has ( branch . name ) ) {
1261- protectedBranches . add ( branch . name )
1262- fallbackCount ++
1263- }
1264- }
1265-
1266- if ( fallbackCount > 0 ) {
1267- console . log ( `🛡️ Emergency fallback: ${ fallbackCount } very recent branches (< 1 day) protected due to HTTP failure` )
1268- }
1269- }
1270- catch {
1271- console . log ( '⚠️ Could not apply emergency fallback protection' )
1272- }
1273- }
1274-
1275- console . log ( `🎯 HTTP-based analysis complete: protecting ${ protectedBranches . size } branches total` )
1124+ const openPRs = await this . getPullRequests ( 'open' )
1125+ const protectedBranches = new Set < string > (
1126+ openPRs
1127+ . map ( pr => pr . head )
1128+ . filter ( head => head . startsWith ( 'buddy-bot/' ) ) ,
1129+ )
12761130
1131+ console . log ( `🔍 GitHub API reports ${ openPRs . length } open PR(s); ${ protectedBranches . size } are buddy-bot branches` )
1132+ console . log ( `🛡️ Protecting ${ protectedBranches . size } branches with confirmed open PRs` )
1133+ this . prDetectionSuccessful = true
12771134 return protectedBranches
12781135 }
12791136 catch ( error ) {
1280- console . warn ( '⚠️ HTTP-based analysis failed, using conservative fallback :' , error )
1137+ console . warn ( '⚠️ Failed to fetch open PRs via API, falling back to age-based protection :' , error )
12811138
12821139 // Conservative fallback: protect branches less than 30 days old
12831140 try {
@@ -1296,8 +1153,9 @@ export class GitHubProvider implements GitProvider {
12961153 return conservativeBranches
12971154 }
12981155 catch {
1299- // Ultimate fallback: protect everything
1300- console . log ( '🛡️ Ultimate fallback: protecting all branches' )
1156+ // Ultimate fallback: leave prDetectionSuccessful=false so cleanupStaleBranches
1157+ // applies its age-based filter against whatever branches it can enumerate.
1158+ console . log ( '⚠️ Could not enumerate branches for age-based fallback' )
13011159 return new Set < string > ( )
13021160 }
13031161 }
@@ -1312,21 +1170,20 @@ export class GitHubProvider implements GitProvider {
13121170 const orphanedBranches = await this . getOrphanedBuddyBotBranches ( )
13131171 console . log ( `🔍 Found ${ orphanedBranches . length } orphaned buddy-bot branches (no associated open PRs)` )
13141172
1315- // Since we have 100% accurate HTTP-based PR detection, we can clean up ALL orphaned branches
1316- // Only apply age filter if HTTP detection failed (indicated by very conservative protection)
1173+ // When PR detection succeeded authoritatively, every orphan is a true orphan.
1174+ // If detection fell back to age-based protection, only delete branches older
1175+ // than the configured threshold to avoid wiping out branches whose live PRs
1176+ // we couldn't verify.
13171177 let branchesToDelete = orphanedBranches
13181178
1319- // Use the HTTP detection success flag to determine cleanup strategy
1320- if ( this . httpDetectionSuccessful ) {
1321- // HTTP detection worked perfectly - clean up ALL orphaned branches regardless of age
1322- console . log ( `🎯 HTTP detection successful - cleaning up ALL ${ branchesToDelete . length } orphaned branches (any age)` )
1179+ if ( this . prDetectionSuccessful ) {
1180+ console . log ( `🎯 PR detection successful - cleaning up ALL ${ branchesToDelete . length } orphaned branches (any age)` )
13231181 }
13241182 else {
1325- // HTTP detection failed - apply conservative age filter
13261183 const cutoffDate = new Date ( )
13271184 cutoffDate . setDate ( cutoffDate . getDate ( ) - olderThanDays )
13281185 branchesToDelete = orphanedBranches . filter ( branch => branch . lastCommitDate < cutoffDate )
1329- console . log ( `⚠️ HTTP detection failed - only deleting branches older than ${ olderThanDays } days` )
1186+ console . log ( `⚠️ PR detection failed - only deleting branches older than ${ olderThanDays } days` )
13301187 console . log ( `🔍 Found ${ branchesToDelete . length } stale buddy-bot branches (older than ${ olderThanDays } days)` )
13311188 }
13321189
0 commit comments