mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
hotfix(ci): keep ai review feedback fast and deterministic
This commit is contained in:
@@ -20,6 +20,7 @@ Use only the review contract in this file plus the checked-out PR diff and nearb
|
|||||||
- Do not pad the review with praise or generic best-practice advice.
|
- Do not pad the review with praise or generic best-practice advice.
|
||||||
- Read `.ccs-ai-review-scope.md` first when it is present. It defines the bounded review scope for this run.
|
- Read `.ccs-ai-review-scope.md` first when it is present. It defines the bounded review scope for this run.
|
||||||
- If the mode is `triage`, be explicit in the summary that the review was hotspot-based rather than exhaustive.
|
- If the mode is `triage`, be explicit in the summary that the review was hotspot-based rather than exhaustive.
|
||||||
|
- Do not spend turns narrating your process. Read the bounded inputs, confirm issues, and emit the schema.
|
||||||
|
|
||||||
## Core Questions
|
## Core Questions
|
||||||
|
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ jobs:
|
|||||||
AI_REVIEW_PR_NUMBER: ${{ needs.prepare.outputs.pr_number }}
|
AI_REVIEW_PR_NUMBER: ${{ needs.prepare.outputs.pr_number }}
|
||||||
AI_REVIEW_BASE_REF: ${{ needs.prepare.outputs.base_ref }}
|
AI_REVIEW_BASE_REF: ${{ needs.prepare.outputs.base_ref }}
|
||||||
AI_REVIEW_MODE: ${{ needs.prepare.outputs.review_mode }}
|
AI_REVIEW_MODE: ${{ needs.prepare.outputs.review_mode }}
|
||||||
|
AI_REVIEW_PR_SIZE_CLASS: ${{ needs.prepare.outputs.pr_size_class }}
|
||||||
AI_REVIEW_MAX_TURNS: ${{ needs.prepare.outputs.max_turns }}
|
AI_REVIEW_MAX_TURNS: ${{ needs.prepare.outputs.max_turns }}
|
||||||
AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }}
|
AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }}
|
||||||
AI_REVIEW_SCOPE_FILE: ${{ env.REVIEW_SCOPE_FILE }}
|
AI_REVIEW_SCOPE_FILE: ${{ env.REVIEW_SCOPE_FILE }}
|
||||||
@@ -518,6 +519,7 @@ jobs:
|
|||||||
- Respect the selected review mode budget and scope. Do not try to exhaustively map the repository on auto runs.
|
- Respect the selected review mode budget and scope. Do not try to exhaustively map the repository on auto runs.
|
||||||
- If the mode is `triage`, prioritize hotspots and be explicit that the review was bounded rather than exhaustive.
|
- If the mode is `triage`, prioritize hotspots and be explicit that the review was bounded rather than exhaustive.
|
||||||
- Do not reconstruct the full PR diff for `fast` or `triage` reviews, and do not look for omitted files outside the bounded workspace.
|
- Do not reconstruct the full PR diff for `fast` or `triage` reviews, and do not look for omitted files outside the bounded workspace.
|
||||||
|
- Do not narrate your process or summarize interim reads. Spend turns on bounded file reads and the final JSON output only.
|
||||||
- Return only structured output that matches the provided JSON schema.
|
- Return only structured output that matches the provided JSON schema.
|
||||||
- Do NOT write files.
|
- Do NOT write files.
|
||||||
- Do NOT post GitHub comments yourself.
|
- Do NOT post GitHub comments yourself.
|
||||||
@@ -528,7 +530,8 @@ jobs:
|
|||||||
--model ${{ env.REVIEW_MODEL }}
|
--model ${{ env.REVIEW_MODEL }}
|
||||||
--permission-mode bypassPermissions
|
--permission-mode bypassPermissions
|
||||||
--max-turns ${{ needs.prepare.outputs.max_turns }}
|
--max-turns ${{ needs.prepare.outputs.max_turns }}
|
||||||
--allowedTools "Read"
|
--tools "Read"
|
||||||
|
--disallowedTools "Bash,Edit"
|
||||||
--json-schema '${{ env.REVIEW_OUTPUT_SCHEMA }}'
|
--json-schema '${{ env.REVIEW_OUTPUT_SCHEMA }}'
|
||||||
|
|
||||||
- name: Render review comment
|
- name: Render review comment
|
||||||
@@ -554,6 +557,7 @@ jobs:
|
|||||||
AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }}
|
AI_REVIEW_TIMEOUT_MINUTES: ${{ needs.prepare.outputs.claude_timeout_minutes }}
|
||||||
AI_REVIEW_STATUS: ${{ steps.claude-review.outcome }}
|
AI_REVIEW_STATUS: ${{ steps.claude-review.outcome }}
|
||||||
AI_REVIEW_STRUCTURED_OUTPUT: ${{ steps.claude-review.outputs.structured_output }}
|
AI_REVIEW_STRUCTURED_OUTPUT: ${{ steps.claude-review.outputs.structured_output }}
|
||||||
|
AI_REVIEW_SCOPE_MANIFEST_FILE: ${{ env.REVIEW_SCOPE_MANIFEST_FILE }}
|
||||||
|
|
||||||
- name: Publish review comment
|
- name: Publish review comment
|
||||||
if: always() && steps.app-token.outcome == 'success'
|
if: always() && steps.app-token.outcome == 'success'
|
||||||
|
|||||||
@@ -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) {
|
export function normalizeStructuredOutput(raw) {
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return { ok: false, reason: 'missing structured output' };
|
return { ok: false, reason: 'missing structured output' };
|
||||||
@@ -461,6 +521,7 @@ export function renderIncompleteReview({
|
|||||||
runUrl,
|
runUrl,
|
||||||
runtimeTools,
|
runtimeTools,
|
||||||
turnsUsed,
|
turnsUsed,
|
||||||
|
selectedFiles,
|
||||||
rendering: renderOptions,
|
rendering: renderOptions,
|
||||||
status,
|
status,
|
||||||
}) {
|
}) {
|
||||||
@@ -468,7 +529,7 @@ export function renderIncompleteReview({
|
|||||||
const lines = [
|
const lines = [
|
||||||
'### ⚠️ AI Review Incomplete',
|
'### ⚠️ 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 })}`,
|
`- Outcome: ${describeIncompleteOutcome({ reason, rendering, turnsUsed, status })}`,
|
||||||
];
|
];
|
||||||
@@ -484,6 +545,15 @@ export function renderIncompleteReview({
|
|||||||
if (runtimeBudget) {
|
if (runtimeBudget) {
|
||||||
lines.push(`- Runtime budget: ${escapeMarkdownText(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) {
|
if (runtimeTools?.length) {
|
||||||
lines.push(`- Runtime tools: ${runtimeTools.map(renderCode).join(', ')}`);
|
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 runUrl = env.AI_REVIEW_RUN_URL || '#';
|
||||||
const validation = normalizeStructuredOutput(env.AI_REVIEW_STRUCTURED_OUTPUT);
|
const validation = normalizeStructuredOutput(env.AI_REVIEW_STRUCTURED_OUTPUT);
|
||||||
const metadata = readExecutionMetadata(env.AI_REVIEW_EXECUTION_FILE);
|
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 status = cleanText(env.AI_REVIEW_STATUS).toLowerCase() || null;
|
||||||
const rendering = normalizeRenderingMetadata({
|
const rendering = normalizeRenderingMetadata({
|
||||||
mode: env.AI_REVIEW_MODE,
|
mode: env.AI_REVIEW_MODE,
|
||||||
@@ -521,6 +592,7 @@ export function writeReviewFromEnv(env = process.env) {
|
|||||||
runUrl,
|
runUrl,
|
||||||
runtimeTools: metadata.runtimeTools,
|
runtimeTools: metadata.runtimeTools,
|
||||||
turnsUsed: metadata.turnsUsed,
|
turnsUsed: metadata.turnsUsed,
|
||||||
|
selectedFiles,
|
||||||
rendering,
|
rendering,
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
const MODE_LIMITS = {
|
const MODE_LIMITS = {
|
||||||
fast: { maxFiles: 16, maxChangedLines: 900, maxPatchLines: 90, maxPatchChars: 7000 },
|
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 },
|
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 = {
|
const MODE_LABELS = {
|
||||||
fast: 'diff-focused bounded review',
|
fast: 'diff-focused bounded review',
|
||||||
triage: 'hotspot-based bounded review (non-exhaustive)',
|
triage: 'hotspot-based bounded review (non-exhaustive)',
|
||||||
@@ -35,6 +39,13 @@ function cleanText(value) {
|
|||||||
return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ') : '';
|
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) {
|
function escapeMarkdown(value) {
|
||||||
return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>|])/g, '\\$1');
|
return cleanText(value).replace(/\\/g, '\\\\').replace(/([`*_{}[\]<>|])/g, '\\$1');
|
||||||
}
|
}
|
||||||
@@ -127,8 +138,17 @@ export function normalizePullFiles(files) {
|
|||||||
}).map((file) => ({ ...file, score: scoreFile(file) }));
|
}).map((file) => ({ ...file, score: scoreFile(file) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildReviewScope(files, mode) {
|
function resolveModeLimits(mode, sizeClass) {
|
||||||
const limits = MODE_LIMITS[mode] || MODE_LIMITS.fast;
|
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 reviewable = files.filter((file) => file.reviewable);
|
||||||
const lowSignal = files.filter((file) => !file.reviewable);
|
const lowSignal = files.filter((file) => !file.reviewable);
|
||||||
const usingChangedFallback = reviewable.length === 0;
|
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 prNumber = Number.parseInt(cleanText(env.AI_REVIEW_PR_NUMBER), 10);
|
||||||
const baseRef = cleanText(env.AI_REVIEW_BASE_REF || 'dev');
|
const baseRef = cleanText(env.AI_REVIEW_BASE_REF || 'dev');
|
||||||
const mode = cleanText(env.AI_REVIEW_MODE || 'fast').toLowerCase();
|
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 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 timeoutMinutes = Number.parseInt(cleanText(env.AI_REVIEW_TIMEOUT_MINUTES || '0'), 10) || 0;
|
||||||
const outputFile = env.AI_REVIEW_SCOPE_FILE || '.ccs-ai-review-scope.md';
|
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(
|
const files = normalizePullFiles(
|
||||||
await collectPullRequestFiles(`${apiUrl}/repos/${repository}/pulls/${prNumber}/files?per_page=100`, fetchPage)
|
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 });
|
const markdown = renderReviewScope({ prNumber, baseRef, turnBudget, timeoutMinutes, scope });
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
|
fs.mkdirSync(path.dirname(outputFile), { recursive: true });
|
||||||
|
|||||||
@@ -35,10 +35,16 @@ describe('ai-review workflow', () => {
|
|||||||
expect(claudeReviewStep?.with?.path_to_claude_code_executable).toBe(
|
expect(claudeReviewStep?.with?.path_to_claude_code_executable).toBe(
|
||||||
'${{ steps.toolchain.outputs.claude_path }}'
|
'${{ steps.toolchain.outputs.claude_path }}'
|
||||||
);
|
);
|
||||||
|
expect(claudeReviewStep?.with?.claude_args).toContain('--tools "Read"');
|
||||||
|
expect(claudeReviewStep?.with?.claude_args).toContain('--disallowedTools "Bash,Edit"');
|
||||||
|
|
||||||
const promptStep = steps.find((step) => step.id === 'review-prompt');
|
const promptStep = steps.find((step) => step.id === 'review-prompt');
|
||||||
expect(promptStep).toBeDefined();
|
expect(promptStep).toBeDefined();
|
||||||
expect(promptStep?.run).toContain("printf '%s\\n' \\");
|
expect(promptStep?.run).toContain("printf '%s\\n' \\");
|
||||||
expect(promptStep?.run).not.toContain("| sed 's/^ //'");
|
expect(promptStep?.run).not.toContain("| sed 's/^ //'");
|
||||||
|
|
||||||
|
const reviewScopeStep = steps.find((step) => step.id === 'review-scope');
|
||||||
|
expect(reviewScopeStep).toBeDefined();
|
||||||
|
expect(reviewScopeStep?.env?.AI_REVIEW_PR_SIZE_CLASS).toBe('${{ needs.prepare.outputs.pr_size_class }}');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ describe('normalize-ai-review-output', () => {
|
|||||||
test('writes a safe incomplete comment with mode and runtime context instead of leaking raw assistant text', () => {
|
test('writes a safe incomplete comment with mode and runtime context instead of leaking raw assistant text', () => {
|
||||||
withTempDir('ai-review-', (tempDir) => {
|
withTempDir('ai-review-', (tempDir) => {
|
||||||
const executionFile = path.join(tempDir, 'claude-execution-output.json');
|
const executionFile = path.join(tempDir, 'claude-execution-output.json');
|
||||||
|
const manifestFile = path.join(tempDir, 'selected-files.txt');
|
||||||
const outputFile = path.join(tempDir, 'pr_review.md');
|
const outputFile = path.join(tempDir, 'pr_review.md');
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -160,6 +161,10 @@ describe('normalize-ai-review-output', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
manifestFile,
|
||||||
|
['.github/workflows/ai-review.yml', 'scripts/github/prepare-ai-review-scope.mjs', 'src/ccs.ts'].join('\n')
|
||||||
|
);
|
||||||
|
|
||||||
const result = reviewOutput.writeReviewFromEnv({
|
const result = reviewOutput.writeReviewFromEnv({
|
||||||
AI_REVIEW_EXECUTION_FILE: executionFile,
|
AI_REVIEW_EXECUTION_FILE: executionFile,
|
||||||
@@ -173,6 +178,7 @@ describe('normalize-ai-review-output', () => {
|
|||||||
AI_REVIEW_TIMEOUT_MINUTES: '5',
|
AI_REVIEW_TIMEOUT_MINUTES: '5',
|
||||||
AI_REVIEW_OUTPUT_FILE: outputFile,
|
AI_REVIEW_OUTPUT_FILE: outputFile,
|
||||||
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592',
|
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592',
|
||||||
|
AI_REVIEW_SCOPE_MANIFEST_FILE: manifestFile,
|
||||||
AI_REVIEW_STRUCTURED_OUTPUT: '',
|
AI_REVIEW_STRUCTURED_OUTPUT: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,6 +192,11 @@ describe('normalize-ai-review-output', () => {
|
|||||||
expect(markdown).toContain('- Review mode: `triage` (hotspot-based bounded review (non-exhaustive))');
|
expect(markdown).toContain('- Review mode: `triage` (hotspot-based bounded review (non-exhaustive))');
|
||||||
expect(markdown).toContain('- Review scope: 10/46 reviewable files; 700/2310 reviewable changed lines');
|
expect(markdown).toContain('- Review scope: 10/46 reviewable files; 700/2310 reviewable changed lines');
|
||||||
expect(markdown).toContain('- Runtime budget: 25 turns / 5 minutes');
|
expect(markdown).toContain('- Runtime budget: 25 turns / 5 minutes');
|
||||||
|
expect(markdown).toContain(
|
||||||
|
'- Hotspot files in this pass: `.github/workflows/ai-review.yml`, `scripts/github/prepare-ai-review-scope.mjs`, `src/ccs.ts`'
|
||||||
|
);
|
||||||
|
expect(markdown).toContain('- Remaining reviewable scope not fully covered: 36 files; 1610 changed lines');
|
||||||
|
expect(markdown).toContain('- Manual follow-up: Focus manual review on the hotspot files above');
|
||||||
expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`');
|
expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`');
|
||||||
expect(markdown).toContain('Turns used: 25');
|
expect(markdown).toContain('Turns used: 25');
|
||||||
expect(markdown).not.toContain('Now let me verify the findings');
|
expect(markdown).not.toContain('Now let me verify the findings');
|
||||||
@@ -195,6 +206,7 @@ describe('normalize-ai-review-output', () => {
|
|||||||
test('uses a timeout-safe fallback message when the bounded review hits the workflow cap', () => {
|
test('uses a timeout-safe fallback message when the bounded review hits the workflow cap', () => {
|
||||||
withTempDir('ai-review-', (tempDir) => {
|
withTempDir('ai-review-', (tempDir) => {
|
||||||
const executionFile = path.join(tempDir, 'claude-execution-output.json');
|
const executionFile = path.join(tempDir, 'claude-execution-output.json');
|
||||||
|
const manifestFile = path.join(tempDir, 'selected-files.txt');
|
||||||
const outputFile = path.join(tempDir, 'pr_review.md');
|
const outputFile = path.join(tempDir, 'pr_review.md');
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -209,6 +221,7 @@ describe('normalize-ai-review-output', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
fs.writeFileSync(manifestFile, ['src/commands/help-command.ts', 'src/ccs.ts'].join('\n'));
|
||||||
|
|
||||||
const result = reviewOutput.writeReviewFromEnv({
|
const result = reviewOutput.writeReviewFromEnv({
|
||||||
AI_REVIEW_EXECUTION_FILE: executionFile,
|
AI_REVIEW_EXECUTION_FILE: executionFile,
|
||||||
@@ -223,6 +236,7 @@ describe('normalize-ai-review-output', () => {
|
|||||||
AI_REVIEW_STATUS: 'cancelled',
|
AI_REVIEW_STATUS: 'cancelled',
|
||||||
AI_REVIEW_OUTPUT_FILE: outputFile,
|
AI_REVIEW_OUTPUT_FILE: outputFile,
|
||||||
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592',
|
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/23758377592',
|
||||||
|
AI_REVIEW_SCOPE_MANIFEST_FILE: manifestFile,
|
||||||
AI_REVIEW_STRUCTURED_OUTPUT: '',
|
AI_REVIEW_STRUCTURED_OUTPUT: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,6 +249,10 @@ describe('normalize-ai-review-output', () => {
|
|||||||
expect(markdown).toContain('- Review mode: `fast` (diff-focused bounded review)');
|
expect(markdown).toContain('- Review mode: `fast` (diff-focused bounded review)');
|
||||||
expect(markdown).toContain('- Review scope: 6/52 reviewable files; 640/2480 reviewable changed lines');
|
expect(markdown).toContain('- Review scope: 6/52 reviewable files; 640/2480 reviewable changed lines');
|
||||||
expect(markdown).toContain('- Runtime budget: 5 turns / 5 minutes');
|
expect(markdown).toContain('- Runtime budget: 5 turns / 5 minutes');
|
||||||
|
expect(markdown).toContain(
|
||||||
|
'- Hotspot files in this pass: `src/commands/help-command.ts`, `src/ccs.ts`'
|
||||||
|
);
|
||||||
|
expect(markdown).toContain('- Remaining reviewable scope not fully covered: 46 files; 1840 changed lines');
|
||||||
expect(markdown).not.toContain('Partial draft that should never reach the published markdown.');
|
expect(markdown).not.toContain('Partial draft that should never reach the published markdown.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,6 +113,70 @@ describe('prepare-ai-review-scope', () => {
|
|||||||
expect(scope.scopeLabel).toBe('changed files');
|
expect(scope.scopeLabel).toBe('changed files');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shrinks triage scope for xlarge PRs to keep hotspot reviews fast', () => {
|
||||||
|
const scope = reviewScope.buildReviewScope(
|
||||||
|
reviewScope.normalizePullFiles([
|
||||||
|
{
|
||||||
|
filename: '.github/workflows/ai-review.yml',
|
||||||
|
status: 'modified',
|
||||||
|
additions: 130,
|
||||||
|
deletions: 30,
|
||||||
|
changes: 160,
|
||||||
|
patch: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'scripts/github/prepare-ai-review-scope.mjs',
|
||||||
|
status: 'modified',
|
||||||
|
additions: 120,
|
||||||
|
deletions: 20,
|
||||||
|
changes: 140,
|
||||||
|
patch: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'src/commands/help-command.ts',
|
||||||
|
status: 'modified',
|
||||||
|
additions: 70,
|
||||||
|
deletions: 10,
|
||||||
|
changes: 80,
|
||||||
|
patch: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'src/ccs.ts',
|
||||||
|
status: 'modified',
|
||||||
|
additions: 50,
|
||||||
|
deletions: 15,
|
||||||
|
changes: 65,
|
||||||
|
patch: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'tests/unit/commands/help-command.test.ts',
|
||||||
|
status: 'modified',
|
||||||
|
additions: 40,
|
||||||
|
deletions: 5,
|
||||||
|
changes: 45,
|
||||||
|
patch: '@@ -1 +1 @@\n-old\n+new',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
'triage',
|
||||||
|
{ sizeClass: 'xlarge' }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scope.selected.length).toBeLessThanOrEqual(4);
|
||||||
|
expect(scope.selected.map((file: { filename: string }) => file.filename)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'.github/workflows/ai-review.yml',
|
||||||
|
'scripts/github/prepare-ai-review-scope.mjs',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(scope.selectedChanges).toBeLessThanOrEqual(360);
|
||||||
|
expect(scope.limits).toEqual({
|
||||||
|
maxFiles: 4,
|
||||||
|
maxChangedLines: 360,
|
||||||
|
maxPatchLines: 45,
|
||||||
|
maxPatchChars: 3200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('renders deterministic scope metadata and fences patch content safely', () => {
|
test('renders deterministic scope metadata and fences patch content safely', () => {
|
||||||
const oversizedPatch = ['+line 1', '```', ...Array.from({ length: 118 }, (_, index) => `+line ${index + 2}`)].join('\n');
|
const oversizedPatch = ['+line 1', '```', ...Array.from({ length: 118 }, (_, index) => `+line ${index + 2}`)].join('\n');
|
||||||
const scope = reviewScope.buildReviewScope(
|
const scope = reviewScope.buildReviewScope(
|
||||||
|
|||||||
Reference in New Issue
Block a user