github.ts
1 import { Octokit } from '@octokit/rest'; 2 import { getProvider } from './provider'; 3 import type { ChangedFile, CiCheck, PrMetadata, PrSearchResult, Provider, ReviewSummary } from './types'; 4 5 export function parsePrUrl(url: string): { owner: string; repo: string; pullNumber: number } { 6 const match = url.match(/github\.com\/([^/]+)\/([^/]+)\/pulls?\/(\d+)/); 7 if (!match) { 8 throw new Error(`Invalid GitHub PR URL: ${url}`); 9 } 10 return { 11 owner: match[1], 12 repo: match[2], 13 pullNumber: parseInt(match[3], 10), 14 }; 15 } 16 17 export async function getPrMetadata( 18 octokit: Octokit, 19 owner: string, 20 repo: string, 21 pullNumber: number 22 ): Promise<PrMetadata> { 23 const { data } = await octokit.pulls.get({ owner, repo, pull_number: pullNumber }); 24 return { 25 title: data.title, 26 description: data.body ?? '', 27 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API can return null 28 author: data.user?.login ?? 'unknown', 29 baseBranch: data.base.ref, 30 headBranch: data.head.ref, 31 headSha: data.head.sha, 32 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API can return null 33 merged: data.merged ?? false, 34 state: data.state, 35 createdAt: data.created_at, 36 updatedAt: data.updated_at, 37 url: data.html_url, 38 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API can return null 39 labels: (data.labels ?? []).map((l) => (typeof l === 'string' ? l : (l.name ?? ''))).filter(Boolean), 40 mergeable: data.mergeable ?? null, 41 isDraft: data.draft ?? false, 42 commitCount: data.commits, 43 requestedReviewers: (data.requested_reviewers ?? []).map((u) => u.login), 44 requestedTeams: (data.requested_teams ?? []).map((t) => t.name), 45 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- GitHub API can return null 46 mergeableState: data.mergeable_state ?? null, 47 autoMerge: data.auto_merge ? { method: data.auto_merge.merge_method } : null, 48 milestone: data.milestone ? { title: data.milestone.title, dueOn: data.milestone.due_on } : null, 49 }; 50 } 51 52 export async function getPrDiff(octokit: Octokit, owner: string, repo: string, pullNumber: number): Promise<string> { 53 const response = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { 54 owner, 55 repo, 56 pull_number: pullNumber, 57 headers: { 58 accept: 'application/vnd.github.v3.diff', 59 }, 60 }); 61 return response.data as unknown as string; 62 } 63 64 export async function getChangedFiles( 65 octokit: Octokit, 66 owner: string, 67 repo: string, 68 pullNumber: number 69 ): Promise<ChangedFile[]> { 70 const files: ChangedFile[] = []; 71 let page = 1; 72 // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- pagination loop 73 while (true) { 74 const { data } = await octokit.pulls.listFiles({ 75 owner, 76 repo, 77 pull_number: pullNumber, 78 per_page: 100, 79 page, 80 }); 81 for (const f of data) { 82 files.push({ 83 filename: f.filename, 84 status: f.status as ChangedFile['status'], 85 additions: f.additions, 86 deletions: f.deletions, 87 previous_filename: f.previous_filename, 88 }); 89 } 90 if (data.length < 100) break; 91 page++; 92 } 93 return files; 94 } 95 96 export async function getFileContent( 97 octokit: Octokit, 98 owner: string, 99 repo: string, 100 path: string, 101 ref: string 102 ): Promise<string | null> { 103 try { 104 const { data } = await octokit.repos.getContent({ owner, repo, path, ref }); 105 if (Array.isArray(data) || data.type !== 'file') return null; 106 if ('content' in data && data.content) { 107 return Buffer.from(data.content, 'base64').toString('utf-8'); 108 } 109 return null; 110 } catch { 111 return null; 112 } 113 } 114 115 export async function searchPullRequests(octokit: Octokit, login: string, limit = 30): Promise<PrSearchResult[]> { 116 const queries = [ 117 { q: `is:pr is:open author:${login}`, role: 'author' as const }, 118 { q: `is:pr is:open review-requested:${login}`, role: 'review-requested' as const }, 119 ]; 120 121 const results = await Promise.all( 122 queries.map(async ({ q, role }) => { 123 const { data } = await octokit.search.issuesAndPullRequests({ 124 q, 125 sort: 'updated', 126 order: 'desc', 127 per_page: limit, 128 }); 129 return data.items.map((item) => { 130 // repository_url looks like "https://api.github.com/repos/owner/name" 131 const repoParts = item.repository_url.split('/'); 132 const repoName = repoParts.at(-1) ?? ''; 133 const repoOwner = repoParts.at(-2) ?? ''; 134 return { 135 number: item.number, 136 title: item.title, 137 url: item.html_url, 138 repoOwner, 139 repoName, 140 author: item.user?.login ?? 'unknown', 141 updatedAt: item.updated_at, 142 isDraft: item.draft ?? false, 143 role, 144 }; 145 }); 146 }) 147 ); 148 149 // Deduplicate by URL (a PR can appear in both queries), prefer 'review-requested' role 150 const seen = new Map<string, PrSearchResult>(); 151 for (const list of results) { 152 for (const pr of list) { 153 const existing = seen.get(pr.url); 154 if (!existing || (existing.role === 'author' && pr.role === 'review-requested')) { 155 seen.set(pr.url, pr); 156 } 157 } 158 } 159 160 return Array.from(seen.values()) 161 .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) 162 .slice(0, limit); 163 } 164 165 export async function getCiStatus( 166 octokit: Octokit, 167 owner: string, 168 repo: string, 169 ref: string 170 ): Promise<{ checks: CiCheck[]; conclusion: 'success' | 'failure' | 'pending' | 'neutral' }> { 171 const { data } = await octokit.checks.listForRef({ owner, repo, ref, per_page: 100 }); 172 const checks: CiCheck[] = data.check_runs.map((run) => ({ 173 name: run.name, 174 status: run.status as CiCheck['status'], 175 conclusion: run.conclusion ?? null, 176 })); 177 178 let conclusion: 'success' | 'failure' | 'pending' | 'neutral' = 'success'; 179 if (checks.length === 0) { 180 conclusion = 'neutral'; 181 } else if ( 182 checks.some((c) => c.conclusion === 'failure' || c.conclusion === 'timed_out' || c.conclusion === 'cancelled') 183 ) { 184 conclusion = 'failure'; 185 } else if (checks.some((c) => c.status === 'in_progress' || c.status === 'queued')) { 186 conclusion = 'pending'; 187 } else if ( 188 checks.every((c) => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral') 189 ) { 190 conclusion = 'success'; 191 } else { 192 conclusion = 'neutral'; 193 } 194 195 return { checks, conclusion }; 196 } 197 198 export async function getReviewStatus( 199 octokit: Octokit, 200 owner: string, 201 repo: string, 202 pullNumber: number 203 ): Promise<ReviewSummary> { 204 const { data: reviews } = await octokit.pulls.listReviews({ 205 owner, 206 repo, 207 pull_number: pullNumber, 208 per_page: 100, 209 }); 210 211 // Keep only the latest review per reviewer 212 const latestByUser = new Map<string, string>(); 213 for (const r of reviews) { 214 const login = r.user?.login ?? 'unknown'; 215 const state = r.state; 216 if (state === 'DISMISSED' || state === 'PENDING') continue; 217 latestByUser.set(login, state); 218 } 219 220 let approved = 0; 221 let changesRequested = 0; 222 let commented = 0; 223 for (const state of latestByUser.values()) { 224 if (state === 'APPROVED') approved++; 225 else if (state === 'CHANGES_REQUESTED') changesRequested++; 226 else if (state === 'COMMENTED') commented++; 227 } 228 229 return { approved, changesRequested, commented }; 230 } 231 232 function extractImports(content: string, filePath: string): string[] { 233 const imports: string[] = []; 234 const dir = filePath.split('/').slice(0, -1).join('/'); 235 236 // Match ES import statements 237 const importRegex = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g; 238 let match; 239 while ((match = importRegex.exec(content)) !== null) { 240 const importPath = match[1]; 241 if (importPath.startsWith('.')) { 242 // Relative import — resolve to a file path 243 const resolved = resolveRelativePath(dir, importPath); 244 if (resolved) imports.push(resolved); 245 } 246 } 247 248 return imports; 249 } 250 251 function resolveRelativePath(dir: string, importPath: string): string | null { 252 const parts = (dir ? dir + '/' + importPath : importPath).split('/'); 253 const normalized: string[] = []; 254 for (const part of parts) { 255 if (part === '..') normalized.pop(); 256 else if (part !== '.') normalized.push(part); 257 } 258 const base = normalized.join('/'); 259 // Return without extension — caller will try common extensions 260 return base; 261 } 262 263 const TS_EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js']; 264 265 const SMART_IMPORTS_SYSTEM_PROMPT = `You are a code analysis tool. Given source files from a repository, identify all local/internal file imports. Return repo-relative file paths as a JSON array of strings. Nothing else. 266 267 Rules: 268 - Only include imports that reference files within the same repository 269 - Skip standard library, external packages, and framework imports 270 - Resolve relative imports to repo-relative paths using each file's location 271 - For C# \`using\` statements, infer the likely file path from the namespace (use the file's own namespace declaration for context) 272 - Include file extensions (e.g., .cs, .rs, .py, .go, .ts) 273 - Return unique paths only`; 274 275 async function extractImportsWithLLM( 276 changedFileContents: Record<string, string>, 277 changedFilePaths: string[], 278 providerName: Provider 279 ): Promise<string[]> { 280 const fileEntries = changedFilePaths 281 .filter((p) => changedFileContents[p]) 282 .map((p) => `--- ${p} ---\n${changedFileContents[p]}`) 283 .join('\n\n'); 284 285 if (!fileEntries) return []; 286 287 const provider = getProvider(providerName); 288 const quickEntry = provider.models.find((m) => m.quick) ?? provider.models[0]; 289 const quickModel = quickEntry.id; 290 291 try { 292 const result = await provider.quick({ 293 content: fileEntries, 294 systemPrompt: SMART_IMPORTS_SYSTEM_PROMPT, 295 model: quickModel, 296 }); 297 298 // Extract JSON array from response 299 const start = result.indexOf('['); 300 const end = result.lastIndexOf(']'); 301 if (start === -1 || end <= start) return []; 302 303 const parsed: unknown = JSON.parse(result.slice(start, end + 1)); 304 if (!Array.isArray(parsed)) return []; 305 return parsed.filter((p): p is string => typeof p === 'string'); 306 } catch (err) { 307 console.warn('[github] Smart import extraction failed, returning empty:', err); 308 return []; 309 } 310 } 311 312 export async function getNeighborFiles( 313 octokit: Octokit, 314 owner: string, 315 repo: string, 316 changedFilePaths: string[], 317 changedFileContents: Record<string, string>, 318 ref: string, 319 smartImportsProvider?: Provider 320 ): Promise<Record<string, string>> { 321 if (smartImportsProvider) { 322 console.log(`[github] Using smart (${smartImportsProvider}) import extraction`); 323 const importPaths = await extractImportsWithLLM(changedFileContents, changedFilePaths, smartImportsProvider); 324 console.log(`[github] ${smartImportsProvider} found ${importPaths.length} import(s):`, importPaths); 325 326 // Filter out paths already in the changed set 327 const neighborPaths = importPaths.filter((p) => !changedFilePaths.includes(p)); 328 console.log(`[github] ${neighborPaths.length} neighbor file(s) to fetch (after excluding changed files)`); 329 const pathsToFetch = neighborPaths.slice(0, 30); 330 331 const results: Record<string, string> = {}; 332 const concurrency = 5; 333 for (let i = 0; i < pathsToFetch.length; i += concurrency) { 334 const batch = pathsToFetch.slice(i, i + concurrency); 335 await Promise.all( 336 batch.map(async (filePath) => { 337 const content = await getFileContent(octokit, owner, repo, filePath, ref); 338 if (content !== null) { 339 results[filePath] = content; 340 } 341 }) 342 ); 343 } 344 console.log(`[github] Fetched ${Object.keys(results).length} neighbor file(s):`, Object.keys(results)); 345 return results; 346 } 347 348 // Default: existing regex-based extraction 349 const neighborPaths = new Set<string>(); 350 351 for (const filePath of changedFilePaths) { 352 const content = changedFileContents[filePath]; 353 if (!content) continue; 354 355 const imports = extractImports(content, filePath); 356 for (const imp of imports) { 357 const alreadyChanged = changedFilePaths.some((p) => p === imp || TS_EXTENSIONS.some((ext) => p === imp + ext)); 358 if (!alreadyChanged) { 359 neighborPaths.add(imp); 360 } 361 } 362 } 363 364 const results: Record<string, string> = {}; 365 const pathsToFetch = Array.from(neighborPaths).slice(0, 30); 366 367 const concurrency = 5; 368 for (let i = 0; i < pathsToFetch.length; i += concurrency) { 369 const batch = pathsToFetch.slice(i, i + concurrency); 370 await Promise.all( 371 batch.map(async (basePath) => { 372 for (const ext of TS_EXTENSIONS) { 373 const fullPath = basePath + ext; 374 const content = await getFileContent(octokit, owner, repo, fullPath, ref); 375 if (content !== null) { 376 results[fullPath] = content; 377 break; 378 } 379 } 380 }) 381 ); 382 } 383 384 return results; 385 }