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
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
- [快速上手](#快速上手)
|
|
21
21
|
- [命令详解](#命令详解)
|
|
22
22
|
- [coderev review(核心审查)](#coderev-review核心审查)
|
|
23
|
+
- [coderev index(代码库索引)🆕](#coderev-index代码库索引)
|
|
23
24
|
- [coderev fix(自动修复)](#coderev-fix自动修复)
|
|
24
25
|
- [coderev hook(Git Hooks)](#coderev-hookgit-hooks)
|
|
25
26
|
- [coderev stats(统计看板)](#coderev-stats统计看板)
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -43,6 +43,9 @@ program
|
|
|
43
43
|
.option('--min-confidence <number>', 'Minimum confidence threshold 0-100 (default: 60)', '60')
|
|
44
44
|
.option('--agents <list>', 'Comma-separated agent list: security,bugs,quality')
|
|
45
45
|
.option('--blame', 'Enable git blame context analysis to distinguish new vs pre-existing issues')
|
|
46
|
+
.option('--rag', 'Enable RAG codebase context retrieval (requires coderev index first)')
|
|
47
|
+
.option('--issue <url>', 'Validate that PR addresses a linked GitHub/GitLab issue')
|
|
48
|
+
.option('--verify-issue', 'Auto-verify PR addresses linked issues from commit messages')
|
|
46
49
|
.action(async (options) => {
|
|
47
50
|
try {
|
|
48
51
|
const config = loadConfig(options.config);
|
|
@@ -183,8 +186,35 @@ program
|
|
|
183
186
|
single: options.single || undefined,
|
|
184
187
|
minConfidence: parseInt(options.minConfidence) || undefined,
|
|
185
188
|
blame: options.blame || undefined,
|
|
189
|
+
rag: options.rag || undefined,
|
|
190
|
+
repoRoot: options.repo || options.config ? path.dirname(options.config) : process.cwd(),
|
|
186
191
|
});
|
|
187
192
|
|
|
193
|
+
// ── Issue Validation ──
|
|
194
|
+
if (options.issue) {
|
|
195
|
+
const { validateIssue } = require('./issue-validator');
|
|
196
|
+
try {
|
|
197
|
+
const issueResult = await validateIssue(options.issue, diff, {
|
|
198
|
+
token: options.githubToken || options.gitlabToken,
|
|
199
|
+
repoPath: options.repo || process.cwd(),
|
|
200
|
+
reviewResult: result,
|
|
201
|
+
});
|
|
202
|
+
// Append issue validation to result
|
|
203
|
+
result._issueValidation = issueResult.report;
|
|
204
|
+
result._issueValidationFormatted = issueResult.formatted;
|
|
205
|
+
} catch (err) {
|
|
206
|
+
result._issueValidation = { error: err.message };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (options.verifyIssue) {
|
|
211
|
+
const { findRelatedIssues, parseCommitLog } = require('./issue-validator');
|
|
212
|
+
const repoPath = options.repo || process.cwd();
|
|
213
|
+
const commitLog = parseCommitLog(repoPath);
|
|
214
|
+
const relatedIssues = findRelatedIssues(diff, commitLog);
|
|
215
|
+
result._relatedIssuesFound = relatedIssues;
|
|
216
|
+
}
|
|
217
|
+
|
|
188
218
|
let output;
|
|
189
219
|
if (options.output === 'json') {
|
|
190
220
|
output = JSON.stringify(result, null, 2);
|
|
@@ -325,6 +355,12 @@ program
|
|
|
325
355
|
return;
|
|
326
356
|
}
|
|
327
357
|
|
|
358
|
+
// ── Print issue validation report if available ──
|
|
359
|
+
if (result._issueValidationFormatted) {
|
|
360
|
+
// Print after main output (sent to console.error so it's visible even with piped output)
|
|
361
|
+
console.error(result._issueValidationFormatted);
|
|
362
|
+
}
|
|
363
|
+
|
|
328
364
|
// ── CI Mode ──
|
|
329
365
|
if (options.ci && (result.issues || []).length > 0) {
|
|
330
366
|
const errorIssues = result.issues.filter(i => i.type === 'error');
|
|
@@ -1187,6 +1223,45 @@ program
|
|
|
1187
1223
|
}
|
|
1188
1224
|
});
|
|
1189
1225
|
|
|
1226
|
+
program
|
|
1227
|
+
.command('index')
|
|
1228
|
+
.description('Build codebase index for RAG-powered code reviews')
|
|
1229
|
+
.option('-r, --repo <path>', 'Path to git repository', '.')
|
|
1230
|
+
.option('--max-files <number>', 'Maximum files to index (default: 500)', '500')
|
|
1231
|
+
.option('--json', 'Output as JSON')
|
|
1232
|
+
.action(async (options) => {
|
|
1233
|
+
try {
|
|
1234
|
+
const { buildIndex, loadIndex, INDEX_DIR } = require('./rag-indexer');
|
|
1235
|
+
const repoRoot = path.resolve(options.repo);
|
|
1236
|
+
|
|
1237
|
+
console.error(chalk.blue(`📚 Building codebase index for ${repoRoot}...`));
|
|
1238
|
+
console.error(chalk.gray(` Max files: ${options.maxFiles}`));
|
|
1239
|
+
|
|
1240
|
+
const index = buildIndex(repoRoot, { maxFiles: parseInt(options.maxFiles) });
|
|
1241
|
+
|
|
1242
|
+
if (options.json) {
|
|
1243
|
+
console.log(JSON.stringify(index.stats, null, 2));
|
|
1244
|
+
} else {
|
|
1245
|
+
console.log(chalk.green(`\n✅ Index built successfully!`));
|
|
1246
|
+
console.log(chalk.bold(`\n📊 Statistics:`));
|
|
1247
|
+
console.log(` Files scanned: ${index.stats.filesScanned}`);
|
|
1248
|
+
console.log(` Symbols extracted: ${index.stats.symbolsExtracted}`);
|
|
1249
|
+
console.log(` Time: ${index.stats.timeMs}ms`);
|
|
1250
|
+
console.log(` Stored at: ${path.join(repoRoot, INDEX_DIR)}`);
|
|
1251
|
+
if (Object.keys(index.stats.languageBreakdown).length > 0) {
|
|
1252
|
+
console.log(chalk.bold(`\n🔤 Language breakdown:`));
|
|
1253
|
+
for (const [lang, count] of Object.entries(index.stats.languageBreakdown).sort((a, b) => b[1] - a[1])) {
|
|
1254
|
+
console.log(` ${lang}: ${count} symbols`);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
console.log(chalk.blue(`\n💡 Tip: Run \`coderev review --rag\` to use codebase context in reviews.`));
|
|
1258
|
+
}
|
|
1259
|
+
} catch (err) {
|
|
1260
|
+
console.error(chalk.red(`✖ ${err.message}`));
|
|
1261
|
+
process.exit(1);
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1190
1265
|
program.parse(process.argv);
|
|
1191
1266
|
|
|
1192
1267
|
// ── Helpers ───────────────────────────────────────────────────
|
|
@@ -1286,6 +1361,10 @@ function formatTerminal(result) {
|
|
|
1286
1361
|
enLines.push(' ' + chalk.cyan(' Files analyzed: ') + bc.filesAnalyzed);
|
|
1287
1362
|
}
|
|
1288
1363
|
}
|
|
1364
|
+
if (result._rag) {
|
|
1365
|
+
enLines.push('\n' + chalk.bold('RAG Context:'));
|
|
1366
|
+
enLines.push(' ' + chalk.magenta(`Indexed ${result._rag.indexedSymbols} symbols from codebase`));
|
|
1367
|
+
}
|
|
1289
1368
|
enLines.push('\n' + '━'.repeat(50));
|
|
1290
1369
|
|
|
1291
1370
|
return cnLines.join('\n') + '\n' + enLines.join('\n');
|
|
@@ -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
|
+
};
|