ai-commit-reviewer 1.0.1
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 +350 -0
- package/bin/cli.js +190 -0
- package/bin/dashboard.js +505 -0
- package/bin/install.js +111 -0
- package/bin/uninstall.js +44 -0
- package/package.json +58 -0
- package/src/analyzer/api.js +197 -0
- package/src/analyzer/git.js +158 -0
- package/src/analyzer/prompt.js +408 -0
- package/src/config.js +93 -0
- package/src/index.js +94 -0
- package/src/memory/index.js +101 -0
- package/src/output/colors.js +85 -0
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-commit-reviewer",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Self-improving AI code reviewer for React, React Native and Next.js. Runs on every git commit. Catches crashes, ANRs, security holes, hydration errors, and bad patterns before they hit production.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ai-reviewer": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"review": "node src/index.js",
|
|
11
|
+
"install-hook": "node bin/install.js",
|
|
12
|
+
"uninstall-hook": "node bin/uninstall.js",
|
|
13
|
+
"dashboard": "node bin/dashboard.js",
|
|
14
|
+
"status": "node bin/cli.js status",
|
|
15
|
+
"test": "node src/index.js --dry-run"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"react",
|
|
19
|
+
"react-native",
|
|
20
|
+
"nextjs",
|
|
21
|
+
"next.js",
|
|
22
|
+
"code-review",
|
|
23
|
+
"ai-code-review",
|
|
24
|
+
"git-hook",
|
|
25
|
+
"pre-commit",
|
|
26
|
+
"pre-commit-hook",
|
|
27
|
+
"ai",
|
|
28
|
+
"openai",
|
|
29
|
+
"anthropic",
|
|
30
|
+
"gemini",
|
|
31
|
+
"security",
|
|
32
|
+
"crash-detection",
|
|
33
|
+
"ANR",
|
|
34
|
+
"hydration",
|
|
35
|
+
"senior-dev",
|
|
36
|
+
"linter",
|
|
37
|
+
"static-analysis",
|
|
38
|
+
"commit-hook",
|
|
39
|
+
"self-improving",
|
|
40
|
+
"mobile",
|
|
41
|
+
"typescript",
|
|
42
|
+
"javascript"
|
|
43
|
+
],
|
|
44
|
+
"author": "Sagnik Pal",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/sagnik-pal/ai-commit-reviewer.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/sagnik-pal/ai-commit-reviewer#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/sagnik-pal/ai-commit-reviewer/issues"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=16.0.0"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {}
|
|
58
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// ── analyzer/api.js ───────────────────────────────────────
|
|
2
|
+
// Supports multiple AI providers:
|
|
3
|
+
// - Google Gemini (free tier — default)
|
|
4
|
+
// - Anthropic Claude
|
|
5
|
+
// - OpenAI
|
|
6
|
+
//
|
|
7
|
+
// Set in .env:
|
|
8
|
+
// GEMINI_API_KEY=... → uses Gemini (free)
|
|
9
|
+
// ANTHROPIC_API_KEY=... → uses Claude
|
|
10
|
+
// OPENAI_API_KEY=... → uses OpenAI
|
|
11
|
+
//
|
|
12
|
+
// Priority: Gemini → Anthropic → OpenAI
|
|
13
|
+
|
|
14
|
+
const https = require("https");
|
|
15
|
+
const config = require("../config");
|
|
16
|
+
|
|
17
|
+
// ── Detect which provider to use ─────────────────────────
|
|
18
|
+
function getProvider() {
|
|
19
|
+
if (process.env.GEMINI_API_KEY) return "gemini";
|
|
20
|
+
if (process.env.ANTHROPIC_API_KEY) return "anthropic";
|
|
21
|
+
if (process.env.OPENAI_API_KEY) return "openai";
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getApiKey() {
|
|
26
|
+
if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
|
|
27
|
+
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
|
|
28
|
+
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Default model per provider ────────────────────────────
|
|
33
|
+
function getModel(provider) {
|
|
34
|
+
if (process.env.AI_REVIEWER_MODEL) return process.env.AI_REVIEWER_MODEL;
|
|
35
|
+
if (provider === "gemini") return "gemini-1.5-flash";
|
|
36
|
+
if (provider === "anthropic") return "claude-3-5-haiku-20241022";
|
|
37
|
+
if (provider === "openai") return "gpt-4o-mini";
|
|
38
|
+
return "gemini-1.5-flash";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Raw HTTPS request helper ──────────────────────────────
|
|
42
|
+
function httpsRequest({ hostname, path, method = "POST", headers, body }) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const req = https.request(
|
|
45
|
+
{ hostname, path, method, headers },
|
|
46
|
+
(res) => {
|
|
47
|
+
let data = "";
|
|
48
|
+
res.on("data", (chunk) => (data += chunk));
|
|
49
|
+
res.on("end", () => {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = JSON.parse(data);
|
|
52
|
+
resolve(parsed);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
reject(new Error(`Failed to parse response: ${data.slice(0, 200)}`));
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
);
|
|
59
|
+
req.on("error", (e) => reject(new Error(`Network error: ${e.message}`)));
|
|
60
|
+
req.setTimeout(90000, () => {
|
|
61
|
+
req.destroy();
|
|
62
|
+
reject(new Error("Request timed out after 90s"));
|
|
63
|
+
});
|
|
64
|
+
req.write(body);
|
|
65
|
+
req.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Gemini ────────────────────────────────────────────────
|
|
70
|
+
async function callGemini(prompt, apiKey, model) {
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
73
|
+
generationConfig: {
|
|
74
|
+
temperature: 0.2,
|
|
75
|
+
maxOutputTokens: 3500,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const data = await httpsRequest({
|
|
80
|
+
hostname: "generativelanguage.googleapis.com",
|
|
81
|
+
path: `/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
|
82
|
+
headers: {
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"Content-Length": Buffer.byteLength(body),
|
|
85
|
+
},
|
|
86
|
+
body,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (data.error) {
|
|
90
|
+
throw new Error(`Gemini error: ${data.error.message}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Anthropic ─────────────────────────────────────────────
|
|
97
|
+
async function callAnthropic(prompt, apiKey, model) {
|
|
98
|
+
const body = JSON.stringify({
|
|
99
|
+
model,
|
|
100
|
+
max_tokens: 3500,
|
|
101
|
+
temperature: 0.2,
|
|
102
|
+
messages: [{ role: "user", content: prompt }],
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const data = await httpsRequest({
|
|
106
|
+
hostname: "api.anthropic.com",
|
|
107
|
+
path: "/v1/messages",
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
"x-api-key": apiKey,
|
|
111
|
+
"anthropic-version": "2023-06-01",
|
|
112
|
+
"Content-Length": Buffer.byteLength(body),
|
|
113
|
+
},
|
|
114
|
+
body,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (data.error) {
|
|
118
|
+
throw new Error(`Anthropic error: ${data.error.message}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return data.content?.[0]?.text || "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── OpenAI ────────────────────────────────────────────────
|
|
125
|
+
async function callOpenAI(prompt, apiKey, model) {
|
|
126
|
+
const body = JSON.stringify({
|
|
127
|
+
model,
|
|
128
|
+
max_tokens: 3500,
|
|
129
|
+
temperature: 0.2,
|
|
130
|
+
messages: [{ role: "user", content: prompt }],
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const data = await httpsRequest({
|
|
134
|
+
hostname: "api.openai.com",
|
|
135
|
+
path: "/v1/chat/completions",
|
|
136
|
+
headers: {
|
|
137
|
+
"Content-Type": "application/json",
|
|
138
|
+
Authorization: `Bearer ${apiKey}`,
|
|
139
|
+
"Content-Length": Buffer.byteLength(body),
|
|
140
|
+
},
|
|
141
|
+
body,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (data.error) {
|
|
145
|
+
throw new Error(`OpenAI error: ${data.error.message}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return data.choices?.[0]?.message?.content || "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Main export ───────────────────────────────────────────
|
|
152
|
+
async function callAI(prompt) {
|
|
153
|
+
const provider = getProvider();
|
|
154
|
+
const apiKey = getApiKey();
|
|
155
|
+
const model = getModel(provider);
|
|
156
|
+
|
|
157
|
+
if (!provider || !apiKey) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"No API key found. Add one of these to your .env:\n" +
|
|
160
|
+
" GEMINI_API_KEY=... (free)\n" +
|
|
161
|
+
" ANTHROPIC_API_KEY=... ($5 free credits)\n" +
|
|
162
|
+
" OPENAI_API_KEY=... (paid)"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (process.env.AI_REVIEWER_VERBOSE === "true") {
|
|
167
|
+
process.stderr.write(` [ai-reviewer] provider: ${provider} | model: ${model}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (provider === "gemini") return callGemini(prompt, apiKey, model);
|
|
171
|
+
if (provider === "anthropic") return callAnthropic(prompt, apiKey, model);
|
|
172
|
+
if (provider === "openai") return callOpenAI(prompt, apiKey, model);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Parse metadata from review response ──────────────────
|
|
176
|
+
function parseMetadata(review) {
|
|
177
|
+
const defaults = {
|
|
178
|
+
has_blockers: false,
|
|
179
|
+
new_patterns_found: [],
|
|
180
|
+
categories_flagged: [],
|
|
181
|
+
top_issue: "",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (review.includes("LGTM")) return defaults;
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const match = review.match(
|
|
188
|
+
/REVIEW_METADATA_START\s*([\s\S]*?)\s*REVIEW_METADATA_END/
|
|
189
|
+
);
|
|
190
|
+
if (!match) return defaults;
|
|
191
|
+
return { ...defaults, ...JSON.parse(match[1]) };
|
|
192
|
+
} catch {
|
|
193
|
+
return defaults;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { callAI, parseMetadata };
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// ── analyzer/git.js ───────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
const { execSync } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const config = require("../config");
|
|
7
|
+
|
|
8
|
+
function run(cmd) {
|
|
9
|
+
try {
|
|
10
|
+
return execSync(cmd, {
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
13
|
+
}).trim();
|
|
14
|
+
} catch {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Git roots ─────────────────────────────────────────────
|
|
20
|
+
function getGitRoot() {
|
|
21
|
+
return run("git rev-parse --show-toplevel");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Relative path from git root to cwd
|
|
25
|
+
// e.g. cwd = /newmecode/nextjs, gitRoot = /newmecode → "nextjs"
|
|
26
|
+
function getSubProjectPrefix() {
|
|
27
|
+
const gitRoot = getGitRoot();
|
|
28
|
+
const cwd = process.cwd();
|
|
29
|
+
if (!gitRoot || cwd === gitRoot) return "";
|
|
30
|
+
const rel = path.relative(gitRoot, cwd);
|
|
31
|
+
return rel; // e.g. "nextjs" or "mobile"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Staged files ──────────────────────────────────────────
|
|
35
|
+
function getStagedFiles() {
|
|
36
|
+
const raw = run("git diff --cached --name-only --diff-filter=ACM");
|
|
37
|
+
if (!raw) return [];
|
|
38
|
+
|
|
39
|
+
const gitRoot = getGitRoot();
|
|
40
|
+
const prefix = path.relative(gitRoot, process.cwd()); // e.g. "nextjs"
|
|
41
|
+
|
|
42
|
+
return raw
|
|
43
|
+
.split("\n")
|
|
44
|
+
.map((f) => f.trim())
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.filter((f) => !prefix || f.startsWith(prefix + "/"))
|
|
47
|
+
.map((f) => prefix ? f.slice(prefix.length + 1) : f)
|
|
48
|
+
.filter((f) => config.extensions.some((ext) => f.endsWith(ext)))
|
|
49
|
+
.filter((f) => !config.ignorePatterns.some((p) => f.includes(p)));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Diff ──────────────────────────────────────────────────
|
|
53
|
+
function getStagedDiff(files) {
|
|
54
|
+
if (!files.length) return "";
|
|
55
|
+
|
|
56
|
+
const gitRoot = getGitRoot();
|
|
57
|
+
const prefix = path.relative(gitRoot, process.cwd());
|
|
58
|
+
|
|
59
|
+
// Build absolute paths then make relative to git root
|
|
60
|
+
const gitPaths = files.map((f) => {
|
|
61
|
+
const abs = path.join(process.cwd(), f);
|
|
62
|
+
return path.relative(gitRoot, abs);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Run git diff from the git root, not cwd
|
|
66
|
+
let diff;
|
|
67
|
+
try {
|
|
68
|
+
diff = execSync(
|
|
69
|
+
`git diff --cached -- ${gitPaths.map(p => `"${p}"`).join(" ")}`,
|
|
70
|
+
{
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
cwd: gitRoot, // <-- run from git root, not cwd
|
|
73
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
74
|
+
}
|
|
75
|
+
).trim();
|
|
76
|
+
} catch {
|
|
77
|
+
diff = "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (diff.length > config.maxDiffChars) {
|
|
81
|
+
diff = diff.slice(0, config.maxDiffChars) + "\n\n... [diff truncated]";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return diff;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Codebase snapshot for duplicate detection ─────────────
|
|
88
|
+
function buildCodebaseSnapshot(stagedFiles) {
|
|
89
|
+
const stagedSet = new Set(stagedFiles);
|
|
90
|
+
const snapshot = [];
|
|
91
|
+
let fileCount = 0;
|
|
92
|
+
|
|
93
|
+
// Also scan "containers" and other common dirs
|
|
94
|
+
const dirsToScan = [
|
|
95
|
+
...config.srcDirs,
|
|
96
|
+
"containers", "container", "pages", "views",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const dir of [...new Set(dirsToScan)]) {
|
|
100
|
+
if (!fs.existsSync(dir)) continue;
|
|
101
|
+
|
|
102
|
+
const files = walkDir(dir).filter(
|
|
103
|
+
(f) =>
|
|
104
|
+
config.extensions.some((ext) => f.endsWith(ext)) &&
|
|
105
|
+
!config.ignorePatterns.some((p) => f.includes(p)) &&
|
|
106
|
+
!stagedSet.has(f)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (fileCount >= config.maxSnapshotFiles) break;
|
|
111
|
+
try {
|
|
112
|
+
const lines = fs
|
|
113
|
+
.readFileSync(file, "utf8")
|
|
114
|
+
.split("\n")
|
|
115
|
+
.slice(0, config.maxContextLines)
|
|
116
|
+
.join("\n");
|
|
117
|
+
snapshot.push(`=== EXISTING: ${file} ===\n${lines}\n`);
|
|
118
|
+
fileCount++;
|
|
119
|
+
} catch {
|
|
120
|
+
// skip unreadable
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return snapshot.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function walkDir(dir) {
|
|
129
|
+
const results = [];
|
|
130
|
+
try {
|
|
131
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
132
|
+
const full = path.join(dir, entry.name);
|
|
133
|
+
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
134
|
+
results.push(...walkDir(full));
|
|
135
|
+
} else if (entry.isFile()) {
|
|
136
|
+
results.push(full);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// skip
|
|
141
|
+
}
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Git info ──────────────────────────────────────────────
|
|
146
|
+
function getGitInfo() {
|
|
147
|
+
return {
|
|
148
|
+
branch: run("git rev-parse --abbrev-ref HEAD") || "unknown",
|
|
149
|
+
author: run("git config user.name") || "unknown",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
getStagedFiles,
|
|
155
|
+
getStagedDiff,
|
|
156
|
+
buildCodebaseSnapshot,
|
|
157
|
+
getGitInfo,
|
|
158
|
+
};
|