Skip to content
Merged
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
51 changes: 34 additions & 17 deletions scripts/docs-sync-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { isAbsolute, resolve, sep } from 'node:path';
import { pathToFileURL } from 'node:url';
import {
REVIEW_COMMENT_MARKER,
REVIEW_JSON_SCHEMA,
buildReviewPrompts,
classifyPullRequest,
createDeferredResult,
Expand Down Expand Up @@ -188,6 +187,22 @@ export async function generateOpenAIReview(
apiKey: string,
fetchImpl: FetchLike = fetch,
): Promise<unknown> {
const response = await requestOpenAIReview(prompt, model, apiKey, fetchImpl, true);
const data = await response.json() as {
choices?: Array<{ message?: { content?: string | null } }>;
};
const text = data.choices?.[0]?.message?.content?.trim();
if (!text) throw new Error('OpenAI returned empty content.');
return parseReviewJson(text);
}

async function requestOpenAIReview(
prompt: string,
model: string,
apiKey: string,
fetchImpl: FetchLike,
useLowReasoning: boolean,
): Promise<Response> {
const response = await fetchImpl('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Expand All @@ -196,28 +211,30 @@ export async function generateOpenAIReview(
},
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
reasoning_effort: 'low',
response_format: {
type: 'json_schema',
json_schema: {
name: 'docs_sync_review',
strict: true,
schema: REVIEW_JSON_SCHEMA,
messages: [
{
role: 'system',
content: 'Return only valid JSON matching the requested review object. Do not wrap it in markdown.',
},
},
{ role: 'user', content: prompt },
],
temperature: 0.1,
...(useLowReasoning ? { reasoning_effort: 'low' } : {}),
}),
signal: AbortSignal.timeout(180_000),
});
if (response.status === 400 && useLowReasoning) {
return requestOpenAIReview(prompt, model, apiKey, fetchImpl, false);
}
if (!response.ok) throw new Error(`OpenAI request failed with HTTP ${response.status}.`);
const data = await response.json() as {
choices?: Array<{ message?: { content?: string | null } }>;
};
const text = data.choices?.[0]?.message?.content?.trim();
if (!text) throw new Error('OpenAI returned empty content.');
return response;
}

function parseReviewJson(text: string): unknown {
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1];
const candidate = fenced ?? text;
try {
return JSON.parse(text) as unknown;
return JSON.parse(candidate) as unknown;
} catch {
throw new Error('OpenAI returned invalid JSON.');
}
Expand Down
26 changes: 24 additions & 2 deletions src/docs-sync-review-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,11 +356,33 @@ describe('OpenAI and documentation boundaries', () => {
body: expect.stringContaining('"reasoning_effort":"low"'),
}),
);
const requestInit = (fetchImpl.mock.calls as unknown as Array<[unknown, { body?: string }?]>)[0]?.[1];
expect(requestInit?.body).toContain('"response_format"');
expect(result).toEqual({ verdict: 'no_update_needed', summary: 'Covered.', findings: [] });
});

it('accepts fenced OpenAI JSON output', async () => {
const fetchImpl = vi.fn(async () => jsonResponse({
choices: [{ message: { content: '```json\n{"verdict":"no_update_needed","summary":"Covered.","findings":[]}\n```' } }],
}));

await expect(generateOpenAIReview('review prompt', 'gpt-test', 'api-key', fetchImpl))
.resolves.toEqual({ verdict: 'no_update_needed', summary: 'Covered.', findings: [] });
});

it('retries without low reasoning when the selected model rejects it', async () => {
const fetchImpl = vi.fn()
.mockResolvedValueOnce(jsonResponse({ error: { message: 'unsupported parameter' } }, 400))
.mockResolvedValueOnce(jsonResponse({
choices: [{ message: { content: '{"verdict":"no_update_needed","summary":"Covered.","findings":[]}' } }],
}));

await expect(generateOpenAIReview('review prompt', 'gpt-test', 'api-key', fetchImpl))
.resolves.toEqual({ verdict: 'no_update_needed', summary: 'Covered.', findings: [] });
expect((fetchImpl.mock.calls as unknown as Array<[unknown, { body?: string }]>)[0]?.[1]?.body)
.toContain('"reasoning_effort":"low"');
expect((fetchImpl.mock.calls as unknown as Array<[unknown, { body?: string }]>)[1]?.[1]?.body)
.not.toContain('reasoning_effort');
});

it('defaults the docs review model to gpt-5.4-mini', async () => {
const fetchImpl = vi.fn(async () => jsonResponse({
choices: [{ message: { content: JSON.stringify({ verdict: 'no_update_needed', summary: 'Covered.', findings: [] }) } }],
Expand Down