@warpmetrics/review 0.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/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/init.js +183 -0
- package/defaults/config.json +12 -0
- package/defaults/skills.md +44 -0
- package/defaults/warp-review.yml +32 -0
- package/package.json +27 -0
- package/src/context.js +112 -0
- package/src/github.js +179 -0
- package/src/index.js +48 -0
- package/src/llm.js +6 -0
- package/src/outcome.js +64 -0
- package/src/prompt.js +45 -0
- package/src/review.js +299 -0
- package/src/warpmetrics.js +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 WarpMetrics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# warp-review
|
|
2
|
+
|
|
3
|
+
AI code reviewer that learns your codebase. Powered by [WarpMetrics](https://warpmetrics.com).
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Quickstart
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npx @warpmetrics/review init
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That's it. Open a PR and warp-review will post its first review.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
- Reviews every PR with AI (Claude)
|
|
18
|
+
- Posts inline comments on specific lines with suggested fixes
|
|
19
|
+
- Tracks which comments get accepted or ignored
|
|
20
|
+
- Learns your team's preferences via a local skills file
|
|
21
|
+
- Sends telemetry to WarpMetrics so you can see review effectiveness over time
|
|
22
|
+
|
|
23
|
+
## How it works
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
PR opened/synchronize PR closed
|
|
27
|
+
| |
|
|
28
|
+
v v
|
|
29
|
+
Review Job Outcome Job
|
|
30
|
+
1. Fetch diff + files 1. Find run via WM API
|
|
31
|
+
2. Read skills.md 2. Log PR outcome (merged/closed)
|
|
32
|
+
3. One LLM call 3. Check thread resolution
|
|
33
|
+
4. Post inline comments 4. Log comment outcomes
|
|
34
|
+
5. Log to WarpMetrics (accepted/ignored)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Each review posts inline comments directly on the lines that need attention. When the PR closes, warp-review checks which comments were resolved (accepted) and which were ignored, logging everything to WarpMetrics.
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
### `.warp-review/config.json`
|
|
42
|
+
|
|
43
|
+
| Option | Default | Description |
|
|
44
|
+
|--------|---------|-------------|
|
|
45
|
+
| `model` | `claude-sonnet-4-20250514` | Anthropic model to use |
|
|
46
|
+
| `maxFilesPerReview` | `15` | Maximum files to review per PR |
|
|
47
|
+
| `ignorePatterns` | `["*.lock", ...]` | Glob patterns for files to skip |
|
|
48
|
+
|
|
49
|
+
### `.warp-review/skills.md`
|
|
50
|
+
|
|
51
|
+
This file is the repo-local brain of warp-review. It ships with sensible defaults covering bugs, security issues, and common pitfalls. Edit it to teach warp-review your team's conventions.
|
|
52
|
+
|
|
53
|
+
See [`defaults/skills.md`](defaults/skills.md) for the full default file.
|
|
54
|
+
|
|
55
|
+
## Analytics
|
|
56
|
+
|
|
57
|
+
warp-review sends review telemetry to [WarpMetrics](https://warpmetrics.com). See which comments get accepted, how much each review costs, and how your acceptance rate changes over time.
|
|
58
|
+
|
|
59
|
+
Get your API key at [warpmetrics.com/app/api-keys](https://warpmetrics.com/app/api-keys).
|
|
60
|
+
|
|
61
|
+
## FAQ
|
|
62
|
+
|
|
63
|
+
**Does it review every PR?**
|
|
64
|
+
Yes, on every `opened` and `synchronize` (new commits pushed) event.
|
|
65
|
+
|
|
66
|
+
**What if I don't want it to review certain files?**
|
|
67
|
+
Add glob patterns to `ignorePatterns` in `.warp-review/config.json`.
|
|
68
|
+
|
|
69
|
+
**Can I use it without WarpMetrics?**
|
|
70
|
+
No — WarpMetrics is required for outcome tracking and the review lifecycle. It's free to sign up.
|
|
71
|
+
|
|
72
|
+
**Does it work on PRs from forks?**
|
|
73
|
+
No. GitHub doesn't expose repository secrets to fork PRs for security reasons, so the API keys aren't available. This is a GitHub limitation.
|
|
74
|
+
|
|
75
|
+
**Is my code sent to WarpMetrics?**
|
|
76
|
+
No. Your code goes to Anthropic's API. WarpMetrics only receives metadata: token counts, latency, cost, comment text, and outcomes.
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/bin/init.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from 'readline';
|
|
4
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const defaultsDir = join(__dirname, '..', 'defaults');
|
|
11
|
+
|
|
12
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
13
|
+
|
|
14
|
+
function ask(question) {
|
|
15
|
+
return new Promise(resolve => rl.question(question, resolve));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function log(msg) {
|
|
19
|
+
console.log(msg);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
log('');
|
|
24
|
+
log(' warp-review \u2014 AI code reviewer powered by WarpMetrics');
|
|
25
|
+
log('');
|
|
26
|
+
|
|
27
|
+
// 1. Anthropic API key
|
|
28
|
+
const llmKey = await ask(' ? Anthropic API key: ');
|
|
29
|
+
if (!llmKey.startsWith('sk-ant-')) {
|
|
30
|
+
log(' \u26a0 Warning: key doesn\'t start with sk-ant- — make sure this is a valid Anthropic API key');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. WarpMetrics API key
|
|
34
|
+
const wmKey = await ask(' ? WarpMetrics API key (get one at warpmetrics.com/app/api-keys): ');
|
|
35
|
+
if (!wmKey.startsWith('wm_')) {
|
|
36
|
+
log(' \u26a0 Warning: key doesn\'t start with wm_ — make sure this is a valid WarpMetrics API key');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Model
|
|
40
|
+
const modelInput = await ask(' ? Model (default: claude-sonnet-4-20250514): ');
|
|
41
|
+
const model = modelInput.trim() || 'claude-sonnet-4-20250514';
|
|
42
|
+
|
|
43
|
+
log('');
|
|
44
|
+
|
|
45
|
+
// 4. Set GitHub secrets
|
|
46
|
+
let ghAvailable = false;
|
|
47
|
+
try {
|
|
48
|
+
execSync('gh --version', { stdio: 'ignore' });
|
|
49
|
+
ghAvailable = true;
|
|
50
|
+
} catch {
|
|
51
|
+
ghAvailable = false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (ghAvailable) {
|
|
55
|
+
log(' Setting GitHub secrets...');
|
|
56
|
+
try {
|
|
57
|
+
execSync('gh secret set WARP_REVIEW_LLM_API_KEY', { input: llmKey, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
58
|
+
log(' \u2713 WARP_REVIEW_LLM_API_KEY set');
|
|
59
|
+
} catch (e) {
|
|
60
|
+
log(` \u2717 Failed to set WARP_REVIEW_LLM_API_KEY: ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
execSync('gh secret set WARP_REVIEW_WARPMETRICS_API_KEY', { input: wmKey, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
64
|
+
log(' \u2713 WARP_REVIEW_WARPMETRICS_API_KEY set');
|
|
65
|
+
} catch (e) {
|
|
66
|
+
log(` \u2717 Failed to set WARP_REVIEW_WARPMETRICS_API_KEY: ${e.message}`);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
log(' gh (GitHub CLI) not found. Set these secrets manually:');
|
|
70
|
+
log('');
|
|
71
|
+
log(' gh secret set WARP_REVIEW_LLM_API_KEY');
|
|
72
|
+
log(' gh secret set WARP_REVIEW_WARPMETRICS_API_KEY');
|
|
73
|
+
log(' (gh will prompt for the value interactively)');
|
|
74
|
+
}
|
|
75
|
+
log('');
|
|
76
|
+
|
|
77
|
+
// 5. Create .warp-review/skills.md
|
|
78
|
+
const warpReviewDir = '.warp-review';
|
|
79
|
+
if (existsSync(warpReviewDir)) {
|
|
80
|
+
const overwrite = await ask(' warp-review is already configured. Overwrite? (y/N): ');
|
|
81
|
+
if (overwrite.toLowerCase() !== 'y') {
|
|
82
|
+
log(' Skipping .warp-review/ creation');
|
|
83
|
+
} else {
|
|
84
|
+
createWarpReviewDir(model);
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
createWarpReviewDir(model);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 6. Create workflow
|
|
91
|
+
const workflowPath = '.github/workflows/warp-review.yml';
|
|
92
|
+
if (existsSync(workflowPath)) {
|
|
93
|
+
const overwrite = await ask(' Workflow already exists. Overwrite? (y/N): ');
|
|
94
|
+
if (overwrite.toLowerCase() !== 'y') {
|
|
95
|
+
log(' Skipping workflow creation');
|
|
96
|
+
} else {
|
|
97
|
+
createWorkflow();
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
createWorkflow();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
log('');
|
|
104
|
+
|
|
105
|
+
// 7. Register outcome classifications
|
|
106
|
+
log(' Registering outcome classifications with WarpMetrics...');
|
|
107
|
+
const classifications = [
|
|
108
|
+
{ name: 'Accepted', classification: 'success' },
|
|
109
|
+
{ name: 'Merged', classification: 'success' },
|
|
110
|
+
{ name: 'Active', classification: 'neutral' },
|
|
111
|
+
{ name: 'Superseded', classification: 'neutral' },
|
|
112
|
+
{ name: 'Closed', classification: 'neutral' },
|
|
113
|
+
{ name: 'Ignored', classification: 'failure' },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
let classOk = true;
|
|
117
|
+
for (const { name, classification } of classifications) {
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(`https://api.warpmetrics.com/v1/outcomes/classifications/${encodeURIComponent(name)}`, {
|
|
120
|
+
method: 'PUT',
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${wmKey}`,
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({ classification }),
|
|
126
|
+
});
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
classOk = false;
|
|
129
|
+
console.warn(` \u26a0 Failed to set classification ${name}: ${res.status}`);
|
|
130
|
+
}
|
|
131
|
+
} catch (e) {
|
|
132
|
+
classOk = false;
|
|
133
|
+
console.warn(` \u26a0 Failed to set classification ${name}: ${e.message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (classOk) {
|
|
137
|
+
log(' \u2713 Outcomes configured');
|
|
138
|
+
} else {
|
|
139
|
+
log(' \u26a0 Some classifications failed — you can set them manually in the WarpMetrics dashboard');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 8. Print next steps
|
|
143
|
+
log('');
|
|
144
|
+
log(' Done! Next steps:');
|
|
145
|
+
log(' 1. git add .warp-review .github/workflows/warp-review.yml');
|
|
146
|
+
log(' 2. git commit -m "Add warp-review"');
|
|
147
|
+
log(' 3. Open a PR to see your first AI review');
|
|
148
|
+
log(' 4. View analytics at https://app.warpmetrics.com');
|
|
149
|
+
log('');
|
|
150
|
+
log(' Optional \u2014 add this badge to your README:');
|
|
151
|
+
log(' ');
|
|
152
|
+
log(' (copy the line above into your README.md)');
|
|
153
|
+
log('');
|
|
154
|
+
|
|
155
|
+
rl.close();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function createWarpReviewDir(model) {
|
|
159
|
+
mkdirSync('.warp-review', { recursive: true });
|
|
160
|
+
|
|
161
|
+
log(' Creating .warp-review/skills.md...');
|
|
162
|
+
copyFileSync(join(defaultsDir, 'skills.md'), '.warp-review/skills.md');
|
|
163
|
+
log(' \u2713 Default skills file created');
|
|
164
|
+
|
|
165
|
+
log(' Creating .warp-review/config.json...');
|
|
166
|
+
const config = JSON.parse(readFileSync(join(defaultsDir, 'config.json'), 'utf8'));
|
|
167
|
+
config.model = model;
|
|
168
|
+
writeFileSync('.warp-review/config.json', JSON.stringify(config, null, 2) + '\n');
|
|
169
|
+
log(' \u2713 Config created');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function createWorkflow() {
|
|
173
|
+
log(' Creating .github/workflows/warp-review.yml...');
|
|
174
|
+
mkdirSync('.github/workflows', { recursive: true });
|
|
175
|
+
copyFileSync(join(defaultsDir, 'warp-review.yml'), '.github/workflows/warp-review.yml');
|
|
176
|
+
log(' \u2713 Workflow created');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
main().catch(err => {
|
|
180
|
+
console.error('init failed:', err.message);
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
rl.close();
|
|
183
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# warp-review skills
|
|
2
|
+
|
|
3
|
+
These rules guide how warp-review reviews your code. Edit this file to teach
|
|
4
|
+
warp-review your team's preferences. The more specific you are, the better
|
|
5
|
+
the reviews get.
|
|
6
|
+
|
|
7
|
+
Check your review analytics at https://app.warpmetrics.com to see which
|
|
8
|
+
comments lead to merged changes and which get ignored — then update these
|
|
9
|
+
rules accordingly.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## General
|
|
14
|
+
|
|
15
|
+
- Focus on bugs, logic errors, and security issues over style nitpicks
|
|
16
|
+
- Don't comment on formatting — assume the repo has a formatter
|
|
17
|
+
- If a pattern appears intentional and consistent, don't flag it
|
|
18
|
+
- Limit to 5 comments per file maximum — prioritize by severity
|
|
19
|
+
|
|
20
|
+
## What to flag
|
|
21
|
+
|
|
22
|
+
- Null/undefined access without guards
|
|
23
|
+
- Unhandled promise rejections or missing error handling
|
|
24
|
+
- SQL injection, XSS, or other injection vulnerabilities
|
|
25
|
+
- Race conditions in async code
|
|
26
|
+
- Resource leaks (unclosed connections, file handles, streams)
|
|
27
|
+
- Hardcoded secrets or credentials
|
|
28
|
+
- Off-by-one errors in loops and array access
|
|
29
|
+
- Missing input validation on public API boundaries
|
|
30
|
+
|
|
31
|
+
## What to ignore
|
|
32
|
+
|
|
33
|
+
- Import ordering
|
|
34
|
+
- Variable naming preferences (unless misleading)
|
|
35
|
+
- Comment style or missing comments
|
|
36
|
+
- Minor type annotation differences
|
|
37
|
+
- Whitespace or formatting
|
|
38
|
+
|
|
39
|
+
## Repo-specific rules
|
|
40
|
+
|
|
41
|
+
<!-- Add your own rules below. Examples: -->
|
|
42
|
+
<!-- - We use Result<T, E> pattern for error handling, don't suggest try/catch -->
|
|
43
|
+
<!-- - All database queries must go through the QueryBuilder, never raw SQL -->
|
|
44
|
+
<!-- - React components must use named exports, not default exports -->
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: warp-review
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [opened, synchronize, closed]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
pull-requests: write
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
review:
|
|
13
|
+
if: github.event.action == 'opened' || github.event.action == 'synchronize'
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: warpmetrics/warp-review@v1
|
|
18
|
+
with:
|
|
19
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
20
|
+
llm-api-key: ${{ secrets.WARP_REVIEW_LLM_API_KEY }}
|
|
21
|
+
warpmetrics-api-key: ${{ secrets.WARP_REVIEW_WARPMETRICS_API_KEY }}
|
|
22
|
+
mode: review
|
|
23
|
+
|
|
24
|
+
track-outcome:
|
|
25
|
+
if: github.event.action == 'closed'
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: warpmetrics/warp-review@v1
|
|
29
|
+
with:
|
|
30
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
31
|
+
warpmetrics-api-key: ${{ secrets.WARP_REVIEW_WARPMETRICS_API_KEY }}
|
|
32
|
+
mode: outcome
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@warpmetrics/review",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "AI code reviewer that learns your codebase. Powered by WarpMetrics.",
|
|
6
|
+
"bin": {
|
|
7
|
+
"warp-review": "./bin/init.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"defaults/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@warpmetrics/warp": "latest",
|
|
18
|
+
"@anthropic-ai/sdk": "latest",
|
|
19
|
+
"minimatch": "^10.0.0"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/warpmetrics/warp-review"
|
|
25
|
+
},
|
|
26
|
+
"keywords": ["code-review", "ai", "github-action", "warpmetrics", "llm"]
|
|
27
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
2
|
+
const RESERVED_SYSTEM = 4_000;
|
|
3
|
+
const RESERVED_RESPONSE = 4_000;
|
|
4
|
+
|
|
5
|
+
function estimateTokens(text) {
|
|
6
|
+
return Math.ceil(text.length / 4);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getValidLines(patch) {
|
|
10
|
+
if (!patch) return new Set();
|
|
11
|
+
const lines = patch.split('\n');
|
|
12
|
+
const valid = new Set();
|
|
13
|
+
let currentLine = 0;
|
|
14
|
+
|
|
15
|
+
for (const raw of lines) {
|
|
16
|
+
const hunkMatch = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
17
|
+
if (hunkMatch) {
|
|
18
|
+
currentLine = parseInt(hunkMatch[1], 10);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (raw.startsWith('-')) continue;
|
|
22
|
+
if (raw.startsWith('\\')) continue;
|
|
23
|
+
valid.add(currentLine);
|
|
24
|
+
currentLine++;
|
|
25
|
+
}
|
|
26
|
+
return valid;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractSnippet(patch, targetLine) {
|
|
30
|
+
if (!patch) return null;
|
|
31
|
+
const lines = patch.split('\n');
|
|
32
|
+
let currentLine = 0;
|
|
33
|
+
const patchLines = [];
|
|
34
|
+
|
|
35
|
+
for (const raw of lines) {
|
|
36
|
+
const hunkMatch = raw.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
37
|
+
if (hunkMatch) {
|
|
38
|
+
currentLine = parseInt(hunkMatch[1], 10);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (raw.startsWith('-')) continue;
|
|
42
|
+
if (raw.startsWith('\\')) continue;
|
|
43
|
+
patchLines.push({ line: currentLine, text: raw.startsWith('+') ? raw.slice(1) : raw });
|
|
44
|
+
currentLine++;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const idx = patchLines.findIndex(p => p.line === targetLine);
|
|
48
|
+
if (idx === -1) return null;
|
|
49
|
+
const start = Math.max(0, idx - 1);
|
|
50
|
+
const end = Math.min(patchLines.length, idx + 2);
|
|
51
|
+
return patchLines.slice(start, end).map(p => p.text).join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildContext(files, config) {
|
|
55
|
+
const contextWindow = DEFAULT_CONTEXT_WINDOW;
|
|
56
|
+
const budget = contextWindow - RESERVED_SYSTEM - RESERVED_RESPONSE;
|
|
57
|
+
|
|
58
|
+
// Sort by diff size ascending — smaller diffs get full context first
|
|
59
|
+
const sorted = [...files].sort((a, b) => {
|
|
60
|
+
const aDiff = estimateTokens(a.patch || '');
|
|
61
|
+
const bDiff = estimateTokens(b.patch || '');
|
|
62
|
+
return aDiff - bDiff;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
let usedTokens = 0;
|
|
66
|
+
let truncatedCount = 0;
|
|
67
|
+
const sections = [];
|
|
68
|
+
|
|
69
|
+
for (const file of sorted) {
|
|
70
|
+
const diffText = file.patch || '(no diff available)';
|
|
71
|
+
const diffTokens = estimateTokens(diffText);
|
|
72
|
+
|
|
73
|
+
// Always include the diff — skip if even the diff doesn't fit
|
|
74
|
+
if (usedTokens + diffTokens > budget) {
|
|
75
|
+
truncatedCount++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let fullContent = file.content || null;
|
|
80
|
+
let fullContentIncluded = false;
|
|
81
|
+
|
|
82
|
+
if (fullContent) {
|
|
83
|
+
const fullTokens = estimateTokens(fullContent);
|
|
84
|
+
if (usedTokens + diffTokens + fullTokens <= budget) {
|
|
85
|
+
fullContentIncluded = true;
|
|
86
|
+
usedTokens += diffTokens + fullTokens;
|
|
87
|
+
} else {
|
|
88
|
+
// Diff fits, full content doesn't
|
|
89
|
+
usedTokens += diffTokens;
|
|
90
|
+
truncatedCount++;
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
usedTokens += diffTokens;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ext = file.filename.split('.').pop() || '';
|
|
97
|
+
let section = `## File: ${file.filename} (${file.status})\n\n### Diff\n\`\`\`diff\n${diffText}\n\`\`\`\n`;
|
|
98
|
+
|
|
99
|
+
if (fullContentIncluded && fullContent) {
|
|
100
|
+
section += `\n### Full file content\n\`\`\`${ext}\n${fullContent}\n\`\`\`\n`;
|
|
101
|
+
} else if (fullContent) {
|
|
102
|
+
section += `\n### Full file content\n(full content omitted — file too large)\n`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sections.push(section);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
userMessage: sections.join('\n---\n\n'),
|
|
110
|
+
truncatedCount,
|
|
111
|
+
};
|
|
112
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
function ghHeaders() {
|
|
2
|
+
return {
|
|
3
|
+
Authorization: `token ${process.env.GITHUB_TOKEN}`,
|
|
4
|
+
Accept: 'application/vnd.github+json',
|
|
5
|
+
'Content-Type': 'application/json',
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function fetchWithRetry(url, options, maxRetries = 3) {
|
|
10
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
11
|
+
const res = await fetch(url, options);
|
|
12
|
+
if (res.status === 429) {
|
|
13
|
+
const retryAfter = parseInt(res.headers.get('retry-after') || '5', 10);
|
|
14
|
+
const delay = retryAfter * 1000 * (attempt + 1);
|
|
15
|
+
console.warn(`Rate limited by GitHub API, retrying in ${delay / 1000}s...`);
|
|
16
|
+
await new Promise(r => setTimeout(r, delay));
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
return res;
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`GitHub API rate limit exceeded after ${maxRetries} retries`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getChangedFiles(owner, repo, pr) {
|
|
25
|
+
const files = [];
|
|
26
|
+
let page = 1;
|
|
27
|
+
while (true) {
|
|
28
|
+
const res = await fetchWithRetry(
|
|
29
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}/files?per_page=100&page=${page}`,
|
|
30
|
+
{ headers: ghHeaders() },
|
|
31
|
+
);
|
|
32
|
+
if (!res.ok) throw new Error(`Failed to fetch changed files: ${res.status}`);
|
|
33
|
+
const batch = await res.json();
|
|
34
|
+
files.push(...batch);
|
|
35
|
+
if (batch.length < 100) break;
|
|
36
|
+
page++;
|
|
37
|
+
}
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function getFileContent(owner, repo, path, ref) {
|
|
42
|
+
const res = await fetchWithRetry(
|
|
43
|
+
`https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${ref}`,
|
|
44
|
+
{ headers: ghHeaders() },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (res.status === 403) {
|
|
48
|
+
// File too large for contents API — try Git Blob API
|
|
49
|
+
// Need to get the file SHA first from the tree
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (!res.ok) return null;
|
|
53
|
+
|
|
54
|
+
const data = await res.json();
|
|
55
|
+
if (data.encoding === 'base64' && data.content) {
|
|
56
|
+
return Buffer.from(data.content, 'base64').toString('utf8');
|
|
57
|
+
}
|
|
58
|
+
// Binary file or unexpected encoding
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function getFileViaBlob(owner, repo, sha) {
|
|
63
|
+
const res = await fetchWithRetry(
|
|
64
|
+
`https://api.github.com/repos/${owner}/${repo}/git/blobs/${sha}`,
|
|
65
|
+
{ headers: ghHeaders() },
|
|
66
|
+
);
|
|
67
|
+
if (!res.ok) return null;
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
if (data.encoding === 'base64' && data.content) {
|
|
70
|
+
return Buffer.from(data.content, 'base64').toString('utf8');
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function postReview(owner, repo, pr, headSha, body, comments) {
|
|
76
|
+
const res = await fetchWithRetry(
|
|
77
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}/reviews`,
|
|
78
|
+
{
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: ghHeaders(),
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
commit_id: headSha,
|
|
83
|
+
body,
|
|
84
|
+
event: 'COMMENT',
|
|
85
|
+
comments: comments.map(c => ({
|
|
86
|
+
path: c.file,
|
|
87
|
+
line: c.line,
|
|
88
|
+
side: 'RIGHT',
|
|
89
|
+
body: c.body,
|
|
90
|
+
})),
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const text = await res.text();
|
|
96
|
+
throw new Error(`Failed to post review: ${res.status} ${text}`);
|
|
97
|
+
}
|
|
98
|
+
return res.json();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getReviewCommentIds(owner, repo, pr, reviewId) {
|
|
102
|
+
const res = await fetchWithRetry(
|
|
103
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}/reviews/${reviewId}/comments`,
|
|
104
|
+
{ headers: ghHeaders() },
|
|
105
|
+
);
|
|
106
|
+
if (!res.ok) throw new Error(`Failed to fetch review comments: ${res.status}`);
|
|
107
|
+
const comments = await res.json();
|
|
108
|
+
return comments.map(c => c.id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function dismissReview(owner, repo, pr, reviewId, headSha) {
|
|
112
|
+
const shortSha = headSha.slice(0, 7);
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(
|
|
115
|
+
`https://api.github.com/repos/${owner}/${repo}/pulls/${pr}/reviews/${reviewId}/dismissals`,
|
|
116
|
+
{
|
|
117
|
+
method: 'PUT',
|
|
118
|
+
headers: ghHeaders(),
|
|
119
|
+
body: JSON.stringify({ message: `Superseded by new review after commit ${shortSha}` }),
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
console.warn(`Failed to dismiss review ${reviewId}: ${res.status} — posting new review alongside old one`);
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
console.warn(`Failed to dismiss review ${reviewId}: ${e.message} — continuing`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function buildThreadMap(owner, repo, pr) {
|
|
131
|
+
const query = `query($owner: String!, $repo: String!, $pr: Int!) {
|
|
132
|
+
repository(owner: $owner, name: $repo) {
|
|
133
|
+
pullRequest(number: $pr) {
|
|
134
|
+
reviewThreads(first: 100) {
|
|
135
|
+
nodes { isResolved, comments(first: 1) { nodes { databaseId } } }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}`;
|
|
140
|
+
const res = await fetch('https://api.github.com/graphql', {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: ghHeaders(),
|
|
143
|
+
body: JSON.stringify({ query, variables: { owner, repo, pr } }),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) throw new Error(`GitHub GraphQL error: ${res.status}`);
|
|
146
|
+
const { data } = await res.json();
|
|
147
|
+
|
|
148
|
+
const map = new Map();
|
|
149
|
+
const threads = data?.repository?.pullRequest?.reviewThreads?.nodes || [];
|
|
150
|
+
for (const thread of threads) {
|
|
151
|
+
const id = thread.comments.nodes[0]?.databaseId;
|
|
152
|
+
if (id) map.set(id, { resolved: thread.isResolved });
|
|
153
|
+
}
|
|
154
|
+
return map;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function getThreadStatus(commentId, threadMap, fullRepo) {
|
|
158
|
+
const thread = threadMap.get(commentId);
|
|
159
|
+
if (!thread) return { resolved: false, latestReply: null };
|
|
160
|
+
|
|
161
|
+
let latestReply = null;
|
|
162
|
+
if (!thread.resolved) {
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetchWithRetry(
|
|
165
|
+
`https://api.github.com/repos/${fullRepo}/pulls/comments/${commentId}/replies`,
|
|
166
|
+
{ headers: ghHeaders() },
|
|
167
|
+
);
|
|
168
|
+
if (res.ok) {
|
|
169
|
+
const replies = await res.json();
|
|
170
|
+
if (replies.length > 0) {
|
|
171
|
+
latestReply = replies[replies.length - 1].body.slice(0, 280);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// Failed to fetch replies — leave latestReply as null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { resolved: thread.resolved, latestReply };
|
|
179
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { review } from './review.js';
|
|
3
|
+
import { trackOutcome } from './outcome.js';
|
|
4
|
+
|
|
5
|
+
function getContext() {
|
|
6
|
+
const [owner, repo] = process.env.GITHUB_REPOSITORY.split('/');
|
|
7
|
+
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
|
|
8
|
+
const pull = event.pull_request;
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
owner,
|
|
12
|
+
repo,
|
|
13
|
+
fullRepo: `${owner}/${repo}`,
|
|
14
|
+
pr: pull.number,
|
|
15
|
+
action: event.action,
|
|
16
|
+
headSha: pull.head.sha,
|
|
17
|
+
baseSha: pull.base.sha,
|
|
18
|
+
title: pull.title,
|
|
19
|
+
body: pull.body || '',
|
|
20
|
+
htmlUrl: pull.html_url,
|
|
21
|
+
prAuthor: pull.user.login,
|
|
22
|
+
additions: pull.additions,
|
|
23
|
+
deletions: pull.deletions,
|
|
24
|
+
changedFiles: pull.changed_files,
|
|
25
|
+
baseBranch: pull.base.ref,
|
|
26
|
+
merged: pull.merged || false,
|
|
27
|
+
mergedBy: pull.merged_by?.login || null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
const mode = process.env.MODE || 'review';
|
|
33
|
+
const ctx = getContext();
|
|
34
|
+
|
|
35
|
+
if (mode === 'review') {
|
|
36
|
+
await review(ctx);
|
|
37
|
+
} else if (mode === 'outcome') {
|
|
38
|
+
await trackOutcome(ctx);
|
|
39
|
+
} else {
|
|
40
|
+
console.error(`Unknown mode: ${mode}. Use 'review' or 'outcome'.`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
main().catch((err) => {
|
|
46
|
+
console.error('warp-review failed:', err.message);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
});
|
package/src/llm.js
ADDED
package/src/outcome.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { outcome, flush } from '@warpmetrics/warp';
|
|
2
|
+
import { findRun } from './warpmetrics.js';
|
|
3
|
+
import { buildThreadMap, getThreadStatus } from './github.js';
|
|
4
|
+
|
|
5
|
+
export async function trackOutcome(ctx) {
|
|
6
|
+
const { owner, repo, fullRepo, pr, merged, mergedBy } = ctx;
|
|
7
|
+
|
|
8
|
+
// 1. Find the run
|
|
9
|
+
let runDetail;
|
|
10
|
+
try {
|
|
11
|
+
if (!process.env.WARPMETRICS_API_KEY) return;
|
|
12
|
+
runDetail = await findRun(fullRepo, pr);
|
|
13
|
+
} catch (e) {
|
|
14
|
+
console.warn(`WarpMetrics API unreachable during outcome tracking: ${e.message}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. No run found — exit silently
|
|
19
|
+
if (!runDetail) return;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
// 3. PR-level outcome
|
|
23
|
+
if (merged) {
|
|
24
|
+
outcome(runDetail.id, 'Merged', { merged_by: mergedBy });
|
|
25
|
+
} else {
|
|
26
|
+
outcome(runDetail.id, 'Closed');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 4. Find active round (non-Superseded)
|
|
30
|
+
const activeRound = runDetail.groups
|
|
31
|
+
.filter(g => g.opts?.round)
|
|
32
|
+
.sort((a, b) => b.opts.round - a.opts.round)
|
|
33
|
+
.find(g => !g.outcomes?.some(o => o.name === 'Superseded'));
|
|
34
|
+
|
|
35
|
+
if (!activeRound) return;
|
|
36
|
+
|
|
37
|
+
// 5. Round-level outcome
|
|
38
|
+
outcome(activeRound.id, 'Active');
|
|
39
|
+
|
|
40
|
+
// 6. Comment-level outcomes
|
|
41
|
+
const threadMap = await buildThreadMap(owner, repo, pr);
|
|
42
|
+
|
|
43
|
+
const commentGroups = (activeRound.groups || []).filter(g => g.label !== '_summary');
|
|
44
|
+
for (const commentGroup of commentGroups) {
|
|
45
|
+
const commentId = commentGroup.opts?.github_comment_id;
|
|
46
|
+
if (!commentId) continue;
|
|
47
|
+
|
|
48
|
+
const thread = await getThreadStatus(commentId, threadMap, fullRepo);
|
|
49
|
+
if (thread.resolved) {
|
|
50
|
+
outcome(commentGroup.id, 'Accepted');
|
|
51
|
+
} else {
|
|
52
|
+
const opts = thread.latestReply ? { reason: thread.latestReply } : {};
|
|
53
|
+
outcome(commentGroup.id, 'Ignored', opts);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
// 7. Flush
|
|
58
|
+
try {
|
|
59
|
+
await flush();
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.warn(`Failed to flush WarpMetrics events: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function buildSystemPrompt(skills, title, body) {
|
|
2
|
+
return `You are warp-review, an AI code reviewer. You review pull request diffs and
|
|
3
|
+
post helpful, actionable comments.
|
|
4
|
+
|
|
5
|
+
## Your review rules
|
|
6
|
+
|
|
7
|
+
${skills}
|
|
8
|
+
|
|
9
|
+
## Pull request context
|
|
10
|
+
|
|
11
|
+
Title: ${title}
|
|
12
|
+
Description: ${body}
|
|
13
|
+
|
|
14
|
+
## Instructions
|
|
15
|
+
|
|
16
|
+
- You are reviewing an entire pull request across multiple files
|
|
17
|
+
- Use the PR title and description to understand the author's intent
|
|
18
|
+
- For each file you receive: the unified diff AND the full file content for context
|
|
19
|
+
- Look for cross-file issues: broken references, inconsistent signatures, missing imports
|
|
20
|
+
- For each issue, respond with a JSON array of comments
|
|
21
|
+
- Each comment must have: file (path), line (number in the new file), body (the comment text)
|
|
22
|
+
- IMPORTANT: Only comment on lines that appear in the diff (added or modified lines marked with +). Do NOT comment on unchanged lines — they cannot receive inline comments.
|
|
23
|
+
- Maximum 5 comments per file, 20 comments total — prioritize by severity
|
|
24
|
+
- If everything looks fine, return an empty array []
|
|
25
|
+
- Be concise. One comment = one issue. No preamble.
|
|
26
|
+
- Every comment must suggest a fix or explain WHY something is wrong
|
|
27
|
+
- Never comment on things covered by linters or formatters
|
|
28
|
+
|
|
29
|
+
## Response format
|
|
30
|
+
|
|
31
|
+
Respond with ONLY a JSON array. Each comment must include a \`category\` from this list:
|
|
32
|
+
- \`bug\` — logic errors, null access, off-by-one, wrong return values
|
|
33
|
+
- \`security\` — injection, XSS, auth bypass, hardcoded secrets
|
|
34
|
+
- \`error-handling\` — missing try/catch, unhandled rejections, swallowed errors
|
|
35
|
+
- \`performance\` — N+1 queries, unnecessary allocations, missing caching
|
|
36
|
+
- \`concurrency\` — race conditions, deadlocks, missing locks
|
|
37
|
+
- \`resource-leak\` — unclosed connections, file handles, streams
|
|
38
|
+
- \`api-contract\` — breaking changes, missing validation, wrong types
|
|
39
|
+
- \`other\` — anything not in the above categories
|
|
40
|
+
|
|
41
|
+
[
|
|
42
|
+
{"file": "src/auth.js", "line": 42, "category": "bug", "body": "This can throw if \`user\` is null. Add a guard: \`if (!user) return;\`"},
|
|
43
|
+
{"file": "src/db.js", "line": 87, "category": "security", "body": "SQL injection risk — use parameterized query instead of string interpolation"}
|
|
44
|
+
]`;
|
|
45
|
+
}
|
package/src/review.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { minimatch } from 'minimatch';
|
|
3
|
+
import { run, group, call, outcome, flush } from '@warpmetrics/warp';
|
|
4
|
+
import { createClient } from './llm.js';
|
|
5
|
+
import { findRun } from './warpmetrics.js';
|
|
6
|
+
import {
|
|
7
|
+
getChangedFiles, getFileContent, getFileViaBlob,
|
|
8
|
+
postReview, getReviewCommentIds, dismissReview,
|
|
9
|
+
} from './github.js';
|
|
10
|
+
import { buildContext, getValidLines, extractSnippet } from './context.js';
|
|
11
|
+
import { buildSystemPrompt } from './prompt.js';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SKILLS = readFileSync(new URL('../defaults/skills.md', import.meta.url), 'utf8');
|
|
14
|
+
const DEFAULT_CONFIG = JSON.parse(readFileSync(new URL('../defaults/config.json', import.meta.url), 'utf8'));
|
|
15
|
+
|
|
16
|
+
function readConfig() {
|
|
17
|
+
try {
|
|
18
|
+
if (existsSync('.warp-review/config.json')) {
|
|
19
|
+
return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync('.warp-review/config.json', 'utf8')) };
|
|
20
|
+
}
|
|
21
|
+
} catch { /* fall through */ }
|
|
22
|
+
return DEFAULT_CONFIG;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readSkills() {
|
|
26
|
+
try {
|
|
27
|
+
if (existsSync('.warp-review/skills.md')) {
|
|
28
|
+
return readFileSync('.warp-review/skills.md', 'utf8');
|
|
29
|
+
}
|
|
30
|
+
} catch { /* fall through */ }
|
|
31
|
+
return DEFAULT_SKILLS;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseComments(text) {
|
|
35
|
+
// Strip markdown code fences
|
|
36
|
+
let cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
37
|
+
|
|
38
|
+
// Extract first [...] block
|
|
39
|
+
const start = cleaned.indexOf('[');
|
|
40
|
+
const end = cleaned.lastIndexOf(']');
|
|
41
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
42
|
+
cleaned = cleaned.slice(start, end + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return JSON.parse(cleaned);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function llmWithRetry(anthropic, params, maxRetries = 3) {
|
|
49
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
50
|
+
try {
|
|
51
|
+
return await anthropic.messages.create(params);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
const isRetryable = e.status === 429 || e.status === 529 || e.status >= 500 || e.code === 'ECONNREFUSED' || e.code === 'ETIMEDOUT' || e.code === 'ENOTFOUND';
|
|
54
|
+
if (!isRetryable || attempt === maxRetries - 1) throw e;
|
|
55
|
+
const delay = Math.pow(2, attempt) * 1000;
|
|
56
|
+
console.warn(`LLM API error (${e.status || e.code}), retrying in ${delay / 1000}s...`);
|
|
57
|
+
await new Promise(r => setTimeout(r, delay));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildSummary(commentsPosted, filesReviewed, runId, wmAvailable, { totalFiltered = 0, truncatedCount = 0 } = {}) {
|
|
63
|
+
const analyticsLink = wmAvailable && runId
|
|
64
|
+
? `\n\n[View review analytics \u2192](https://app.warpmetrics.com/runs/${runId})`
|
|
65
|
+
: '';
|
|
66
|
+
|
|
67
|
+
const notes = [];
|
|
68
|
+
if (totalFiltered > filesReviewed) {
|
|
69
|
+
notes.push(`Reviewed ${filesReviewed}/${totalFiltered} files. Increase \`maxFilesPerReview\` in \`.warp-review/config.json\` to review more.`);
|
|
70
|
+
}
|
|
71
|
+
if (truncatedCount > 0) {
|
|
72
|
+
notes.push(`Context was truncated for ${truncatedCount} large file(s).`);
|
|
73
|
+
}
|
|
74
|
+
const notesText = notes.length > 0 ? '\n\n' + notes.join(' ') : '';
|
|
75
|
+
|
|
76
|
+
if (commentsPosted.length > 0) {
|
|
77
|
+
const fileCount = new Set(commentsPosted.map(c => c.file)).size;
|
|
78
|
+
const firstComment = commentsPosted[0].body.slice(0, 100);
|
|
79
|
+
return `**warp-review** found ${commentsPosted.length} issue(s) in ${fileCount} file(s).${notesText}\n\nMost critical: ${firstComment}${analyticsLink}\n\n<sub>Powered by [WarpMetrics](https://warpmetrics.com) \u00b7 Edit \`.warp-review/skills.md\` to customize</sub>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `**warp-review** reviewed ${filesReviewed} file(s) \u2014 no issues found.${notesText}${analyticsLink}\n\n<sub>Powered by [WarpMetrics](https://warpmetrics.com)</sub>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function review(ctx) {
|
|
86
|
+
const { owner, repo, fullRepo, pr, headSha, htmlUrl, prAuthor, additions, deletions, changedFiles, baseBranch, title, body } = ctx;
|
|
87
|
+
|
|
88
|
+
// 1. Validate LLM API key
|
|
89
|
+
if (!process.env.LLM_API_KEY) {
|
|
90
|
+
console.error('LLM_API_KEY is required for review mode. Set WARP_REVIEW_LLM_API_KEY as a repository secret.');
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 2. Read config
|
|
96
|
+
const config = readConfig();
|
|
97
|
+
|
|
98
|
+
// 3. Query WarpMetrics for existing run
|
|
99
|
+
let runDetail = null;
|
|
100
|
+
let wmAvailable = true;
|
|
101
|
+
try {
|
|
102
|
+
if (process.env.WARPMETRICS_API_KEY) {
|
|
103
|
+
runDetail = await findRun(fullRepo, pr);
|
|
104
|
+
} else {
|
|
105
|
+
wmAvailable = false;
|
|
106
|
+
}
|
|
107
|
+
} catch (e) {
|
|
108
|
+
console.warn(`WarpMetrics API unreachable \u2014 skipping re-review detection: ${e.message}`);
|
|
109
|
+
wmAvailable = false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. Handle re-review: dismiss previous and supersede
|
|
113
|
+
let runRef;
|
|
114
|
+
let nextRoundNum = 1;
|
|
115
|
+
|
|
116
|
+
if (runDetail) {
|
|
117
|
+
const rounds = runDetail.groups.filter(g => g.opts?.round);
|
|
118
|
+
const prevRound = rounds.sort((a, b) => b.opts.round - a.opts.round)[0];
|
|
119
|
+
if (prevRound) {
|
|
120
|
+
nextRoundNum = prevRound.opts.round + 1;
|
|
121
|
+
|
|
122
|
+
// Find github_review_id in the _summary sub-group
|
|
123
|
+
const prevSummary = prevRound.groups?.find(g => g.label === '_summary');
|
|
124
|
+
const prevReviewId = prevSummary?.opts?.github_review_id;
|
|
125
|
+
if (prevReviewId) {
|
|
126
|
+
await dismissReview(owner, repo, pr, prevReviewId, headSha);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
outcome(prevRound.id, 'Superseded');
|
|
130
|
+
}
|
|
131
|
+
runRef = runDetail.id;
|
|
132
|
+
} else {
|
|
133
|
+
// Create new run
|
|
134
|
+
if (wmAvailable) {
|
|
135
|
+
runRef = run('warp-review', {
|
|
136
|
+
name: `${fullRepo}#${pr}`, repo: fullRepo, pr, pr_url: htmlUrl,
|
|
137
|
+
pr_author: prAuthor, additions, deletions, changed_files: changedFiles, base_branch: baseBranch,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
// 5. Fetch changed files
|
|
144
|
+
const allFiles = await getChangedFiles(owner, repo, pr);
|
|
145
|
+
|
|
146
|
+
// 6. Filter files
|
|
147
|
+
const filtered = allFiles.filter(f => {
|
|
148
|
+
if (f.status === 'removed') return false;
|
|
149
|
+
for (const pattern of config.ignorePatterns || []) {
|
|
150
|
+
if (minimatch(f.filename, pattern, { matchBase: true })) return false;
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const filesToReview = filtered.slice(0, config.maxFilesPerReview || 15);
|
|
156
|
+
|
|
157
|
+
// 7. Fetch full file content
|
|
158
|
+
for (const file of filesToReview) {
|
|
159
|
+
let content = await getFileContent(owner, repo, file.filename, headSha);
|
|
160
|
+
if (content === null && file.sha) {
|
|
161
|
+
content = await getFileViaBlob(owner, repo, file.sha);
|
|
162
|
+
}
|
|
163
|
+
file.content = content;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 8. Read skills
|
|
167
|
+
const skills = readSkills();
|
|
168
|
+
|
|
169
|
+
// 9. Create WM round group
|
|
170
|
+
const languages = [...new Set(filesToReview.map(f => {
|
|
171
|
+
const parts = f.filename.split('.');
|
|
172
|
+
return parts.length > 1 ? `.${parts.pop()}` : '';
|
|
173
|
+
}).filter(Boolean))];
|
|
174
|
+
|
|
175
|
+
const { userMessage, truncatedCount } = buildContext(filesToReview, config);
|
|
176
|
+
|
|
177
|
+
let round = null;
|
|
178
|
+
if (runRef) {
|
|
179
|
+
round = group(runRef, `Review ${nextRoundNum}`, {
|
|
180
|
+
round: nextRoundNum, sha: headSha, model: config.model,
|
|
181
|
+
files_reviewed: filesToReview.length, context_truncated: truncatedCount,
|
|
182
|
+
languages, timestamp: new Date().toISOString(),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 10. LLM call
|
|
187
|
+
const anthropic = createClient(process.env.LLM_API_KEY);
|
|
188
|
+
const systemPrompt = buildSystemPrompt(skills, title, body);
|
|
189
|
+
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
response = await llmWithRetry(anthropic, {
|
|
193
|
+
model: config.model,
|
|
194
|
+
max_tokens: 4096,
|
|
195
|
+
system: systemPrompt,
|
|
196
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
197
|
+
});
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.error(`LLM API unreachable after retries: ${e.message}`);
|
|
200
|
+
const summaryBody = '**warp-review** could not complete the review \u2014 LLM API unreachable.\n\n<sub>Powered by [WarpMetrics](https://warpmetrics.com)</sub>';
|
|
201
|
+
await postReview(owner, repo, pr, headSha, summaryBody, []);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (round) {
|
|
206
|
+
call(round, response);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 11. Parse LLM response
|
|
210
|
+
const responseText = response.content?.[0]?.text || '[]';
|
|
211
|
+
let parsedComments;
|
|
212
|
+
try {
|
|
213
|
+
parsedComments = parseComments(responseText);
|
|
214
|
+
} catch {
|
|
215
|
+
// Retry once with correction
|
|
216
|
+
console.warn('LLM returned invalid JSON, retrying...');
|
|
217
|
+
try {
|
|
218
|
+
const retryResponse = await llmWithRetry(anthropic, {
|
|
219
|
+
model: config.model,
|
|
220
|
+
max_tokens: 4096,
|
|
221
|
+
system: systemPrompt,
|
|
222
|
+
messages: [
|
|
223
|
+
{ role: 'user', content: userMessage },
|
|
224
|
+
{ role: 'assistant', content: responseText },
|
|
225
|
+
{ role: 'user', content: 'Your previous response was not valid JSON. Respond with ONLY a JSON array, no other text.' },
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
if (round) call(round, retryResponse);
|
|
229
|
+
parsedComments = parseComments(retryResponse.content?.[0]?.text || '[]');
|
|
230
|
+
} catch {
|
|
231
|
+
console.warn('LLM retry also failed — posting summary only');
|
|
232
|
+
parsedComments = [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!Array.isArray(parsedComments)) parsedComments = [];
|
|
237
|
+
|
|
238
|
+
// 12. Validate line numbers
|
|
239
|
+
const filePatches = new Map();
|
|
240
|
+
for (const f of filesToReview) {
|
|
241
|
+
filePatches.set(f.filename, f.patch);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const validComments = parsedComments.filter(c => {
|
|
245
|
+
if (!c.file || !c.line || !c.body) return false;
|
|
246
|
+
const validLines = getValidLines(filePatches.get(c.file));
|
|
247
|
+
return validLines.has(c.line);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Derive runId for summary link
|
|
251
|
+
const runId = typeof runRef === 'string' ? runRef : runRef?.id;
|
|
252
|
+
|
|
253
|
+
// 13. Post review
|
|
254
|
+
const summaryBody = buildSummary(validComments, filesToReview.length, runId, wmAvailable, {
|
|
255
|
+
totalFiltered: filtered.length, truncatedCount,
|
|
256
|
+
});
|
|
257
|
+
const reviewResult = await postReview(owner, repo, pr, headSha, summaryBody, validComments);
|
|
258
|
+
const reviewId = reviewResult.id;
|
|
259
|
+
|
|
260
|
+
// 14. Get comment IDs (matched by array index order)
|
|
261
|
+
let commentIds = [];
|
|
262
|
+
if (validComments.length > 0) {
|
|
263
|
+
try {
|
|
264
|
+
commentIds = await getReviewCommentIds(owner, repo, pr, reviewId);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.warn(`Failed to get review comment IDs: ${e.message}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 15. Log comment groups + summary to WM
|
|
271
|
+
if (round) {
|
|
272
|
+
for (let i = 0; i < validComments.length; i++) {
|
|
273
|
+
const c = validComments[i];
|
|
274
|
+
const snippet = extractSnippet(filePatches.get(c.file), c.line);
|
|
275
|
+
const ghId = commentIds[i] || null;
|
|
276
|
+
group(round, `${c.file}:${c.line}`, {
|
|
277
|
+
file: c.file, line: c.line, body: c.body, category: c.category,
|
|
278
|
+
...(snippet && { snippet }), github_comment_id: ghId,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
group(round, '_summary', {
|
|
283
|
+
comments_generated: parsedComments.length,
|
|
284
|
+
comments_posted: validComments.length,
|
|
285
|
+
comments_dropped: parsedComments.length - validComments.length,
|
|
286
|
+
github_review_id: reviewId,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
console.log(`warp-review: posted ${validComments.length} comment(s) on PR #${pr}`);
|
|
291
|
+
} finally {
|
|
292
|
+
// 16. Flush
|
|
293
|
+
try {
|
|
294
|
+
await flush();
|
|
295
|
+
} catch (e) {
|
|
296
|
+
console.warn(`Failed to flush WarpMetrics events: ${e.message}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const API = 'https://api.warpmetrics.com/v1';
|
|
2
|
+
|
|
3
|
+
function headers() {
|
|
4
|
+
return { Authorization: `Bearer ${process.env.WARPMETRICS_API_KEY}` };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function findRun(repo, pr) {
|
|
8
|
+
const name = `${repo}#${pr}`;
|
|
9
|
+
const res = await fetch(
|
|
10
|
+
`${API}/runs?label=warp-review&name=${encodeURIComponent(name)}&limit=1`,
|
|
11
|
+
{ headers: headers() },
|
|
12
|
+
);
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
throw new Error(`WarpMetrics API error: ${res.status} ${res.statusText}`);
|
|
15
|
+
}
|
|
16
|
+
const { data } = await res.json();
|
|
17
|
+
|
|
18
|
+
if (!data || !data.length) return null;
|
|
19
|
+
|
|
20
|
+
const detail = await fetch(`${API}/runs/${data[0].id}`, { headers: headers() });
|
|
21
|
+
if (!detail.ok) {
|
|
22
|
+
throw new Error(`WarpMetrics API error fetching run detail: ${detail.status}`);
|
|
23
|
+
}
|
|
24
|
+
const result = await detail.json();
|
|
25
|
+
return result.data;
|
|
26
|
+
}
|