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