@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 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
+ ![warp-review](https://img.shields.io/badge/warp--review---%25%20accepted-purple)
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(' ![warp-review](https://img.shields.io/badge/warp--review---%25%20accepted-purple)');
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,12 @@
1
+ {
2
+ "model": "claude-sonnet-4-20250514",
3
+ "maxFilesPerReview": 15,
4
+ "ignorePatterns": [
5
+ "*.lock",
6
+ "*.min.js",
7
+ "*.map",
8
+ "dist/**",
9
+ "node_modules/**",
10
+ "*.generated.*"
11
+ ]
12
+ }
@@ -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
@@ -0,0 +1,6 @@
1
+ import Anthropic from '@anthropic-ai/sdk';
2
+ import { warp } from '@warpmetrics/warp';
3
+
4
+ export function createClient(apiKey) {
5
+ return warp(new Anthropic({ apiKey }));
6
+ }
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
+ }