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 +166 -0
- package/package.json +8 -9
- package/bin/devin-bugs.mjs +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,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.
|
|
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": "
|
|
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",
|
|
@@ -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
|
-
"
|
|
35
|
+
"node": ">=18.0.0"
|
|
37
36
|
},
|
|
38
37
|
"devDependencies": {
|
|
39
38
|
"@types/bun": "latest",
|
package/bin/devin-bugs.mjs
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
|
-
}
|