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.tsBlame402 lines · 1 contributor
2be65d0Claude1/**
2 * Streaming PR review routes.
3 *
4 * Two endpoints:
5 *
6 * GET /:owner/:repo/pulls/:number/review/stream
7 * SSE endpoint — streams a real-time AI review of the PR diff.
8 * If a review is already in progress, sends a waiting message and
9 * polls every 2s until it finishes (max 60s), then streams the result.
10 * On completion, saves the full review as a PR comment (idempotent).
11 *
12 * GET /:owner/:repo/pulls/:number/review/stream-ui
13 * Returns an HTML fragment (<div id="stream-review">) with inline JS
14 * that opens an EventSource to ./review/stream and renders tokens in
15 * real-time. Can be embedded in any page via an iframe or fetch-insert.
16 *
17 * Neither endpoint modifies src/routes/pulls.tsx.
18 */
19
20import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users, pullRequests } from "../db/schema";
24import { softAuth } from "../middleware/auth";
25import type { AuthEnv } from "../middleware/auth";
26import {
27 streamPrReview,
28 isReviewStreaming,
29 type StreamingReviewToken,
30} from "../lib/streaming-review";
31
32const app = new Hono<AuthEnv>();
33
34// ---------------------------------------------------------------------------
35// Helpers
36// ---------------------------------------------------------------------------
37
38/** Resolve owner + repo from URL params. Returns null if not found. */
39async function resolveRepo(ownerName: string, repoName: string) {
40 const [owner] = await db
41 .select()
42 .from(users)
43 .where(eq(users.username, ownerName))
44 .limit(1);
45 if (!owner) return null;
46
47 const [repo] = await db
48 .select()
49 .from(repositories)
50 .where(
51 and(
52 eq(repositories.ownerId, owner.id),
53 eq(repositories.name, repoName)
54 )
55 )
56 .limit(1);
57 if (!repo) return null;
58
59 return { owner, repo };
60}
61
62/** Resolve a PR by repo id + PR number. Returns null if not found. */
63async function resolvePr(repositoryId: string, prNumber: number) {
64 const [pr] = await db
65 .select()
66 .from(pullRequests)
67 .where(
68 and(
69 eq(pullRequests.repositoryId, repositoryId),
70 eq(pullRequests.number, prNumber)
71 )
72 )
73 .limit(1);
74 return pr ?? null;
75}
76
77/** Encode a SSE data line from a StreamingReviewToken. */
78function encodeToken(token: StreamingReviewToken): string {
79 return `data: ${JSON.stringify(token)}\n\n`;
80}
81
82// ---------------------------------------------------------------------------
83// SSE stream endpoint
84// ---------------------------------------------------------------------------
85
86app.get("/:owner/:repo/pulls/:number/review/stream", softAuth, async (c) => {
87 const { owner: ownerName, repo: repoName, number: numberStr } = c.req.param();
88 const prNumber = parseInt(numberStr, 10);
89 if (isNaN(prNumber) || prNumber < 1) {
90 return c.json({ error: "Invalid PR number" }, 400);
91 }
92
93 // Resolve repo
94 const resolved = await resolveRepo(ownerName, repoName);
95 if (!resolved) {
96 return c.json({ error: "Repository not found" }, 404);
97 }
98 const { repo } = resolved;
99
100 // Resolve PR
101 const pr = await resolvePr(repo.id, prNumber);
102 if (!pr) {
103 return c.json({ error: "Pull request not found" }, 404);
104 }
105
106 const prId = pr.id;
107 const encoder = new TextEncoder();
108
109 const stream = new ReadableStream<Uint8Array>({
110 async start(controller) {
111 let closed = false;
112
113 const safeEnqueue = (chunk: string) => {
114 if (closed) return;
115 try {
116 controller.enqueue(encoder.encode(chunk));
117 } catch {
118 closed = true;
119 }
120 };
121
122 const close = () => {
123 if (closed) return;
124 closed = true;
125 try {
126 controller.close();
127 } catch {
128 // already closed
129 }
130 };
131
132 // Flush headers on proxy buffering
133 safeEnqueue(": open\n\n");
134
135 // If a review is already streaming, wait for it (max 60s, poll 2s)
136 if (isReviewStreaming(prId)) {
137 safeEnqueue(
138 encodeToken({
139 type: "token",
140 content: "A review is already in progress. Waiting for it to complete...\n",
141 })
142 );
143
144 const MAX_WAIT_MS = 60_000;
145 const POLL_MS = 2_000;
146 const deadline = Date.now() + MAX_WAIT_MS;
147
148 while (isReviewStreaming(prId) && Date.now() < deadline && !closed) {
149 await new Promise((r) => setTimeout(r, POLL_MS));
150 }
151
152 if (isReviewStreaming(prId)) {
153 safeEnqueue(
154 encodeToken({
155 type: "error",
156 error: "Timed out waiting for in-progress review to complete.",
157 })
158 );
159 close();
160 return;
161 }
162
163 // Review finished while we were waiting — inform the client
164 safeEnqueue(
165 encodeToken({
166 type: "done",
167 })
168 );
169 close();
170 return;
171 }
172
173 // Start the streaming review
174 try {
175 for await (const token of streamPrReview(
176 prId,
177 ownerName,
178 repoName,
179 pr.baseBranch,
180 pr.headBranch
181 )) {
182 if (closed) break;
183 safeEnqueue(encodeToken(token));
184 if (token.type === "done" || token.type === "error") {
185 break;
186 }
187 }
188 } catch (err) {
189 const message = err instanceof Error ? err.message : String(err);
190 safeEnqueue(encodeToken({ type: "error", error: message }));
191 }
192
193 close();
194 },
195 });
196
197 // Handle client disconnect via AbortSignal — the ReadableStream cancel
198 // is not directly wired, but closing the outer Response suffices for
199 // Bun's HTTP layer to GC the stream controller.
200 return new Response(stream, {
201 status: 200,
202 headers: {
203 "Content-Type": "text/event-stream; charset=utf-8",
204 "Cache-Control": "no-cache, no-transform",
205 "Connection": "keep-alive",
206 "X-Accel-Buffering": "no",
207 },
208 });
209});
210
211// ---------------------------------------------------------------------------
212// Embeddable widget endpoint
213// ---------------------------------------------------------------------------
214
215app.get("/:owner/:repo/pulls/:number/review/stream-ui", softAuth, async (c) => {
216 const { owner: ownerName, repo: repoName, number: numberStr } = c.req.param();
217 const prNumber = parseInt(numberStr, 10);
218 if (isNaN(prNumber) || prNumber < 1) {
219 return c.html("<p>Invalid PR number</p>", 400);
220 }
221
222 const streamUrl = `/${ownerName}/${repoName}/pulls/${prNumber}/review/stream`;
223
224 const html = `<!DOCTYPE html>
225<html lang="en">
226<head>
227<meta charset="utf-8">
228<meta name="viewport" content="width=device-width, initial-scale=1">
229<title>AI Stream Review — ${ownerName}/${repoName} #${prNumber}</title>
230<style>
231 * { box-sizing: border-box; margin: 0; padding: 0; }
232 body {
233 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
234 background: #0d1117;
235 color: #e6edf3;
236 padding: 16px;
237 }
238 #stream-review {
239 border: 1px solid #30363d;
240 border-radius: 8px;
241 background: #161b22;
242 padding: 16px;
243 position: relative;
244 }
245 .sr-header {
246 display: flex;
247 align-items: center;
248 gap: 10px;
249 margin-bottom: 14px;
250 font-size: 14px;
251 font-weight: 600;
252 color: #8b949e;
253 text-transform: uppercase;
254 letter-spacing: 0.04em;
255 }
256 .sr-spinner {
257 display: inline-block;
258 width: 14px; height: 14px;
259 border: 2px solid #30363d;
260 border-top-color: #58a6ff;
261 border-radius: 50%;
262 animation: spin 0.8s linear infinite;
263 }
264 @keyframes spin { to { transform: rotate(360deg); } }
265 .sr-section {
266 margin-bottom: 14px;
267 }
268 .sr-section-label {
269 font-size: 11px;
270 font-weight: 600;
271 text-transform: uppercase;
272 letter-spacing: 0.06em;
273 color: #58a6ff;
274 margin-bottom: 6px;
275 padding: 2px 6px;
276 border-left: 2px solid #58a6ff;
277 }
278 .sr-section-label.finding { color: #f0883e; border-left-color: #f0883e; }
279 .sr-section-label.verdict { color: #3fb950; border-left-color: #3fb950; }
280 pre.sr-content {
281 font-family: "SFMono-Regular", Consolas, monospace;
282 font-size: 13px;
283 line-height: 1.6;
284 white-space: pre-wrap;
285 word-break: break-word;
286 color: #e6edf3;
287 background: none;
288 border: none;
289 padding: 0;
290 }
291 .sr-done-msg {
292 font-size: 12px;
293 color: #3fb950;
294 margin-top: 12px;
295 padding: 6px 10px;
296 border: 1px solid #3fb950;
297 border-radius: 4px;
298 display: none;
299 }
300 .sr-error-msg {
301 font-size: 12px;
302 color: #f85149;
303 margin-top: 12px;
304 padding: 6px 10px;
305 border: 1px solid #f85149;
306 border-radius: 4px;
307 display: none;
308 }
309</style>
310</head>
311<body>
312<div id="stream-review">
313 <div class="sr-header">
314 <span class="sr-spinner" id="sr-spinner"></span>
315 <span>AI Stream Review</span>
316 </div>
317 <div id="sr-body">
318 <div class="sr-section" id="sr-section-summary">
319 <div class="sr-section-label summary">Summary</div>
320 <pre class="sr-content" id="sr-pre-summary"></pre>
321 </div>
322 <div class="sr-section" id="sr-section-finding" style="display:none">
323 <div class="sr-section-label finding">Findings</div>
324 <pre class="sr-content" id="sr-pre-finding"></pre>
325 </div>
326 <div class="sr-section" id="sr-section-verdict" style="display:none">
327 <div class="sr-section-label verdict">Verdict</div>
328 <pre class="sr-content" id="sr-pre-verdict"></pre>
329 </div>
330 </div>
331 <div class="sr-done-msg" id="sr-done">Review complete. Comment saved to PR.</div>
332 <div class="sr-error-msg" id="sr-error"></div>
333</div>
334
335<script>
336(function() {
337 var streamUrl = ${JSON.stringify(streamUrl)};
338 var currentSection = "summary";
339 var sectionPres = {
340 summary: document.getElementById("sr-pre-summary"),
341 finding: document.getElementById("sr-pre-finding"),
342 verdict: document.getElementById("sr-pre-verdict")
343 };
344 var sectionDivs = {
345 summary: document.getElementById("sr-section-summary"),
346 finding: document.getElementById("sr-section-finding"),
347 verdict: document.getElementById("sr-section-verdict")
348 };
349 var spinner = document.getElementById("sr-spinner");
350 var doneMsg = document.getElementById("sr-done");
351 var errorMsg = document.getElementById("sr-error");
352
353 function showSection(name) {
354 if (sectionDivs[name]) {
355 sectionDivs[name].style.display = "";
356 }
357 currentSection = name;
358 }
359
360 var es = new EventSource(streamUrl);
361
362 es.onmessage = function(e) {
363 var token;
364 try { token = JSON.parse(e.data); } catch { return; }
365
366 if (token.type === "token" && token.content) {
367 var pre = sectionPres[currentSection];
368 if (pre) pre.textContent += token.content;
369 } else if (token.type === "section_start" && token.section) {
370 showSection(token.section);
371 } else if (token.type === "done") {
372 es.close();
373 if (spinner) spinner.style.display = "none";
374 if (doneMsg) doneMsg.style.display = "";
375 } else if (token.type === "error") {
376 es.close();
377 if (spinner) spinner.style.display = "none";
378 if (errorMsg) {
379 errorMsg.textContent = "Error: " + (token.error || "Unknown error");
380 errorMsg.style.display = "";
381 }
382 }
383 };
384
385 es.onerror = function() {
386 es.close();
387 if (spinner) spinner.style.display = "none";
388 if (errorMsg) {
389 errorMsg.textContent = "Connection error. The review stream was interrupted.";
390 errorMsg.style.display = "";
391 }
392 };
393})();
394</script>
395</body>
396</html>`;
397
398 return c.html(html);
399});
400
401export { app as streamingReviewRoutes };
402export default app;