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.
| cc34156 | 1 | /** |
| 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 | ||
| 20 | import { and, eq, gt, sql } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { prComments, prVisits } from "../db/schema"; | |
| 23 | import { | |
| 24 | getAnthropic, | |
| 25 | isAiAvailable, | |
| 26 | MODEL_SONNET, | |
| 27 | extractText, | |
| 28 | } from "./ai-client"; | |
| 29 | import { commitsBetween } from "../git/repository"; | |
| 30 | ||
| 31 | // --------------------------------------------------------------------------- | |
| 32 | // Public interface | |
| 33 | // --------------------------------------------------------------------------- | |
| 34 | ||
| 35 | export 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. */ | |
| 51 | const 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 | */ | |
| 64 | export 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 | */ | |
| 95 | export 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 | ||
| 212 | function 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 | ||
| 236 | interface ClaudeContextResponse { | |
| 237 | summary: string; | |
| 238 | suggestedStartLine?: string; | |
| 239 | } | |
| 240 | ||
| 241 | async 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 | ||
| 256 | Changes 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 | ||
| 261 | Write a brief 2-3 sentence "welcome back" summary telling the reviewer what happened. | |
| 262 | If there are changed files, suggest one as the best starting point. | |
| 263 | ||
| 264 | Respond with JSON: | |
| 265 | {"summary": "...", "suggestedStartLine": "src/foo.ts:42 — reason (optional, omit if no files)"} | |
| 266 | ||
| 267 | Rules: | |
| 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 | } |