gitpal-cli 1.0.3 → 1.0.5
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 +100 -6
- package/package.json +5 -2
- package/src/ai.js +26 -49
- package/src/commands/config.js +1 -0
- package/src/commands/explain.js +181 -0
- package/src/commands/issue.js +291 -0
- package/src/index.js +13 -1
- package/tests/ai.test.js +207 -0
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# 🤖 GitPal — AI-Powered Git Assistant CLI
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
> Stop writing commit messages manually. Let AI do it in 3 seconds.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/gitpal-cli)
|
|
6
6
|
[](https://www.npmjs.com/package/gitpal-cli)
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
|
-
[](https://nodejs.org)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
@@ -157,7 +157,7 @@ gitpal changelog --ver 2.0.0
|
|
|
157
157
|
|
|
158
158
|
# 📄 CHANGELOG v2.0.0:
|
|
159
159
|
#
|
|
160
|
-
# ## [2.0.0] - 2026-03-
|
|
160
|
+
# ## [2.0.0] - 2026-03-22
|
|
161
161
|
#
|
|
162
162
|
# ### Features
|
|
163
163
|
# - Payment gateway integration
|
|
@@ -193,6 +193,91 @@ gitpal config
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
+
### `gitpal review` — AI Code Reviewer
|
|
197
|
+
Reviews your staged code for bugs, security issues and bad practices before you commit — like having a senior developer on your team 24/7.
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
git add .
|
|
201
|
+
gitpal review
|
|
202
|
+
|
|
203
|
+
# 🐛 Bugs Found:
|
|
204
|
+
# - No error handling on login failure
|
|
205
|
+
#
|
|
206
|
+
# 🔒 Security Issues:
|
|
207
|
+
# - Password stored as plain text, use bcrypt
|
|
208
|
+
#
|
|
209
|
+
# 💡 Improvements:
|
|
210
|
+
# - Add input validation for username and password
|
|
211
|
+
#
|
|
212
|
+
# ❌ Verdict: Do not commit
|
|
213
|
+
|
|
214
|
+
# ? What would you like to do?
|
|
215
|
+
# ✅ Looks good — generate commit message and commit
|
|
216
|
+
# ❌ I will fix the issues first
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Options:**
|
|
220
|
+
```bash
|
|
221
|
+
gitpal review --review-only # Only review, skip commit step
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
### `gitpal explain` — Explain Any Code
|
|
227
|
+
Explains any file, function or commit in plain English — perfect for understanding old code or teammate's changes.
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
# Explain an entire file
|
|
231
|
+
gitpal explain src/auth.js
|
|
232
|
+
|
|
233
|
+
# 📖 Explaining file: auth.js
|
|
234
|
+
# ──────────────────────────────────────────────────
|
|
235
|
+
# This file handles all authentication logic.
|
|
236
|
+
# It has 4 main functions:
|
|
237
|
+
# - login() — verifies user credentials
|
|
238
|
+
# - register() — creates new user account
|
|
239
|
+
# - verifyToken() — checks if JWT is valid
|
|
240
|
+
# - logout() — clears user session
|
|
241
|
+
#
|
|
242
|
+
# Depends on: bcrypt, jsonwebtoken, User model
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
# Explain a specific function
|
|
247
|
+
gitpal explain src/payment.js --function processPayment
|
|
248
|
+
|
|
249
|
+
# 📖 Explaining function: processPayment()
|
|
250
|
+
# ──────────────────────────────────────────────────
|
|
251
|
+
# This function handles Razorpay payment processing.
|
|
252
|
+
# Step 1 — Creates payment order with amount
|
|
253
|
+
# Step 2 — Sends to Razorpay API
|
|
254
|
+
# Step 3 — Waits for webhook confirmation
|
|
255
|
+
# Step 4 — Updates database on success
|
|
256
|
+
#
|
|
257
|
+
# Depends on: razorpay, axios, Order model
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
# Explain any commit
|
|
262
|
+
gitpal explain a3f2c1
|
|
263
|
+
|
|
264
|
+
# 📖 Explaining commit: a3f2c1
|
|
265
|
+
# ──────────────────────────────────────────────────
|
|
266
|
+
# This commit added JWT authentication.
|
|
267
|
+
# - Created login function with bcrypt password check
|
|
268
|
+
# - Added JWT token generation on success
|
|
269
|
+
# - Protected private routes with middleware
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
**Options:**
|
|
273
|
+
```bash
|
|
274
|
+
gitpal explain src/auth.js # Explain full file
|
|
275
|
+
gitpal explain src/auth.js --function login # Explain one function
|
|
276
|
+
gitpal explain a3f2c1 # Explain a commit
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
196
281
|
## 🔄 Full Daily Workflow
|
|
197
282
|
|
|
198
283
|
```
|
|
@@ -201,6 +286,8 @@ Morning — open your project
|
|
|
201
286
|
Write some code (auth feature)
|
|
202
287
|
↓
|
|
203
288
|
git add .
|
|
289
|
+
gitpal review → AI checks for bugs and security issues
|
|
290
|
+
↓
|
|
204
291
|
gitpal commit → "feat(auth): add Google OAuth login"
|
|
205
292
|
↓
|
|
206
293
|
Write more code (fix a bug)
|
|
@@ -216,6 +303,9 @@ gitpal pr → Full PR description, copy to GitHub
|
|
|
216
303
|
↓
|
|
217
304
|
Releasing v2.0?
|
|
218
305
|
gitpal changelog --ver 2.0.0 → Full changelog ready
|
|
306
|
+
↓
|
|
307
|
+
Understanding old code?
|
|
308
|
+
gitpal explain src/auth.js → Plain English explanation
|
|
219
309
|
```
|
|
220
310
|
|
|
221
311
|
---
|
|
@@ -228,6 +318,8 @@ gitpal changelog --ver 2.0.0 → Full changelog ready
|
|
|
228
318
|
| Spend 15 mins on PR description | Generated in 3 seconds |
|
|
229
319
|
| Forget what you built last week | Plain English summary instantly |
|
|
230
320
|
| Write changelog manually | Auto-generated from commits |
|
|
321
|
+
| No code review before commit | AI catches bugs before they reach GitHub |
|
|
322
|
+
| Confused by old code | Explained in plain English instantly |
|
|
231
323
|
| Works with one AI only | Works with 4 AI providers |
|
|
232
324
|
|
|
233
325
|
---
|
|
@@ -247,7 +339,9 @@ gitpal/
|
|
|
247
339
|
│ ├── summary.js ← gitpal summary
|
|
248
340
|
│ ├── pr.js ← gitpal pr
|
|
249
341
|
│ ├── changelog.js ← gitpal changelog
|
|
250
|
-
│
|
|
342
|
+
│ ├── config.js ← gitpal config
|
|
343
|
+
│ ├── review.js ← gitpal review
|
|
344
|
+
│ └── explain.js ← gitpal explain
|
|
251
345
|
└── tests/
|
|
252
346
|
└── ai.test.js
|
|
253
347
|
```
|
|
@@ -258,7 +352,7 @@ gitpal/
|
|
|
258
352
|
|
|
259
353
|
```bash
|
|
260
354
|
# Clone the repo
|
|
261
|
-
git clone https://github.com/
|
|
355
|
+
git clone https://github.com/h1a2r3s4h/gitpal
|
|
262
356
|
cd gitpal
|
|
263
357
|
|
|
264
358
|
# Install dependencies
|
|
@@ -292,7 +386,7 @@ Contributions are welcome! To add a new AI provider:
|
|
|
292
386
|
|
|
293
387
|
Built by **Harshit Gangwar**
|
|
294
388
|
|
|
295
|
-
- GitHub: [@
|
|
389
|
+
- GitHub: [@h1a2r3s4h](https://github.com/h1a2r3s4h)
|
|
296
390
|
- npm: [gitpal-cli](https://www.npmjs.com/package/gitpal-cli)
|
|
297
391
|
|
|
298
392
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitpal-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "AI-powered Git assistant CLI",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -26,5 +26,8 @@
|
|
|
26
26
|
"ora": "^7.0.1",
|
|
27
27
|
"simple-git": "^3.21.0"
|
|
28
28
|
},
|
|
29
|
-
"type": "module"
|
|
29
|
+
"type": "module",
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"jest": "^30.3.0"
|
|
32
|
+
}
|
|
30
33
|
}
|
package/src/ai.js
CHANGED
|
@@ -2,7 +2,6 @@ import fs from 'fs';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
|
|
5
|
-
// Config file stored in user's home directory
|
|
6
5
|
const CONFIG_PATH = path.join(os.homedir(), '.gitpal.json');
|
|
7
6
|
|
|
8
7
|
export function loadConfig() {
|
|
@@ -14,23 +13,11 @@ export function saveConfig(config) {
|
|
|
14
13
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
// ─── AI PROVIDER ROUTER ───────────────────────────────────────────────────────
|
|
18
|
-
// All providers receive the same prompt and return a plain string response.
|
|
19
|
-
// Adding a new provider = add one function + one case below.
|
|
20
|
-
|
|
21
16
|
async function callAnthropic(prompt, apiKey) {
|
|
22
17
|
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
23
18
|
method: 'POST',
|
|
24
|
-
headers: {
|
|
25
|
-
|
|
26
|
-
'x-api-key': apiKey,
|
|
27
|
-
'anthropic-version': '2023-06-01',
|
|
28
|
-
},
|
|
29
|
-
body: JSON.stringify({
|
|
30
|
-
model: 'claude-3-haiku-20240307',
|
|
31
|
-
max_tokens: 500,
|
|
32
|
-
messages: [{ role: 'user', content: prompt }],
|
|
33
|
-
}),
|
|
19
|
+
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
|
20
|
+
body: JSON.stringify({ model: 'claude-3-haiku-20240307', max_tokens: 500, messages: [{ role: 'user', content: prompt }] }),
|
|
34
21
|
});
|
|
35
22
|
const data = await res.json();
|
|
36
23
|
if (!res.ok) throw new Error(data.error?.message || 'Anthropic API error');
|
|
@@ -40,15 +27,8 @@ async function callAnthropic(prompt, apiKey) {
|
|
|
40
27
|
async function callOpenAI(prompt, apiKey) {
|
|
41
28
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
42
29
|
method: 'POST',
|
|
43
|
-
headers: {
|
|
44
|
-
|
|
45
|
-
Authorization: `Bearer ${apiKey}`,
|
|
46
|
-
},
|
|
47
|
-
body: JSON.stringify({
|
|
48
|
-
model: 'gpt-3.5-turbo',
|
|
49
|
-
max_tokens: 500,
|
|
50
|
-
messages: [{ role: 'user', content: prompt }],
|
|
51
|
-
}),
|
|
30
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
31
|
+
body: JSON.stringify({ model: 'gpt-3.5-turbo', max_tokens: 500, messages: [{ role: 'user', content: prompt }] }),
|
|
52
32
|
});
|
|
53
33
|
const data = await res.json();
|
|
54
34
|
if (!res.ok) throw new Error(data.error?.message || 'OpenAI API error');
|
|
@@ -60,9 +40,7 @@ async function callGemini(prompt, apiKey) {
|
|
|
60
40
|
const res = await fetch(url, {
|
|
61
41
|
method: 'POST',
|
|
62
42
|
headers: { 'Content-Type': 'application/json' },
|
|
63
|
-
body: JSON.stringify({
|
|
64
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
65
|
-
}),
|
|
43
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
|
|
66
44
|
});
|
|
67
45
|
const data = await res.json();
|
|
68
46
|
if (!res.ok) throw new Error(data.error?.message || 'Gemini API error');
|
|
@@ -72,39 +50,38 @@ async function callGemini(prompt, apiKey) {
|
|
|
72
50
|
async function callGroq(prompt, apiKey) {
|
|
73
51
|
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
|
74
52
|
method: 'POST',
|
|
75
|
-
headers: {
|
|
76
|
-
|
|
77
|
-
Authorization: `Bearer ${apiKey}`,
|
|
78
|
-
},
|
|
79
|
-
body: JSON.stringify({
|
|
80
|
-
model: 'llama-3.3-70b-versatile',
|
|
81
|
-
max_tokens: 500,
|
|
82
|
-
messages: [{ role: 'user', content: prompt }],
|
|
83
|
-
}),
|
|
53
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
54
|
+
body: JSON.stringify({ model: 'llama-3.3-70b-versatile', max_tokens: 500, messages: [{ role: 'user', content: prompt }] }),
|
|
84
55
|
});
|
|
85
56
|
const data = await res.json();
|
|
86
57
|
if (!res.ok) throw new Error(data.error?.message || 'Groq API error');
|
|
87
58
|
return data.choices[0].message.content.trim();
|
|
88
59
|
}
|
|
89
60
|
|
|
90
|
-
|
|
61
|
+
async function callOpenRouter(prompt, apiKey, model) {
|
|
62
|
+
const selectedModel = model || 'google/gemini-2.0-flash-exp:free';
|
|
63
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'HTTP-Referer': 'https://github.com/h1a2r3s4h/gitpal', 'X-Title': 'GitPal CLI' },
|
|
66
|
+
body: JSON.stringify({ model: selectedModel, max_tokens: 500, messages: [{ role: 'user', content: prompt }] }),
|
|
67
|
+
});
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
if (!res.ok) throw new Error(data.error?.message || 'OpenRouter API error');
|
|
70
|
+
return data.choices[0].message.content.trim();
|
|
71
|
+
}
|
|
91
72
|
|
|
92
73
|
export async function askAI(prompt) {
|
|
93
74
|
const config = loadConfig();
|
|
94
|
-
|
|
95
75
|
const provider = config.provider;
|
|
96
76
|
const apiKey = config.apiKey;
|
|
97
|
-
|
|
98
|
-
if (!provider || !apiKey)
|
|
99
|
-
throw new Error('No AI provider configured. Run: gitpal config');
|
|
100
|
-
}
|
|
101
|
-
|
|
77
|
+
const model = config.model;
|
|
78
|
+
if (!provider || !apiKey) throw new Error('No AI provider configured. Run: gitpal config');
|
|
102
79
|
switch (provider) {
|
|
103
|
-
case 'anthropic':
|
|
104
|
-
case 'openai':
|
|
105
|
-
case 'gemini':
|
|
106
|
-
case 'groq':
|
|
107
|
-
|
|
108
|
-
|
|
80
|
+
case 'anthropic': return callAnthropic(prompt, apiKey);
|
|
81
|
+
case 'openai': return callOpenAI(prompt, apiKey);
|
|
82
|
+
case 'gemini': return callGemini(prompt, apiKey);
|
|
83
|
+
case 'groq': return callGroq(prompt, apiKey);
|
|
84
|
+
case 'openrouter': return callOpenRouter(prompt, apiKey, model);
|
|
85
|
+
default: throw new Error(`Unknown provider "${provider}". Run: gitpal config`);
|
|
109
86
|
}
|
|
110
87
|
}
|
package/src/commands/config.js
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { isGitRepo } from '../git.js';
|
|
6
|
+
import { askAI } from '../ai.js';
|
|
7
|
+
import simpleGit from 'simple-git';
|
|
8
|
+
|
|
9
|
+
const git = simpleGit();
|
|
10
|
+
|
|
11
|
+
export async function explainCommand(target, options) {
|
|
12
|
+
// 1. Guard: must be inside a git repo
|
|
13
|
+
if (!(await isGitRepo())) {
|
|
14
|
+
console.log(chalk.red('❌ Not a git repository.'));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Decide what to explain
|
|
19
|
+
if (!target) {
|
|
20
|
+
console.log(chalk.red('❌ Please provide something to explain.'));
|
|
21
|
+
console.log(chalk.dim('\nExamples:'));
|
|
22
|
+
console.log(chalk.cyan(' gitpal explain a3f2c1 ') + chalk.dim('← explain a commit'));
|
|
23
|
+
console.log(chalk.cyan(' gitpal explain src/auth.js ') + chalk.dim('← explain a file'));
|
|
24
|
+
console.log(chalk.cyan(' gitpal explain src/auth.js --function login') + chalk.dim('← explain a function'));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 3. Check if target is a file or a commit hash
|
|
29
|
+
const isFile = fs.existsSync(target);
|
|
30
|
+
const isCommitHash = /^[0-9a-f]{6,40}$/i.test(target);
|
|
31
|
+
|
|
32
|
+
if (isFile) {
|
|
33
|
+
await explainFile(target, options);
|
|
34
|
+
} else if (isCommitHash) {
|
|
35
|
+
await explainCommit(target);
|
|
36
|
+
} else {
|
|
37
|
+
console.log(chalk.red(`❌ "${target}" is not a valid file or commit hash.`));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── EXPLAIN FILE ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
async function explainFile(filePath, options) {
|
|
45
|
+
const spinner = ora(`Reading ${filePath}...`).start();
|
|
46
|
+
|
|
47
|
+
let code;
|
|
48
|
+
try {
|
|
49
|
+
code = fs.readFileSync(filePath, 'utf-8');
|
|
50
|
+
} catch {
|
|
51
|
+
spinner.fail(chalk.red(`Cannot read file: ${filePath}`));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!code.trim()) {
|
|
56
|
+
spinner.fail(chalk.yellow('File is empty.'));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
spinner.succeed(`Read ${path.basename(filePath)} successfully.`);
|
|
61
|
+
|
|
62
|
+
const aiSpinner = ora('AI is analyzing the code...').start();
|
|
63
|
+
|
|
64
|
+
let prompt;
|
|
65
|
+
|
|
66
|
+
if (options.function) {
|
|
67
|
+
// Explain a specific function
|
|
68
|
+
prompt = `You are a senior developer explaining code to a junior developer.
|
|
69
|
+
|
|
70
|
+
Analyze this code and explain ONLY the function named "${options.function}".
|
|
71
|
+
|
|
72
|
+
Explain:
|
|
73
|
+
1. What this function does in simple words
|
|
74
|
+
2. What inputs it takes
|
|
75
|
+
3. What it returns
|
|
76
|
+
4. Step by step what happens inside it
|
|
77
|
+
5. Any important things to know
|
|
78
|
+
|
|
79
|
+
Use simple language. No jargon. Maximum 10 lines.
|
|
80
|
+
End with: "Depends on: X, Y, Z" (list any libraries or functions it uses)
|
|
81
|
+
|
|
82
|
+
File: ${filePath}
|
|
83
|
+
Code:
|
|
84
|
+
${code.slice(0, 4000)}`;
|
|
85
|
+
} else {
|
|
86
|
+
// Explain the entire file
|
|
87
|
+
prompt = `You are a senior developer explaining code to a junior developer.
|
|
88
|
+
|
|
89
|
+
Analyze this entire file and explain it clearly.
|
|
90
|
+
|
|
91
|
+
Tell me:
|
|
92
|
+
1. What is the PURPOSE of this file in one sentence
|
|
93
|
+
2. What are the MAIN functions/classes (list each with one line description)
|
|
94
|
+
3. How does it FIT into a typical project
|
|
95
|
+
4. Any IMPORTANT patterns or techniques used
|
|
96
|
+
|
|
97
|
+
Use simple language. Be concise. Maximum 15 lines.
|
|
98
|
+
End with: "Depends on: X, Y, Z" (list key imports/dependencies)
|
|
99
|
+
|
|
100
|
+
File: ${filePath}
|
|
101
|
+
Code:
|
|
102
|
+
${code.slice(0, 4000)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const explanation = await askAI(prompt);
|
|
107
|
+
aiSpinner.succeed('Explanation ready!\n');
|
|
108
|
+
|
|
109
|
+
const title = options.function
|
|
110
|
+
? `📖 Explaining function: ${chalk.cyan(options.function)}() in ${chalk.dim(filePath)}`
|
|
111
|
+
: `📖 Explaining file: ${chalk.cyan(path.basename(filePath))}`;
|
|
112
|
+
|
|
113
|
+
console.log(chalk.bold(title));
|
|
114
|
+
console.log(chalk.dim('─'.repeat(50)));
|
|
115
|
+
console.log(chalk.white(explanation));
|
|
116
|
+
console.log('');
|
|
117
|
+
|
|
118
|
+
} catch (err) {
|
|
119
|
+
aiSpinner.fail(chalk.red(`AI Error: ${err.message}`));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── EXPLAIN COMMIT ───────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
async function explainCommit(hash) {
|
|
127
|
+
const spinner = ora(`Fetching commit ${hash}...`).start();
|
|
128
|
+
|
|
129
|
+
let diff, log;
|
|
130
|
+
try {
|
|
131
|
+
diff = await git.show([hash, '--stat', '--patch']);
|
|
132
|
+
log = await git.log({ from: `${hash}^`, to: hash, maxCount: 1 });
|
|
133
|
+
} catch {
|
|
134
|
+
spinner.fail(chalk.red(`Commit "${hash}" not found.`));
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
spinner.succeed('Commit found.');
|
|
139
|
+
|
|
140
|
+
const aiSpinner = ora('AI is analyzing the commit...').start();
|
|
141
|
+
|
|
142
|
+
const commitMessage = log.all[0]?.message || 'No message';
|
|
143
|
+
const commitDate = log.all[0]?.date || '';
|
|
144
|
+
const commitAuthor = log.all[0]?.author_name || '';
|
|
145
|
+
|
|
146
|
+
const prompt = `You are a senior developer explaining a git commit to a junior developer.
|
|
147
|
+
|
|
148
|
+
Explain this commit in plain English.
|
|
149
|
+
|
|
150
|
+
Tell me:
|
|
151
|
+
1. WHAT changed — what was added, removed or modified
|
|
152
|
+
2. WHY it was likely changed — what problem it solves
|
|
153
|
+
3. FILES affected — list each file and what changed in it
|
|
154
|
+
4. IMPACT — how does this affect the overall project
|
|
155
|
+
|
|
156
|
+
Use simple language. Be specific. Maximum 15 lines.
|
|
157
|
+
|
|
158
|
+
Commit: ${hash}
|
|
159
|
+
Message: ${commitMessage}
|
|
160
|
+
Author: ${commitAuthor}
|
|
161
|
+
Date: ${commitDate}
|
|
162
|
+
|
|
163
|
+
Diff:
|
|
164
|
+
${diff.slice(0, 4000)}`;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const explanation = await askAI(prompt);
|
|
168
|
+
aiSpinner.succeed('Explanation ready!\n');
|
|
169
|
+
|
|
170
|
+
console.log(chalk.bold(`📖 Explaining commit: ${chalk.cyan(hash)}`));
|
|
171
|
+
console.log(chalk.dim(`Message: ${commitMessage}`));
|
|
172
|
+
console.log(chalk.dim(`Author: ${commitAuthor} | Date: ${commitDate}`));
|
|
173
|
+
console.log(chalk.dim('─'.repeat(50)));
|
|
174
|
+
console.log(chalk.white(explanation));
|
|
175
|
+
console.log('');
|
|
176
|
+
|
|
177
|
+
} catch (err) {
|
|
178
|
+
aiSpinner.fail(chalk.red(`AI Error: ${err.message}`));
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import simpleGit from 'simple-git';
|
|
7
|
+
import { isGitRepo, getCurrentBranch } from '../git.js';
|
|
8
|
+
import { askAI, loadConfig, saveConfig } from '../ai.js';
|
|
9
|
+
|
|
10
|
+
const git = simpleGit();
|
|
11
|
+
|
|
12
|
+
// ─── GITHUB API ───────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
async function fetchIssue(repo, issueNumber, token) {
|
|
15
|
+
const url = `https://api.github.com/repos/${repo}/issues/${issueNumber}`;
|
|
16
|
+
const headers = {
|
|
17
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
18
|
+
'User-Agent': 'gitpal-cli',
|
|
19
|
+
};
|
|
20
|
+
if (token) headers['Authorization'] = `token ${token}`;
|
|
21
|
+
|
|
22
|
+
const res = await fetch(url, { headers });
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
if (res.status === 404) throw new Error(`Issue #${issueNumber} not found in ${repo}`);
|
|
25
|
+
if (res.status === 401) throw new Error('Invalid GitHub token. Run: gitpal config --github-token YOUR_TOKEN');
|
|
26
|
+
throw new Error(`GitHub API error: ${res.status}`);
|
|
27
|
+
}
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function fetchRepoFiles(repo, token) {
|
|
32
|
+
const url = `https://api.github.com/repos/${repo}/git/trees/HEAD?recursive=1`;
|
|
33
|
+
const headers = {
|
|
34
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
35
|
+
'User-Agent': 'gitpal-cli',
|
|
36
|
+
};
|
|
37
|
+
if (token) headers['Authorization'] = `token ${token}`;
|
|
38
|
+
|
|
39
|
+
const res = await fetch(url, { headers });
|
|
40
|
+
if (!res.ok) return [];
|
|
41
|
+
const data = await res.json();
|
|
42
|
+
return data.tree?.filter(f => f.type === 'blob').map(f => f.path) || [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── READ LOCAL FILES ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function readLocalFiles(maxFiles = 10) {
|
|
48
|
+
const extensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.java', '.go'];
|
|
49
|
+
const ignore = ['node_modules', '.git', 'dist', 'build', 'coverage'];
|
|
50
|
+
|
|
51
|
+
const files = [];
|
|
52
|
+
|
|
53
|
+
function walk(dir) {
|
|
54
|
+
if (files.length >= maxFiles) return;
|
|
55
|
+
try {
|
|
56
|
+
const entries = fs.readdirSync(dir);
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (ignore.includes(entry)) continue;
|
|
59
|
+
const fullPath = path.join(dir, entry);
|
|
60
|
+
const stat = fs.statSync(fullPath);
|
|
61
|
+
if (stat.isDirectory()) {
|
|
62
|
+
walk(fullPath);
|
|
63
|
+
} else if (extensions.includes(path.extname(entry))) {
|
|
64
|
+
try {
|
|
65
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
66
|
+
files.push({ path: fullPath, content: content.slice(0, 1000) });
|
|
67
|
+
if (files.length >= maxFiles) return;
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
walk(process.cwd());
|
|
75
|
+
return files;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── MAIN COMMAND ─────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
export async function issueCommand(issueNumber, options) {
|
|
81
|
+
if (!(await isGitRepo())) {
|
|
82
|
+
console.log(chalk.red('❌ Not a git repository. Clone the repo first.'));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const config = loadConfig();
|
|
87
|
+
|
|
88
|
+
// Get GitHub token
|
|
89
|
+
let token = options.githubToken || config.githubToken;
|
|
90
|
+
if (!token) {
|
|
91
|
+
console.log(chalk.yellow('⚠️ No GitHub token found. Using public API (rate limited).'));
|
|
92
|
+
console.log(chalk.dim('Add token: gitpal config --github-token YOUR_TOKEN\n'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get repo
|
|
96
|
+
let repo = options.repo;
|
|
97
|
+
if (!repo) {
|
|
98
|
+
try {
|
|
99
|
+
const remotes = await git.getRemotes(true);
|
|
100
|
+
const origin = remotes.find(r => r.name === 'origin');
|
|
101
|
+
if (origin) {
|
|
102
|
+
const match = origin.refs.fetch.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
|
103
|
+
if (match) repo = match[1];
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!repo) {
|
|
109
|
+
console.log(chalk.red('❌ Could not detect repo. Use: gitpal issue 234 --repo owner/repo'));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log(chalk.dim(`\nRepo: ${repo} | Issue: #${issueNumber}\n`));
|
|
114
|
+
|
|
115
|
+
// ── Step 1: Fetch Issue ──────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
const issueSpinner = ora(`Fetching issue #${issueNumber} from GitHub...`).start();
|
|
118
|
+
let issue;
|
|
119
|
+
try {
|
|
120
|
+
issue = await fetchIssue(repo, issueNumber, token);
|
|
121
|
+
issueSpinner.succeed(`Issue found: "${issue.title}"`);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
issueSpinner.fail(chalk.red(`Failed: ${err.message}`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Step 2: Read local codebase ──────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
const codeSpinner = ora('Reading your local codebase...').start();
|
|
130
|
+
const localFiles = readLocalFiles(10);
|
|
131
|
+
codeSpinner.succeed(`Read ${localFiles.length} files from codebase.`);
|
|
132
|
+
|
|
133
|
+
// ── Step 3: AI Analysis ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
const aiSpinner = ora('AI is analyzing the issue and your codebase...').start();
|
|
136
|
+
|
|
137
|
+
const codeContext = localFiles
|
|
138
|
+
.map(f => `File: ${f.path}\n${f.content}`)
|
|
139
|
+
.join('\n\n---\n\n');
|
|
140
|
+
|
|
141
|
+
const prompt = `You are a senior developer helping a junior developer fix a GitHub issue.
|
|
142
|
+
|
|
143
|
+
GITHUB ISSUE:
|
|
144
|
+
Title: ${issue.title}
|
|
145
|
+
Description: ${issue.body || 'No description'}
|
|
146
|
+
Labels: ${issue.labels?.map(l => l.name).join(', ') || 'None'}
|
|
147
|
+
|
|
148
|
+
LOCAL CODEBASE (first 10 files):
|
|
149
|
+
${codeContext.slice(0, 5000)}
|
|
150
|
+
|
|
151
|
+
Based on the issue and codebase, provide:
|
|
152
|
+
|
|
153
|
+
1. UNDERSTANDING (2-3 lines explaining the bug simply)
|
|
154
|
+
|
|
155
|
+
2. FILES TO CHANGE (list exact file paths that need changes)
|
|
156
|
+
|
|
157
|
+
3. HOW TO FIX (step by step, simple language, include code snippets)
|
|
158
|
+
|
|
159
|
+
4. DIFFICULTY: Easy / Medium / Hard
|
|
160
|
+
|
|
161
|
+
5. ESTIMATED TIME: X minutes/hours
|
|
162
|
+
|
|
163
|
+
6. COMMIT MESSAGE (conventional commit format)
|
|
164
|
+
|
|
165
|
+
7. PR TITLE (clear and professional)
|
|
166
|
+
|
|
167
|
+
8. PR DESCRIPTION (What changed, Why, How to test)
|
|
168
|
+
|
|
169
|
+
Be specific and practical. Junior developers should understand this.`;
|
|
170
|
+
|
|
171
|
+
let analysis;
|
|
172
|
+
try {
|
|
173
|
+
analysis = await askAI(prompt);
|
|
174
|
+
aiSpinner.succeed('Analysis complete!\n');
|
|
175
|
+
} catch (err) {
|
|
176
|
+
aiSpinner.fail(chalk.red(`AI Error: ${err.message}`));
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Step 4: Display Analysis ─────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
console.log(chalk.bold.cyan(`\n🔍 Issue #${issueNumber}: ${issue.title}\n`));
|
|
183
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
184
|
+
console.log(chalk.white(analysis));
|
|
185
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
186
|
+
|
|
187
|
+
// ── Step 5: Ask what to do ───────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
const { action } = await inquirer.prompt([{
|
|
190
|
+
type: 'list',
|
|
191
|
+
name: 'action',
|
|
192
|
+
message: '\nWhat would you like to do?',
|
|
193
|
+
choices: [
|
|
194
|
+
{ name: '🌿 Create a new branch for this fix', value: 'branch' },
|
|
195
|
+
{ name: '📋 Generate full PR description', value: 'pr' },
|
|
196
|
+
{ name: '👀 I will fix it manually', value: 'manual' },
|
|
197
|
+
{ name: '❌ Exit', value: 'exit' },
|
|
198
|
+
],
|
|
199
|
+
}]);
|
|
200
|
+
|
|
201
|
+
if (action === 'exit' || action === 'manual') {
|
|
202
|
+
console.log(chalk.yellow('\nGood luck with the fix! Run gitpal commit when done.'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Step 6: Create Branch ────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
if (action === 'branch' || action === 'pr') {
|
|
209
|
+
const branchName = `fix/issue-${issueNumber}-${issue.title
|
|
210
|
+
.toLowerCase()
|
|
211
|
+
.replace(/[^a-z0-9\s]/g, '')
|
|
212
|
+
.replace(/\s+/g, '-')
|
|
213
|
+
.slice(0, 30)}`;
|
|
214
|
+
|
|
215
|
+
const { confirmBranch } = await inquirer.prompt([{
|
|
216
|
+
type: 'confirm',
|
|
217
|
+
name: 'confirmBranch',
|
|
218
|
+
message: `Create branch: ${chalk.cyan(branchName)}?`,
|
|
219
|
+
default: true,
|
|
220
|
+
}]);
|
|
221
|
+
|
|
222
|
+
if (confirmBranch) {
|
|
223
|
+
const branchSpinner = ora('Creating branch...').start();
|
|
224
|
+
try {
|
|
225
|
+
await git.checkoutLocalBranch(branchName);
|
|
226
|
+
branchSpinner.succeed(chalk.green(`Branch created: ${branchName}`));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
branchSpinner.fail(chalk.red(`Branch error: ${err.message}`));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Step 7: Generate PR Description ─────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
if (action === 'pr') {
|
|
236
|
+
const prSpinner = ora('Generating PR description...').start();
|
|
237
|
+
|
|
238
|
+
const prPrompt = `Write a professional GitHub Pull Request description for fixing issue #${issueNumber}.
|
|
239
|
+
|
|
240
|
+
Issue Title: ${issue.title}
|
|
241
|
+
Issue Description: ${issue.body?.slice(0, 500) || 'No description'}
|
|
242
|
+
|
|
243
|
+
Format exactly like this:
|
|
244
|
+
## Fixes
|
|
245
|
+
Closes #${issueNumber}
|
|
246
|
+
|
|
247
|
+
## What changed
|
|
248
|
+
(bullet points)
|
|
249
|
+
|
|
250
|
+
## Why
|
|
251
|
+
(brief reason)
|
|
252
|
+
|
|
253
|
+
## How to test
|
|
254
|
+
(testing steps)
|
|
255
|
+
|
|
256
|
+
## Type of change
|
|
257
|
+
(Bug fix / Feature / etc)`;
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
const prDesc = await askAI(prPrompt);
|
|
261
|
+
prSpinner.succeed('PR description ready!\n');
|
|
262
|
+
console.log(chalk.bold('\n📝 Pull Request Description:\n'));
|
|
263
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
264
|
+
console.log(chalk.white(prDesc));
|
|
265
|
+
console.log(chalk.dim('─'.repeat(60)));
|
|
266
|
+
console.log(chalk.dim('\n💡 Copy the above and paste into your GitHub PR.\n'));
|
|
267
|
+
} catch (err) {
|
|
268
|
+
prSpinner.fail(chalk.red(`Error: ${err.message}`));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Step 8: Final instructions ───────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
console.log(chalk.bold.green('\n✅ You are ready to contribute!\n'));
|
|
275
|
+
console.log(chalk.white('Next steps:'));
|
|
276
|
+
console.log(chalk.cyan(' 1.') + chalk.white(' Fix the issue in your editor'));
|
|
277
|
+
console.log(chalk.cyan(' 2.') + chalk.white(' git add .'));
|
|
278
|
+
console.log(chalk.cyan(' 3.') + chalk.white(' gitpal commit'));
|
|
279
|
+
console.log(chalk.cyan(' 4.') + chalk.white(` git push origin fix/issue-${issueNumber}`));
|
|
280
|
+
console.log(chalk.cyan(' 5.') + chalk.white(' Open PR on GitHub\n'));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── CONFIG GITHUB TOKEN ──────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
export function saveGithubToken(token) {
|
|
286
|
+
const config = loadConfig();
|
|
287
|
+
config.githubToken = token;
|
|
288
|
+
saveConfig(config);
|
|
289
|
+
console.log(chalk.green('\n✅ GitHub token saved!'));
|
|
290
|
+
console.log(chalk.dim('You can now use: gitpal issue 234 --repo owner/repo\n'));
|
|
291
|
+
}
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,8 @@ import { prCommand } from './commands/pr.js';
|
|
|
6
6
|
import { changelogCommand } from './commands/changelog.js';
|
|
7
7
|
import { configCommand } from './commands/config.js';
|
|
8
8
|
import { reviewCommand } from './commands/review.js';
|
|
9
|
-
|
|
9
|
+
import { explainCommand } from './commands/explain.js';
|
|
10
|
+
import { issueCommand, saveGithubToken } from './commands/issue.js';
|
|
10
11
|
const program = new Command();
|
|
11
12
|
|
|
12
13
|
console.log(chalk.cyan.bold('\n🤖 GitPal — Your AI Git Assistant\n'));
|
|
@@ -53,7 +54,18 @@ program
|
|
|
53
54
|
.option('-r, --review-only', 'Only review, do not commit')
|
|
54
55
|
.action(reviewCommand);
|
|
55
56
|
|
|
57
|
+
program
|
|
58
|
+
.command('explain <target>')
|
|
59
|
+
.description('Explain any file or commit in plain English')
|
|
60
|
+
.option('-f, --function <name>', 'Explain a specific function')
|
|
61
|
+
.action(explainCommand);
|
|
56
62
|
|
|
63
|
+
program
|
|
64
|
+
.command('issue <number>')
|
|
65
|
+
.description('Fetch and fix any GitHub issue with AI guidance')
|
|
66
|
+
.option('-r, --repo <repo>', 'GitHub repo (owner/repo)')
|
|
67
|
+
.option('-t, --github-token <token>', 'GitHub personal access token')
|
|
68
|
+
.action(issueCommand);
|
|
57
69
|
program.parse(process.argv);
|
|
58
70
|
|
|
59
71
|
// Show help if no command given
|
package/tests/ai.test.js
CHANGED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const CONFIG_PATH = path.join(os.homedir(), '.gitpal.json');
|
|
7
|
+
|
|
8
|
+
function cleanConfig() {
|
|
9
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
10
|
+
fs.unlinkSync(CONFIG_PATH);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ─── CONFIG TESTS ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
describe('Config Management', () => {
|
|
17
|
+
|
|
18
|
+
beforeEach(() => cleanConfig());
|
|
19
|
+
afterEach(() => cleanConfig());
|
|
20
|
+
|
|
21
|
+
test('loadConfig returns empty object when no config exists', async () => {
|
|
22
|
+
const { loadConfig } = await import('../src/ai.js');
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
expect(config).toEqual({});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('saveConfig saves provider and apiKey correctly', async () => {
|
|
28
|
+
const { saveConfig, loadConfig } = await import('../src/ai.js');
|
|
29
|
+
saveConfig({ provider: 'groq', apiKey: 'test-key-123' });
|
|
30
|
+
const config = loadConfig();
|
|
31
|
+
expect(config.provider).toBe('groq');
|
|
32
|
+
expect(config.apiKey).toBe('test-key-123');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('saveConfig overwrites existing config', async () => {
|
|
36
|
+
const { saveConfig, loadConfig } = await import('../src/ai.js');
|
|
37
|
+
saveConfig({ provider: 'openai', apiKey: 'old-key' });
|
|
38
|
+
saveConfig({ provider: 'anthropic', apiKey: 'new-key' });
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
expect(config.provider).toBe('anthropic');
|
|
41
|
+
expect(config.apiKey).toBe('new-key');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('saveConfig supports all 4 providers', async () => {
|
|
45
|
+
const { saveConfig, loadConfig } = await import('../src/ai.js');
|
|
46
|
+
const providers = ['groq', 'openai', 'gemini', 'anthropic'];
|
|
47
|
+
for (const provider of providers) {
|
|
48
|
+
saveConfig({ provider, apiKey: `key-for-${provider}` });
|
|
49
|
+
const config = loadConfig();
|
|
50
|
+
expect(config.provider).toBe(provider);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('config file is created at correct path', async () => {
|
|
55
|
+
const { saveConfig } = await import('../src/ai.js');
|
|
56
|
+
saveConfig({ provider: 'groq', apiKey: 'test-key' });
|
|
57
|
+
expect(fs.existsSync(CONFIG_PATH)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('config file contains valid JSON', async () => {
|
|
61
|
+
const { saveConfig } = await import('../src/ai.js');
|
|
62
|
+
saveConfig({ provider: 'groq', apiKey: 'test-key' });
|
|
63
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
|
|
64
|
+
expect(() => JSON.parse(raw)).not.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── AI PROVIDER TESTS ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe('AI Provider Validation', () => {
|
|
72
|
+
|
|
73
|
+
beforeEach(() => cleanConfig());
|
|
74
|
+
afterEach(() => cleanConfig());
|
|
75
|
+
|
|
76
|
+
test('askAI throws error when no config exists', async () => {
|
|
77
|
+
const { askAI } = await import('../src/ai.js');
|
|
78
|
+
await expect(askAI('test prompt')).rejects.toThrow('No AI provider configured');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('askAI throws error for unknown provider', async () => {
|
|
82
|
+
const { saveConfig, askAI } = await import('../src/ai.js');
|
|
83
|
+
saveConfig({ provider: 'unknownprovider', apiKey: 'test-key' });
|
|
84
|
+
await expect(askAI('test prompt')).rejects.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('askAI throws error when apiKey is missing', async () => {
|
|
88
|
+
const { saveConfig, askAI } = await import('../src/ai.js');
|
|
89
|
+
saveConfig({ provider: 'groq', apiKey: '' });
|
|
90
|
+
await expect(askAI('test prompt')).rejects.toThrow();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ─── GIT UTILITY TESTS ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('Git Utilities', () => {
|
|
98
|
+
|
|
99
|
+
test('isGitRepo returns a boolean', async () => {
|
|
100
|
+
const { isGitRepo } = await import('../src/git.js');
|
|
101
|
+
const result = await isGitRepo();
|
|
102
|
+
expect(typeof result).toBe('boolean');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('isGitRepo returns true inside a git repo', async () => {
|
|
106
|
+
const { isGitRepo } = await import('../src/git.js');
|
|
107
|
+
const result = await isGitRepo();
|
|
108
|
+
expect(result).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('getRecentCommits returns an array', async () => {
|
|
112
|
+
const { getRecentCommits } = await import('../src/git.js');
|
|
113
|
+
const commits = await getRecentCommits(3);
|
|
114
|
+
expect(Array.isArray(commits)).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('getRecentCommits respects the limit', async () => {
|
|
118
|
+
const { getRecentCommits } = await import('../src/git.js');
|
|
119
|
+
const commits = await getRecentCommits(2);
|
|
120
|
+
expect(commits.length).toBeLessThanOrEqual(2);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('getCurrentBranch returns a string', async () => {
|
|
124
|
+
const { getCurrentBranch } = await import('../src/git.js');
|
|
125
|
+
const branch = await getCurrentBranch();
|
|
126
|
+
expect(typeof branch).toBe('string');
|
|
127
|
+
expect(branch.length).toBeGreaterThan(0);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('getStagedDiff returns a string', async () => {
|
|
131
|
+
const { getStagedDiff } = await import('../src/git.js');
|
|
132
|
+
const diff = await getStagedDiff();
|
|
133
|
+
expect(typeof diff).toBe('string');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ─── PACKAGE.JSON VALIDATION ──────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
describe('Package Configuration', () => {
|
|
141
|
+
|
|
142
|
+
test('package.json has correct name', () => {
|
|
143
|
+
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8'));
|
|
144
|
+
expect(pkg.name).toBe('gitpal-cli');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('package.json has bin field', () => {
|
|
148
|
+
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8'));
|
|
149
|
+
expect(pkg.bin).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('package.json has version', () => {
|
|
153
|
+
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8'));
|
|
154
|
+
expect(pkg.version).toBeDefined();
|
|
155
|
+
expect(pkg.version).toMatch(/^\d+\.\d+\.\d+$/);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('package.json has required dependencies', () => {
|
|
159
|
+
const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf-8'));
|
|
160
|
+
const required = ['commander', 'simple-git', 'chalk', 'ora', 'inquirer'];
|
|
161
|
+
required.forEach(dep => {
|
|
162
|
+
expect(pkg.dependencies).toHaveProperty(dep);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('bin/gitpal.js file exists and is not empty', () => {
|
|
167
|
+
const binPath = './bin/gitpal.js';
|
|
168
|
+
expect(fs.existsSync(binPath)).toBe(true);
|
|
169
|
+
const content = fs.readFileSync(binPath, 'utf-8');
|
|
170
|
+
expect(content.trim().length).toBeGreaterThan(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ─── COMMAND FILES EXIST ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('Command Files', () => {
|
|
178
|
+
|
|
179
|
+
const commands = ['commit', 'summary', 'pr', 'changelog', 'config', 'review'];
|
|
180
|
+
|
|
181
|
+
commands.forEach(cmd => {
|
|
182
|
+
test(`src/commands/${cmd}.js exists`, () => {
|
|
183
|
+
expect(fs.existsSync(`./src/commands/${cmd}.js`)).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test(`src/commands/${cmd}.js is not empty`, () => {
|
|
187
|
+
const content = fs.readFileSync(`./src/commands/${cmd}.js`, 'utf-8');
|
|
188
|
+
expect(content.trim().length).toBeGreaterThan(0);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('src/ai.js exists and is not empty', () => {
|
|
193
|
+
const content = fs.readFileSync('./src/ai.js', 'utf-8');
|
|
194
|
+
expect(content.trim().length).toBeGreaterThan(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('src/git.js exists and is not empty', () => {
|
|
198
|
+
const content = fs.readFileSync('./src/git.js', 'utf-8');
|
|
199
|
+
expect(content.trim().length).toBeGreaterThan(0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('src/index.js exists and is not empty', () => {
|
|
203
|
+
const content = fs.readFileSync('./src/index.js', 'utf-8');
|
|
204
|
+
expect(content.trim().length).toBeGreaterThan(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
});
|