prsmith 1.0.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/README.md +140 -36
- package/bin/cli.js +196 -16
- package/eslint.config.js +8 -6
- package/package.json +4 -2
- package/src/ai.js +118 -0
- package/src/batch.js +206 -0
- package/src/config.js +33 -0
- package/src/formatter.js +43 -11
- package/src/github.js +153 -0
- package/src/prompts.js +144 -26
- package/src/templates.js +5 -9
- package/tests/features.test.js +210 -0
- package/tests/formatter.test.js +42 -19
package/src/prompts.js
CHANGED
|
@@ -1,27 +1,145 @@
|
|
|
1
|
-
import inquirer from
|
|
2
|
-
|
|
3
|
-
export async function getReviewData() {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
|
|
3
|
+
export async function getReviewData(options = {}, config = {}) {
|
|
4
|
+
const defaultSeverities = ['Critical', 'Major', 'Minor', 'Suggestion'];
|
|
5
|
+
let severities = [...defaultSeverities];
|
|
6
|
+
if (config.templates) {
|
|
7
|
+
for (const key of Object.keys(config.templates)) {
|
|
8
|
+
if (!severities.includes(key)) {
|
|
9
|
+
severities.push(key);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// 1. Gather Core Fields
|
|
15
|
+
const coreQuestions = [];
|
|
16
|
+
|
|
17
|
+
if (!options.severity) {
|
|
18
|
+
coreQuestions.push({
|
|
19
|
+
type: 'select',
|
|
20
|
+
name: 'severity',
|
|
21
|
+
message: 'Select severity:',
|
|
22
|
+
choices: severities,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!options.title) {
|
|
27
|
+
coreQuestions.push({
|
|
28
|
+
type: 'input',
|
|
29
|
+
name: 'title',
|
|
30
|
+
message: 'Review title:',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!options.issue) {
|
|
35
|
+
coreQuestions.push({
|
|
36
|
+
type: 'editor',
|
|
37
|
+
name: 'issue',
|
|
38
|
+
message: 'Describe the issue:',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!options.fix) {
|
|
43
|
+
coreQuestions.push({
|
|
44
|
+
type: 'editor',
|
|
45
|
+
name: 'fix',
|
|
46
|
+
message: 'Suggested fix:',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const coreAnswers =
|
|
51
|
+
coreQuestions.length > 0 ? await inquirer.prompt(coreQuestions) : {};
|
|
52
|
+
const currentData = { ...options, ...coreAnswers };
|
|
53
|
+
|
|
54
|
+
// 2. Gather File & Line Context
|
|
55
|
+
let fileAnswers = {};
|
|
56
|
+
if (currentData.path === undefined && currentData.line === undefined) {
|
|
57
|
+
const { addContext } = await inquirer.prompt([
|
|
58
|
+
{
|
|
59
|
+
type: 'confirm',
|
|
60
|
+
name: 'addContext',
|
|
61
|
+
message: 'Would you like to add file & line context?',
|
|
62
|
+
default: false,
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
if (addContext) {
|
|
67
|
+
fileAnswers = await inquirer.prompt([
|
|
68
|
+
{
|
|
69
|
+
type: 'input',
|
|
70
|
+
name: 'path',
|
|
71
|
+
message: 'File path (relative to repo root, e.g. src/index.js):',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: 'input',
|
|
75
|
+
name: 'line',
|
|
76
|
+
message: 'Line number or range (e.g. 12 or 45-50):',
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 3. Gather Before/After Code Snippets
|
|
83
|
+
let codeAnswers = {};
|
|
84
|
+
if (currentData.before === undefined && currentData.after === undefined) {
|
|
85
|
+
const { addSnippets } = await inquirer.prompt([
|
|
86
|
+
{
|
|
87
|
+
type: 'confirm',
|
|
88
|
+
name: 'addSnippets',
|
|
89
|
+
message: 'Would you like to add Before/After code snippets?',
|
|
90
|
+
default: false,
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
if (addSnippets) {
|
|
95
|
+
codeAnswers = await inquirer.prompt([
|
|
96
|
+
{
|
|
97
|
+
type: 'input',
|
|
98
|
+
name: 'lang',
|
|
99
|
+
message: 'Programming language (for markdown syntax highlighting):',
|
|
100
|
+
default: 'javascript',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'editor',
|
|
104
|
+
name: 'before',
|
|
105
|
+
message: 'Original Code (Before):',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
type: 'editor',
|
|
109
|
+
name: 'after',
|
|
110
|
+
message: 'Proposed Code (After):',
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 4. Gather AI Polish Toggle
|
|
117
|
+
let aiAnswers = {};
|
|
118
|
+
if (currentData.ai === undefined) {
|
|
119
|
+
const hasKey = !!(
|
|
120
|
+
config.aiApiKey ||
|
|
121
|
+
process.env.GEMINI_API_KEY ||
|
|
122
|
+
process.env.OPENAI_API_KEY ||
|
|
123
|
+
process.env.GROQ_API_KEY
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const { ai } = await inquirer.prompt([
|
|
127
|
+
{
|
|
128
|
+
type: 'confirm',
|
|
129
|
+
name: 'ai',
|
|
130
|
+
message: hasKey
|
|
131
|
+
? 'Polish the description and fix with AI for a constructive, polite tone?'
|
|
132
|
+
: 'Polish with AI? (Note: Needs AI key configured in .prsmith.json or env)',
|
|
133
|
+
default: false,
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
aiAnswers = { ai };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...currentData,
|
|
141
|
+
...fileAnswers,
|
|
142
|
+
...codeAnswers,
|
|
143
|
+
...aiAnswers,
|
|
144
|
+
};
|
|
27
145
|
}
|
package/src/templates.js
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
export const templates = {
|
|
2
|
-
Critical:
|
|
3
|
-
"The current implementation introduces a critical issue.",
|
|
2
|
+
Critical: 'The current implementation introduces a critical issue.',
|
|
4
3
|
|
|
5
|
-
Major:
|
|
6
|
-
"The current implementation introduces a significant issue.",
|
|
4
|
+
Major: 'The current implementation introduces a significant issue.',
|
|
7
5
|
|
|
8
|
-
Minor:
|
|
9
|
-
"The current implementation could be improved.",
|
|
6
|
+
Minor: 'The current implementation could be improved.',
|
|
10
7
|
|
|
11
|
-
Suggestion:
|
|
12
|
-
|
|
13
|
-
};
|
|
8
|
+
Suggestion: 'The implementation works, but there may be a cleaner approach.',
|
|
9
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { generateMarkdown } from '../src/formatter.js';
|
|
4
|
+
import { detectGithubRepo } from '../src/github.js';
|
|
5
|
+
import { polishText } from '../src/ai.js';
|
|
6
|
+
|
|
7
|
+
describe('v2.0.0 Features Test Suite', () => {
|
|
8
|
+
let existsSyncSpy;
|
|
9
|
+
let readFileSyncSpy;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
// Spy on fs methods
|
|
14
|
+
existsSyncSpy = vi.spyOn(fs, 'existsSync');
|
|
15
|
+
readFileSyncSpy = vi.spyOn(fs, 'readFileSync');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.restoreAllMocks();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('File & Line Context Deep Links', () => {
|
|
23
|
+
it('should generate standard file path text if no githubRepo is detected', () => {
|
|
24
|
+
const data = {
|
|
25
|
+
severity: 'Suggestion',
|
|
26
|
+
title: 'Optimized Loop',
|
|
27
|
+
issue: 'Loop could be faster.',
|
|
28
|
+
fix: 'Use for-i loop.',
|
|
29
|
+
path: 'src/utils/math.js',
|
|
30
|
+
line: '15-20',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
existsSyncSpy.mockReturnValue(false);
|
|
34
|
+
|
|
35
|
+
const markdown = generateMarkdown(data);
|
|
36
|
+
expect(markdown).toContain('📁 **File:** `src/utils/math.js:15-20`');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should generate a clickable GitHub deep link if githubRepo is provided', () => {
|
|
40
|
+
const data = {
|
|
41
|
+
severity: 'Suggestion',
|
|
42
|
+
title: 'Optimized Loop',
|
|
43
|
+
issue: 'Loop could be faster.',
|
|
44
|
+
fix: 'Use for-i loop.',
|
|
45
|
+
path: 'src/utils/math.js',
|
|
46
|
+
line: '15-20',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const config = {
|
|
50
|
+
githubRepo: { owner: 'tarunyaprogrammer', repo: 'PRSmith' },
|
|
51
|
+
defaultBranch: 'main',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const markdown = generateMarkdown(data, config);
|
|
55
|
+
expect(markdown).toContain(
|
|
56
|
+
'📁 **File:** [`src/utils/math.js:15-20`](https://github.com/tarunyaprogrammer/PRSmith/blob/main/src/utils/math.js#L15)'
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Before / After Code Snippet Formatting', () => {
|
|
62
|
+
it('should render collapsible before and after code blocks beautifully', () => {
|
|
63
|
+
const data = {
|
|
64
|
+
severity: 'Minor',
|
|
65
|
+
title: 'Use Strict Equality',
|
|
66
|
+
issue: 'Loose equality can lead to bugs.',
|
|
67
|
+
fix: 'Use strict equality instead.',
|
|
68
|
+
lang: 'typescript',
|
|
69
|
+
before: 'if (x == y) { return; }',
|
|
70
|
+
after: 'if (x === y) { return; }',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const markdown = generateMarkdown(data);
|
|
74
|
+
expect(markdown).toContain('<details>');
|
|
75
|
+
expect(markdown).toContain(
|
|
76
|
+
'<summary>🔍 View Code Diff / Comparison</summary>'
|
|
77
|
+
);
|
|
78
|
+
expect(markdown).toContain(
|
|
79
|
+
'**Before:**\n```typescript\nif (x == y) { return; }\n```'
|
|
80
|
+
);
|
|
81
|
+
expect(markdown).toContain(
|
|
82
|
+
'**After:**\n```typescript\nif (x === y) { return; }\n```'
|
|
83
|
+
);
|
|
84
|
+
expect(markdown).toContain('</details>');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('Git Config Parsing (detectGithubRepo)', () => {
|
|
89
|
+
it('should correctly parse HTTPS git urls from config file', () => {
|
|
90
|
+
existsSyncSpy.mockReturnValue(true);
|
|
91
|
+
readFileSyncSpy.mockReturnValue(`
|
|
92
|
+
[core]
|
|
93
|
+
repositoryformatversion = 0
|
|
94
|
+
filemode = true
|
|
95
|
+
[remote "origin"]
|
|
96
|
+
url = https://github.com/tarunyaprogrammer/PRSmith.git
|
|
97
|
+
fetch = +refs/heads/*:refs/remotes/origin/*
|
|
98
|
+
`);
|
|
99
|
+
|
|
100
|
+
const repo = detectGithubRepo();
|
|
101
|
+
expect(repo).toEqual({ owner: 'tarunyaprogrammer', repo: 'PRSmith' });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should correctly parse SSH git urls from config file', () => {
|
|
105
|
+
existsSyncSpy.mockReturnValue(true);
|
|
106
|
+
readFileSyncSpy.mockReturnValue(`
|
|
107
|
+
[remote "origin"]
|
|
108
|
+
url = git@github.com:google/antigravity.git
|
|
109
|
+
`);
|
|
110
|
+
|
|
111
|
+
const repo = detectGithubRepo();
|
|
112
|
+
expect(repo).toEqual({ owner: 'google', repo: 'antigravity' });
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('AI Polish (polishText) HTTP mock', () => {
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
global.fetch = vi.fn();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should fallback to original text if no key is provided', async () => {
|
|
122
|
+
const originalText = 'This is a harsh comment!';
|
|
123
|
+
const polished = await polishText(
|
|
124
|
+
originalText,
|
|
125
|
+
'Problem Description',
|
|
126
|
+
{}
|
|
127
|
+
);
|
|
128
|
+
expect(polished).toBe(originalText);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should invoke Gemini API correctly and return polished content', async () => {
|
|
132
|
+
const originalText = 'Your code is slow.';
|
|
133
|
+
const responseMock = {
|
|
134
|
+
ok: true,
|
|
135
|
+
json: async () => ({
|
|
136
|
+
candidates: [
|
|
137
|
+
{
|
|
138
|
+
content: {
|
|
139
|
+
parts: [
|
|
140
|
+
{
|
|
141
|
+
text: 'The current implementation has room for performance optimization.',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
global.fetch.mockResolvedValue(responseMock);
|
|
150
|
+
|
|
151
|
+
const config = {
|
|
152
|
+
aiProvider: 'gemini',
|
|
153
|
+
aiApiKey: 'mock-key',
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const polished = await polishText(
|
|
157
|
+
originalText,
|
|
158
|
+
'Problem Description',
|
|
159
|
+
config
|
|
160
|
+
);
|
|
161
|
+
expect(polished).toBe(
|
|
162
|
+
'The current implementation has room for performance optimization.'
|
|
163
|
+
);
|
|
164
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
165
|
+
expect.stringContaining('generativelanguage.googleapis.com'),
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
method: 'POST',
|
|
168
|
+
body: expect.stringContaining('Your code is slow.'),
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should invoke OpenAI API correctly and return polished content', async () => {
|
|
174
|
+
const originalText = 'Close the resource.';
|
|
175
|
+
const responseMock = {
|
|
176
|
+
ok: true,
|
|
177
|
+
json: async () => ({
|
|
178
|
+
choices: [
|
|
179
|
+
{
|
|
180
|
+
message: {
|
|
181
|
+
content:
|
|
182
|
+
'It is highly recommended to properly release the resource in a finally block.',
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
}),
|
|
187
|
+
};
|
|
188
|
+
global.fetch.mockResolvedValue(responseMock);
|
|
189
|
+
|
|
190
|
+
const config = {
|
|
191
|
+
aiProvider: 'openai',
|
|
192
|
+
aiApiKey: 'mock-key-openai',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const polished = await polishText(originalText, 'Suggested Fix', config);
|
|
196
|
+
expect(polished).toBe(
|
|
197
|
+
'It is highly recommended to properly release the resource in a finally block.'
|
|
198
|
+
);
|
|
199
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
200
|
+
'https://api.openai.com/v1/chat/completions',
|
|
201
|
+
expect.objectContaining({
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: expect.objectContaining({
|
|
204
|
+
Authorization: 'Bearer mock-key-openai',
|
|
205
|
+
}),
|
|
206
|
+
})
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
});
|
package/tests/formatter.test.js
CHANGED
|
@@ -1,32 +1,55 @@
|
|
|
1
|
-
import { describe, it, expect } from
|
|
2
|
-
import { generateMarkdown } from
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { generateMarkdown } from '../src/formatter.js';
|
|
3
3
|
|
|
4
|
-
describe(
|
|
5
|
-
it(
|
|
4
|
+
describe('formatter.js', () => {
|
|
5
|
+
it('should generate proper markdown for Critical severity', () => {
|
|
6
6
|
const data = {
|
|
7
|
-
severity:
|
|
8
|
-
title:
|
|
9
|
-
issue:
|
|
10
|
-
fix:
|
|
7
|
+
severity: 'Critical',
|
|
8
|
+
title: 'Memory Leak',
|
|
9
|
+
issue: 'The connection is never closed.',
|
|
10
|
+
fix: 'Add a finally block to close the connection.',
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
const markdown = generateMarkdown(data);
|
|
14
|
-
|
|
15
|
-
expect(markdown).toContain(
|
|
16
|
-
expect(markdown).toContain(
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
|
|
15
|
+
expect(markdown).toContain('### Critical: Memory Leak');
|
|
16
|
+
expect(markdown).toContain(
|
|
17
|
+
'The current implementation introduces a critical issue.'
|
|
18
|
+
);
|
|
19
|
+
expect(markdown).toContain('The connection is never closed.');
|
|
20
|
+
expect(markdown).toContain('Add a finally block to close the connection.');
|
|
19
21
|
});
|
|
20
22
|
|
|
21
|
-
it(
|
|
23
|
+
it('should fallback to a default intro if severity is unknown', () => {
|
|
22
24
|
const data = {
|
|
23
|
-
severity:
|
|
24
|
-
title:
|
|
25
|
-
issue:
|
|
26
|
-
fix:
|
|
25
|
+
severity: 'Unknown',
|
|
26
|
+
title: 'Something',
|
|
27
|
+
issue: 'Bad code.',
|
|
28
|
+
fix: 'Fix code.',
|
|
27
29
|
};
|
|
28
30
|
|
|
29
31
|
const markdown = generateMarkdown(data);
|
|
30
|
-
expect(markdown).toContain(
|
|
32
|
+
expect(markdown).toContain(
|
|
33
|
+
'The current implementation requires attention.'
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should use custom templates when passed via config', () => {
|
|
38
|
+
const data = {
|
|
39
|
+
severity: 'Nitpick',
|
|
40
|
+
title: 'Formatting',
|
|
41
|
+
issue: 'Indentation is off.',
|
|
42
|
+
fix: 'Fix indentation.',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const config = {
|
|
46
|
+
templates: {
|
|
47
|
+
Nitpick: 'This is a tiny nitpick.',
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const markdown = generateMarkdown(data, config);
|
|
52
|
+
expect(markdown).toContain('### Nitpick: Formatting');
|
|
53
|
+
expect(markdown).toContain('This is a tiny nitpick.');
|
|
31
54
|
});
|
|
32
55
|
});
|