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