git-diff-ai-reviewer 1.1.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/.github/workflows/publish.yml +29 -0
- package/README.md +204 -0
- package/bin/review.js +283 -0
- package/package.json +43 -0
- package/src/claude.js +95 -0
- package/src/config.js +52 -0
- package/src/gemini.js +76 -0
- package/src/git.js +113 -0
- package/src/index.js +41 -0
- package/src/output.js +119 -0
- package/src/prompts.js +108 -0
- package/src/provider.js +84 -0
- package/src/rules.js +97 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to NPM
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
id-token: write # required for provenance
|
|
13
|
+
steps:
|
|
14
|
+
- name: Checkout
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Setup Node.js
|
|
18
|
+
uses: actions/setup-node@v4
|
|
19
|
+
with:
|
|
20
|
+
node-version: '20'
|
|
21
|
+
registry-url: 'https://registry.npmjs.org'
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: npm install
|
|
25
|
+
|
|
26
|
+
- name: Publish to NPM
|
|
27
|
+
run: npm publish --provenance --access public
|
|
28
|
+
env:
|
|
29
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# git-diff-ai-reviewer
|
|
2
|
+
|
|
3
|
+
AI-powered code review tool using **Claude** or **Gemini** APIs. Reviews git branch diffs and generates structured, actionable feedback with severity levels.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
> **`--save-dev` is correct** — this is a development tool (like ESLint or Prettier) used only during development and CI. It should never be in your production bundle.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# From npm (recommended)
|
|
11
|
+
npm install --save-dev git-diff-ai-reviewer
|
|
12
|
+
|
|
13
|
+
# Or directly from GitHub
|
|
14
|
+
npm install --save-dev github:andrii-posia/git-diff-ai-reviewer
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then install **only the SDK for the provider you want to use** (also as devDependencies):
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# If using Claude (Anthropic)
|
|
21
|
+
npm install --save-dev @anthropic-ai/sdk
|
|
22
|
+
|
|
23
|
+
# If using Gemini (Google)
|
|
24
|
+
npm install --save-dev @google/genai
|
|
25
|
+
|
|
26
|
+
# If you want both available
|
|
27
|
+
npm install --save-dev @anthropic-ai/sdk @google/genai
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
> Neither SDK is installed automatically. You only pay for what you use.
|
|
32
|
+
|
|
33
|
+
## Setup
|
|
34
|
+
|
|
35
|
+
Set the API key for your chosen provider:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# For Claude (Anthropic)
|
|
39
|
+
export ANTHROPIC_API_KEY=your-key-here
|
|
40
|
+
|
|
41
|
+
# For Gemini (Google)
|
|
42
|
+
export GEMINI_API_KEY=your-key-here
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### CLI
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Review with auto-detected provider (based on which env var is set)
|
|
51
|
+
git-diff-ai-reviewer review
|
|
52
|
+
|
|
53
|
+
# Explicitly choose a provider
|
|
54
|
+
git-diff-ai-reviewer review --provider claude
|
|
55
|
+
git-diff-ai-reviewer review --provider gemini
|
|
56
|
+
|
|
57
|
+
# Review against a specific base branch
|
|
58
|
+
git-diff-ai-reviewer review --base develop
|
|
59
|
+
|
|
60
|
+
# Preview diff without calling API
|
|
61
|
+
git-diff-ai-reviewer review --dry-run
|
|
62
|
+
|
|
63
|
+
# Generate fix prompt from latest review
|
|
64
|
+
git-diff-ai-reviewer fix
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### npm scripts
|
|
68
|
+
|
|
69
|
+
Add to your `package.json`:
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"scripts": {
|
|
73
|
+
"review": "git-diff-ai-reviewer review",
|
|
74
|
+
"review:fix": "git-diff-ai-reviewer fix",
|
|
75
|
+
"review:dry": "git-diff-ai-reviewer review --dry-run"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Providers
|
|
81
|
+
|
|
82
|
+
| Provider | Env Variable | Default Model |
|
|
83
|
+
|----------|-------------|---------------|
|
|
84
|
+
| Claude | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` |
|
|
85
|
+
| Gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` |
|
|
86
|
+
|
|
87
|
+
The provider is auto-detected from which API key is set. You can also set it explicitly in the config or via `--provider` flag.
|
|
88
|
+
|
|
89
|
+
## Severity Levels
|
|
90
|
+
|
|
91
|
+
Each review finding is tagged with a severity:
|
|
92
|
+
|
|
93
|
+
- 🔴 **CRITICAL** — Bugs, security vulnerabilities, data loss risks. Must fix before merging.
|
|
94
|
+
- 🟡 **WARNING** — Performance issues, bad practices, potential bugs. Should be addressed.
|
|
95
|
+
- 🔵 **SUGGESTION** — Style improvements, readability, refactoring opportunities. Nice to have.
|
|
96
|
+
|
|
97
|
+
The CLI exits with code `1` if CRITICAL issues are found (useful for CI gates).
|
|
98
|
+
|
|
99
|
+
## Review Rule Presets
|
|
100
|
+
|
|
101
|
+
Three built-in rule presets control what the AI checks for:
|
|
102
|
+
|
|
103
|
+
### `basic` — Quick, lightweight review
|
|
104
|
+
- Syntax errors, typos, unused variables
|
|
105
|
+
- Debug statements left in code
|
|
106
|
+
- Hardcoded secrets
|
|
107
|
+
- Basic error handling
|
|
108
|
+
|
|
109
|
+
### `standard` (default) — Balanced everyday review
|
|
110
|
+
- Everything in `basic`, plus:
|
|
111
|
+
- Input validation, null checks
|
|
112
|
+
- Code duplication, naming conventions
|
|
113
|
+
- Async/await correctness
|
|
114
|
+
- Resource cleanup
|
|
115
|
+
|
|
116
|
+
### `comprehensive` — Full audit for critical code
|
|
117
|
+
- Everything in `standard`, plus:
|
|
118
|
+
- Security (SQL injection, XSS, auth)
|
|
119
|
+
- Race conditions, memory leaks
|
|
120
|
+
- API design, backward compatibility
|
|
121
|
+
- Accessibility, test coverage
|
|
122
|
+
- Performance analysis
|
|
123
|
+
|
|
124
|
+
Use a preset name or provide custom rules in your config.
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
Create `.ai-review.config.json` in your project root:
|
|
129
|
+
|
|
130
|
+
```json
|
|
131
|
+
{
|
|
132
|
+
"provider": "claude",
|
|
133
|
+
"baseBranch": "main",
|
|
134
|
+
"model": "",
|
|
135
|
+
"maxTokens": 4096,
|
|
136
|
+
"outputDir": "./reviews",
|
|
137
|
+
"reviewRules": "standard"
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Config Options
|
|
142
|
+
|
|
143
|
+
| Option | Type | Default | Description |
|
|
144
|
+
|--------|------|---------|-------------|
|
|
145
|
+
| `provider` | string | auto-detect | `"claude"` or `"gemini"` |
|
|
146
|
+
| `baseBranch` | string | `"main"` | Branch to compare against |
|
|
147
|
+
| `model` | string | per-provider | Override the AI model |
|
|
148
|
+
| `maxTokens` | number | `4096` | Max response tokens |
|
|
149
|
+
| `outputDir` | string | `"./reviews"` | Where to write output files |
|
|
150
|
+
| `reviewRules` | string\|array | `"standard"` | Preset name or custom rules array |
|
|
151
|
+
|
|
152
|
+
### Custom Rules Example
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"provider": "gemini",
|
|
157
|
+
"reviewRules": [
|
|
158
|
+
"Use camelCase for variable names",
|
|
159
|
+
"All functions must have JSDoc comments",
|
|
160
|
+
"No inline styles in React components",
|
|
161
|
+
"Database queries must use parameterized statements"
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Programmatic API
|
|
167
|
+
|
|
168
|
+
```javascript
|
|
169
|
+
const {
|
|
170
|
+
reviewCode,
|
|
171
|
+
getDiff,
|
|
172
|
+
getChangedFiles,
|
|
173
|
+
detectProvider,
|
|
174
|
+
STANDARD_RULES,
|
|
175
|
+
} = require('git-diff-ai-reviewer');
|
|
176
|
+
|
|
177
|
+
const diff = getDiff('main');
|
|
178
|
+
const files = getChangedFiles('main');
|
|
179
|
+
const provider = detectProvider({ provider: 'gemini' });
|
|
180
|
+
const review = await reviewCode(diff, {
|
|
181
|
+
provider,
|
|
182
|
+
changedFiles: files,
|
|
183
|
+
branchName: 'feature/xyz',
|
|
184
|
+
reviewRules: STANDARD_RULES,
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Output Files
|
|
189
|
+
|
|
190
|
+
Review outputs are saved to `./reviews/` (configurable):
|
|
191
|
+
- `review-output-<timestamp>.txt` — Full review with severity-tagged findings
|
|
192
|
+
- `fix-prompt-<timestamp>.txt` — AI agent-ready prompt to auto-fix issues
|
|
193
|
+
|
|
194
|
+
## CI Integration
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
# GitHub Actions example
|
|
198
|
+
- name: AI Code Review
|
|
199
|
+
run: npx git-diff-ai-reviewer review --provider claude
|
|
200
|
+
env:
|
|
201
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The CLI exits with code `1` on CRITICAL findings, making it suitable as a CI gate.
|
package/bin/review.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { getDiff, getChangedFiles, getBranchName, isGitRepo } = require('../src/git');
|
|
6
|
+
const { reviewCode, parseSeverityCounts, detectProvider, PROVIDERS, DEFAULT_MODELS } = require('../src/provider');
|
|
7
|
+
const { buildFixPrompt } = require('../src/prompts');
|
|
8
|
+
const { loadConfig } = require('../src/config');
|
|
9
|
+
const { writeReviewOutput, writeFixPrompt, findLatestReview } = require('../src/output');
|
|
10
|
+
|
|
11
|
+
// ─── Argument Parsing ────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function parseArgs(argv) {
|
|
14
|
+
const args = argv.slice(2);
|
|
15
|
+
const flags = {};
|
|
16
|
+
let command = null;
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
switch (args[i]) {
|
|
20
|
+
case '--base':
|
|
21
|
+
case '-b':
|
|
22
|
+
flags.baseBranch = args[++i];
|
|
23
|
+
break;
|
|
24
|
+
case '--output':
|
|
25
|
+
case '-o':
|
|
26
|
+
flags.outputDir = args[++i];
|
|
27
|
+
break;
|
|
28
|
+
case '--config':
|
|
29
|
+
case '-c':
|
|
30
|
+
flags.configPath = args[++i];
|
|
31
|
+
break;
|
|
32
|
+
case '--model':
|
|
33
|
+
case '-m':
|
|
34
|
+
flags.model = args[++i];
|
|
35
|
+
break;
|
|
36
|
+
case '--provider':
|
|
37
|
+
case '-p':
|
|
38
|
+
flags.provider = args[++i];
|
|
39
|
+
break;
|
|
40
|
+
case '--dry-run':
|
|
41
|
+
flags.dryRun = true;
|
|
42
|
+
break;
|
|
43
|
+
case '--help':
|
|
44
|
+
case '-h':
|
|
45
|
+
flags.help = true;
|
|
46
|
+
break;
|
|
47
|
+
default:
|
|
48
|
+
if (!args[i].startsWith('-') && !command) {
|
|
49
|
+
command = args[i];
|
|
50
|
+
} else {
|
|
51
|
+
console.warn(`Unknown option: ${args[i]}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { command: command || 'review', flags };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Help ────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
function showHelp() {
|
|
62
|
+
console.log(`
|
|
63
|
+
╔══════════════════════════════════════════════════════╗
|
|
64
|
+
║ 🤖 AI Code Review CLI ║
|
|
65
|
+
╚══════════════════════════════════════════════════════╝
|
|
66
|
+
|
|
67
|
+
Usage: ai-review <command> [options]
|
|
68
|
+
|
|
69
|
+
Commands:
|
|
70
|
+
review Review code changes using AI (default)
|
|
71
|
+
fix Generate a fix prompt from the latest review
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
-b, --base <branch> Base branch to compare against (default: main)
|
|
75
|
+
-p, --provider <name> AI provider: 'claude' or 'gemini' (auto-detected from env)
|
|
76
|
+
-m, --model <model> Model name (defaults per provider)
|
|
77
|
+
-o, --output <dir> Output directory for review files (default: ./reviews)
|
|
78
|
+
-c, --config <path> Path to config file
|
|
79
|
+
--dry-run Extract diff and show prompt without calling AI API
|
|
80
|
+
-h, --help Show this help message
|
|
81
|
+
|
|
82
|
+
Providers:
|
|
83
|
+
claude Uses ANTHROPIC_API_KEY (default model: claude-sonnet-4-20250514)
|
|
84
|
+
gemini Uses GEMINI_API_KEY (default model: gemini-2.0-flash)
|
|
85
|
+
|
|
86
|
+
Severity Levels:
|
|
87
|
+
🔴 CRITICAL Bugs, security issues, data loss — must fix
|
|
88
|
+
🟡 WARNING Performance, bad practices — should fix
|
|
89
|
+
🔵 SUGGESTION Style, readability — nice to have
|
|
90
|
+
|
|
91
|
+
Review Rule Presets:
|
|
92
|
+
basic Quick lint-level checks
|
|
93
|
+
standard Balanced everyday review (default)
|
|
94
|
+
comprehensive Full audit for critical code
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
ai-review review Review with auto-detected provider
|
|
98
|
+
ai-review review --provider gemini Review using Gemini
|
|
99
|
+
ai-review review --base develop Review against develop branch
|
|
100
|
+
ai-review review --dry-run Preview without calling API
|
|
101
|
+
ai-review fix Generate fix prompt from latest review
|
|
102
|
+
|
|
103
|
+
Config File (.ai-review.config.json):
|
|
104
|
+
{
|
|
105
|
+
"provider": "claude",
|
|
106
|
+
"baseBranch": "main",
|
|
107
|
+
"model": "",
|
|
108
|
+
"maxTokens": 4096,
|
|
109
|
+
"outputDir": "./reviews",
|
|
110
|
+
"reviewRules": "standard"
|
|
111
|
+
}
|
|
112
|
+
`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Review Command ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
async function commandReview(config, flags) {
|
|
118
|
+
const baseBranch = flags.baseBranch || config.baseBranch;
|
|
119
|
+
const outputDir = path.resolve(flags.outputDir || config.outputDir);
|
|
120
|
+
|
|
121
|
+
// Detect provider
|
|
122
|
+
const provider = flags.provider || detectProvider(config);
|
|
123
|
+
const model = flags.model || config.model || DEFAULT_MODELS[provider];
|
|
124
|
+
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log('🔍 AI Code Review');
|
|
127
|
+
console.log('─'.repeat(40));
|
|
128
|
+
console.log(` Provider: ${provider}`);
|
|
129
|
+
console.log(` Model: ${model}`);
|
|
130
|
+
|
|
131
|
+
// Get branch and diff info
|
|
132
|
+
const branchName = getBranchName();
|
|
133
|
+
console.log(` Branch: ${branchName}`);
|
|
134
|
+
console.log(` Base: ${baseBranch}`);
|
|
135
|
+
|
|
136
|
+
const changedFiles = getChangedFiles(baseBranch);
|
|
137
|
+
if (changedFiles.length === 0) {
|
|
138
|
+
console.log('\n✅ No changes detected. Nothing to review.');
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
141
|
+
console.log(` Changed: ${changedFiles.length} file(s)`);
|
|
142
|
+
changedFiles.forEach(f => console.log(` - ${f}`));
|
|
143
|
+
|
|
144
|
+
const diff = getDiff(baseBranch);
|
|
145
|
+
if (!diff.trim()) {
|
|
146
|
+
console.log('\n✅ Empty diff. Nothing to review.');
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
console.log(` Diff size: ${diff.length} characters`);
|
|
150
|
+
console.log('─'.repeat(40));
|
|
151
|
+
|
|
152
|
+
// Dry run mode — show what would be sent
|
|
153
|
+
if (flags.dryRun) {
|
|
154
|
+
console.log('\n📋 DRY RUN — Diff extracted, API not called.\n');
|
|
155
|
+
console.log(`Provider: ${provider} | Model: ${model}`);
|
|
156
|
+
console.log(`Rules: ${config.reviewRules.length} active`);
|
|
157
|
+
console.log('Changed files:');
|
|
158
|
+
changedFiles.forEach(f => console.log(` - ${f}`));
|
|
159
|
+
console.log(`\nDiff preview (first 500 chars):\n${diff.slice(0, 500)}...`);
|
|
160
|
+
console.log('\n💡 Remove --dry-run to send to AI for review.');
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Send to AI provider
|
|
165
|
+
console.log(`\n⏳ Sending to ${provider} (${model}) for review...\n`);
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const reviewText = await reviewCode(diff, {
|
|
169
|
+
provider,
|
|
170
|
+
changedFiles,
|
|
171
|
+
branchName,
|
|
172
|
+
model,
|
|
173
|
+
maxTokens: config.maxTokens,
|
|
174
|
+
reviewRules: config.reviewRules,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Parse severity counts
|
|
178
|
+
const severityCounts = parseSeverityCounts(reviewText);
|
|
179
|
+
|
|
180
|
+
// Write output
|
|
181
|
+
const outputPath = writeReviewOutput(reviewText, outputDir, {
|
|
182
|
+
branchName,
|
|
183
|
+
changedFiles,
|
|
184
|
+
severityCounts,
|
|
185
|
+
provider,
|
|
186
|
+
model,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Summary
|
|
190
|
+
console.log('═'.repeat(40));
|
|
191
|
+
console.log(' Review Complete!');
|
|
192
|
+
console.log('═'.repeat(40));
|
|
193
|
+
console.log(` 🔴 Critical: ${severityCounts.critical}`);
|
|
194
|
+
console.log(` 🟡 Warning: ${severityCounts.warning}`);
|
|
195
|
+
console.log(` 🔵 Suggestion: ${severityCounts.suggestion}`);
|
|
196
|
+
console.log('─'.repeat(40));
|
|
197
|
+
console.log(` 📄 Output: ${outputPath}`);
|
|
198
|
+
console.log('');
|
|
199
|
+
console.log('💡 Run "ai-review fix" to generate an AI fix prompt.');
|
|
200
|
+
console.log('');
|
|
201
|
+
|
|
202
|
+
// Exit with non-zero if critical issues found
|
|
203
|
+
if (severityCounts.critical > 0) {
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
console.error(`\n❌ Review failed: ${error.message}`);
|
|
208
|
+
process.exit(2);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Fix Command ────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
async function commandFix(config, flags) {
|
|
215
|
+
const baseBranch = flags.baseBranch || config.baseBranch;
|
|
216
|
+
const outputDir = path.resolve(flags.outputDir || config.outputDir);
|
|
217
|
+
|
|
218
|
+
console.log('');
|
|
219
|
+
console.log('🔧 Generate Fix Prompt');
|
|
220
|
+
console.log('─'.repeat(40));
|
|
221
|
+
|
|
222
|
+
// Find latest review
|
|
223
|
+
const latestReview = findLatestReview(outputDir);
|
|
224
|
+
if (!latestReview) {
|
|
225
|
+
console.error('❌ No review output found. Run "ai-review review" first.');
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log(` Using review: ${latestReview}`);
|
|
230
|
+
|
|
231
|
+
const reviewText = fs.readFileSync(latestReview, 'utf-8');
|
|
232
|
+
const diff = getDiff(baseBranch);
|
|
233
|
+
|
|
234
|
+
// Build fix prompt
|
|
235
|
+
const fixPromptText = buildFixPrompt(reviewText, diff);
|
|
236
|
+
|
|
237
|
+
// Write fix prompt
|
|
238
|
+
const fixPath = writeFixPrompt(fixPromptText, outputDir);
|
|
239
|
+
|
|
240
|
+
console.log('─'.repeat(40));
|
|
241
|
+
console.log(` 📄 Fix prompt: ${fixPath}`);
|
|
242
|
+
console.log('');
|
|
243
|
+
console.log('💡 Paste the contents of this file into your AI agent to auto-fix issues.');
|
|
244
|
+
console.log('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Main ───────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
async function main() {
|
|
250
|
+
const { command, flags } = parseArgs(process.argv);
|
|
251
|
+
|
|
252
|
+
if (flags.help) {
|
|
253
|
+
showHelp();
|
|
254
|
+
process.exit(0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Verify git repo
|
|
258
|
+
if (!isGitRepo()) {
|
|
259
|
+
console.error('❌ Not a git repository. Run this command from within a git project.');
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Load config
|
|
264
|
+
const config = loadConfig(flags.configPath);
|
|
265
|
+
|
|
266
|
+
switch (command) {
|
|
267
|
+
case 'review':
|
|
268
|
+
await commandReview(config, flags);
|
|
269
|
+
break;
|
|
270
|
+
case 'fix':
|
|
271
|
+
await commandFix(config, flags);
|
|
272
|
+
break;
|
|
273
|
+
default:
|
|
274
|
+
console.error(`❌ Unknown command: ${command}`);
|
|
275
|
+
console.error(' Run "ai-review --help" for usage info.');
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
main().catch(err => {
|
|
281
|
+
console.error(`\n❌ Unexpected error: ${err.message}`);
|
|
282
|
+
process.exit(2);
|
|
283
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-diff-ai-reviewer",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "AI-powered code review using Claude or Gemini API. Reviews git branch diffs and generates actionable fix prompts.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-diff-ai-reviewer": "./bin/review.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node bin/review.js --help"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"code-review",
|
|
14
|
+
"ai",
|
|
15
|
+
"claude",
|
|
16
|
+
"gemini",
|
|
17
|
+
"linter",
|
|
18
|
+
"git",
|
|
19
|
+
"diff"
|
|
20
|
+
],
|
|
21
|
+
"author": "Andrii Posia",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/andrii-posia/git-diff-ai-reviewer.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/andrii-posia/git-diff-ai-reviewer/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/andrii-posia/git-diff-ai-reviewer#readme",
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
33
|
+
"@google/genai": "^0.14.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"@anthropic-ai/sdk": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"@google/genai": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/claude.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const { buildReviewSystemPrompt, buildReviewUserPrompt } = require('./prompts');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dynamically load the Anthropic SDK.
|
|
5
|
+
* @returns {Object} The Anthropic class
|
|
6
|
+
*/
|
|
7
|
+
function loadAnthropicSDK() {
|
|
8
|
+
try {
|
|
9
|
+
return require('@anthropic-ai/sdk');
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'Claude SDK not installed. Run: npm install @anthropic-ai/sdk'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create an Anthropic client instance.
|
|
19
|
+
* @returns {Anthropic}
|
|
20
|
+
*/
|
|
21
|
+
function createClient() {
|
|
22
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'ANTHROPIC_API_KEY environment variable is not set.\n' +
|
|
26
|
+
'Get your API key at https://console.anthropic.com/settings/keys\n' +
|
|
27
|
+
'Then set it: export ANTHROPIC_API_KEY=your-key-here'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const Anthropic = loadAnthropicSDK();
|
|
31
|
+
return new Anthropic({ apiKey });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Send code diff to Claude for review.
|
|
36
|
+
* @param {string} diff - The git diff content
|
|
37
|
+
* @param {Object} options
|
|
38
|
+
* @param {string[]} options.changedFiles - List of changed file paths
|
|
39
|
+
* @param {string} options.branchName - Current branch name
|
|
40
|
+
* @param {string} [options.model] - Claude model to use
|
|
41
|
+
* @param {number} [options.maxTokens] - Max response tokens
|
|
42
|
+
* @param {string[]} [options.reviewRules] - Custom review rules
|
|
43
|
+
* @returns {Promise<string>} The review text
|
|
44
|
+
*/
|
|
45
|
+
async function reviewCode(diff, options = {}) {
|
|
46
|
+
const {
|
|
47
|
+
changedFiles = [],
|
|
48
|
+
branchName = 'unknown',
|
|
49
|
+
model = 'claude-sonnet-4-20250514',
|
|
50
|
+
maxTokens = 4096,
|
|
51
|
+
reviewRules = [],
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
const client = createClient();
|
|
55
|
+
|
|
56
|
+
const systemPrompt = buildReviewSystemPrompt({ reviewRules });
|
|
57
|
+
const userMessage = buildReviewUserPrompt(diff, changedFiles, branchName);
|
|
58
|
+
|
|
59
|
+
console.log(`🤖 Sending ${diff.length} chars of diff to Claude (${model})...`);
|
|
60
|
+
|
|
61
|
+
const response = await client.messages.create({
|
|
62
|
+
model,
|
|
63
|
+
max_tokens: maxTokens,
|
|
64
|
+
system: systemPrompt,
|
|
65
|
+
messages: [
|
|
66
|
+
{ role: 'user', content: userMessage },
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const reviewText = response.content
|
|
71
|
+
.filter(block => block.type === 'text')
|
|
72
|
+
.map(block => block.text)
|
|
73
|
+
.join('\n');
|
|
74
|
+
|
|
75
|
+
return reviewText;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse review text to extract severity counts.
|
|
80
|
+
* @param {string} reviewText - Raw review text from Claude
|
|
81
|
+
* @returns {{ critical: number, warning: number, suggestion: number }}
|
|
82
|
+
*/
|
|
83
|
+
function parseSeverityCounts(reviewText) {
|
|
84
|
+
const critical = (reviewText.match(/\[SEVERITY\]\s*CRITICAL/gi) || []).length;
|
|
85
|
+
const warning = (reviewText.match(/\[SEVERITY\]\s*WARNING/gi) || []).length;
|
|
86
|
+
const suggestion = (reviewText.match(/\[SEVERITY\]\s*SUGGESTION/gi) || []).length;
|
|
87
|
+
|
|
88
|
+
return { critical, warning, suggestion };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
createClient,
|
|
93
|
+
reviewCode,
|
|
94
|
+
parseSeverityCounts,
|
|
95
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { resolveRules } = require('./rules');
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILENAME = '.ai-review.config.json';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CONFIG = {
|
|
8
|
+
provider: '', // 'claude' or 'gemini' — auto-detected from env vars if empty
|
|
9
|
+
baseBranch: 'main',
|
|
10
|
+
model: '', // per-provider default applied at runtime
|
|
11
|
+
maxTokens: 4096,
|
|
12
|
+
outputDir: './reviews',
|
|
13
|
+
reviewRules: 'standard', // preset name ('basic', 'standard', 'comprehensive') or custom array
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Load configuration from .ai-review.config.json in the project root.
|
|
18
|
+
* Falls back to defaults for missing values.
|
|
19
|
+
* @param {string} [configPath] - Explicit path to config file
|
|
20
|
+
* @param {string} [cwd] - Working directory to search for config
|
|
21
|
+
* @returns {Object} Merged configuration
|
|
22
|
+
*/
|
|
23
|
+
function loadConfig(configPath, cwd = process.cwd()) {
|
|
24
|
+
const resolvedPath = configPath
|
|
25
|
+
? path.resolve(cwd, configPath)
|
|
26
|
+
: path.join(cwd, CONFIG_FILENAME);
|
|
27
|
+
|
|
28
|
+
let fileConfig = {};
|
|
29
|
+
|
|
30
|
+
if (fs.existsSync(resolvedPath)) {
|
|
31
|
+
try {
|
|
32
|
+
const raw = fs.readFileSync(resolvedPath, 'utf-8');
|
|
33
|
+
fileConfig = JSON.parse(raw);
|
|
34
|
+
console.log(`📋 Loaded config from ${resolvedPath}`);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
console.warn(`⚠️ Failed to parse config file ${resolvedPath}: ${error.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const merged = { ...DEFAULT_CONFIG, ...fileConfig };
|
|
41
|
+
|
|
42
|
+
// Resolve rule presets to actual rule arrays
|
|
43
|
+
merged.reviewRules = resolveRules(merged.reviewRules);
|
|
44
|
+
|
|
45
|
+
return merged;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
loadConfig,
|
|
50
|
+
DEFAULT_CONFIG,
|
|
51
|
+
CONFIG_FILENAME,
|
|
52
|
+
};
|
package/src/gemini.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { buildReviewSystemPrompt, buildReviewUserPrompt } = require('./prompts');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dynamically load the Gemini SDK.
|
|
5
|
+
* @returns {Object} The GoogleGenAI class
|
|
6
|
+
*/
|
|
7
|
+
function loadGeminiSDK() {
|
|
8
|
+
try {
|
|
9
|
+
return require('@google/genai');
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'Gemini SDK not installed. Run: npm install @google/genai'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a Gemini client instance.
|
|
19
|
+
* @returns {Object} GoogleGenAI instance
|
|
20
|
+
*/
|
|
21
|
+
function createGeminiClient() {
|
|
22
|
+
const apiKey = process.env.GEMINI_API_KEY;
|
|
23
|
+
if (!apiKey) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'GEMINI_API_KEY environment variable is not set.\n' +
|
|
26
|
+
'Get your API key at https://aistudio.google.com/apikey\n' +
|
|
27
|
+
'Then set it: export GEMINI_API_KEY=your-key-here'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const { GoogleGenAI } = loadGeminiSDK();
|
|
31
|
+
return new GoogleGenAI({ apiKey });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Send code diff to Gemini for review.
|
|
36
|
+
* @param {string} diff - The git diff content
|
|
37
|
+
* @param {Object} options
|
|
38
|
+
* @param {string[]} options.changedFiles - List of changed file paths
|
|
39
|
+
* @param {string} options.branchName - Current branch name
|
|
40
|
+
* @param {string} [options.model] - Gemini model to use
|
|
41
|
+
* @param {number} [options.maxTokens] - Max response tokens
|
|
42
|
+
* @param {string[]} [options.reviewRules] - Custom review rules
|
|
43
|
+
* @returns {Promise<string>} The review text
|
|
44
|
+
*/
|
|
45
|
+
async function reviewCodeWithGemini(diff, options = {}) {
|
|
46
|
+
const {
|
|
47
|
+
changedFiles = [],
|
|
48
|
+
branchName = 'unknown',
|
|
49
|
+
model = 'gemini-2.0-flash',
|
|
50
|
+
maxTokens = 4096,
|
|
51
|
+
reviewRules = [],
|
|
52
|
+
} = options;
|
|
53
|
+
|
|
54
|
+
const client = createGeminiClient();
|
|
55
|
+
|
|
56
|
+
const systemPrompt = buildReviewSystemPrompt({ reviewRules });
|
|
57
|
+
const userMessage = buildReviewUserPrompt(diff, changedFiles, branchName);
|
|
58
|
+
|
|
59
|
+
console.log(`🤖 Sending ${diff.length} chars of diff to Gemini (${model})...`);
|
|
60
|
+
|
|
61
|
+
const response = await client.models.generateContent({
|
|
62
|
+
model,
|
|
63
|
+
contents: userMessage,
|
|
64
|
+
config: {
|
|
65
|
+
systemInstruction: systemPrompt,
|
|
66
|
+
maxOutputTokens: maxTokens,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return response.text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
createGeminiClient,
|
|
75
|
+
reviewCodeWithGemini,
|
|
76
|
+
};
|
package/src/git.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get the diff between the current branch and a base branch.
|
|
5
|
+
* @param {string} baseBranch - The base branch to compare against (default: 'main')
|
|
6
|
+
* @param {string} cwd - Working directory (default: process.cwd())
|
|
7
|
+
* @returns {string} The git diff output
|
|
8
|
+
*/
|
|
9
|
+
function getDiff(baseBranch = 'main', cwd = process.cwd()) {
|
|
10
|
+
try {
|
|
11
|
+
// First try three-dot diff (for branch comparison)
|
|
12
|
+
const diff = execSync(`git diff ${baseBranch}...HEAD`, {
|
|
13
|
+
cwd,
|
|
14
|
+
encoding: 'utf-8',
|
|
15
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large diffs
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!diff.trim()) {
|
|
19
|
+
// Fallback: try two-dot diff (for uncommitted changes)
|
|
20
|
+
const uncommitted = execSync('git diff HEAD', {
|
|
21
|
+
cwd,
|
|
22
|
+
encoding: 'utf-8',
|
|
23
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
if (!uncommitted.trim()) {
|
|
27
|
+
// Also check staged changes
|
|
28
|
+
const staged = execSync('git diff --cached', {
|
|
29
|
+
cwd,
|
|
30
|
+
encoding: 'utf-8',
|
|
31
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
32
|
+
});
|
|
33
|
+
return staged;
|
|
34
|
+
}
|
|
35
|
+
return uncommitted;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return diff;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(`Failed to get git diff: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get list of changed files between current branch and base branch.
|
|
46
|
+
* @param {string} baseBranch - The base branch to compare against
|
|
47
|
+
* @param {string} cwd - Working directory
|
|
48
|
+
* @returns {string[]} Array of changed file paths
|
|
49
|
+
*/
|
|
50
|
+
function getChangedFiles(baseBranch = 'main', cwd = process.cwd()) {
|
|
51
|
+
try {
|
|
52
|
+
const output = execSync(`git diff --name-only ${baseBranch}...HEAD`, {
|
|
53
|
+
cwd,
|
|
54
|
+
encoding: 'utf-8',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const files = output.trim().split('\n').filter(Boolean);
|
|
58
|
+
|
|
59
|
+
if (files.length === 0) {
|
|
60
|
+
// Fallback to uncommitted changes
|
|
61
|
+
const uncommitted = execSync('git diff --name-only HEAD', {
|
|
62
|
+
cwd,
|
|
63
|
+
encoding: 'utf-8',
|
|
64
|
+
});
|
|
65
|
+
return uncommitted.trim().split('\n').filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return files;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
throw new Error(`Failed to get changed files: ${error.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get the current branch name.
|
|
76
|
+
* @param {string} cwd - Working directory
|
|
77
|
+
* @returns {string} Current branch name
|
|
78
|
+
*/
|
|
79
|
+
function getBranchName(cwd = process.cwd()) {
|
|
80
|
+
try {
|
|
81
|
+
return execSync('git rev-parse --abbrev-ref HEAD', {
|
|
82
|
+
cwd,
|
|
83
|
+
encoding: 'utf-8',
|
|
84
|
+
}).trim();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new Error(`Failed to get branch name: ${error.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if we are inside a git repository.
|
|
92
|
+
* @param {string} cwd - Working directory
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
function isGitRepo(cwd = process.cwd()) {
|
|
96
|
+
try {
|
|
97
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
98
|
+
cwd,
|
|
99
|
+
encoding: 'utf-8',
|
|
100
|
+
stdio: 'pipe',
|
|
101
|
+
});
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
getDiff,
|
|
110
|
+
getChangedFiles,
|
|
111
|
+
getBranchName,
|
|
112
|
+
isGitRepo,
|
|
113
|
+
};
|
package/src/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { getDiff, getChangedFiles, getBranchName, isGitRepo } = require('./git');
|
|
2
|
+
const { reviewCode, parseSeverityCounts, detectProvider, PROVIDERS, DEFAULT_MODELS } = require('./provider');
|
|
3
|
+
const { buildFixPrompt, buildReviewSystemPrompt, buildReviewUserPrompt } = require('./prompts');
|
|
4
|
+
const { loadConfig } = require('./config');
|
|
5
|
+
const { writeReviewOutput, writeFixPrompt, findLatestReview } = require('./output');
|
|
6
|
+
const { BASIC_RULES, STANDARD_RULES, COMPREHENSIVE_RULES, RULE_PRESETS, resolveRules } = require('./rules');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
// Git utilities
|
|
10
|
+
getDiff,
|
|
11
|
+
getChangedFiles,
|
|
12
|
+
getBranchName,
|
|
13
|
+
isGitRepo,
|
|
14
|
+
|
|
15
|
+
// AI providers
|
|
16
|
+
reviewCode,
|
|
17
|
+
parseSeverityCounts,
|
|
18
|
+
detectProvider,
|
|
19
|
+
PROVIDERS,
|
|
20
|
+
DEFAULT_MODELS,
|
|
21
|
+
|
|
22
|
+
// Prompts
|
|
23
|
+
buildFixPrompt,
|
|
24
|
+
buildReviewSystemPrompt,
|
|
25
|
+
buildReviewUserPrompt,
|
|
26
|
+
|
|
27
|
+
// Config
|
|
28
|
+
loadConfig,
|
|
29
|
+
|
|
30
|
+
// Output
|
|
31
|
+
writeReviewOutput,
|
|
32
|
+
writeFixPrompt,
|
|
33
|
+
findLatestReview,
|
|
34
|
+
|
|
35
|
+
// Rules
|
|
36
|
+
BASIC_RULES,
|
|
37
|
+
STANDARD_RULES,
|
|
38
|
+
COMPREHENSIVE_RULES,
|
|
39
|
+
RULE_PRESETS,
|
|
40
|
+
resolveRules,
|
|
41
|
+
};
|
package/src/output.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Ensure the output directory exists.
|
|
6
|
+
* @param {string} dir - Directory path
|
|
7
|
+
*/
|
|
8
|
+
function ensureDir(dir) {
|
|
9
|
+
if (!fs.existsSync(dir)) {
|
|
10
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate a timestamped filename.
|
|
16
|
+
* @param {string} prefix - File prefix (e.g., 'review-output')
|
|
17
|
+
* @param {string} ext - File extension (e.g., '.txt')
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function timestampedFilename(prefix, ext = '.txt') {
|
|
21
|
+
const now = new Date();
|
|
22
|
+
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
23
|
+
return `${prefix}-${ts}${ext}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Write the review output to a text file.
|
|
28
|
+
* @param {string} reviewText - The review content
|
|
29
|
+
* @param {string} outputDir - Output directory path
|
|
30
|
+
* @param {Object} meta - Metadata to include in the header
|
|
31
|
+
* @param {string} meta.branchName - Branch name
|
|
32
|
+
* @param {string[]} meta.changedFiles - List of changed files
|
|
33
|
+
* @param {{ critical: number, warning: number, suggestion: number }} meta.severityCounts
|
|
34
|
+
* @returns {string} Path to the written file
|
|
35
|
+
*/
|
|
36
|
+
function writeReviewOutput(reviewText, outputDir, meta = {}) {
|
|
37
|
+
ensureDir(outputDir);
|
|
38
|
+
|
|
39
|
+
const filename = timestampedFilename('review-output');
|
|
40
|
+
const filePath = path.join(outputDir, filename);
|
|
41
|
+
|
|
42
|
+
const header = [
|
|
43
|
+
'═'.repeat(60),
|
|
44
|
+
' AI CODE REVIEW',
|
|
45
|
+
'═'.repeat(60),
|
|
46
|
+
` Provider: ${meta.provider || 'unknown'}`,
|
|
47
|
+
` Model: ${meta.model || 'unknown'}`,
|
|
48
|
+
` Branch: ${meta.branchName || 'unknown'}`,
|
|
49
|
+
` Date: ${new Date().toISOString()}`,
|
|
50
|
+
` Files: ${(meta.changedFiles || []).length} changed`,
|
|
51
|
+
'',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
if (meta.severityCounts) {
|
|
55
|
+
const sc = meta.severityCounts;
|
|
56
|
+
header.push(
|
|
57
|
+
' Severity Summary:',
|
|
58
|
+
` 🔴 CRITICAL: ${sc.critical}`,
|
|
59
|
+
` 🟡 WARNING: ${sc.warning}`,
|
|
60
|
+
` 🔵 SUGGESTION: ${sc.suggestion}`,
|
|
61
|
+
'',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
header.push('═'.repeat(60), '');
|
|
66
|
+
|
|
67
|
+
const content = header.join('\n') + '\n' + reviewText + '\n';
|
|
68
|
+
|
|
69
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
70
|
+
return filePath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write the fix prompt to a text file.
|
|
75
|
+
* @param {string} promptText - The fix prompt content
|
|
76
|
+
* @param {string} outputDir - Output directory path
|
|
77
|
+
* @returns {string} Path to the written file
|
|
78
|
+
*/
|
|
79
|
+
function writeFixPrompt(promptText, outputDir) {
|
|
80
|
+
ensureDir(outputDir);
|
|
81
|
+
|
|
82
|
+
const filename = timestampedFilename('fix-prompt');
|
|
83
|
+
const filePath = path.join(outputDir, filename);
|
|
84
|
+
|
|
85
|
+
const header = [
|
|
86
|
+
'═'.repeat(60),
|
|
87
|
+
' AI FIX PROMPT — paste this into your AI agent',
|
|
88
|
+
'═'.repeat(60),
|
|
89
|
+
` Generated: ${new Date().toISOString()}`,
|
|
90
|
+
'═'.repeat(60),
|
|
91
|
+
'',
|
|
92
|
+
].join('\n');
|
|
93
|
+
|
|
94
|
+
fs.writeFileSync(filePath, header + '\n' + promptText + '\n', 'utf-8');
|
|
95
|
+
return filePath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Find the most recent review output file in the output directory.
|
|
100
|
+
* @param {string} outputDir - Output directory path
|
|
101
|
+
* @returns {string|null} Path to the latest review file, or null
|
|
102
|
+
*/
|
|
103
|
+
function findLatestReview(outputDir) {
|
|
104
|
+
if (!fs.existsSync(outputDir)) return null;
|
|
105
|
+
|
|
106
|
+
const files = fs.readdirSync(outputDir)
|
|
107
|
+
.filter(f => f.startsWith('review-output-') && f.endsWith('.txt'))
|
|
108
|
+
.sort()
|
|
109
|
+
.reverse();
|
|
110
|
+
|
|
111
|
+
return files.length > 0 ? path.join(outputDir, files[0]) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
writeReviewOutput,
|
|
116
|
+
writeFixPrompt,
|
|
117
|
+
findLatestReview,
|
|
118
|
+
ensureDir,
|
|
119
|
+
};
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Severity levels for review findings.
|
|
3
|
+
*/
|
|
4
|
+
const SEVERITY_LEVELS = {
|
|
5
|
+
CRITICAL: 'CRITICAL', // Bugs, security issues, data loss risks
|
|
6
|
+
WARNING: 'WARNING', // Performance issues, bad practices, potential bugs
|
|
7
|
+
SUGGESTION: 'SUGGESTION', // Style, readability, minor improvements
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the system prompt for code review.
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {string[]} [options.reviewRules] - Additional custom review rules
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function buildReviewSystemPrompt(options = {}) {
|
|
17
|
+
const customRules = options.reviewRules
|
|
18
|
+
? `\n\nAdditional review rules provided by the project:\n${options.reviewRules.map((r, i) => `${i + 1}. ${r}`).join('\n')}`
|
|
19
|
+
: '';
|
|
20
|
+
|
|
21
|
+
return `You are an expert code reviewer. You review git diffs and provide actionable, specific feedback.
|
|
22
|
+
|
|
23
|
+
Your review MUST follow this exact output format for each finding:
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
[SEVERITY] CRITICAL | WARNING | SUGGESTION
|
|
27
|
+
[FILE] <filename>
|
|
28
|
+
[LINE] <line number or range, e.g., 42 or 42-50>
|
|
29
|
+
[TITLE] <short summary of the issue>
|
|
30
|
+
[DESCRIPTION]
|
|
31
|
+
<detailed explanation of the issue>
|
|
32
|
+
[FIX]
|
|
33
|
+
<specific code or instructions to fix the issue>
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
Severity definitions:
|
|
37
|
+
- CRITICAL: Bugs, security vulnerabilities, data loss risks, crashes, broken functionality. These MUST be fixed before merging.
|
|
38
|
+
- WARNING: Performance issues, bad practices, potential future bugs, missing error handling. Should be addressed.
|
|
39
|
+
- SUGGESTION: Code style, readability improvements, refactoring opportunities, documentation. Nice to have.
|
|
40
|
+
|
|
41
|
+
Rules:
|
|
42
|
+
1. Be specific — reference exact file names and line numbers from the diff.
|
|
43
|
+
2. Provide concrete fix suggestions, not vague advice.
|
|
44
|
+
3. Focus on what changed in the diff, not preexisting code.
|
|
45
|
+
4. Group related issues together if they share the same root cause.
|
|
46
|
+
5. At the end, provide a SUMMARY section with counts by severity level.
|
|
47
|
+
6. If the code looks good, say so — don't invent issues.${customRules}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build the user message for code review.
|
|
52
|
+
* @param {string} diff - The git diff content
|
|
53
|
+
* @param {string[]} changedFiles - List of changed files
|
|
54
|
+
* @param {string} branchName - Current branch name
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function buildReviewUserPrompt(diff, changedFiles, branchName) {
|
|
58
|
+
return `Please review the following code changes on branch "${branchName}".
|
|
59
|
+
|
|
60
|
+
Changed files:
|
|
61
|
+
${changedFiles.map(f => `- ${f}`).join('\n')}
|
|
62
|
+
|
|
63
|
+
Git diff:
|
|
64
|
+
\`\`\`diff
|
|
65
|
+
${diff}
|
|
66
|
+
\`\`\`
|
|
67
|
+
|
|
68
|
+
Provide your review following the structured format specified in your instructions.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build a prompt that an AI agent can use to automatically fix issues found in the review.
|
|
73
|
+
* @param {string} reviewText - The review output text
|
|
74
|
+
* @param {string} diff - The original git diff
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function buildFixPrompt(reviewText, diff) {
|
|
78
|
+
return `You are an AI coding agent. Below is a code review of recent changes, followed by the original diff.
|
|
79
|
+
Your task is to fix ALL issues marked as CRITICAL and WARNING. For SUGGESTION items, fix them only if they are trivial.
|
|
80
|
+
|
|
81
|
+
Apply the fixes directly to the source files. For each fix:
|
|
82
|
+
1. State which file and line you are modifying.
|
|
83
|
+
2. Show the exact change (before → after).
|
|
84
|
+
3. Explain briefly why the change is needed.
|
|
85
|
+
|
|
86
|
+
=== CODE REVIEW ===
|
|
87
|
+
${reviewText}
|
|
88
|
+
|
|
89
|
+
=== ORIGINAL DIFF ===
|
|
90
|
+
\`\`\`diff
|
|
91
|
+
${diff}
|
|
92
|
+
\`\`\`
|
|
93
|
+
|
|
94
|
+
=== INSTRUCTIONS ===
|
|
95
|
+
- Fix CRITICAL issues first, then WARNING, then SUGGESTION.
|
|
96
|
+
- Do not introduce new bugs or change unrelated code.
|
|
97
|
+
- If a fix requires refactoring, explain the approach before applying.
|
|
98
|
+
- Output each fix in a clear, copy-pasteable format.
|
|
99
|
+
|
|
100
|
+
Begin fixing the issues now.`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
SEVERITY_LEVELS,
|
|
105
|
+
buildReviewSystemPrompt,
|
|
106
|
+
buildReviewUserPrompt,
|
|
107
|
+
buildFixPrompt,
|
|
108
|
+
};
|
package/src/provider.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const { reviewCode: reviewWithClaude, parseSeverityCounts } = require('./claude');
|
|
2
|
+
const { reviewCodeWithGemini } = require('./gemini');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Supported AI providers.
|
|
6
|
+
*/
|
|
7
|
+
const PROVIDERS = {
|
|
8
|
+
CLAUDE: 'claude',
|
|
9
|
+
GEMINI: 'gemini',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default models per provider.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_MODELS = {
|
|
16
|
+
[PROVIDERS.CLAUDE]: 'claude-sonnet-4-20250514',
|
|
17
|
+
[PROVIDERS.GEMINI]: 'gemini-2.0-flash',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect which provider to use based on config and available env vars.
|
|
22
|
+
* Priority: explicit config > available API key.
|
|
23
|
+
* @param {Object} config - Loaded config
|
|
24
|
+
* @returns {string} provider name
|
|
25
|
+
*/
|
|
26
|
+
function detectProvider(config) {
|
|
27
|
+
// Explicit config takes priority
|
|
28
|
+
if (config.provider) {
|
|
29
|
+
return config.provider.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Auto-detect from available API keys
|
|
33
|
+
if (process.env.ANTHROPIC_API_KEY) return PROVIDERS.CLAUDE;
|
|
34
|
+
if (process.env.GEMINI_API_KEY) return PROVIDERS.GEMINI;
|
|
35
|
+
|
|
36
|
+
throw new Error(
|
|
37
|
+
'No AI provider configured.\n' +
|
|
38
|
+
'Either set "provider" in .ai-review.config.json, or set one of:\n' +
|
|
39
|
+
' - ANTHROPIC_API_KEY for Claude\n' +
|
|
40
|
+
' - GEMINI_API_KEY for Gemini'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Review code using the configured AI provider.
|
|
46
|
+
* @param {string} diff - The git diff content
|
|
47
|
+
* @param {Object} options - Review options
|
|
48
|
+
* @param {string} options.provider - AI provider ('claude' or 'gemini')
|
|
49
|
+
* @param {string[]} options.changedFiles
|
|
50
|
+
* @param {string} options.branchName
|
|
51
|
+
* @param {string} [options.model]
|
|
52
|
+
* @param {number} [options.maxTokens]
|
|
53
|
+
* @param {string[]} [options.reviewRules]
|
|
54
|
+
* @returns {Promise<string>} Review text
|
|
55
|
+
*/
|
|
56
|
+
async function reviewCode(diff, options = {}) {
|
|
57
|
+
const provider = options.provider || PROVIDERS.CLAUDE;
|
|
58
|
+
|
|
59
|
+
// Use provider-specific default model if not explicitly set
|
|
60
|
+
const model = options.model || DEFAULT_MODELS[provider];
|
|
61
|
+
|
|
62
|
+
const reviewOptions = { ...options, model };
|
|
63
|
+
|
|
64
|
+
switch (provider) {
|
|
65
|
+
case PROVIDERS.CLAUDE:
|
|
66
|
+
return reviewWithClaude(diff, reviewOptions);
|
|
67
|
+
|
|
68
|
+
case PROVIDERS.GEMINI:
|
|
69
|
+
return reviewCodeWithGemini(diff, reviewOptions);
|
|
70
|
+
|
|
71
|
+
default:
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Unknown provider: "${provider}". Supported: ${Object.values(PROVIDERS).join(', ')}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
PROVIDERS,
|
|
80
|
+
DEFAULT_MODELS,
|
|
81
|
+
detectProvider,
|
|
82
|
+
reviewCode,
|
|
83
|
+
parseSeverityCounts,
|
|
84
|
+
};
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default review rule sets that can be used out of the box.
|
|
3
|
+
* Users can reference these by name in .ai-review.config.json
|
|
4
|
+
* or customize their own rules.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Basic rules — lightweight, fast review for quick PRs.
|
|
9
|
+
*/
|
|
10
|
+
const BASIC_RULES = [
|
|
11
|
+
'Check for syntax errors and typos',
|
|
12
|
+
'Flag unused variables and dead code',
|
|
13
|
+
'Check for console.log or debug statements left in production code',
|
|
14
|
+
'Verify error handling exists for async operations',
|
|
15
|
+
'Check for hardcoded secrets, API keys, or passwords',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Standard rules — balanced review covering common issues.
|
|
20
|
+
*/
|
|
21
|
+
const STANDARD_RULES = [
|
|
22
|
+
...BASIC_RULES,
|
|
23
|
+
'Check for proper input validation and sanitization',
|
|
24
|
+
'Verify functions have clear, descriptive names',
|
|
25
|
+
'Flag overly complex functions (too many parameters, deep nesting, long functions)',
|
|
26
|
+
'Check for missing or incorrect error messages',
|
|
27
|
+
'Verify proper use of const/let (no unnecessary var usage)',
|
|
28
|
+
'Check for potential null/undefined reference errors',
|
|
29
|
+
'Flag duplicated code that could be extracted into reusable functions',
|
|
30
|
+
'Verify that async/await and Promises are used correctly',
|
|
31
|
+
'Check for proper resource cleanup (event listeners, timers, connections)',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Comprehensive rules — thorough review for critical code.
|
|
36
|
+
*/
|
|
37
|
+
const COMPREHENSIVE_RULES = [
|
|
38
|
+
...STANDARD_RULES,
|
|
39
|
+
'Check for SQL injection, XSS, and other security vulnerabilities',
|
|
40
|
+
'Verify proper authentication and authorization checks',
|
|
41
|
+
'Review error handling strategy — are errors logged, reported, and recoverable?',
|
|
42
|
+
'Check for race conditions in concurrent or async code',
|
|
43
|
+
'Verify proper memory management (no memory leaks, large object retention)',
|
|
44
|
+
'Check for proper HTTP status codes and API response formats',
|
|
45
|
+
'Review database queries for N+1 problems and missing indexes',
|
|
46
|
+
'Verify backward compatibility — will this break existing clients or APIs?',
|
|
47
|
+
'Check for accessibility issues in UI code (ARIA labels, keyboard navigation)',
|
|
48
|
+
'Review test coverage — are edge cases and error paths tested?',
|
|
49
|
+
'Check for proper logging with appropriate log levels',
|
|
50
|
+
'Verify that environment-specific config is not hardcoded',
|
|
51
|
+
'Check for proper handling of edge cases (empty arrays, zero values, boundary conditions)',
|
|
52
|
+
'Review for performance issues (unnecessary re-renders, expensive computations in loops)',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Named rule presets that can be referenced in config.
|
|
57
|
+
*/
|
|
58
|
+
const RULE_PRESETS = {
|
|
59
|
+
basic: BASIC_RULES,
|
|
60
|
+
standard: STANDARD_RULES,
|
|
61
|
+
comprehensive: COMPREHENSIVE_RULES,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve review rules from config.
|
|
66
|
+
* Accepts a preset name (string) or custom rules array.
|
|
67
|
+
* @param {string|string[]} rulesConfig - Preset name or custom rules
|
|
68
|
+
* @returns {string[]} Resolved rules
|
|
69
|
+
*/
|
|
70
|
+
function resolveRules(rulesConfig) {
|
|
71
|
+
if (!rulesConfig || (Array.isArray(rulesConfig) && rulesConfig.length === 0)) {
|
|
72
|
+
return STANDARD_RULES; // Default to standard if nothing specified
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof rulesConfig === 'string') {
|
|
76
|
+
const preset = RULE_PRESETS[rulesConfig.toLowerCase()];
|
|
77
|
+
if (!preset) {
|
|
78
|
+
console.warn(`⚠️ Unknown rule preset: "${rulesConfig}". Using "standard".`);
|
|
79
|
+
return STANDARD_RULES;
|
|
80
|
+
}
|
|
81
|
+
return preset;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(rulesConfig)) {
|
|
85
|
+
return rulesConfig;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return STANDARD_RULES;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
BASIC_RULES,
|
|
93
|
+
STANDARD_RULES,
|
|
94
|
+
COMPREHENSIVE_RULES,
|
|
95
|
+
RULE_PRESETS,
|
|
96
|
+
resolveRules,
|
|
97
|
+
};
|