devin-bugs 0.1.1 → 0.2.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/dist/cli.js ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ // @bun
3
+ var m=Object.defineProperty;var f=(z)=>z;function g(z,Q){this[z]=f.bind(null,Q)}var h=(z,Q)=>{for(var X in Q)m(z,X,{get:Q[X],enumerable:!0,configurable:!0,set:g.bind(Q,X)})};var j=(z,Q)=>()=>(z&&(Q=z(z=0)),Q);import{homedir as C}from"node:os";import{join as U}from"node:path";var R="https://app.devin.ai/api",_="https://app.devin.ai",d,c,B,Pz,q=300;var O=j(()=>{d=U(C(),".config","devin-bugs"),c=U(C(),".cache","devin-bugs"),B=U(d,"token.json"),Pz=U(c,"browser-profile")});var E={};h(E,{getToken:()=>K,forceReauth:()=>N,clearAuth:()=>Mz});import{existsSync as D,mkdirSync as n,readFileSync as o,writeFileSync as s,unlinkSync as a}from"node:fs";import{dirname as t}from"node:path";import{createServer as r}from"node:http";import{execFile as e}from"node:child_process";function zz(z){let Q=z.replace(/-/g,"+").replace(/_/g,"/");return Buffer.from(Q,"base64").toString("utf-8")}function Qz(z){let Q=z.split(".");if(Q.length!==3)throw Error("Invalid JWT format");let X=JSON.parse(zz(Q[1]));if(typeof X.exp!=="number")throw Error("JWT missing exp claim");return X.exp*1000}function Xz(z){if(!D(z))n(z,{recursive:!0})}function Yz(){try{if(!D(B))return null;let z=o(B,"utf-8"),Q=JSON.parse(z);if(!Q.accessToken||!Q.expiresAt)return null;return Q}catch{return null}}function A(z){Xz(t(B));let Q=Qz(z),X={accessToken:z,obtainedAt:Date.now(),expiresAt:Q};return s(B,JSON.stringify(X,null,2)),X}function P(){try{if(D(B))a(B)}catch{}}function Zz(z){return z.expiresAt-Date.now()>q*1000}function $z(z){let Q=process.platform==="darwin"?{cmd:"open",args:[z]}:process.platform==="win32"?{cmd:"cmd",args:["/c","start","",z]}:{cmd:"xdg-open",args:[z]};e(Q.cmd,Q.args,(X)=>{if(X)console.error("\x1B[33m▸ Could not open browser automatically.\x1B[0m"),console.error(` Open this URL manually: ${z}
4
+ `)})}function Jz(z){return`<!DOCTYPE html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8">
8
+ <title>devin-bugs — Login</title>
9
+ <style>
10
+ * { margin: 0; padding: 0; box-sizing: border-box; }
11
+ body {
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
13
+ background: #141414; color: #e0e0e0;
14
+ display: flex; align-items: center; justify-content: center;
15
+ min-height: 100vh; padding: 2rem;
16
+ }
17
+ .card {
18
+ background: #1e1e1e; border: 1px solid #333; border-radius: 12px;
19
+ padding: 2.5rem; max-width: 560px; width: 100%;
20
+ }
21
+ h1 { font-size: 1.25rem; color: #fff; margin-bottom: 0.5rem; }
22
+ .subtitle { color: #888; font-size: 0.9rem; margin-bottom: 1.5rem; }
23
+ .step {
24
+ display: flex; gap: 0.75rem; margin-bottom: 1.25rem;
25
+ padding: 0.75rem; border-radius: 8px; background: #252525;
26
+ }
27
+ .step-num {
28
+ flex-shrink: 0; width: 24px; height: 24px; border-radius: 50%;
29
+ background: #3b82f6; color: #fff; font-size: 0.75rem; font-weight: 700;
30
+ display: flex; align-items: center; justify-content: center;
31
+ }
32
+ .step-text { font-size: 0.9rem; line-height: 1.5; }
33
+ .step-text a { color: #60a5fa; text-decoration: none; }
34
+ .step-text a:hover { text-decoration: underline; }
35
+ code {
36
+ background: #0d1117; color: #7ee787; padding: 0.5rem 0.75rem;
37
+ border-radius: 6px; display: block; font-size: 0.8rem;
38
+ margin-top: 0.5rem; cursor: pointer; border: 1px solid #333;
39
+ word-break: break-all; position: relative;
40
+ }
41
+ code:hover { border-color: #3b82f6; }
42
+ code::after {
43
+ content: 'click to copy'; position: absolute; right: 8px; top: 8px;
44
+ font-size: 0.65rem; color: #888; font-family: sans-serif;
45
+ }
46
+ .success {
47
+ display: none; padding: 1rem; border-radius: 8px;
48
+ background: #052e16; border: 1px solid #16a34a; text-align: center;
49
+ }
50
+ .success h2 { color: #4ade80; font-size: 1rem; }
51
+ .success p { color: #86efac; font-size: 0.85rem; margin-top: 0.5rem; }
52
+ .waiting {
53
+ text-align: center; padding: 1rem; color: #888;
54
+ font-size: 0.85rem; margin-top: 0.5rem;
55
+ }
56
+ .dot { animation: pulse 1.5s infinite; }
57
+ @keyframes pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 1; } }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <div class="card">
62
+ <h1>devin-bugs</h1>
63
+ <p class="subtitle">Authenticate with Devin to extract PR review data</p>
64
+
65
+ <div id="steps">
66
+ <div class="step">
67
+ <div class="step-num">1</div>
68
+ <div class="step-text">
69
+ <a href="${_}" target="_blank" rel="noopener">
70
+ Open app.devin.ai</a> and log in with GitHub
71
+ </div>
72
+ </div>
73
+
74
+ <div class="step">
75
+ <div class="step-num">2</div>
76
+ <div class="step-text">
77
+ Open the browser console (<strong>F12</strong> → Console tab) and paste:
78
+ <code id="snippet" onclick="copySnippet()">fetch('http://localhost:${z}/callback',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:await __HACK__getAccessToken()})}).then(()=>document.title='✓ Token sent!')</code>
79
+ </div>
80
+ </div>
81
+
82
+ <div class="waiting">
83
+ Waiting for token<span class="dot">...</span>
84
+ </div>
85
+ </div>
86
+
87
+ <div class="success" id="success">
88
+ <h2>✓ Authentication successful!</h2>
89
+ <p>You can close this tab and return to your terminal.</p>
90
+ </div>
91
+ </div>
92
+
93
+ <script>
94
+ function copySnippet() {
95
+ navigator.clipboard.writeText(document.getElementById('snippet').textContent);
96
+ const el = document.getElementById('snippet');
97
+ el.style.borderColor = '#4ade80';
98
+ setTimeout(() => el.style.borderColor = '#333', 1500);
99
+ }
100
+
101
+ // Poll the local server to check if token was received
102
+ async function poll() {
103
+ try {
104
+ const res = await fetch('/status');
105
+ const data = await res.json();
106
+ if (data.received) {
107
+ document.getElementById('steps').style.display = 'none';
108
+ document.getElementById('success').style.display = 'block';
109
+ return;
110
+ }
111
+ } catch {}
112
+ setTimeout(poll, 1500);
113
+ }
114
+ poll();
115
+ </script>
116
+ </body>
117
+ </html>`}function w(){return new Promise((z,Q)=>{let X=null,Y=r((J,$)=>{if($.setHeader("Access-Control-Allow-Origin","*"),$.setHeader("Access-Control-Allow-Methods","POST, GET, OPTIONS"),$.setHeader("Access-Control-Allow-Headers","Content-Type"),J.method==="OPTIONS"){$.writeHead(204),$.end();return}if(J.method==="GET"&&J.url==="/status"){$.writeHead(200,{"Content-Type":"application/json"}),$.end(JSON.stringify({received:X!==null}));return}if(J.method==="POST"&&J.url==="/callback"){let M="";J.on("data",(G)=>M+=G.toString()),J.on("end",()=>{try{let G=JSON.parse(M);if(typeof G.token==="string"&&G.token.length>20){X=G.token,$.writeHead(200,{"Content-Type":"application/json"}),$.end(JSON.stringify({ok:!0})),setTimeout(()=>{Y.close(),z({token:X,server:Y})},500);return}}catch{}$.writeHead(400,{"Content-Type":"application/json"}),$.end(JSON.stringify({error:"Invalid token"}))});return}if(J.method==="GET"&&(J.url==="/"||J.url==="/login")){let M=Y.address().port;$.writeHead(200,{"Content-Type":"text/html"}),$.end(Jz(M));return}$.writeHead(404),$.end("Not found")});Y.listen(0,"127.0.0.1",()=>{let $=Y.address().port;console.error("\x1B[33m▸ Opening browser for Devin login...\x1B[0m"),console.error(` Local server: http://localhost:${$}
118
+ `),$z(`http://localhost:${$}`),setTimeout(()=>{if(!X)Y.close(),Q(Error("Login timed out after 5 minutes."))},300000)}),Y.on("error",Q)})}async function K(z){let Q=process.env.DEVIN_TOKEN;if(Q&&Q.length>0)return Q;if(!z?.noCache){let Y=Yz();if(Y&&Zz(Y))return Y.accessToken}let{token:X}=await w();return console.error(`\x1B[32m✓ Authentication successful!\x1B[0m
119
+ `),A(X),X}async function N(){P();let{token:z}=await w();return console.error(`\x1B[32m✓ Authentication successful!\x1B[0m
120
+ `),A(z),z}function Mz(){P(),console.error("Cleared cached token.")}var S=j(()=>{O()});import{parseArgs as Hz}from"util";var u=/(?:https?:\/\/)?github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/,p=/^([^/#]+)\/([^/#]+)#(\d+)$/,l=/^([^/#]+)\/([^/#]+)\/pull\/(\d+)$/,i=/(?:https?:\/\/)?app\.devin\.ai\/review\/([^/]+)\/([^/]+)\/pull\/(\d+)/;function x(z){let Q=z.match(u)??z.match(i)??z.match(p)??z.match(l);if(!Q)throw Error(`Invalid PR reference: ${z}
121
+ Expected: owner/repo#123 or https://github.com/owner/repo/pull/123`);let[,X,Y,J]=Q;return{owner:X,repo:Y,number:parseInt(J,10),prPath:`github.com/${X}/${Y}/pull/${J}`}}S();O();class F extends Error{constructor(){super("Authentication expired. Re-authenticating...");this.name="AuthExpiredError"}}class I extends Error{status;body;constructor(z,Q){super(`Devin API error ${z}: ${Q}`);this.status=z;this.body=Q;this.name="ApiError"}}async function Wz(z,Q){let X=`${R}/${z}`,Y=await fetch(X,{headers:{Authorization:`Bearer ${Q}`,Accept:"application/json"}});if(!Y.ok){if(Y.status===401||Y.status===403)throw new F;let J=await Y.text().catch(()=>"");throw new I(Y.status,J)}return Y.json()}async function H(z,Q){return Wz(`pr-review/digest?pr_path=${encodeURIComponent(z)}`,Q)}function Gz(z){if(!z)return null;let Q=z.match(/<!--\s*devin-review-comment\s*(\{.+\})\s*-->/);if(!Q?.[1])return null;try{let X=JSON.parse(Q[1]);return{id:String(X.id??""),file_path:String(X.file_path??""),start_line:typeof X.start_line==="number"?X.start_line:0,end_line:typeof X.end_line==="number"?X.end_line:0,side:X.side==="LEFT"?"LEFT":"RIGHT"}}catch{return null}}function Bz(z){if(z.startsWith("\uD83D\uDD34"))return"severe";if(z.startsWith("\uD83D\uDFE1"))return"warning";if(z.startsWith("\uD83D\uDFE2"))return"info";return"info"}function Vz(z){return z.match(/\*\*(.+?)\*\*/)?.[1]?.trim()??z.split(`
122
+ `)[0]?.slice(0,120).trim()??""}function Lz(z){return z.split(`
123
+ `).slice(1).join(`
124
+ `).trim()}function Uz(z){return z.match(/(?:recommendation|suggested fix|fix):\s*(.+?)(?:\n\n|\n#+|\n🔴|\n🟡|$)/is)?.[1]?.trim()??""}function Kz(z,Q){if(z.startsWith("BUG_"))return"lifeguard-bug";if(z.startsWith("ANALYSIS_")||z.startsWith("INFO_"))return"lifeguard-analysis";let X=Q.toLowerCase();if(X.includes("potential bug")||X.includes("\uD83D\uDD34")||X.includes("bug:")||X.includes("race condition")||X.includes("vulnerability")||X.includes("double-charge")||X.includes("sql injection"))return"lifeguard-bug";return"lifeguard-analysis"}function Fz(z,Q){let X=Gz(Q.hidden_header),Y=Q.body??"";if(!Y&&!X)return null;let J=X?.id??String(Q.devin_review_id??""),$=Kz(J,Y);return{filePath:X?.file_path??"",startLine:X?.start_line??null,endLine:X?.end_line??null,side:X?.side??"RIGHT",title:Vz(Y),description:Lz(Y),severity:Bz(Y),recommendation:Uz(Y),needsInvestigation:Y.toLowerCase().includes("needs investigation"),type:$,isResolved:z.is_resolved,isOutdated:z.is_outdated,htmlUrl:Q.html_url??null}}function Iz(z){return z.devin_review_id!=null||z.hidden_header?.includes("devin-review-comment")===!0||z.author?.login==="devin-ai-integration"||z.author?.login==="devin-ai-integration[bot]"||z.author?.login==="devin-ai[bot]"}function b(z,Q){let X=[];for(let Y of z.review_threads){if(!Q?.includeResolved&&Y.is_resolved)continue;if(!Q?.includeOutdated&&Y.is_outdated)continue;for(let J of Y.comments){if(!Iz(J))continue;let $=Fz(Y,J);if($){X.push($);break}}}if(!Q?.includeAnalysis)return X.filter((Y)=>Y.type==="lifeguard-bug");return X}var Z={reset:"\x1B[0m",bold:"\x1B[1m",dim:"\x1B[2m",red:"\x1B[31m",green:"\x1B[32m",yellow:"\x1B[33m",cyan:"\x1B[36m",white:"\x1B[37m",bgRed:"\x1B[41m",bgYellow:"\x1B[43m",bgBlue:"\x1B[44m"};function Oz(z){let Q=z.toUpperCase();switch(z.toLowerCase()){case"severe":case"critical":return`${Z.bgRed}${Z.white}${Z.bold} ${Q} ${Z.reset}`;case"warning":return`${Z.bgYellow}${Z.bold} ${Q} ${Z.reset}`;default:return`${Z.bgBlue}${Z.white} ${Q} ${Z.reset}`}}function Dz(z){if(z==="lifeguard-bug")return`${Z.red}${Z.bold}BUG${Z.reset}`;return`${Z.cyan}${Z.bold}INFO${Z.reset}`}function Nz(z){if(!z.filePath)return"";let Q=`${Z.cyan}${z.filePath}${Z.reset}`;if(z.startLine==null)return Q;let X=z.endLine!=null&&z.endLine!==z.startLine?`${Z.dim}:${z.startLine}-${z.endLine}${Z.reset}`:`${Z.dim}:${z.startLine}${Z.reset}`;return`${Q}${X}`}function Sz(z,Q,X){let Y=" ".repeat(Q),J=z.split(/\s+/),$=[],M="";for(let G of J)if(M.length+G.length+1>X-Q)$.push(Y+M),M=G;else M=M?`${M} ${G}`:G;if(M)$.push(Y+M);return $.join(`
125
+ `)}function T(z,Q){let X=[],Y=z.filter((M)=>M.type==="lifeguard-bug").length,J=z.filter((M)=>M.type==="lifeguard-analysis").length,$=[];if(Y>0)$.push(`${Z.red}${Z.bold}${Y} bug${Y===1?"":"s"}${Z.reset}`);if(J>0)$.push(`${Z.cyan}${J} suggestion${J===1?"":"s"}${Z.reset}`);if($.length===0)return X.push(`
126
+ ${Z.green}${Z.bold}No unresolved bugs${Z.reset} in ${Z.dim}${Q.owner}/${Q.repo}#${Q.number}${Z.reset}
127
+ `),X.join(`
128
+ `);X.push(`
129
+ ${$.join(", ")} in ${Z.dim}${Q.owner}/${Q.repo}#${Q.number}${Z.reset}
130
+ `);for(let M of z){let G=Dz(M.type),W=Nz(M),V=Oz(M.severity);if(X.push(` ${G} ${W} ${V}`),M.title)X.push(` ${Z.bold}${Z.white}${M.title}${Z.reset}`);if(M.description&&M.description!==M.title){let L=M.description.replace(/<details>[\s\S]*?<\/details>/g,"").replace(/<!--[\s\S]*?-->/g,"").replace(/^\[.*?\]\(.*?\)$/gm,"").replace(/<a[\s\S]*?<\/a>/g,"").replace(/<picture>[\s\S]*?<\/picture>/g,"").replace(/<img[^>]*>/g,"").replace(/^---\s*$/gm,"").replace(/^\*Was this helpful\?.*$/gm,"").replace(/^#+\s*.+$/gm,"").replace(/\*\*(.+?)\*\*/g,"$1").replace(/`([^`]+)`/g,"$1").trim().split(`
131
+
132
+ `)[0].split(`
133
+ `).filter((y)=>y.trim()).join(" ").trim();if(L)X.push(Sz(`${Z.dim}${L}${Z.reset}`,2,100))}if(M.recommendation)X.push(` ${Z.green}→ ${M.recommendation}${Z.reset}`);X.push("")}return X.join(`
134
+ `)}function v(z){return JSON.stringify(z,null,2)}var jz=`
135
+ \x1B[1mdevin-bugs\x1B[0m \u2014 Extract unresolved bugs from Devin AI code reviews
136
+
137
+ \x1B[1mUsage:\x1B[0m
138
+ devin-bugs <pr> [options]
139
+
140
+ \x1B[1mArguments:\x1B[0m
141
+ pr GitHub PR URL or shorthand
142
+ Examples: owner/repo#123
143
+ https://github.com/owner/repo/pull/123
144
+ https://app.devin.ai/review/owner/repo/pull/123
145
+
146
+ \x1B[1mOptions:\x1B[0m
147
+ --json Output as JSON (for piping)
148
+ --all Include analysis/suggestions, not just bugs
149
+ --raw Dump raw API response (debug)
150
+ --no-cache Force re-authentication
151
+ --login Just authenticate, don't fetch anything
152
+ --logout Clear stored credentials
153
+ --help, -h Show this help
154
+ --version, -v Show version
155
+
156
+ \x1B[1mEnvironment:\x1B[0m
157
+ DEVIN_TOKEN Skip browser auth, use this token directly
158
+
159
+ \x1B[1mExamples:\x1B[0m
160
+ devin-bugs owner/repo#46
161
+ devin-bugs owner/repo#46 --json
162
+ devin-bugs owner/repo#46 --all --raw
163
+ DEVIN_TOKEN=xxx devin-bugs owner/repo#46
164
+ `;function k(){console.log(jz)}function xz(){console.log("devin-bugs 0.1.0")}async function Cz(){let z;try{z=Hz({allowPositionals:!0,options:{json:{type:"boolean",default:!1},all:{type:"boolean",default:!1},raw:{type:"boolean",default:!1},"no-cache":{type:"boolean",default:!1},login:{type:"boolean",default:!1},logout:{type:"boolean",default:!1},help:{type:"boolean",short:"h",default:!1},version:{type:"boolean",short:"v",default:!1}}})}catch(W){console.error(`\x1B[31mError:\x1B[0m ${W.message}`),process.exit(1)}let{values:Q,positionals:X}=z;if(Q.help){k();return}if(Q.version){xz();return}if(Q.logout){let{clearAuth:W}=await Promise.resolve().then(() => (S(),E));W();return}if(Q.login){let W=await K({noCache:Q["no-cache"]});console.error("\x1B[32m\u2713 Authenticated successfully.\x1B[0m"),console.error(` Token cached for future use.
165
+ `);try{let V=JSON.parse(Buffer.from(W.split(".")[1],"base64url").toString()),L=new Date(V.exp*1000);console.error(` Expires: ${L.toLocaleString()}`)}catch{}return}if(X.length===0)console.error(`\x1B[31mError:\x1B[0m Missing PR argument.
166
+ `),k(),process.exit(1);let Y=X[0],J;try{J=x(Y)}catch(W){console.error(`\x1B[31mError:\x1B[0m ${W.message}`),process.exit(1)}let $;try{$=await K({noCache:Q["no-cache"]})}catch(W){console.error(`\x1B[31mAuth error:\x1B[0m ${W.message}`),process.exit(1)}let M;try{M=await H(J.prPath,$)}catch(W){if(W instanceof F){console.error("\x1B[33m\u25B8 Token expired, re-authenticating...\x1B[0m");try{$=await N(),M=await H(J.prPath,$)}catch(V){console.error(`\x1B[31mError:\x1B[0m ${V.message}`),process.exit(1)}}else if(W instanceof I){if(W.status===404)console.error(`\x1B[31mError:\x1B[0m PR not found or no Devin review exists for ${J.owner}/${J.repo}#${J.number}`);else console.error(`\x1B[31mAPI error ${W.status}:\x1B[0m ${W.body}`);process.exit(1)}else throw W}if(Q.raw){console.log(JSON.stringify(M,null,2));return}let G=b(M,{includeAnalysis:Q.all});if(Q.json)console.log(v(G));else console.log(T(G,J))}Cz().catch((z)=>{console.error(`\x1B[31mFatal error:\x1B[0m ${z.message??z}`),process.exit(1)});
package/package.json CHANGED
@@ -1,20 +1,19 @@
1
1
  {
2
2
  "name": "devin-bugs",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "CLI to extract unresolved bugs from Devin AI code reviews",
5
5
  "type": "module",
6
6
  "bin": {
7
- "devin-bugs": "./bin/devin-bugs.mjs"
7
+ "devin-bugs": "dist/cli.js"
8
8
  },
9
9
  "scripts": {
10
+ "build": "bun build src/cli.ts --outfile dist/cli.js --target node --minify && sed -i '1s|#!/usr/bin/env bun|#!/usr/bin/env node|' dist/cli.js && chmod +x dist/cli.js",
10
11
  "start": "bun src/cli.ts",
11
- "typecheck": "bunx tsc --noEmit"
12
+ "typecheck": "bunx tsc --noEmit",
13
+ "prepublishOnly": "npm run build"
12
14
  },
13
15
  "files": [
14
- "bin/",
15
- "src/",
16
- "README.md",
17
- "LICENSE"
16
+ "dist/"
18
17
  ],
19
18
  "keywords": [
20
19
  "devin",
@@ -26,14 +25,14 @@
26
25
  ],
27
26
  "repository": {
28
27
  "type": "git",
29
- "url": "https://github.com/xCatalitY/devin-review-cli.git"
28
+ "url": "git+https://github.com/xCatalitY/devin-review-cli.git"
30
29
  },
31
30
  "homepage": "https://github.com/xCatalitY/devin-review-cli",
32
31
  "bugs": "https://github.com/xCatalitY/devin-review-cli/issues",
33
32
  "license": "MIT",
34
33
  "author": "xCatalitY",
35
34
  "engines": {
36
- "bun": ">=1.0.0"
35
+ "node": ">=18.0.0"
37
36
  },
38
37
  "devDependencies": {
39
38
  "@types/bun": "latest",
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bun
2
- import "../src/cli.ts";
package/src/api.ts DELETED
@@ -1,81 +0,0 @@
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 DELETED
@@ -1,360 +0,0 @@
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 DELETED
@@ -1,195 +0,0 @@
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 DELETED
@@ -1,14 +0,0 @@
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 DELETED
@@ -1,183 +0,0 @@
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 DELETED
@@ -1,162 +0,0 @@
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
- }
package/src/parse-pr.ts DELETED
@@ -1,33 +0,0 @@
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 DELETED
@@ -1,106 +0,0 @@
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
- }