@in-the-loop-labs/pair-review 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +371 -0
- package/bin/git-diff-lines +146 -0
- package/bin/pair-review.js +49 -0
- package/package.json +71 -0
- package/public/css/ai-summary-modal.css +183 -0
- package/public/css/pr.css +8698 -0
- package/public/css/repo-settings.css +891 -0
- package/public/css/styles.css +479 -0
- package/public/favicon.png +0 -0
- package/public/index.html +1104 -0
- package/public/js/components/AIPanel.js +1639 -0
- package/public/js/components/AISummaryModal.js +278 -0
- package/public/js/components/AnalysisConfigModal.js +684 -0
- package/public/js/components/ConfirmDialog.js +227 -0
- package/public/js/components/PreviewModal.js +344 -0
- package/public/js/components/ProgressModal.js +678 -0
- package/public/js/components/ReviewModal.js +531 -0
- package/public/js/components/SplitButton.js +382 -0
- package/public/js/components/StatusIndicator.js +265 -0
- package/public/js/components/SuggestionNavigator.js +489 -0
- package/public/js/components/Toast.js +166 -0
- package/public/js/local.js +1580 -0
- package/public/js/modules/analysis-history.js +940 -0
- package/public/js/modules/comment-manager.js +643 -0
- package/public/js/modules/diff-renderer.js +585 -0
- package/public/js/modules/file-comment-manager.js +1242 -0
- package/public/js/modules/gap-coordinates.js +190 -0
- package/public/js/modules/hunk-parser.js +358 -0
- package/public/js/modules/line-tracker.js +386 -0
- package/public/js/modules/panel-resizer.js +228 -0
- package/public/js/modules/storage-cleanup.js +36 -0
- package/public/js/modules/suggestion-manager.js +692 -0
- package/public/js/pr.js +3503 -0
- package/public/js/repo-settings.js +691 -0
- package/public/js/utils/file-order.js +87 -0
- package/public/js/utils/markdown.js +97 -0
- package/public/js/utils/suggestion-ui.js +55 -0
- package/public/js/utils/tier-icons.js +25 -0
- package/public/local.html +460 -0
- package/public/pr.html +329 -0
- package/public/repo-settings.html +243 -0
- package/src/ai/analyzer.js +2592 -0
- package/src/ai/claude-cli.js +153 -0
- package/src/ai/claude-provider.js +261 -0
- package/src/ai/codex-provider.js +361 -0
- package/src/ai/copilot-provider.js +345 -0
- package/src/ai/gemini-provider.js +375 -0
- package/src/ai/index.js +47 -0
- package/src/ai/prompts/baseline/_meta.json +14 -0
- package/src/ai/prompts/baseline/level1/balanced.js +239 -0
- package/src/ai/prompts/baseline/level1/fast.js +194 -0
- package/src/ai/prompts/baseline/level1/thorough.js +319 -0
- package/src/ai/prompts/baseline/level2/balanced.js +248 -0
- package/src/ai/prompts/baseline/level2/fast.js +201 -0
- package/src/ai/prompts/baseline/level2/thorough.js +367 -0
- package/src/ai/prompts/baseline/level3/balanced.js +280 -0
- package/src/ai/prompts/baseline/level3/fast.js +220 -0
- package/src/ai/prompts/baseline/level3/thorough.js +459 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
- package/src/ai/prompts/config.js +52 -0
- package/src/ai/prompts/index.js +267 -0
- package/src/ai/prompts/shared/diff-instructions.js +50 -0
- package/src/ai/prompts/shared/output-schema.js +179 -0
- package/src/ai/prompts/shared/valid-files.js +37 -0
- package/src/ai/provider.js +260 -0
- package/src/config.js +139 -0
- package/src/database.js +2284 -0
- package/src/git/gitattributes.js +207 -0
- package/src/git/worktree.js +688 -0
- package/src/github/client.js +893 -0
- package/src/github/parser.js +247 -0
- package/src/local-review.js +691 -0
- package/src/main.js +987 -0
- package/src/routes/analysis.js +897 -0
- package/src/routes/comments.js +534 -0
- package/src/routes/config.js +250 -0
- package/src/routes/local.js +1728 -0
- package/src/routes/pr.js +1164 -0
- package/src/routes/shared.js +218 -0
- package/src/routes/worktrees.js +500 -0
- package/src/server.js +295 -0
- package/src/utils/diff-annotator.js +414 -0
- package/src/utils/instructions.js +33 -0
- package/src/utils/json-extractor.js +107 -0
- package/src/utils/line-validation.js +183 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/paths.js +161 -0
- package/src/utils/stats-calculator.js +86 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Codex AI Provider
|
|
4
|
+
*
|
|
5
|
+
* Implements the AI provider interface for OpenAI's Codex CLI.
|
|
6
|
+
* Uses the `codex exec` command for non-interactive execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const { AIProvider, registerProvider } = require('./provider');
|
|
12
|
+
const logger = require('../utils/logger');
|
|
13
|
+
const { extractJSON } = require('../utils/json-extractor');
|
|
14
|
+
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
15
|
+
|
|
16
|
+
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
17
|
+
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Codex model definitions with tier mappings
|
|
21
|
+
*
|
|
22
|
+
* Based on OpenAI Codex Models guide (developers.openai.com/codex/models)
|
|
23
|
+
* - gpt-5.1-codex-mini: Smaller, cost-effective variant for quick scans
|
|
24
|
+
* - gpt-5.1-codex-max: Optimized for long-horizon agentic coding tasks
|
|
25
|
+
* - gpt-5.2-codex: Most advanced agentic coding model for real-world engineering
|
|
26
|
+
*/
|
|
27
|
+
const CODEX_MODELS = [
|
|
28
|
+
{
|
|
29
|
+
id: 'gpt-5.1-codex-mini',
|
|
30
|
+
name: 'GPT-5.1 Mini',
|
|
31
|
+
tier: 'fast',
|
|
32
|
+
tagline: 'Blazing Fast',
|
|
33
|
+
description: 'Quick, low-cost reviews for style issues, obvious bugs, and lint-level feedback.',
|
|
34
|
+
badge: 'Fastest',
|
|
35
|
+
badgeClass: 'badge-speed'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'gpt-5.1-codex-max',
|
|
39
|
+
name: 'GPT-5.1 Max',
|
|
40
|
+
tier: 'balanced',
|
|
41
|
+
tagline: 'Best Balance',
|
|
42
|
+
description: 'Strong everyday reviewer—quality + speed for PR-sized changes and practical suggestions.',
|
|
43
|
+
badge: 'Recommended',
|
|
44
|
+
badgeClass: 'badge-recommended',
|
|
45
|
+
default: true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'gpt-5.2-codex',
|
|
49
|
+
name: 'GPT-5.2',
|
|
50
|
+
tier: 'thorough',
|
|
51
|
+
tagline: 'Deep Review',
|
|
52
|
+
description: 'Most capable for complex diffs—finds subtle issues, reasons across files, and proposes step-by-step fixes.',
|
|
53
|
+
badge: 'Most Thorough',
|
|
54
|
+
badgeClass: 'badge-power'
|
|
55
|
+
}
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
class CodexProvider extends AIProvider {
|
|
59
|
+
constructor(model = 'gpt-5.1-codex-max') {
|
|
60
|
+
super(model);
|
|
61
|
+
|
|
62
|
+
// Check for environment variable to override default command
|
|
63
|
+
// Supports multi-word commands like "npx codex" or "/path/to/codex --verbose"
|
|
64
|
+
const codexCmd = process.env.PAIR_REVIEW_CODEX_CMD || 'codex';
|
|
65
|
+
|
|
66
|
+
// For multi-word commands, use shell mode (same pattern as Claude provider)
|
|
67
|
+
this.useShell = codexCmd.includes(' ');
|
|
68
|
+
|
|
69
|
+
// SECURITY: Codex sandbox modes and shell execution
|
|
70
|
+
//
|
|
71
|
+
// Codex sandbox modes:
|
|
72
|
+
// - read-only: Can browse files but CANNOT run shell commands (too restrictive)
|
|
73
|
+
// - workspace-write: Can read, edit, run commands in working directory only
|
|
74
|
+
// - danger-full-access: Full system access (too permissive)
|
|
75
|
+
//
|
|
76
|
+
// For code review, we need shell commands (git, git-diff-lines) but don't need
|
|
77
|
+
// network access or writes outside the worktree. We use "workspace-write" because:
|
|
78
|
+
// 1. We run in a dedicated worktree, not the main repo
|
|
79
|
+
// 2. "read-only" prevents ALL shell commands including git-diff-lines
|
|
80
|
+
// 3. The AI is instructed to only analyze code, not modify it
|
|
81
|
+
//
|
|
82
|
+
// --full-auto: Non-interactive mode that auto-approves within sandbox bounds.
|
|
83
|
+
// Combined with workspace-write sandbox, this limits damage to the worktree only.
|
|
84
|
+
// Note: The -a flag is for interactive mode only; exec subcommand uses --full-auto.
|
|
85
|
+
if (this.useShell) {
|
|
86
|
+
// In shell mode, build full command string with args
|
|
87
|
+
this.command = `${codexCmd} exec -m ${model} --json --sandbox workspace-write --full-auto -`;
|
|
88
|
+
this.args = [];
|
|
89
|
+
} else {
|
|
90
|
+
this.command = codexCmd;
|
|
91
|
+
this.args = ['exec', '-m', model, '--json', '--sandbox', 'workspace-write', '--full-auto', '-'];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Execute Codex CLI with a prompt
|
|
97
|
+
* @param {string} prompt - The prompt to send to Codex
|
|
98
|
+
* @param {Object} options - Optional configuration
|
|
99
|
+
* @returns {Promise<Object>} Parsed response or error
|
|
100
|
+
*/
|
|
101
|
+
async execute(prompt, options = {}) {
|
|
102
|
+
return new Promise((resolve, reject) => {
|
|
103
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess } = options;
|
|
104
|
+
|
|
105
|
+
const levelPrefix = `[Level ${level}]`;
|
|
106
|
+
logger.info(`${levelPrefix} Executing Codex CLI...`);
|
|
107
|
+
logger.info(`${levelPrefix} Writing prompt: ${prompt.length} bytes`);
|
|
108
|
+
|
|
109
|
+
const codex = spawn(this.command, this.args, {
|
|
110
|
+
cwd,
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
114
|
+
},
|
|
115
|
+
shell: this.useShell
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const pid = codex.pid;
|
|
119
|
+
logger.info(`${levelPrefix} Spawned Codex CLI process: PID ${pid}`);
|
|
120
|
+
|
|
121
|
+
// Register process for cancellation tracking if analysisId provided
|
|
122
|
+
if (analysisId && registerProcess) {
|
|
123
|
+
registerProcess(analysisId, codex);
|
|
124
|
+
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let stdout = '';
|
|
128
|
+
let stderr = '';
|
|
129
|
+
let timeoutId = null;
|
|
130
|
+
let settled = false; // Guard against multiple resolve/reject calls
|
|
131
|
+
|
|
132
|
+
const settle = (fn, value) => {
|
|
133
|
+
if (settled) return;
|
|
134
|
+
settled = true;
|
|
135
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
136
|
+
fn(value);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Set timeout
|
|
140
|
+
if (timeout) {
|
|
141
|
+
timeoutId = setTimeout(() => {
|
|
142
|
+
logger.error(`${levelPrefix} Process ${pid} timed out after ${timeout}ms`);
|
|
143
|
+
codex.kill('SIGTERM');
|
|
144
|
+
settle(reject, new Error(`${levelPrefix} Codex CLI timed out after ${timeout}ms`));
|
|
145
|
+
}, timeout);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Collect stdout
|
|
149
|
+
codex.stdout.on('data', (data) => {
|
|
150
|
+
stdout += data.toString();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Collect stderr
|
|
154
|
+
codex.stderr.on('data', (data) => {
|
|
155
|
+
stderr += data.toString();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Handle completion
|
|
159
|
+
codex.on('close', (code) => {
|
|
160
|
+
if (settled) return; // Already settled by timeout or error
|
|
161
|
+
|
|
162
|
+
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
163
|
+
const isCancellationCode = code === 143 || code === 137;
|
|
164
|
+
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
165
|
+
logger.info(`${levelPrefix} Codex CLI terminated due to analysis cancellation (exit code ${code})`);
|
|
166
|
+
settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Always log stderr if present
|
|
171
|
+
if (stderr.trim()) {
|
|
172
|
+
if (code !== 0) {
|
|
173
|
+
logger.error(`${levelPrefix} Codex CLI stderr (exit code ${code}): ${stderr}`);
|
|
174
|
+
} else {
|
|
175
|
+
logger.warn(`${levelPrefix} Codex CLI stderr (success): ${stderr}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (code !== 0) {
|
|
180
|
+
logger.error(`${levelPrefix} Codex CLI exited with code ${code}`);
|
|
181
|
+
settle(reject, new Error(`${levelPrefix} Codex CLI exited with code ${code}: ${stderr}`));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Parse the Codex JSONL response
|
|
186
|
+
const parsed = this.parseCodexResponse(stdout, level);
|
|
187
|
+
if (parsed.success) {
|
|
188
|
+
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
189
|
+
settle(resolve, parsed.data);
|
|
190
|
+
} else {
|
|
191
|
+
logger.warn(`${levelPrefix} Failed to extract JSON: ${parsed.error}`);
|
|
192
|
+
logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
|
|
193
|
+
logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
|
|
194
|
+
settle(resolve, { raw: stdout, parsed: false });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Handle errors
|
|
199
|
+
codex.on('error', (error) => {
|
|
200
|
+
if (error.code === 'ENOENT') {
|
|
201
|
+
logger.error(`${levelPrefix} Codex CLI not found. Please ensure Codex CLI is installed.`);
|
|
202
|
+
settle(reject, new Error(`${levelPrefix} Codex CLI not found. ${CodexProvider.getInstallInstructions()}`));
|
|
203
|
+
} else {
|
|
204
|
+
logger.error(`${levelPrefix} Codex process error: ${error}`);
|
|
205
|
+
settle(reject, error);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Send the prompt to stdin
|
|
210
|
+
codex.stdin.write(prompt, (err) => {
|
|
211
|
+
if (err) {
|
|
212
|
+
logger.error(`${levelPrefix} Failed to write prompt to stdin: ${err}`);
|
|
213
|
+
codex.kill('SIGTERM');
|
|
214
|
+
settle(reject, new Error(`${levelPrefix} Failed to write prompt to stdin: ${err}`));
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
codex.stdin.end();
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Parse Codex CLI JSONL response
|
|
223
|
+
* Codex outputs JSONL with multiple event types:
|
|
224
|
+
* - thread.started: Session info
|
|
225
|
+
* - turn.started: Turn begins
|
|
226
|
+
* - item.completed: Contains reasoning or agent_message items
|
|
227
|
+
* - turn.completed: Turn ends with usage stats
|
|
228
|
+
*
|
|
229
|
+
* We need to extract the agent_message content which contains the AI response.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} stdout - Raw stdout from Codex CLI (JSONL format)
|
|
232
|
+
* @param {string|number} level - Analysis level for logging
|
|
233
|
+
* @returns {{success: boolean, data?: Object, error?: string}}
|
|
234
|
+
*/
|
|
235
|
+
parseCodexResponse(stdout, level) {
|
|
236
|
+
const levelPrefix = `[Level ${level}]`;
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
// Split by newlines and parse each JSON line
|
|
240
|
+
const lines = stdout.trim().split('\n').filter(line => line.trim());
|
|
241
|
+
let agentMessage = null;
|
|
242
|
+
|
|
243
|
+
for (const line of lines) {
|
|
244
|
+
try {
|
|
245
|
+
const event = JSON.parse(line);
|
|
246
|
+
|
|
247
|
+
// Look for agent_message items which contain the actual response
|
|
248
|
+
if (event.type === 'item.completed' &&
|
|
249
|
+
event.item?.type === 'agent_message' &&
|
|
250
|
+
event.item?.text) {
|
|
251
|
+
agentMessage = event.item.text;
|
|
252
|
+
}
|
|
253
|
+
} catch (lineError) {
|
|
254
|
+
// Skip malformed lines
|
|
255
|
+
logger.debug(`${levelPrefix} Skipping malformed JSONL line: ${line.substring(0, 100)}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (agentMessage) {
|
|
260
|
+
// The agent_message contains the AI's text response
|
|
261
|
+
// Try to extract JSON from it (the AI was asked to output JSON)
|
|
262
|
+
const extracted = extractJSON(agentMessage, level);
|
|
263
|
+
if (extracted.success) {
|
|
264
|
+
return extracted;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// If no JSON found, return the raw message
|
|
268
|
+
logger.warn(`${levelPrefix} Agent message is not JSON, treating as raw text`);
|
|
269
|
+
return { success: false, error: 'Agent message is not valid JSON' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// No agent message found, try extracting JSON directly from stdout
|
|
273
|
+
const extracted = extractJSON(stdout, level);
|
|
274
|
+
return extracted;
|
|
275
|
+
|
|
276
|
+
} catch (parseError) {
|
|
277
|
+
// stdout might not be valid JSONL at all, try extracting JSON from it
|
|
278
|
+
const extracted = extractJSON(stdout, level);
|
|
279
|
+
if (extracted.success) {
|
|
280
|
+
return extracted;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return { success: false, error: `JSONL parse error: ${parseError.message}` };
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Test if Codex CLI is available
|
|
289
|
+
* @returns {Promise<boolean>}
|
|
290
|
+
*/
|
|
291
|
+
async testAvailability() {
|
|
292
|
+
return new Promise((resolve) => {
|
|
293
|
+
// For availability test, we just need to check --version
|
|
294
|
+
// Use shell mode if the command contains spaces
|
|
295
|
+
const codexCmd = process.env.PAIR_REVIEW_CODEX_CMD || 'codex';
|
|
296
|
+
const useShell = codexCmd.includes(' ');
|
|
297
|
+
const command = useShell ? `${codexCmd} --version` : codexCmd;
|
|
298
|
+
const args = useShell ? [] : ['--version'];
|
|
299
|
+
|
|
300
|
+
const codex = spawn(command, args, {
|
|
301
|
+
env: {
|
|
302
|
+
...process.env,
|
|
303
|
+
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
304
|
+
},
|
|
305
|
+
shell: useShell
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
let stdout = '';
|
|
309
|
+
let settled = false;
|
|
310
|
+
|
|
311
|
+
codex.stdout.on('data', (data) => {
|
|
312
|
+
stdout += data.toString();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
codex.on('close', (code) => {
|
|
316
|
+
if (settled) return;
|
|
317
|
+
settled = true;
|
|
318
|
+
if (code === 0 && stdout.includes('codex')) {
|
|
319
|
+
logger.info(`Codex CLI available: ${stdout.trim()}`);
|
|
320
|
+
resolve(true);
|
|
321
|
+
} else {
|
|
322
|
+
logger.warn('Codex CLI not available or returned unexpected output');
|
|
323
|
+
resolve(false);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
codex.on('error', (error) => {
|
|
328
|
+
if (settled) return;
|
|
329
|
+
settled = true;
|
|
330
|
+
logger.warn(`Codex CLI not available: ${error.message}`);
|
|
331
|
+
resolve(false);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
static getProviderName() {
|
|
337
|
+
return 'Codex';
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
static getProviderId() {
|
|
341
|
+
return 'codex';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
static getModels() {
|
|
345
|
+
return CODEX_MODELS;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static getDefaultModel() {
|
|
349
|
+
return 'gpt-5.1-codex-max';
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
static getInstallInstructions() {
|
|
353
|
+
return 'Install Codex CLI: npm install -g @openai/codex\n' +
|
|
354
|
+
'Or visit: https://github.com/openai/codex';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Register this provider
|
|
359
|
+
registerProvider('codex', CodexProvider);
|
|
360
|
+
|
|
361
|
+
module.exports = CodexProvider;
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Copilot AI Provider
|
|
4
|
+
*
|
|
5
|
+
* Implements the AI provider interface for GitHub's Copilot CLI.
|
|
6
|
+
* Uses the `copilot -p` command for non-interactive execution.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
|
+
const { AIProvider, registerProvider } = require('./provider');
|
|
12
|
+
const logger = require('../utils/logger');
|
|
13
|
+
const { extractJSON } = require('../utils/json-extractor');
|
|
14
|
+
const { CancellationError, isAnalysisCancelled } = require('../routes/shared');
|
|
15
|
+
|
|
16
|
+
// Directory containing bin scripts (git-diff-lines, etc.)
|
|
17
|
+
const BIN_DIR = path.join(__dirname, '..', '..', 'bin');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Copilot model definitions with tier mappings
|
|
21
|
+
*
|
|
22
|
+
* GitHub Copilot CLI supports multiple AI models including OpenAI,
|
|
23
|
+
* Anthropic, and Google models via the --model flag.
|
|
24
|
+
*/
|
|
25
|
+
const COPILOT_MODELS = [
|
|
26
|
+
{
|
|
27
|
+
id: 'gpt-5.1-codex-mini',
|
|
28
|
+
name: 'GPT-5.1 Mini',
|
|
29
|
+
tier: 'fast',
|
|
30
|
+
tagline: 'Quick Scan',
|
|
31
|
+
description: 'Rapid feedback for obvious issues and style checks',
|
|
32
|
+
badge: 'Speedy',
|
|
33
|
+
badgeClass: 'badge-speed'
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'gemini-3-pro-preview',
|
|
37
|
+
name: 'Gemini 3 Pro',
|
|
38
|
+
tier: 'balanced',
|
|
39
|
+
tagline: 'Reliable Review',
|
|
40
|
+
description: 'Solid everyday reviews with good coverage',
|
|
41
|
+
badge: 'Recommended',
|
|
42
|
+
badgeClass: 'badge-recommended',
|
|
43
|
+
default: true
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'gpt-5.1-codex-max',
|
|
47
|
+
name: 'GPT-5.1 Max',
|
|
48
|
+
tier: 'thorough',
|
|
49
|
+
tagline: 'Deep Analysis',
|
|
50
|
+
description: 'Comprehensive reviews for complex changes',
|
|
51
|
+
badge: 'Thorough',
|
|
52
|
+
badgeClass: 'badge-power'
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'claude-opus-4.5',
|
|
56
|
+
name: 'Claude Opus 4.5',
|
|
57
|
+
tier: 'premium',
|
|
58
|
+
tagline: 'Ultimate Review',
|
|
59
|
+
description: 'The most capable model for critical code reviews',
|
|
60
|
+
badge: 'Premium',
|
|
61
|
+
badgeClass: 'badge-premium'
|
|
62
|
+
}
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
class CopilotProvider extends AIProvider {
|
|
66
|
+
constructor(model = 'gemini-3-pro-preview') {
|
|
67
|
+
super(model);
|
|
68
|
+
|
|
69
|
+
// Check for environment variable to override default command
|
|
70
|
+
// Supports multi-word commands like "gh copilot" or custom paths
|
|
71
|
+
const copilotCmd = process.env.PAIR_REVIEW_COPILOT_CMD || 'copilot';
|
|
72
|
+
|
|
73
|
+
// For multi-word commands, use shell mode (same pattern as other providers)
|
|
74
|
+
this.useShell = copilotCmd.includes(' ');
|
|
75
|
+
|
|
76
|
+
// Store base args for later - prompt value will be inserted after -p flag
|
|
77
|
+
// -p: non-interactive prompt mode (exits after completion)
|
|
78
|
+
// --model: specify the AI model
|
|
79
|
+
// -s: silent mode (output only agent response, no stats)
|
|
80
|
+
//
|
|
81
|
+
// SECURITY: Use --allow-tool and --deny-tool to control tool permissions.
|
|
82
|
+
//
|
|
83
|
+
// Copilot CLI permission flags:
|
|
84
|
+
// - --allow-tool <pattern>: Whitelist tools (supports glob patterns)
|
|
85
|
+
// - --deny-tool <pattern>: Blacklist tools (takes precedence over allow)
|
|
86
|
+
// - --allow-all-tools: Auto-approve all tools without prompts
|
|
87
|
+
//
|
|
88
|
+
// For shell commands, use shell(<prefix>) syntax to match command prefixes.
|
|
89
|
+
// E.g., shell(git) allows "git status", "git diff", etc.
|
|
90
|
+
// ============================================================================
|
|
91
|
+
const readOnlyArgs = [
|
|
92
|
+
// Allow specific read-only git commands (not blanket 'git' to block git commit, push, etc.)
|
|
93
|
+
'--allow-tool', 'shell(git diff)',
|
|
94
|
+
'--allow-tool', 'shell(git log)',
|
|
95
|
+
'--allow-tool', 'shell(git show)',
|
|
96
|
+
'--allow-tool', 'shell(git status)',
|
|
97
|
+
'--allow-tool', 'shell(git branch)',
|
|
98
|
+
'--allow-tool', 'shell(git rev-parse)',
|
|
99
|
+
// Custom tool for annotated diff line mapping (matches both direct and path invocations)
|
|
100
|
+
'--allow-tool', 'shell(git-diff-lines)',
|
|
101
|
+
'--allow-tool', 'shell(*/git-diff-lines)', // Absolute path invocation
|
|
102
|
+
// Allow read-only shell commands
|
|
103
|
+
'--allow-tool', 'shell(ls)', // Directory listing
|
|
104
|
+
'--allow-tool', 'shell(cat)', // File content viewing
|
|
105
|
+
'--allow-tool', 'shell(pwd)', // Current directory
|
|
106
|
+
'--allow-tool', 'shell(head)', // File head viewing
|
|
107
|
+
'--allow-tool', 'shell(tail)', // File tail viewing
|
|
108
|
+
'--allow-tool', 'shell(wc)', // Word/line count
|
|
109
|
+
'--allow-tool', 'shell(find)', // File finding
|
|
110
|
+
'--allow-tool', 'shell(grep)', // Pattern searching
|
|
111
|
+
'--allow-tool', 'shell(rg)', // Ripgrep (fast pattern searching)
|
|
112
|
+
// Deny dangerous shell commands (takes precedence over allow)
|
|
113
|
+
'--deny-tool', 'shell(rm)',
|
|
114
|
+
'--deny-tool', 'shell(mv)',
|
|
115
|
+
'--deny-tool', 'shell(chmod)',
|
|
116
|
+
'--deny-tool', 'shell(chown)',
|
|
117
|
+
'--deny-tool', 'shell(sudo)',
|
|
118
|
+
'--deny-tool', 'shell(git commit)',
|
|
119
|
+
'--deny-tool', 'shell(git push)',
|
|
120
|
+
'--deny-tool', 'shell(git checkout)',
|
|
121
|
+
'--deny-tool', 'shell(git reset)',
|
|
122
|
+
'--deny-tool', 'shell(git rebase)',
|
|
123
|
+
'--deny-tool', 'shell(git merge)',
|
|
124
|
+
// Block file write tools
|
|
125
|
+
'--deny-tool', 'write',
|
|
126
|
+
// Auto-approve remaining tools to avoid interactive prompts
|
|
127
|
+
'--allow-all-tools',
|
|
128
|
+
// Allow access to all paths (needed for analyzing files outside cwd)
|
|
129
|
+
'--allow-all-paths',
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// Command and base args are the same regardless of shell mode
|
|
133
|
+
// (shell mode only affects how command is built in execute())
|
|
134
|
+
this.command = copilotCmd;
|
|
135
|
+
// Args without the prompt - prompt will be added as value to -p flag in execute()
|
|
136
|
+
this.baseArgs = ['--model', model, ...readOnlyArgs, '-s'];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Execute Copilot CLI with a prompt
|
|
141
|
+
* @param {string} prompt - The prompt to send to Copilot
|
|
142
|
+
* @param {Object} options - Optional configuration
|
|
143
|
+
* @returns {Promise<Object>} Parsed response or error
|
|
144
|
+
*/
|
|
145
|
+
async execute(prompt, options = {}) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const { cwd = process.cwd(), timeout = 300000, level = 'unknown', analysisId, registerProcess } = options;
|
|
148
|
+
|
|
149
|
+
const levelPrefix = `[Level ${level}]`;
|
|
150
|
+
logger.info(`${levelPrefix} Executing Copilot CLI...`);
|
|
151
|
+
logger.info(`${levelPrefix} Writing prompt: ${prompt.length} bytes`);
|
|
152
|
+
|
|
153
|
+
// Build the command with other args first, then -p <prompt> at the end
|
|
154
|
+
// The -p flag expects the prompt value immediately after it
|
|
155
|
+
let fullCommand = this.command;
|
|
156
|
+
let fullArgs;
|
|
157
|
+
|
|
158
|
+
if (this.useShell) {
|
|
159
|
+
// Escape the prompt for shell
|
|
160
|
+
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
|
161
|
+
// Build: copilot --model X --deny-tool ... -s -p 'prompt'
|
|
162
|
+
fullCommand = `${this.command} ${this.baseArgs.join(' ')} -p '${escapedPrompt}'`;
|
|
163
|
+
fullArgs = [];
|
|
164
|
+
} else {
|
|
165
|
+
// Build args array: --model X --deny-tool ... -s -p <prompt>
|
|
166
|
+
fullArgs = [...this.baseArgs, '-p', prompt];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const copilot = spawn(fullCommand, fullArgs, {
|
|
170
|
+
cwd,
|
|
171
|
+
env: {
|
|
172
|
+
...process.env,
|
|
173
|
+
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
174
|
+
},
|
|
175
|
+
shell: this.useShell
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const pid = copilot.pid;
|
|
179
|
+
logger.info(`${levelPrefix} Spawned Copilot CLI process: PID ${pid}`);
|
|
180
|
+
|
|
181
|
+
// Register process for cancellation tracking if analysisId provided
|
|
182
|
+
if (analysisId && registerProcess) {
|
|
183
|
+
registerProcess(analysisId, copilot);
|
|
184
|
+
logger.info(`${levelPrefix} Registered process ${pid} for analysis ${analysisId}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let stdout = '';
|
|
188
|
+
let stderr = '';
|
|
189
|
+
let timeoutId = null;
|
|
190
|
+
let settled = false; // Guard against multiple resolve/reject calls
|
|
191
|
+
|
|
192
|
+
const settle = (fn, value) => {
|
|
193
|
+
if (settled) return;
|
|
194
|
+
settled = true;
|
|
195
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
196
|
+
fn(value);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Set timeout
|
|
200
|
+
if (timeout) {
|
|
201
|
+
timeoutId = setTimeout(() => {
|
|
202
|
+
logger.error(`${levelPrefix} Process ${pid} timed out after ${timeout}ms`);
|
|
203
|
+
copilot.kill('SIGTERM');
|
|
204
|
+
settle(reject, new Error(`${levelPrefix} Copilot CLI timed out after ${timeout}ms`));
|
|
205
|
+
}, timeout);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Collect stdout
|
|
209
|
+
copilot.stdout.on('data', (data) => {
|
|
210
|
+
stdout += data.toString();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Collect stderr
|
|
214
|
+
copilot.stderr.on('data', (data) => {
|
|
215
|
+
stderr += data.toString();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Handle completion
|
|
219
|
+
copilot.on('close', (code) => {
|
|
220
|
+
if (settled) return; // Already settled by timeout or error
|
|
221
|
+
|
|
222
|
+
// Check for cancellation signals (SIGTERM=143, SIGKILL=137)
|
|
223
|
+
const isCancellationCode = code === 143 || code === 137;
|
|
224
|
+
if (isCancellationCode && analysisId && isAnalysisCancelled(analysisId)) {
|
|
225
|
+
logger.info(`${levelPrefix} Copilot CLI terminated due to analysis cancellation (exit code ${code})`);
|
|
226
|
+
settle(reject, new CancellationError(`${levelPrefix} Analysis cancelled by user`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Always log stderr if present
|
|
231
|
+
if (stderr.trim()) {
|
|
232
|
+
if (code !== 0) {
|
|
233
|
+
logger.error(`${levelPrefix} Copilot CLI stderr (exit code ${code}): ${stderr}`);
|
|
234
|
+
} else {
|
|
235
|
+
logger.warn(`${levelPrefix} Copilot CLI stderr (success): ${stderr}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (code !== 0) {
|
|
240
|
+
logger.error(`${levelPrefix} Copilot CLI exited with code ${code}`);
|
|
241
|
+
settle(reject, new Error(`${levelPrefix} Copilot CLI exited with code ${code}: ${stderr}`));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Extract JSON from the response
|
|
246
|
+
const extracted = extractJSON(stdout, level);
|
|
247
|
+
if (extracted.success) {
|
|
248
|
+
logger.success(`${levelPrefix} Successfully parsed JSON response`);
|
|
249
|
+
settle(resolve, extracted.data);
|
|
250
|
+
} else {
|
|
251
|
+
logger.warn(`${levelPrefix} Failed to extract JSON: ${extracted.error}`);
|
|
252
|
+
logger.info(`${levelPrefix} Raw response length: ${stdout.length} characters`);
|
|
253
|
+
logger.info(`${levelPrefix} Raw response preview: ${stdout.substring(0, 500)}...`);
|
|
254
|
+
settle(resolve, { raw: stdout, parsed: false });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Handle errors
|
|
259
|
+
copilot.on('error', (error) => {
|
|
260
|
+
if (error.code === 'ENOENT') {
|
|
261
|
+
logger.error(`${levelPrefix} Copilot CLI not found. Please ensure Copilot CLI is installed.`);
|
|
262
|
+
settle(reject, new Error(`${levelPrefix} Copilot CLI not found. ${CopilotProvider.getInstallInstructions()}`));
|
|
263
|
+
} else {
|
|
264
|
+
logger.error(`${levelPrefix} Copilot process error: ${error}`);
|
|
265
|
+
settle(reject, error);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Test if Copilot CLI is available
|
|
273
|
+
* @returns {Promise<boolean>}
|
|
274
|
+
*/
|
|
275
|
+
async testAvailability() {
|
|
276
|
+
return new Promise((resolve) => {
|
|
277
|
+
// For availability test, check --version
|
|
278
|
+
const copilotCmd = process.env.PAIR_REVIEW_COPILOT_CMD || 'copilot';
|
|
279
|
+
const useShell = copilotCmd.includes(' ');
|
|
280
|
+
const command = useShell ? `${copilotCmd} --version` : copilotCmd;
|
|
281
|
+
const args = useShell ? [] : ['--version'];
|
|
282
|
+
|
|
283
|
+
const copilot = spawn(command, args, {
|
|
284
|
+
env: {
|
|
285
|
+
...process.env,
|
|
286
|
+
PATH: `${BIN_DIR}:${process.env.PATH}`
|
|
287
|
+
},
|
|
288
|
+
shell: useShell
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
let stdout = '';
|
|
292
|
+
let settled = false;
|
|
293
|
+
|
|
294
|
+
copilot.stdout.on('data', (data) => {
|
|
295
|
+
stdout += data.toString();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
copilot.on('close', (code) => {
|
|
299
|
+
if (settled) return;
|
|
300
|
+
settled = true;
|
|
301
|
+
// Copilot CLI typically outputs version info on success
|
|
302
|
+
if (code === 0) {
|
|
303
|
+
logger.info(`Copilot CLI available: ${stdout.trim()}`);
|
|
304
|
+
resolve(true);
|
|
305
|
+
} else {
|
|
306
|
+
logger.warn('Copilot CLI not available or returned unexpected output');
|
|
307
|
+
resolve(false);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
copilot.on('error', (error) => {
|
|
312
|
+
if (settled) return;
|
|
313
|
+
settled = true;
|
|
314
|
+
logger.warn(`Copilot CLI not available: ${error.message}`);
|
|
315
|
+
resolve(false);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
static getProviderName() {
|
|
321
|
+
return 'Copilot';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
static getProviderId() {
|
|
325
|
+
return 'copilot';
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
static getModels() {
|
|
329
|
+
return COPILOT_MODELS;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
static getDefaultModel() {
|
|
333
|
+
return 'gemini-3-pro-preview';
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
static getInstallInstructions() {
|
|
337
|
+
return 'Install GitHub Copilot CLI: npm install -g @github/copilot\n' +
|
|
338
|
+
'Or visit: https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli';
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Register this provider
|
|
343
|
+
registerProvider('copilot', CopilotProvider);
|
|
344
|
+
|
|
345
|
+
module.exports = CopilotProvider;
|