22import * as fs from 'fs' ;
33import * as path from 'path' ;
44import * as os from 'os' ;
5- import * as https from 'https' ;
65import * as childProcess from 'child_process' ;
76import tokenEstimatorsData from './tokenEstimators.json' ;
87import modelPricingData from './modelPricing.json' ;
@@ -14,6 +13,14 @@ import { BackendCommandHandler } from './backend/commands';
1413import * as packageJson from '../package.json' ;
1514import { getModelDisplayName } from './webview/shared/modelUtils' ;
1615import { ConfirmationMessages } from "./backend/ui/messages" ;
16+ import {
17+ detectAiType ,
18+ discoverGitHubRepos ,
19+ fetchRepoPrs ,
20+ type RepoPrDetail ,
21+ type RepoPrInfo ,
22+ type RepoPrStatsResult ,
23+ } from './githubPrService' ;
1724
1825import type {
1926 TokenUsageStats ,
@@ -141,31 +148,6 @@ type LocalViewRegressionCase = {
141148 open : ( ) => Promise < void > ;
142149} ;
143150
144- type RepoPrDetail = {
145- number : number ;
146- title : string ;
147- url : string ;
148- aiType : 'copilot' | 'claude' | 'openai' | 'other-ai' ;
149- role : 'author' | 'reviewer-requested' ;
150- } ;
151-
152- type RepoPrInfo = {
153- owner : string ;
154- repo : string ;
155- repoUrl : string ;
156- totalPrs : number ;
157- aiAuthoredPrs : number ;
158- aiReviewRequestedPrs : number ;
159- aiDetails : RepoPrDetail [ ] ;
160- error ?: string ;
161- } ;
162-
163- type RepoPrStatsResult = {
164- repos : RepoPrInfo [ ] ;
165- authenticated : boolean ;
166- since : string ; // ISO date string
167- } ;
168-
169151class CopilotTokenTracker implements vscode . Disposable {
170152 // Cache version - increment this when making changes that require cache invalidation
171153 private static readonly CACHE_VERSION = 39 ; // Cache-aware cost: track cachedReadTokens/cacheCreationTokens in ModelUsage
@@ -1150,139 +1132,6 @@ class CopilotTokenTracker implements vscode.Disposable {
11501132 return this . githubSession ;
11511133 }
11521134
1153- /** Detect which AI system a GitHub login belongs to, or null if not an AI bot. */
1154- private detectAiType ( login : string ) : RepoPrDetail [ 'aiType' ] | null {
1155- const l = login . toLowerCase ( ) ;
1156- if ( l . includes ( 'copilot' ) ) { return 'copilot' ; }
1157- if ( l . includes ( 'claude' ) || l . includes ( 'anthropic' ) ) { return 'claude' ; }
1158- if ( l . includes ( 'openai' ) || l . includes ( 'codex' ) ) { return 'openai' ; }
1159- return null ;
1160- }
1161-
1162- /**
1163- * Discover GitHub repos from known session workspace folders.
1164- * Deduplicates by owner/repo so each GitHub repo is only fetched once.
1165- */
1166- private async discoverGitHubRepos ( ) : Promise < { owner : string ; repo : string } [ ] > {
1167- const workspacePaths : string [ ] = [ ] ;
1168-
1169- const matrix = this . _lastCustomizationMatrix ;
1170- if ( matrix && matrix . workspaces . length > 0 ) {
1171- for ( const ws of matrix . workspaces ) {
1172- if ( ! ws . workspacePath . startsWith ( '<unresolved:' ) ) {
1173- workspacePaths . push ( ws . workspacePath ) ;
1174- }
1175- }
1176- }
1177- // Also include currently open VS Code workspace folders
1178- for ( const folder of vscode . workspace . workspaceFolders ?? [ ] ) {
1179- const p = folder . uri . fsPath ;
1180- if ( ! workspacePaths . includes ( p ) ) {
1181- workspacePaths . push ( p ) ;
1182- }
1183- }
1184-
1185- const seen = new Set < string > ( ) ;
1186- const repos : { owner : string ; repo : string } [ ] = [ ] ;
1187- for ( const workspacePath of workspacePaths ) {
1188- try {
1189- const remote = childProcess . execSync ( 'git remote get-url origin' , {
1190- cwd : workspacePath ,
1191- encoding : 'utf8' ,
1192- timeout : 3000 ,
1193- stdio : [ 'pipe' , 'pipe' , 'pipe' ] ,
1194- } ) . trim ( ) ;
1195- // Only process github.com remotes
1196- const match = remote . match ( / g i t h u b \. c o m [: / ] ( [ ^ / ] + ) \/ ( [ ^ / \s ] + ?) (?: \. g i t ) ? $ / i) ;
1197- if ( ! match ) { continue ; }
1198- const key = `${ match [ 1 ] } /${ match [ 2 ] } ` . toLowerCase ( ) ;
1199- if ( seen . has ( key ) ) { continue ; }
1200- seen . add ( key ) ;
1201- repos . push ( { owner : match [ 1 ] , repo : match [ 2 ] } ) ;
1202- } catch {
1203- // Not a git repo or no remote — skip
1204- }
1205- }
1206- return repos ;
1207- }
1208-
1209- /** Fetch a single page of PRs from GitHub REST API. */
1210- private fetchRepoPrsPage (
1211- owner : string ,
1212- repo : string ,
1213- token : string ,
1214- page : number ,
1215- ) : Promise < { prs : any [ ] ; statusCode ?: number ; error ?: string } > {
1216- return new Promise ( ( resolve ) => {
1217- const req = https . request (
1218- {
1219- hostname : 'api.github.com' ,
1220- path : `/repos/${ owner } /${ repo } /pulls?state=all&per_page=100&sort=created&direction=desc&page=${ page } ` ,
1221- headers : {
1222- Authorization : `Bearer ${ token } ` ,
1223- 'User-Agent' : 'copilot-token-tracker' ,
1224- Accept : 'application/vnd.github.v3+json' ,
1225- } ,
1226- } ,
1227- ( res ) => {
1228- let data = '' ;
1229- res . on ( 'data' , ( chunk ) => ( data += chunk ) ) ;
1230- res . on ( 'end' , ( ) => {
1231- try {
1232- const parsed = JSON . parse ( data ) ;
1233- if ( ! Array . isArray ( parsed ) ) {
1234- resolve ( { prs : [ ] , statusCode : res . statusCode , error : parsed . message ?? 'Unexpected API response' } ) ;
1235- } else {
1236- resolve ( { prs : parsed , statusCode : res . statusCode } ) ;
1237- }
1238- } catch ( e ) {
1239- resolve ( { prs : [ ] , statusCode : res . statusCode , error : String ( e ) } ) ;
1240- }
1241- } ) ;
1242- } ,
1243- ) ;
1244- req . on ( 'error' , ( e ) => resolve ( { prs : [ ] , error : e . message } ) ) ;
1245- req . setTimeout ( 15000 , ( ) => {
1246- req . destroy ( new Error ( 'Request timed out after 15 s' ) ) ;
1247- } ) ;
1248- req . end ( ) ;
1249- } ) ;
1250- }
1251-
1252- /** Fetch all PRs from the last 30 days for a repo, paginating as needed. */
1253- private async fetchRepoPrs (
1254- owner : string ,
1255- repo : string ,
1256- token : string ,
1257- since : Date ,
1258- ) : Promise < { prs : any [ ] ; error ?: string } > {
1259- const allPrs : any [ ] = [ ] ;
1260- const MAX_PAGES = 5 ; // Cap at 500 PRs per repo
1261- for ( let page = 1 ; page <= MAX_PAGES ; page ++ ) {
1262- const { prs, statusCode, error } = await this . fetchRepoPrsPage ( owner , repo , token , page ) ;
1263- if ( error ) {
1264- const msg = statusCode === 404
1265- ? 'Repo not found or not accessible with current token'
1266- : statusCode === 403
1267- ? ( error || 'Access denied (private repo requires additional permissions)' )
1268- : error ;
1269- return { prs : allPrs , error : msg } ;
1270- }
1271- if ( prs . length === 0 ) { break ; }
1272- for ( const pr of prs ) {
1273- if ( new Date ( pr . created_at ) >= since ) {
1274- allPrs . push ( pr ) ;
1275- }
1276- }
1277- // Stop paginating when the oldest PR on this page is before our window
1278- const oldest = prs [ prs . length - 1 ] ;
1279- if ( new Date ( oldest . created_at ) < since || prs . length < 100 ) {
1280- break ;
1281- }
1282- }
1283- return { prs : allPrs } ;
1284- }
1285-
12861135 /** Load PR stats for all discovered GitHub repos and send results to the analysis panel. */
12871136 private async loadRepoPrStats ( ) : Promise < void > {
12881137 if ( ! this . analysisPanel ) { return ; }
@@ -1316,13 +1165,14 @@ class CopilotTokenTracker implements vscode.Disposable {
13161165 this . log ( `✅ GitHub session synced from existing VS Code auth: ${ session . account . label } ` ) ;
13171166 }
13181167
1319- const repos = await this . discoverGitHubRepos ( ) ;
1168+ const workspacePaths = this . _buildWorkspacePaths ( ) ;
1169+ const repos = discoverGitHubRepos ( workspacePaths ) ;
13201170 this . analysisPanel . webview . postMessage ( { command : 'repoPrStatsProgress' , total : repos . length , done : 0 } ) ;
13211171
13221172 const results : RepoPrInfo [ ] = [ ] ;
13231173 for ( let i = 0 ; i < repos . length ; i ++ ) {
13241174 const { owner, repo } = repos [ i ] ;
1325- const { prs, error } = await this . fetchRepoPrs ( owner , repo , session . accessToken , since ) ;
1175+ const { prs, error } = await fetchRepoPrs ( owner , repo , session . accessToken , since ) ;
13261176
13271177 let totalPrs = 0 ;
13281178 let aiAuthoredPrs = 0 ;
@@ -1332,13 +1182,13 @@ class CopilotTokenTracker implements vscode.Disposable {
13321182 if ( ! error ) {
13331183 totalPrs = prs . length ;
13341184 for ( const pr of prs ) {
1335- const authorAi = this . detectAiType ( pr . user ?. login ?? '' ) ;
1185+ const authorAi = detectAiType ( pr . user ?. login ?? '' ) ;
13361186 if ( authorAi ) {
13371187 aiAuthoredPrs ++ ;
13381188 aiDetails . push ( { number : pr . number , title : pr . title , url : pr . html_url , aiType : authorAi , role : 'author' } ) ;
13391189 }
13401190 for ( const reviewer of ( pr . requested_reviewers ?? [ ] ) ) {
1341- const reviewerAi = this . detectAiType ( reviewer . login ?? '' ) ;
1191+ const reviewerAi = detectAiType ( reviewer . login ?? '' ) ;
13421192 if ( reviewerAi ) {
13431193 aiReviewRequestedPrs ++ ;
13441194 aiDetails . push ( { number : pr . number , title : pr . title , url : pr . html_url , aiType : reviewerAi , role : 'reviewer-requested' } ) ;
@@ -1366,6 +1216,26 @@ class CopilotTokenTracker implements vscode.Disposable {
13661216 this . analysisPanel . webview . postMessage ( { command : 'repoPrStatsLoaded' , data : result } ) ;
13671217 }
13681218
1219+ /** Collect workspace paths from the customization matrix and currently open VS Code workspace folders. */
1220+ private _buildWorkspacePaths ( ) : string [ ] {
1221+ const workspacePaths : string [ ] = [ ] ;
1222+ const matrix = this . _lastCustomizationMatrix ;
1223+ if ( matrix && matrix . workspaces . length > 0 ) {
1224+ for ( const ws of matrix . workspaces ) {
1225+ if ( ! ws . workspacePath . startsWith ( '<unresolved:' ) ) {
1226+ workspacePaths . push ( ws . workspacePath ) ;
1227+ }
1228+ }
1229+ }
1230+ for ( const folder of vscode . workspace . workspaceFolders ?? [ ] ) {
1231+ const p = folder . uri . fsPath ;
1232+ if ( ! workspacePaths . includes ( p ) ) {
1233+ workspacePaths . push ( p ) ;
1234+ }
1235+ }
1236+ return workspacePaths ;
1237+ }
1238+
13691239 /**
13701240 * Restore GitHub authentication session on extension startup.
13711241 * Always attempts a silent getSession so that a pre-existing VS Code GitHub
0 commit comments