mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
hotfix(ci): restore reliable structured ai review comments
This commit is contained in:
@@ -15,36 +15,42 @@ function loadWorkflow() {
|
||||
}
|
||||
|
||||
describe('ai-review workflow', () => {
|
||||
test('uses the self-hosted Claude binary instead of reinstalling it on internal PRs', () => {
|
||||
test('pins automatic review helpers to trusted base-branch assets', () => {
|
||||
const workflow = loadWorkflow();
|
||||
const steps = workflow.jobs.review.steps;
|
||||
|
||||
const toolchainStep = steps.find((step) => step.id === 'toolchain');
|
||||
expect(toolchainStep).toBeDefined();
|
||||
expect(toolchainStep?.name).toBe('Resolve self-hosted Claude executable');
|
||||
expect(toolchainStep?.run).toContain('command -v claude');
|
||||
expect(toolchainStep?.run).toContain('"/home/github-runner/.local/bin/claude"');
|
||||
expect(toolchainStep?.run).toContain('"/root/.local/bin/claude"');
|
||||
expect(toolchainStep?.run).toContain('Missing self-hosted Claude executable. Checked:');
|
||||
expect(toolchainStep?.run).toContain('echo "claude_path=$CLAUDE_PATH" >> "$GITHUB_OUTPUT"');
|
||||
|
||||
const claudeReviewStep = steps.find((step) => step.id === 'claude-review');
|
||||
expect(claudeReviewStep).toBeDefined();
|
||||
expect(claudeReviewStep?.uses).toBe('anthropics/claude-code-action@v1');
|
||||
expect(claudeReviewStep?.['continue-on-error']).toBe(true);
|
||||
expect(claudeReviewStep?.with?.path_to_claude_code_executable).toBe(
|
||||
'${{ 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');
|
||||
expect(promptStep).toBeDefined();
|
||||
expect(promptStep?.run).toContain("printf '%s\\n' \\");
|
||||
expect(promptStep?.run).not.toContain("| sed 's/^ //'");
|
||||
expect(promptStep?.run).toContain('build-ai-review-packet.mjs');
|
||||
expect(promptStep?.run).toContain('run-ai-review-direct.mjs');
|
||||
expect(promptStep?.run).not.toContain('bootstrapping from checked-out internal branch asset');
|
||||
expect(promptStep?.run).toContain('using minimal packet builder');
|
||||
expect(promptStep?.run).toContain('using incomplete-review fallback');
|
||||
|
||||
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 }}');
|
||||
|
||||
const packetStep = steps.find((step) => step.id === 'review-packet');
|
||||
expect(packetStep).toBeDefined();
|
||||
expect(packetStep?.run).toContain('node "$AI_REVIEW_PACKET_SCRIPT"');
|
||||
expect(packetStep?.env?.AI_REVIEW_PACKET_FILE).toBe('${{ env.REVIEW_PACKET_FILE }}');
|
||||
expect(packetStep?.env?.AI_REVIEW_PACKET_INCLUDED_MANIFEST_FILE).toBe(
|
||||
'${{ env.REVIEW_PACKET_INCLUDED_MANIFEST_FILE }}'
|
||||
);
|
||||
|
||||
const directReviewStep = steps.find((step) => step.id === 'direct-review');
|
||||
expect(directReviewStep).toBeDefined();
|
||||
expect(directReviewStep?.uses).toBeUndefined();
|
||||
expect(directReviewStep?.run).toContain('node "$AI_REVIEW_DIRECT_REVIEW_SCRIPT"');
|
||||
expect(directReviewStep?.['continue-on-error']).toBe(true);
|
||||
expect(directReviewStep?.env?.AI_REVIEW_PROMPT).toBe('${{ steps.review-prompt.outputs.content }}');
|
||||
expect(directReviewStep?.env?.AI_REVIEW_PACKET_INCLUDED_MANIFEST_FILE).toBe(
|
||||
'${{ steps.review-packet.outputs.packet_included_manifest_file }}'
|
||||
);
|
||||
expect(directReviewStep?.env?.AI_REVIEW_PACKET_INCLUDED_FILES).toBe(
|
||||
'${{ steps.review-packet.outputs.packet_included_files }}'
|
||||
);
|
||||
expect(directReviewStep?.env?.AI_REVIEW_REQUEST_BUFFER_MS).toBe(45000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const packetBuilder = await import('../../../../scripts/github/build-ai-review-packet.mjs');
|
||||
|
||||
function withTempDir(prefix: string, run: (tempDir: string) => void) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
try {
|
||||
run(tempDir);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('build-ai-review-packet', () => {
|
||||
test('adds stable line numbers to packet content blocks', () => {
|
||||
expect(packetBuilder.addLineNumbers('first\nsecond')).toBe(' 1 | first\n 2 | second');
|
||||
});
|
||||
|
||||
test('builds a packet with current and base snapshots for selected files', () => {
|
||||
withTempDir('ai-review-packet-', (tempDir) => {
|
||||
const rootDir = path.join(tempDir, 'repo');
|
||||
const baseDir = path.join(tempDir, '.ccs-ai-review-base');
|
||||
fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, 'src/example.ts'), 'export const value = 2;\nconsole.log(value);\n');
|
||||
fs.writeFileSync(path.join(baseDir, 'src/example.ts'), 'export const value = 1;\n');
|
||||
|
||||
const packet = packetBuilder.buildReviewPacket({
|
||||
scopeMarkdown: '# AI Review Scope\n\n- Selected files: 1 of 1 reviewable files',
|
||||
files: ['src/example.ts'],
|
||||
rootDir,
|
||||
baseDir,
|
||||
maxChars: 20000,
|
||||
perFileMaxLines: 40,
|
||||
perFileMaxChars: 4000,
|
||||
}).packet;
|
||||
|
||||
expect(packet).toContain('# AI Review Packet');
|
||||
expect(packet).toContain('## File: `src/example.ts`');
|
||||
expect(packet).toContain('### Current file content');
|
||||
expect(packet).toContain(' 1 | export const value = 2;');
|
||||
expect(packet).toContain('### Base snapshot content');
|
||||
expect(packet).toContain(' 1 | export const value = 1;');
|
||||
expect(packet).toContain('- Selected files in packet: 1 of 1');
|
||||
});
|
||||
});
|
||||
|
||||
test('omits file snapshots when the global packet budget is exceeded', () => {
|
||||
withTempDir('ai-review-packet-', (tempDir) => {
|
||||
const rootDir = path.join(tempDir, 'repo');
|
||||
const baseDir = path.join(tempDir, '.ccs-ai-review-base');
|
||||
fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(rootDir, 'src/one.ts'), 'export const one = 1;\n'.repeat(30));
|
||||
fs.writeFileSync(path.join(rootDir, 'src/two.ts'), 'export const two = 2;\n'.repeat(30));
|
||||
fs.writeFileSync(path.join(baseDir, 'src/one.ts'), 'export const one = 0;\n');
|
||||
fs.writeFileSync(path.join(baseDir, 'src/two.ts'), 'export const two = 0;\n');
|
||||
|
||||
const packet = packetBuilder.buildReviewPacket({
|
||||
scopeMarkdown: '# AI Review Scope\n\n' + '- review scope metadata\n'.repeat(60),
|
||||
files: ['src/one.ts', 'src/two.ts'],
|
||||
rootDir,
|
||||
baseDir,
|
||||
maxChars: 1500,
|
||||
perFileMaxLines: 120,
|
||||
perFileMaxChars: 8000,
|
||||
}).packet;
|
||||
|
||||
expect(packet).not.toContain('## File: `src/two.ts`');
|
||||
expect(packet).toContain('Additional selected files omitted from packet due to the global context budget: 2');
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the full packet within the configured maxChars budget', () => {
|
||||
withTempDir('ai-review-packet-', (tempDir) => {
|
||||
const rootDir = path.join(tempDir, 'repo');
|
||||
const baseDir = path.join(tempDir, '.ccs-ai-review-base');
|
||||
fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
|
||||
|
||||
fs.writeFileSync(path.join(rootDir, 'src/huge.ts'), 'export const huge = 1;\n'.repeat(80));
|
||||
fs.writeFileSync(path.join(baseDir, 'src/huge.ts'), 'export const base = 0;\n'.repeat(80));
|
||||
|
||||
const result = packetBuilder.buildReviewPacket({
|
||||
scopeMarkdown: '# AI Review Scope\n\n' + '- selected file\n'.repeat(200),
|
||||
files: ['src/huge.ts'],
|
||||
rootDir,
|
||||
baseDir,
|
||||
maxChars: 1000,
|
||||
perFileMaxLines: 200,
|
||||
perFileMaxChars: 12000,
|
||||
});
|
||||
|
||||
expect(result.packet.length).toBeLessThanOrEqual(1000);
|
||||
expect(result.totalSelectedFiles).toBe(1);
|
||||
expect(result.includedFilePaths).toEqual([]);
|
||||
expect(result.packet).toContain('Scope metadata was truncated to preserve packet budget for file contents.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,13 +104,16 @@ describe('normalize-ai-review-output', () => {
|
||||
reviewableFiles: 34,
|
||||
selectedChanges: 620,
|
||||
reviewableChanges: 2140,
|
||||
packetIncludedFiles: 6,
|
||||
packetTotalFiles: 8,
|
||||
packetOmittedFiles: 2,
|
||||
maxTurns: 6,
|
||||
timeoutMinutes: 5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(markdown).toContain(
|
||||
'> 🧭 Review context: mode `triage`; hotspot-based bounded review (non-exhaustive); scope 8/34 reviewable files; 620/2140 reviewable changed lines; turn budget 6 turns; workflow cap 5 minutes.'
|
||||
'> 🧭 Review context: mode `triage`; expanded packaged review with broader coverage; scope 8/34 reviewable files; 620/2140 reviewable changed lines; packet 6/8 selected files included in the final review packet; 2 selected files omitted for packet budget; turn budget 6 turns; workflow cap 5 minutes.'
|
||||
);
|
||||
expect(markdown).toContain('**⚠️ APPROVED WITH NOTES**');
|
||||
});
|
||||
@@ -174,6 +177,9 @@ describe('normalize-ai-review-output', () => {
|
||||
AI_REVIEW_REVIEWABLE_FILES: '46',
|
||||
AI_REVIEW_SELECTED_CHANGES: '700',
|
||||
AI_REVIEW_REVIEWABLE_CHANGES: '2310',
|
||||
AI_REVIEW_PACKET_INCLUDED_FILES: '7',
|
||||
AI_REVIEW_PACKET_TOTAL_FILES: '10',
|
||||
AI_REVIEW_PACKET_OMITTED_FILES: '3',
|
||||
AI_REVIEW_MAX_TURNS: '25',
|
||||
AI_REVIEW_TIMEOUT_MINUTES: '5',
|
||||
AI_REVIEW_OUTPUT_FILE: outputFile,
|
||||
@@ -189,14 +195,17 @@ describe('normalize-ai-review-output', () => {
|
||||
expect(markdown).toContain(
|
||||
'The `triage` review reached its 25-turn runtime budget before it produced validated structured output.'
|
||||
);
|
||||
expect(markdown).toContain('- Review mode: `triage` (hotspot-based bounded review (non-exhaustive))');
|
||||
expect(markdown).toContain('- Review mode: `triage` (expanded packaged review with broader coverage)');
|
||||
expect(markdown).toContain('- Review scope: 10/46 reviewable files; 700/2310 reviewable changed lines');
|
||||
expect(markdown).toContain(
|
||||
'- Packet coverage: 7/10 selected files included in the final review packet; 3 selected files omitted for packet budget'
|
||||
);
|
||||
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('- Remaining reviewable scope not fully covered: 39 files');
|
||||
expect(markdown).toContain('- Manual follow-up: Focus manual review on the selected files above');
|
||||
expect(markdown).toContain('Runtime tools: `Bash`, `Edit`, `Read`');
|
||||
expect(markdown).toContain('Turns used: 25');
|
||||
expect(markdown).not.toContain('Now let me verify the findings');
|
||||
@@ -231,6 +240,9 @@ describe('normalize-ai-review-output', () => {
|
||||
AI_REVIEW_REVIEWABLE_FILES: '52',
|
||||
AI_REVIEW_SELECTED_CHANGES: '640',
|
||||
AI_REVIEW_REVIEWABLE_CHANGES: '2480',
|
||||
AI_REVIEW_PACKET_INCLUDED_FILES: '5',
|
||||
AI_REVIEW_PACKET_TOTAL_FILES: '6',
|
||||
AI_REVIEW_PACKET_OMITTED_FILES: '1',
|
||||
AI_REVIEW_MAX_TURNS: '5',
|
||||
AI_REVIEW_TIMEOUT_MINUTES: '5',
|
||||
AI_REVIEW_STATUS: 'cancelled',
|
||||
@@ -246,13 +258,16 @@ describe('normalize-ai-review-output', () => {
|
||||
expect(markdown).toContain(
|
||||
'The `fast` review hit the workflow runtime cap before it produced validated structured output. The run stayed bounded to 5 minutes.'
|
||||
);
|
||||
expect(markdown).toContain('- Review mode: `fast` (diff-focused bounded review)');
|
||||
expect(markdown).toContain('- Review mode: `fast` (selected-file packaged review)');
|
||||
expect(markdown).toContain('- Review scope: 6/52 reviewable files; 640/2480 reviewable changed lines');
|
||||
expect(markdown).toContain(
|
||||
'- Packet coverage: 5/6 selected files included in the final review packet; 1 selected file omitted for packet budget'
|
||||
);
|
||||
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).toContain('- Remaining reviewable scope not fully covered: 47 files');
|
||||
expect(markdown).not.toContain('Partial draft that should never reach the published markdown.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('prepare-ai-review-scope', () => {
|
||||
expect(scope.scopeLabel).toBe('changed files');
|
||||
});
|
||||
|
||||
test('shrinks triage scope for xlarge PRs to keep hotspot reviews fast', () => {
|
||||
test('keeps broad triage coverage for xlarge PRs when the review packet can still fit', () => {
|
||||
const scope = reviewScope.buildReviewScope(
|
||||
reviewScope.normalizePullFiles([
|
||||
{
|
||||
@@ -161,19 +161,19 @@ describe('prepare-ai-review-scope', () => {
|
||||
{ sizeClass: 'xlarge' }
|
||||
);
|
||||
|
||||
expect(scope.selected.length).toBeLessThanOrEqual(4);
|
||||
expect(scope.selected.length).toBe(5);
|
||||
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.selectedChanges).toBe(490);
|
||||
expect(scope.limits).toEqual({
|
||||
maxFiles: 4,
|
||||
maxChangedLines: 360,
|
||||
maxPatchLines: 45,
|
||||
maxPatchChars: 3200,
|
||||
maxFiles: 24,
|
||||
maxChangedLines: 2400,
|
||||
maxPatchLines: 140,
|
||||
maxPatchChars: 12000,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -202,10 +202,12 @@ describe('prepare-ai-review-scope', () => {
|
||||
});
|
||||
|
||||
expect(markdown).toContain('# AI Review Scope');
|
||||
expect(markdown).toContain('- Mode: `triage` (hotspot-based bounded review (non-exhaustive))');
|
||||
expect(markdown).toContain('- Mode: `triage` (expanded packaged review with broader coverage)');
|
||||
expect(markdown).toContain('- Selected files: 1 of 1 reviewable files (1 total changed files)');
|
||||
expect(markdown).toContain('- Turn budget: 6');
|
||||
expect(markdown).toContain('- Workflow cap: 5 minutes');
|
||||
expect(markdown).toContain('````diff');
|
||||
expect(markdown).toContain('```');
|
||||
expect(markdown).toContain('... patch trimmed for bounded review ...');
|
||||
expect(markdown).not.toContain('... patch trimmed for bounded review ...');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const directReview = await import('../../../../scripts/github/run-ai-review-direct.mjs');
|
||||
|
||||
function withTempDir(prefix: string, run: (tempDir: string) => Promise<void> | void) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
return Promise.resolve()
|
||||
.then(() => run(tempDir))
|
||||
.finally(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
|
||||
function createResponse(text: string) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('run-ai-review-direct', () => {
|
||||
test('reserves the tail of the step budget for deterministic fallback publication', () => {
|
||||
const window = directReview.resolveAttemptWindow({
|
||||
timeoutMinutes: 8,
|
||||
configuredTimeoutMs: 240000,
|
||||
requestBufferMs: 45000,
|
||||
minAttemptMs: 20000,
|
||||
startedAt: 0,
|
||||
now: 420001,
|
||||
});
|
||||
|
||||
expect(window.canAttempt).toBe(false);
|
||||
expect(window.timeoutMs).toBeNull();
|
||||
});
|
||||
|
||||
test('extracts json candidates from fenced or chatty model replies', () => {
|
||||
expect(directReview.extractJsonCandidate('```json\n{"ok":true}\n```')).toBe('{"ok":true}');
|
||||
expect(directReview.extractJsonCandidate('Here is the result:\n{"ok":true}\nThanks')).toBe('{"ok":true}');
|
||||
});
|
||||
|
||||
test('uses the included-manifest files for fallback coverage instead of assuming a prefix slice', async () => {
|
||||
await withTempDir('ai-review-direct-', async (tempDir) => {
|
||||
const outputFile = path.join(tempDir, 'review.md');
|
||||
const logFile = path.join(tempDir, 'attempts.json');
|
||||
const packetFile = path.join(tempDir, 'packet.md');
|
||||
const manifestFile = path.join(tempDir, 'selected-files.txt');
|
||||
const includedManifestFile = path.join(tempDir, 'included-files.txt');
|
||||
fs.writeFileSync(packetFile, '# AI Review Packet\n\npacket body\n');
|
||||
fs.writeFileSync(manifestFile, 'src/large.ts\nsrc/small.ts\n');
|
||||
fs.writeFileSync(includedManifestFile, 'src/small.ts\n');
|
||||
|
||||
const result = await directReview.writeDirectReviewFromEnv(
|
||||
{
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
REVIEW_MODEL: 'glm-5.1',
|
||||
GITHUB_REPOSITORY: 'kaitranntt/ccs',
|
||||
AI_REVIEW_PROMPT: 'You are a reviewer.',
|
||||
AI_REVIEW_PACKET_FILE: packetFile,
|
||||
AI_REVIEW_SCOPE_MANIFEST_FILE: manifestFile,
|
||||
AI_REVIEW_PACKET_INCLUDED_MANIFEST_FILE: includedManifestFile,
|
||||
AI_REVIEW_OUTPUT_FILE: outputFile,
|
||||
AI_REVIEW_LOG_FILE: logFile,
|
||||
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/1',
|
||||
AI_REVIEW_MODE: 'triage',
|
||||
AI_REVIEW_SELECTED_FILES: '2',
|
||||
AI_REVIEW_REVIEWABLE_FILES: '4',
|
||||
AI_REVIEW_SELECTED_CHANGES: '120',
|
||||
AI_REVIEW_REVIEWABLE_CHANGES: '220',
|
||||
AI_REVIEW_SCOPE_LABEL: 'reviewable files',
|
||||
AI_REVIEW_PACKET_INCLUDED_FILES: '1',
|
||||
AI_REVIEW_PACKET_TOTAL_FILES: '2',
|
||||
AI_REVIEW_PACKET_OMITTED_FILES: '1',
|
||||
AI_REVIEW_TIMEOUT_MINUTES: '8',
|
||||
AI_REVIEW_REQUEST_TIMEOUT_MS: '50',
|
||||
AI_REVIEW_MAX_ATTEMPTS: '1',
|
||||
AI_REVIEW_PR_NUMBER: '888',
|
||||
},
|
||||
async () => {
|
||||
throw new Error('forced direct review failure');
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.usedFallback).toBe(true);
|
||||
const markdown = fs.readFileSync(outputFile, 'utf8');
|
||||
expect(markdown).toContain('`src/small.ts`');
|
||||
expect(markdown).not.toContain('`src/large.ts`');
|
||||
});
|
||||
});
|
||||
|
||||
test('writes the structured review markdown when the first response validates', async () => {
|
||||
await withTempDir('ai-review-direct-', async (tempDir) => {
|
||||
const outputFile = path.join(tempDir, 'review.md');
|
||||
const logFile = path.join(tempDir, 'attempts.json');
|
||||
const packetFile = path.join(tempDir, 'packet.md');
|
||||
const manifestFile = path.join(tempDir, 'selected-files.txt');
|
||||
const includedManifestFile = path.join(tempDir, 'included-files.txt');
|
||||
fs.writeFileSync(packetFile, '# AI Review Packet\n\npacket body\n');
|
||||
fs.writeFileSync(manifestFile, 'src/example.ts\n');
|
||||
fs.writeFileSync(includedManifestFile, 'src/example.ts\n');
|
||||
|
||||
const result = await directReview.writeDirectReviewFromEnv(
|
||||
{
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
REVIEW_MODEL: 'glm-5.1',
|
||||
GITHUB_REPOSITORY: 'kaitranntt/ccs',
|
||||
AI_REVIEW_PROMPT: 'You are a reviewer.',
|
||||
AI_REVIEW_PACKET_FILE: packetFile,
|
||||
AI_REVIEW_SCOPE_MANIFEST_FILE: manifestFile,
|
||||
AI_REVIEW_PACKET_INCLUDED_MANIFEST_FILE: includedManifestFile,
|
||||
AI_REVIEW_OUTPUT_FILE: outputFile,
|
||||
AI_REVIEW_LOG_FILE: logFile,
|
||||
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/1',
|
||||
AI_REVIEW_MODE: 'fast',
|
||||
AI_REVIEW_SELECTED_FILES: '1',
|
||||
AI_REVIEW_REVIEWABLE_FILES: '1',
|
||||
AI_REVIEW_SELECTED_CHANGES: '18',
|
||||
AI_REVIEW_REVIEWABLE_CHANGES: '18',
|
||||
AI_REVIEW_SCOPE_LABEL: 'reviewable files',
|
||||
AI_REVIEW_PACKET_INCLUDED_FILES: '1',
|
||||
AI_REVIEW_PACKET_TOTAL_FILES: '1',
|
||||
AI_REVIEW_PACKET_OMITTED_FILES: '0',
|
||||
AI_REVIEW_TIMEOUT_MINUTES: '8',
|
||||
AI_REVIEW_PR_NUMBER: '888',
|
||||
},
|
||||
async () =>
|
||||
createResponse(
|
||||
JSON.stringify({
|
||||
summary: 'The PR looks correct.',
|
||||
findings: [],
|
||||
securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }],
|
||||
ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'na', notes: 'No CLI changes.' }],
|
||||
informational: ['The packet covered the selected file.'],
|
||||
strengths: ['The response validated on the first attempt.'],
|
||||
overallAssessment: 'approved',
|
||||
overallRationale: 'No confirmed regressions remain.',
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(result.usedFallback).toBe(false);
|
||||
expect(fs.readFileSync(outputFile, 'utf8')).toContain('### 📋 Summary');
|
||||
expect(fs.readFileSync(outputFile, 'utf8')).toContain('**✅ APPROVED**');
|
||||
expect(fs.readFileSync(outputFile, 'utf8')).toContain(
|
||||
'packet 1/1 selected files included in the final review packet'
|
||||
);
|
||||
expect(JSON.parse(fs.readFileSync(logFile, 'utf8')).attempts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('retries with a repair attempt when the first response is invalid', async () => {
|
||||
await withTempDir('ai-review-direct-', async (tempDir) => {
|
||||
const outputFile = path.join(tempDir, 'review.md');
|
||||
const logFile = path.join(tempDir, 'attempts.json');
|
||||
const packetFile = path.join(tempDir, 'packet.md');
|
||||
const manifestFile = path.join(tempDir, 'selected-files.txt');
|
||||
const includedManifestFile = path.join(tempDir, 'included-files.txt');
|
||||
fs.writeFileSync(packetFile, '# AI Review Packet\n\npacket body\n');
|
||||
fs.writeFileSync(manifestFile, 'src/example.ts\n');
|
||||
fs.writeFileSync(includedManifestFile, 'src/example.ts\n');
|
||||
|
||||
const responses = [
|
||||
createResponse('{"summary":"missing required fields"}'),
|
||||
createResponse(
|
||||
JSON.stringify({
|
||||
summary: 'The PR needs a small follow-up only.',
|
||||
findings: [],
|
||||
securityChecklist: [{ check: 'Injection safety', status: 'pass', notes: 'Covered.' }],
|
||||
ccsCompliance: [{ rule: 'ASCII-only CLI output', status: 'na', notes: 'No CLI changes.' }],
|
||||
informational: [],
|
||||
strengths: ['The repair path returned valid JSON.'],
|
||||
overallAssessment: 'approved_with_notes',
|
||||
overallRationale: 'No blocking issues remain after the repair pass.',
|
||||
})
|
||||
),
|
||||
];
|
||||
|
||||
const result = await directReview.writeDirectReviewFromEnv(
|
||||
{
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
|
||||
ANTHROPIC_AUTH_TOKEN: 'test-token',
|
||||
REVIEW_MODEL: 'glm-5.1',
|
||||
GITHUB_REPOSITORY: 'kaitranntt/ccs',
|
||||
AI_REVIEW_PROMPT: 'You are a reviewer.',
|
||||
AI_REVIEW_PACKET_FILE: packetFile,
|
||||
AI_REVIEW_SCOPE_MANIFEST_FILE: manifestFile,
|
||||
AI_REVIEW_PACKET_INCLUDED_MANIFEST_FILE: includedManifestFile,
|
||||
AI_REVIEW_OUTPUT_FILE: outputFile,
|
||||
AI_REVIEW_LOG_FILE: logFile,
|
||||
AI_REVIEW_RUN_URL: 'https://github.com/kaitranntt/ccs/actions/runs/1',
|
||||
AI_REVIEW_MODE: 'triage',
|
||||
AI_REVIEW_SELECTED_FILES: '3',
|
||||
AI_REVIEW_REVIEWABLE_FILES: '5',
|
||||
AI_REVIEW_SELECTED_CHANGES: '140',
|
||||
AI_REVIEW_REVIEWABLE_CHANGES: '180',
|
||||
AI_REVIEW_SCOPE_LABEL: 'reviewable files',
|
||||
AI_REVIEW_PACKET_INCLUDED_FILES: '2',
|
||||
AI_REVIEW_PACKET_TOTAL_FILES: '3',
|
||||
AI_REVIEW_PACKET_OMITTED_FILES: '1',
|
||||
AI_REVIEW_TIMEOUT_MINUTES: '10',
|
||||
AI_REVIEW_PR_NUMBER: '888',
|
||||
},
|
||||
async () => responses.shift() as ReturnType<typeof createResponse>
|
||||
);
|
||||
|
||||
expect(result.usedFallback).toBe(false);
|
||||
expect(fs.readFileSync(outputFile, 'utf8')).toContain('**⚠️ APPROVED WITH NOTES**');
|
||||
expect(fs.readFileSync(outputFile, 'utf8')).toContain(
|
||||
'packet 2/3 selected files included in the final review packet; 1 selected file omitted for packet budget'
|
||||
);
|
||||
expect(JSON.parse(fs.readFileSync(logFile, 'utf8')).attempts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user