@adriandmitroca/relay 0.0.2
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/README.md +121 -0
- package/dist/assets/index-BcE2ldjQ.css +1 -0
- package/dist/assets/index-RaJgQa_m.js +15 -0
- package/dist/index.html +16 -0
- package/package.json +47 -0
- package/scripts/install-service.sh +52 -0
- package/scripts/uninstall-service.sh +10 -0
- package/src/api/config.ts +481 -0
- package/src/api/issues.ts +81 -0
- package/src/api/middleware.ts +14 -0
- package/src/api/router.ts +31 -0
- package/src/cli.ts +184 -0
- package/src/config.ts +195 -0
- package/src/constants.ts +21 -0
- package/src/daemon.ts +1096 -0
- package/src/dashboard.ts +175 -0
- package/src/db.ts +718 -0
- package/src/notifications/telegram.ts +334 -0
- package/src/queue.ts +98 -0
- package/src/sources/asana.ts +161 -0
- package/src/sources/jira.ts +255 -0
- package/src/sources/linear.ts +233 -0
- package/src/sources/sentry.ts +222 -0
- package/src/sources/types.ts +20 -0
- package/src/utils/html.ts +23 -0
- package/src/utils/logger.ts +49 -0
- package/src/worker/claude.ts +297 -0
- package/src/worker/fix.ts +195 -0
- package/src/worker/git.ts +111 -0
- package/src/worker/triage.ts +122 -0
package/dist/index.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Relay</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-RaJgQa_m.js"></script>
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BcE2ldjQ.css">
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="root"></div>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adriandmitroca/relay",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Autonomous SWE agent — triage and fix issues from Sentry, Asana, Linear, and Jira with Claude",
|
|
5
|
+
"module": "src/cli.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"relay": "src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"dist/",
|
|
13
|
+
"scripts/"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.0.0"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "bun run build && bun src/cli.ts start",
|
|
20
|
+
"dev": "DEV=1 bun --watch src/cli.ts start & bunx vite --config dashboard/vite.config.ts --open",
|
|
21
|
+
"build": "bunx vite build --config dashboard/vite.config.ts",
|
|
22
|
+
"test": "bun test",
|
|
23
|
+
"prepublishOnly": "bun run build"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
27
|
+
"@semantic-release/git": "^10.0.1",
|
|
28
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
29
|
+
"@tanstack/react-query": "^5.90.21",
|
|
30
|
+
"@types/bun": "latest",
|
|
31
|
+
"@types/react": "^19.2.14",
|
|
32
|
+
"@types/react-dom": "^19.2.3",
|
|
33
|
+
"@vitejs/plugin-react": "^5.1.4",
|
|
34
|
+
"react": "^19.2.4",
|
|
35
|
+
"react-dom": "^19.2.4",
|
|
36
|
+
"react-router": "^7.13.1",
|
|
37
|
+
"semantic-release": "^24.0.0",
|
|
38
|
+
"tailwindcss": "^4.2.1",
|
|
39
|
+
"vite": "^7.3.1"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"hono": "^4.12.5"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
LABEL="com.relay.daemon"
|
|
5
|
+
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
|
|
6
|
+
ENGINEER_AI_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
7
|
+
BUN_PATH="$(which bun)"
|
|
8
|
+
LOG_LEVEL="${1:-info}"
|
|
9
|
+
|
|
10
|
+
# Stop existing service if running
|
|
11
|
+
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
|
|
12
|
+
|
|
13
|
+
cat > "$PLIST" <<EOF
|
|
14
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
15
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
16
|
+
<plist version="1.0">
|
|
17
|
+
<dict>
|
|
18
|
+
<key>Label</key>
|
|
19
|
+
<string>$LABEL</string>
|
|
20
|
+
<key>ProgramArguments</key>
|
|
21
|
+
<array>
|
|
22
|
+
<string>$BUN_PATH</string>
|
|
23
|
+
<string>src/cli.ts</string>
|
|
24
|
+
<string>start</string>
|
|
25
|
+
<string>--log-level</string>
|
|
26
|
+
<string>$LOG_LEVEL</string>
|
|
27
|
+
</array>
|
|
28
|
+
<key>WorkingDirectory</key>
|
|
29
|
+
<string>$ENGINEER_AI_DIR</string>
|
|
30
|
+
<key>RunAtLoad</key>
|
|
31
|
+
<true/>
|
|
32
|
+
<key>KeepAlive</key>
|
|
33
|
+
<true/>
|
|
34
|
+
<key>StandardOutPath</key>
|
|
35
|
+
<string>$ENGINEER_AI_DIR/relay.stdout.log</string>
|
|
36
|
+
<key>StandardErrorPath</key>
|
|
37
|
+
<string>$ENGINEER_AI_DIR/relay.stderr.log</string>
|
|
38
|
+
<key>EnvironmentVariables</key>
|
|
39
|
+
<dict>
|
|
40
|
+
<key>PATH</key>
|
|
41
|
+
<string>$(dirname "$BUN_PATH"):/usr/local/bin:/usr/bin:/bin</string>
|
|
42
|
+
</dict>
|
|
43
|
+
</dict>
|
|
44
|
+
</plist>
|
|
45
|
+
EOF
|
|
46
|
+
|
|
47
|
+
launchctl bootstrap "gui/$(id -u)" "$PLIST"
|
|
48
|
+
|
|
49
|
+
echo "Relay service installed and started."
|
|
50
|
+
echo " Logs: $ENGINEER_AI_DIR/relay.stderr.log"
|
|
51
|
+
echo " Stop: launchctl bootout gui/$(id -u)/$LABEL"
|
|
52
|
+
echo " Uninstall: bash $ENGINEER_AI_DIR/scripts/uninstall-service.sh"
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { readdir, stat } from "node:fs/promises";
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import type { ConfigDB } from "../db.ts";
|
|
5
|
+
|
|
6
|
+
const SECRET_KEYS = new Set(["authToken", "accessToken", "apiKey", "apiToken", "botToken", "bot_token"]);
|
|
7
|
+
|
|
8
|
+
function maskValue(val: string): string {
|
|
9
|
+
if (val.length <= 8) return "•".repeat(val.length);
|
|
10
|
+
return val.slice(0, 4) + "•".repeat(Math.min(val.length - 8, 20)) + val.slice(-4);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function maskSecrets(obj: Record<string, unknown>): Record<string, unknown> {
|
|
14
|
+
const result = { ...obj };
|
|
15
|
+
for (const key of Object.keys(result)) {
|
|
16
|
+
if (SECRET_KEYS.has(key) && typeof result[key] === "string") {
|
|
17
|
+
result[key] = maskValue(result[key] as string);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createConfigRoutes(getConfigDB: () => ConfigDB, onConfigChange: () => void) {
|
|
24
|
+
const app = new Hono();
|
|
25
|
+
|
|
26
|
+
// ─── Settings ───
|
|
27
|
+
|
|
28
|
+
app.get("/api/settings", (c) => {
|
|
29
|
+
return c.json(getConfigDB().getSettings());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.put("/api/settings", async (c) => {
|
|
33
|
+
const body = await c.req.json<Record<string, string>>();
|
|
34
|
+
getConfigDB().updateSettings(body);
|
|
35
|
+
onConfigChange();
|
|
36
|
+
return c.json({ ok: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ─── Config Check ───
|
|
40
|
+
|
|
41
|
+
app.get("/api/config/check", (c) => {
|
|
42
|
+
return c.json({ hasConfig: getConfigDB().hasConfig() });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── Workspaces ───
|
|
46
|
+
|
|
47
|
+
app.get("/api/workspaces", (c) => {
|
|
48
|
+
const configDB = getConfigDB();
|
|
49
|
+
const workspaces = configDB.getWorkspaces().map((ws) => {
|
|
50
|
+
const projects = configDB.getProjects(ws.id).map((proj) => ({
|
|
51
|
+
...proj,
|
|
52
|
+
sources: configDB.getSourceConfigs(proj.id).map((src) => ({
|
|
53
|
+
...src,
|
|
54
|
+
config: JSON.stringify(maskSecrets(JSON.parse(src.config))),
|
|
55
|
+
})),
|
|
56
|
+
}));
|
|
57
|
+
const telegram = configDB.getTelegramConfig(ws.id);
|
|
58
|
+
const maskedTelegram = telegram ? { ...telegram, bot_token: maskValue(telegram.bot_token) } : null;
|
|
59
|
+
return { ...ws, projects, telegram: maskedTelegram };
|
|
60
|
+
});
|
|
61
|
+
return c.json(workspaces);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
app.post("/api/workspaces", async (c) => {
|
|
65
|
+
const { key } = await c.req.json<{ key: string }>();
|
|
66
|
+
if (!key?.trim()) return c.json({ error: "key is required" }, 400);
|
|
67
|
+
try {
|
|
68
|
+
const ws = getConfigDB().createWorkspace(key.trim());
|
|
69
|
+
onConfigChange();
|
|
70
|
+
return c.json(ws, 201);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return c.json({ error: String(err) }, 400);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
app.put("/api/workspaces/:id", async (c) => {
|
|
77
|
+
const id = parseInt(c.req.param("id"));
|
|
78
|
+
const { key } = await c.req.json<{ key: string }>();
|
|
79
|
+
if (!key?.trim()) return c.json({ error: "key is required" }, 400);
|
|
80
|
+
getConfigDB().updateWorkspace(id, key.trim());
|
|
81
|
+
onConfigChange();
|
|
82
|
+
return c.json({ ok: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.delete("/api/workspaces/:id", (c) => {
|
|
86
|
+
const id = parseInt(c.req.param("id"));
|
|
87
|
+
getConfigDB().deleteWorkspace(id);
|
|
88
|
+
onConfigChange();
|
|
89
|
+
return c.json({ ok: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ─── Projects ───
|
|
93
|
+
|
|
94
|
+
app.post("/api/workspaces/:id/projects", async (c) => {
|
|
95
|
+
const workspaceId = parseInt(c.req.param("id"));
|
|
96
|
+
const body = await c.req.json<{ key: string; repoPath: string; baseBranch: string; testCommand?: string }>();
|
|
97
|
+
if (!body.key?.trim() || !body.repoPath?.trim() || !body.baseBranch?.trim()) {
|
|
98
|
+
return c.json({ error: "key, repoPath, and baseBranch are required" }, 400);
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
const proj = getConfigDB().createProject(workspaceId, {
|
|
102
|
+
key: body.key.trim(),
|
|
103
|
+
repoPath: body.repoPath.trim(),
|
|
104
|
+
baseBranch: body.baseBranch.trim(),
|
|
105
|
+
testCommand: body.testCommand?.trim(),
|
|
106
|
+
});
|
|
107
|
+
onConfigChange();
|
|
108
|
+
return c.json(proj, 201);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return c.json({ error: String(err) }, 400);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
app.put("/api/projects/:id", async (c) => {
|
|
115
|
+
const id = parseInt(c.req.param("id"));
|
|
116
|
+
const body = await c.req.json<{ key?: string; repoPath?: string; baseBranch?: string; testCommand?: string | null }>();
|
|
117
|
+
getConfigDB().updateProject(id, body);
|
|
118
|
+
onConfigChange();
|
|
119
|
+
return c.json({ ok: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
app.delete("/api/projects/:id", (c) => {
|
|
123
|
+
const id = parseInt(c.req.param("id"));
|
|
124
|
+
getConfigDB().deleteProject(id);
|
|
125
|
+
onConfigChange();
|
|
126
|
+
return c.json({ ok: true });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ─── Source Configs ───
|
|
130
|
+
|
|
131
|
+
app.post("/api/projects/:id/sources", async (c) => {
|
|
132
|
+
const projectId = parseInt(c.req.param("id"));
|
|
133
|
+
const body = await c.req.json<{ type: string; config: Record<string, unknown>; enabled?: boolean }>();
|
|
134
|
+
if (!body.type?.trim() || !body.config) {
|
|
135
|
+
return c.json({ error: "type and config are required" }, 400);
|
|
136
|
+
}
|
|
137
|
+
const src = getConfigDB().upsertSourceConfig(projectId, body.type.trim(), body.config, body.enabled ?? true);
|
|
138
|
+
onConfigChange();
|
|
139
|
+
return c.json(src, 201);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
app.put("/api/sources/:id", async (c) => {
|
|
143
|
+
const id = parseInt(c.req.param("id"));
|
|
144
|
+
const body = await c.req.json<{ config?: Record<string, unknown>; enabled?: boolean }>();
|
|
145
|
+
// Preserve existing secrets when the client sends back masked values
|
|
146
|
+
if (body.config) {
|
|
147
|
+
const existing = getConfigDB().getSourceConfig(id);
|
|
148
|
+
if (existing) {
|
|
149
|
+
const existingConfig = JSON.parse(existing.config) as Record<string, unknown>;
|
|
150
|
+
for (const key of SECRET_KEYS) {
|
|
151
|
+
if (typeof body.config[key] === "string" && (body.config[key] as string).includes("•")) {
|
|
152
|
+
body.config[key] = existingConfig[key];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
getConfigDB().updateSourceConfig(id, body);
|
|
158
|
+
onConfigChange();
|
|
159
|
+
return c.json({ ok: true });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
app.put("/api/sources/:id/toggle", async (c) => {
|
|
163
|
+
const id = parseInt(c.req.param("id"));
|
|
164
|
+
const { enabled } = await c.req.json<{ enabled: boolean }>();
|
|
165
|
+
getConfigDB().updateSourceConfig(id, { enabled });
|
|
166
|
+
onConfigChange();
|
|
167
|
+
return c.json({ ok: true });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
app.delete("/api/sources/:id", (c) => {
|
|
171
|
+
const id = parseInt(c.req.param("id"));
|
|
172
|
+
getConfigDB().deleteSourceConfig(id);
|
|
173
|
+
onConfigChange();
|
|
174
|
+
return c.json({ ok: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ─── Telegram ───
|
|
178
|
+
|
|
179
|
+
app.put("/api/workspaces/:id/telegram", async (c) => {
|
|
180
|
+
const workspaceId = parseInt(c.req.param("id"));
|
|
181
|
+
const body = await c.req.json<{ botToken: string; chatId: string; enabled?: boolean }>();
|
|
182
|
+
if (!body.botToken?.trim() || !body.chatId?.trim()) {
|
|
183
|
+
return c.json({ error: "botToken and chatId are required" }, 400);
|
|
184
|
+
}
|
|
185
|
+
let botToken = body.botToken.trim();
|
|
186
|
+
// Preserve existing token if client sent back a masked value
|
|
187
|
+
if (botToken.includes("•")) {
|
|
188
|
+
const existing = getConfigDB().getTelegramConfig(workspaceId);
|
|
189
|
+
if (existing) botToken = existing.bot_token;
|
|
190
|
+
}
|
|
191
|
+
const tg = getConfigDB().upsertTelegramConfig(workspaceId, botToken, body.chatId.trim(), body.enabled ?? true);
|
|
192
|
+
onConfigChange();
|
|
193
|
+
return c.json({ ...tg, bot_token: maskValue(tg.bot_token) });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
app.put("/api/workspaces/:id/telegram/toggle", async (c) => {
|
|
197
|
+
const workspaceId = parseInt(c.req.param("id"));
|
|
198
|
+
const { enabled } = await c.req.json<{ enabled: boolean }>();
|
|
199
|
+
getConfigDB().updateTelegramEnabled(workspaceId, enabled);
|
|
200
|
+
onConfigChange();
|
|
201
|
+
return c.json({ ok: true });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.delete("/api/workspaces/:id/telegram", (c) => {
|
|
205
|
+
const workspaceId = parseInt(c.req.param("id"));
|
|
206
|
+
getConfigDB().deleteTelegramConfig(workspaceId);
|
|
207
|
+
onConfigChange();
|
|
208
|
+
return c.json({ ok: true });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ─── Project Discovery ───
|
|
212
|
+
|
|
213
|
+
app.post("/api/workspaces/:id/discover", async (c) => {
|
|
214
|
+
const workspaceId = parseInt(c.req.param("id"));
|
|
215
|
+
const { path: dirPath } = await c.req.json<{ path: string }>();
|
|
216
|
+
if (!dirPath?.trim()) return c.json({ error: "path is required" }, 400);
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const dirStat = await stat(dirPath);
|
|
220
|
+
if (!dirStat.isDirectory()) return c.json({ error: "Path is not a directory" }, 400);
|
|
221
|
+
} catch {
|
|
222
|
+
return c.json({ error: "Path does not exist or is not accessible" }, 400);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const existingPaths = new Set(
|
|
226
|
+
getConfigDB().getProjects(workspaceId).map((p) => p.repo_path),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const toKebabCase = (name: string) =>
|
|
230
|
+
name.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
|
|
231
|
+
|
|
232
|
+
async function scanDirectory(dir: string, depth = 0, maxDepth = 2): Promise<string[]> {
|
|
233
|
+
if (depth > maxDepth) return [];
|
|
234
|
+
const repos: string[] = [];
|
|
235
|
+
let entries;
|
|
236
|
+
try {
|
|
237
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
for (const entry of entries) {
|
|
242
|
+
if (!entry.isDirectory()) continue;
|
|
243
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
244
|
+
const full = join(dir, entry.name);
|
|
245
|
+
try {
|
|
246
|
+
await stat(join(full, ".git"));
|
|
247
|
+
repos.push(full);
|
|
248
|
+
} catch {
|
|
249
|
+
repos.push(...(await scanDirectory(full, depth + 1, maxDepth)));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return repos;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function detectBaseBranch(repoPath: string): Promise<string> {
|
|
256
|
+
try {
|
|
257
|
+
const proc = Bun.spawn(["git", "symbolic-ref", "refs/remotes/origin/HEAD"], { cwd: repoPath, stdout: "pipe", stderr: "ignore" });
|
|
258
|
+
const text = await new Response(proc.stdout).text();
|
|
259
|
+
const ref = text.trim().replace("refs/remotes/origin/", "");
|
|
260
|
+
if (ref) return ref;
|
|
261
|
+
} catch {}
|
|
262
|
+
for (const branch of ["main", "master"]) {
|
|
263
|
+
try {
|
|
264
|
+
await stat(join(repoPath, ".git", "refs", "remotes", "origin", branch));
|
|
265
|
+
return branch;
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
return "main";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function detectTestCommand(repoPath: string): Promise<string | undefined> {
|
|
272
|
+
try {
|
|
273
|
+
const pkgFile = Bun.file(join(repoPath, "package.json"));
|
|
274
|
+
const pkg = await pkgFile.json() as { scripts?: Record<string, string> };
|
|
275
|
+
const testScript = pkg.scripts?.test;
|
|
276
|
+
if (testScript && !testScript.includes("no test specified")) {
|
|
277
|
+
try { await stat(join(repoPath, "bun.lockb")); return "bun test"; } catch {}
|
|
278
|
+
try { await stat(join(repoPath, "bun.lock")); return "bun test"; } catch {}
|
|
279
|
+
return "npm test";
|
|
280
|
+
}
|
|
281
|
+
} catch {}
|
|
282
|
+
try {
|
|
283
|
+
const makefile = await Bun.file(join(repoPath, "Makefile")).text();
|
|
284
|
+
if (/^test:/m.test(makefile)) return "make test";
|
|
285
|
+
} catch {}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function detectSourceHints(repoPath: string): Promise<string[]> {
|
|
290
|
+
const hints: string[] = [];
|
|
291
|
+
const exists = async (path: string) => { try { await stat(join(repoPath, path)); return true; } catch { return false; } };
|
|
292
|
+
|
|
293
|
+
if (await exists(".sentryclirc") || await exists("sentry.properties")) hints.push("sentry");
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const pkg = await Bun.file(join(repoPath, "package.json")).text();
|
|
297
|
+
if (pkg.includes("@linear/sdk")) hints.push("linear");
|
|
298
|
+
} catch {}
|
|
299
|
+
|
|
300
|
+
if (await exists(".asana.yml")) hints.push("asana");
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const proc = Bun.spawn(["git", "log", "--oneline", "-20"], { cwd: repoPath, stdout: "pipe", stderr: "ignore" });
|
|
304
|
+
const log = await new Response(proc.stdout).text();
|
|
305
|
+
if (/\b[A-Z]{2,10}-\d+\b/.test(log)) hints.push("jira");
|
|
306
|
+
} catch {}
|
|
307
|
+
|
|
308
|
+
return hints;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const repoPaths = await scanDirectory(dirPath.trim());
|
|
312
|
+
const newPaths = repoPaths.filter((p) => !existingPaths.has(p));
|
|
313
|
+
|
|
314
|
+
const discovered = await Promise.all(
|
|
315
|
+
newPaths.map(async (repoPath) => {
|
|
316
|
+
const [baseBranch, testCommand, sourceHints] = await Promise.all([
|
|
317
|
+
detectBaseBranch(repoPath),
|
|
318
|
+
detectTestCommand(repoPath),
|
|
319
|
+
detectSourceHints(repoPath),
|
|
320
|
+
]);
|
|
321
|
+
return {
|
|
322
|
+
repoPath,
|
|
323
|
+
key: toKebabCase(basename(repoPath)),
|
|
324
|
+
baseBranch,
|
|
325
|
+
testCommand,
|
|
326
|
+
sourceHints,
|
|
327
|
+
};
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
return c.json({ discovered });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ─── Setup Wizard ───
|
|
335
|
+
|
|
336
|
+
app.post("/api/setup/complete", async (c) => {
|
|
337
|
+
const body = await c.req.json<{
|
|
338
|
+
settings?: Record<string, string>;
|
|
339
|
+
workspaces: Array<{
|
|
340
|
+
key: string;
|
|
341
|
+
telegram?: { botToken: string; chatId: string };
|
|
342
|
+
projects: Array<{
|
|
343
|
+
key: string;
|
|
344
|
+
repoPath: string;
|
|
345
|
+
baseBranch: string;
|
|
346
|
+
testCommand?: string;
|
|
347
|
+
sources?: Array<{ type: string; config: Record<string, unknown> }>;
|
|
348
|
+
}>;
|
|
349
|
+
}>;
|
|
350
|
+
}>();
|
|
351
|
+
|
|
352
|
+
if (!body.workspaces?.length) {
|
|
353
|
+
return c.json({ error: "At least one workspace is required" }, 400);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const configDB = getConfigDB();
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
// Settings
|
|
360
|
+
if (body.settings) {
|
|
361
|
+
configDB.updateSettings(body.settings);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Create workspaces
|
|
365
|
+
for (const wsData of body.workspaces) {
|
|
366
|
+
const ws = configDB.createWorkspace(wsData.key);
|
|
367
|
+
|
|
368
|
+
if (wsData.telegram) {
|
|
369
|
+
configDB.upsertTelegramConfig(ws.id, wsData.telegram.botToken, wsData.telegram.chatId);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const projData of wsData.projects) {
|
|
373
|
+
const proj = configDB.createProject(ws.id, {
|
|
374
|
+
key: projData.key,
|
|
375
|
+
repoPath: projData.repoPath,
|
|
376
|
+
baseBranch: projData.baseBranch,
|
|
377
|
+
testCommand: projData.testCommand,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if (projData.sources) {
|
|
381
|
+
for (const src of projData.sources) {
|
|
382
|
+
configDB.upsertSourceConfig(proj.id, src.type, src.config);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
onConfigChange();
|
|
389
|
+
return c.json({ ok: true }, 201);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
return c.json({ error: String(err) }, 400);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ─── Connection Testing ───
|
|
396
|
+
|
|
397
|
+
app.post("/api/test/sentry", async (c) => {
|
|
398
|
+
const { authToken, org, project } = await c.req.json<{ authToken: string; org: string; project: string }>();
|
|
399
|
+
try {
|
|
400
|
+
const resp = await fetch(`https://sentry.io/api/0/projects/${org}/${project}/`, {
|
|
401
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
402
|
+
});
|
|
403
|
+
if (resp.ok) return c.json({ ok: true });
|
|
404
|
+
const text = await resp.text();
|
|
405
|
+
return c.json({ ok: false, error: `${resp.status}: ${text.slice(0, 200)}` });
|
|
406
|
+
} catch (err) {
|
|
407
|
+
return c.json({ ok: false, error: String(err) });
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
app.post("/api/test/asana", async (c) => {
|
|
412
|
+
const { accessToken, projectGid } = await c.req.json<{ accessToken: string; projectGid: string }>();
|
|
413
|
+
try {
|
|
414
|
+
const resp = await fetch(`https://app.asana.com/api/1.0/projects/${projectGid}`, {
|
|
415
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
416
|
+
});
|
|
417
|
+
if (resp.ok) return c.json({ ok: true });
|
|
418
|
+
const text = await resp.text();
|
|
419
|
+
return c.json({ ok: false, error: `${resp.status}: ${text.slice(0, 200)}` });
|
|
420
|
+
} catch (err) {
|
|
421
|
+
return c.json({ ok: false, error: String(err) });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
app.post("/api/test/linear", async (c) => {
|
|
426
|
+
const { apiKey, teamId } = await c.req.json<{ apiKey: string; teamId: string }>();
|
|
427
|
+
try {
|
|
428
|
+
const resp = await fetch("https://api.linear.app/graphql", {
|
|
429
|
+
method: "POST",
|
|
430
|
+
headers: { Authorization: apiKey, "Content-Type": "application/json" },
|
|
431
|
+
body: JSON.stringify({ query: `{ team(id: "${teamId}") { id name } }` }),
|
|
432
|
+
});
|
|
433
|
+
if (!resp.ok) {
|
|
434
|
+
const text = await resp.text();
|
|
435
|
+
return c.json({ ok: false, error: `${resp.status}: ${text.slice(0, 200)}` });
|
|
436
|
+
}
|
|
437
|
+
const data = await resp.json() as { data?: { team?: { name: string } }; errors?: Array<{ message: string }> };
|
|
438
|
+
if (data.errors?.length) return c.json({ ok: false, error: data.errors[0].message });
|
|
439
|
+
if (!data.data?.team) return c.json({ ok: false, error: "Team not found" });
|
|
440
|
+
return c.json({ ok: true, teamName: data.data.team.name });
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return c.json({ ok: false, error: String(err) });
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
app.post("/api/test/jira", async (c) => {
|
|
447
|
+
const { host, email, apiToken, projectKey } = await c.req.json<{ host: string; email: string; apiToken: string; projectKey: string }>();
|
|
448
|
+
try {
|
|
449
|
+
const encoded = btoa(`${email}:${apiToken}`);
|
|
450
|
+
const resp = await fetch(`https://${host}/rest/api/3/project/${projectKey}`, {
|
|
451
|
+
headers: { Authorization: `Basic ${encoded}`, Accept: "application/json" },
|
|
452
|
+
});
|
|
453
|
+
if (resp.ok) {
|
|
454
|
+
const data = await resp.json() as { name: string };
|
|
455
|
+
return c.json({ ok: true, projectName: data.name });
|
|
456
|
+
}
|
|
457
|
+
const text = await resp.text();
|
|
458
|
+
return c.json({ ok: false, error: `${resp.status}: ${text.slice(0, 200)}` });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
return c.json({ ok: false, error: String(err) });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
app.post("/api/test/telegram", async (c) => {
|
|
465
|
+
const { botToken, chatId } = await c.req.json<{ botToken: string; chatId: string }>();
|
|
466
|
+
try {
|
|
467
|
+
const resp = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
468
|
+
method: "POST",
|
|
469
|
+
headers: { "Content-Type": "application/json" },
|
|
470
|
+
body: JSON.stringify({ chat_id: chatId, text: "🔌 Relay connection test — success!" }),
|
|
471
|
+
});
|
|
472
|
+
const data = await resp.json() as { ok: boolean; description?: string };
|
|
473
|
+
if (data.ok) return c.json({ ok: true });
|
|
474
|
+
return c.json({ ok: false, error: data.description ?? "Unknown error" });
|
|
475
|
+
} catch (err) {
|
|
476
|
+
return c.json({ ok: false, error: String(err) });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
return app;
|
|
481
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { IssueDB, IssueRow } from "../db.ts";
|
|
3
|
+
import { type CallbackAction, type CallbackData } from "../constants.ts";
|
|
4
|
+
|
|
5
|
+
const VALID_ACTIONS: CallbackAction[] = ["fix", "accept", "discard", "skip", "retry"];
|
|
6
|
+
|
|
7
|
+
type ActionHandler = (data: CallbackData) => Promise<{ ok: boolean; error?: string; issue?: IssueRow }>;
|
|
8
|
+
type StatusProvider = () => Array<{ workspace: string; telegram: boolean; sources: string[] }>;
|
|
9
|
+
|
|
10
|
+
export function createIssueRoutes(getDB: () => IssueDB, getActionHandler: () => ActionHandler | null, getStatusProvider: () => StatusProvider | null) {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
|
|
13
|
+
app.get("/api/issues", (c) => {
|
|
14
|
+
const db = getDB();
|
|
15
|
+
const limit = Math.min(parseInt(c.req.query("limit") ?? "100"), 500);
|
|
16
|
+
const status = c.req.query("status") ?? undefined;
|
|
17
|
+
const source = c.req.query("source") ?? undefined;
|
|
18
|
+
const issues = db.getFiltered({ status, source, limit });
|
|
19
|
+
return c.json(issues);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.get("/api/issues/:id", (c) => {
|
|
23
|
+
const db = getDB();
|
|
24
|
+
const id = parseInt(c.req.param("id"));
|
|
25
|
+
const issue = db.getById(id);
|
|
26
|
+
if (!issue) return c.json({ error: "Not found" }, 404);
|
|
27
|
+
return c.json(issue);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
app.post("/api/issues/:id/action", async (c) => {
|
|
31
|
+
const actionHandler = getActionHandler();
|
|
32
|
+
if (!actionHandler) {
|
|
33
|
+
return c.json({ ok: false, error: "Action handler not configured" }, 503);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const db = getDB();
|
|
37
|
+
const id = parseInt(c.req.param("id"));
|
|
38
|
+
|
|
39
|
+
let body: { action?: string };
|
|
40
|
+
try {
|
|
41
|
+
body = await c.req.json();
|
|
42
|
+
} catch {
|
|
43
|
+
return c.json({ ok: false, error: "Invalid JSON body" }, 400);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!body.action || !VALID_ACTIONS.includes(body.action as CallbackAction)) {
|
|
47
|
+
return c.json({ ok: false, error: `Invalid action. Must be one of: ${VALID_ACTIONS.join(", ")}` }, 400);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const issue = db.getById(id);
|
|
51
|
+
if (!issue) {
|
|
52
|
+
return c.json({ ok: false, error: "Issue not found" }, 404);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const result = await actionHandler({
|
|
57
|
+
action: body.action as CallbackAction,
|
|
58
|
+
source: issue.source,
|
|
59
|
+
sourceId: issue.sourceId,
|
|
60
|
+
});
|
|
61
|
+
return c.json(result, result.ok ? 200 : 400);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return c.json({ ok: false, error: String(err) }, 500);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
app.get("/api/stats", (c) => {
|
|
68
|
+
const db = getDB();
|
|
69
|
+
const workspaceKey = c.req.query("workspace") ?? undefined;
|
|
70
|
+
const rows = db.getStatsBySource(workspaceKey);
|
|
71
|
+
return c.json(rows);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.get("/api/status", (c) => {
|
|
75
|
+
const provider = getStatusProvider();
|
|
76
|
+
const status = provider ? provider() : [];
|
|
77
|
+
return c.json(status);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return app;
|
|
81
|
+
}
|