devin-bugs 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 Hannah
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,136 @@
1
+ # devin-review-cli
2
+
3
+ CLI to extract unresolved bugs from [Devin AI](https://devin.ai) code reviews. Pulls flagged bugs from any PR that Devin has reviewed and outputs them in your terminal or as JSON.
4
+
5
+ ```
6
+ $ devin-bugs owner/repo#46
7
+
8
+ 1 bug in owner/repo#46
9
+
10
+ BUG lib/apply/assist.ts:124-136 WARNING
11
+ Reverting packet to 'ready' after credits charged creates an unrecoverable retry loop
12
+ In prepareApplyAssist, when createApplication fails with a non-P2002 error,
13
+ the packet is reverted to 'ready' but credits have already been charged...
14
+ ```
15
+
16
+ ## Install
17
+
18
+ Requires [Bun](https://bun.sh) (v1.0+).
19
+
20
+ ```bash
21
+ git clone https://github.com/xCatalitY/devin-review-cli.git
22
+ cd devin-review-cli
23
+ bun install
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```bash
29
+ # GitHub PR URL
30
+ devin-bugs https://github.com/owner/repo/pull/123
31
+
32
+ # Shorthand
33
+ devin-bugs owner/repo#123
34
+
35
+ # Devin review URL
36
+ devin-bugs https://app.devin.ai/review/owner/repo/pull/123
37
+ ```
38
+
39
+ ### Options
40
+
41
+ ```
42
+ --json Output as JSON (for piping)
43
+ --all Include analysis/suggestions, not just bugs
44
+ --raw Dump raw API response (debug)
45
+ --no-cache Force re-authentication
46
+ --login Just authenticate, don't fetch anything
47
+ --logout Clear stored credentials
48
+ --help, -h Show help
49
+ ```
50
+
51
+ ### Examples
52
+
53
+ ```bash
54
+ # Get bugs as JSON for scripting
55
+ devin-bugs owner/repo#46 --json | jq '.[].title'
56
+
57
+ # Include all flags (bugs + analysis suggestions)
58
+ devin-bugs owner/repo#46 --all
59
+
60
+ # Pipe to another tool
61
+ devin-bugs owner/repo#46 --json | jq '.[] | select(.severity == "severe")'
62
+
63
+ # Skip browser, use token directly
64
+ DEVIN_TOKEN=eyJ... devin-bugs owner/repo#46
65
+ ```
66
+
67
+ ## Authentication
68
+
69
+ On first run, the CLI opens your browser to a local page with instructions:
70
+
71
+ 1. Log in to [app.devin.ai](https://app.devin.ai) with GitHub
72
+ 2. Paste a one-liner in the browser console (auto-copied from the instruction page)
73
+ 3. The token is sent back to the CLI and cached
74
+
75
+ Subsequent runs use the cached token automatically. Tokens are stored at `~/.config/devin-bugs/token.json`.
76
+
77
+ For CI or headless environments, set `DEVIN_TOKEN` as an environment variable.
78
+
79
+ ## How it works
80
+
81
+ The CLI reverse-engineers Devin's internal PR review API:
82
+
83
+ 1. Authenticates via Devin's Auth0-based auth system
84
+ 2. Fetches the review digest from `GET /api/pr-review/digest`
85
+ 3. Parses review threads for Devin's "lifeguard" bug flags
86
+ 4. Filters to unresolved, non-outdated items
87
+ 5. Outputs formatted results
88
+
89
+ ### API endpoints used
90
+
91
+ | Endpoint | Purpose |
92
+ |----------|---------|
93
+ | `GET pr-review/digest?pr_path=...` | Full review data with flags, threads, checks |
94
+ | `GET pr-review/info?pr_path=...` | PR metadata |
95
+ | `GET pr-review/jobs?pr_path=...` | Review job status |
96
+
97
+ ## JSON output schema
98
+
99
+ ```typescript
100
+ interface Bug {
101
+ filePath: string; // "lib/apply/assist.ts"
102
+ startLine: number; // 124
103
+ endLine: number; // 136
104
+ side: "LEFT" | "RIGHT";
105
+ title: string; // Short description
106
+ description: string; // Full explanation
107
+ severity: string; // "severe" | "warning" | "info"
108
+ recommendation: string; // Suggested fix
109
+ type: "lifeguard-bug" | "lifeguard-analysis";
110
+ isResolved: boolean;
111
+ isOutdated: boolean;
112
+ htmlUrl: string | null; // Link to GitHub comment
113
+ }
114
+ ```
115
+
116
+ ## Project structure
117
+
118
+ ```
119
+ src/
120
+ cli.ts Entry point, arg parsing, orchestration
121
+ auth.ts Browser-based auth + token caching
122
+ api.ts Devin API client with retry on 401
123
+ filter.ts Bug extraction from digest response
124
+ format.ts Terminal (ANSI) and JSON formatters
125
+ parse-pr.ts PR URL/shorthand parser
126
+ types.ts TypeScript interfaces
127
+ config.ts Paths and constants
128
+ ```
129
+
130
+ ## Disclaimer
131
+
132
+ This tool uses Devin's internal API, which is not officially documented or supported. It may break if Devin changes their API. Use at your own risk.
133
+
134
+ ## License
135
+
136
+ MIT
package/bin/devin-bugs ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/cli.ts";
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "devin-bugs",
3
+ "version": "0.1.0",
4
+ "description": "CLI to extract unresolved bugs from Devin AI code reviews",
5
+ "type": "module",
6
+ "bin": {
7
+ "devin-bugs": "./bin/devin-bugs"
8
+ },
9
+ "scripts": {
10
+ "start": "bun src/cli.ts",
11
+ "typecheck": "bunx tsc --noEmit"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "src/",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "devin",
21
+ "code-review",
22
+ "cli",
23
+ "bugs",
24
+ "pr-review",
25
+ "ai"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/xCatalitY/devin-review-cli.git"
30
+ },
31
+ "homepage": "https://github.com/xCatalitY/devin-review-cli",
32
+ "bugs": "https://github.com/xCatalitY/devin-review-cli/issues",
33
+ "license": "MIT",
34
+ "author": "xCatalitY",
35
+ "engines": {
36
+ "bun": ">=1.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "latest",
40
+ "typescript": "^5.8.3"
41
+ }
42
+ }
package/src/api.ts ADDED
@@ -0,0 +1,81 @@
1
+ import { DEVIN_API_BASE } from "./config.js";
2
+ import type { DigestResponse } from "./types.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Error classes
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export class AuthExpiredError extends Error {
9
+ constructor() {
10
+ super("Authentication expired. Re-authenticating...");
11
+ this.name = "AuthExpiredError";
12
+ }
13
+ }
14
+
15
+ export class ApiError extends Error {
16
+ constructor(
17
+ public readonly status: number,
18
+ public readonly body: string
19
+ ) {
20
+ super(`Devin API error ${status}: ${body}`);
21
+ this.name = "ApiError";
22
+ }
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Generic request helper
27
+ // ---------------------------------------------------------------------------
28
+
29
+ async function apiRequest<T>(path: string, token: string): Promise<T> {
30
+ const url = `${DEVIN_API_BASE}/${path}`;
31
+ const res = await fetch(url, {
32
+ headers: {
33
+ Authorization: `Bearer ${token}`,
34
+ Accept: "application/json",
35
+ },
36
+ });
37
+
38
+ if (!res.ok) {
39
+ if (res.status === 401 || res.status === 403) {
40
+ throw new AuthExpiredError();
41
+ }
42
+ const body = await res.text().catch(() => "");
43
+ throw new ApiError(res.status, body);
44
+ }
45
+
46
+ return res.json() as Promise<T>;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Endpoints
51
+ // ---------------------------------------------------------------------------
52
+
53
+ export async function fetchDigest(
54
+ prPath: string,
55
+ token: string
56
+ ): Promise<DigestResponse> {
57
+ return apiRequest<DigestResponse>(
58
+ `pr-review/digest?pr_path=${encodeURIComponent(prPath)}`,
59
+ token
60
+ );
61
+ }
62
+
63
+ export async function fetchPRInfo(
64
+ prPath: string,
65
+ token: string
66
+ ): Promise<Record<string, unknown>> {
67
+ return apiRequest<Record<string, unknown>>(
68
+ `pr-review/info?pr_path=${encodeURIComponent(prPath)}`,
69
+ token
70
+ );
71
+ }
72
+
73
+ export async function fetchJobs(
74
+ prPath: string,
75
+ token: string
76
+ ): Promise<{ jobs: unknown[] }> {
77
+ return apiRequest<{ jobs: unknown[] }>(
78
+ `pr-review/jobs?pr_path=${encodeURIComponent(prPath)}`,
79
+ token
80
+ );
81
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,360 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { createServer, type Server } from "node:http";
4
+ import { execFile } from "node:child_process";
5
+ import {
6
+ TOKEN_PATH,
7
+ DEVIN_APP_URL,
8
+ TOKEN_REFRESH_MARGIN_SEC,
9
+ } from "./config.js";
10
+ import type { CachedToken } from "./types.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // JWT helpers (no library — just decode the payload for `exp`)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function base64UrlDecode(str: string): string {
17
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/");
18
+ return Buffer.from(padded, "base64").toString("utf-8");
19
+ }
20
+
21
+ function decodeTokenExpiry(jwt: string): number {
22
+ const parts = jwt.split(".");
23
+ if (parts.length !== 3) throw new Error("Invalid JWT format");
24
+ const payload = JSON.parse(base64UrlDecode(parts[1]!));
25
+ if (typeof payload.exp !== "number") throw new Error("JWT missing exp claim");
26
+ return payload.exp * 1000; // convert to epoch ms
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Token cache (disk)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function ensureDir(dirPath: string): void {
34
+ if (!existsSync(dirPath)) {
35
+ mkdirSync(dirPath, { recursive: true });
36
+ }
37
+ }
38
+
39
+ function readCachedToken(): CachedToken | null {
40
+ try {
41
+ if (!existsSync(TOKEN_PATH)) return null;
42
+ const raw = readFileSync(TOKEN_PATH, "utf-8");
43
+ const parsed = JSON.parse(raw) as CachedToken;
44
+ if (!parsed.accessToken || !parsed.expiresAt) return null;
45
+ return parsed;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function writeCachedToken(accessToken: string): CachedToken {
52
+ ensureDir(dirname(TOKEN_PATH));
53
+ const expiresAt = decodeTokenExpiry(accessToken);
54
+ const cached: CachedToken = {
55
+ accessToken,
56
+ obtainedAt: Date.now(),
57
+ expiresAt,
58
+ };
59
+ writeFileSync(TOKEN_PATH, JSON.stringify(cached, null, 2));
60
+ return cached;
61
+ }
62
+
63
+ function clearCachedToken(): void {
64
+ try {
65
+ if (existsSync(TOKEN_PATH)) unlinkSync(TOKEN_PATH);
66
+ } catch {
67
+ // ignore
68
+ }
69
+ }
70
+
71
+ function isTokenValid(cached: CachedToken): boolean {
72
+ return cached.expiresAt - Date.now() > TOKEN_REFRESH_MARGIN_SEC * 1000;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Open URL in system browser (safe — no shell interpolation)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ function openBrowser(url: string): void {
80
+ const opener =
81
+ process.platform === "darwin"
82
+ ? { cmd: "open", args: [url] }
83
+ : process.platform === "win32"
84
+ ? { cmd: "cmd", args: ["/c", "start", "", url] }
85
+ : { cmd: "xdg-open", args: [url] };
86
+
87
+ execFile(opener.cmd, opener.args, (err) => {
88
+ if (err) {
89
+ console.error(`\x1b[33m▸ Could not open browser automatically.\x1b[0m`);
90
+ console.error(` Open this URL manually: ${url}\n`);
91
+ }
92
+ });
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Local callback server
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * The capture page served at localhost. It instructs the user to:
101
+ * 1. Log in to Devin in a new tab
102
+ * 2. Paste a one-liner in the browser console that sends the token back
103
+ *
104
+ * This is the same pattern as many CLIs that can't do standard OAuth.
105
+ * The one-liner calls __HACK__getAccessToken() on app.devin.ai and
106
+ * POSTs the result to our localhost callback.
107
+ */
108
+ function buildCapturePage(port: number): string {
109
+ return `<!DOCTYPE html>
110
+ <html lang="en">
111
+ <head>
112
+ <meta charset="utf-8">
113
+ <title>devin-bugs — Login</title>
114
+ <style>
115
+ * { margin: 0; padding: 0; box-sizing: border-box; }
116
+ body {
117
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
118
+ background: #141414; color: #e0e0e0;
119
+ display: flex; align-items: center; justify-content: center;
120
+ min-height: 100vh; padding: 2rem;
121
+ }
122
+ .card {
123
+ background: #1e1e1e; border: 1px solid #333; border-radius: 12px;
124
+ padding: 2.5rem; max-width: 560px; width: 100%;
125
+ }
126
+ h1 { font-size: 1.25rem; color: #fff; margin-bottom: 0.5rem; }
127
+ .subtitle { color: #888; font-size: 0.9rem; margin-bottom: 1.5rem; }
128
+ .step {
129
+ display: flex; gap: 0.75rem; margin-bottom: 1.25rem;
130
+ padding: 0.75rem; border-radius: 8px; background: #252525;
131
+ }
132
+ .step-num {
133
+ flex-shrink: 0; width: 24px; height: 24px; border-radius: 50%;
134
+ background: #3b82f6; color: #fff; font-size: 0.75rem; font-weight: 700;
135
+ display: flex; align-items: center; justify-content: center;
136
+ }
137
+ .step-text { font-size: 0.9rem; line-height: 1.5; }
138
+ .step-text a { color: #60a5fa; text-decoration: none; }
139
+ .step-text a:hover { text-decoration: underline; }
140
+ code {
141
+ background: #0d1117; color: #7ee787; padding: 0.5rem 0.75rem;
142
+ border-radius: 6px; display: block; font-size: 0.8rem;
143
+ margin-top: 0.5rem; cursor: pointer; border: 1px solid #333;
144
+ word-break: break-all; position: relative;
145
+ }
146
+ code:hover { border-color: #3b82f6; }
147
+ code::after {
148
+ content: 'click to copy'; position: absolute; right: 8px; top: 8px;
149
+ font-size: 0.65rem; color: #888; font-family: sans-serif;
150
+ }
151
+ .success {
152
+ display: none; padding: 1rem; border-radius: 8px;
153
+ background: #052e16; border: 1px solid #16a34a; text-align: center;
154
+ }
155
+ .success h2 { color: #4ade80; font-size: 1rem; }
156
+ .success p { color: #86efac; font-size: 0.85rem; margin-top: 0.5rem; }
157
+ .waiting {
158
+ text-align: center; padding: 1rem; color: #888;
159
+ font-size: 0.85rem; margin-top: 0.5rem;
160
+ }
161
+ .dot { animation: pulse 1.5s infinite; }
162
+ @keyframes pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
163
+ </style>
164
+ </head>
165
+ <body>
166
+ <div class="card">
167
+ <h1>devin-bugs</h1>
168
+ <p class="subtitle">Authenticate with Devin to extract PR review data</p>
169
+
170
+ <div id="steps">
171
+ <div class="step">
172
+ <div class="step-num">1</div>
173
+ <div class="step-text">
174
+ <a href="${DEVIN_APP_URL}" target="_blank" rel="noopener">
175
+ Open app.devin.ai</a> and log in with GitHub
176
+ </div>
177
+ </div>
178
+
179
+ <div class="step">
180
+ <div class="step-num">2</div>
181
+ <div class="step-text">
182
+ Open the browser console (<strong>F12</strong> → Console tab) and paste:
183
+ <code id="snippet" onclick="copySnippet()">fetch('http://localhost:${port}/callback',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:await __HACK__getAccessToken()})}).then(()=>document.title='✓ Token sent!')</code>
184
+ </div>
185
+ </div>
186
+
187
+ <div class="waiting">
188
+ Waiting for token<span class="dot">...</span>
189
+ </div>
190
+ </div>
191
+
192
+ <div class="success" id="success">
193
+ <h2>✓ Authentication successful!</h2>
194
+ <p>You can close this tab and return to your terminal.</p>
195
+ </div>
196
+ </div>
197
+
198
+ <script>
199
+ function copySnippet() {
200
+ navigator.clipboard.writeText(document.getElementById('snippet').textContent);
201
+ const el = document.getElementById('snippet');
202
+ el.style.borderColor = '#4ade80';
203
+ setTimeout(() => el.style.borderColor = '#333', 1500);
204
+ }
205
+
206
+ // Poll the local server to check if token was received
207
+ async function poll() {
208
+ try {
209
+ const res = await fetch('/status');
210
+ const data = await res.json();
211
+ if (data.received) {
212
+ document.getElementById('steps').style.display = 'none';
213
+ document.getElementById('success').style.display = 'block';
214
+ return;
215
+ }
216
+ } catch {}
217
+ setTimeout(poll, 1500);
218
+ }
219
+ poll();
220
+ </script>
221
+ </body>
222
+ </html>`;
223
+ }
224
+
225
+ /**
226
+ * Start a local HTTP server that:
227
+ * - Serves the capture page at /
228
+ * - Receives the token at POST /callback (from the console one-liner)
229
+ * - Reports status at GET /status (for the page to poll)
230
+ */
231
+ function startCallbackServer(): Promise<{ token: string; server: Server }> {
232
+ return new Promise((resolve, reject) => {
233
+ let receivedToken: string | null = null;
234
+
235
+ const server = createServer((req, res) => {
236
+ // CORS headers for cross-origin fetch from app.devin.ai
237
+ res.setHeader("Access-Control-Allow-Origin", "*");
238
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
239
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
240
+
241
+ if (req.method === "OPTIONS") {
242
+ res.writeHead(204);
243
+ res.end();
244
+ return;
245
+ }
246
+
247
+ if (req.method === "GET" && req.url === "/status") {
248
+ res.writeHead(200, { "Content-Type": "application/json" });
249
+ res.end(JSON.stringify({ received: receivedToken !== null }));
250
+ return;
251
+ }
252
+
253
+ if (req.method === "POST" && req.url === "/callback") {
254
+ let body = "";
255
+ req.on("data", (chunk: Buffer) => (body += chunk.toString()));
256
+ req.on("end", () => {
257
+ try {
258
+ const data = JSON.parse(body) as { token?: string };
259
+ if (typeof data.token === "string" && data.token.length > 20) {
260
+ receivedToken = data.token;
261
+ res.writeHead(200, { "Content-Type": "application/json" });
262
+ res.end(JSON.stringify({ ok: true }));
263
+ // Resolve after a short delay to let the page poll /status
264
+ setTimeout(() => {
265
+ server.close();
266
+ resolve({ token: receivedToken!, server });
267
+ }, 500);
268
+ return;
269
+ }
270
+ } catch {}
271
+ res.writeHead(400, { "Content-Type": "application/json" });
272
+ res.end(JSON.stringify({ error: "Invalid token" }));
273
+ });
274
+ return;
275
+ }
276
+
277
+ // Serve the capture page
278
+ if (req.method === "GET" && (req.url === "/" || req.url === "/login")) {
279
+ const port = (server.address() as { port: number }).port;
280
+ res.writeHead(200, { "Content-Type": "text/html" });
281
+ res.end(buildCapturePage(port));
282
+ return;
283
+ }
284
+
285
+ res.writeHead(404);
286
+ res.end("Not found");
287
+ });
288
+
289
+ server.listen(0, "127.0.0.1", () => {
290
+ const addr = server.address() as { port: number };
291
+ const port = addr.port;
292
+
293
+ console.error(`\x1b[33m▸ Opening browser for Devin login...\x1b[0m`);
294
+ console.error(` Local server: http://localhost:${port}\n`);
295
+
296
+ openBrowser(`http://localhost:${port}`);
297
+
298
+ // Timeout after 5 minutes
299
+ setTimeout(() => {
300
+ if (!receivedToken) {
301
+ server.close();
302
+ reject(new Error("Login timed out after 5 minutes."));
303
+ }
304
+ }, 5 * 60 * 1000);
305
+ });
306
+
307
+ server.on("error", reject);
308
+ });
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Public API
313
+ // ---------------------------------------------------------------------------
314
+
315
+ export interface GetTokenOptions {
316
+ noCache?: boolean;
317
+ }
318
+
319
+ /**
320
+ * Get a valid Devin API auth token. Strategy:
321
+ * 1. DEVIN_TOKEN env var (for CI/scripts)
322
+ * 2. Cached token from disk (if not expired)
323
+ * 3. Interactive login via system browser + localhost callback
324
+ */
325
+ export async function getToken(opts?: GetTokenOptions): Promise<string> {
326
+ // 1. Environment variable override
327
+ const envToken = process.env["DEVIN_TOKEN"];
328
+ if (envToken && envToken.length > 0) {
329
+ return envToken;
330
+ }
331
+
332
+ // 2. Cached token
333
+ if (!opts?.noCache) {
334
+ const cached = readCachedToken();
335
+ if (cached && isTokenValid(cached)) {
336
+ return cached.accessToken;
337
+ }
338
+ }
339
+
340
+ // 3. Interactive login via browser
341
+ const { token } = await startCallbackServer();
342
+ console.error("\x1b[32m✓ Authentication successful!\x1b[0m\n");
343
+ writeCachedToken(token);
344
+ return token;
345
+ }
346
+
347
+ /** Force re-authentication by clearing cache and launching browser */
348
+ export async function forceReauth(): Promise<string> {
349
+ clearCachedToken();
350
+ const { token } = await startCallbackServer();
351
+ console.error("\x1b[32m✓ Authentication successful!\x1b[0m\n");
352
+ writeCachedToken(token);
353
+ return token;
354
+ }
355
+
356
+ /** Clear stored credentials */
357
+ export function clearAuth(): void {
358
+ clearCachedToken();
359
+ console.error("Cleared cached token.");
360
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { parseArgs } from "node:util";
4
+ import { parsePR } from "./parse-pr.js";
5
+ import { getToken, forceReauth } from "./auth.js";
6
+ import { fetchDigest, AuthExpiredError, ApiError } from "./api.js";
7
+ import { extractFlags } from "./filter.js";
8
+ import { formatTerminal, formatJSON } from "./format.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // CLI argument parsing
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const HELP = `
15
+ \x1b[1mdevin-bugs\x1b[0m — Extract unresolved bugs from Devin AI code reviews
16
+
17
+ \x1b[1mUsage:\x1b[0m
18
+ devin-bugs <pr> [options]
19
+
20
+ \x1b[1mArguments:\x1b[0m
21
+ pr GitHub PR URL or shorthand
22
+ Examples: owner/repo#123
23
+ https://github.com/owner/repo/pull/123
24
+ https://app.devin.ai/review/owner/repo/pull/123
25
+
26
+ \x1b[1mOptions:\x1b[0m
27
+ --json Output as JSON (for piping)
28
+ --all Include analysis/suggestions, not just bugs
29
+ --raw Dump raw API response (debug)
30
+ --no-cache Force re-authentication
31
+ --login Just authenticate, don't fetch anything
32
+ --logout Clear stored credentials
33
+ --help, -h Show this help
34
+ --version, -v Show version
35
+
36
+ \x1b[1mEnvironment:\x1b[0m
37
+ DEVIN_TOKEN Skip browser auth, use this token directly
38
+
39
+ \x1b[1mExamples:\x1b[0m
40
+ devin-bugs owner/repo#46
41
+ devin-bugs owner/repo#46 --json
42
+ devin-bugs owner/repo#46 --all --raw
43
+ DEVIN_TOKEN=xxx devin-bugs owner/repo#46
44
+ `;
45
+
46
+ function printHelp(): void {
47
+ console.log(HELP);
48
+ }
49
+
50
+ function printVersion(): void {
51
+ console.log("devin-bugs 0.1.0");
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Main
56
+ // ---------------------------------------------------------------------------
57
+
58
+ async function main(): Promise<void> {
59
+ let parsed;
60
+ try {
61
+ parsed = parseArgs({
62
+ allowPositionals: true,
63
+ options: {
64
+ json: { type: "boolean", default: false },
65
+ all: { type: "boolean", default: false },
66
+ raw: { type: "boolean", default: false },
67
+ "no-cache": { type: "boolean", default: false },
68
+ login: { type: "boolean", default: false },
69
+ logout: { type: "boolean", default: false },
70
+ help: { type: "boolean", short: "h", default: false },
71
+ version: { type: "boolean", short: "v", default: false },
72
+ },
73
+ });
74
+ } catch (err: any) {
75
+ console.error(`\x1b[31mError:\x1b[0m ${err.message}`);
76
+ process.exit(1);
77
+ }
78
+
79
+ const { values, positionals } = parsed;
80
+
81
+ if (values.help) {
82
+ printHelp();
83
+ return;
84
+ }
85
+ if (values.version) {
86
+ printVersion();
87
+ return;
88
+ }
89
+
90
+ // --logout: clear credentials and exit
91
+ if (values.logout) {
92
+ const { clearAuth } = await import("./auth.js");
93
+ clearAuth();
94
+ return;
95
+ }
96
+
97
+ // --login: just authenticate and exit
98
+ if (values.login) {
99
+ const token = await getToken({ noCache: values["no-cache"] });
100
+ console.error("\x1b[32m✓ Authenticated successfully.\x1b[0m");
101
+ console.error(` Token cached for future use.\n`);
102
+ // Show token expiry
103
+ try {
104
+ const payload = JSON.parse(
105
+ Buffer.from(token.split(".")[1]!, "base64url").toString()
106
+ );
107
+ const exp = new Date(payload.exp * 1000);
108
+ console.error(` Expires: ${exp.toLocaleString()}`);
109
+ } catch {
110
+ // ignore
111
+ }
112
+ return;
113
+ }
114
+
115
+ // Require a PR argument
116
+ if (positionals.length === 0) {
117
+ console.error("\x1b[31mError:\x1b[0m Missing PR argument.\n");
118
+ printHelp();
119
+ process.exit(1);
120
+ }
121
+
122
+ const prInput = positionals[0]!;
123
+ let pr;
124
+ try {
125
+ pr = parsePR(prInput);
126
+ } catch (err: any) {
127
+ console.error(`\x1b[31mError:\x1b[0m ${err.message}`);
128
+ process.exit(1);
129
+ }
130
+
131
+ // Get auth token
132
+ let token: string;
133
+ try {
134
+ token = await getToken({ noCache: values["no-cache"] });
135
+ } catch (err: any) {
136
+ console.error(`\x1b[31mAuth error:\x1b[0m ${err.message}`);
137
+ process.exit(1);
138
+ }
139
+
140
+ // Fetch digest (with one retry on auth failure)
141
+ let digest;
142
+ try {
143
+ digest = await fetchDigest(pr.prPath, token);
144
+ } catch (err) {
145
+ if (err instanceof AuthExpiredError) {
146
+ // Re-authenticate and retry
147
+ console.error("\x1b[33m▸ Token expired, re-authenticating...\x1b[0m");
148
+ try {
149
+ token = await forceReauth();
150
+ digest = await fetchDigest(pr.prPath, token);
151
+ } catch (retryErr: any) {
152
+ console.error(`\x1b[31mError:\x1b[0m ${retryErr.message}`);
153
+ process.exit(1);
154
+ }
155
+ } else if (err instanceof ApiError) {
156
+ if (err.status === 404) {
157
+ console.error(
158
+ `\x1b[31mError:\x1b[0m PR not found or no Devin review exists for ${pr.owner}/${pr.repo}#${pr.number}`
159
+ );
160
+ } else {
161
+ console.error(`\x1b[31mAPI error ${err.status}:\x1b[0m ${err.body}`);
162
+ }
163
+ process.exit(1);
164
+ } else {
165
+ throw err;
166
+ }
167
+ }
168
+
169
+ // --raw: dump full response
170
+ if (values.raw) {
171
+ console.log(JSON.stringify(digest, null, 2));
172
+ return;
173
+ }
174
+
175
+ // Extract and filter flags
176
+ const flags = extractFlags(digest!, {
177
+ includeAnalysis: values.all,
178
+ });
179
+
180
+ // Output
181
+ if (values.json) {
182
+ console.log(formatJSON(flags));
183
+ } else {
184
+ console.log(formatTerminal(flags, pr));
185
+ }
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Run
190
+ // ---------------------------------------------------------------------------
191
+
192
+ main().catch((err) => {
193
+ console.error(`\x1b[31mFatal error:\x1b[0m ${err.message ?? err}`);
194
+ process.exit(1);
195
+ });
package/src/config.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export const DEVIN_API_BASE = "https://app.devin.ai/api";
5
+ export const DEVIN_APP_URL = "https://app.devin.ai";
6
+ export const DEVIN_LOGIN_URL = "https://app.devin.ai/auth/login";
7
+
8
+ export const CONFIG_DIR = join(homedir(), ".config", "devin-bugs");
9
+ export const CACHE_DIR = join(homedir(), ".cache", "devin-bugs");
10
+ export const TOKEN_PATH = join(CONFIG_DIR, "token.json");
11
+ export const BROWSER_DATA_DIR = join(CACHE_DIR, "browser-profile");
12
+
13
+ /** Refresh token if less than this many seconds until expiry */
14
+ export const TOKEN_REFRESH_MARGIN_SEC = 300;
package/src/filter.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type { DigestResponse, ReviewThread, ReviewComment, LifeguardFlag } from "./types.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Parse hidden_header: <!-- devin-review-comment {JSON} -->
5
+ // ---------------------------------------------------------------------------
6
+
7
+ interface HiddenHeaderData {
8
+ id: string;
9
+ file_path: string;
10
+ start_line: number;
11
+ end_line: number;
12
+ side: "LEFT" | "RIGHT";
13
+ }
14
+
15
+ function parseHiddenHeader(header: string | null | undefined): HiddenHeaderData | null {
16
+ if (!header) return null;
17
+
18
+ // Format: <!-- devin-review-comment {"id":"...","file_path":"...","start_line":N,...} -->
19
+ const match = header.match(/<!--\s*devin-review-comment\s*(\{.+\})\s*-->/);
20
+ if (!match?.[1]) return null;
21
+
22
+ try {
23
+ const data = JSON.parse(match[1]) as Record<string, unknown>;
24
+ return {
25
+ id: String(data.id ?? ""),
26
+ file_path: String(data.file_path ?? ""),
27
+ start_line: typeof data.start_line === "number" ? data.start_line : 0,
28
+ end_line: typeof data.end_line === "number" ? data.end_line : 0,
29
+ side: data.side === "LEFT" ? "LEFT" : "RIGHT",
30
+ };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Parse bug body: emoji severity + bold title + description
38
+ // ---------------------------------------------------------------------------
39
+
40
+ function parseSeverity(body: string): string {
41
+ if (body.startsWith("🔴")) return "severe";
42
+ if (body.startsWith("🟡")) return "warning";
43
+ if (body.startsWith("🟢")) return "info";
44
+ return "info";
45
+ }
46
+
47
+ function parseTitle(body: string): string {
48
+ const match = body.match(/\*\*(.+?)\*\*/);
49
+ return match?.[1]?.trim() ?? body.split("\n")[0]?.slice(0, 120).trim() ?? "";
50
+ }
51
+
52
+ function parseDescription(body: string): string {
53
+ // Everything after the first line (title line)
54
+ const lines = body.split("\n");
55
+ return lines
56
+ .slice(1)
57
+ .join("\n")
58
+ .trim();
59
+ }
60
+
61
+ function parseRecommendation(body: string): string {
62
+ // Look for "Recommendation:" or "Fix:" or "→" sections
63
+ const match = body.match(/(?:recommendation|suggested fix|fix):\s*(.+?)(?:\n\n|\n#+|\n🔴|\n🟡|$)/is);
64
+ return match?.[1]?.trim() ?? "";
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Determine flag type from the comment body/id
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function determineType(id: string, body: string): LifeguardFlag["type"] {
72
+ if (id.startsWith("BUG_")) return "lifeguard-bug";
73
+ if (id.startsWith("ANALYSIS_") || id.startsWith("INFO_")) return "lifeguard-analysis";
74
+
75
+ // Fallback: check body for bug indicators
76
+ const lower = body.toLowerCase();
77
+ if (
78
+ lower.includes("potential bug") ||
79
+ lower.includes("🔴") ||
80
+ lower.includes("bug:") ||
81
+ lower.includes("race condition") ||
82
+ lower.includes("vulnerability") ||
83
+ lower.includes("double-charge") ||
84
+ lower.includes("sql injection")
85
+ ) {
86
+ return "lifeguard-bug";
87
+ }
88
+ return "lifeguard-analysis";
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Extract a LifeguardFlag from a Devin review thread
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function extractFlag(
96
+ thread: ReviewThread,
97
+ comment: ReviewComment
98
+ ): LifeguardFlag | null {
99
+ const header = parseHiddenHeader(comment.hidden_header);
100
+ const body = comment.body ?? "";
101
+ if (!body && !header) return null;
102
+
103
+ const id = header?.id ?? String(comment.devin_review_id ?? "");
104
+ const type = determineType(id, body);
105
+
106
+ return {
107
+ filePath: header?.file_path ?? "",
108
+ startLine: header?.start_line ?? null,
109
+ endLine: header?.end_line ?? null,
110
+ side: header?.side ?? "RIGHT",
111
+ title: parseTitle(body),
112
+ description: parseDescription(body),
113
+ severity: parseSeverity(body),
114
+ recommendation: parseRecommendation(body),
115
+ needsInvestigation: body.toLowerCase().includes("needs investigation"),
116
+ type,
117
+ isResolved: thread.is_resolved,
118
+ isOutdated: thread.is_outdated,
119
+ htmlUrl: comment.html_url ?? null,
120
+ };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Identify Devin review comments
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function isDevinComment(comment: ReviewComment): boolean {
128
+ return (
129
+ comment.devin_review_id != null ||
130
+ comment.hidden_header?.includes("devin-review-comment") === true ||
131
+ comment.author?.login === "devin-ai-integration" ||
132
+ comment.author?.login === "devin-ai-integration[bot]" ||
133
+ comment.author?.login === "devin-ai[bot]"
134
+ );
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Public API
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export interface FilterOptions {
142
+ /** Include lifeguard-analysis items, not just bugs */
143
+ includeAnalysis?: boolean;
144
+ /** Include resolved items */
145
+ includeResolved?: boolean;
146
+ /** Include outdated items */
147
+ includeOutdated?: boolean;
148
+ }
149
+
150
+ /**
151
+ * Extract all LifeguardFlags from a digest response.
152
+ * Default: only unresolved, non-outdated bugs.
153
+ */
154
+ export function extractFlags(
155
+ digest: DigestResponse,
156
+ opts?: FilterOptions
157
+ ): LifeguardFlag[] {
158
+ const flags: LifeguardFlag[] = [];
159
+
160
+ for (const thread of digest.review_threads) {
161
+ // Apply thread-level filters
162
+ if (!opts?.includeResolved && thread.is_resolved) continue;
163
+ if (!opts?.includeOutdated && thread.is_outdated) continue;
164
+
165
+ // Extract from first Devin comment in the thread
166
+ for (const comment of thread.comments) {
167
+ if (!isDevinComment(comment)) continue;
168
+
169
+ const flag = extractFlag(thread, comment);
170
+ if (flag) {
171
+ flags.push(flag);
172
+ break; // One flag per thread
173
+ }
174
+ }
175
+ }
176
+
177
+ // Filter by type
178
+ if (!opts?.includeAnalysis) {
179
+ return flags.filter((f) => f.type === "lifeguard-bug");
180
+ }
181
+
182
+ return flags;
183
+ }
package/src/format.ts ADDED
@@ -0,0 +1,162 @@
1
+ import type { LifeguardFlag, ParsedPR } from "./types.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // ANSI color helpers (no dependency)
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const c = {
8
+ reset: "\x1b[0m",
9
+ bold: "\x1b[1m",
10
+ dim: "\x1b[2m",
11
+ red: "\x1b[31m",
12
+ green: "\x1b[32m",
13
+ yellow: "\x1b[33m",
14
+ cyan: "\x1b[36m",
15
+ white: "\x1b[37m",
16
+ bgRed: "\x1b[41m",
17
+ bgYellow: "\x1b[43m",
18
+ bgBlue: "\x1b[44m",
19
+ };
20
+
21
+ function severityColor(severity: string): string {
22
+ switch (severity.toLowerCase()) {
23
+ case "severe":
24
+ case "critical":
25
+ return c.red;
26
+ case "warning":
27
+ return c.yellow;
28
+ default:
29
+ return c.cyan;
30
+ }
31
+ }
32
+
33
+ function severityBadge(severity: string): string {
34
+ const upper = severity.toUpperCase();
35
+ switch (severity.toLowerCase()) {
36
+ case "severe":
37
+ case "critical":
38
+ return `${c.bgRed}${c.white}${c.bold} ${upper} ${c.reset}`;
39
+ case "warning":
40
+ return `${c.bgYellow}${c.bold} ${upper} ${c.reset}`;
41
+ default:
42
+ return `${c.bgBlue}${c.white} ${upper} ${c.reset}`;
43
+ }
44
+ }
45
+
46
+ function typeBadge(type: LifeguardFlag["type"]): string {
47
+ if (type === "lifeguard-bug") {
48
+ return `${c.red}${c.bold}BUG${c.reset}`;
49
+ }
50
+ return `${c.cyan}${c.bold}INFO${c.reset}`;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Terminal formatter
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function formatLocation(flag: LifeguardFlag): string {
58
+ if (!flag.filePath) return "";
59
+ const file = `${c.cyan}${flag.filePath}${c.reset}`;
60
+ if (flag.startLine == null) return file;
61
+ const line =
62
+ flag.endLine != null && flag.endLine !== flag.startLine
63
+ ? `${c.dim}:${flag.startLine}-${flag.endLine}${c.reset}`
64
+ : `${c.dim}:${flag.startLine}${c.reset}`;
65
+ return `${file}${line}`;
66
+ }
67
+
68
+ function wrapText(text: string, indent: number, maxWidth: number): string {
69
+ const pad = " ".repeat(indent);
70
+ const words = text.split(/\s+/);
71
+ const lines: string[] = [];
72
+ let current = "";
73
+
74
+ for (const word of words) {
75
+ if (current.length + word.length + 1 > maxWidth - indent) {
76
+ lines.push(pad + current);
77
+ current = word;
78
+ } else {
79
+ current = current ? `${current} ${word}` : word;
80
+ }
81
+ }
82
+ if (current) lines.push(pad + current);
83
+ return lines.join("\n");
84
+ }
85
+
86
+ export function formatTerminal(flags: LifeguardFlag[], pr: ParsedPR): string {
87
+ const lines: string[] = [];
88
+
89
+ // Header
90
+ const bugCount = flags.filter((f) => f.type === "lifeguard-bug").length;
91
+ const analysisCount = flags.filter((f) => f.type === "lifeguard-analysis").length;
92
+
93
+ const parts: string[] = [];
94
+ if (bugCount > 0) parts.push(`${c.red}${c.bold}${bugCount} bug${bugCount === 1 ? "" : "s"}${c.reset}`);
95
+ if (analysisCount > 0) parts.push(`${c.cyan}${analysisCount} suggestion${analysisCount === 1 ? "" : "s"}${c.reset}`);
96
+
97
+ if (parts.length === 0) {
98
+ lines.push(`\n ${c.green}${c.bold}No unresolved bugs${c.reset} in ${c.dim}${pr.owner}/${pr.repo}#${pr.number}${c.reset}\n`);
99
+ return lines.join("\n");
100
+ }
101
+
102
+ lines.push(
103
+ `\n ${parts.join(", ")} in ${c.dim}${pr.owner}/${pr.repo}#${pr.number}${c.reset}\n`
104
+ );
105
+
106
+ // Each flag
107
+ for (const flag of flags) {
108
+ const badge = typeBadge(flag.type);
109
+ const location = formatLocation(flag);
110
+ const sev = severityBadge(flag.severity);
111
+
112
+ lines.push(` ${badge} ${location} ${sev}`);
113
+
114
+ if (flag.title) {
115
+ lines.push(` ${c.bold}${c.white}${flag.title}${c.reset}`);
116
+ }
117
+
118
+ // Show description (first paragraph, stripped of markdown/HTML noise)
119
+ if (flag.description && flag.description !== flag.title) {
120
+ const desc = flag.description
121
+ .replace(/<details>[\s\S]*?<\/details>/g, "") // remove <details> blocks
122
+ .replace(/<!--[\s\S]*?-->/g, "") // remove HTML comments
123
+ .replace(/^\[.*?\]\(.*?\)$/gm, "") // remove markdown links on own line
124
+ .replace(/<a[\s\S]*?<\/a>/g, "") // remove <a> tags
125
+ .replace(/<picture>[\s\S]*?<\/picture>/g, "") // remove <picture> tags
126
+ .replace(/<img[^>]*>/g, "") // remove <img> tags
127
+ .replace(/^---\s*$/gm, "") // remove horizontal rules
128
+ .replace(/^\*Was this helpful\?.*$/gm, "") // remove feedback prompt
129
+ .replace(/^#+\s*.+$/gm, "") // remove headings
130
+ .replace(/\*\*(.+?)\*\*/g, "$1") // remove bold markers
131
+ .replace(/`([^`]+)`/g, "$1") // remove inline code markers
132
+ .trim()
133
+ .split("\n\n")[0]! // first paragraph only
134
+ .split("\n")
135
+ .filter((l) => l.trim())
136
+ .join(" ")
137
+ .trim();
138
+
139
+ if (desc) {
140
+ lines.push(wrapText(`${c.dim}${desc}${c.reset}`, 2, 100));
141
+ }
142
+ }
143
+
144
+ if (flag.recommendation) {
145
+ lines.push(
146
+ ` ${c.green}→ ${flag.recommendation}${c.reset}`
147
+ );
148
+ }
149
+
150
+ lines.push(""); // blank line between flags
151
+ }
152
+
153
+ return lines.join("\n");
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // JSON formatter
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export function formatJSON(flags: LifeguardFlag[]): string {
161
+ return JSON.stringify(flags, null, 2);
162
+ }
@@ -0,0 +1,33 @@
1
+ import type { ParsedPR } from "./types.js";
2
+
3
+ const GITHUB_URL_RE =
4
+ /(?:https?:\/\/)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/;
5
+ const SHORTHAND_RE = /^([^/#]+)\/([^/#]+)#(\d+)$/;
6
+ const PATH_RE = /^([^/#]+)\/([^/#]+)\/pull\/(\d+)$/;
7
+
8
+ /** Also accept Devin review URLs: app.devin.ai/review/owner/repo/pull/123 */
9
+ const DEVIN_URL_RE =
10
+ /(?:https?:\/\/)?app\.devin\.ai\/review\/([^/]+)\/([^/]+)\/pull\/(\d+)/;
11
+
12
+ export function parsePR(input: string): ParsedPR {
13
+ const match =
14
+ input.match(GITHUB_URL_RE) ??
15
+ input.match(DEVIN_URL_RE) ??
16
+ input.match(SHORTHAND_RE) ??
17
+ input.match(PATH_RE);
18
+
19
+ if (!match) {
20
+ throw new Error(
21
+ `Invalid PR reference: ${input}\n` +
22
+ `Expected: owner/repo#123 or https://github.com/owner/repo/pull/123`
23
+ );
24
+ }
25
+
26
+ const [, owner, repo, num] = match;
27
+ return {
28
+ owner: owner!,
29
+ repo: repo!,
30
+ number: parseInt(num!, 10),
31
+ prPath: `github.com/${owner}/${repo}/pull/${num}`,
32
+ };
33
+ }
package/src/types.ts ADDED
@@ -0,0 +1,106 @@
1
+ // ---------------------------------------------------------------------------
2
+ // PR reference
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface ParsedPR {
6
+ owner: string;
7
+ repo: string;
8
+ number: number;
9
+ /** e.g. "github.com/owner/repo/pull/123" */
10
+ prPath: string;
11
+ }
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Cached auth token
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export interface CachedToken {
18
+ accessToken: string;
19
+ /** epoch ms when token was obtained */
20
+ obtainedAt: number;
21
+ /** epoch ms when token expires (from JWT `exp` claim) */
22
+ expiresAt: number;
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Devin Digest API response (partial — fields we care about)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface DigestResponse {
30
+ id: number;
31
+ title: string;
32
+ state: string;
33
+ author?: { login: string; avatar_url?: string; is_bot?: boolean };
34
+ head_ref: string;
35
+ base_ref: string;
36
+ additions: number;
37
+ deletions: number;
38
+ review_threads: ReviewThread[];
39
+ comments: ReviewComment[];
40
+ reviews: Review[];
41
+ checks: Check[];
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ export interface ReviewThread {
46
+ is_resolved: boolean;
47
+ is_outdated: boolean;
48
+ resolved_by?: { login: string; avatar_url?: string } | null;
49
+ comments: ReviewComment[];
50
+ }
51
+
52
+ export interface ReviewComment {
53
+ id: number | string;
54
+ body: string;
55
+ body_html?: string;
56
+ /** Non-null means this is a Devin review comment */
57
+ devin_review_id?: string | null;
58
+ /** Structured metadata header hidden from display */
59
+ hidden_header?: string | null;
60
+ html_url?: string | null;
61
+ author?: { login: string; avatar_url?: string; is_bot?: boolean };
62
+ pull_request_review?: { id: number; state: string } | null;
63
+ reaction_groups?: unknown[];
64
+ [key: string]: unknown;
65
+ }
66
+
67
+ export interface Review {
68
+ id: number;
69
+ body: string;
70
+ body_html?: string;
71
+ state: string;
72
+ author?: { login: string; avatar_url?: string; is_bot?: boolean };
73
+ devin_review_id?: string | null;
74
+ [key: string]: unknown;
75
+ }
76
+
77
+ export interface Check {
78
+ id: string;
79
+ name: string;
80
+ status: string;
81
+ conclusion: string | null;
82
+ workflow_name?: string;
83
+ is_required?: boolean;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Extracted bug/flag
88
+ // ---------------------------------------------------------------------------
89
+
90
+ export interface LifeguardFlag {
91
+ filePath: string;
92
+ startLine: number | null;
93
+ endLine: number | null;
94
+ side: "LEFT" | "RIGHT";
95
+ title: string;
96
+ description: string;
97
+ severity: string;
98
+ recommendation: string;
99
+ needsInvestigation: boolean;
100
+ type: "lifeguard-bug" | "lifeguard-analysis";
101
+ /** Source thread resolution status */
102
+ isResolved: boolean;
103
+ isOutdated: boolean;
104
+ /** URL to the comment on GitHub */
105
+ htmlUrl: string | null;
106
+ }