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
14 changes: 10 additions & 4 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'node:path';
import type { Command } from 'commander';

import { ClaudeClient } from '../lib/claude.js';
import { checkCopilotAvailable, runCopilotReview } from '../lib/copilot.js';
import * as codex from '../lib/codex.js';
import { findProjectRoot, loadConfig } from '../lib/config.js';
import * as git from '../lib/git.js';
Expand Down Expand Up @@ -46,6 +47,7 @@ export async function runStart(taskFile: string, options: StartCommandOptions):
if (!options.dryRun) {
requireAnthropicApiKey();
await codex.checkCodexAvailable();
await checkCopilotAvailable();
}

let state = loadState(projectRoot);
Expand Down Expand Up @@ -134,6 +136,7 @@ export async function runStart(taskFile: string, options: StartCommandOptions):
claude,
verbose: options.verbose,
log: scopedLogger,
serviceRoot,
});

await updateStep(projectRoot, task.id, step.service, {
Expand Down Expand Up @@ -227,6 +230,7 @@ async function runCloudReviewLoop(opts: {
claude: ClaudeClient;
verbose?: boolean;
log: logger.Logger;
serviceRoot: string;
}): Promise<{ sessionId: string; finalIteration: number; lastReviewComments: ReviewComment[]; lastArbiterResult: ArbiterResult }> {
let sessionId = opts.sessionId;
let iteration = opts.stepState.iteration;
Expand Down Expand Up @@ -258,11 +262,13 @@ async function runCloudReviewLoop(opts: {
const diff = await codex.getDiff(sessionId);

opts.log.info(`Running review (iteration ${String(iteration + 1)})...`);
const review = await opts.claude.runReviewer({
spec: opts.spec,
diff,
model: opts.config.review.model,

await codex.applyDiff(sessionId);
const comments = await runCopilotReview(opts.spec, { cwd: opts.serviceRoot }).finally(async () => {
await git.exec(['checkout', '.'], opts.serviceRoot);
});

const review = { comments };
opts.log.reviewSummary(review.comments);

opts.log.info(`Requesting arbiter decision (model: ${opts.config.review.model})`);
Expand Down
238 changes: 238 additions & 0 deletions src/lib/copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { spawn } from 'node:child_process';

import type { CommentSeverity, CopilotErrorCode, ReviewComment } from '../types/index.js';

const COPILOT_TIMEOUT_MS = 120_000;

interface CommandResult {
stdout: string;
stderr: string;
exitCode: number;
}

interface ParsedComment {
message: string;
severity?: unknown;
file?: unknown;
line?: unknown;
}

/**
* `copilot --output-format=json` emits JSONL (one JSON object per line). The schema appears to vary
* across versions, so this parser is intentionally defensive: it scans parsed object trees for
* message-like fields (`message`/`body`/`text`) plus optional location/severity metadata.
*/
export class CopilotReviewError extends Error {
code: CopilotErrorCode;
stdout: string;
stderr: string;
exitCode: number;

constructor(code: CopilotErrorCode, message: string, details?: { stdout?: string; stderr?: string; exitCode?: number }) {
super(message);
this.name = 'CopilotReviewError';
this.code = code;
this.stdout = details?.stdout ?? '';
this.stderr = details?.stderr ?? '';
this.exitCode = details?.exitCode ?? 1;
}
}

export class CopilotNotFoundError extends CopilotReviewError {
constructor() {
super('not_found', 'copilot CLI not found. Install and authenticate GitHub Copilot CLI, then retry.');
this.name = 'CopilotNotFoundError';
}
}

function runCopilotCommand(args: string[], opts?: { cwd?: string; timeoutMs?: number }): Promise<CommandResult> {
return new Promise<CommandResult>((resolve, reject) => {
const child = spawn('copilot', args, {
cwd: opts?.cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});

let stdout = '';
let stderr = '';

child.stdout.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});

child.stderr.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});

const timeout = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`copilot command timed out after ${String(opts?.timeoutMs ?? COPILOT_TIMEOUT_MS)}ms`));
}, opts?.timeoutMs ?? COPILOT_TIMEOUT_MS);

child.once('error', (error) => {
clearTimeout(timeout);
reject(error);
});

child.once('close', (code) => {
clearTimeout(timeout);
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 1,
});
});
});
}

function normalizeSeverity(raw: unknown): CommentSeverity {
const sev = typeof raw === 'string' ? raw.toLowerCase() : '';

if (sev === 'error' || sev === 'high' || sev === 'critical') {
return 'critical';
}

if (sev === 'warning' || sev === 'medium') {
return 'important';
}

if (sev === 'info' || sev === 'low') {
return 'minor';
}

return 'minor';
}

function maybeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw)) {
return Math.trunc(raw);
}

if (typeof raw === 'string') {
const n = Number.parseInt(raw, 10);
if (!Number.isNaN(n)) {
return n;
}
}

return undefined;
}

function collectParsedComments(value: unknown, comments: ParsedComment[]): void {
if (Array.isArray(value)) {
for (const item of value) {
collectParsedComments(item, comments);
}
return;
}

if (!value || typeof value !== 'object') {
return;
}

const record = value as Record<string, unknown>;

const message = [record.message, record.body, record.text].find((field) => typeof field === 'string');

if (typeof message === 'string' && message.trim()) {
comments.push({
message: message.trim(),
severity: record.severity ?? record.priority,
file: record.file ?? record.path,
line: record.line,
});
}

for (const nested of Object.values(record)) {
collectParsedComments(nested, comments);
}
}

function parseReviewComments(stdout: string): ReviewComment[] {
const parsedObjects: unknown[] = [];

for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}

try {
parsedObjects.push(JSON.parse(trimmed));
} catch {
// Ignore non-JSON lines.
}
}

const parsed: ParsedComment[] = [];
for (const entry of parsedObjects) {
collectParsedComments(entry, parsed);
}

const deduped = new Set<string>();
const output: ReviewComment[] = [];

for (const item of parsed) {
const file = typeof item.file === 'string' && item.file.length > 0 ? item.file : undefined;
const line = maybeNumber(item.line);
const lineKey = line === undefined ? '' : String(line);
const key = `${item.message}::${file ?? ''}::${lineKey}`;

if (deduped.has(key)) {
continue;
}

deduped.add(key);
output.push({
severity: normalizeSeverity(item.severity),
file,
line,
comment: item.message,
});
}

return output;
}

export async function checkCopilotAvailable(): Promise<void> {
try {
const result = await runCopilotCommand(['--version']);
if (result.exitCode !== 0) {
throw new CopilotNotFoundError();
}
} catch {
throw new CopilotNotFoundError();
}
}

export async function runCopilotReview(spec: string, opts?: { cwd?: string }): Promise<ReviewComment[]> {
const prompt = `Review the staged changes against the following spec.\nReport bugs, missing requirements, security issues, and logic errors.\nIgnore style issues.\n\nSpec:\n${spec}`;

let result: CommandResult;

try {
result = await runCopilotCommand(['-p', prompt, '--silent', '--output-format=json'], { cwd: opts?.cwd });
} catch (error: unknown) {
throw new CopilotReviewError(
'review_failed',
`Failed to execute copilot review: ${error instanceof Error ? error.message : String(error)}`,
);
}

if (result.exitCode !== 0) {
throw new CopilotReviewError('review_failed', 'Copilot review command failed.', result);
}

if (!result.stdout.trim()) {
throw new CopilotReviewError('review_failed', 'Copilot review produced no output.', result);
}

try {
return parseReviewComments(result.stdout);
} catch (error: unknown) {
throw new CopilotReviewError(
'parse_failed',
`Failed to parse Copilot review output: ${error instanceof Error ? error.message : String(error)}`,
result,
);
}
}
10 changes: 3 additions & 7 deletions src/lib/review-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'node:path';

import * as codex from './codex.js';
import type { ClaudeClient } from './claude.js';
import { runCopilotReview } from './copilot.js';
import * as git from './git.js';
import * as logger from './logger.js';
import * as state from './state.js';
Expand Down Expand Up @@ -89,19 +90,14 @@ export async function runReviewLoop(opts: ReviewLoopOptions): Promise<ReviewLoop
logger.info(`Waiting for reviewer response (${formatElapsed(reviewerStartedAt)})`);
}, 15_000)
: null;
const review = await opts.claude
.runReviewer({
spec: opts.step.spec,
diff,
model: opts.config.review.model,
})
.finally(() => {
const comments = await runCopilotReview(opts.step.spec, { cwd: serviceRoot }).finally(() => {
if (reviewerHeartbeat) {
clearInterval(reviewerHeartbeat);
}
});
logger.info(`Reviewer response received in ${formatElapsed(reviewerStartedAt)}`);

const review = { comments };
logger.reviewSummary(review.comments);

logger.info(`Requesting arbiter decision (model: ${opts.config.review.model})`);
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export type StepStatus = 'pending' | 'in_progress' | 'done' | 'failed' | 'escala

export type TaskStatus = 'in_progress' | 'review' | 'done' | 'blocked' | 'escalated';


export type CopilotErrorCode = 'not_found' | 'review_failed' | 'parse_failed';
export type CodexErrorCode =
| 'submit_failed'
| 'resume_failed'
Expand Down
Loading
Loading