tlc-claude-code 1.0.0 → 1.1.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
CHANGED
|
@@ -290,10 +290,91 @@ Commands install to `.claude/commands/tlc/`
|
|
|
290
290
|
|
|
291
291
|
---
|
|
292
292
|
|
|
293
|
+
## VPS Deployment
|
|
294
|
+
|
|
295
|
+
Deploy TLC server for your team on any VPS.
|
|
296
|
+
|
|
297
|
+
### Quick Setup (Ubuntu)
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
curl -fsSL https://raw.githubusercontent.com/jurgencalleja/TLC/main/scripts/vps-setup.sh | bash
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### What You Get
|
|
304
|
+
|
|
305
|
+
| URL | Service |
|
|
306
|
+
|-----|---------|
|
|
307
|
+
| `https://dashboard.project.com` | TLC Dashboard with auth |
|
|
308
|
+
| `https://main.project.com` | Main branch deployment |
|
|
309
|
+
| `https://feat-x.project.com` | Feature branch deployment |
|
|
310
|
+
|
|
311
|
+
### Requirements
|
|
312
|
+
|
|
313
|
+
- Ubuntu 22.04+ VPS (2GB+ RAM)
|
|
314
|
+
- Domain with wildcard DNS (`*.project.com → VPS_IP`)
|
|
315
|
+
- GitHub/GitLab repo access
|
|
316
|
+
|
|
317
|
+
### Manual Setup
|
|
318
|
+
|
|
319
|
+
1. **Install dependencies**
|
|
320
|
+
```bash
|
|
321
|
+
apt install docker.io nginx certbot nodejs npm postgresql
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
2. **Clone and configure**
|
|
325
|
+
```bash
|
|
326
|
+
git clone https://github.com/jurgencalleja/TLC.git /opt/tlc
|
|
327
|
+
cd /opt/tlc && npm install
|
|
328
|
+
cp .env.example .env # Edit with your settings
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
3. **Setup nginx + SSL**
|
|
332
|
+
```bash
|
|
333
|
+
certbot --nginx -d "*.project.com" -d "dashboard.project.com"
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
4. **Start server**
|
|
337
|
+
```bash
|
|
338
|
+
systemctl enable tlc && systemctl start tlc
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
5. **Configure webhook** in GitHub/GitLab repo settings
|
|
342
|
+
|
|
343
|
+
[**Full VPS Guide →**](docs/vps-deployment.md)
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Kubernetes Deployment
|
|
348
|
+
|
|
349
|
+
For teams using Kubernetes:
|
|
350
|
+
|
|
351
|
+
```bash
|
|
352
|
+
# Add Helm repo
|
|
353
|
+
helm repo add tlc https://jurgencalleja.github.io/TLC/charts
|
|
354
|
+
|
|
355
|
+
# Install
|
|
356
|
+
helm install tlc tlc/tlc-server \
|
|
357
|
+
--set domain=project.example.com \
|
|
358
|
+
--set slack.webhookUrl=https://hooks.slack.com/...
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Kubernetes Features
|
|
362
|
+
|
|
363
|
+
- **Auto-scaling** branch deployments per namespace
|
|
364
|
+
- **Ingress** with wildcard TLS
|
|
365
|
+
- **Persistent volumes** for deployment state
|
|
366
|
+
- **ConfigMaps** for environment config
|
|
367
|
+
|
|
368
|
+
[**Full K8s Guide →**](docs/kubernetes-deployment.md)
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
293
372
|
## Documentation
|
|
294
373
|
|
|
295
374
|
- **[Help / All Commands](help.md)** — Complete command reference
|
|
296
375
|
- **[Team Workflow](docs/team-workflow.md)** — Guide for teams (engineers + PO + QA)
|
|
376
|
+
- **[VPS Deployment](docs/vps-deployment.md)** — Deploy on Ubuntu VPS
|
|
377
|
+
- **[Kubernetes Deployment](docs/kubernetes-deployment.md)** — Deploy on K8s
|
|
297
378
|
|
|
298
379
|
---
|
|
299
380
|
|
package/package.json
CHANGED
|
@@ -31,7 +31,7 @@ function parseOverdriveArgs(args = '') {
|
|
|
31
31
|
if (/^\d+$/.test(part)) {
|
|
32
32
|
options.phase = parseInt(part, 10);
|
|
33
33
|
} else if (part === '--agents' && parts[i + 1]) {
|
|
34
|
-
options.agents = Math.min(parseInt(parts[++i], 10),
|
|
34
|
+
options.agents = Math.min(parseInt(parts[++i], 10), 10); // Max 10 agents
|
|
35
35
|
} else if (part === '--mode' && parts[i + 1]) {
|
|
36
36
|
options.mode = parts[++i];
|
|
37
37
|
} else if (part === '--dry-run') {
|
|
@@ -33,9 +33,9 @@ describe('overdrive-command', () => {
|
|
|
33
33
|
expect(options.agents).toBe(4);
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it('caps agents at
|
|
37
|
-
const options = parseOverdriveArgs('--agents
|
|
38
|
-
expect(options.agents).toBe(
|
|
36
|
+
it('caps agents at 10', () => {
|
|
37
|
+
const options = parseOverdriveArgs('--agents 15');
|
|
38
|
+
expect(options.agents).toBe(10);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it('parses --mode flag', () => {
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR Reviewer Module
|
|
3
|
+
* Automatically reviews code changes for TLC compliance
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get changed files between two refs
|
|
12
|
+
* @param {string} base - Base ref (e.g., 'main')
|
|
13
|
+
* @param {string} head - Head ref (e.g., 'HEAD')
|
|
14
|
+
* @param {string} cwd - Working directory
|
|
15
|
+
* @returns {Array} List of changed files with status
|
|
16
|
+
*/
|
|
17
|
+
function getChangedFiles(base = 'main', head = 'HEAD', cwd = process.cwd()) {
|
|
18
|
+
try {
|
|
19
|
+
const output = execSync(`git diff --name-status ${base}...${head}`, {
|
|
20
|
+
cwd,
|
|
21
|
+
encoding: 'utf-8',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return output
|
|
25
|
+
.trim()
|
|
26
|
+
.split('\n')
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.map(line => {
|
|
29
|
+
const [status, ...fileParts] = line.split('\t');
|
|
30
|
+
return {
|
|
31
|
+
status: status.trim(),
|
|
32
|
+
file: fileParts.join('\t').trim(),
|
|
33
|
+
isTest: isTestFile(fileParts.join('\t').trim()),
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if a file is a test file
|
|
43
|
+
* @param {string} filePath - File path
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function isTestFile(filePath) {
|
|
47
|
+
const testPatterns = [
|
|
48
|
+
/\.test\.[jt]sx?$/,
|
|
49
|
+
/\.spec\.[jt]sx?$/,
|
|
50
|
+
/test_.*\.py$/,
|
|
51
|
+
/_test\.py$/,
|
|
52
|
+
/_test\.go$/,
|
|
53
|
+
/\.test\.go$/,
|
|
54
|
+
/spec\/.*_spec\.rb$/,
|
|
55
|
+
/__tests__\//,
|
|
56
|
+
/tests?\//i,
|
|
57
|
+
];
|
|
58
|
+
return testPatterns.some(p => p.test(filePath));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if implementation file has corresponding test
|
|
63
|
+
* @param {string} implFile - Implementation file path
|
|
64
|
+
* @param {Array} allFiles - All changed files
|
|
65
|
+
* @param {string} cwd - Working directory
|
|
66
|
+
* @returns {Object} Test coverage info
|
|
67
|
+
*/
|
|
68
|
+
function findTestForFile(implFile, allFiles, cwd = process.cwd()) {
|
|
69
|
+
// Skip if it's already a test file
|
|
70
|
+
if (isTestFile(implFile)) {
|
|
71
|
+
return { hasTest: true, isTestFile: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Skip non-code files
|
|
75
|
+
const codeExtensions = ['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.rb'];
|
|
76
|
+
const ext = path.extname(implFile);
|
|
77
|
+
if (!codeExtensions.includes(ext)) {
|
|
78
|
+
return { hasTest: true, skipped: true, reason: 'non-code file' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Generate possible test file names
|
|
82
|
+
const baseName = path.basename(implFile, ext);
|
|
83
|
+
const dirName = path.dirname(implFile);
|
|
84
|
+
|
|
85
|
+
const possibleTestFiles = [
|
|
86
|
+
// Same directory patterns
|
|
87
|
+
`${dirName}/${baseName}.test${ext}`,
|
|
88
|
+
`${dirName}/${baseName}.spec${ext}`,
|
|
89
|
+
`${dirName}/__tests__/${baseName}.test${ext}`,
|
|
90
|
+
`${dirName}/__tests__/${baseName}${ext}`,
|
|
91
|
+
// Test directory patterns
|
|
92
|
+
`test/${dirName}/${baseName}.test${ext}`,
|
|
93
|
+
`tests/${dirName}/${baseName}.test${ext}`,
|
|
94
|
+
`test/${baseName}.test${ext}`,
|
|
95
|
+
`tests/${baseName}.test${ext}`,
|
|
96
|
+
// Python patterns
|
|
97
|
+
`${dirName}/test_${baseName}.py`,
|
|
98
|
+
`tests/test_${baseName}.py`,
|
|
99
|
+
// Go patterns
|
|
100
|
+
`${dirName}/${baseName}_test.go`,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Check if any test file exists in changed files
|
|
104
|
+
const changedTestFile = allFiles.find(
|
|
105
|
+
f => f.isTest && possibleTestFiles.some(p => f.file.includes(baseName))
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (changedTestFile) {
|
|
109
|
+
return { hasTest: true, testFile: changedTestFile.file, inChangeset: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if test file exists on disk
|
|
113
|
+
for (const testFile of possibleTestFiles) {
|
|
114
|
+
const fullPath = path.join(cwd, testFile);
|
|
115
|
+
if (fs.existsSync(fullPath)) {
|
|
116
|
+
return { hasTest: true, testFile, existsOnDisk: true };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { hasTest: false, searchedPatterns: possibleTestFiles.slice(0, 3) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Analyze commit order to verify test-first development
|
|
125
|
+
* @param {string} base - Base ref
|
|
126
|
+
* @param {string} head - Head ref
|
|
127
|
+
* @param {string} cwd - Working directory
|
|
128
|
+
* @returns {Object} Commit order analysis
|
|
129
|
+
*/
|
|
130
|
+
function analyzeCommitOrder(base = 'main', head = 'HEAD', cwd = process.cwd()) {
|
|
131
|
+
try {
|
|
132
|
+
const output = execSync(
|
|
133
|
+
`git log --oneline --name-status ${base}..${head}`,
|
|
134
|
+
{ cwd, encoding: 'utf-8' }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const commits = [];
|
|
138
|
+
let currentCommit = null;
|
|
139
|
+
|
|
140
|
+
for (const line of output.split('\n')) {
|
|
141
|
+
if (/^[a-f0-9]{7,}/.test(line)) {
|
|
142
|
+
if (currentCommit) commits.push(currentCommit);
|
|
143
|
+
const [hash, ...msgParts] = line.split(' ');
|
|
144
|
+
currentCommit = {
|
|
145
|
+
hash,
|
|
146
|
+
message: msgParts.join(' '),
|
|
147
|
+
files: [],
|
|
148
|
+
hasTests: false,
|
|
149
|
+
hasImpl: false,
|
|
150
|
+
};
|
|
151
|
+
} else if (currentCommit && line.trim()) {
|
|
152
|
+
const [status, file] = line.split('\t');
|
|
153
|
+
if (file) {
|
|
154
|
+
const isTest = isTestFile(file);
|
|
155
|
+
currentCommit.files.push({ status, file, isTest });
|
|
156
|
+
if (isTest) currentCommit.hasTests = true;
|
|
157
|
+
else currentCommit.hasImpl = true;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (currentCommit) commits.push(currentCommit);
|
|
162
|
+
|
|
163
|
+
// Analyze TDD compliance
|
|
164
|
+
const analysis = {
|
|
165
|
+
commits: commits.length,
|
|
166
|
+
testFirstCommits: 0,
|
|
167
|
+
implOnlyCommits: 0,
|
|
168
|
+
mixedCommits: 0,
|
|
169
|
+
violations: [],
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
for (const commit of commits) {
|
|
173
|
+
if (commit.hasTests && !commit.hasImpl) {
|
|
174
|
+
analysis.testFirstCommits++;
|
|
175
|
+
} else if (commit.hasImpl && !commit.hasTests) {
|
|
176
|
+
analysis.implOnlyCommits++;
|
|
177
|
+
// Check if it's a fix/refactor (acceptable)
|
|
178
|
+
const isFixOrRefactor = /^(fix|refactor|chore|docs|style):/i.test(commit.message);
|
|
179
|
+
if (!isFixOrRefactor) {
|
|
180
|
+
analysis.violations.push({
|
|
181
|
+
commit: commit.hash,
|
|
182
|
+
message: commit.message,
|
|
183
|
+
reason: 'Implementation without tests',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
} else if (commit.hasTests && commit.hasImpl) {
|
|
187
|
+
analysis.mixedCommits++;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
analysis.tddScore = commits.length > 0
|
|
192
|
+
? Math.round((analysis.testFirstCommits / commits.length) * 100)
|
|
193
|
+
: 100;
|
|
194
|
+
|
|
195
|
+
return analysis;
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return { error: e.message };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check for common security issues in diff
|
|
203
|
+
* @param {string} base - Base ref
|
|
204
|
+
* @param {string} head - Head ref
|
|
205
|
+
* @param {string} cwd - Working directory
|
|
206
|
+
* @returns {Array} Security issues found
|
|
207
|
+
*/
|
|
208
|
+
function checkSecurityIssues(base = 'main', head = 'HEAD', cwd = process.cwd()) {
|
|
209
|
+
const issues = [];
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const diff = execSync(`git diff ${base}...${head}`, {
|
|
213
|
+
cwd,
|
|
214
|
+
encoding: 'utf-8',
|
|
215
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const patterns = [
|
|
219
|
+
{ pattern: /password\s*=\s*['"][^'"]+['"]/gi, type: 'hardcoded-password', severity: 'high' },
|
|
220
|
+
{ pattern: /api[_-]?key\s*=\s*['"][^'"]+['"]/gi, type: 'hardcoded-api-key', severity: 'high' },
|
|
221
|
+
{ pattern: /secret\s*=\s*['"][^'"]+['"]/gi, type: 'hardcoded-secret', severity: 'high' },
|
|
222
|
+
{ pattern: /eval\s*\(/g, type: 'eval-usage', severity: 'medium' },
|
|
223
|
+
{ pattern: /innerHTML\s*=/g, type: 'innerhtml-xss', severity: 'medium' },
|
|
224
|
+
{ pattern: /dangerouslySetInnerHTML/g, type: 'react-xss', severity: 'medium' },
|
|
225
|
+
{ pattern: /exec\s*\(\s*[`'"]/g, type: 'command-injection', severity: 'high' },
|
|
226
|
+
{ pattern: /SELECT.*FROM.*WHERE.*\+/gi, type: 'sql-injection', severity: 'high' },
|
|
227
|
+
{ pattern: /\.env(?:\.local)?$/gm, type: 'env-file-committed', severity: 'high' },
|
|
228
|
+
{ pattern: /console\.log\(/g, type: 'console-log', severity: 'low' },
|
|
229
|
+
{ pattern: /TODO|FIXME|HACK|XXX/g, type: 'todo-comment', severity: 'info' },
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
// Only check added lines
|
|
233
|
+
const addedLines = diff
|
|
234
|
+
.split('\n')
|
|
235
|
+
.filter(line => line.startsWith('+') && !line.startsWith('+++'));
|
|
236
|
+
|
|
237
|
+
for (const { pattern, type, severity } of patterns) {
|
|
238
|
+
for (const line of addedLines) {
|
|
239
|
+
if (pattern.test(line)) {
|
|
240
|
+
issues.push({
|
|
241
|
+
type,
|
|
242
|
+
severity,
|
|
243
|
+
line: line.slice(1).trim().slice(0, 100),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} catch (e) {
|
|
249
|
+
// Ignore diff errors
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return issues;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Generate review report
|
|
257
|
+
* @param {Object} options - Review options
|
|
258
|
+
* @returns {Object} Review report
|
|
259
|
+
*/
|
|
260
|
+
function generateReview(options = {}) {
|
|
261
|
+
const {
|
|
262
|
+
base = 'main',
|
|
263
|
+
head = 'HEAD',
|
|
264
|
+
cwd = process.cwd(),
|
|
265
|
+
prNumber = null,
|
|
266
|
+
} = options;
|
|
267
|
+
|
|
268
|
+
const report = {
|
|
269
|
+
timestamp: new Date().toISOString(),
|
|
270
|
+
base,
|
|
271
|
+
head,
|
|
272
|
+
prNumber,
|
|
273
|
+
passed: true,
|
|
274
|
+
summary: [],
|
|
275
|
+
details: {},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// 1. Get changed files
|
|
279
|
+
const changedFiles = getChangedFiles(base, head, cwd);
|
|
280
|
+
report.details.changedFiles = changedFiles;
|
|
281
|
+
report.details.fileCount = changedFiles.length;
|
|
282
|
+
|
|
283
|
+
// 2. Check test coverage for changed files
|
|
284
|
+
const coverageIssues = [];
|
|
285
|
+
const implFiles = changedFiles.filter(f => !f.isTest && f.status !== 'D');
|
|
286
|
+
|
|
287
|
+
for (const file of implFiles) {
|
|
288
|
+
const testInfo = findTestForFile(file.file, changedFiles, cwd);
|
|
289
|
+
if (!testInfo.hasTest) {
|
|
290
|
+
coverageIssues.push({
|
|
291
|
+
file: file.file,
|
|
292
|
+
issue: 'No test file found',
|
|
293
|
+
suggestions: testInfo.searchedPatterns,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
report.details.coverage = {
|
|
299
|
+
implFiles: implFiles.length,
|
|
300
|
+
testFiles: changedFiles.filter(f => f.isTest).length,
|
|
301
|
+
missingTests: coverageIssues.length,
|
|
302
|
+
issues: coverageIssues,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
if (coverageIssues.length > 0) {
|
|
306
|
+
report.passed = false;
|
|
307
|
+
report.summary.push(`❌ ${coverageIssues.length} files missing tests`);
|
|
308
|
+
} else {
|
|
309
|
+
report.summary.push(`✅ All changed files have tests`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 3. Analyze commit order (TDD compliance)
|
|
313
|
+
const commitAnalysis = analyzeCommitOrder(base, head, cwd);
|
|
314
|
+
report.details.commits = commitAnalysis;
|
|
315
|
+
|
|
316
|
+
if (commitAnalysis.tddScore !== undefined) {
|
|
317
|
+
if (commitAnalysis.tddScore < 50 && commitAnalysis.commits > 2) {
|
|
318
|
+
report.passed = false;
|
|
319
|
+
report.summary.push(`❌ TDD score: ${commitAnalysis.tddScore}% (target: 50%+)`);
|
|
320
|
+
} else {
|
|
321
|
+
report.summary.push(`✅ TDD score: ${commitAnalysis.tddScore}%`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (commitAnalysis.violations.length > 0) {
|
|
325
|
+
report.summary.push(`⚠️ ${commitAnalysis.violations.length} commits without tests`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// 4. Security check
|
|
330
|
+
const securityIssues = checkSecurityIssues(base, head, cwd);
|
|
331
|
+
report.details.security = securityIssues;
|
|
332
|
+
|
|
333
|
+
const highSeverity = securityIssues.filter(i => i.severity === 'high');
|
|
334
|
+
const mediumSeverity = securityIssues.filter(i => i.severity === 'medium');
|
|
335
|
+
|
|
336
|
+
if (highSeverity.length > 0) {
|
|
337
|
+
report.passed = false;
|
|
338
|
+
report.summary.push(`❌ ${highSeverity.length} high severity security issues`);
|
|
339
|
+
}
|
|
340
|
+
if (mediumSeverity.length > 0) {
|
|
341
|
+
report.summary.push(`⚠️ ${mediumSeverity.length} medium severity security issues`);
|
|
342
|
+
}
|
|
343
|
+
if (highSeverity.length === 0 && mediumSeverity.length === 0) {
|
|
344
|
+
report.summary.push(`✅ No security issues detected`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// 5. Overall verdict
|
|
348
|
+
report.verdict = report.passed ? 'APPROVED' : 'CHANGES_REQUESTED';
|
|
349
|
+
|
|
350
|
+
return report;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Format review report as markdown
|
|
355
|
+
* @param {Object} report - Review report
|
|
356
|
+
* @returns {string} Markdown formatted report
|
|
357
|
+
*/
|
|
358
|
+
function formatReviewMarkdown(report) {
|
|
359
|
+
const lines = [];
|
|
360
|
+
|
|
361
|
+
lines.push('# Code Review Report');
|
|
362
|
+
lines.push('');
|
|
363
|
+
lines.push(`**Date:** ${report.timestamp}`);
|
|
364
|
+
lines.push(`**Base:** ${report.base} → **Head:** ${report.head}`);
|
|
365
|
+
if (report.prNumber) {
|
|
366
|
+
lines.push(`**PR:** #${report.prNumber}`);
|
|
367
|
+
}
|
|
368
|
+
lines.push('');
|
|
369
|
+
|
|
370
|
+
// Verdict
|
|
371
|
+
const verdictEmoji = report.passed ? '✅' : '❌';
|
|
372
|
+
lines.push(`## ${verdictEmoji} Verdict: ${report.verdict}`);
|
|
373
|
+
lines.push('');
|
|
374
|
+
|
|
375
|
+
// Summary
|
|
376
|
+
lines.push('## Summary');
|
|
377
|
+
lines.push('');
|
|
378
|
+
for (const item of report.summary) {
|
|
379
|
+
lines.push(`- ${item}`);
|
|
380
|
+
}
|
|
381
|
+
lines.push('');
|
|
382
|
+
|
|
383
|
+
// Coverage details
|
|
384
|
+
if (report.details.coverage.missingTests > 0) {
|
|
385
|
+
lines.push('## Missing Tests');
|
|
386
|
+
lines.push('');
|
|
387
|
+
lines.push('| File | Suggested Test Location |');
|
|
388
|
+
lines.push('|------|------------------------|');
|
|
389
|
+
for (const issue of report.details.coverage.issues) {
|
|
390
|
+
const suggestion = issue.suggestions?.[0] || 'N/A';
|
|
391
|
+
lines.push(`| \`${issue.file}\` | \`${suggestion}\` |`);
|
|
392
|
+
}
|
|
393
|
+
lines.push('');
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// TDD violations
|
|
397
|
+
if (report.details.commits.violations?.length > 0) {
|
|
398
|
+
lines.push('## TDD Violations');
|
|
399
|
+
lines.push('');
|
|
400
|
+
lines.push('| Commit | Message | Issue |');
|
|
401
|
+
lines.push('|--------|---------|-------|');
|
|
402
|
+
for (const v of report.details.commits.violations) {
|
|
403
|
+
lines.push(`| \`${v.commit}\` | ${v.message.slice(0, 40)} | ${v.reason} |`);
|
|
404
|
+
}
|
|
405
|
+
lines.push('');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Security issues
|
|
409
|
+
const importantSecurity = report.details.security.filter(
|
|
410
|
+
i => i.severity === 'high' || i.severity === 'medium'
|
|
411
|
+
);
|
|
412
|
+
if (importantSecurity.length > 0) {
|
|
413
|
+
lines.push('## Security Issues');
|
|
414
|
+
lines.push('');
|
|
415
|
+
lines.push('| Severity | Type | Sample |');
|
|
416
|
+
lines.push('|----------|------|--------|');
|
|
417
|
+
for (const issue of importantSecurity) {
|
|
418
|
+
lines.push(`| ${issue.severity.toUpperCase()} | ${issue.type} | \`${issue.line.slice(0, 50)}\` |`);
|
|
419
|
+
}
|
|
420
|
+
lines.push('');
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Stats
|
|
424
|
+
lines.push('## Statistics');
|
|
425
|
+
lines.push('');
|
|
426
|
+
lines.push(`- Files changed: ${report.details.fileCount}`);
|
|
427
|
+
lines.push(`- Implementation files: ${report.details.coverage.implFiles}`);
|
|
428
|
+
lines.push(`- Test files: ${report.details.coverage.testFiles}`);
|
|
429
|
+
if (report.details.commits.commits) {
|
|
430
|
+
lines.push(`- Commits: ${report.details.commits.commits}`);
|
|
431
|
+
lines.push(`- TDD Score: ${report.details.commits.tddScore}%`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return lines.join('\n');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Run review and return result
|
|
439
|
+
* @param {Object} options - Review options
|
|
440
|
+
* @returns {Object} Review result with report and formatted output
|
|
441
|
+
*/
|
|
442
|
+
function runReview(options = {}) {
|
|
443
|
+
const report = generateReview(options);
|
|
444
|
+
const markdown = formatReviewMarkdown(report);
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
report,
|
|
448
|
+
markdown,
|
|
449
|
+
passed: report.passed,
|
|
450
|
+
verdict: report.verdict,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
module.exports = {
|
|
455
|
+
getChangedFiles,
|
|
456
|
+
isTestFile,
|
|
457
|
+
findTestForFile,
|
|
458
|
+
analyzeCommitOrder,
|
|
459
|
+
checkSecurityIssues,
|
|
460
|
+
generateReview,
|
|
461
|
+
formatReviewMarkdown,
|
|
462
|
+
runReview,
|
|
463
|
+
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isTestFile,
|
|
4
|
+
formatReviewMarkdown,
|
|
5
|
+
} from './pr-reviewer.js';
|
|
6
|
+
|
|
7
|
+
describe('pr-reviewer', () => {
|
|
8
|
+
describe('isTestFile', () => {
|
|
9
|
+
it('identifies JavaScript test files', () => {
|
|
10
|
+
expect(isTestFile('src/auth.test.js')).toBe(true);
|
|
11
|
+
expect(isTestFile('src/auth.spec.js')).toBe(true);
|
|
12
|
+
expect(isTestFile('src/__tests__/auth.js')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('identifies TypeScript test files', () => {
|
|
16
|
+
expect(isTestFile('src/auth.test.ts')).toBe(true);
|
|
17
|
+
expect(isTestFile('src/auth.spec.tsx')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('identifies Python test files', () => {
|
|
21
|
+
expect(isTestFile('test_auth.py')).toBe(true);
|
|
22
|
+
expect(isTestFile('auth_test.py')).toBe(true);
|
|
23
|
+
expect(isTestFile('tests/test_login.py')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('identifies Go test files', () => {
|
|
27
|
+
expect(isTestFile('auth_test.go')).toBe(true);
|
|
28
|
+
expect(isTestFile('pkg/auth.test.go')).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('identifies Ruby spec files', () => {
|
|
32
|
+
expect(isTestFile('spec/auth_spec.rb')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('identifies files in test directories', () => {
|
|
36
|
+
expect(isTestFile('test/helpers.js')).toBe(true);
|
|
37
|
+
expect(isTestFile('tests/utils.js')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false for implementation files', () => {
|
|
41
|
+
expect(isTestFile('src/auth.js')).toBe(false);
|
|
42
|
+
expect(isTestFile('lib/utils.ts')).toBe(false);
|
|
43
|
+
expect(isTestFile('main.py')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns false for config files', () => {
|
|
47
|
+
expect(isTestFile('package.json')).toBe(false);
|
|
48
|
+
expect(isTestFile('.eslintrc.js')).toBe(false);
|
|
49
|
+
expect(isTestFile('tsconfig.json')).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns false for documentation', () => {
|
|
53
|
+
expect(isTestFile('README.md')).toBe(false);
|
|
54
|
+
expect(isTestFile('docs/api.md')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('formatReviewMarkdown', () => {
|
|
59
|
+
it('formats passing review', () => {
|
|
60
|
+
const report = {
|
|
61
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
62
|
+
base: 'main',
|
|
63
|
+
head: 'feature',
|
|
64
|
+
passed: true,
|
|
65
|
+
verdict: 'APPROVED',
|
|
66
|
+
summary: ['✅ All tests pass'],
|
|
67
|
+
details: {
|
|
68
|
+
fileCount: 5,
|
|
69
|
+
coverage: { implFiles: 3, testFiles: 2, missingTests: 0, issues: [] },
|
|
70
|
+
commits: { commits: 2, tddScore: 100, violations: [] },
|
|
71
|
+
security: [],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const markdown = formatReviewMarkdown(report);
|
|
76
|
+
|
|
77
|
+
expect(markdown).toContain('# Code Review Report');
|
|
78
|
+
expect(markdown).toContain('APPROVED');
|
|
79
|
+
expect(markdown).toContain('✅ All tests pass');
|
|
80
|
+
expect(markdown).toContain('main');
|
|
81
|
+
expect(markdown).toContain('feature');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('formats failing review with missing tests', () => {
|
|
85
|
+
const report = {
|
|
86
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
87
|
+
base: 'main',
|
|
88
|
+
head: 'feature',
|
|
89
|
+
passed: false,
|
|
90
|
+
verdict: 'CHANGES_REQUESTED',
|
|
91
|
+
summary: ['❌ 2 files missing tests'],
|
|
92
|
+
details: {
|
|
93
|
+
fileCount: 5,
|
|
94
|
+
coverage: {
|
|
95
|
+
implFiles: 3,
|
|
96
|
+
testFiles: 1,
|
|
97
|
+
missingTests: 2,
|
|
98
|
+
issues: [
|
|
99
|
+
{ file: 'src/a.js', suggestions: ['src/a.test.js'] },
|
|
100
|
+
{ file: 'src/b.js', suggestions: ['src/b.test.js'] },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
commits: { commits: 1, tddScore: 50, violations: [] },
|
|
104
|
+
security: [],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const markdown = formatReviewMarkdown(report);
|
|
109
|
+
|
|
110
|
+
expect(markdown).toContain('CHANGES_REQUESTED');
|
|
111
|
+
expect(markdown).toContain('Missing Tests');
|
|
112
|
+
expect(markdown).toContain('src/a.js');
|
|
113
|
+
expect(markdown).toContain('src/b.js');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('formats failing review with TDD violations', () => {
|
|
117
|
+
const report = {
|
|
118
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
119
|
+
base: 'main',
|
|
120
|
+
head: 'feature',
|
|
121
|
+
passed: false,
|
|
122
|
+
verdict: 'CHANGES_REQUESTED',
|
|
123
|
+
summary: ['❌ TDD violations'],
|
|
124
|
+
details: {
|
|
125
|
+
fileCount: 3,
|
|
126
|
+
coverage: { implFiles: 2, testFiles: 1, missingTests: 0, issues: [] },
|
|
127
|
+
commits: {
|
|
128
|
+
commits: 2,
|
|
129
|
+
tddScore: 0,
|
|
130
|
+
violations: [
|
|
131
|
+
{ commit: 'abc1234', message: 'feat: add feature', reason: 'No tests' },
|
|
132
|
+
],
|
|
133
|
+
},
|
|
134
|
+
security: [],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const markdown = formatReviewMarkdown(report);
|
|
139
|
+
|
|
140
|
+
expect(markdown).toContain('TDD Violations');
|
|
141
|
+
expect(markdown).toContain('abc1234');
|
|
142
|
+
expect(markdown).toContain('No tests');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('formats failing review with security issues', () => {
|
|
146
|
+
const report = {
|
|
147
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
148
|
+
base: 'main',
|
|
149
|
+
head: 'feature',
|
|
150
|
+
passed: false,
|
|
151
|
+
verdict: 'CHANGES_REQUESTED',
|
|
152
|
+
summary: ['❌ Security issues'],
|
|
153
|
+
details: {
|
|
154
|
+
fileCount: 2,
|
|
155
|
+
coverage: { implFiles: 1, testFiles: 1, missingTests: 0, issues: [] },
|
|
156
|
+
commits: { commits: 1, tddScore: 100, violations: [] },
|
|
157
|
+
security: [
|
|
158
|
+
{ type: 'hardcoded-password', severity: 'high', line: 'password = "secret"' },
|
|
159
|
+
{ type: 'eval-usage', severity: 'medium', line: 'eval(input)' },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const markdown = formatReviewMarkdown(report);
|
|
165
|
+
|
|
166
|
+
expect(markdown).toContain('Security Issues');
|
|
167
|
+
expect(markdown).toContain('HIGH');
|
|
168
|
+
expect(markdown).toContain('hardcoded-password');
|
|
169
|
+
expect(markdown).toContain('MEDIUM');
|
|
170
|
+
expect(markdown).toContain('eval-usage');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('includes PR number when present', () => {
|
|
174
|
+
const report = {
|
|
175
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
176
|
+
base: 'main',
|
|
177
|
+
head: 'feature',
|
|
178
|
+
prNumber: 42,
|
|
179
|
+
passed: true,
|
|
180
|
+
verdict: 'APPROVED',
|
|
181
|
+
summary: [],
|
|
182
|
+
details: {
|
|
183
|
+
fileCount: 0,
|
|
184
|
+
coverage: { implFiles: 0, testFiles: 0, missingTests: 0, issues: [] },
|
|
185
|
+
commits: {},
|
|
186
|
+
security: [],
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const markdown = formatReviewMarkdown(report);
|
|
191
|
+
|
|
192
|
+
expect(markdown).toContain('**PR:** #42');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('includes statistics', () => {
|
|
196
|
+
const report = {
|
|
197
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
198
|
+
base: 'main',
|
|
199
|
+
head: 'feature',
|
|
200
|
+
passed: true,
|
|
201
|
+
verdict: 'APPROVED',
|
|
202
|
+
summary: [],
|
|
203
|
+
details: {
|
|
204
|
+
fileCount: 10,
|
|
205
|
+
coverage: { implFiles: 6, testFiles: 4, missingTests: 0, issues: [] },
|
|
206
|
+
commits: { commits: 5, tddScore: 80, violations: [] },
|
|
207
|
+
security: [],
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const markdown = formatReviewMarkdown(report);
|
|
212
|
+
|
|
213
|
+
expect(markdown).toContain('Statistics');
|
|
214
|
+
expect(markdown).toContain('Files changed: 10');
|
|
215
|
+
expect(markdown).toContain('Implementation files: 6');
|
|
216
|
+
expect(markdown).toContain('Test files: 4');
|
|
217
|
+
expect(markdown).toContain('Commits: 5');
|
|
218
|
+
expect(markdown).toContain('TDD Score: 80%');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('handles empty report gracefully', () => {
|
|
222
|
+
const report = {
|
|
223
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
224
|
+
base: 'main',
|
|
225
|
+
head: 'HEAD',
|
|
226
|
+
passed: true,
|
|
227
|
+
verdict: 'APPROVED',
|
|
228
|
+
summary: [],
|
|
229
|
+
details: {
|
|
230
|
+
fileCount: 0,
|
|
231
|
+
coverage: { implFiles: 0, testFiles: 0, missingTests: 0, issues: [] },
|
|
232
|
+
commits: {},
|
|
233
|
+
security: [],
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const markdown = formatReviewMarkdown(report);
|
|
238
|
+
|
|
239
|
+
expect(markdown).toContain('# Code Review Report');
|
|
240
|
+
expect(markdown).toContain('APPROVED');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('skips low severity security issues in table', () => {
|
|
244
|
+
const report = {
|
|
245
|
+
timestamp: '2024-01-01T00:00:00Z',
|
|
246
|
+
base: 'main',
|
|
247
|
+
head: 'feature',
|
|
248
|
+
passed: true,
|
|
249
|
+
verdict: 'APPROVED',
|
|
250
|
+
summary: [],
|
|
251
|
+
details: {
|
|
252
|
+
fileCount: 1,
|
|
253
|
+
coverage: { implFiles: 1, testFiles: 0, missingTests: 0, issues: [] },
|
|
254
|
+
commits: {},
|
|
255
|
+
security: [
|
|
256
|
+
{ type: 'console-log', severity: 'low', line: 'console.log("debug")' },
|
|
257
|
+
{ type: 'todo-comment', severity: 'info', line: '// TODO: fix later' },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const markdown = formatReviewMarkdown(report);
|
|
263
|
+
|
|
264
|
+
// Low severity issues should not appear in the Security Issues section
|
|
265
|
+
expect(markdown).not.toContain('Security Issues');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|