/ lib / github.ts
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  }