Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/web/src/lib/github-pr-review/dtos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ export const GitHubPrReviewReviewThreadSchema = z
diffSide: z.enum(['LEFT', 'RIGHT']).nullable(),
diffHunk: z.string().nullable(),
comments: z.array(GitHubPrReviewReviewCommentSchema),
// True when the thread has more comments than this response includes, so a
// client can present the list as partial rather than as the whole thread.
commentsTruncated: z.boolean().optional(),
})
.strict();
export type GitHubPrReviewReviewThread = z.infer<typeof GitHubPrReviewReviewThreadSchema>;
Expand Down Expand Up @@ -230,3 +233,16 @@ export const REVIEW_THREAD_COMMENTS_PAGE_SIZE = 50;
// dropped and whatever was collected is returned (silent truncation).
export const CONVERSATION_COMMENTS_PAGE_SIZE = 100;
export const CONVERSATION_COMMENTS_MAX_PAGES = 5;
// Per-thread review comments beyond the first page: at most 5 follow-up pages
// of 50, so one thread shows its initial 50 plus at most 250 follow-up comments.
// Past the ceiling the thread keeps what was collected (silent truncation),
// matching the conversation-comment scan above.
export const REVIEW_THREAD_COMMENTS_MAX_PAGES = 5;
// One review-threads request spends at most this many follow-up comments.
// `enrichReviewThreads` divides them evenly across the page's threads before
// any request runs, so the split depends only on the response content and never
// on which thread's requests finish first.
export const REVIEW_THREAD_COMMENTS_MAX_PER_REQUEST = 500;
// Threads on one page are enriched with at most this many concurrent GitHub
// requests.
export const REVIEW_THREADS_ENRICHMENT_CONCURRENCY = 5;
2 changes: 2 additions & 0 deletions apps/web/src/lib/github-pr-review/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ export type GraphQlReviewThreadInput = {
originalStartLine?: number | null;
diffSide?: 'LEFT' | 'RIGHT' | null;
diffHunk?: string | null;
commentsTruncated?: boolean;
comments: GraphQlReviewCommentInput[];
};

Expand Down Expand Up @@ -451,6 +452,7 @@ export function buildReviewThreadsResult(args: {
originalStartLine: t.originalStartLine ?? null,
diffSide,
diffHunk: t.diffHunk || null,
commentsTruncated: t.commentsTruncated ?? false,
comments: t.comments.map(mapReviewComment),
};
});
Expand Down
150 changes: 147 additions & 3 deletions apps/web/src/lib/github-pr-review/review-thread-comments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
*/
import {
CONVERSATION_COMMENTS_QUERY_FOR_TEST,
enrichReviewThreads,
fetchAllThreadComments,
REVIEW_THREAD_COMMENTS_FOLLOWUP_QUERY_FOR_TEST,
} from '@/routers/github-pr-review-router';
import {
REVIEW_THREAD_COMMENTS_MAX_PER_REQUEST,
REVIEW_THREAD_COMMENTS_PAGE_SIZE,
} from '@/lib/github-pr-review/dtos';

