hotfix(ci): keep ai review feedback fast and deterministic

This commit is contained in:
Tam Nhu Tran
2026-04-01 22:20:55 -04:00
parent bbf86155bc
commit 4e05488c19
7 changed files with 192 additions and 6 deletions
+73 -1
View File
@@ -290,6 +290,66 @@ function readExecutionMetadata(executionFile) {
}
}
function readSelectedFiles(manifestFile) {
if (!manifestFile || !fs.existsSync(manifestFile)) {
return [];
}
try {
return fs
.readFileSync(manifestFile, 'utf8')
.split('\n')
.map((line) => cleanText(line))
.filter(Boolean);
} catch {
return [];
}
}
function formatHotspotFiles(files) {
if (!files.length) {
return null;
}
const visible = files.slice(0, 4).map(renderCode).join(', ');
return files.length > 4 ? `${visible}, and ${files.length - 4} more` : visible;
}
function formatRemainingCoverage(rendering) {
if (
typeof rendering.selectedFiles !== 'number' ||
typeof rendering.reviewableFiles !== 'number'
) {
return null;
}
const remainingFiles = Math.max(rendering.reviewableFiles - rendering.selectedFiles, 0);
const hasChangeCounts =
typeof rendering.selectedChanges === 'number' &&
typeof rendering.reviewableChanges === 'number';
const remainingChanges = hasChangeCounts
? Math.max(rendering.reviewableChanges - rendering.selectedChanges, 0)
: null;
if (remainingFiles === 0 && (!hasChangeCounts || remainingChanges === 0)) {
return null;
}
if (typeof remainingChanges === 'number') {
return `${remainingFiles} file${remainingFiles === 1 ? '' : 's'}; ${remainingChanges} changed lines`;
}
return `${remainingFiles} file${remainingFiles === 1 ? '' : 's'}`;
}
function formatFallbackFollowUp(rendering) {
if (rendering.mode === 'triage') {
return 'Focus manual review on the hotspot files above, and use `/review` for a deeper pass when release, auth, config, or workflow paths changed.';
}
return 'Use `/review` when you need a deeper maintainer rerun with more surrounding context.';
}
export function normalizeStructuredOutput(raw) {
if (!raw) {
return { ok: false, reason: 'missing structured output' };
@@ -461,6 +521,7 @@ export function renderIncompleteReview({
runUrl,
runtimeTools,
turnsUsed,
selectedFiles,
rendering: renderOptions,
status,
}) {
@@ -468,7 +529,7 @@ export function renderIncompleteReview({
const lines = [
'### ⚠️ AI Review Incomplete',
'',
'Claude did not return validated structured review output, so this workflow did not publish raw scratch text.',
'Claude did not return validated structured review output, so this workflow published deterministic hotspot context instead of raw scratch text.',
'',
`- Outcome: ${describeIncompleteOutcome({ reason, rendering, turnsUsed, status })}`,
];
@@ -484,6 +545,15 @@ export function renderIncompleteReview({
if (runtimeBudget) {
lines.push(`- Runtime budget: ${escapeMarkdownText(runtimeBudget)}`);
}
const hotspotFiles = formatHotspotFiles(selectedFiles || []);
if (hotspotFiles) {
lines.push(`- Hotspot files in this pass: ${hotspotFiles}`);
}
const remainingCoverage = formatRemainingCoverage(rendering);
if (remainingCoverage) {
lines.push(`- Remaining reviewable scope not fully covered: ${escapeMarkdownText(remainingCoverage)}`);
}
lines.push(`- Manual follow-up: ${escapeMarkdownText(formatFallbackFollowUp(rendering))}`);
if (runtimeTools?.length) {
lines.push(`- Runtime tools: ${runtimeTools.map(renderCode).join(', ')}`);
}
@@ -501,6 +571,7 @@ export function writeReviewFromEnv(env = process.env) {
const runUrl = env.AI_REVIEW_RUN_URL || '#';
const validation = normalizeStructuredOutput(env.AI_REVIEW_STRUCTURED_OUTPUT);
const metadata = readExecutionMetadata(env.AI_REVIEW_EXECUTION_FILE);
const selectedFiles = readSelectedFiles(env.AI_REVIEW_SCOPE_MANIFEST_FILE);
const status = cleanText(env.AI_REVIEW_STATUS).toLowerCase() || null;
const rendering = normalizeRenderingMetadata({
mode: env.AI_REVIEW_MODE,
@@ -521,6 +592,7 @@ export function writeReviewFromEnv(env = process.env) {
runUrl,
runtimeTools: metadata.runtimeTools,
turnsUsed: metadata.turnsUsed,
selectedFiles,
rendering,
status,
});
+25 -4
View File
@@ -4,10 +4,14 @@ import { fileURLToPath } from 'node:url';
const MODE_LIMITS = {
fast: { maxFiles: 16, maxChangedLines: 900, maxPatchLines: 90, maxPatchChars: 7000 },
triage: { maxFiles: 10, maxChangedLines: 700, maxPatchLines: 80, maxPatchChars: 6000 },
triage: { maxFiles: 6, maxChangedLines: 520, maxPatchLines: 60, maxPatchChars: 4500 },
deep: { maxFiles: 20, maxChangedLines: 1600, maxPatchLines: 120, maxPatchChars: 9000 },
};
const TRIAGE_SIZE_CLASS_LIMITS = {
xlarge: { maxFiles: 4, maxChangedLines: 360, maxPatchLines: 45, maxPatchChars: 3200 },
};
const MODE_LABELS = {
fast: 'diff-focused bounded review',
triage: 'hotspot-based bounded review (non-exhaustive)',
@@ -35,6 +39,13 @@ function cleanText(value) {
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : '';
}
function normalizeSizeClass(value) {
const sizeClass = cleanText(value).toLowerCase();
return sizeClass === 'small' || sizeClass === 'medium' || sizeClass === 'large' || sizeClass === 'xlarge'
? sizeClass
: null;
}
function escapeMarkdown(value) {
return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>|])/g, '\\$1');
}
@@ -127,8 +138,17 @@ export function normalizePullFiles(files) {
}).map((file) => ({ ...file, score: scoreFile(file) }));
}
export function buildReviewScope(files, mode) {
const limits = MODE_LIMITS[mode] || MODE_LIMITS.fast;
function resolveModeLimits(mode, sizeClass) {
if (mode !== 'triage') {
return MODE_LIMITS[mode] || MODE_LIMITS.fast;
}
return TRIAGE_SIZE_CLASS_LIMITS[sizeClass] || MODE_LIMITS.triage;
}
export function buildReviewScope(files, mode, options = {}) {
const sizeClass = normalizeSizeClass(options.sizeClass);
const limits = resolveModeLimits(mode, sizeClass);
const reviewable = files.filter((file) => file.reviewable);
const lowSignal = files.filter((file) => !file.reviewable);
const usingChangedFallback = reviewable.length === 0;
@@ -260,6 +280,7 @@ export async function writeScopeFromEnv(env = process.env, request) {
const prNumber = Number.parseInt(cleanText(env.AI_REVIEW_PR_NUMBER), 10);
const baseRef = cleanText(env.AI_REVIEW_BASE_REF || 'dev');
const mode = cleanText(env.AI_REVIEW_MODE || 'fast').toLowerCase();
const sizeClass = normalizeSizeClass(env.AI_REVIEW_PR_SIZE_CLASS);
const turnBudget = Number.parseInt(cleanText(env.AI_REVIEW_MAX_TURNS || '0'), 10) || 0;
const timeoutMinutes = Number.parseInt(cleanText(env.AI_REVIEW_TIMEOUT_MINUTES || '0'), 10) || 0;
const outputFile = env.AI_REVIEW_SCOPE_FILE || '.ccs-ai-review-scope.md';
@@ -287,7 +308,7 @@ export async function writeScopeFromEnv(env = process.env, request) {
const files = normalizePullFiles(
await collectPullRequestFiles(`${apiUrl}/repos/${repository}/pulls/${prNumber}/files?per_page=100`, fetchPage)
);
const scope = buildReviewScope(files, mode);
const scope = buildReviewScope(files, mode, { sizeClass });
const markdown = renderReviewScope({ prNumber, baseRef, turnBudget, timeoutMinutes, scope });
fs.mkdirSync(path.dirname(outputFile), { recursive: true });