coderev-cli 1.0.26 → 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 +776 -0
- package/package.json +1 -1
- package/src/cli.js +162 -1
- package/src/issue-validator.js +499 -0
- package/src/issue-validator.test.js +404 -0
- package/src/models.js +59 -0
- package/src/models.test.js +139 -2
- package/src/rag-indexer.js +700 -0
- package/src/rag-indexer.test.js +385 -0
- package/src/reviewer.js +36 -6
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue Validator — 验证 PR 是否真正解决了关联的 issue
|
|
3
|
+
*
|
|
4
|
+
* 功能:
|
|
5
|
+
* - 解析 GitHub/GitLab issue URL
|
|
6
|
+
* - 获取 issue 内容(标题、描述、标签、assignees)
|
|
7
|
+
* - 对比 PR diff 与 issue 描述,检查是否相关
|
|
8
|
+
* - 检测遗漏的关联 issue(基于 commit message 引用)
|
|
9
|
+
* - 输出 issue 覆盖报告
|
|
10
|
+
*
|
|
11
|
+
* 用法:
|
|
12
|
+
* coderev review --issue https://github.com/owner/repo/issues/42
|
|
13
|
+
* coderev review --pr owner/repo#10 --verify-issue
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const https = require('https');
|
|
17
|
+
const http = require('http');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse GitHub/GitLab issue URL into structured reference.
|
|
21
|
+
*/
|
|
22
|
+
function parseIssueRef(input) {
|
|
23
|
+
// GitHub: https://github.com/owner/repo/issues/42
|
|
24
|
+
// GitHub PR: owner/repo#42
|
|
25
|
+
// GitLab: https://gitlab.com/owner/repo/-/issues/42
|
|
26
|
+
// GitLab MR: owner/repo!42
|
|
27
|
+
|
|
28
|
+
const githubUrl = input.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
|
|
29
|
+
if (githubUrl) {
|
|
30
|
+
return { platform: 'github', owner: githubUrl[1], repo: githubUrl[2].replace(/\.git$/, ''), issueNumber: parseInt(githubUrl[3]), url: input };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const gitlabUrl = input.match(/gitlab\.com\/([^/]+)\/([^/]+)\/-\/issues\/(\d+)/);
|
|
34
|
+
if (gitlabUrl) {
|
|
35
|
+
return { platform: 'gitlab', owner: gitlabUrl[1], repo: gitlabUrl[2], issueNumber: parseInt(gitlabUrl[3]), url: input };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// GitHub shorthand: owner/repo#42
|
|
39
|
+
const ghShorthand = input.match(/^([^/]+)\/([^#]+)#(\d+)$/);
|
|
40
|
+
if (ghShorthand) {
|
|
41
|
+
return { platform: 'github', owner: ghShorthand[1], repo: ghShorthand[2], issueNumber: parseInt(ghShorthand[3]), url: `https://github.com/${ghShorthand[1]}/${ghShorthand[2]}/issues/${ghShorthand[3]}` };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// GitLab shorthand: owner/repo!42
|
|
45
|
+
const glShorthand = input.match(/^([^/]+)\/([^!]+)!(\d+)$/);
|
|
46
|
+
if (glShorthand) {
|
|
47
|
+
return { platform: 'gitlab', owner: glShorthand[1], repo: glShorthand[2], issueNumber: parseInt(glShorthand[3]), url: `https://gitlab.com/${glShorthand[1]}/${glShorthand[2]}/-/issues/${glShorthand[3]}` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// GitHub issue number only
|
|
51
|
+
const bareNum = input.match(/^#?(\d+)$/);
|
|
52
|
+
if (bareNum) {
|
|
53
|
+
return { platform: 'github', owner: null, repo: null, issueNumber: parseInt(bareNum[1]), url: null };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Fetch GitHub issue details.
|
|
61
|
+
*/
|
|
62
|
+
function fetchGitHubIssue(owner, repo, issueNumber, token) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const path = `/repos/${owner}/${repo}/issues/${issueNumber}`;
|
|
65
|
+
const opts = {
|
|
66
|
+
hostname: 'api.github.com',
|
|
67
|
+
path,
|
|
68
|
+
method: 'GET',
|
|
69
|
+
headers: {
|
|
70
|
+
'User-Agent': 'coderev',
|
|
71
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
72
|
+
...(token ? { 'Authorization': `token ${token}` } : {}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
https.get(opts, (res) => {
|
|
77
|
+
let data = '';
|
|
78
|
+
res.on('data', (c) => (data += c));
|
|
79
|
+
res.on('end', () => {
|
|
80
|
+
if (res.statusCode === 200) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(data);
|
|
83
|
+
resolve({
|
|
84
|
+
title: parsed.title,
|
|
85
|
+
body: parsed.body || '',
|
|
86
|
+
state: parsed.state,
|
|
87
|
+
labels: (parsed.labels || []).map(l => l.name),
|
|
88
|
+
assignees: (parsed.assignees || []).map(a => a.login),
|
|
89
|
+
milestone: parsed.milestone?.title || null,
|
|
90
|
+
number: parsed.number,
|
|
91
|
+
html_url: parsed.html_url,
|
|
92
|
+
pull_request: parsed.pull_request || null, // Issues that are actually PRs
|
|
93
|
+
});
|
|
94
|
+
} catch {
|
|
95
|
+
reject(new Error('Failed to parse issue JSON'));
|
|
96
|
+
}
|
|
97
|
+
} else if (res.statusCode === 404) {
|
|
98
|
+
reject(new Error(`Issue not found: ${owner}/${repo}#${issueNumber}`));
|
|
99
|
+
} else if (res.statusCode === 403) {
|
|
100
|
+
reject(new Error('GitHub API rate limit exceeded. Set GITHUB_TOKEN for higher limits.'));
|
|
101
|
+
} else {
|
|
102
|
+
reject(new Error(`GitHub API error ${res.statusCode}: ${data.slice(0, 200)}`));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}).on('error', reject);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch issue comments for additional context.
|
|
111
|
+
*/
|
|
112
|
+
function fetchGitHubIssueComments(owner, repo, issueNumber, token) {
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
const path = `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=30`;
|
|
115
|
+
const opts = {
|
|
116
|
+
hostname: 'api.github.com',
|
|
117
|
+
path,
|
|
118
|
+
method: 'GET',
|
|
119
|
+
headers: {
|
|
120
|
+
'User-Agent': 'coderev',
|
|
121
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
122
|
+
...(token ? { 'Authorization': `token ${token}` } : {}),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
https.get(opts, (res) => {
|
|
127
|
+
let data = '';
|
|
128
|
+
res.on('data', (c) => (data += c));
|
|
129
|
+
res.on('end', () => {
|
|
130
|
+
if (res.statusCode === 200) {
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(data);
|
|
133
|
+
resolve(parsed.map(c => ({ user: c.user?.login, body: c.body || '', created_at: c.created_at })));
|
|
134
|
+
} catch {
|
|
135
|
+
resolve([]);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
resolve([]); // Comments are optional context
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}).on('error', () => resolve([]));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fetch linked PRs for an issue (GitHub GraphQL-like via timeline).
|
|
147
|
+
*/
|
|
148
|
+
async function fetchIssueReferences(owner, repo, issueNumber, token) {
|
|
149
|
+
return new Promise((resolve, reject) => {
|
|
150
|
+
const path = `/repos/${owner}/${repo}/issues/${issueNumber}/timeline?per_page=50`;
|
|
151
|
+
const opts = {
|
|
152
|
+
hostname: 'api.github.com',
|
|
153
|
+
path,
|
|
154
|
+
method: 'GET',
|
|
155
|
+
headers: {
|
|
156
|
+
'User-Agent': 'coderev',
|
|
157
|
+
'Accept': 'application/vnd.github.mockingbird.issue-timeline+json',
|
|
158
|
+
...(token ? { 'Authorization': `token ${token}` } : {}),
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
https.get(opts, (res) => {
|
|
163
|
+
let data = '';
|
|
164
|
+
res.on('data', (c) => (data += c));
|
|
165
|
+
res.on('end', () => {
|
|
166
|
+
if (res.statusCode === 200) {
|
|
167
|
+
try {
|
|
168
|
+
const events = JSON.parse(data);
|
|
169
|
+
const refs = {
|
|
170
|
+
linkedPRs: events.filter(e => e.event === 'cross-referenced' && e.source?.type === 'pull_request').length,
|
|
171
|
+
closedBy: events.filter(e => e.event === 'closed' && e.commit_id).length,
|
|
172
|
+
};
|
|
173
|
+
resolve(refs);
|
|
174
|
+
} catch {
|
|
175
|
+
resolve({ linkedPRs: 0, closedBy: 0 });
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
resolve({ linkedPRs: 0, closedBy: 0 });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}).on('error', () => resolve({ linkedPRs: 0, closedBy: 0 }));
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Extract keywords and concepts from issue body for relevance matching.
|
|
187
|
+
*/
|
|
188
|
+
function extractIssueKeywords(issue) {
|
|
189
|
+
const text = `${issue.title} ${issue.body}`.toLowerCase();
|
|
190
|
+
const keywords = new Set();
|
|
191
|
+
|
|
192
|
+
// Extract file paths mentioned
|
|
193
|
+
const fileMatches = text.match(/[`'"]?([\w./-]+\.[\w]{1,6})[`'"]?/g) || [];
|
|
194
|
+
for (const m of fileMatches) {
|
|
195
|
+
const clean = m.replace(/[`'"]/g, '');
|
|
196
|
+
if (/\w+\.\w+$/.test(clean)) keywords.add(clean);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Extract function/class names
|
|
200
|
+
const funcMatches = text.match(/\b([a-z_]\w+)\s*\(/g) || [];
|
|
201
|
+
for (const m of funcMatches) keywords.add(m.replace(/\s*\(/, ''));
|
|
202
|
+
|
|
203
|
+
// Extract key nouns (simple heuristic: capitalized words or technical terms)
|
|
204
|
+
const techTerms = [
|
|
205
|
+
'auth', 'login', 'password', 'token', 'api', 'endpoint', 'route', 'handler',
|
|
206
|
+
'database', 'query', 'migration', 'schema', 'table', 'column', 'index',
|
|
207
|
+
'cache', 'redis', 'queue', 'worker', 'job', 'cron',
|
|
208
|
+
'error', 'exception', 'crash', 'bug', 'fix', 'patch',
|
|
209
|
+
'refactor', 'rename', 'remove', 'deprecate', 'migrate',
|
|
210
|
+
'ui', 'component', 'button', 'modal', 'form', 'input', 'page',
|
|
211
|
+
'permission', 'role', 'access', 'admin', 'user', 'profile',
|
|
212
|
+
'config', 'env', 'environment', 'deploy', 'build', 'ci',
|
|
213
|
+
'payment', 'billing', 'stripe', 'invoice',
|
|
214
|
+
'email', 'sms', 'notification', 'alert', 'webhook',
|
|
215
|
+
'upload', 'download', 'file', 'image', 'video',
|
|
216
|
+
'search', 'filter', 'sort', 'paginate', 'export', 'import',
|
|
217
|
+
];
|
|
218
|
+
for (const term of techTerms) {
|
|
219
|
+
if (text.includes(term)) keywords.add(term);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return [...keywords];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Check if the PR diff addresses the issue content.
|
|
227
|
+
* Uses keyword matching and structured analysis.
|
|
228
|
+
*/
|
|
229
|
+
function validateIssueAgainstDiff(issue, diff, options = {}) {
|
|
230
|
+
const keywords = extractIssueKeywords(issue);
|
|
231
|
+
const diffLower = diff.toLowerCase();
|
|
232
|
+
|
|
233
|
+
const findings = {
|
|
234
|
+
matchedFiles: [],
|
|
235
|
+
matchedKeywords: [],
|
|
236
|
+
unmatchedKeywords: [],
|
|
237
|
+
overallRelevance: 0,
|
|
238
|
+
verdict: 'unknown', // 'fully-addressed' | 'partially-addressed' | 'unaddressed' | 'unknown'
|
|
239
|
+
details: '',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Check which files from the issue are touched in the diff
|
|
243
|
+
for (const kw of keywords) {
|
|
244
|
+
if (kw.includes('.')) {
|
|
245
|
+
// File path
|
|
246
|
+
if (diffLower.includes(kw.toLowerCase())) {
|
|
247
|
+
findings.matchedFiles.push(kw);
|
|
248
|
+
} else {
|
|
249
|
+
findings.unmatchedKeywords.push(kw);
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
// Keyword
|
|
253
|
+
if (diffLower.includes(kw.toLowerCase())) {
|
|
254
|
+
findings.matchedKeywords.push(kw);
|
|
255
|
+
} else {
|
|
256
|
+
findings.unmatchedKeywords.push(kw);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Calculate relevance
|
|
262
|
+
const totalKeys = findings.matchedFiles.length + findings.matchedKeywords.length +
|
|
263
|
+
findings.unmatchedKeywords.length;
|
|
264
|
+
|
|
265
|
+
if (totalKeys === 0) {
|
|
266
|
+
findings.overallRelevance = 50; // No keywords found — cannot determine
|
|
267
|
+
findings.verdict = 'unknown';
|
|
268
|
+
findings.details = 'Cannot determine relevance: no keywords extracted from issue';
|
|
269
|
+
} else {
|
|
270
|
+
const matchedCount = findings.matchedFiles.length + findings.matchedKeywords.length;
|
|
271
|
+
findings.overallRelevance = Math.round((matchedCount / totalKeys) * 100);
|
|
272
|
+
|
|
273
|
+
if (findings.overallRelevance >= 70) {
|
|
274
|
+
findings.verdict = 'fully-addressed';
|
|
275
|
+
findings.details = `PR touches ${findings.matchedFiles.length} mentioned file(s) and ${findings.matchedKeywords.length} keyword(s)`;
|
|
276
|
+
} else if (findings.overallRelevance >= 30) {
|
|
277
|
+
findings.verdict = 'partially-addressed';
|
|
278
|
+
findings.details = `PR only partially addresses the issue: ${findings.unmatchedKeywords.length} keyword(s) missing`;
|
|
279
|
+
} else {
|
|
280
|
+
findings.verdict = 'unaddressed';
|
|
281
|
+
findings.details = `PR does not appear to address the issue: ${findings.unmatchedKeywords.length} keyword(s) not found in diff`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return findings;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Scan commit messages for references to other issues that may be related.
|
|
290
|
+
*/
|
|
291
|
+
function findRelatedIssues(diff, commitLog) {
|
|
292
|
+
if (!commitLog && !diff) return [];
|
|
293
|
+
|
|
294
|
+
const text = (commitLog || '') + '\n' + (diff || '');
|
|
295
|
+
const issueRefs = new Set();
|
|
296
|
+
|
|
297
|
+
// GitHub style: fixes #123, closes #123, resolves #123, ref #123
|
|
298
|
+
const ghPattern = /(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?|ref(?:erence)?|see|relate[sd]?(?:\s+to)?)\s+#(\d+)/gi;
|
|
299
|
+
let match;
|
|
300
|
+
while ((match = ghPattern.exec(text)) !== null) {
|
|
301
|
+
issueRefs.add(`#${match[1]}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// GitLab style: closes !123, relates to !123
|
|
305
|
+
const glPattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|ref(?:erence)?)\s+!(\d+)/gi;
|
|
306
|
+
while ((match = glPattern.exec(text)) !== null) {
|
|
307
|
+
issueRefs.add(`!${match[1]}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return [...issueRefs];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Generate an issue validation report.
|
|
315
|
+
*/
|
|
316
|
+
function generateIssueReport(issue, validation, relatedIssues, reviewResult) {
|
|
317
|
+
const verdictIcons = {
|
|
318
|
+
'fully-addressed': '✅',
|
|
319
|
+
'partially-addressed': '⚠️',
|
|
320
|
+
'unaddressed': '❌',
|
|
321
|
+
'unknown': '❓',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const report = {
|
|
325
|
+
issue: {
|
|
326
|
+
number: issue.number,
|
|
327
|
+
title: issue.title,
|
|
328
|
+
url: issue.html_url || issue.url,
|
|
329
|
+
state: issue.state,
|
|
330
|
+
labels: issue.labels || [],
|
|
331
|
+
assignees: issue.assignees || [],
|
|
332
|
+
},
|
|
333
|
+
validation,
|
|
334
|
+
relatedIssues: relatedIssues || [],
|
|
335
|
+
verdict: validation.verdict,
|
|
336
|
+
combinedScore: reviewResult?.score || 0,
|
|
337
|
+
issuesFound: (reviewResult?.issues || []).length,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
return report;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Format terminal output for issue validation.
|
|
345
|
+
*/
|
|
346
|
+
function formatIssueReport(report) {
|
|
347
|
+
const chalk = require('chalk');
|
|
348
|
+
const lines = [];
|
|
349
|
+
|
|
350
|
+
lines.push(chalk.bold('\n🔗 Issue Validation Report / Issue 验证报告'));
|
|
351
|
+
lines.push('━'.repeat(55));
|
|
352
|
+
|
|
353
|
+
// Issue Info
|
|
354
|
+
lines.push(chalk.bold(`\n📋 Issue #${report.issue.number}: ${report.issue.title}`));
|
|
355
|
+
if (report.issue.url) {
|
|
356
|
+
lines.push(chalk.gray(` ${report.issue.url}`));
|
|
357
|
+
}
|
|
358
|
+
lines.push(chalk.gray(` State: ${report.issue.state}`));
|
|
359
|
+
if (report.issue.labels.length > 0) {
|
|
360
|
+
lines.push(chalk.gray(` Labels: ${report.issue.labels.join(', ')}`));
|
|
361
|
+
}
|
|
362
|
+
if (report.issue.assignees.length > 0) {
|
|
363
|
+
lines.push(chalk.gray(` Assignees: ${report.issue.assignees.join(', ')}`));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Validation Result
|
|
367
|
+
const verdc = report.validation.verdict === 'fully-addressed' ? chalk.green :
|
|
368
|
+
report.validation.verdict === 'partially-addressed' ? chalk.yellow : chalk.red;
|
|
369
|
+
lines.push(chalk.bold(`\n🔄 Verdict / 判定: ${verdc(report.validation.verdict)}`));
|
|
370
|
+
lines.push(` Relevance / 关联度: ${report.validation.overallRelevance}%`);
|
|
371
|
+
lines.push(chalk.gray(` ${report.validation.details}`));
|
|
372
|
+
|
|
373
|
+
if (report.validation.matchedFiles.length > 0) {
|
|
374
|
+
lines.push(chalk.green(`\n 📁 Matched Files / 匹配文件 (${report.validation.matchedFiles.length}):`));
|
|
375
|
+
for (const f of report.validation.matchedFiles) {
|
|
376
|
+
lines.push(chalk.green(` ✔ ${f}`));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (report.validation.matchedKeywords.length > 0) {
|
|
381
|
+
lines.push(chalk.blue(`\n 🔑 Matched Keywords / 匹配关键词 (${report.validation.matchedKeywords.length}):`));
|
|
382
|
+
lines.push(chalk.blue(` ${report.validation.matchedKeywords.join(', ')}`));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (report.validation.unmatchedKeywords.length > 0) {
|
|
386
|
+
lines.push(chalk.yellow(`\n ⚠ Unmatched / 未匹配 (${report.validation.unmatchedKeywords.length}):`));
|
|
387
|
+
lines.push(chalk.yellow(` ${report.validation.unmatchedKeywords.join(', ')}`));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Related issues
|
|
391
|
+
if (report.relatedIssues.length > 0) {
|
|
392
|
+
lines.push(chalk.bold(`\n🔗 Related Issues / 关联 Issue (${report.relatedIssues.length}):`));
|
|
393
|
+
for (const ref of report.relatedIssues) {
|
|
394
|
+
lines.push(chalk.cyan(` → ${ref}`));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Code review summary
|
|
399
|
+
if (report.combinedScore > 0) {
|
|
400
|
+
const sc = report.combinedScore >= 80 ? chalk.green : report.combinedScore >= 50 ? chalk.yellow : chalk.red;
|
|
401
|
+
lines.push(chalk.bold(`\n📊 Code Review: ${sc(report.combinedScore + '/100')} (${report.issuesFound} issues)`));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
lines.push('\n' + '━'.repeat(55));
|
|
405
|
+
return lines.join('\n');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Parse commit log from a git repository for issue references.
|
|
410
|
+
*/
|
|
411
|
+
function parseCommitLog(repoPath, maxCommits = 20) {
|
|
412
|
+
try {
|
|
413
|
+
const { execSync } = require('child_process');
|
|
414
|
+
const log = execSync(`git log --oneline -${maxCommits}`, {
|
|
415
|
+
cwd: repoPath,
|
|
416
|
+
encoding: 'utf-8',
|
|
417
|
+
});
|
|
418
|
+
return log;
|
|
419
|
+
} catch {
|
|
420
|
+
return '';
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Validate an issue against a PR diff.
|
|
426
|
+
*
|
|
427
|
+
* @param {string} issueRef - Issue URL or reference string
|
|
428
|
+
* @param {string} diff - PR diff text
|
|
429
|
+
* @param {object} options
|
|
430
|
+
* @param {string} options.repoPath - Path to git repo (for commit log)
|
|
431
|
+
* @param {string} options.token - GitHub/GitLab token
|
|
432
|
+
* @param {object} options.reviewResult - Existing review result (from reviewDiff)
|
|
433
|
+
* @returns {Promise<{report: object, formatted: string}>}
|
|
434
|
+
*/
|
|
435
|
+
async function validateIssue(issueRef, diff, options = {}) {
|
|
436
|
+
const parsed = parseIssueRef(issueRef);
|
|
437
|
+
if (!parsed) {
|
|
438
|
+
throw new Error(`Cannot parse issue reference: "${issueRef}". Use format: https://github.com/owner/repo/issues/42 or owner/repo#42`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!parsed.owner || !parsed.repo) {
|
|
442
|
+
throw new Error(`Issue reference must include owner and repo. Use format: https://github.com/owner/repo/issues/42`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Fetch issue
|
|
446
|
+
const token = options.token || process.env.GITHUB_TOKEN || process.env.GITLAB_TOKEN;
|
|
447
|
+
let issue;
|
|
448
|
+
|
|
449
|
+
if (parsed.platform === 'github') {
|
|
450
|
+
issue = await fetchGitHubIssue(parsed.owner, parsed.repo, parsed.issueNumber, token);
|
|
451
|
+
// Fetch comments for richer context
|
|
452
|
+
try {
|
|
453
|
+
const comments = await fetchGitHubIssueComments(parsed.owner, parsed.repo, parsed.issueNumber, token);
|
|
454
|
+
if (comments.length > 0) {
|
|
455
|
+
issue.comments = comments;
|
|
456
|
+
issue.body = (issue.body || '') + '\n\n--- Comments ---\n' +
|
|
457
|
+
comments.map(c => `@${c.user}: ${c.body.slice(0, 300)}`).join('\n');
|
|
458
|
+
}
|
|
459
|
+
} catch {}
|
|
460
|
+
} else {
|
|
461
|
+
throw new Error('GitLab issue fetching not yet implemented. Use GitHub issue URLs.');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Validate issue against diff
|
|
465
|
+
const validation = validateIssueAgainstDiff(issue, diff, options);
|
|
466
|
+
|
|
467
|
+
// Find related issues from commit log
|
|
468
|
+
let commitLog = '';
|
|
469
|
+
let relatedIssues = [];
|
|
470
|
+
if (options.repoPath) {
|
|
471
|
+
commitLog = parseCommitLog(options.repoPath);
|
|
472
|
+
}
|
|
473
|
+
relatedIssues = findRelatedIssues(diff, commitLog)
|
|
474
|
+
.filter(ref => ref !== `#${issue.number}`); // Exclude the primary issue
|
|
475
|
+
|
|
476
|
+
// Check for issue references from timeline
|
|
477
|
+
try {
|
|
478
|
+
const refs = await fetchIssueReferences(parsed.owner, parsed.repo, parsed.issueNumber, token);
|
|
479
|
+
// If no related issues from diff, note that the issue has been cross-referenced elsewhere
|
|
480
|
+
} catch {}
|
|
481
|
+
|
|
482
|
+
const report = generateIssueReport(issue, validation, relatedIssues, options.reviewResult);
|
|
483
|
+
const formatted = formatIssueReport(report);
|
|
484
|
+
|
|
485
|
+
return { report, formatted, validation, issue };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
module.exports = {
|
|
489
|
+
parseIssueRef,
|
|
490
|
+
fetchGitHubIssue,
|
|
491
|
+
fetchGitHubIssueComments,
|
|
492
|
+
validateIssueAgainstDiff,
|
|
493
|
+
extractIssueKeywords,
|
|
494
|
+
findRelatedIssues,
|
|
495
|
+
generateIssueReport,
|
|
496
|
+
formatIssueReport,
|
|
497
|
+
parseCommitLog,
|
|
498
|
+
validateIssue,
|
|
499
|
+
};
|