git-ai-review 2.1.0 → 2.2.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/README.md CHANGED
@@ -26,6 +26,13 @@ npx git-ai-review review --copilot
26
26
  npx git-ai-review review --claude
27
27
  ```
28
28
 
29
+ Or review the diff between two branches:
30
+
31
+ ```bash
32
+ npx git-ai-review diff main
33
+ npx git-ai-review diff main feature-branch
34
+ ```
35
+
29
36
  Requirements for the target repo:
30
37
  - An `AGENTS.md` file must exist in the repo root (it's used as policy context for the review)
31
38
  - At least one reviewer CLI must be installed and authenticated: **Codex CLI** (`codex login`), **Copilot CLI** (`copilot auth`), or **Claude CLI** (`claude`)
@@ -61,7 +68,9 @@ Recommended: install multiple for resilient fallback behavior.
61
68
 
62
69
  When an API token env var is set, the network preflight check for that reviewer is skipped — this is useful in CI environments or when interactive OAuth login is not available.
63
70
 
64
- Default fallback chain: Codex → Copilot → Claude. If all are unavailable, review fails.
71
+ Default fallback chain: Codex → Copilot → Claude. If a provider was unavailable on the previous run, it is rotated to the end of the chain on the next attempt (state stored in `.git/ai-review-last-unavailable`, cleared on success). If all are unavailable, review fails.
72
+
73
+ When a reviewer is explicitly selected via `--claude`, `--codex`, or `--copilot`, the preflight check is skipped entirely — the CLI tool handles its own authentication.
65
74
 
66
75
  ## Install in another repository
67
76
 
@@ -142,6 +151,7 @@ In each target repository:
142
151
 
143
152
  - In CI (`CI=true|1|yes`) pre-commit review is skipped.
144
153
  - Report is written to `.git/ai-review-last.json` by default.
154
+ - Last-unavailable provider state: `.git/ai-review-last-unavailable` (used for fallback rotation).
145
155
  - Failure counter file: `.git/ai-review-fail-count`.
146
156
  - Lock file after repeated failures: `.git/ai-review.lock`.
147
157
  - Unlock manually by removing lock and fail-count files.
package/dist/review.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import type { ParsedReview, ReviewReport, ReviewRunnerResult } from './types.js';
2
+ export type ReviewerName = 'codex' | 'copilot' | 'claude';
3
+ export declare function buildFallbackOrder(lastUnavailable: string | null): ReviewerName[];
4
+ export declare function readLastUnavailable(cwd: string): string | null;
5
+ export declare function writeLastUnavailable(cwd: string, reviewer: string): void;
6
+ export declare function clearLastUnavailable(cwd: string): void;
2
7
  export interface RunReviewOptions {
3
8
  verbose?: boolean;
4
9
  reviewer?: 'claude' | 'codex' | 'copilot';
@@ -6,9 +11,9 @@ export interface RunReviewOptions {
6
11
  export interface RunReviewDeps {
7
12
  getStagedDiff: () => string;
8
13
  buildPrompt: (diff: string, cwd: string) => string;
9
- runClaude: (prompt: string, verbose: boolean) => Promise<ReviewRunnerResult>;
10
- runCodex: (prompt: string, verbose: boolean) => Promise<ReviewRunnerResult>;
11
- runCopilot: (prompt: string, verbose: boolean) => Promise<ReviewRunnerResult>;
14
+ runClaude: (prompt: string, verbose: boolean, skipPreflight?: boolean) => Promise<ReviewRunnerResult>;
15
+ runCodex: (prompt: string, verbose: boolean, skipPreflight?: boolean) => Promise<ReviewRunnerResult>;
16
+ runCopilot: (prompt: string, verbose: boolean, skipPreflight?: boolean) => Promise<ReviewRunnerResult>;
12
17
  writeReport: (reportPath: string, report: ReviewReport) => void;
13
18
  log: (message: string) => void;
14
19
  }
package/dist/review.js CHANGED
@@ -12,6 +12,45 @@ import { getGitPath, git } from './git.js';
12
12
  const COPILOT_MODEL = process.env.COPILOT_REVIEW_MODEL || 'gpt-5.3-codex';
13
13
  const TIMEOUT_MS = Number(process.env.AI_REVIEW_TIMEOUT_MS || 300000);
14
14
  const PREFLIGHT_TIMEOUT_SEC = Number(process.env.AI_REVIEW_PREFLIGHT_TIMEOUT_SEC || 8);
15
+ const DEFAULT_FALLBACK_ORDER = ['codex', 'copilot', 'claude'];
16
+ export function buildFallbackOrder(lastUnavailable) {
17
+ if (!lastUnavailable || !DEFAULT_FALLBACK_ORDER.includes(lastUnavailable)) {
18
+ return [...DEFAULT_FALLBACK_ORDER];
19
+ }
20
+ const order = DEFAULT_FALLBACK_ORDER.filter((r) => r !== lastUnavailable);
21
+ order.push(lastUnavailable);
22
+ return order;
23
+ }
24
+ export function readLastUnavailable(cwd) {
25
+ try {
26
+ const filePath = resolve(cwd, getGitPath('ai-review-last-unavailable'));
27
+ if (!existsSync(filePath))
28
+ return null;
29
+ const value = readFileSync(filePath, 'utf8').trim();
30
+ return value || null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function writeLastUnavailable(cwd, reviewer) {
37
+ const filePath = resolve(cwd, getGitPath('ai-review-last-unavailable'));
38
+ try {
39
+ writeFileSync(filePath, reviewer, 'utf8');
40
+ }
41
+ catch {
42
+ console.error(`Warning: could not write last-unavailable state to ${filePath}`);
43
+ }
44
+ }
45
+ export function clearLastUnavailable(cwd) {
46
+ const filePath = resolve(cwd, getGitPath('ai-review-last-unavailable'));
47
+ try {
48
+ rmSync(filePath, { force: true });
49
+ }
50
+ catch {
51
+ console.error(`Warning: could not clear last-unavailable state at ${filePath}`);
52
+ }
53
+ }
15
54
  export function logVerboseRunnerOutput(output, log = console.log) {
16
55
  const label = output.model.toUpperCase();
17
56
  log(`\n----- ${label} STDOUT (RAW) -----`);
@@ -216,13 +255,13 @@ function spawnClaude(bin, args, prompt, env, cwd, timeoutMs) {
216
255
  child.stdin.end();
217
256
  });
218
257
  }
219
- async function runClaude(prompt, verbose) {
258
+ async function runClaude(prompt, verbose, skipPreflight = false) {
220
259
  const claude = resolveBinary('CLAUDE_BIN', ['claude']);
221
260
  if (!claude) {
222
261
  console.error('Claude: binary not found in PATH (or CLAUDE_BIN not set) — skipping.');
223
262
  return { available: false };
224
263
  }
225
- if (!checkPreflight('Claude', 'ANTHROPIC_API_KEY', ['https://api.anthropic.com'], verbose)) {
264
+ if (!skipPreflight && !checkPreflight('Claude', 'ANTHROPIC_API_KEY', ['https://api.anthropic.com'], verbose)) {
226
265
  return { available: false };
227
266
  }
228
267
  try {
@@ -274,13 +313,13 @@ async function runClaude(prompt, verbose) {
274
313
  return { available: false };
275
314
  }
276
315
  }
277
- function runCodex(prompt, verbose) {
316
+ function runCodex(prompt, verbose, skipPreflight = false) {
278
317
  const codex = resolveBinary('CODEX_BIN', ['codex']);
279
318
  if (!codex) {
280
319
  console.error('Codex: binary not found in PATH (or CODEX_BIN not set) — skipping.');
281
320
  return { available: false };
282
321
  }
283
- if (!checkPreflight('Codex', 'OPENAI_API_KEY', ['https://api.openai.com/v1/models', 'https://chatgpt.com'], verbose)) {
322
+ if (!skipPreflight && !checkPreflight('Codex', 'OPENAI_API_KEY', ['https://api.openai.com/v1/models', 'https://chatgpt.com'], verbose)) {
284
323
  return { available: false };
285
324
  }
286
325
  const tempDir = mkdtempSync(join(tmpdir(), 'ai-review-codex-'));
@@ -332,13 +371,13 @@ function runCodex(prompt, verbose) {
332
371
  rmSync(tempDir, { recursive: true, force: true });
333
372
  }
334
373
  }
335
- function runCopilot(prompt, verbose) {
374
+ function runCopilot(prompt, verbose, skipPreflight = false) {
336
375
  const copilot = resolveBinary('COPILOT_BIN', ['copilot']);
337
376
  if (!copilot) {
338
377
  console.error('Copilot: binary not found in PATH (or COPILOT_BIN not set) — skipping.');
339
378
  return { available: false };
340
379
  }
341
- if (!checkPreflight('Copilot', 'GITHUB_TOKEN', ['https://api.github.com'], verbose)) {
380
+ if (!skipPreflight && !checkPreflight('Copilot', 'GITHUB_TOKEN', ['https://api.github.com'], verbose)) {
342
381
  return { available: false };
343
382
  }
344
383
  try {
@@ -423,8 +462,8 @@ export async function runReview(cwd = process.cwd(), options = {}, deps = {}) {
423
462
  getStagedDiff,
424
463
  buildPrompt,
425
464
  runClaude,
426
- runCodex: (p, v) => Promise.resolve(runCodex(p, v)),
427
- runCopilot: (p, v) => Promise.resolve(runCopilot(p, v)),
465
+ runCodex: (p, v, s) => Promise.resolve(runCodex(p, v, s)),
466
+ runCopilot: (p, v, s) => Promise.resolve(runCopilot(p, v, s)),
428
467
  writeReport: (reportPath, report) => {
429
468
  writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf8');
430
469
  },
@@ -432,9 +471,9 @@ export async function runReview(cwd = process.cwd(), options = {}, deps = {}) {
432
471
  ...deps,
433
472
  };
434
473
  const diff = runtimeDeps.getStagedDiff();
435
- if (!diff) {
436
- runtimeDeps.log('AI review: no staged changes.');
437
- return { pass: true, reportPath: resolve(cwd, getGitPath('ai-review-last.json')), reason: 'No staged changes.' };
474
+ if (!diff || !diff.trim()) {
475
+ runtimeDeps.log('AI review: diff is empty — nothing to review.');
476
+ return { pass: true, reportPath: resolve(cwd, getGitPath('ai-review-last.json')), reason: 'Empty diff.' };
438
477
  }
439
478
  const prompt = runtimeDeps.buildPrompt(diff, cwd);
440
479
  if (verbose) {
@@ -447,26 +486,53 @@ export async function runReview(cwd = process.cwd(), options = {}, deps = {}) {
447
486
  let copilot = { available: false };
448
487
  if (reviewer === 'claude') {
449
488
  runtimeDeps.log('Running Claude review (--claude)...');
450
- claude = await runtimeDeps.runClaude(prompt, verbose);
489
+ claude = await runtimeDeps.runClaude(prompt, verbose, true);
451
490
  }
452
491
  else if (reviewer === 'copilot') {
453
492
  runtimeDeps.log('Running Copilot review (--copilot)...');
454
- copilot = await runtimeDeps.runCopilot(prompt, verbose);
493
+ copilot = await runtimeDeps.runCopilot(prompt, verbose, true);
455
494
  }
456
495
  else if (reviewer === 'codex') {
457
496
  runtimeDeps.log('Running Codex review (--codex)...');
458
- codex = await runtimeDeps.runCodex(prompt, verbose);
497
+ codex = await runtimeDeps.runCodex(prompt, verbose, true);
459
498
  }
460
499
  else {
461
- runtimeDeps.log('Running Codex review...');
462
- codex = await runtimeDeps.runCodex(prompt, verbose);
463
- if (!codex.available) {
464
- runtimeDeps.log('Codex unavailable — falling back to Copilot review...');
465
- copilot = await runtimeDeps.runCopilot(prompt, verbose);
466
- if (!copilot.available) {
467
- runtimeDeps.log('Copilot unavailable — falling back to Claude review...');
468
- claude = await runtimeDeps.runClaude(prompt, verbose);
500
+ const lastUnavailable = readLastUnavailable(cwd);
501
+ const fallbackOrder = buildFallbackOrder(lastUnavailable);
502
+ const runners = {
503
+ codex: runtimeDeps.runCodex,
504
+ copilot: runtimeDeps.runCopilot,
505
+ claude: runtimeDeps.runClaude,
506
+ };
507
+ const results = {
508
+ codex: { available: false },
509
+ copilot: { available: false },
510
+ claude: { available: false },
511
+ };
512
+ const labels = { codex: 'Codex', copilot: 'Copilot', claude: 'Claude' };
513
+ let firstUnavailable = null;
514
+ for (let i = 0; i < fallbackOrder.length; i++) {
515
+ const name = fallbackOrder[i];
516
+ if (i === 0) {
517
+ runtimeDeps.log(`Running ${labels[name]} review...`);
518
+ }
519
+ else {
520
+ runtimeDeps.log(`${labels[fallbackOrder[i - 1]]} unavailable — falling back to ${labels[name]} review...`);
469
521
  }
522
+ results[name] = await runners[name](prompt, verbose);
523
+ if (results[name].available)
524
+ break;
525
+ if (!firstUnavailable)
526
+ firstUnavailable = name;
527
+ }
528
+ claude = results.claude;
529
+ codex = results.codex;
530
+ copilot = results.copilot;
531
+ if (firstUnavailable) {
532
+ writeLastUnavailable(cwd, firstUnavailable);
533
+ }
534
+ else {
535
+ clearLastUnavailable(cwd);
470
536
  }
471
537
  }
472
538
  const report = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-ai-review",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Review your git diff with local Codex CLI, Copilot CLI, or Claude CLI — run manually in one command or automatically as a git hook",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",