Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

streaming-review.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

streaming-review.tsBlame283 lines · 1 contributor
53299fbClaude1/**
2 * Streaming PR review — real-time Claude review via SSE.
3 *
4 * Additive to the existing batch ai-review.ts. This module streams tokens
5 * from Claude as they arrive, allowing the browser to display the review
6 * in real time rather than waiting 10–30 seconds for a batch result.
7 *
8 * Usage:
9 * for await (const token of streamPrReview(...)) {
10 * // send token as SSE event
11 * }
12 */
13
14import { eq, and, like } from "drizzle-orm";
15import { db } from "../db";
16import { pullRequests, prComments } from "../db/schema";
17import { getRepoPath } from "../git/repository";
18import { getAnthropic, isAiAvailable, MODEL_SONNET } from "./ai-client";
19import { getBotUserIdOrFallback } from "./bot-user";
20
21// ---------------------------------------------------------------------------
22// Public types
23// ---------------------------------------------------------------------------
24
25export interface StreamingReviewToken {
26 type: "token" | "section_start" | "section_end" | "done" | "error";
27 content?: string; // for type="token"
28 section?: string; // for section_start/end: "summary" | "finding" | "verdict"
29 error?: string; // for type="error"
30}
31
32// ---------------------------------------------------------------------------
33// In-memory streaming state
34// ---------------------------------------------------------------------------
35
36/** PR IDs currently being streamed. Prevents duplicate concurrent streams. */
37const _streamingPrs = new Set<string>();
38
39/**
40 * Returns true when a streaming review is already in progress for the given
41 * PR id.
42 */
43export function isReviewStreaming(prId: string): boolean {
44 return _streamingPrs.has(prId);
45}
46
47// ---------------------------------------------------------------------------
48// Marker embedded in the saved comment body so we can detect duplicates
49// ---------------------------------------------------------------------------
50
51export const STREAM_REVIEW_MARKER = "<!-- gluecron:stream-review:v1 -->";
52
53/** Max bytes of diff sent to Claude. Matches the batch reviewer's cap. */
54const DIFF_BYTE_CAP = 100_000;
55
56// ---------------------------------------------------------------------------
57// Section detection helpers
58// ---------------------------------------------------------------------------
59
60/**
61 * Detect section transitions by examining accumulated lines.
62 *
63 * Sections (in order):
64 * 1. "summary" — opening paragraph
65 * 2. "finding" — numbered list items (lines starting with a digit + ".")
66 * 3. "verdict" — line starting with "Verdict:"
67 *
68 * Returns the section name when a transition is detected, or null otherwise.
69 */
70function detectSectionTransition(
71 accumulated: string,
72 prevSection: string | null
73): string | null {
74 // Split into lines so we can inspect the latest complete line.
75 const lines = accumulated.split("\n");
76 // Look at lines from the end (most recent first)
77 for (let i = lines.length - 1; i >= 0; i--) {
78 const line = lines[i].trim();
79 if (!line) continue;
80
81 // Verdict line — always wins
82 if (line.toLowerCase().startsWith("verdict:") && prevSection !== "verdict") {
83 return "verdict";
84 }
85 // Finding: numbered list item (e.g. "1. ", "2. ", "12. ")
86 if (/^\d+\.\s/.test(line) && prevSection === "summary") {
87 return "finding";
88 }
89 break; // Only inspect the last non-empty line
90 }
91 return null;
92}
93
94// ---------------------------------------------------------------------------
95// Core streaming generator
96// ---------------------------------------------------------------------------
97
98/**
99 * Start a streaming review for a PR.
100 *
101 * Yields `StreamingReviewToken` objects as Claude produces them.
102 * On completion, saves the full review text as a PR comment
103 * (idempotent — skipped when the marker already exists in comments).
104 */
105export async function* streamPrReview(
106 prId: string,
107 ownerName: string,
108 repoName: string,
109 baseBranch: string,
110 headBranch: string
111): AsyncGenerator<StreamingReviewToken> {
112 // Guard: AI must be configured
113 if (!isAiAvailable()) {
114 yield { type: "error", error: "AI not available — ANTHROPIC_API_KEY is not set" };
115 return;
116 }
117
118 // Guard: prevent duplicate concurrent streams
119 if (_streamingPrs.has(prId)) {
120 yield { type: "error", error: "A streaming review is already in progress for this PR" };
121 return;
122 }
123
124 // Idempotency: skip if a stream-review comment already exists
125 try {
126 const [existing] = await db
127 .select({ id: prComments.id })
128 .from(prComments)
129 .where(
130 and(
131 eq(prComments.pullRequestId, prId),
132 eq(prComments.isAiReview, true),
133 like(prComments.body, `%${STREAM_REVIEW_MARKER}%`)
134 )
135 )
136 .limit(1);
137 if (existing) {
138 yield { type: "error", error: "Streaming review already completed for this PR" };
139 return;
140 }
141 } catch {
142 // DB check failed — proceed optimistically
143 }
144
145 // Mark PR as streaming
146 _streamingPrs.add(prId);
147
148 // Accumulate full text for DB persistence
149 let accumulated = "";
150 let currentSection: string | null = null;
151
152 try {
153 // Compute diff via git
154 let diffText = "";
155 try {
156 const cwd = getRepoPath(ownerName, repoName);
157 const proc = Bun.spawn(
158 ["git", "diff", `${baseBranch}...${headBranch}`, "--"],
159 { cwd, stdout: "pipe", stderr: "pipe" }
160 );
161 const raw = await new Response(proc.stdout).text();
162 await proc.exited;
163 diffText = raw;
164 } catch {
165 diffText = "";
166 }
167
168 if (!diffText.trim()) {
169 yield { type: "error", error: "No diff found between branches — nothing to review" };
170 return;
171 }
172
173 // Cap diff size
174 if (diffText.length > DIFF_BYTE_CAP) {
175 diffText = diffText.slice(0, DIFF_BYTE_CAP);
176 }
177
178 // Build messages
179 const systemPrompt =
180 "You are a senior code reviewer. Review this PR diff section by section. " +
181 "Start with a one-paragraph summary of the overall change, then list specific findings " +
182 "(each as: file:line — issue description), then give a verdict " +
183 "(Approve / Request Changes / Comment). Be direct and specific. " +
184 "Format findings as a numbered list. Start the verdict line with 'Verdict:'.";
185
186 const userMessage = `PR diff:\n${diffText}`;
187
188 // Open the streaming request
189 const anthropic = getAnthropic();
190
191 // Emit the summary section start before streaming begins
192 yield { type: "section_start", section: "summary" };
193 currentSection = "summary";
194
2be65d0Claude195 const stream = anthropic.messages.stream({
53299fbClaude196 model: MODEL_SONNET,
197 max_tokens: 2048,
198 system: systemPrompt,
199 messages: [{ role: "user", content: userMessage }],
200 });
201
202 // Stream tokens
203 for await (const chunk of stream) {
204 if (
205 chunk.type === "content_block_delta" &&
206 chunk.delta.type === "text_delta"
207 ) {
208 const text = chunk.delta.text;
209 if (!text) continue;
210
211 accumulated += text;
212
213 // Check for section transitions
214 const newSection = detectSectionTransition(accumulated, currentSection);
215 if (newSection && newSection !== currentSection) {
216 // Close the old section
217 yield { type: "section_end", section: currentSection ?? "summary" };
218 // Open the new section
219 yield { type: "section_start", section: newSection };
220 currentSection = newSection;
221 }
222
223 // Yield the token
224 yield { type: "token", content: text };
225 }
226 }
227
228 // Close the final section
229 if (currentSection) {
230 yield { type: "section_end", section: currentSection };
231 }
232
233 // Persist the full review as a PR comment (idempotent)
234 if (accumulated.trim()) {
235 try {
236 // Re-check idempotency before insert
237 const [existingCheck] = await db
238 .select({ id: prComments.id })
239 .from(prComments)
240 .where(
241 and(
242 eq(prComments.pullRequestId, prId),
243 eq(prComments.isAiReview, true),
244 like(prComments.body, `%${STREAM_REVIEW_MARKER}%`)
245 )
246 )
247 .limit(1);
248
249 if (!existingCheck) {
250 // Resolve author for the comment
251 const [pr] = await db
252 .select({ authorId: pullRequests.authorId })
253 .from(pullRequests)
254 .where(eq(pullRequests.id, prId))
255 .limit(1);
256
257 if (pr) {
258 const commentAuthorId = await getBotUserIdOrFallback(pr.authorId);
259 const commentBody = `## AI Stream Review\n\n${accumulated}\n\n${STREAM_REVIEW_MARKER}`;
260 await db.insert(prComments).values({
261 pullRequestId: prId,
262 authorId: commentAuthorId,
263 isAiReview: true,
264 body: commentBody,
265 });
266 }
267 }
268 } catch (err) {
269 // Non-fatal — review was streamed, just not persisted
270 if (process.env.DEBUG_AI_REVIEW === "1") {
271 console.error("[streaming-review] failed to persist review comment:", err);
272 }
273 }
274 }
275
276 yield { type: "done" };
277 } catch (err) {
278 const message = err instanceof Error ? err.message : String(err);
279 yield { type: "error", error: message };
280 } finally {
281 _streamingPrs.delete(prId);
282 }
283}