♻️ refactor: update analysis.ts
swadhinbiswas committed 3 months ago
9219247
1 parent 1435dfa
commit 9219247
1 file changed
+344 -0
Showing 1 changed file+344-0
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/analysis.ts
+344-0
| @@ -0,0 +1,344 @@ | ||
1 | +/** | |
2 | + * PR Analysis API Endpoint | |
3 | + * Triggers and retrieves AI-powered code review analysis. | |
4 | + */ | |
5 | + | |
6 | +import type { APIRoute } from "astro"; | |
7 | +import { eq, and, desc } from "drizzle-orm"; | |
8 | +import { getDatabase, schema } from "@/db"; | |
9 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
10 | +import { withErrorHandler } from "@/lib/errors"; | |
11 | +import { getUserFromRequest } from "@/lib/auth"; | |
12 | +import { generateId } from "@/lib/utils"; | |
13 | +import { runAnalysisPipeline } from "@/lib/review/pipeline"; | |
14 | +import { generateAISummaries, generateFallbackSummaries } from "@/lib/review/summarizer"; | |
15 | +import { buildFullReviewPrompt } from "@/lib/review/prompts"; | |
16 | +import { parseAIConfigFromStorage } from "@/lib/ai-config"; | |
17 | +import { getAIAdapter } from "@/lib/ai"; | |
18 | +import { logger } from "@/lib/logger"; | |
19 | +import { existsSync } from "fs"; | |
20 | + | |
21 | +export const GET: APIRoute = withErrorHandler(async ({ params }) => { | |
22 | + const { owner, repo, number } = params!; | |
23 | + const prNumber = parseInt(number!); | |
24 | + | |
25 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
26 | + | |
27 | + const user = await db.query.users.findFirst({ | |
28 | + where: eq(schema.users.username, owner!), | |
29 | + }); | |
30 | + | |
31 | + if (!user) { | |
32 | + return new Response(JSON.stringify({ error: { message: "User not found" } }), { status: 404 }); | |
33 | + } | |
34 | + | |
35 | + const repository = await db.query.repositories.findFirst({ | |
36 | + where: and( | |
37 | + eq(schema.repositories.name, repo!), | |
38 | + eq(schema.repositories.ownerId, user.id), | |
39 | + ), | |
40 | + }); | |
41 | + | |
42 | + if (!repository) { | |
43 | + return new Response(JSON.stringify({ error: { message: "Repository not found" } }), { status: 404 }); | |
44 | + } | |
45 | + | |
46 | + const pr = await db.query.pullRequests.findFirst({ | |
47 | + where: and( | |
48 | + eq(schema.pullRequests.repositoryId, repository.id), | |
49 | + eq(schema.pullRequests.number, prNumber), | |
50 | + ), | |
51 | + }); | |
52 | + | |
53 | + if (!pr) { | |
54 | + return new Response(JSON.stringify({ error: { message: "Pull request not found" } }), { status: 404 }); | |
55 | + } | |
56 | + | |
57 | + const analysis = await db.query.prReviewAnalyses.findFirst({ | |
58 | + where: eq(schema.prReviewAnalyses.pullRequestId, pr.id), | |
59 | + orderBy: [desc(schema.prReviewAnalyses.createdAt)], | |
60 | + }); | |
61 | + | |
62 | + if (!analysis) { | |
63 | + return new Response(JSON.stringify({ data: null }), { status: 200 }); | |
64 | + } | |
65 | + | |
66 | + const inlineComments = await db.query.aiInlineComments.findMany({ | |
67 | + where: eq(schema.aiInlineComments.analysisId, analysis.id), | |
68 | + orderBy: [schema.aiInlineComments.filePath, schema.aiInlineComments.line], | |
69 | + }); | |
70 | + | |
71 | + return new Response(JSON.stringify({ | |
72 | + data: { | |
73 | + analysis: { | |
74 | + id: analysis.id, | |
75 | + status: analysis.status, | |
76 | + healthScore: analysis.healthScore, | |
77 | + healthGrade: analysis.healthGrade, | |
78 | + blastRadius: analysis.blastRadius, | |
79 | + architectureImpact: analysis.architectureImpact, | |
80 | + changeGroups: analysis.changeGroups, | |
81 | + complexityData: analysis.complexityData, | |
82 | + summaries: analysis.summaries, | |
83 | + filesAnalyzed: analysis.filesAnalyzed, | |
84 | + totalAdditions: analysis.totalAdditions, | |
85 | + totalDeletions: analysis.totalDeletions, | |
86 | + model: analysis.model, | |
87 | + provider: analysis.provider, | |
88 | + tokensUsed: analysis.tokensUsed, | |
89 | + errorMessage: analysis.errorMessage, | |
90 | + createdAt: analysis.createdAt, | |
91 | + completedAt: analysis.completedAt, | |
92 | + }, | |
93 | + inlineComments: inlineComments.map((c: any) => ({ | |
94 | + id: c.id, | |
95 | + filePath: c.filePath, | |
96 | + line: c.line, | |
97 | + endLine: c.endLine, | |
98 | + severity: c.severity, | |
99 | + type: c.type, | |
100 | + category: c.category, | |
101 | + title: c.title, | |
102 | + message: c.message, | |
103 | + suggestedFix: c.suggestedFix, | |
104 | + explanation: c.explanation, | |
105 | + confidence: c.confidence, | |
106 | + isResolved: c.isResolved, | |
107 | + isApplied: c.isApplied, | |
108 | + })), | |
109 | + }, | |
110 | + }), { status: 200 }); | |
111 | +}); | |
112 | + | |
113 | +export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | |
114 | + const { owner, repo, number } = params!; | |
115 | + const prNumber = parseInt(number!); | |
116 | + | |
117 | + const tokenPayload = await getUserFromRequest(request); | |
118 | + if (!tokenPayload) { | |
119 | + return new Response(JSON.stringify({ error: { message: "Authentication required" } }), { status: 401 }); | |
120 | + } | |
121 | + | |
122 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
123 | + | |
124 | + const user = await db.query.users.findFirst({ | |
125 | + where: eq(schema.users.username, owner!), | |
126 | + }); | |
127 | + | |
128 | + if (!user) { | |
129 | + return new Response(JSON.stringify({ error: { message: "User not found" } }), { status: 404 }); | |
130 | + } | |
131 | + | |
132 | + const repository = await db.query.repositories.findFirst({ | |
133 | + where: and( | |
134 | + eq(schema.repositories.name, repo!), | |
135 | + eq(schema.repositories.ownerId, user.id), | |
136 | + ), | |
137 | + }); | |
138 | + | |
139 | + if (!repository) { | |
140 | + return new Response(JSON.stringify({ error: { message: "Repository not found" } }), { status: 404 }); | |
141 | + } | |
142 | + | |
143 | + const pr = await db.query.pullRequests.findFirst({ | |
144 | + where: and( | |
145 | + eq(schema.pullRequests.repositoryId, repository.id), | |
146 | + eq(schema.pullRequests.number, prNumber), | |
147 | + ), | |
148 | + }); | |
149 | + | |
150 | + if (!pr) { | |
151 | + return new Response(JSON.stringify({ error: { message: "Pull request not found" } }), { status: 404 }); | |
152 | + } | |
153 | + | |
154 | + // Check if analysis is already running | |
155 | + const existingRunning = await db.query.prReviewAnalyses.findFirst({ | |
156 | + where: and( | |
157 | + eq(schema.prReviewAnalyses.pullRequestId, pr.id), | |
158 | + eq(schema.prReviewAnalyses.status, "running"), | |
159 | + ), | |
160 | + }); | |
161 | + | |
162 | + if (existingRunning) { | |
163 | + return new Response(JSON.stringify({ | |
164 | + data: { id: existingRunning.id, status: "running" }, | |
165 | + message: "Analysis already in progress", | |
166 | + }), { status: 200 }); | |
167 | + } | |
168 | + | |
169 | + // Create analysis record | |
170 | + const analysisId = generateId(); | |
171 | + await db.insert(schema.prReviewAnalyses).values({ | |
172 | + id: analysisId, | |
173 | + pullRequestId: pr.id, | |
174 | + repositoryId: repository.id, | |
175 | + status: "running", | |
176 | + baseSha: pr.baseSha, | |
177 | + headSha: pr.headSha, | |
178 | + triggeredById: tokenPayload.userId, | |
179 | + }); | |
180 | + | |
181 | + // Run analysis asynchronously | |
182 | + const repoPath = repository.diskPath; | |
183 | + runAnalysisAsync(analysisId, repoPath, pr.baseSha, pr.headSha, { | |
184 | + prTitle: pr.title, | |
185 | + prBody: pr.body || "", | |
186 | + repoName: repo!, | |
187 | + owner: owner!, | |
188 | + branch: pr.headBranch, | |
189 | + filesChanged: pr.changedFiles || 0, | |
190 | + additions: pr.additions || 0, | |
191 | + deletions: pr.deletions || 0, | |
192 | + }).catch((err: any) => { | |
193 | + logger.error({ err, analysisId }, "Analysis pipeline failed"); | |
194 | + }); | |
195 | + | |
196 | + return new Response(JSON.stringify({ | |
197 | + data: { id: analysisId, status: "running" }, | |
198 | + message: "Analysis started", | |
199 | + }), { status: 202 }); | |
200 | +}); | |
201 | + | |
202 | +async function runAnalysisAsync( | |
203 | + analysisId: string, | |
204 | + repoPath: string, | |
205 | + baseSha: string, | |
206 | + headSha: string, | |
207 | + options: { | |
208 | + prTitle: string; | |
209 | + prBody: string; | |
210 | + repoName: string; | |
211 | + owner: string; | |
212 | + branch: string; | |
213 | + filesChanged: number; | |
214 | + additions: number; | |
215 | + deletions: number; | |
216 | + }, | |
217 | +) { | |
218 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
219 | + | |
220 | + try { | |
221 | + if (!existsSync(repoPath)) { | |
222 | + throw new Error(`Repository path not found: ${repoPath}`); | |
223 | + } | |
224 | + | |
225 | + const analysis = await runAnalysisPipeline(repoPath, baseSha, headSha); | |
226 | + | |
227 | + // Generate summaries | |
228 | + let summaries; | |
229 | + try { | |
230 | + const aiConfig = parseAIConfigFromStorage(undefined as any); | |
231 | + if (aiConfig?.apiKeys) { | |
232 | + summaries = await generateAISummaries(analysis, options, aiConfig); | |
233 | + } else { | |
234 | + summaries = generateFallbackSummaries(analysis, options); | |
235 | + } | |
236 | + } catch { | |
237 | + summaries = generateFallbackSummaries(analysis, options); | |
238 | + } | |
239 | + | |
240 | + // Generate AI review suggestions | |
241 | + let aiSuggestions: any[] = []; | |
242 | + let rawAiResponse: any = null; | |
243 | + let tokensUsed = 0; | |
244 | + | |
245 | + try { | |
246 | + const aiConfig = parseAIConfigFromStorage(undefined as any); | |
247 | + if (aiConfig?.apiKeys && aiConfig.provider) { | |
248 | + const apiKey = (aiConfig.apiKeys as any)[aiConfig.provider]; | |
249 | + if (apiKey) { | |
250 | + const adapter = getAIAdapter(aiConfig.provider as any); | |
251 | + const diffContent = analysis.chunks.map((c: any) => c.content).join("\n\n").slice(0, 50000); | |
252 | + const prompt = buildFullReviewPrompt(analysis, options, diffContent); | |
253 | + | |
254 | + const result = await adapter.complete( | |
255 | + { system: "You are an expert code reviewer. Always respond with valid JSON only, no markdown fences.", user: prompt }, | |
256 | + { provider: aiConfig.provider as any, model: aiConfig.model, apiKey }, | |
257 | + ); | |
258 | + | |
259 | + rawAiResponse = result; | |
260 | + tokensUsed = result.usage?.totalTokens || 0; | |
261 | + | |
262 | + const parsed = parseJSONResponse(result.content); | |
263 | + if (parsed?.suggestions) { | |
264 | + aiSuggestions = parsed.suggestions; | |
265 | + } | |
266 | + } | |
267 | + } | |
268 | + } catch (err) { | |
269 | + logger.warn({ err, analysisId }, "AI review generation failed, continuing with analysis only"); | |
270 | + } | |
271 | + | |
272 | + // Update analysis record | |
273 | + await db.update(schema.prReviewAnalyses).set({ | |
274 | + status: "completed", | |
275 | + complexityData: analysis.complexity.aggregate as any, | |
276 | + dependencyGraph: analysis.depGraph as any, | |
277 | + architectureImpact: analysis.archImpact as any, | |
278 | + changeGroups: analysis.groups as any, | |
279 | + blastRadius: analysis.blastRadius as any, | |
280 | + healthScore: analysis.healthScore.score, | |
281 | + healthGrade: analysis.healthScore.grade, | |
282 | + summaries: summaries as any, | |
283 | + filesAnalyzed: analysis.chunks.length, | |
284 | + chunksProcessed: analysis.chunks.length, | |
285 | + totalAdditions: analysis.stats.totalAdditions, | |
286 | + totalDeletions: analysis.stats.totalDeletions, | |
287 | + tokensUsed, | |
288 | + rawAiResponse, | |
289 | + completedAt: new Date(), | |
290 | + }).where(eq(schema.prReviewAnalyses.id, analysisId)); | |
291 | + | |
292 | + // Get the pull request ID from the analysis record | |
293 | + const analysisRecord = await db.query.prReviewAnalyses.findFirst({ | |
294 | + where: eq(schema.prReviewAnalyses.id, analysisId), | |
295 | + }); | |
296 | + | |
297 | + // Insert inline comments | |
298 | + for (const suggestion of aiSuggestions) { | |
299 | + if (suggestion.path && suggestion.line) { | |
300 | + await db.insert(schema.aiInlineComments).values({ | |
301 | + id: generateId(), | |
302 | + analysisId, | |
303 | + pullRequestId: analysisRecord?.pullRequestId || "", | |
304 | + filePath: suggestion.path, | |
305 | + line: suggestion.line, | |
306 | + severity: suggestion.severity || "info", | |
307 | + type: suggestion.type || "suggestion", | |
308 | + category: suggestion.category || "code-quality", | |
309 | + title: suggestion.title || "Review suggestion", | |
310 | + message: suggestion.message || "", | |
311 | + suggestedFix: suggestion.suggestedFix, | |
312 | + explanation: suggestion.explanation, | |
313 | + confidence: Math.round((suggestion.confidence || 0.5) * 100), | |
314 | + }); | |
315 | + } | |
316 | + } | |
317 | + | |
318 | + logger.info({ analysisId, filesAnalyzed: analysis.chunks.length }, "Analysis completed successfully"); | |
319 | + } catch (err) { | |
320 | + logger.error({ err, analysisId }, "Analysis failed"); | |
321 | + | |
322 | + await db.update(schema.prReviewAnalyses).set({ | |
323 | + status: "failed", | |
324 | + errorMessage: err instanceof Error ? err.message : "Unknown error", | |
325 | + completedAt: new Date(), | |
326 | + }).where(eq(schema.prReviewAnalyses.id, analysisId)); | |
327 | + } | |
328 | +} | |
329 | + | |
330 | +function parseJSONResponse(text: string): any { | |
331 | + try { | |
332 | + return JSON.parse(text); | |
333 | + } catch { | |
334 | + const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/); | |
335 | + if (jsonMatch) { | |
336 | + try { return JSON.parse(jsonMatch[1]); } catch {} | |
337 | + } | |
338 | + const objMatch = text.match(/\{[\s\S]*\}/); | |
339 | + if (objMatch) { | |
340 | + try { return JSON.parse(objMatch[0]); } catch {} | |
341 | + } | |
342 | + return null; | |
343 | + } | |
344 | +} | |
0 | 345 | |