Skip to content

Commit b2bcf29

Browse files
Copilotrajbos
andauthored
Extract GitHub PR feature into dedicated githubPrService.ts
Agent-Logs-Url: https://github.com/rajbos/github-copilot-token-usage/sessions/fefce18b-d88e-4482-8e79-740c21a5a9c2 Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com>
1 parent 1f7796a commit b2bcf29

3 files changed

Lines changed: 323 additions & 163 deletions

File tree

vscode-extension/src/extension.ts

Lines changed: 33 additions & 163 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import * as fs from 'fs';
33
import * as path from 'path';
44
import * as os from 'os';
5-
import * as https from 'https';
65
import * as childProcess from 'child_process';
76
import tokenEstimatorsData from './tokenEstimators.json';
87
import modelPricingData from './modelPricing.json';
@@ -14,6 +13,14 @@ import { BackendCommandHandler } from './backend/commands';
1413
import * as packageJson from '../package.json';
1514
import { getModelDisplayName } from './webview/shared/modelUtils';
1615
import { 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

1825
import 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-
169151
class 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(/github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/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
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import * as https from 'https';
2+
import * as childProcess from 'child_process';
3+
4+
export type RepoPrDetail = {
5+
number: number;
6+
title: string;
7+
url: string;
8+
aiType: 'copilot' | 'claude' | 'openai' | 'other-ai';
9+
role: 'author' | 'reviewer-requested';
10+
};
11+
12+
export type RepoPrInfo = {
13+
owner: string;
14+
repo: string;
15+
repoUrl: string;
16+
totalPrs: number;
17+
aiAuthoredPrs: number;
18+
aiReviewRequestedPrs: number;
19+
aiDetails: RepoPrDetail[];
20+
error?: string;
21+
};
22+
23+
export type RepoPrStatsResult = {
24+
repos: RepoPrInfo[];
25+
authenticated: boolean;
26+
since: string; // ISO date string
27+
};
28+
29+
/** Detect which AI system a GitHub login belongs to, or null if not an AI bot. */
30+
export function detectAiType(login: string): RepoPrDetail['aiType'] | null {
31+
const l = login.toLowerCase();
32+
if (l.includes('copilot')) { return 'copilot'; }
33+
if (l.includes('claude') || l.includes('anthropic')) { return 'claude'; }
34+
if (l.includes('openai') || l.includes('codex')) { return 'openai'; }
35+
return null;
36+
}
37+
38+
/** Fetch a single page of PRs from GitHub REST API. */
39+
export function fetchRepoPrsPage(
40+
owner: string,
41+
repo: string,
42+
token: string,
43+
page: number,
44+
): Promise<{ prs: any[]; statusCode?: number; error?: string }> {
45+
return new Promise((resolve) => {
46+
const req = https.request(
47+
{
48+
hostname: 'api.github.com',
49+
path: `/repos/${owner}/${repo}/pulls?state=all&per_page=100&sort=created&direction=desc&page=${page}`,
50+
headers: {
51+
Authorization: `Bearer ${token}`,
52+
'User-Agent': 'copilot-token-tracker',
53+
Accept: 'application/vnd.github.v3+json',
54+
},
55+
},
56+
(res) => {
57+
let data = '';
58+
res.on('data', (chunk) => (data += chunk));
59+
res.on('end', () => {
60+
try {
61+
const parsed = JSON.parse(data);
62+
if (!Array.isArray(parsed)) {
63+
resolve({ prs: [], statusCode: res.statusCode, error: parsed.message ?? 'Unexpected API response' });
64+
} else {
65+
resolve({ prs: parsed, statusCode: res.statusCode });
66+
}
67+
} catch (e) {
68+
resolve({ prs: [], statusCode: res.statusCode, error: String(e) });
69+
}
70+
});
71+
},
72+
);
73+
req.on('error', (e) => resolve({ prs: [], error: e.message }));
74+
req.setTimeout(15000, () => {
75+
req.destroy(new Error('Request timed out after 15 s'));
76+
});
77+
req.end();
78+
});
79+
}
80+
81+
/** Fetch all PRs from the last 30 days for a repo, paginating as needed. */
82+
export async function fetchRepoPrs(
83+
owner: string,
84+
repo: string,
85+
token: string,
86+
since: Date,
87+
fetchPage: (owner: string, repo: string, token: string, page: number) => Promise<{ prs: any[]; statusCode?: number; error?: string }> = fetchRepoPrsPage,
88+
): Promise<{ prs: any[]; error?: string }> {
89+
const allPrs: any[] = [];
90+
const MAX_PAGES = 5; // Cap at 500 PRs per repo
91+
for (let page = 1; page <= MAX_PAGES; page++) {
92+
const { prs, statusCode, error } = await fetchPage(owner, repo, token, page);
93+
if (error) {
94+
const msg = statusCode === 404
95+
? 'Repo not found or not accessible with current token'
96+
: statusCode === 403
97+
? (error || 'Access denied (private repo requires additional permissions)')
98+
: error;
99+
return { prs: allPrs, error: msg };
100+
}
101+
if (prs.length === 0) { break; }
102+
for (const pr of prs) {
103+
if (new Date(pr.created_at) >= since) {
104+
allPrs.push(pr);
105+
}
106+
}
107+
// Stop paginating when the oldest PR on this page is before our window
108+
const oldest = prs[prs.length - 1];
109+
if (new Date(oldest.created_at) < since || prs.length < 100) {
110+
break;
111+
}
112+
}
113+
return { prs: allPrs };
114+
}
115+
116+
/**
117+
* Discover GitHub repos from workspace paths using git remote.
118+
* Deduplicates by owner/repo so each GitHub repo is only fetched once.
119+
*/
120+
export function discoverGitHubRepos(workspacePaths: string[]): { owner: string; repo: string }[] {
121+
const seen = new Set<string>();
122+
const repos: { owner: string; repo: string }[] = [];
123+
for (const workspacePath of workspacePaths) {
124+
try {
125+
const remote = childProcess.execSync('git remote get-url origin', {
126+
cwd: workspacePath,
127+
encoding: 'utf8',
128+
timeout: 3000,
129+
stdio: ['pipe', 'pipe', 'pipe'],
130+
}).trim();
131+
// Only process github.com remotes
132+
const match = remote.match(/github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/i);
133+
if (!match) { continue; }
134+
const key = `${match[1]}/${match[2]}`.toLowerCase();
135+
if (seen.has(key)) { continue; }
136+
seen.add(key);
137+
repos.push({ owner: match[1], repo: match[2] });
138+
} catch {
139+
// Not a git repo or no remote — skip
140+
}
141+
}
142+
return repos;
143+
}

0 commit comments

Comments
 (0)