From 47a0dd6f5df94c4b12268251e1d28f77edf3e299 Mon Sep 17 00:00:00 2001
From: syn
Date: Sat, 18 Jul 2026 00:32:28 -0500
Subject: [PATCH 1/2] Speed up PR review thread loading
---
.changeset/quick-review-threads.md | 5 +
.plans/FRONTEND_STATIC_REVIEW_20260715.html | 23 ++-
.plans/PR_REVIEW_PERF_PLAN.md | 69 +++++++-
benchmarks/pr-review-performance.mjs | 116 ++++++++++---
src/github.test.ts | 183 ++++++++++++++++++++
src/modules/github/client.ts | 13 +-
src/modules/github/comments.ts | 149 +++++++++++++++-
src/modules/github/index.ts | 3 +
src/modules/github/schemas.ts | 43 +++++
src/modules/pr-events/service.ts | 47 ++++-
src/pr-event-state.test.ts | 19 ++
src/server/routes/github.ts | 2 +
web/src/api/github.ts | 7 +-
13 files changed, 627 insertions(+), 52 deletions(-)
create mode 100644 .changeset/quick-review-threads.md
diff --git a/.changeset/quick-review-threads.md b/.changeset/quick-review-threads.md
new file mode 100644
index 00000000..e4ce4a90
--- /dev/null
+++ b/.changeset/quick-review-threads.md
@@ -0,0 +1,5 @@
+---
+'neondeck': patch
+---
+
+Load PR review threads through a lean cancellable GitHub query and a short-lived mutation-aware cache so warm review surfaces open within the performance budget.
diff --git a/.plans/FRONTEND_STATIC_REVIEW_20260715.html b/.plans/FRONTEND_STATIC_REVIEW_20260715.html
index 0298004b..3e70208e 100644
--- a/.plans/FRONTEND_STATIC_REVIEW_20260715.html
+++ b/.plans/FRONTEND_STATIC_REVIEW_20260715.html
@@ -871,6 +871,9 @@ Executive summary
long tasks. That baseline remains retained. Stable thread identity, local metadata
reuse, and active-patch-first scheduling now produce one thread request, zero
abandoned patches, and a 798 ms first-patch median that passes all three samples.
+ The final thread-surface pass keeps the full Flue action intact while using an
+ 84.7% smaller web query plus a bounded 15-second cache; warm thread visibility now
+ measures 459 ms median and passes its target. The original results remain retained.
@@ -920,10 +923,10 @@
@@ -1352,6 +1362,7 @@
Recommended sequence
COMPLETED · MEASUREMENT + FIRST WAVE Performance measurement gate: the five-part fixture pass, chat isolation, thresholded Pierre CodeView, and real registered-PR trace are complete. Runtime topology, duplicate review DOM, and lazy registry remain deferred behind workload/budget evidence.
COMPLETED · REQUEST-PATH REMEDIATION P1 · Real PR request path: discuss the measured duplicate review-thread identity and repeated local metadata work, then implement only the approved fixes and repeat npm run bench:pr-review. The stable thread key and bounded revision-metadata cache are implemented and verified on the retained target.
COMPLETED · ACTIVE PATCH PRIORITY P1 · Active patch priority: discuss whether neighbor and unresolved-path prefetch should wait for the active patch. The retained remediated trace was 195 ms over target; the implemented active-first schedule now reaches a 798 ms median and passes all three samples while preserving zero abandoned reads.
+
COMPLETED · WARM THREAD PATH P1 · Review-thread latency: correct the harness's production-mode detection, measure request versus render cost, slim the web-only GitHub query, and add only a bounded 15-second mutation-aware cache after the lean query remained too slow. Warm thread visibility now reaches 459 ms median with an 84.7% smaller response; cold GitHub latency remains explicit.
COMPLETED · COPY/DISCLOSURE P2 · Copy/disclosure: row-specific action names and full-value affordances for operational text. Suggested command: $impeccable clarify.
COMPLETED · PR #140 P2 · Editor state ownership: keep new-comment, draft-edit, and thread-reply bodies scoped to their editor generation so older async completions cannot clear newer work.
COMPLETED · FINAL PASS P3 · Final pass: tabular metrics, timer cleanup, operation-scoped status, stable finding IDs, and config tightening. Suggested command: $impeccable polish.
diff --git a/.plans/PR_REVIEW_PERF_PLAN.md b/.plans/PR_REVIEW_PERF_PLAN.md
index cf89395a..79f02c1f 100644
--- a/.plans/PR_REVIEW_PERF_PLAN.md
+++ b/.plans/PR_REVIEW_PERF_PLAN.md
@@ -1,6 +1,6 @@
# PR Review / File Tree Performance Plan
-Status: phases 1–5 implemented; real-PR verification, request-path remediation, and active-patch prioritization complete; cold-fetch decision remains
+Status: phases 1–5 implemented; real-PR verification, request-path remediation, active-patch prioritization, and warm review-thread remediation complete; tree and cold-fetch decisions remain
Prior art: `.plans/archived/DIFF_UI_PLAN.md`, `.plans/archived/DIFF_REVIEW.md`
## 2026-07-17 reconciliation
@@ -266,6 +266,59 @@ but before the custom element painted. A direct DevTools trace measured 498 ms
LCP, 0.00 CLS, and a 36 ms Pierre forced-reflow total with no estimated
user-visible savings.
+## 2026-07-18 review-thread surface result
+
+A production-only follow-up isolated the remaining thread delay. The previous
+1,724 ms result is retained above, but the rerun found that its browser origin
+included the Vite development client despite being described as production.
+React Strict Mode could start and abort an initial request in that mode. The
+benchmark now detects Vite development mode and refuses it unless
+`--allow-development` is explicit. It also records failed and aborted API
+requests plus thread request start, response end, duration, bytes, and
+post-response render time.
+
+The clean production baseline still missed: one 381 KB thread response took
+about 810 ms directly and thread visibility measured 1,644 ms median. Three
+instrumented reloads showed a roughly 362 ms request start, 1,011 ms request
+duration, and only 11 ms of React work after the response. This confirmed the
+GitHub GraphQL path, not TanStack Query, React, or Pierre rendering, as the
+bottleneck.
+
+The review surface now uses a dedicated GitHub query that omits diff hunks,
+review/database ids, and pull-request backreferences that the UI does not read.
+The full Flue action continues using the full-fidelity query. The HTTP response
+also omits duplicate unresolved-thread and unresolved-comment collections; the
+client derives unresolved threads from the canonical list. The lean uncached
+run reduced thread transfer from 381,244 B to 58,240 B, but still measured
+1,511 ms thread visibility because the GitHub request remained 906–1,119 ms.
+
+Because the lean query could not reach the warm budget alone, it is backed by a
+small in-process cache: 15-second TTL, 16 entries, token-scoped keys, and
+explicit invalidation after review submission, thread reply, and
+resolve/unresolve. Reads invalidated while in flight are not stored. In-flight
+requests are not shared, so one browser cancellation cannot cancel another
+caller's work. Browser cancellation is propagated through the GraphQL request
+to GitHub.
+
+| Path | Active priority | Lean, no cache | Lean + warm cache | Change / verdict |
+| ------------------------------ | --------------: | -------------: | ----------------: | ------------------------------------- |
+| Production tree visible | 612 ms | 639 ms | 642 ms | Still misses; separate tree follow-up |
+| Production first patch visible | 798 ms | 924 ms | 934 ms | Median passes |
+| Production threads visible | 1,724 ms | 1,511 ms | 459 ms | 73.4% faster; median passes |
+| Initial backend thread read | 917 ms | 684 ms | 655 ms | Cold GitHub round trip remains |
+| Warm backend thread read | not recorded | not applicable | 6.8 ms | Short-lived in-process reuse |
+| Thread transfer per sample | 381,244 B | 58,240 B | 58,240 B | 84.7% smaller |
+| Total API transfer per sample | 742,850 B | 400,821 B | 400,821 B | 46.0% smaller |
+| Thread render after response | not recorded | 16.8 ms | 6.5 ms | Rendering remains negligible |
+| Aborted or failed API requests | 0 | 0 | 0 | Stable production path |
+
+The final three thread samples were 608, 452, and 459 ms. The first sample's
+604 ms FCP made a sub-500 ms thread paint impossible even though its cached
+thread request took 23 ms; the median satisfies the warm harness target. A
+separate final DevTools trace measured 530 ms LCP, 0.00 CLS, one successful
+thread request, and zero estimated FCP/LCP savings from render-blocking
+resources. The cold first read remains explicitly outside the warm-cache pass.
+
## Confirmed follow-up candidates and remediation status
1. **Completed — stabilize review-thread identity.** `reviewThreads(pr)`
@@ -283,15 +336,21 @@ user-visible savings.
isolated from adjacent, draft, and unresolved background reads until it
settles. The next run reached a 798 ms median and passed the target in all
three samples without abandoned reads.
-4. **Discuss later — revisit cold fetch.** The 4.98-second object fetch misses the target,
+4. **Completed — slim and briefly reuse review-surface threads.** The web path
+ now uses an 84.7% smaller query response plus a bounded 15-second cache with
+ mutation invalidation and race protection. Production thread visibility is
+ 459 ms median, while the full-fidelity Flue action is unchanged.
+5. **Discuss later — revisit cold fetch.** The 4.98-second object fetch misses the target,
but it is a one-time revision cost. Separate network fetch time from local
metadata time before changing refspecs or the `<3s` budget.
Acceptance is partial on the same real target: duplicate thread requests and
settlement-driven abandoned patch reads are eliminated, the first-patch browser
-budget now passes, backend targets pass, and fallback code is unchanged. The
-tree, threads, and one-time cold-object budgets still miss and remain separate
-follow-ups. Raw baseline, remediation, and active-priority results are
+budget now passes, warm thread visibility passes on the median, backend targets
+pass, and fallback code is unchanged. The tree and one-time cold-object budgets
+still miss and remain separate follow-ups; a cold GitHub thread read also
+remains slower than the warm UI budget. Raw baseline, remediation, and
+active-priority results are
gitignored at
`benchmarks/results/pr-12204-real-local.json` and
`benchmarks/results/pr-12204-remediation-local.json`, and
diff --git a/benchmarks/pr-review-performance.mjs b/benchmarks/pr-review-performance.mjs
index d7b10993..7b628399 100644
--- a/benchmarks/pr-review-performance.mjs
+++ b/benchmarks/pr-review-performance.mjs
@@ -14,6 +14,7 @@ const baseRef = readStringArg('--base-ref', 'main');
const title = readStringArg('--title', `${repo}#${number}`);
const samples = readPositiveIntegerArg('--samples', 3);
const includeGitHubFallback = args.includes('--include-github-fallback');
+const allowDevelopment = args.includes('--allow-development');
const outPath = readStringArg(
'--out',
'benchmarks/results/pr-review-real-local.json',
@@ -21,7 +22,12 @@ const outPath = readStringArg(
const [owner, name] = parseRepo(repo);
const target = { repo, number, head, base, baseRef, title };
-await assertServerAvailable(origin);
+const serverMode = await inspectServer(origin);
+if (serverMode === 'development' && !allowDevelopment) {
+ throw new Error(
+ `${origin} is serving the Vite development client. Run npm run build:dashboard followed by npm start, or pass --allow-development for a diagnostic run.`,
+ );
+}
const initialAuto = await measureJson(fileListUrl('auto'));
const files = initialAuto.json.data?.files ?? [];
@@ -41,13 +47,27 @@ const activeIndex = files.findIndex((file) => file.path === activeFile.path);
const warmFirstPatch = await repeat(samples, () =>
measureJson(fileDiffUrl(activeFile.path, 'auto')),
);
-const threads = await measureJson(`${origin}/api/github/prs/review-threads`, {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ repo, prNumber: number }),
-});
-const threadData = threads.json.data ?? {};
-const unresolvedPaths = (threadData.unresolvedReviewThreads ?? [])
+const initialThreads = await measureJson(
+ `${origin}/api/github/prs/review-threads`,
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ repo, prNumber: number }),
+ },
+);
+const warmReviewThreads = await repeat(samples, () =>
+ measureJson(`${origin}/api/github/prs/review-threads`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ repo, prNumber: number }),
+ }),
+);
+const threadData = initialThreads.json.data ?? {};
+const reviewThreads = threadData.reviewThreads ?? [];
+const unresolvedReviewThreads =
+ threadData.unresolvedReviewThreads ??
+ reviewThreads.filter((thread) => !thread.isResolved);
+const unresolvedPaths = unresolvedReviewThreads
.map((thread) => thread.path ?? thread.comments?.[0]?.path)
.filter(Boolean);
const fanoutPaths = [
@@ -83,7 +103,7 @@ try {
measureBrowserReview(browser, {
origin,
target,
- threadCount: threadData.reviewThreads?.length ?? 0,
+ threadCount: reviewThreads.length,
}),
);
} finally {
@@ -91,7 +111,7 @@ try {
}
const output = {
- version: 2,
+ version: 3,
generatedAt: new Date().toISOString(),
environment: {
platform: process.platform,
@@ -99,6 +119,7 @@ const output = {
node: process.version,
browser: 'Playwright Chromium',
origin,
+ serverMode,
samples,
note: 'Machine-local real-PR evidence; not a CI performance gate.',
},
@@ -107,8 +128,8 @@ const output = {
fileCount: files.length,
additions: initialAuto.json.data?.diffSummary?.additions ?? null,
deletions: initialAuto.json.data?.diffSummary?.deletions ?? null,
- reviewThreads: threadData.reviewThreads?.length ?? 0,
- unresolvedReviewThreads: threadData.unresolvedReviewThreads?.length ?? 0,
+ reviewThreads: reviewThreads.length,
+ unresolvedReviewThreads: unresolvedReviewThreads.length,
},
budgets: {
warmTreeVisibleMs: 500,
@@ -128,9 +149,14 @@ const output = {
...aggregateRequests(warmFirstPatch),
},
reviewThreads: {
- ...requestSummary(threads),
- total: threadData.reviewThreads?.length ?? 0,
- unresolved: threadData.unresolvedReviewThreads?.length ?? 0,
+ initial: {
+ ...requestSummary(initialThreads),
+ warning:
+ 'This is cold only when the in-process review-thread cache was empty before the run.',
+ },
+ warm: aggregateRequests(warmReviewThreads),
+ total: reviewThreads.length,
+ unresolved: unresolvedReviewThreads.length,
unresolvedPaths: [...new Set(unresolvedPaths)],
},
concurrentFanout,
@@ -208,6 +234,14 @@ async function measureBrowserReview(
colorScheme: 'dark',
});
const page = await context.newPage();
+ const failedApiRequests = [];
+ page.on('requestfailed', (request) => {
+ if (!request.url().includes('/api/github/')) return;
+ failedApiRequests.push({
+ url: request.url(),
+ errorText: request.failure()?.errorText ?? 'unknown',
+ });
+ });
const url = new URL('/review', browserOrigin);
url.searchParams.set('repo', browserTarget.repo);
url.searchParams.set('number', String(browserTarget.number));
@@ -296,7 +330,7 @@ async function measureBrowserReview(
{ timeout: 15_000 },
);
await page.waitForTimeout(3_000);
- return await page.evaluate(() => {
+ const browserResult = await page.evaluate(() => {
const state = window.__NEONDECK_REAL_PR_PERF__;
const resources = performance
.getEntriesByType('resource')
@@ -310,6 +344,10 @@ async function measureBrowserReview(
const firstContentfulPaint = performance.getEntriesByName(
'first-contentful-paint',
)[0];
+ const threadResponseEndMs = Math.max(
+ 0,
+ ...threadRequests.map((entry) => entry.responseEnd),
+ );
return {
treeVisibleMs: state.treeVisibleMs,
firstPatchMs: state.firstPatchMs,
@@ -332,19 +370,47 @@ async function measureBrowserReview(
(sum, entry) => sum + entry.transferSize,
0,
),
+ threadEncodedBodyBytes: threadRequests.reduce(
+ (sum, entry) => sum + entry.encodedBodySize,
+ 0,
+ ),
+ threadRequestStartMs:
+ threadRequests.length > 0
+ ? Math.min(...threadRequests.map((entry) => entry.startTime))
+ : 0,
+ threadResponseEndMs,
+ threadRequestDurationMs: Math.max(
+ 0,
+ ...threadRequests.map((entry) => entry.duration),
+ ),
+ threadRenderAfterResponseMs:
+ threadResponseEndMs > 0
+ ? Math.max(0, state.threadsVisibleMs - threadResponseEndMs)
+ : 0,
patchRequestCount: patchRequests.length,
patchRequestsStartedBeforeFirstPatch: patchRequests.filter(
(entry) => entry.startTime < state.firstPatchMs,
).length,
- abortedPatchRequestCount: patchRequests.filter(
- (entry) => entry.transferSize === 0,
- ).length,
lastApiResponseMs: Math.max(
0,
...resources.map((entry) => entry.responseEnd),
),
};
});
+ const abortedRequests = failedApiRequests.filter((request) =>
+ request.errorText.includes('ERR_ABORTED'),
+ );
+ return {
+ ...browserResult,
+ failedApiRequestCount: failedApiRequests.length,
+ abortedApiRequestCount: abortedRequests.length,
+ abortedThreadRequestCount: abortedRequests.filter((request) =>
+ request.url.endsWith('/review-threads'),
+ ).length,
+ abortedPatchRequestCount: abortedRequests.filter((request) =>
+ request.url.includes('/files/diff?'),
+ ).length,
+ };
} finally {
await context.close();
}
@@ -379,8 +445,16 @@ function aggregateBrowserSamples(browserResults) {
'apiTransferBytes',
'threadRequestCount',
'threadTransferBytes',
+ 'threadEncodedBodyBytes',
+ 'threadRequestStartMs',
+ 'threadResponseEndMs',
+ 'threadRequestDurationMs',
+ 'threadRenderAfterResponseMs',
'patchRequestCount',
'patchRequestsStartedBeforeFirstPatch',
+ 'failedApiRequestCount',
+ 'abortedApiRequestCount',
+ 'abortedThreadRequestCount',
'abortedPatchRequestCount',
'lastApiResponseMs',
].map((key) => [
@@ -423,11 +497,13 @@ async function repeat(count, fn) {
return values;
}
-async function assertServerAvailable(serverOrigin) {
+async function inspectServer(serverOrigin) {
const response = await fetch(serverOrigin);
if (!response.ok) {
throw new Error(`${serverOrigin} returned ${response.status}.`);
}
+ const html = await response.text();
+ return html.includes('/@vite/client') ? 'development' : 'production';
}
function parseRepo(value) {
diff --git a/src/github.test.ts b/src/github.test.ts
index 208786a4..a997598e 100644
--- a/src/github.test.ts
+++ b/src/github.test.ts
@@ -6,14 +6,17 @@ import {
addPrReviewDraftComment,
buildPullRequestQueries,
clearGitHubPullRequestQueueCache,
+ clearPullRequestReviewSurfaceThreadCache,
deletePrReviewNeonSeedsForComments,
fetchFailingCheckFacts,
fetchGitHubIssues,
fetchCheckSummary,
fetchPullRequestFiles,
+ fetchPullRequestReviewSurfaceThreadsWithMetadata,
fetchPullRequestReviewThreads,
fetchPullRequestReviewThreadsWithMetadata,
fetchPullRequestQueue,
+ invalidatePullRequestReviewSurfaceThreadCache,
postPullRequestComment,
readLivePrReviewDraft,
recordPrReviewNeonSeed,
@@ -35,6 +38,7 @@ const tempRoots: string[] = [];
afterEach(async () => {
globalThis.fetch = originalFetch;
clearGitHubPullRequestQueueCache();
+ clearPullRequestReviewSurfaceThreadCache();
vi.restoreAllMocks();
await Promise.all(
tempRoots
@@ -786,6 +790,185 @@ describe('github foundation', () => {
});
});
+ it('uses a lean review-thread query for the interactive review surface', async () => {
+ const fetchedBodies: Array<{ query?: string }> = [];
+ const controller = new AbortController();
+ const requestSignals: AbortSignal[] = [];
+ globalThis.fetch = vi.fn
(async (_input, init) => {
+ if (init?.signal) requestSignals.push(init.signal);
+ fetchedBodies.push(JSON.parse(String(init?.body ?? '{}')));
+ return jsonResponse({
+ data: {
+ repository: {
+ pullRequest: {
+ reviewThreads: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [
+ {
+ id: 'thread-1',
+ isResolved: false,
+ isOutdated: false,
+ path: 'src/app.ts',
+ line: 12,
+ originalLine: null,
+ diffSide: 'RIGHT',
+ comments: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [
+ {
+ id: 'comment-1',
+ body: 'Surface comment',
+ url: 'https://example.test/comment-1',
+ author: { login: 'reviewer' },
+ createdAt: '2026-06-30T20:05:00Z',
+ updatedAt: '2026-06-30T20:05:00Z',
+ path: 'src/app.ts',
+ line: 12,
+ originalLine: null,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+ });
+ });
+
+ await expect(
+ fetchPullRequestReviewSurfaceThreadsWithMetadata({
+ token: 'token',
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ signal: controller.signal,
+ }),
+ ).resolves.toMatchObject({
+ reviewThreads: [
+ {
+ id: 'thread-1',
+ pullRequestRepo: null,
+ pullRequestNumber: null,
+ comments: [
+ {
+ id: 'comment-1',
+ databaseId: null,
+ diffHunk: null,
+ reviewId: null,
+ },
+ ],
+ },
+ ],
+ truncated: false,
+ });
+ controller.abort();
+ expect(requestSignals[0]?.aborted).toBe(true);
+ await fetchPullRequestReviewSurfaceThreadsWithMetadata({
+ token: 'token',
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ });
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
+ invalidatePullRequestReviewSurfaceThreadCache({
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ });
+ await fetchPullRequestReviewSurfaceThreadsWithMetadata({
+ token: 'token',
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ });
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
+ expect(fetchedBodies[0]?.query).toContain(
+ 'NeondeckPullRequestReviewSurfaceThreads',
+ );
+ expect(fetchedBodies[0]?.query).not.toContain('diffHunk');
+ expect(fetchedBodies[0]?.query).not.toContain('pullRequestReview');
+ expect(fetchedBodies[0]?.query).not.toContain('databaseId');
+ });
+
+ it('does not cache a review-thread read invalidated while in flight', async () => {
+ const body = {
+ data: {
+ repository: {
+ pullRequest: {
+ reviewThreads: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [],
+ },
+ },
+ },
+ },
+ };
+ let resolveFirst!: (response: Response) => void;
+ let calls = 0;
+ globalThis.fetch = vi.fn(async () => {
+ calls += 1;
+ if (calls === 1) {
+ return new Promise((resolve) => {
+ resolveFirst = resolve;
+ });
+ }
+ return jsonResponse(body);
+ });
+
+ const first = fetchPullRequestReviewSurfaceThreadsWithMetadata({
+ token: 'token',
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ });
+ await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalledTimes(1));
+ invalidatePullRequestReviewSurfaceThreadCache({
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ });
+ resolveFirst(jsonResponse(body));
+ await first;
+ await fetchPullRequestReviewSurfaceThreadsWithMetadata({
+ token: 'token',
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ });
+
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
+ });
+
+ it('preserves caller cancellation instead of reporting a GitHub timeout', async () => {
+ globalThis.fetch = vi.fn(async (_input, init) => {
+ const signal = init?.signal;
+ if (!signal) throw new Error('Expected a request signal.');
+ return new Promise((_resolve, reject) => {
+ if (signal.aborted) {
+ reject(signal.reason);
+ return;
+ }
+ signal.addEventListener('abort', () => reject(signal.reason), {
+ once: true,
+ });
+ });
+ });
+ const controller = new AbortController();
+ const request = fetchPullRequestReviewSurfaceThreadsWithMetadata({
+ token: 'token',
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 124,
+ signal: controller.signal,
+ });
+
+ controller.abort();
+
+ await expect(request).rejects.toMatchObject({ name: 'AbortError' });
+ });
+
it('logs when review thread pagination reaches the page cap', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
let page = 0;
diff --git a/src/modules/github/client.ts b/src/modules/github/client.ts
index 84bf6035..aecc839c 100644
--- a/src/modules/github/client.ts
+++ b/src/modules/github/client.ts
@@ -22,10 +22,12 @@ export async function githubGraphqlFetch(
token: string,
query: string,
variables: Record,
+ options: { signal?: AbortSignal } = {},
) {
const response = await githubFetch(token, 'https://api.github.com/graphql', {
method: 'POST',
body: JSON.stringify({ query, variables }),
+ signal: options.signal,
});
const data = await response.json();
const parsed = v.parse(githubGraphqlBaseResponseSchema, data);
@@ -44,6 +46,7 @@ export async function githubFetch(
init: RequestInit = {},
) {
let response: Response;
+ const timeoutSignal = AbortSignal.timeout(githubRequestTimeoutMs);
try {
response = await fetch(url, {
...init,
@@ -55,10 +58,16 @@ export async function githubFetch(
'User-Agent': 'neondeck',
'X-GitHub-Api-Version': '2022-11-28',
},
- signal: AbortSignal.timeout(githubRequestTimeoutMs),
+ signal: init.signal
+ ? AbortSignal.any([init.signal, timeoutSignal])
+ : timeoutSignal,
});
} catch (error) {
- if (isRequestTimeout(error)) {
+ if (
+ timeoutSignal.aborted &&
+ !init.signal?.aborted &&
+ isRequestTimeout(error)
+ ) {
throw new Error(
`GitHub request timed out after ${Math.round(githubRequestTimeoutMs / 1000)}s`,
);
diff --git a/src/modules/github/comments.ts b/src/modules/github/comments.ts
index 81bb5b4a..30c44300 100644
--- a/src/modules/github/comments.ts
+++ b/src/modules/github/comments.ts
@@ -1,3 +1,4 @@
+import { createHash } from 'node:crypto';
import * as v from 'valibot';
import { encodePathSegment, githubFetch, githubGraphqlFetch } from './client';
import {
@@ -6,6 +7,7 @@ import {
githubReviewThreadCommentsGraphqlResponseSchema,
githubReviewThreadsGraphqlResponseSchema,
pullRequestReviewThreadNodeQuery,
+ pullRequestReviewSurfaceThreadsQuery,
pullRequestReviewThreadsQuery,
reviewThreadCommentsQuery,
} from './schemas';
@@ -17,6 +19,22 @@ import type {
GitHubReviewThreadGraphqlNode,
} from './schemas';
+const reviewSurfaceCacheTtlMs = 15_000;
+const reviewSurfaceCacheMaxEntries = 16;
+const reviewSurfaceCache = new Map();
+const reviewSurfaceTargetEpochs = new Map();
+
+type ReviewThreadsWithMetadata = {
+ reviewThreads: GitHubPullRequestReviewThread[];
+ truncated: boolean;
+};
+
+type CachedReviewSurfaceThreads = {
+ targetKey: string;
+ expiresAt: number;
+ value: ReviewThreadsWithMetadata;
+};
+
export async function postPullRequestComment(options: {
token: string;
owner: string;
@@ -53,6 +71,7 @@ export async function fetchPullRequestReviewThreads(options: {
owner: string;
repo: string;
number: number;
+ signal?: AbortSignal;
}): Promise {
return (await fetchPullRequestReviewThreadsWithMetadata(options))
.reviewThreads;
@@ -63,9 +82,111 @@ export async function fetchPullRequestReviewThreadsWithMetadata(options: {
owner: string;
repo: string;
number: number;
+ signal?: AbortSignal;
}): Promise<{
reviewThreads: GitHubPullRequestReviewThread[];
truncated: boolean;
+}> {
+ return fetchReviewThreadsWithQuery(options, pullRequestReviewThreadsQuery);
+}
+
+export async function fetchPullRequestReviewSurfaceThreadsWithMetadata(options: {
+ token: string;
+ owner: string;
+ repo: string;
+ number: number;
+ signal?: AbortSignal;
+}): Promise {
+ const targetKey = reviewSurfaceTargetKey(options);
+ const cacheKey = `${targetKey}\u0000${tokenFingerprint(options.token)}`;
+ const cached = reviewSurfaceCache.get(cacheKey);
+ if (cached && cached.expiresAt > Date.now()) {
+ reviewSurfaceCache.delete(cacheKey);
+ reviewSurfaceCache.set(cacheKey, cached);
+ return cached.value;
+ }
+ if (cached) reviewSurfaceCache.delete(cacheKey);
+
+ const targetEpoch = reviewSurfaceTargetEpochs.get(targetKey) ?? 0;
+ const value = await fetchReviewThreadsWithQuery(
+ options,
+ pullRequestReviewSurfaceThreadsQuery,
+ );
+ if ((reviewSurfaceTargetEpochs.get(targetKey) ?? 0) === targetEpoch) {
+ storeReviewSurfaceThreads(cacheKey, {
+ targetKey,
+ expiresAt: Date.now() + reviewSurfaceCacheTtlMs,
+ value,
+ });
+ }
+ return value;
+}
+
+export function invalidatePullRequestReviewSurfaceThreadCache(options: {
+ owner: string;
+ repo: string;
+ number: number;
+}) {
+ const targetKey = reviewSurfaceTargetKey(options);
+ reviewSurfaceTargetEpochs.set(
+ targetKey,
+ (reviewSurfaceTargetEpochs.get(targetKey) ?? 0) + 1,
+ );
+ for (const [key, cached] of reviewSurfaceCache) {
+ if (cached.targetKey === targetKey) reviewSurfaceCache.delete(key);
+ }
+}
+
+export function clearPullRequestReviewSurfaceThreadCache() {
+ reviewSurfaceCache.clear();
+ reviewSurfaceTargetEpochs.clear();
+}
+
+function storeReviewSurfaceThreads(
+ key: string,
+ value: CachedReviewSurfaceThreads,
+) {
+ const now = Date.now();
+ for (const [cachedKey, cached] of reviewSurfaceCache) {
+ if (cached.expiresAt <= now) reviewSurfaceCache.delete(cachedKey);
+ }
+ reviewSurfaceCache.delete(key);
+ reviewSurfaceCache.set(key, value);
+ while (reviewSurfaceCache.size > reviewSurfaceCacheMaxEntries) {
+ const oldestKey = reviewSurfaceCache.keys().next().value;
+ if (oldestKey === undefined) break;
+ reviewSurfaceCache.delete(oldestKey);
+ }
+}
+
+function reviewSurfaceTargetKey(options: {
+ owner: string;
+ repo: string;
+ number: number;
+}) {
+ return [
+ options.owner.toLowerCase(),
+ options.repo.toLowerCase(),
+ options.number,
+ ].join('\u0000');
+}
+
+function tokenFingerprint(token: string) {
+ return createHash('sha256').update(token).digest('base64url').slice(0, 16);
+}
+
+async function fetchReviewThreadsWithQuery(
+ options: {
+ token: string;
+ owner: string;
+ repo: string;
+ number: number;
+ signal?: AbortSignal;
+ },
+ query: string,
+): Promise<{
+ reviewThreads: GitHubPullRequestReviewThread[];
+ truncated: boolean;
}> {
const threads: GitHubPullRequestReviewThread[] = [];
let cursor: string | null = null;
@@ -74,19 +195,22 @@ export async function fetchPullRequestReviewThreadsWithMetadata(options: {
for (let page = 0; page < 5; page += 1) {
const data = await githubGraphqlFetch(
options.token,
- pullRequestReviewThreadsQuery,
+ query,
{
owner: options.owner,
name: options.repo,
number: options.number,
after: cursor,
},
+ { signal: options.signal },
);
const parsed = v.parse(githubReviewThreadsGraphqlResponseSchema, data);
const pullRequest = parsed.data.repository?.pullRequest;
if (!pullRequest) break;
for (const thread of pullRequest.reviewThreads.nodes ?? []) {
- threads.push(await normalizeReviewThread(options.token, thread));
+ threads.push(
+ await normalizeReviewThread(options.token, thread, options.signal),
+ );
}
if (!pullRequest.reviewThreads.pageInfo.hasNextPage) break;
@@ -110,11 +234,13 @@ export async function fetchPullRequestReviewThreadsWithMetadata(options: {
export async function fetchPullRequestReviewThread(options: {
token: string;
threadId: string;
+ signal?: AbortSignal;
}): Promise {
const data = await githubGraphqlFetch(
options.token,
pullRequestReviewThreadNodeQuery,
{ threadId: options.threadId },
+ { signal: options.signal },
);
const parsed = v.parse(githubReviewThreadNodeGraphqlResponseSchema, data);
const thread = parsed.data.node;
@@ -123,14 +249,15 @@ export async function fetchPullRequestReviewThread(options: {
`GitHub review thread "${options.threadId}" was not found.`,
);
}
- return normalizeReviewThread(options.token, thread);
+ return normalizeReviewThread(options.token, thread, options.signal);
}
async function normalizeReviewThread(
token: string,
thread: GitHubReviewThreadGraphqlNode,
+ signal?: AbortSignal,
): Promise {
- const comments = await fetchAllReviewThreadComments(token, thread);
+ const comments = await fetchAllReviewThreadComments(token, thread, signal);
return {
id: thread.id,
isResolved: thread.isResolved,
@@ -151,6 +278,7 @@ async function normalizeReviewThread(
async function fetchAllReviewThreadComments(
token: string,
thread: GitHubReviewThreadGraphqlNode,
+ signal?: AbortSignal,
) {
const comments = [...(thread.comments.nodes ?? [])];
let cursor = thread.comments.pageInfo.endCursor;
@@ -163,10 +291,15 @@ async function fetchAllReviewThreadComments(
truncated = true;
break;
}
- const data = await githubGraphqlFetch(token, reviewThreadCommentsQuery, {
- threadId: thread.id,
- after: cursor,
- });
+ const data = await githubGraphqlFetch(
+ token,
+ reviewThreadCommentsQuery,
+ {
+ threadId: thread.id,
+ after: cursor,
+ },
+ { signal },
+ );
const parsed = v.parse(
githubReviewThreadCommentsGraphqlResponseSchema,
data,
diff --git a/src/modules/github/index.ts b/src/modules/github/index.ts
index bfa8fa54..1d1bd346 100644
--- a/src/modules/github/index.ts
+++ b/src/modules/github/index.ts
@@ -63,10 +63,13 @@ export type {
GitHubPrReviewVerdict,
} from './reviews';
export {
+ clearPullRequestReviewSurfaceThreadCache,
fetchPullRequestReviewThread,
+ fetchPullRequestReviewSurfaceThreadsWithMetadata,
fetchPullRequestReviewThreads,
postPullRequestComment,
fetchPullRequestReviewThreadsWithMetadata,
+ invalidatePullRequestReviewSurfaceThreadCache,
} from './comments';
export type {
GitHubBranchPushPermissions,
diff --git a/src/modules/github/schemas.ts b/src/modules/github/schemas.ts
index e016fb56..8fa2e6da 100644
--- a/src/modules/github/schemas.ts
+++ b/src/modules/github/schemas.ts
@@ -712,6 +712,49 @@ export const pullRequestReviewThreadsQuery = `
}
`;
+export const pullRequestReviewSurfaceThreadsQuery = `
+ query NeondeckPullRequestReviewSurfaceThreads($owner: String!, $name: String!, $number: Int!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ pullRequest(number: $number) {
+ reviewThreads(first: 100, after: $after) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ id
+ isResolved
+ isOutdated
+ path
+ line
+ originalLine
+ diffSide
+ comments(first: 100) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ id
+ body
+ url
+ author {
+ login
+ }
+ createdAt
+ updatedAt
+ path
+ line
+ originalLine
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
export const reviewThreadCommentsQuery = `
query NeondeckPullRequestReviewThreadComments($threadId: ID!, $after: String) {
node(id: $threadId) {
diff --git a/src/modules/pr-events/service.ts b/src/modules/pr-events/service.ts
index cd795d18..6f485114 100644
--- a/src/modules/pr-events/service.ts
+++ b/src/modules/pr-events/service.ts
@@ -9,9 +9,11 @@ import {
fetchPullRequestEventState,
fetchPullRequestFiles,
fetchPullRequestFilesWithCache,
+ fetchPullRequestReviewSurfaceThreadsWithMetadata,
fetchPullRequestReviewThreadsWithMetadata,
fetchPullRequestReviewThread,
GitHubPrReviewSubmitError,
+ invalidatePullRequestReviewSurfaceThreadCache,
postPullRequestComment,
pullRequestEventStateTruncation,
readLivePrReviewDraft,
@@ -109,6 +111,10 @@ export async function getGitHubPrReviewThreads(
input: v.InferInput,
paths: RuntimePaths = runtimePaths(),
dependencies: PrEventStateDependencies = {},
+ options: {
+ signal?: AbortSignal;
+ surface?: boolean;
+ } = {},
): Promise {
const action = 'github_pr_review_threads_get';
await ensureRuntimeHome(paths);
@@ -134,12 +140,15 @@ export async function getGitHubPrReviewThreads(
try {
const fetcher =
dependencies.fetchPullRequestReviewThreads ??
- fetchPullRequestReviewThreadsWithMetadata;
+ (options.surface
+ ? fetchPullRequestReviewSurfaceThreadsWithMetadata
+ : fetchPullRequestReviewThreadsWithMetadata);
const result = await fetcher({
token,
owner: resolved.target.owner,
repo: resolved.target.repo,
number: resolved.target.number,
+ signal: options.signal,
});
threads = result.reviewThreads;
truncated =
@@ -165,14 +174,19 @@ export async function getGitHubPrReviewThreads(
action,
false,
`Fetched ${threads.length} review thread(s) for ${resolved.target.repoFullName}#${resolved.target.number}.`,
- {
- target: eventTargetJson(resolved.target),
- reviewThreads: threads as unknown as JsonValue,
- reviewThreadsTruncated: truncated,
- unresolvedReviewThreads: unresolvedThreads as unknown as JsonValue,
- unresolvedReviewComments:
- unresolvedReviewComments as unknown as JsonValue,
- },
+ options.surface
+ ? {
+ reviewThreads: threads as unknown as JsonValue,
+ reviewThreadsTruncated: truncated,
+ }
+ : {
+ target: eventTargetJson(resolved.target),
+ reviewThreads: threads as unknown as JsonValue,
+ reviewThreadsTruncated: truncated,
+ unresolvedReviewThreads: unresolvedThreads as unknown as JsonValue,
+ unresolvedReviewComments:
+ unresolvedReviewComments as unknown as JsonValue,
+ },
);
}
@@ -860,6 +874,11 @@ export async function postGitHubPrReview(
commentIds: parsedReview.output.commentIds,
fetchHeadSha: dependencies.fetchPullRequestHeadSha,
});
+ invalidatePullRequestReviewSurfaceThreadCache({
+ owner: resolved.target.owner,
+ repo: resolved.target.repo,
+ number: resolved.target.number,
+ });
return okResult(
'github_pr_review_post',
true,
@@ -1030,6 +1049,11 @@ export async function postGitHubPrThreadReply(
threadId,
body: parsed.output.text,
});
+ invalidatePullRequestReviewSurfaceThreadCache({
+ owner: resolved.target.owner,
+ repo: resolved.target.repo,
+ number: resolved.target.number,
+ });
return okResult(action, true, 'Posted review thread reply.', {
thread: thread as unknown as JsonValue,
});
@@ -1104,6 +1128,11 @@ export async function postGitHubPrThreadResolution(
: (dependencies.unresolvePullRequestReviewThread ??
unresolvePullRequestReviewThread);
const thread = await mutator({ token, threadId });
+ invalidatePullRequestReviewSurfaceThreadCache({
+ owner: target.target.owner,
+ repo: target.target.repo,
+ number: target.target.number,
+ });
return okResult(
action,
true,
diff --git a/src/pr-event-state.test.ts b/src/pr-event-state.test.ts
index c095f092..63283cb2 100644
--- a/src/pr-event-state.test.ts
+++ b/src/pr-event-state.test.ts
@@ -319,6 +319,25 @@ describe('PR event state watermarks', () => {
],
},
});
+ const surfaceResult = await getGitHubPrReviewThreads(
+ { repo: 'neondeck', prNumber: 123 },
+ paths,
+ dependencies,
+ { surface: true },
+ );
+ expect(surfaceResult).toMatchObject({
+ ok: true,
+ data: {
+ reviewThreads: [
+ expect.objectContaining({ id: 'thread-1' }),
+ expect.objectContaining({ id: 'thread-2' }),
+ ],
+ reviewThreadsTruncated: false,
+ },
+ });
+ expect(surfaceResult.data).not.toHaveProperty('target');
+ expect(surfaceResult.data).not.toHaveProperty('unresolvedReviewThreads');
+ expect(surfaceResult.data).not.toHaveProperty('unresolvedReviewComments');
await expect(
getGitHubPrRequestedChanges(
{ repo: 'neondeck', prNumber: 123 },
diff --git a/src/server/routes/github.ts b/src/server/routes/github.ts
index 54f039af..0e6c2bf2 100644
--- a/src/server/routes/github.ts
+++ b/src/server/routes/github.ts
@@ -478,6 +478,8 @@ export function createGitHubRoutes(paths: RuntimePaths) {
const result = await getGitHubPrReviewThreads(
(await safeJsonBody(c)) as Parameters[0],
paths,
+ {},
+ { signal: c.req.raw.signal, surface: true },
);
return c.json(result, result.ok ? 200 : 400);
});
diff --git a/web/src/api/github.ts b/web/src/api/github.ts
index fb45cb4e..1a63ea3d 100644
--- a/web/src/api/github.ts
+++ b/web/src/api/github.ts
@@ -97,10 +97,13 @@ export async function getGitHubPrReviewThreads(
{ repo: input.repo, prNumber: input.number },
options,
);
+ const reviewThreads = response.data?.reviewThreads ?? [];
return {
- reviewThreads: response.data?.reviewThreads ?? [],
+ reviewThreads,
reviewThreadsTruncated: response.data?.reviewThreadsTruncated ?? false,
- unresolvedReviewThreads: response.data?.unresolvedReviewThreads ?? [],
+ unresolvedReviewThreads:
+ response.data?.unresolvedReviewThreads ??
+ reviewThreads.filter((thread) => !thread.isResolved),
};
}
From be52965b53eef66d42bb51153687067d78460160 Mon Sep 17 00:00:00 2001
From: syn
Date: Sat, 18 Jul 2026 08:29:55 -0500
Subject: [PATCH 2/2] Invalidate review thread cache after mutation attempts
---
src/modules/pr-events/service.ts | 40 ++++++++++++--------
src/pr-event-state.test.ts | 64 ++++++++++++++++++++++++++++++++
2 files changed, 88 insertions(+), 16 deletions(-)
diff --git a/src/modules/pr-events/service.ts b/src/modules/pr-events/service.ts
index 6f485114..3997858d 100644
--- a/src/modules/pr-events/service.ts
+++ b/src/modules/pr-events/service.ts
@@ -1044,16 +1044,20 @@ export async function postGitHubPrThreadReply(
const replier =
dependencies.replyToPullRequestReviewThread ??
replyToPullRequestReviewThread;
- const thread = await replier({
- token,
- threadId,
- body: parsed.output.text,
- });
- invalidatePullRequestReviewSurfaceThreadCache({
- owner: resolved.target.owner,
- repo: resolved.target.repo,
- number: resolved.target.number,
- });
+ let thread: GitHubPullRequestReviewThread;
+ try {
+ thread = await replier({
+ token,
+ threadId,
+ body: parsed.output.text,
+ });
+ } finally {
+ invalidatePullRequestReviewSurfaceThreadCache({
+ owner: resolved.target.owner,
+ repo: resolved.target.repo,
+ number: resolved.target.number,
+ });
+ }
return okResult(action, true, 'Posted review thread reply.', {
thread: thread as unknown as JsonValue,
});
@@ -1127,12 +1131,16 @@ export async function postGitHubPrThreadResolution(
resolvePullRequestReviewThread)
: (dependencies.unresolvePullRequestReviewThread ??
unresolvePullRequestReviewThread);
- const thread = await mutator({ token, threadId });
- invalidatePullRequestReviewSurfaceThreadCache({
- owner: target.target.owner,
- repo: target.target.repo,
- number: target.target.number,
- });
+ let thread: GitHubPullRequestReviewThread;
+ try {
+ thread = await mutator({ token, threadId });
+ } finally {
+ invalidatePullRequestReviewSurfaceThreadCache({
+ owner: target.target.owner,
+ repo: target.target.repo,
+ number: target.target.number,
+ });
+ }
return okResult(
action,
true,
diff --git a/src/pr-event-state.test.ts b/src/pr-event-state.test.ts
index 63283cb2..d74888ef 100644
--- a/src/pr-event-state.test.ts
+++ b/src/pr-event-state.test.ts
@@ -8,6 +8,10 @@ import type {
GitHubPullRequestEventState,
GitHubPullRequestReviewThread,
} from './modules/github';
+import {
+ fetchPullRequestReviewSurfaceThreadsWithMetadata,
+ invalidatePullRequestReviewSurfaceThreadCache,
+} from './modules/github';
import {
getGitHubPrBranchPermissions,
getGitHubPrFiles,
@@ -910,6 +914,66 @@ describe('PR event state watermarks', () => {
expect(resolveCalls).toEqual(['fetch:thread-1', 'resolve:thread-1']);
});
+ it('invalidates cached review threads when a mutation follow-up fails', async () => {
+ const home = await tempHome();
+ const paths = runtimePaths(home);
+ await writeRepoRegistry(paths.repos);
+ const token = 'mutation-cache-token';
+ const target = {
+ token,
+ owner: 'pandemicsyn',
+ repo: 'neondeck',
+ number: 123,
+ };
+ const originalFetch = globalThis.fetch;
+ let fetchCalls = 0;
+ globalThis.fetch = async () => {
+ fetchCalls += 1;
+ return new Response(
+ JSON.stringify({
+ data: {
+ repository: {
+ pullRequest: {
+ reviewThreads: {
+ pageInfo: { hasNextPage: false, endCursor: null },
+ nodes: [],
+ },
+ },
+ },
+ },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ );
+ };
+
+ try {
+ await fetchPullRequestReviewSurfaceThreadsWithMetadata(target);
+ await expect(
+ postGitHubPrThreadReply(
+ { repo: 'neondeck', prNumber: 123 },
+ 'thread-1',
+ { text: 'Thanks, fixed.' },
+ paths,
+ {
+ token,
+ fetchPullRequestReviewThread: async () => reviewThread(),
+ replyToPullRequestReviewThread: async () => {
+ throw new Error('Follow-up thread refresh failed.');
+ },
+ },
+ ),
+ ).resolves.toMatchObject({
+ ok: false,
+ action: 'github_pr_thread_reply_post',
+ });
+ await fetchPullRequestReviewSurfaceThreadsWithMetadata(target);
+ expect(fetchCalls).toBe(2);
+ } finally {
+ globalThis.fetch = originalFetch;
+ invalidatePullRequestReviewSurfaceThreadCache(target);
+ }
+ });
+
it('preserves omitted review draft fields on partial saves', async () => {
const home = await tempHome();
const paths = runtimePaths(home);