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

review-context.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.

review-context.tsBlame293 lines · 1 contributor
cc34156Claude1/**
2 * Context Restore for Stale Reviews.
3 *
4 * When a user opens a PR they previously reviewed (or started reviewing),
5 * this module provides an AI-generated summary of what changed since their
6 * last visit and where they left off. Shown as a dismissible banner on the
7 * PR detail page.
8 *
9 * Tracks visits via the `pr_visits` table (migration 0088). On each PR page
10 * load we upsert the visit timestamp so the next visit can compute the delta.
11 *
12 * The AI summary is only generated when:
13 * - The user has visited this PR before (non-first-visit)
14 * - The last visit was >4 hours ago
15 * - There are commits or new comments since the last visit
16 *
17 * Never throws — all paths are wrapped in try/catch.
18 */
19
20import { and, eq, gt, sql } from "drizzle-orm";
21import { db } from "../db";
22import { prComments, prVisits } from "../db/schema";
23import {
24 getAnthropic,
25 isAiAvailable,
26 MODEL_SONNET,
27 extractText,
28} from "./ai-client";
29import { commitsBetween } from "../git/repository";
30
31// ---------------------------------------------------------------------------
32// Public interface
33// ---------------------------------------------------------------------------
34
35export interface ReviewContext {
36 /** ISO string of the user's last visit */
37 lastVisitedAt: string;
38 /** Number of commits pushed since last visit */
39 commitsSince: number;
40 /** Number of new comments since last visit */
41 newComments: number;
42 /** Number of comments with unresolved threads */
43 unresolvedThreads: number;
44 /** AI-written 2-3 sentence summary of what changed */
45 summary: string;
46 /** Optional file:line hint for where to start reviewing */
47 suggestedStartLine?: string;
48}
49
50/** Minimum hours since last visit before we show the context banner. */
51const MIN_STALENESS_HOURS = 4;
52
53// ---------------------------------------------------------------------------
54// Visit tracking
55// ---------------------------------------------------------------------------
56
57/**
58 * Record (or refresh) the user's visit to this PR. Call on every PR page load
59 * for authenticated users. Uses an upsert so the row always reflects the
60 * most recent visit.
61 *
62 * Never throws.
63 */
64export async function recordPrVisit(
65 prId: string,
66 userId: string
67): Promise<void> {
68 try {
69 await db
70 .insert(prVisits)
71 .values({ prId, userId, visitedAt: new Date() })
72 .onConflictDoUpdate({
73 target: [prVisits.prId, prVisits.userId],
74 set: { visitedAt: new Date() },
75 });
76 } catch (err) {
77 console.error("[review-context] recordPrVisit error:", err);
78 }
79}
80
81// ---------------------------------------------------------------------------
82// Context computation
83// ---------------------------------------------------------------------------
84
85/**
86 * Compute context for a returning visitor. Returns null when:
87 * - No previous visit exists (first-time visit)
88 * - Visit was too recent (<4h ago)
89 * - No changes since last visit
90 * - AI unavailable AND no new comments (nothing to say)
91 *
92 * NOTE: Call `recordPrVisit` AFTER this function so the previous timestamp
93 * is still available when computing the delta.
94 */
95export async function getReviewContext(
96 prId: string,
97 userId: string,
98 opts?: {
99 ownerName?: string;
100 repoName?: string;
101 baseBranch?: string;
102 headBranch?: string;
103 }
104): Promise<ReviewContext | null> {
105 try {
106 // --- Look up previous visit ---
107 const [visit] = await db
108 .select()
109 .from(prVisits)
110 .where(and(eq(prVisits.prId, prId), eq(prVisits.userId, userId)))
111 .limit(1);
112
113 if (!visit) return null; // First visit
114
115 const lastVisitedAt = new Date(visit.visitedAt);
116 const hoursSince =
117 (Date.now() - lastVisitedAt.getTime()) / 3_600_000;
118
119 if (hoursSince < MIN_STALENESS_HOURS) return null; // Too recent
120
121 // --- Count new comments since last visit ---
122 let newComments = 0;
123 try {
124 const [countRow] = await db
125 .select({ count: sql<number>`count(*)::int` })
126 .from(prComments)
127 .where(
128 and(
129 eq(prComments.pullRequestId, prId),
130 gt(prComments.createdAt, lastVisitedAt)
131 )
132 );
133 newComments = countRow?.count || 0;
134 } catch {
135 /* non-fatal */
136 }
137
138 // --- Count commits since last visit (via git log) ---
139 let commitsSince = 0;
140 let changedFiles: string[] = [];
141 if (opts?.ownerName && opts?.repoName && opts?.baseBranch && opts?.headBranch) {
142 try {
143 const allCommits = await commitsBetween(
144 opts.ownerName,
145 opts.repoName,
146 opts.baseBranch,
147 opts.headBranch
148 );
149 const newCommits = allCommits.filter(
150 (c) => new Date(c.date) > lastVisitedAt
151 );
152 commitsSince = newCommits.length;
153 // Rough file list from commit messages (best-effort)
154 changedFiles = newCommits
155 .flatMap((c) => (c.message || "").split("\n"))
156 .filter((l) => l.startsWith("M\t") || l.startsWith("A\t") || l.startsWith("D\t"))
157 .map((l) => l.slice(2))
158 .filter(Boolean)
159 .slice(0, 5);
160 } catch {
161 /* swallow — git ops can fail */
162 }
163 }
164
165 // Nothing changed — skip the banner
166 if (commitsSince === 0 && newComments === 0) return null;
167
168 // --- AI summary (optional) ---
169 let summary = buildFallbackSummary(commitsSince, newComments, hoursSince);
170 let suggestedStartLine: string | undefined;
171
172 if (isAiAvailable() && (commitsSince > 0 || newComments > 0)) {
173 try {
174 const aiResult = await callClaudeForContext({
175 commitsSince,
176 newComments,
177 changedFiles,
178 hoursSince,
179 lastVisitedAt: lastVisitedAt.toISOString(),
180 });
181 if (aiResult) {
182 summary = aiResult.summary;
183 suggestedStartLine = aiResult.suggestedStartLine;
184 }
185 } catch (err) {
186 console.error("[review-context] Claude call failed:", err);
187 // Keep fallback summary
188 }
189 }
190
191 // Unresolved threads — comments without a resolution marker (heuristic)
192 const unresolvedThreads = newComments; // simple proxy for now
193
194 return {
195 lastVisitedAt: lastVisitedAt.toISOString(),
196 commitsSince,
197 newComments,
198 unresolvedThreads,
199 summary,
200 suggestedStartLine,
201 };
202 } catch (err) {
203 console.error("[review-context] getReviewContext error:", err);
204 return null;
205 }
206}
207
208// ---------------------------------------------------------------------------
209// Helpers
210// ---------------------------------------------------------------------------
211
212function buildFallbackSummary(
213 commitsSince: number,
214 newComments: number,
215 hoursSince: number
216): string {
217 const parts: string[] = [];
218 const hoursLabel =
219 hoursSince >= 24
220 ? `${Math.floor(hoursSince / 24)} day${Math.floor(hoursSince / 24) === 1 ? "" : "s"}`
221 : `${Math.round(hoursSince)} hour${Math.round(hoursSince) === 1 ? "" : "s"}`;
222
223 if (commitsSince > 0) {
224 parts.push(
225 `${commitsSince} new commit${commitsSince === 1 ? " was" : "s were"} pushed since your last visit ${hoursLabel} ago`
226 );
227 }
228 if (newComments > 0) {
229 parts.push(
230 `${newComments} new comment${newComments === 1 ? " was" : "s were"} added`
231 );
232 }
233 return parts.join(". ") + ".";
234}
235
236interface ClaudeContextResponse {
237 summary: string;
238 suggestedStartLine?: string;
239}
240
241async function callClaudeForContext(input: {
242 commitsSince: number;
243 newComments: number;
244 changedFiles: string[];
245 hoursSince: number;
246 lastVisitedAt: string;
247}): Promise<ClaudeContextResponse | null> {
248 const client = getAnthropic();
249 const hoursLabel =
250 input.hoursSince >= 24
251 ? `${Math.floor(input.hoursSince / 24)} day${Math.floor(input.hoursSince / 24) === 1 ? "" : "s"}`
252 : `${Math.round(input.hoursSince)} hours`;
253
254 const prompt = `A developer is returning to a pull request they last reviewed ${hoursLabel} ago.
255
256Changes since their last visit:
257- New commits pushed: ${input.commitsSince}
258- New comments added: ${input.newComments}
259${input.changedFiles.length > 0 ? `- Files changed: ${input.changedFiles.join(", ")}` : ""}
260
261Write a brief 2-3 sentence "welcome back" summary telling the reviewer what happened.
262If there are changed files, suggest one as the best starting point.
263
264Respond with JSON:
265{"summary": "...", "suggestedStartLine": "src/foo.ts:42 — reason (optional, omit if no files)"}
266
267Rules:
268- summary must be specific and human, not generic
269- No more than 3 sentences
270- If no changed files, omit suggestedStartLine
271- Return only valid JSON, no prose`;
272
273 const msg = await client.messages.create({
274 model: MODEL_SONNET,
275 max_tokens: 500,
276 messages: [{ role: "user", content: prompt }],
277 });
278
279 const text = extractText(msg);
280
281 // Extract JSON from response
282 try {
283 const jsonMatch = text.match(/\{[\s\S]*\}/);
284 if (jsonMatch) {
285 const parsed = JSON.parse(jsonMatch[0]) as ClaudeContextResponse;
286 if (typeof parsed.summary === "string") return parsed;
287 }
288 } catch {
289 /* fall through */
290 }
291
292 return null;
293}