function commentNode(
id: number,
Expand Down Expand Up @@ -96,17 +101,19 @@ describe('fetchAllThreadComments', () => {

const octokit = { request } as never;

const comments = await fetchAllThreadComments({
const { comments, truncated } = await fetchAllThreadComments({
octokit,
threadId: 'thread_1',
initialConnection: {
pageInfo: { hasNextPage: true, endCursor: 'c1' },
nodes: [commentNode(1)],
},
budget: { remainingFollowUpComments: REVIEW_THREAD_COMMENTS_MAX_PER_REQUEST },
});

// All three pages aggregated to completion — no silent truncation.
// All three pages aggregated to completion.
expect(comments.map(c => c.databaseId)).toEqual([1, 2, 3]);
expect(truncated).toBe(false);
// Zero-count reactionGroups are dropped; DTO reactions stay empty.
expect(comments.map(c => c.reactions)).toEqual([[], [], []]);
expect(request).toHaveBeenCalledTimes(2);
Expand Down Expand Up @@ -144,7 +151,7 @@ describe('fetchAllThreadComments', () => {
});

it('keeps only reactionGroups with totalCount > 0 in the DTO shape', async () => {
const comments = await fetchAllThreadComments({
const { comments } = await fetchAllThreadComments({
octokit: { request: jest.fn() } as never,
threadId: 'thread_1',
initialConnection: {
Expand Down Expand Up @@ -194,6 +201,7 @@ describe('fetchAllThreadComments', () => {
]),
],
},
budget: { remainingFollowUpComments: REVIEW_THREAD_COMMENTS_MAX_PER_REQUEST },
});

expect(comments[0]?.reactions).toEqual([
Expand All @@ -202,3 +210,139 @@ describe('fetchAllThreadComments', () => {
]);
});
});

function commentPage(startId: number, count: number, hasNextPage: boolean) {
return {
pageInfo: { hasNextPage, endCursor: hasNextPage ? `cursor_${startId + count}` : null },
nodes: Array.from({ length: count }, (_, index) => commentNode(startId + index)),
};
}

function threadNode(index: number, comments: ReturnType<typeof commentPage>) {
return {
id: `thread_${index}`,
isResolved: false,
isOutdated: false,
subjectType: 'LINE' as const,
path: `src/file-${index}.ts`,
line: index + 1,
startLine: null,
originalLine: null,
originalStartLine: null,
diffSide: 'RIGHT' as const,
comments,
};
}

describe('thread comment pagination bounds', () => {
it('stops at the per-thread page ceiling while GitHub keeps reporting another page', async () => {
let nextId = 1000;
const request = jest.fn(async () => {
const start = nextId;
nextId += REVIEW_THREAD_COMMENTS_PAGE_SIZE;
return {
data: {
data: {
node: { comments: commentPage(start, REVIEW_THREAD_COMMENTS_PAGE_SIZE, true) },
},
},
};
});

const { comments, truncated } = await fetchAllThreadComments({
octokit: { request } as never,
threadId: 'thread_1',
initialConnection: commentPage(1, REVIEW_THREAD_COMMENTS_PAGE_SIZE, true),
// Far above the per-thread ceiling: the page ceiling is the binding cap.
budget: { remainingFollowUpComments: 1_000_000 },
});

expect(request).toHaveBeenCalledTimes(5);
// Initial page plus the 5-follow-up-page ceiling, 50 comments per page.
expect(comments).toHaveLength(300);
expect(truncated).toBe(true);
});

it('asks for only what the allowance keeps and stops when it is spent', async () => {
// One page of 50 plus 10 from the next page, then the allowance is spent.
const budget = { remainingFollowUpComments: 60 };
const requestedFirsts: number[] = [];
let nextId = 5000;
const request = jest.fn(async (_path: string, options: { variables: { first: number } }) => {
requestedFirsts.push(options.variables.first);
const start = nextId;
nextId += REVIEW_THREAD_COMMENTS_PAGE_SIZE;
return {
data: {
data: {
node: { comments: commentPage(start, REVIEW_THREAD_COMMENTS_PAGE_SIZE, true) },
},
},
};
});

const { comments, truncated } = await fetchAllThreadComments({
octokit: { request } as never,
threadId: 'thread_1',
initialConnection: commentPage(1, 1, true),
budget,
});

// The second request asks only for the 10 comments the allowance still holds.
expect(requestedFirsts).toEqual([50, 10]);
expect(comments).toHaveLength(61);
expect(truncated).toBe(true);
expect(budget.remainingFollowUpComments).toBe(0);
});

it('never runs more than the configured number of thread comment requests at once', async () => {
let inFlight = 0;
let maxInFlight = 0;
const request = jest.fn(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
// Yield one microtask so every concurrent slot is observed as in flight
// before any request settles; no wall-clock delay is involved.
await Promise.resolve();
inFlight -= 1;
return { data: { data: { node: { comments: commentPage(9000, 1, false) } } } };
});
const nodes = Array.from({ length: 15 }, (_, index) =>
threadNode(index, commentPage(index * 10 + 1, 1, true))
);

const threads = await enrichReviewThreads({ octokit: { request } as never, nodes });

expect(threads).toHaveLength(nodes.length);
expect(request).toHaveBeenCalledTimes(nodes.length);
expect(maxInFlight).toBe(5);
});

it('splits the request budget evenly before any request and repeats it identically', async () => {
const request = jest.fn(async () => ({
data: {
data: {
node: {
comments: commentPage(2000, REVIEW_THREAD_COMMENTS_PAGE_SIZE, true),
},
},
},
}));
const threadCount = 20;
const nodes = Array.from({ length: threadCount }, (_, index) =>
threadNode(index, commentPage(index * 10 + 1, 1, true))
);

const first = await enrichReviewThreads({ octokit: { request } as never, nodes });
const second = await enrichReviewThreads({ octokit: { request } as never, nodes });

const collected = first.reduce((total, thread) => total + thread.comments.length, 0);
// 500 follow-up comments split across 20 threads: 25 each.
expect(collected - threadCount).toBe(500);
expect(first.every(thread => thread.comments.length === 26)).toBe(true);
// Identical responses produce identical per-thread results every run.
expect(second.map(thread => thread.comments.length)).toEqual(
first.map(thread => thread.comments.length)
);
});
});
Loading