coderev-cli 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/cli.js +79 -0
- package/src/issue-validator.js +499 -0
- package/src/issue-validator.test.js +404 -0
- package/src/rag-indexer.js +700 -0
- package/src/rag-indexer.test.js +385 -0
- package/src/reviewer.js +36 -6
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for issue-validator.js
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - parseIssueRef (GitHub/GitLab URLs, shorthands)
|
|
6
|
+
* - extractIssueKeywords
|
|
7
|
+
* - validateIssueAgainstDiff
|
|
8
|
+
* - findRelatedIssues
|
|
9
|
+
* - formatIssueReport
|
|
10
|
+
* - validateIssue (integration)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { describe, it } = require('node:test');
|
|
14
|
+
const assert = require('node:assert/strict');
|
|
15
|
+
|
|
16
|
+
describe('issue-validator.js', () => {
|
|
17
|
+
const {
|
|
18
|
+
parseIssueRef,
|
|
19
|
+
extractIssueKeywords,
|
|
20
|
+
validateIssueAgainstDiff,
|
|
21
|
+
findRelatedIssues,
|
|
22
|
+
generateIssueReport,
|
|
23
|
+
formatIssueReport,
|
|
24
|
+
parseCommitLog,
|
|
25
|
+
} = require('./issue-validator');
|
|
26
|
+
|
|
27
|
+
describe('parseIssueRef', () => {
|
|
28
|
+
it('should parse full GitHub issue URL', () => {
|
|
29
|
+
const ref = parseIssueRef('https://github.com/facebook/react/issues/42');
|
|
30
|
+
assert.equal(ref.platform, 'github');
|
|
31
|
+
assert.equal(ref.owner, 'facebook');
|
|
32
|
+
assert.equal(ref.repo, 'react');
|
|
33
|
+
assert.equal(ref.issueNumber, 42);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should parse GitHub shorthand owner/repo#42', () => {
|
|
37
|
+
const ref = parseIssueRef('facebook/react#42');
|
|
38
|
+
assert.equal(ref.platform, 'github');
|
|
39
|
+
assert.equal(ref.owner, 'facebook');
|
|
40
|
+
assert.equal(ref.repo, 'react');
|
|
41
|
+
assert.equal(ref.issueNumber, 42);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should parse bare issue number #42', () => {
|
|
45
|
+
const ref = parseIssueRef('#42');
|
|
46
|
+
assert.equal(ref.platform, 'github');
|
|
47
|
+
assert.equal(ref.issueNumber, 42);
|
|
48
|
+
assert.equal(ref.owner, null);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should parse bare number 42', () => {
|
|
52
|
+
const ref = parseIssueRef('42');
|
|
53
|
+
assert.equal(ref.platform, 'github');
|
|
54
|
+
assert.equal(ref.issueNumber, 42);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should parse GitLab issue URL', () => {
|
|
58
|
+
const ref = parseIssueRef('https://gitlab.com/gitlab-org/gitlab/-/issues/999');
|
|
59
|
+
assert.equal(ref.platform, 'gitlab');
|
|
60
|
+
assert.equal(ref.owner, 'gitlab-org');
|
|
61
|
+
assert.equal(ref.repo, 'gitlab');
|
|
62
|
+
assert.equal(ref.issueNumber, 999);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should parse GitLab shorthand owner/repo!42', () => {
|
|
66
|
+
const ref = parseIssueRef('gitlab-org/gitlab!42');
|
|
67
|
+
assert.equal(ref.platform, 'gitlab');
|
|
68
|
+
assert.equal(ref.owner, 'gitlab-org');
|
|
69
|
+
assert.equal(ref.repo, 'gitlab');
|
|
70
|
+
assert.equal(ref.issueNumber, 42);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should return null for unparseable input', () => {
|
|
74
|
+
assert.equal(parseIssueRef('not-an-issue'), null);
|
|
75
|
+
assert.equal(parseIssueRef(''), null);
|
|
76
|
+
assert.equal(parseIssueRef('https://example.com'), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should parse GitHub URL with .git in repo name', () => {
|
|
80
|
+
const ref = parseIssueRef('https://github.com/owner/repo.git/issues/42');
|
|
81
|
+
assert.equal(ref.repo, 'repo');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should parse GitHub shorthand with hyphen in name', () => {
|
|
85
|
+
const ref = parseIssueRef('my-org/my-repo#123');
|
|
86
|
+
assert.equal(ref.owner, 'my-org');
|
|
87
|
+
assert.equal(ref.repo, 'my-repo');
|
|
88
|
+
assert.equal(ref.issueNumber, 123);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should store original URL in ref', () => {
|
|
92
|
+
const url = 'https://github.com/owner/repo/issues/42';
|
|
93
|
+
const ref = parseIssueRef(url);
|
|
94
|
+
assert.equal(ref.url, url);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('extractIssueKeywords', () => {
|
|
99
|
+
it('should extract file paths from issue body', () => {
|
|
100
|
+
const issue = {
|
|
101
|
+
title: 'Fix login bug',
|
|
102
|
+
body: 'The `src/auth/login.js` file has a typo. Also check `src/utils/helper.ts`.',
|
|
103
|
+
};
|
|
104
|
+
const keywords = extractIssueKeywords(issue);
|
|
105
|
+
assert.ok(keywords.includes('src/auth/login.js'));
|
|
106
|
+
assert.ok(keywords.includes('src/utils/helper.ts'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should extract function names', () => {
|
|
110
|
+
const issue = {
|
|
111
|
+
title: 'Refactor handleLogin',
|
|
112
|
+
body: 'Need to fix authenticate() and validateInput() functions.',
|
|
113
|
+
};
|
|
114
|
+
const keywords = extractIssueKeywords(issue);
|
|
115
|
+
// Function names are lowercased: authenticate, validateinput, handlelogin
|
|
116
|
+
assert.ok(keywords.some(k => k.includes('authenticate')));
|
|
117
|
+
assert.ok(keywords.some(k => k.includes('validateinput')));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should extract technical keywords', () => {
|
|
121
|
+
const issue = {
|
|
122
|
+
title: 'Add rate limiting to API',
|
|
123
|
+
body: 'We need rate limiting on the auth endpoint to prevent abuse.',
|
|
124
|
+
};
|
|
125
|
+
const keywords = extractIssueKeywords(issue);
|
|
126
|
+
assert.ok(keywords.includes('auth'));
|
|
127
|
+
assert.ok(keywords.includes('api'));
|
|
128
|
+
assert.ok(keywords.includes('endpoint'));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should extract keywords from title and body combined', () => {
|
|
132
|
+
const issue = {
|
|
133
|
+
title: 'Fix database connection pool',
|
|
134
|
+
body: 'The database connections are not being released properly. Need to add cache layer.',
|
|
135
|
+
};
|
|
136
|
+
const keywords = extractIssueKeywords(issue);
|
|
137
|
+
assert.ok(keywords.includes('database'));
|
|
138
|
+
assert.ok(keywords.includes('cache'));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should handle empty body', () => {
|
|
142
|
+
const issue = { title: 'Update README', body: '' };
|
|
143
|
+
const keywords = extractIssueKeywords(issue);
|
|
144
|
+
// README is not a tech term, so keywords should be minimal
|
|
145
|
+
assert.ok(Array.isArray(keywords));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should handle emojis and special chars', () => {
|
|
149
|
+
const issue = {
|
|
150
|
+
title: '🐛 Fix login crash',
|
|
151
|
+
body: 'The login page crashes. Need to fix the authentication flow.',
|
|
152
|
+
};
|
|
153
|
+
const keywords = extractIssueKeywords(issue);
|
|
154
|
+
assert.ok(keywords.includes('auth'));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('validateIssueAgainstDiff', () => {
|
|
159
|
+
it('should detect fully-addressed when keywords match diff', () => {
|
|
160
|
+
const issue = {
|
|
161
|
+
title: 'Fix login handler',
|
|
162
|
+
body: 'The `src/auth/login.js` needs to handle error cases and auth properly.',
|
|
163
|
+
};
|
|
164
|
+
const diff = `
|
|
165
|
+
diff --git a/src/auth/login.js b/src/auth/login.js
|
|
166
|
+
+ function login(req, res) {
|
|
167
|
+
+ try {
|
|
168
|
+
+ authenticate(req.body);
|
|
169
|
+
+ } catch (err) {
|
|
170
|
+
+ handleError(err);
|
|
171
|
+
+ }
|
|
172
|
+
+ }
|
|
173
|
+
+ function handleError(err) {
|
|
174
|
+
+ res.status(500).json({ error: err.message });
|
|
175
|
+
+ }
|
|
176
|
+
`;
|
|
177
|
+
const result = validateIssueAgainstDiff(issue, diff);
|
|
178
|
+
// Should be fully-addressed since all keywords are in diff
|
|
179
|
+
assert.ok(result.overallRelevance >= 65);
|
|
180
|
+
assert.ok(result.matchedKeywords.length > 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should detect partially-addressed when some keywords match', () => {
|
|
184
|
+
const issue = {
|
|
185
|
+
title: 'Add login, signup, and password reset pages',
|
|
186
|
+
body: 'We need `signup.js`, `login.js`, and `reset-password.js`.',
|
|
187
|
+
};
|
|
188
|
+
const diff = `
|
|
189
|
+
diff --git a/src/auth/login.js b/src/auth/login.js
|
|
190
|
+
+ export function login() {}
|
|
191
|
+
`;
|
|
192
|
+
const result = validateIssueAgainstDiff(issue, diff);
|
|
193
|
+
// Only login.js matched — should be partial
|
|
194
|
+
assert.ok(result.overallRelevance < 70);
|
|
195
|
+
assert.ok(result.unmatchedKeywords.length > 0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should detect unaddressed when no keywords match', () => {
|
|
199
|
+
const issue = {
|
|
200
|
+
title: 'Fix payment gateway integration',
|
|
201
|
+
body: 'The Stripe payment integration is broken. Need to fix billing service.',
|
|
202
|
+
};
|
|
203
|
+
const diff = `
|
|
204
|
+
diff --git a/src/homepage.js b/src/homepage.js
|
|
205
|
+
+ function renderHomepage() {}
|
|
206
|
+
`;
|
|
207
|
+
const result = validateIssueAgainstDiff(issue, diff);
|
|
208
|
+
assert.equal(result.verdict, 'unaddressed');
|
|
209
|
+
assert.ok(result.overallRelevance < 30);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should return unknown when no keywords extracted', () => {
|
|
213
|
+
const issue = { title: 'Foo', body: 'Bar' };
|
|
214
|
+
const diff = 'some random diff';
|
|
215
|
+
const result = validateIssueAgainstDiff(issue, diff);
|
|
216
|
+
assert.equal(result.verdict, 'unknown');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should track matched and unmatched keywords', () => {
|
|
220
|
+
const issue = {
|
|
221
|
+
title: 'Fix API handler',
|
|
222
|
+
body: 'The API handler in api.js needs error handling and caching.',
|
|
223
|
+
};
|
|
224
|
+
const diff = `
|
|
225
|
+
diff --git a/src/api.js b/src/api.js
|
|
226
|
+
+ export async function handler(req, res) {
|
|
227
|
+
+ try {
|
|
228
|
+
+ const result = await getData();
|
|
229
|
+
+ res.json(result);
|
|
230
|
+
+ } catch (err) {
|
|
231
|
+
+ // error handling
|
|
232
|
+
+ }
|
|
233
|
+
+ }
|
|
234
|
+
`;
|
|
235
|
+
const result = validateIssueAgainstDiff(issue, diff);
|
|
236
|
+
assert.ok(result.matchedKeywords.includes('api'));
|
|
237
|
+
assert.ok(result.matchedKeywords.includes('error'));
|
|
238
|
+
assert.ok(result.matchedFiles.includes('api.js'));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('should be case-insensitive for keyword matching', () => {
|
|
242
|
+
const issue = {
|
|
243
|
+
title: 'Fix AUTH bug',
|
|
244
|
+
body: 'Need to fix Authentication.',
|
|
245
|
+
};
|
|
246
|
+
const diff = 'fixed auth issue';
|
|
247
|
+
const result = validateIssueAgainstDiff(issue, diff);
|
|
248
|
+
assert.ok(result.matchedKeywords.includes('auth'));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('findRelatedIssues', () => {
|
|
253
|
+
it('should find GitHub issue references in commit log', () => {
|
|
254
|
+
const commitLog = `
|
|
255
|
+
abc1234 fix: resolve login timeout issue (fixes #42)
|
|
256
|
+
def5678 refactor: update build config, ref #43
|
|
257
|
+
ghi9012 docs: closes #44 and resolves #45
|
|
258
|
+
`;
|
|
259
|
+
const related = findRelatedIssues('', commitLog);
|
|
260
|
+
assert.ok(related.includes('#42'));
|
|
261
|
+
assert.ok(related.includes('#43'));
|
|
262
|
+
assert.ok(related.includes('#44'));
|
|
263
|
+
assert.ok(related.includes('#45'));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should find issue references in diff', () => {
|
|
267
|
+
const diff = 'Fixes #100 and relates to #101. Also related to #102.';
|
|
268
|
+
const related = findRelatedIssues(diff, '');
|
|
269
|
+
assert.ok(related.includes('#100'));
|
|
270
|
+
assert.ok(related.includes('#101'));
|
|
271
|
+
assert.ok(related.includes('#102'));
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should find references in both commit log and diff', () => {
|
|
275
|
+
const commitLog = 'fixes #1';
|
|
276
|
+
const diff = 'closes #2';
|
|
277
|
+
const related = findRelatedIssues(diff, commitLog);
|
|
278
|
+
assert.ok(related.includes('#1'));
|
|
279
|
+
assert.ok(related.includes('#2'));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should handle GitLab style references', () => {
|
|
283
|
+
const commitLog = 'closes !123 and fixes !456';
|
|
284
|
+
const related = findRelatedIssues('', commitLog);
|
|
285
|
+
assert.ok(related.includes('!123'));
|
|
286
|
+
assert.ok(related.includes('!456'));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('should deduplicate references', () => {
|
|
290
|
+
const text = 'fixes #42 and fixes #42 and closes #42';
|
|
291
|
+
const related = findRelatedIssues(text, '');
|
|
292
|
+
const count42 = related.filter(r => r === '#42').length;
|
|
293
|
+
assert.equal(count42, 1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should return empty array when no references found', () => {
|
|
297
|
+
assert.deepEqual(findRelatedIssues('', 'No references here'), []);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should handle various verb forms', () => {
|
|
301
|
+
const text = 'fix #1 fixed #2 fixes #3 close #4 closed #5 closes #6 resolve #7 resolved #8 resolves #9';
|
|
302
|
+
const related = findRelatedIssues(text, '');
|
|
303
|
+
['#1', '#2', '#3', '#4', '#5', '#6', '#7', '#8', '#9'].forEach(r => {
|
|
304
|
+
assert.ok(related.includes(r), `Expected ${r} to be found`);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should match "see #123" and "related to #456" patterns', () => {
|
|
309
|
+
const text = 'see #123, related to #456, reference #789';
|
|
310
|
+
const related = findRelatedIssues(text, '');
|
|
311
|
+
assert.ok(related.includes('#123'));
|
|
312
|
+
assert.ok(related.includes('#456'));
|
|
313
|
+
assert.ok(related.includes('#789'));
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('generateIssueReport', () => {
|
|
318
|
+
it('should generate a structured report', () => {
|
|
319
|
+
const issue = {
|
|
320
|
+
number: 42,
|
|
321
|
+
title: 'Fix login bug',
|
|
322
|
+
url: 'https://github.com/owner/repo/issues/42',
|
|
323
|
+
state: 'open',
|
|
324
|
+
labels: ['bug', 'high-priority'],
|
|
325
|
+
assignees: ['alice'],
|
|
326
|
+
html_url: 'https://github.com/owner/repo/issues/42',
|
|
327
|
+
};
|
|
328
|
+
const validation = {
|
|
329
|
+
verdict: 'fully-addressed',
|
|
330
|
+
overallRelevance: 85,
|
|
331
|
+
details: 'PR touches 3 mentioned files',
|
|
332
|
+
matchedFiles: ['login.js'],
|
|
333
|
+
matchedKeywords: ['auth', 'login'],
|
|
334
|
+
unmatchedKeywords: [],
|
|
335
|
+
};
|
|
336
|
+
const reviewResult = { score: 90, issues: [] };
|
|
337
|
+
const report = generateIssueReport(issue, validation, ['#43', '#44'], reviewResult);
|
|
338
|
+
|
|
339
|
+
assert.equal(report.issue.number, 42);
|
|
340
|
+
assert.equal(report.verdict, 'fully-addressed');
|
|
341
|
+
assert.equal(report.validation.overallRelevance, 85);
|
|
342
|
+
assert.deepEqual(report.relatedIssues, ['#43', '#44']);
|
|
343
|
+
assert.equal(report.combinedScore, 90);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe('formatIssueReport', () => {
|
|
348
|
+
it('should return a non-empty string', () => {
|
|
349
|
+
const issue = {
|
|
350
|
+
number: 1,
|
|
351
|
+
title: 'Test issue',
|
|
352
|
+
url: 'https://github.com/test/test/issues/1',
|
|
353
|
+
state: 'open',
|
|
354
|
+
labels: [],
|
|
355
|
+
assignees: [],
|
|
356
|
+
};
|
|
357
|
+
const validation = {
|
|
358
|
+
verdict: 'fully-addressed',
|
|
359
|
+
overallRelevance: 100,
|
|
360
|
+
details: 'All keywords matched',
|
|
361
|
+
matchedFiles: ['test.js'],
|
|
362
|
+
matchedKeywords: ['test'],
|
|
363
|
+
unmatchedKeywords: [],
|
|
364
|
+
};
|
|
365
|
+
const report = generateIssueReport(issue, validation, [], null);
|
|
366
|
+
const formatted = formatIssueReport(report);
|
|
367
|
+
assert.ok(typeof formatted === 'string');
|
|
368
|
+
assert.ok(formatted.length > 0);
|
|
369
|
+
assert.ok(formatted.includes('Issue Validation'));
|
|
370
|
+
assert.ok(formatted.includes('fully-addressed'));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should show unmatched keywords when present', () => {
|
|
374
|
+
const issue = {
|
|
375
|
+
number: 2,
|
|
376
|
+
title: 'Partial fix',
|
|
377
|
+
url: 'https://github.com/test/test/issues/2',
|
|
378
|
+
state: 'open',
|
|
379
|
+
labels: [],
|
|
380
|
+
assignees: [],
|
|
381
|
+
};
|
|
382
|
+
const validation = {
|
|
383
|
+
verdict: 'partially-addressed',
|
|
384
|
+
overallRelevance: 40,
|
|
385
|
+
details: 'Partially addressed',
|
|
386
|
+
matchedFiles: [],
|
|
387
|
+
matchedKeywords: ['api'],
|
|
388
|
+
unmatchedKeywords: ['database', 'cache'],
|
|
389
|
+
};
|
|
390
|
+
const report = generateIssueReport(issue, validation, [], null);
|
|
391
|
+
const formatted = formatIssueReport(report);
|
|
392
|
+
assert.ok(formatted.includes('partially-addressed'));
|
|
393
|
+
assert.ok(formatted.includes('database'));
|
|
394
|
+
assert.ok(formatted.includes('cache'));
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('parseCommitLog', () => {
|
|
399
|
+
it('should return empty string for non-git directory', () => {
|
|
400
|
+
const log = parseCommitLog('/tmp/nonexistent-repo');
|
|
401
|
+
assert.equal(log, '');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
});
|