Files
ccs/scripts/github/run-ai-review-direct.mjs
T

350 lines
11 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
normalizeStructuredOutput,
renderIncompleteReview,
renderStructuredReview,
} from './normalize-ai-review-output.mjs';
function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function readTextFile(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch {
return '';
}
}
function readSelectedFiles(filePath) {
return readTextFile(filePath)
.split('\n')
.map((line) => cleanText(line))
.filter(Boolean);
}
function stripCodeFence(value) {
const text = cleanText(value);
const fenceMatch = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/u);
return fenceMatch ? cleanText(fenceMatch[1]) : text;
}
export function extractJsonCandidate(value) {
const text = stripCodeFence(value);
const firstBrace = text.indexOf('{');
const lastBrace = text.lastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace) {
return text.slice(firstBrace, lastBrace + 1);
}
return text;
}
function collectMessageText(responseJson) {
const content = Array.isArray(responseJson?.content) ? responseJson.content : [];
return content
.filter((block) => block?.type === 'text' && typeof block?.text === 'string')
.map((block) => block.text)
.join('\n\n');
}
async function postReviewRequest({ apiUrl, apiKey, model, system, prompt, timeoutMs, fetchImpl }) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(`${apiUrl.replace(/\/$/, '')}/v1/messages`, {
method: 'POST',
signal: controller.signal,
headers: {
'anthropic-version': '2023-06-01',
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
'x-api-key': apiKey,
},
body: JSON.stringify({
model,
max_tokens: 6000,
temperature: 0,
system,
messages: [{ role: 'user', content: prompt }],
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`review api returned ${response.status}: ${errorText}`);
}
return response.json();
} finally {
clearTimeout(timeout);
}
}
function buildSystemPrompt(reviewPrompt) {
return `${reviewPrompt}
## Critical Response Contract
Return JSON only. Do not wrap it in markdown fences.
Return a single object with these keys only:
- summary
- findings
- securityChecklist
- ccsCompliance
- informational
- strengths
- overallAssessment
- overallRationale
Each finding may optionally include:
- snippets: an array of up to 2 objects with required code plus optional label and language
If snippets are present:
- keep code literal only, without markdown fences
- keep each snippet under 20 lines
- use snippets only for short evidence that materially clarifies the finding
Use empty arrays rather than inventing low-value feedback.
Every finding must be confirmed by the review packet.`;
}
function buildPrimaryPrompt({ meta, packet }) {
return `REPO: ${meta.repository}
PR NUMBER: ${meta.prNumber}
PR BASE REF: ${meta.baseRef}
PR HEAD REF: ${meta.headRef}
PR HEAD SHA: ${meta.headSha}
CONTRIBUTOR: @${meta.authorLogin}
AUTHOR ASSOCIATION: ${meta.authorAssociation}
REVIEW MODE: ${meta.reviewMode}
PR SIZE CLASS: ${meta.sizeClass}
CHANGED FILES: ${meta.changedFiles}
ADDITIONS: ${meta.additions}
DELETIONS: ${meta.deletions}
TOTAL CHURN: ${meta.totalChurn}
Review the generated packet below and return the final JSON review object.
${packet}`;
}
function buildRepairPrompt({ validationReason, previousCandidate }) {
return `Your previous response did not validate: ${validationReason}.
Return corrected JSON only. Keep only confirmed findings. Do not add markdown fences.
Previous candidate:
${previousCandidate}`;
}
export function resolveAttemptWindow({
timeoutMinutes,
configuredTimeoutMs,
requestBufferMs = 45000,
minAttemptMs = 20000,
startedAt = Date.now(),
now = Date.now(),
}) {
if (!Number.isInteger(timeoutMinutes) || timeoutMinutes <= 0) {
return {
canAttempt: true,
timeoutMs: configuredTimeoutMs,
deadline: null,
remainingMs: null,
};
}
const stepBudgetMs = timeoutMinutes * 60 * 1000;
const bufferMs = Math.min(Math.max(requestBufferMs, 5000), Math.max(stepBudgetMs - 5000, 5000));
const deadline = startedAt + Math.max(stepBudgetMs - bufferMs, minAttemptMs);
const remainingMs = deadline - now;
if (remainingMs < minAttemptMs) {
return {
canAttempt: false,
timeoutMs: null,
deadline,
remainingMs,
};
}
return {
canAttempt: true,
timeoutMs: Math.min(configuredTimeoutMs, remainingMs),
deadline,
remainingMs,
};
}
export function resolveCoveredSelectedFiles({
selectedFiles,
packetIncludedFiles,
includedManifestFiles,
}) {
if (includedManifestFiles.length > 0) {
return includedManifestFiles;
}
if (!Number.isInteger(packetIncludedFiles) || packetIncludedFiles <= 0) {
return [];
}
if (packetIncludedFiles >= selectedFiles.length) {
return selectedFiles;
}
return selectedFiles.slice(0, packetIncludedFiles);
}
export async function writeDirectReviewFromEnv(env = process.env, fetchImpl = globalThis.fetch) {
const outputFile = cleanText(env.AI_REVIEW_OUTPUT_FILE || 'pr_review.md');
const logFile = cleanText(env.AI_REVIEW_LOG_FILE || '.ccs-ai-review-attempts.json');
const prompt = cleanText(env.AI_REVIEW_PROMPT);
const packet = readTextFile(cleanText(env.AI_REVIEW_PACKET_FILE || '.ccs-ai-review-packet.md'));
const selectedFiles = readSelectedFiles(
cleanText(env.AI_REVIEW_SCOPE_MANIFEST_FILE || '.ccs-ai-review-selected-files.txt')
);
const includedManifestFiles = readSelectedFiles(
cleanText(env.AI_REVIEW_PACKET_INCLUDED_MANIFEST_FILE || '.ccs-ai-review-packet-included-files.txt')
);
const timeoutMs = Number.parseInt(cleanText(env.AI_REVIEW_REQUEST_TIMEOUT_MS || '240000'), 10) || 240000;
const timeoutMinutes = Number.parseInt(cleanText(env.AI_REVIEW_TIMEOUT_MINUTES || '0'), 10) || 0;
const requestBufferMs = Number.parseInt(cleanText(env.AI_REVIEW_REQUEST_BUFFER_MS || '45000'), 10) || 45000;
const minAttemptMs = Number.parseInt(cleanText(env.AI_REVIEW_REQUEST_MIN_MS || '20000'), 10) || 20000;
const maxAttempts = Number.parseInt(cleanText(env.AI_REVIEW_MAX_ATTEMPTS || '3'), 10) || 3;
const startedAt = Date.now();
const rendering = {
mode: env.AI_REVIEW_MODE,
selectedFiles: env.AI_REVIEW_SELECTED_FILES,
reviewableFiles: env.AI_REVIEW_REVIEWABLE_FILES,
selectedChanges: env.AI_REVIEW_SELECTED_CHANGES,
reviewableChanges: env.AI_REVIEW_REVIEWABLE_CHANGES,
scopeLabel: env.AI_REVIEW_SCOPE_LABEL,
timeoutMinutes: env.AI_REVIEW_TIMEOUT_MINUTES,
packetIncludedFiles: env.AI_REVIEW_PACKET_INCLUDED_FILES,
packetTotalFiles: env.AI_REVIEW_PACKET_TOTAL_FILES,
packetOmittedFiles: env.AI_REVIEW_PACKET_OMITTED_FILES,
};
const packetIncludedFiles = Number.parseInt(cleanText(env.AI_REVIEW_PACKET_INCLUDED_FILES || '0'), 10) || 0;
const coveredSelectedFiles = resolveCoveredSelectedFiles({
selectedFiles,
packetIncludedFiles,
includedManifestFiles,
});
const meta = {
repository: cleanText(env.GITHUB_REPOSITORY),
prNumber: cleanText(env.AI_REVIEW_PR_NUMBER),
baseRef: cleanText(env.AI_REVIEW_BASE_REF),
headRef: cleanText(env.AI_REVIEW_HEAD_REF),
headSha: cleanText(env.AI_REVIEW_HEAD_SHA),
authorLogin: cleanText(env.AI_REVIEW_AUTHOR_LOGIN),
authorAssociation: cleanText(env.AI_REVIEW_AUTHOR_ASSOCIATION),
reviewMode: cleanText(env.AI_REVIEW_MODE),
sizeClass: cleanText(env.AI_REVIEW_PR_SIZE_CLASS),
changedFiles: cleanText(env.AI_REVIEW_CHANGED_FILES),
additions: cleanText(env.AI_REVIEW_ADDITIONS),
deletions: cleanText(env.AI_REVIEW_DELETIONS),
totalChurn: cleanText(env.AI_REVIEW_TOTAL_CHURN),
};
const system = buildSystemPrompt(prompt);
const attempts = [];
let finalValidation = null;
let lastReason = 'missing structured output';
let previousCandidate = '';
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const attemptWindow = resolveAttemptWindow({
timeoutMinutes,
configuredTimeoutMs: timeoutMs,
requestBufferMs,
minAttemptMs,
startedAt,
now: Date.now(),
});
if (!attemptWindow.canAttempt || !attemptWindow.timeoutMs) {
attempts.push({
attempt,
status: 'skipped_budget',
validationReason: 'reserved remaining runtime for deterministic fallback publication',
remainingMs: attemptWindow.remainingMs,
});
lastReason = 'review runtime budget reserved for deterministic fallback publication';
break;
}
try {
const attemptPrompt =
attempt === 1
? buildPrimaryPrompt({ meta, packet })
: `${buildPrimaryPrompt({ meta, packet })}\n\n${buildRepairPrompt({ validationReason: lastReason, previousCandidate })}`;
const attemptStartedAt = new Date().toISOString();
const responseJson = await postReviewRequest({
apiUrl: cleanText(env.ANTHROPIC_BASE_URL),
apiKey: cleanText(env.ANTHROPIC_AUTH_TOKEN),
model: cleanText(env.REVIEW_MODEL || env.ANTHROPIC_MODEL || 'glm-5.1'),
system,
prompt: attemptPrompt,
timeoutMs: attemptWindow.timeoutMs,
fetchImpl,
});
const rawText = collectMessageText(responseJson);
previousCandidate = extractJsonCandidate(rawText);
const validation = normalizeStructuredOutput(previousCandidate);
attempts.push({
attempt,
startedAt: attemptStartedAt,
status: validation.ok ? 'validated' : 'invalid',
timeoutMs: attemptWindow.timeoutMs,
validationReason: validation.ok ? null : validation.reason,
responsePreview: rawText.slice(0, 800),
});
if (validation.ok) {
finalValidation = validation.value;
break;
}
lastReason = validation.reason || lastReason;
} catch (error) {
attempts.push({
attempt,
status: 'error',
timeoutMs: attemptWindow.timeoutMs,
validationReason: error instanceof Error ? error.message : String(error),
});
lastReason = error instanceof Error ? error.message : String(error);
}
}
const markdown = finalValidation
? renderStructuredReview(finalValidation, {
model: cleanText(env.REVIEW_MODEL || 'glm-5.1'),
rendering,
})
: renderIncompleteReview({
model: cleanText(env.REVIEW_MODEL || 'glm-5.1'),
reason: lastReason,
runUrl: cleanText(env.AI_REVIEW_RUN_URL || '#'),
selectedFiles: coveredSelectedFiles,
rendering,
status: 'failure',
});
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
fs.writeFileSync(outputFile, `${markdown}\n`, 'utf8');
fs.writeFileSync(logFile, `${JSON.stringify({ attempts, success: !!finalValidation }, null, 2)}\n`, 'utf8');
return { usedFallback: !finalValidation, attempts };
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
const result = await writeDirectReviewFromEnv();
if (result.usedFallback) {
process.exitCode = 1;
}
}