Skip to content
Draft
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
79 changes: 51 additions & 28 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,28 +104,21 @@ describe('ApiClient', () => {
expect(callBody.repositoryName).toBeUndefined();
});

it('decodes base64 generalComment in response', async () => {
const encoded = Buffer.from('Good code').toString('base64');
mockOkResponse({ generalComment: encoded });
it('sets async true in the request body', async () => {
mockOkResponse({ reviewId: 'apirev_x' });
await client.review({ patch: 'x' });

const result = await client.review({ patch: 'x' });
expect(result.generalComment).toBe('Good code');
const callBody = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(callBody.async).toBe(true);
});

it('decodes base64 fileComments array in response', async () => {
const c1 = Buffer.from('comment 1').toString('base64');
const c2 = Buffer.from('comment 2').toString('base64');
mockOkResponse({ fileComments: [c1, c2] });

const result = await client.review({ patch: 'x' });
expect(result.fileComments).toEqual(['comment 1', 'comment 2']);
});
it('returns the reviewId and reviewCount from the 202 receipt', async () => {
const reviewCount = { current: 5, limit: 100, remaining: 95 };
mockOkResponse({ reviewId: 'apirev_abc', reviewCount });

it('handles response with no generalComment or fileComments', async () => {
mockOkResponse({});
const result = await client.review({ patch: 'x' });
expect(result.generalComment).toBeUndefined();
expect(result.fileComments).toBeUndefined();
expect(result.reviewId).toBe('apirev_abc');
expect(result.reviewCount).toEqual(reviewCount);
});

it('passes through reviewCount unchanged', async () => {
Expand Down Expand Up @@ -296,21 +289,51 @@ describe('ApiClient', () => {
});
});

describe('review with reviewSessionId', () => {
it('includes reviewSessionId in body when provided', async () => {
mockOkResponse({});
await client.review({ patch: 'x', reviewSessionId: 'sess-123' });
describe('getReviewResult', () => {
it('GETs /api/review/result/:reviewId with auth', async () => {
mockOkResponse({ status: 'pending' });
await client.getReviewResult('apirev_abc');

const callBody = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(callBody.reviewSessionId).toBe('sess-123');
expect(fetchMock).toHaveBeenCalledWith(
'http://test-api.local/api/review/result/apirev_abc',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({ 'Authorization': 'Bearer test-api-key' }),
})
);
});

it('omits reviewSessionId when not provided', async () => {
mockOkResponse({});
await client.review({ patch: 'x' });
it('returns pending while the review runs', async () => {
mockOkResponse({ status: 'pending' });
expect(await client.getReviewResult('apirev_abc')).toEqual({ status: 'pending' });
});

const callBody = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(callBody.reviewSessionId).toBeUndefined();
it('decodes base64 comments on a done result', async () => {
const gc = Buffer.from('All good').toString('base64');
const fc = Buffer.from('line 1').toString('base64');
mockOkResponse({ status: 'done', generalComment: gc, fileComments: [fc], isOptibotInstalled: true });

expect(await client.getReviewResult('apirev_abc')).toEqual({
status: 'done',
generalComment: 'All good',
fileComments: ['line 1'],
isOptibotInstalled: true,
});
});

it('returns failed with the error message', async () => {
mockOkResponse({ status: 'failed', error: 'boom' });
expect(await client.getReviewResult('apirev_abc')).toEqual({ status: 'failed', error: 'boom' });
});

it('returns not_found on a 404', async () => {
mockErrorResponse(404, { status: 'not_found' });
expect(await client.getReviewResult('apirev_abc')).toEqual({ status: 'not_found' });
});

it('throws on a non-404 error', async () => {
mockErrorResponse(401, { message: 'Unauthorized' });
await expect(client.getReviewResult('apirev_abc')).rejects.toThrow('Unauthorized');
});
});

Expand Down
69 changes: 50 additions & 19 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ReviewResponse,
AsyncReviewResponse,
ReviewResultResponse,
ApiKeyCreateResponse,
ApiKeyListItem,
ApiKeyListResponse,
Expand Down Expand Up @@ -44,24 +45,25 @@ export class ApiClient {
throw error;
}

/**
* Request a review asynchronously. Returns a receipt ({ reviewId }) rather
* than the review itself: the backend enqueues the work and the result is
* fetched separately via {@link getReviewResult}. Requires a backend with
* async review support deployed.
*/
async review(params: {
patch: string;
repositoryName?: string;
files?: Record<string, string>;
reviewSessionId?: string;
}): Promise<ReviewResponse> {
}): Promise<AsyncReviewResponse> {
const patchBase64 = Buffer.from(params.patch, 'utf-8').toString('base64');

const body: Record<string, unknown> = { patch: patchBase64 };
const body: Record<string, unknown> = { patch: patchBase64, async: true };

if (params.repositoryName) {
body.repositoryName = params.repositoryName;
}

if (params.reviewSessionId) {
body.reviewSessionId = params.reviewSessionId;
}

if (params.files && Object.keys(params.files).length > 0) {
// Null-prototype map: filenames from a repo are untrusted input
// (a file literally named `__proto__` would otherwise pollute).
Expand All @@ -86,20 +88,49 @@ export class ApiClient {
await this.throwApiError(response);
}

const result = await response.json() as ReviewResponse;
return await response.json() as AsyncReviewResponse;
}

/**
* Fetch the result of an async review by its reviewId. Returns `pending`
* while the review runs, `done` with the decoded payload when it finishes,
* `failed` with an error, or `not_found` (HTTP 404) when the reviewId is
* unknown or expired.
*/
async getReviewResult(reviewId: string): Promise<ReviewResultResponse> {
const response = await fetch(
`${API_BASE_URL}/api/review/result/${encodeURIComponent(reviewId)}`,
{
method: 'GET',
headers: {
...CLIENT_HEADERS,
'Authorization': `Bearer ${this.apiKey}`,
},
}
);

if (response.status === 404) {
return { status: 'not_found' };
}

// Decode base64-encoded fields
if (result.generalComment && typeof result.generalComment === 'string') {
result.generalComment = Buffer.from(result.generalComment, 'base64').toString('utf-8');
if (!response.ok) {
await this.throwApiError(response);
}

if (result.fileComments && Array.isArray(result.fileComments)) {
result.fileComments = result.fileComments.map((comment: string) => {
if (typeof comment === 'string') {
return Buffer.from(comment, 'base64').toString('utf-8');
}
return comment;
});
const result = await response.json() as ReviewResultResponse;

// Decode base64-encoded comments on a completed review.
if (result.status === 'done') {
if (typeof result.generalComment === 'string') {
result.generalComment = Buffer.from(result.generalComment, 'base64').toString('utf-8');
}
if (Array.isArray(result.fileComments)) {
result.fileComments = result.fileComments.map((comment) =>
typeof comment === 'string'
? Buffer.from(comment, 'base64').toString('utf-8')
: comment
);
}
}

return result;
Expand Down
62 changes: 62 additions & 0 deletions src/lib/reviewPolling.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest';
import { waitForReviewResult, ReviewResultFetcher } from './reviewPolling.js';
import { ReviewResultResponse } from '../types.js';

const fetcher = (results: ReviewResultResponse[]): ReviewResultFetcher => {
const queue = [...results];
return {
getReviewResult: vi.fn(async () => queue.shift() ?? { status: 'pending' }),
};
};

// No real waiting in tests.
const noSleep = () => Promise.resolve();

describe('waitForReviewResult', () => {
it('returns immediately when the first poll is done', async () => {
const done: ReviewResultResponse = { status: 'done', generalComment: 'ok' };
const client = fetcher([done]);

const result = await waitForReviewResult(client, 'apirev_x', { sleep: noSleep });

expect(result).toEqual(done);
expect(client.getReviewResult).toHaveBeenCalledTimes(1);
});

it('returns the failed result', async () => {
const client = fetcher([{ status: 'failed', error: 'boom' }]);
const result = await waitForReviewResult(client, 'apirev_x', { sleep: noSleep });
expect(result).toEqual({ status: 'failed', error: 'boom' });
});

it('keeps polling through pending and not_found until done', async () => {
const client = fetcher([
{ status: 'not_found' },
{ status: 'pending' },
{ status: 'pending' },
{ status: 'done', generalComment: 'ok' },
]);

const result = await waitForReviewResult(client, 'apirev_x', { sleep: noSleep });

expect(result).toEqual({ status: 'done', generalComment: 'ok' });
expect(client.getReviewResult).toHaveBeenCalledTimes(4);
});

it('throws when it exceeds the timeout', async () => {
// Always pending; a clock that jumps past the timeout after the first poll.
const client: ReviewResultFetcher = {
getReviewResult: vi.fn(async () => ({ status: 'pending' })),
};
let t = 0;
const now = () => {
const value = t;
t += 1000; // advance 1s per read
return value;
};

await expect(
waitForReviewResult(client, 'apirev_x', { timeoutMs: 1500, sleep: noSleep, now }),
).rejects.toThrow('Timed out');
});
});
56 changes: 56 additions & 0 deletions src/lib/reviewPolling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ReviewResultResponse } from '../types.js';

/** Terminal states of a review. */
export type TerminalReviewResult = Extract<
ReviewResultResponse,
{ status: 'done' } | { status: 'failed' }
>;

/** Minimal surface needed to poll for a review result (the ApiClient satisfies it). */
export interface ReviewResultFetcher {
getReviewResult(reviewId: string): Promise<ReviewResultResponse>;
}

export interface WaitForReviewResultOptions {
/** Give up after this long. Default 20 minutes. */
timeoutMs?: number;
/** Delay between polls. Default 3 seconds. */
intervalMs?: number;
/** Injectable clock, for tests. */
now?: () => number;
/** Injectable delay, for tests. */
sleep?: (ms: number) => Promise<void>;
}

/**
* Poll for an async review's result until it reaches a terminal state.
*
* `pending` and the brief post-enqueue `not_found` race both mean "keep
* waiting": the job was accepted (the POST returned a reviewId), so the result
* is on its way. Throws on timeout.
*/
export async function waitForReviewResult(
client: ReviewResultFetcher,
reviewId: string,
options: WaitForReviewResultOptions = {},
): Promise<TerminalReviewResult> {
const timeoutMs = options.timeoutMs ?? 20 * 60 * 1000;
const intervalMs = options.intervalMs ?? 3000;
const now = options.now ?? (() => Date.now());
const sleep = options.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));

const start = now();

for (;;) {
const result = await client.getReviewResult(reviewId);
if (result.status === 'done' || result.status === 'failed') {
return result;
}

if (now() - start >= timeoutMs) {
throw new Error('Timed out waiting for the review result.');
}

await sleep(intervalMs);
}
}
10 changes: 8 additions & 2 deletions src/lib/reviewProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,16 @@ export class ReviewProgressService {
private progressCallback: ProgressCallback | null = null;
private connectionTimeout: NodeJS.Timeout | null = null;

async startSession(onProgress: ProgressCallback): Promise<string> {
/**
* Open the progress socket and join a review's room. Pass `sessionId` to join
* a known room — for async reviews this is the server's reviewId, so progress
* events reach this client (handshake "Option A"). When omitted, a random id
* is generated (legacy behavior).
*/
async startSession(onProgress: ProgressCallback, sessionId?: string): Promise<string> {
this.cleanup();

this.sessionId = randomUUID();
this.sessionId = sessionId ?? randomUUID();
this.progressCallback = onProgress;

return new Promise((resolve) => {
Expand Down
Loading