coderev-cli 1.0.15 → 1.0.17
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 +542 -219
- package/package.json +1 -1
- package/src/blame.js +247 -0
- package/src/cli.js +202 -170
- package/src/config.js +86 -8
- package/src/reviewer.js +19 -0
package/package.json
CHANGED
package/src/blame.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git blame context analysis for coderev.
|
|
3
|
+
*
|
|
4
|
+
* Runs `git blame` on modified files to distinguish:
|
|
5
|
+
* - **New issues**: introduced in the current diff/commit
|
|
6
|
+
* - **Pre-existing issues**: already present before this change
|
|
7
|
+
*
|
|
8
|
+
* This helps reviewers focus on what's actually new vs. inherited debt.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { execSync } = require('child_process');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a unified diff into per-file line additions.
|
|
17
|
+
* @param {string} diff - The git diff text
|
|
18
|
+
* @returns {Array<{file: string, addedLines: number[]}>}
|
|
19
|
+
*/
|
|
20
|
+
function parseDiffAddedLines(diff) {
|
|
21
|
+
if (!diff || typeof diff !== 'string') return [];
|
|
22
|
+
|
|
23
|
+
const result = [];
|
|
24
|
+
const lines = diff.split('\n');
|
|
25
|
+
let currentFile = null;
|
|
26
|
+
let currentAdded = [];
|
|
27
|
+
let oldStart = 0, newStart = 0, oldCount = 0, newCount = 0;
|
|
28
|
+
let newLineOffset = 0;
|
|
29
|
+
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
// Detect file header
|
|
32
|
+
const fileMatch = line.match(/^\+\+\+ b\/(.*)/);
|
|
33
|
+
if (fileMatch) {
|
|
34
|
+
if (currentFile && currentAdded.length > 0) {
|
|
35
|
+
result.push({ file: currentFile, addedLines: [...new Set(currentAdded)].sort((a, b) => a - b) });
|
|
36
|
+
}
|
|
37
|
+
currentFile = fileMatch[1];
|
|
38
|
+
currentAdded = [];
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Chunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
43
|
+
const chunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
44
|
+
if (chunkMatch) {
|
|
45
|
+
oldStart = parseInt(chunkMatch[1], 10);
|
|
46
|
+
oldCount = chunkMatch[2] ? parseInt(chunkMatch[2], 10) : 1;
|
|
47
|
+
newStart = parseInt(chunkMatch[3], 10);
|
|
48
|
+
newCount = chunkMatch[4] ? parseInt(chunkMatch[4], 10) : 1;
|
|
49
|
+
newLineOffset = newStart - 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Track added lines (context or removed = old)
|
|
54
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
55
|
+
newLineOffset++;
|
|
56
|
+
currentAdded.push(newLineOffset);
|
|
57
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
58
|
+
// Removed lines don't advance new position
|
|
59
|
+
continue;
|
|
60
|
+
} else {
|
|
61
|
+
// Context line: advances both old and new
|
|
62
|
+
newLineOffset++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Push last file
|
|
67
|
+
if (currentFile && currentAdded.length > 0) {
|
|
68
|
+
result.push({ file: currentFile, addedLines: [...new Set(currentAdded)].sort((a, b) => a - b) });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run `git blame` on a file for specific line numbers.
|
|
76
|
+
* @param {string} filePath - Path relative to git repo root
|
|
77
|
+
* @param {number[]} lineNumbers - Array of line numbers to blame
|
|
78
|
+
* @param {string} [repoPath] - Path to git repo (default: cwd)
|
|
79
|
+
* @returns {Promise<object>} Map of lineNumber -> { author, commit, date, isNew }
|
|
80
|
+
*/
|
|
81
|
+
function blameLines(filePath, lineNumbers, repoPath) {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
if (!lineNumbers || lineNumbers.length === 0) {
|
|
84
|
+
resolve({});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cwd = repoPath || process.cwd();
|
|
89
|
+
const absPath = path.resolve(cwd, filePath);
|
|
90
|
+
|
|
91
|
+
if (!fs.existsSync(absPath)) {
|
|
92
|
+
resolve({});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Run git blame for specific lines, porcelain format
|
|
98
|
+
const lineArgs = lineNumbers.map(n => `-L ${n},${n}`).join(' ');
|
|
99
|
+
const cmd = `git blame --porcelain ${lineArgs} -- "${filePath}"`;
|
|
100
|
+
|
|
101
|
+
const stdout = execSync(cmd, {
|
|
102
|
+
cwd,
|
|
103
|
+
encoding: 'utf-8',
|
|
104
|
+
timeout: 10000,
|
|
105
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
106
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const result = {};
|
|
110
|
+
|
|
111
|
+
// Parse porcelain format
|
|
112
|
+
const porcelainLines = stdout.split('\n');
|
|
113
|
+
let i = 0;
|
|
114
|
+
|
|
115
|
+
while (i < porcelainLines.length) {
|
|
116
|
+
const line = porcelainLines[i];
|
|
117
|
+
if (!line.trim()) { i++; continue; }
|
|
118
|
+
|
|
119
|
+
// Header line: commit-hash author-line file-line (or not)
|
|
120
|
+
const headerMatch = line.match(/^([a-f0-9]+)\s+(\d+)\s+(\d+)\s+(\d+)$/);
|
|
121
|
+
if (!headerMatch) { i++; continue; }
|
|
122
|
+
|
|
123
|
+
const commitHash = headerMatch[1];
|
|
124
|
+
const origLine = parseInt(headerMatch[2], 10);
|
|
125
|
+
const finalLine = parseInt(headerMatch[3], 10);
|
|
126
|
+
const numLines = parseInt(headerMatch[4], 10);
|
|
127
|
+
|
|
128
|
+
// Skip boundary (not committed yet)
|
|
129
|
+
if (commitHash === '0000000000000000000000000000000000000000') {
|
|
130
|
+
result[finalLine] = { commit: commitHash, author: '(uncommitted)', date: new Date(), isNew: true };
|
|
131
|
+
// Skip ahead the content lines
|
|
132
|
+
i += numLines + 1;
|
|
133
|
+
while (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) { i++; }
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Read header fields until content
|
|
138
|
+
let author = '(unknown)';
|
|
139
|
+
let date = new Date(0);
|
|
140
|
+
let isNew = false;
|
|
141
|
+
|
|
142
|
+
i++;
|
|
143
|
+
while (i < porcelainLines.length && !porcelainLines[i].startsWith('\t')) {
|
|
144
|
+
const hdr = porcelainLines[i];
|
|
145
|
+
if (hdr.startsWith('author ')) {
|
|
146
|
+
author = hdr.slice(7);
|
|
147
|
+
} else if (hdr.startsWith('author-time ')) {
|
|
148
|
+
date = new Date(parseInt(hdr.slice(12), 10) * 1000);
|
|
149
|
+
} else if (hdr.startsWith('boundary')) {
|
|
150
|
+
// Boundary commit = root of history
|
|
151
|
+
}
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Line content (starts with \t)
|
|
156
|
+
if (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) {
|
|
157
|
+
// Content line - skip
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
result[finalLine] = { commit: commitHash, author, date, isNew };
|
|
161
|
+
|
|
162
|
+
// Jump to next header (skip content lines)
|
|
163
|
+
while (i < porcelainLines.length && porcelainLines[i].startsWith('\t')) { i++; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
resolve(result);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// git blame failed (file not tracked, etc.)
|
|
169
|
+
resolve({});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Analyze a diff with git blame to classify issues.
|
|
176
|
+
* @param {string} diff - The git diff
|
|
177
|
+
* @param {object} [options] - Options
|
|
178
|
+
* @param {string} [options.repoPath] - Git repo path
|
|
179
|
+
* @returns {Promise<{fileContexts: Array}>}
|
|
180
|
+
*/
|
|
181
|
+
async function analyzeDiffContext(diff, options = {}) {
|
|
182
|
+
const files = parseDiffAddedLines(diff);
|
|
183
|
+
const fileContexts = [];
|
|
184
|
+
|
|
185
|
+
for (const fileEntry of files) {
|
|
186
|
+
const { file, addedLines } = fileEntry;
|
|
187
|
+
if (addedLines.length === 0) continue;
|
|
188
|
+
|
|
189
|
+
const blameMap = await blameLines(file, addedLines, options.repoPath);
|
|
190
|
+
const newLines = [];
|
|
191
|
+
const existingLines = [];
|
|
192
|
+
|
|
193
|
+
for (const lineNum of addedLines) {
|
|
194
|
+
if (blameMap[lineNum] && blameMap[lineNum].isNew) {
|
|
195
|
+
newLines.push(lineNum);
|
|
196
|
+
} else {
|
|
197
|
+
existingLines.push(lineNum);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
fileContexts.push({
|
|
202
|
+
file,
|
|
203
|
+
totalNewLines: addedLines.length,
|
|
204
|
+
newLines,
|
|
205
|
+
existingLines,
|
|
206
|
+
existingCount: existingLines.length,
|
|
207
|
+
newCount: newLines.length,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return fileContexts;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Tag issues with blame context (new vs pre-existing).
|
|
216
|
+
* @param {Array} issues - List of review issues
|
|
217
|
+
* @param {Array} fileContexts - Output from analyzeDiffContext
|
|
218
|
+
* @returns {Array} Tagged issues with `isNew` field
|
|
219
|
+
*/
|
|
220
|
+
function tagIssuesWithBlame(issues, fileContexts) {
|
|
221
|
+
if (!issues || !fileContexts) return issues || [];
|
|
222
|
+
|
|
223
|
+
const lineMap = {};
|
|
224
|
+
for (const ctx of fileContexts) {
|
|
225
|
+
for (const ln of ctx.newLines) {
|
|
226
|
+
lineMap[`${ctx.file}:${ln}`] = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return issues.map(issue => {
|
|
231
|
+
if (!issue.file || !issue.line) {
|
|
232
|
+
return { ...issue, isNew: null }; // Can't determine
|
|
233
|
+
}
|
|
234
|
+
const key = `${issue.file}:${issue.line}`;
|
|
235
|
+
return {
|
|
236
|
+
...issue,
|
|
237
|
+
isNew: lineMap[key] || false,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = {
|
|
243
|
+
parseDiffAddedLines,
|
|
244
|
+
blameLines,
|
|
245
|
+
analyzeDiffContext,
|
|
246
|
+
tagIssuesWithBlame,
|
|
247
|
+
};
|