@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/src/daemon.ts
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
import { loadConfig, allProjects, findProjectConfig, type Config, type ProjectConfig, type WorkspaceConfig } from "./config.ts";
|
|
2
|
+
import { IssueDB, ConfigDB, type IssueRow, type IssueStatus } from "./db.ts";
|
|
3
|
+
import { PriorityQueue } from "./queue.ts";
|
|
4
|
+
import { SentryAdapter } from "./sources/sentry.ts";
|
|
5
|
+
import { AsanaAdapter } from "./sources/asana.ts";
|
|
6
|
+
import { LinearAdapter } from "./sources/linear.ts";
|
|
7
|
+
import { JiraAdapter } from "./sources/jira.ts";
|
|
8
|
+
import type { NormalizedIssue, Severity, SourceAdapter } from "./sources/types.ts";
|
|
9
|
+
import { triageIssue } from "./worker/triage.ts";
|
|
10
|
+
import { fixIssue } from "./worker/fix.ts";
|
|
11
|
+
import type { ClaudeStreamEvent } from "./worker/claude.ts";
|
|
12
|
+
import { createWorktree, removeWorktree, pushBranch, buildPRUrl } from "./worker/git.ts";
|
|
13
|
+
import { TelegramBot } from "./notifications/telegram.ts";
|
|
14
|
+
import { type CallbackAction, type CallbackData } from "./constants.ts";
|
|
15
|
+
import { DashboardServer } from "./dashboard.ts";
|
|
16
|
+
import { logger, setLogLevel, setLogFile } from "./utils/logger.ts";
|
|
17
|
+
import { esc } from "./utils/html.ts";
|
|
18
|
+
import { join, dirname } from "node:path";
|
|
19
|
+
|
|
20
|
+
interface WorkspaceRuntime {
|
|
21
|
+
config: WorkspaceConfig;
|
|
22
|
+
telegram: TelegramBot | null;
|
|
23
|
+
adapters: Map<string, SourceAdapter>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface PipelineItem {
|
|
27
|
+
issue: NormalizedIssue;
|
|
28
|
+
row: IssueRow;
|
|
29
|
+
project: ProjectConfig;
|
|
30
|
+
adapter: SourceAdapter | null;
|
|
31
|
+
workspaceKey: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActionResult {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
error?: string;
|
|
37
|
+
issue?: IssueRow;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class Daemon {
|
|
41
|
+
private config!: Config;
|
|
42
|
+
private db!: IssueDB;
|
|
43
|
+
private configDB!: ConfigDB;
|
|
44
|
+
private queue!: PriorityQueue<PipelineItem>;
|
|
45
|
+
private workspaces: Map<string, WorkspaceRuntime> = new Map();
|
|
46
|
+
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
47
|
+
private configWatcher: ReturnType<typeof import("node:fs").watch> | null = null;
|
|
48
|
+
private stopping = false;
|
|
49
|
+
private configPath: string;
|
|
50
|
+
private dashboard!: DashboardServer;
|
|
51
|
+
private baseDir: string;
|
|
52
|
+
|
|
53
|
+
constructor(configPath: string) {
|
|
54
|
+
this.configPath = configPath;
|
|
55
|
+
this.baseDir = dirname(configPath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async start() {
|
|
59
|
+
// Init DB
|
|
60
|
+
const dbPath = join(this.baseDir, "sqlite.db");
|
|
61
|
+
this.db = new IssueDB(dbPath);
|
|
62
|
+
this.configDB = new ConfigDB(this.db.getDatabase());
|
|
63
|
+
|
|
64
|
+
// Auto-import config.json if DB has no config
|
|
65
|
+
if (!this.configDB.hasConfig()) {
|
|
66
|
+
const configFile = Bun.file(this.configPath);
|
|
67
|
+
if (await configFile.exists()) {
|
|
68
|
+
const jsonConfig = await loadConfig(this.configPath);
|
|
69
|
+
this.configDB.importFromJson(jsonConfig);
|
|
70
|
+
logger.info("Auto-imported config.json into database");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Start dashboard (always โ even without config for setup wizard)
|
|
75
|
+
const isDev = process.env.DEV === "1";
|
|
76
|
+
// Resolve dist/ relative to this source file so it works both in-repo and when
|
|
77
|
+
// installed globally via npm (where cwd != package root).
|
|
78
|
+
const distDir = join(dirname(import.meta.dir), "dist");
|
|
79
|
+
this.dashboard = new DashboardServer(this.db);
|
|
80
|
+
this.dashboard.setConfigDB(this.configDB);
|
|
81
|
+
this.dashboard.setConfigChangeHandler(() => this.reloadFromDB());
|
|
82
|
+
this.dashboard.setActionHandler((data) => this.executeAction(data));
|
|
83
|
+
this.dashboard.setStatusProvider(() => this.getIntegrationsStatus());
|
|
84
|
+
if (!isDev && await Bun.file(join(distDir, "index.html")).exists()) {
|
|
85
|
+
this.dashboard.setDistDir(distDir);
|
|
86
|
+
}
|
|
87
|
+
this.dashboard.start();
|
|
88
|
+
|
|
89
|
+
// If no config at all, wait for setup via dashboard
|
|
90
|
+
if (!this.configDB.hasConfig()) {
|
|
91
|
+
logger.info("No config found โ dashboard running at http://localhost:7842/setup");
|
|
92
|
+
if (!isDev) await this.openBrowser();
|
|
93
|
+
this.setupShutdownHandlers();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Load config from DB
|
|
98
|
+
this.config = this.configDB.toConfig();
|
|
99
|
+
setLogLevel(this.config.logLevel);
|
|
100
|
+
|
|
101
|
+
// Preflight checks
|
|
102
|
+
await this.preflight();
|
|
103
|
+
|
|
104
|
+
const stuckIssues = this.db.resetStuckIssues();
|
|
105
|
+
|
|
106
|
+
// Init per-workspace runtimes
|
|
107
|
+
this.initWorkspaces();
|
|
108
|
+
|
|
109
|
+
// Notify topics of interrupted issues
|
|
110
|
+
for (const row of stuckIssues) {
|
|
111
|
+
if (!row.telegramThreadId) continue;
|
|
112
|
+
const ws = this.getWorkspaceRuntime(row.workspaceKey);
|
|
113
|
+
if (!ws?.telegram) continue;
|
|
114
|
+
const msg = row.status === "working"
|
|
115
|
+
? "โธ Interrupted โ waiting for confirmation to restart"
|
|
116
|
+
: "๐ Interrupted โ re-triaging automatically...";
|
|
117
|
+
await ws.telegram.sendThreadMessage(row.telegramThreadId, msg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Init queue
|
|
121
|
+
this.queue = new PriorityQueue(this.config.maxConcurrency, (item) => this.processItem(item));
|
|
122
|
+
|
|
123
|
+
// Re-send pending approvals
|
|
124
|
+
await this.resendPendingApprovals();
|
|
125
|
+
|
|
126
|
+
// Run first poll
|
|
127
|
+
await this.poll();
|
|
128
|
+
|
|
129
|
+
// Start poll interval
|
|
130
|
+
this.pollTimer = setInterval(() => this.poll(), this.config.pollIntervalSeconds * 1000);
|
|
131
|
+
|
|
132
|
+
// Watch config.json for hot reload (legacy support)
|
|
133
|
+
this.watchConfig();
|
|
134
|
+
|
|
135
|
+
// Setup shutdown handlers
|
|
136
|
+
this.setupShutdownHandlers();
|
|
137
|
+
|
|
138
|
+
const projects = allProjects(this.config);
|
|
139
|
+
logger.info("Daemon started", {
|
|
140
|
+
workspaces: this.config.workspaces.length,
|
|
141
|
+
projects: projects.length,
|
|
142
|
+
pollInterval: `${this.config.pollIntervalSeconds}s`,
|
|
143
|
+
maxConcurrency: this.config.maxConcurrency,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private setupShutdownHandlers() {
|
|
149
|
+
const shutdown = async () => {
|
|
150
|
+
await this.stop();
|
|
151
|
+
process.exit(0);
|
|
152
|
+
};
|
|
153
|
+
process.on("SIGINT", shutdown);
|
|
154
|
+
process.on("SIGTERM", shutdown);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async openBrowser() {
|
|
158
|
+
try {
|
|
159
|
+
const url = "http://localhost:7842";
|
|
160
|
+
if (process.platform === "darwin") {
|
|
161
|
+
await Bun.$`open ${url}`.quiet().nothrow();
|
|
162
|
+
} else {
|
|
163
|
+
await Bun.$`xdg-open ${url}`.quiet().nothrow();
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async reloadFromDB() {
|
|
169
|
+
try {
|
|
170
|
+
const newConfig = this.configDB.toConfig();
|
|
171
|
+
|
|
172
|
+
// First-time config after setup wizard
|
|
173
|
+
if (!this.config && newConfig.workspaces.length > 0) {
|
|
174
|
+
this.config = newConfig;
|
|
175
|
+
setLogLevel(this.config.logLevel);
|
|
176
|
+
await this.preflight();
|
|
177
|
+
this.initWorkspaces();
|
|
178
|
+
this.queue = new PriorityQueue(this.config.maxConcurrency, (item) => this.processItem(item));
|
|
179
|
+
await this.poll();
|
|
180
|
+
this.pollTimer = setInterval(() => this.poll(), this.config.pollIntervalSeconds * 1000);
|
|
181
|
+
logger.info("Config loaded from setup wizard, daemon fully started");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!this.config) return;
|
|
186
|
+
|
|
187
|
+
// Restart poll timer if interval changed
|
|
188
|
+
if (newConfig.pollIntervalSeconds !== this.config.pollIntervalSeconds) {
|
|
189
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
190
|
+
this.pollTimer = setInterval(() => this.poll(), newConfig.pollIntervalSeconds * 1000);
|
|
191
|
+
logger.info("Poll interval updated", { seconds: newConfig.pollIntervalSeconds });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (newConfig.logLevel !== this.config.logLevel) {
|
|
195
|
+
setLogLevel(newConfig.logLevel);
|
|
196
|
+
logger.info("Log level updated", { level: newConfig.logLevel });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Reinit workspaces if workspace config changed
|
|
200
|
+
const oldWsJson = JSON.stringify(this.config.workspaces);
|
|
201
|
+
const newWsJson = JSON.stringify(newConfig.workspaces);
|
|
202
|
+
if (oldWsJson !== newWsJson) {
|
|
203
|
+
for (const ws of this.workspaces.values()) {
|
|
204
|
+
ws.telegram?.stopPolling();
|
|
205
|
+
}
|
|
206
|
+
this.workspaces.clear();
|
|
207
|
+
this.config = newConfig;
|
|
208
|
+
this.initWorkspaces();
|
|
209
|
+
logger.info("Workspaces reinitialized");
|
|
210
|
+
} else {
|
|
211
|
+
this.config = newConfig;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
logger.info("Config reloaded from DB");
|
|
215
|
+
await this.poll();
|
|
216
|
+
} catch (err) {
|
|
217
|
+
logger.error("Config reload from DB failed", { error: String(err) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async stop() {
|
|
222
|
+
if (this.stopping) return;
|
|
223
|
+
this.stopping = true;
|
|
224
|
+
logger.info("Shutting down...");
|
|
225
|
+
|
|
226
|
+
if (this.configWatcher) {
|
|
227
|
+
this.configWatcher.close();
|
|
228
|
+
this.configWatcher = null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.pollTimer) {
|
|
232
|
+
clearInterval(this.pollTimer);
|
|
233
|
+
this.pollTimer = null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const ws of this.workspaces.values()) {
|
|
237
|
+
ws.telegram?.stopPolling();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
await this.queue.drain(10_000);
|
|
241
|
+
this.dashboard.stop();
|
|
242
|
+
this.db.close();
|
|
243
|
+
|
|
244
|
+
logger.info("Daemon stopped");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async preflight() {
|
|
248
|
+
// Check repos exist
|
|
249
|
+
for (const { project: p } of allProjects(this.config)) {
|
|
250
|
+
const gitDir = Bun.file(join(p.repoPath, ".git"));
|
|
251
|
+
if (!(await gitDir.exists())) {
|
|
252
|
+
const stat = await Bun.$`test -d ${p.repoPath}`.quiet().nothrow();
|
|
253
|
+
if (stat.exitCode !== 0) {
|
|
254
|
+
throw new Error(`Repo path does not exist: ${p.repoPath}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check claude is installed
|
|
260
|
+
const claude = await Bun.$`which claude`.quiet().nothrow();
|
|
261
|
+
if (claude.exitCode !== 0) {
|
|
262
|
+
throw new Error("'claude' CLI not found in PATH. Install Claude Code first.");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Check gh is installed and authenticated
|
|
266
|
+
const gh = await Bun.$`gh auth status`.quiet().nothrow();
|
|
267
|
+
if (gh.exitCode !== 0) {
|
|
268
|
+
throw new Error("'gh' CLI not authenticated. Run 'gh auth login' first.");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private initWorkspaces() {
|
|
273
|
+
// Share Telegram bot instances across workspaces with the same bot token
|
|
274
|
+
const telegramBots = new Map<string, TelegramBot>();
|
|
275
|
+
|
|
276
|
+
for (const wsConfig of this.config.workspaces) {
|
|
277
|
+
const adapters = new Map<string, SourceAdapter>();
|
|
278
|
+
|
|
279
|
+
let sentryAdapter: SentryAdapter | null = null;
|
|
280
|
+
let asanaAdapter: AsanaAdapter | null = null;
|
|
281
|
+
let linearAdapter: LinearAdapter | null = null;
|
|
282
|
+
let jiraAdapter: JiraAdapter | null = null;
|
|
283
|
+
|
|
284
|
+
for (const project of wsConfig.projects) {
|
|
285
|
+
if (project.sources.sentry) {
|
|
286
|
+
if (!sentryAdapter) {
|
|
287
|
+
sentryAdapter = new SentryAdapter(project.sources.sentry.authToken);
|
|
288
|
+
adapters.set("sentry", sentryAdapter);
|
|
289
|
+
}
|
|
290
|
+
sentryAdapter.addProject(project.key, project.sources.sentry);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (project.sources.asana) {
|
|
294
|
+
if (!asanaAdapter) {
|
|
295
|
+
asanaAdapter = new AsanaAdapter(project.sources.asana.accessToken);
|
|
296
|
+
adapters.set("asana", asanaAdapter);
|
|
297
|
+
}
|
|
298
|
+
asanaAdapter.addProject(project.key, project.sources.asana);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (project.sources.linear) {
|
|
302
|
+
if (!linearAdapter) {
|
|
303
|
+
linearAdapter = new LinearAdapter(project.sources.linear.apiKey);
|
|
304
|
+
adapters.set("linear", linearAdapter);
|
|
305
|
+
}
|
|
306
|
+
linearAdapter.addProject(project.key, project.sources.linear);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (project.sources.jira) {
|
|
310
|
+
if (!jiraAdapter) {
|
|
311
|
+
jiraAdapter = new JiraAdapter();
|
|
312
|
+
adapters.set("jira", jiraAdapter);
|
|
313
|
+
}
|
|
314
|
+
jiraAdapter.addProject(project.key, project.sources.jira);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let telegram: TelegramBot | null = null;
|
|
319
|
+
if (wsConfig.telegram) {
|
|
320
|
+
telegram = telegramBots.get(wsConfig.telegram.botToken) ?? null;
|
|
321
|
+
if (!telegram) {
|
|
322
|
+
telegram = new TelegramBot(wsConfig.telegram.botToken, wsConfig.telegram.chatId);
|
|
323
|
+
telegram.startPolling();
|
|
324
|
+
telegramBots.set(wsConfig.telegram.botToken, telegram);
|
|
325
|
+
}
|
|
326
|
+
telegram.setCallbackHandler((data) => this.handleCallback(data, wsConfig.key));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.workspaces.set(wsConfig.key, { config: wsConfig, telegram, adapters });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private watchConfig() {
|
|
334
|
+
const { watch } = require("node:fs") as typeof import("node:fs");
|
|
335
|
+
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
336
|
+
|
|
337
|
+
this.configWatcher = watch(this.configPath, () => {
|
|
338
|
+
if (debounce) clearTimeout(debounce);
|
|
339
|
+
debounce = setTimeout(() => this.reloadConfig(), 500);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
logger.debug("Watching config for changes", { path: this.configPath });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async reloadConfig() {
|
|
346
|
+
try {
|
|
347
|
+
const newConfig = await loadConfig(this.configPath);
|
|
348
|
+
|
|
349
|
+
// Restart poll timer if interval changed
|
|
350
|
+
if (newConfig.pollIntervalSeconds !== this.config.pollIntervalSeconds) {
|
|
351
|
+
if (this.pollTimer) clearInterval(this.pollTimer);
|
|
352
|
+
this.pollTimer = setInterval(() => this.poll(), newConfig.pollIntervalSeconds * 1000);
|
|
353
|
+
logger.info("Poll interval updated", { seconds: newConfig.pollIntervalSeconds });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (newConfig.logLevel !== this.config.logLevel) {
|
|
357
|
+
setLogLevel(newConfig.logLevel);
|
|
358
|
+
logger.info("Log level updated", { level: newConfig.logLevel });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Reinit workspaces if workspace config changed
|
|
362
|
+
const oldWsJson = JSON.stringify(this.config.workspaces);
|
|
363
|
+
const newWsJson = JSON.stringify(newConfig.workspaces);
|
|
364
|
+
if (oldWsJson !== newWsJson) {
|
|
365
|
+
for (const ws of this.workspaces.values()) {
|
|
366
|
+
ws.telegram?.stopPolling();
|
|
367
|
+
}
|
|
368
|
+
this.workspaces.clear();
|
|
369
|
+
this.config = newConfig;
|
|
370
|
+
this.initWorkspaces();
|
|
371
|
+
logger.info("Workspaces reinitialized");
|
|
372
|
+
} else {
|
|
373
|
+
this.config = newConfig;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
logger.info("Config reloaded");
|
|
377
|
+
|
|
378
|
+
// Trigger immediate poll after reload
|
|
379
|
+
await this.poll();
|
|
380
|
+
} catch (err) {
|
|
381
|
+
logger.error("Config reload failed, keeping current config", { error: String(err) });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private async poll() {
|
|
386
|
+
if (this.stopping) return;
|
|
387
|
+
|
|
388
|
+
for (const [wsKey, ws] of this.workspaces) {
|
|
389
|
+
for (const project of ws.config.projects) {
|
|
390
|
+
for (const [name, adapter] of ws.adapters) {
|
|
391
|
+
try {
|
|
392
|
+
const issues = await adapter.poll(project.key);
|
|
393
|
+
let newCount = 0;
|
|
394
|
+
|
|
395
|
+
for (const issue of issues) {
|
|
396
|
+
if (this.db.exists(issue.source, issue.sourceId)) continue;
|
|
397
|
+
|
|
398
|
+
const row = this.db.upsert({ ...issue, workspaceKey: wsKey });
|
|
399
|
+
const enqueued = this.queue.enqueue(
|
|
400
|
+
`${issue.source}:${issue.sourceId}`,
|
|
401
|
+
{ issue, row, project, adapter, workspaceKey: wsKey },
|
|
402
|
+
issue.severity as Severity,
|
|
403
|
+
);
|
|
404
|
+
if (enqueued) newCount++;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (newCount > 0) {
|
|
408
|
+
logger.info("New issues found", { workspace: wsKey, source: name, project: project.key, count: newCount });
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
logger.error("Poll error", { workspace: wsKey, source: name, project: project.key, error: String(err) });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Pick up manually queued entries
|
|
418
|
+
await this.processManualEntries();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private async processManualEntries() {
|
|
422
|
+
const newEntries = [...this.db.getByStatus("new"), ...this.db.getByStatus("triaged_actionable")];
|
|
423
|
+
for (const row of newEntries) {
|
|
424
|
+
const found = findProjectConfig(this.config, row.projectKey);
|
|
425
|
+
if (!found) {
|
|
426
|
+
logger.warn("Manual entry references unknown project", { projectKey: row.projectKey, id: row.id });
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const ws = this.getWorkspaceRuntime(row.workspaceKey);
|
|
431
|
+
if (!ws) {
|
|
432
|
+
logger.warn("Manual entry references unknown workspace", { workspaceKey: row.workspaceKey, id: row.id });
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const issue: NormalizedIssue = this.rowToIssue(row);
|
|
437
|
+
const adapter = ws.adapters.get(row.source) ?? null;
|
|
438
|
+
|
|
439
|
+
const enqueued = this.queue.enqueue(
|
|
440
|
+
`${row.source}:${row.sourceId}`,
|
|
441
|
+
{ issue, row, project: found.project, adapter, workspaceKey: row.workspaceKey },
|
|
442
|
+
issue.severity as Severity,
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
if (enqueued) {
|
|
446
|
+
logger.info("Enqueued manual entry", { source: row.source, id: row.sourceId, project: row.projectKey });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async loadProjectContext(repoPath: string): Promise<string | undefined> {
|
|
452
|
+
const candidates = ["CLAUDE.md", ".claude/CLAUDE.md", "AGENTS.md"];
|
|
453
|
+
for (const name of candidates) {
|
|
454
|
+
const file = Bun.file(join(repoPath, name));
|
|
455
|
+
if (await file.exists()) {
|
|
456
|
+
const content = await file.text();
|
|
457
|
+
return content.slice(0, 4000);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return undefined;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private dbUpdate(id: number, fields: Partial<Record<string, unknown>>) {
|
|
464
|
+
this.db.update(id, fields);
|
|
465
|
+
if (fields.status !== undefined) {
|
|
466
|
+
const row = this.db.getById(id);
|
|
467
|
+
if (row) this.dashboard.broadcast({ type: "issue_update", issue: row });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private dbUpdateStatus(id: number, status: IssueStatus) {
|
|
472
|
+
this.db.updateStatus(id, status);
|
|
473
|
+
const row = this.db.getById(id);
|
|
474
|
+
if (row) this.dashboard.broadcast({ type: "issue_update", issue: row });
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private getWorkspaceRuntime(workspaceKey: string): WorkspaceRuntime | undefined {
|
|
478
|
+
return this.workspaces.get(workspaceKey);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private createStreamReporter(ws: WorkspaceRuntime, issueId: number, progressMsgId: number | null, stage: "triage" | "fix") {
|
|
482
|
+
let lastUpdate = 0;
|
|
483
|
+
const THROTTLE_MS = 3000;
|
|
484
|
+
const startTime = Date.now();
|
|
485
|
+
let toolCallCount = 0;
|
|
486
|
+
let lastTool = "";
|
|
487
|
+
let costUsd: number | undefined;
|
|
488
|
+
|
|
489
|
+
const icon = stage === "triage" ? "๐" : "โก";
|
|
490
|
+
const label = stage === "triage" ? "Triaging" : "Working";
|
|
491
|
+
|
|
492
|
+
return (event: ClaudeStreamEvent) => {
|
|
493
|
+
if (event.type === "tool_use") {
|
|
494
|
+
toolCallCount++;
|
|
495
|
+
lastTool = event.tool;
|
|
496
|
+
} else if (event.type === "text") {
|
|
497
|
+
// Broadcast text events immediately (no throttle) for live output
|
|
498
|
+
this.dashboard.broadcast({
|
|
499
|
+
type: "stream_text",
|
|
500
|
+
issueId,
|
|
501
|
+
stage,
|
|
502
|
+
text: event.text,
|
|
503
|
+
});
|
|
504
|
+
return;
|
|
505
|
+
} else if (event.type === "result" && "costUsd" in event) {
|
|
506
|
+
costUsd = (event as { costUsd?: number }).costUsd;
|
|
507
|
+
} else {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const now = Date.now();
|
|
512
|
+
if (now - lastUpdate < THROTTLE_MS) return;
|
|
513
|
+
lastUpdate = now;
|
|
514
|
+
|
|
515
|
+
const elapsed = Math.round((now - startTime) / 1000);
|
|
516
|
+
|
|
517
|
+
// Broadcast to web dashboard
|
|
518
|
+
this.dashboard.broadcast({
|
|
519
|
+
type: "stream_progress",
|
|
520
|
+
issueId,
|
|
521
|
+
stage,
|
|
522
|
+
tool: lastTool,
|
|
523
|
+
elapsed,
|
|
524
|
+
toolCallCount,
|
|
525
|
+
startedAt: startTime,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// Update Telegram if available
|
|
529
|
+
if (ws.telegram && progressMsgId) {
|
|
530
|
+
let text = `${icon} <b>${label}...</b> ${lastTool} (${elapsed}s ยท ${toolCallCount} calls`;
|
|
531
|
+
if (costUsd != null) text += ` ยท ~$${costUsd.toFixed(3)}`;
|
|
532
|
+
text += `)`;
|
|
533
|
+
ws.telegram.editMessage(progressMsgId, text).catch(() => {});
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async processItem(item: PipelineItem) {
|
|
539
|
+
const { issue, row, project, adapter, workspaceKey } = item;
|
|
540
|
+
const ws = this.getWorkspaceRuntime(workspaceKey);
|
|
541
|
+
if (!ws) {
|
|
542
|
+
logger.error("Workspace runtime not found", { workspaceKey });
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const logCtx = { workspace: workspaceKey, source: issue.source, id: issue.sourceId, title: issue.title };
|
|
547
|
+
|
|
548
|
+
// Set triaging status early so the UI updates immediately
|
|
549
|
+
if (row.triageVerdict !== "fixable") {
|
|
550
|
+
this.dbUpdateStatus(row.id, "triaging");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Reuse existing topic or create new one
|
|
554
|
+
let threadId = row.telegramThreadId;
|
|
555
|
+
if (ws.telegram) {
|
|
556
|
+
if (threadId) {
|
|
557
|
+
await ws.telegram.reopenThread(threadId);
|
|
558
|
+
const retryMsg = row.status === "triaged_actionable" && row.triageVerdict === "fixable"
|
|
559
|
+
? "โ๏ธ Starting..."
|
|
560
|
+
: "๐ Retrying...";
|
|
561
|
+
await ws.telegram.sendThreadMessage(threadId, retryMsg);
|
|
562
|
+
} else {
|
|
563
|
+
threadId = await ws.telegram.createIssueTopic(row);
|
|
564
|
+
if (threadId) {
|
|
565
|
+
this.dbUpdate(row.id, { telegramThreadId: threadId });
|
|
566
|
+
} else {
|
|
567
|
+
logger.warn("Failed to create forum topic, continuing without Telegram", logCtx);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const projectContext = await this.loadProjectContext(project.repoPath);
|
|
574
|
+
const context = adapter
|
|
575
|
+
? await adapter.getIssueContext(issue)
|
|
576
|
+
: `Task: ${issue.title}\n\n${issue.body || "Investigate and address the issue described in the title."}`;
|
|
577
|
+
|
|
578
|
+
// Skip triage if already triaged as fixable (e.g. user clicked "Start Fix")
|
|
579
|
+
if (row.triageVerdict === "fixable") {
|
|
580
|
+
logger.info("Skipping triage, already triaged as fixable", logCtx);
|
|
581
|
+
this.dbUpdateStatus(row.id, "triaged_actionable");
|
|
582
|
+
await this.fixStage(row, issue, project, context, projectContext, ws, threadId, logCtx, { plan: row.triagePlan, sessionId: row.sessionId ?? undefined });
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
await this.triageStage(row, issue, project, context, projectContext, ws, threadId, logCtx);
|
|
587
|
+
} catch (err) {
|
|
588
|
+
logger.error("Pipeline error", { ...logCtx, error: String(err) });
|
|
589
|
+
this.dbUpdate(row.id, { status: "failed", fixSummary: String(err), failureReason: "pipeline:unexpected" });
|
|
590
|
+
if (ws.telegram && threadId) {
|
|
591
|
+
const msgId = await ws.telegram.sendFailedMessage(row.source, row.sourceId, String(err).slice(0, 500), threadId, "pipeline:unexpected");
|
|
592
|
+
if (msgId) this.dbUpdate(row.id, { telegramMessageId: msgId });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
private async pendingConfirmation(rowId: number, row: IssueRow, threadId: number | null, ws: WorkspaceRuntime) {
|
|
598
|
+
if (ws.telegram && threadId) {
|
|
599
|
+
const msgId = await ws.telegram.sendFixReady(row, threadId);
|
|
600
|
+
if (msgId) this.db.update(rowId, { telegramMessageId: msgId });
|
|
601
|
+
}
|
|
602
|
+
this.queue.pause();
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private async triageStage(
|
|
606
|
+
row: IssueRow,
|
|
607
|
+
issue: NormalizedIssue,
|
|
608
|
+
project: ProjectConfig,
|
|
609
|
+
context: string,
|
|
610
|
+
projectContext: string | undefined,
|
|
611
|
+
ws: WorkspaceRuntime,
|
|
612
|
+
threadId: number | null,
|
|
613
|
+
logCtx: Record<string, string>,
|
|
614
|
+
): Promise<void> {
|
|
615
|
+
if (!this.config.triage) {
|
|
616
|
+
logger.info("Triage disabled, awaiting fix confirmation", logCtx);
|
|
617
|
+
this.dbUpdate(row.id, { status: "pending_confirmation", triageVerdict: "fixable" });
|
|
618
|
+
const updatedRow = this.db.get(issue.source, issue.sourceId)!;
|
|
619
|
+
await this.pendingConfirmation(row.id, updatedRow, threadId, ws);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
logger.info("Triaging issue", logCtx);
|
|
624
|
+
|
|
625
|
+
const triageMsgId = ws.telegram && threadId
|
|
626
|
+
? await ws.telegram.sendThreadMessage(threadId, "๐ Triaging with Sonnet...")
|
|
627
|
+
: null;
|
|
628
|
+
|
|
629
|
+
const onEvent = this.createStreamReporter(ws, row.id, triageMsgId, "triage");
|
|
630
|
+
|
|
631
|
+
const triageLogPath = `/tmp/relay-${row.id}-triage.ndjson`;
|
|
632
|
+
const triage = await triageIssue(context, project.repoPath, this.config.triageTimeout, projectContext, "sonnet", onEvent, triageLogPath);
|
|
633
|
+
|
|
634
|
+
const triageStats = {
|
|
635
|
+
triageDurationMs: triage.durationMs,
|
|
636
|
+
triageInputTokens: triage.inputTokens,
|
|
637
|
+
triageOutputTokens: triage.outputTokens,
|
|
638
|
+
triageCostUsd: triage.costUsd ?? null,
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
if (triage.failed) {
|
|
642
|
+
logger.warn("Triage error", { ...logCtx, reason: triage.reason });
|
|
643
|
+
this.dbUpdate(row.id, { status: "failed", triageReason: triage.reason, failureReason: triage.failureReason, ...triageStats });
|
|
644
|
+
if (ws.telegram && threadId) {
|
|
645
|
+
const msgId = await ws.telegram.sendFailedMessage(row.source, row.sourceId, triage.reason, threadId, triage.failureReason);
|
|
646
|
+
if (msgId) this.dbUpdate(row.id, { telegramMessageId: msgId });
|
|
647
|
+
}
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
this.dbUpdate(row.id, {
|
|
652
|
+
triageVerdict: triage.fixable ? "fixable" : "unfixable",
|
|
653
|
+
triageReason: triage.reason,
|
|
654
|
+
triagePlan: triage.plan,
|
|
655
|
+
triageConfidence: triage.confidence,
|
|
656
|
+
sessionId: triage.sessionId,
|
|
657
|
+
status: triage.fixable ? "pending_confirmation" : "triaged_not_actionable",
|
|
658
|
+
...triageStats,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
if (ws.telegram) {
|
|
662
|
+
const updatedRow = this.db.get(issue.source, issue.sourceId)!;
|
|
663
|
+
const triageResultMsgId = await ws.telegram.sendTriageResult(updatedRow, triage, threadId ?? undefined);
|
|
664
|
+
if (triageResultMsgId) {
|
|
665
|
+
this.dbUpdate(row.id, { telegramMessageId: triageResultMsgId });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (!triage.fixable) {
|
|
670
|
+
logger.info("Issue not fixable", logCtx);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
logger.info("Triage fixable, awaiting fix confirmation โ queue paused", logCtx);
|
|
675
|
+
this.queue.pause();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private async fixStage(
|
|
679
|
+
row: IssueRow,
|
|
680
|
+
issue: NormalizedIssue,
|
|
681
|
+
project: ProjectConfig,
|
|
682
|
+
context: string,
|
|
683
|
+
projectContext: string | undefined,
|
|
684
|
+
ws: WorkspaceRuntime,
|
|
685
|
+
threadId: number | null,
|
|
686
|
+
logCtx: Record<string, string>,
|
|
687
|
+
triage: { plan: string | null; sessionId?: string },
|
|
688
|
+
) {
|
|
689
|
+
if (ws.telegram && threadId) {
|
|
690
|
+
await ws.telegram.sendThreadMessage(threadId, "๐ Creating worktree...");
|
|
691
|
+
}
|
|
692
|
+
logger.info("Creating worktree", logCtx);
|
|
693
|
+
let worktreePath: string;
|
|
694
|
+
let branchName: string;
|
|
695
|
+
try {
|
|
696
|
+
({ worktreePath, branchName } = await createWorktree(
|
|
697
|
+
project.repoPath,
|
|
698
|
+
issue.source,
|
|
699
|
+
issue.sourceId,
|
|
700
|
+
project.baseBranch,
|
|
701
|
+
));
|
|
702
|
+
} catch (err) {
|
|
703
|
+
logger.error("Worktree creation failed", { ...logCtx, error: String(err) });
|
|
704
|
+
this.dbUpdate(row.id, { status: "failed", fixSummary: String(err), failureReason: "worktree:create_failed" });
|
|
705
|
+
if (ws.telegram && threadId) {
|
|
706
|
+
const msgId = await ws.telegram.sendFailedMessage(row.source, row.sourceId, String(err), threadId, "worktree:create_failed");
|
|
707
|
+
if (msgId) this.dbUpdate(row.id, { telegramMessageId: msgId });
|
|
708
|
+
}
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
this.dbUpdate(row.id, { worktreePath, branch: branchName, status: "working" });
|
|
713
|
+
|
|
714
|
+
logger.info("Fixing issue", logCtx);
|
|
715
|
+
|
|
716
|
+
const fixMsgId = ws.telegram && threadId
|
|
717
|
+
? await ws.telegram.sendThreadMessage(threadId, "โก Working with Opus...")
|
|
718
|
+
: null;
|
|
719
|
+
|
|
720
|
+
const onEvent = this.createStreamReporter(ws, row.id, fixMsgId, "fix");
|
|
721
|
+
|
|
722
|
+
const fixLogPath = `/tmp/relay-${row.id}-fix.ndjson`;
|
|
723
|
+
const fixResult = await fixIssue({
|
|
724
|
+
issueContext: context,
|
|
725
|
+
triagePlan: triage.plan ?? "Investigate the issue, find the root cause, and implement a fix.",
|
|
726
|
+
worktreePath,
|
|
727
|
+
baseBranch: project.baseBranch,
|
|
728
|
+
testCommand: project.testCommand,
|
|
729
|
+
claudeTimeout: this.config.claudeTimeout,
|
|
730
|
+
allowedTools: this.config.allowedTools,
|
|
731
|
+
projectContext,
|
|
732
|
+
model: "opus",
|
|
733
|
+
sessionId: triage.sessionId,
|
|
734
|
+
onEvent,
|
|
735
|
+
logPath: fixLogPath,
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
const fixStats = {
|
|
739
|
+
fixDurationMs: fixResult.durationMs,
|
|
740
|
+
fixInputTokens: fixResult.inputTokens,
|
|
741
|
+
fixOutputTokens: fixResult.outputTokens,
|
|
742
|
+
fixCostUsd: fixResult.costUsd ?? null,
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
if (!fixResult.success) {
|
|
746
|
+
logger.warn("Fix failed", { ...logCtx, error: fixResult.error });
|
|
747
|
+
this.dbUpdate(row.id, { status: "failed", fixSummary: fixResult.error, failureReason: fixResult.failureReason, ...fixStats });
|
|
748
|
+
await removeWorktree(project.repoPath, worktreePath, branchName);
|
|
749
|
+
if (ws.telegram && threadId) {
|
|
750
|
+
const msgId = await ws.telegram.sendFailedMessage(row.source, row.sourceId, fixResult.error ?? "unknown error", threadId, fixResult.failureReason);
|
|
751
|
+
if (msgId) this.dbUpdate(row.id, { telegramMessageId: msgId });
|
|
752
|
+
}
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
this.dbUpdate(row.id, {
|
|
757
|
+
status: "pending_approval",
|
|
758
|
+
fixSummary: fixResult.summary,
|
|
759
|
+
diffSummary: fixResult.diffSummary,
|
|
760
|
+
diffPatch: fixResult.diffPatch,
|
|
761
|
+
...fixStats,
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
if (ws.telegram) {
|
|
765
|
+
const approvalRow = this.db.get(issue.source, issue.sourceId)!;
|
|
766
|
+
const approvalMsgId = await ws.telegram.sendApproval(
|
|
767
|
+
approvalRow,
|
|
768
|
+
fixResult.summary,
|
|
769
|
+
threadId ?? undefined,
|
|
770
|
+
{ durationMs: fixResult.durationMs, inputTokens: fixResult.inputTokens, outputTokens: fixResult.outputTokens, costUsd: fixResult.costUsd },
|
|
771
|
+
);
|
|
772
|
+
if (approvalMsgId) {
|
|
773
|
+
this.dbUpdate(row.id, { telegramMessageId: approvalMsgId });
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
logger.info("Fix ready, awaiting approval โ queue paused", logCtx);
|
|
778
|
+
this.queue.pause();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async executeAction(data: CallbackData): Promise<ActionResult> {
|
|
782
|
+
const row = this.db.get(data.source, data.sourceId);
|
|
783
|
+
if (!row) {
|
|
784
|
+
return { ok: false, error: "Issue not found" };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const ws = this.getWorkspaceRuntime(row.workspaceKey);
|
|
788
|
+
if (!ws) {
|
|
789
|
+
return { ok: false, error: "Workspace not found" };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const found = findProjectConfig(this.config, row.projectKey);
|
|
793
|
+
if (!found) {
|
|
794
|
+
return { ok: false, error: "Project not found" };
|
|
795
|
+
}
|
|
796
|
+
const { project } = found;
|
|
797
|
+
const adapter = ws.adapters.get(row.source);
|
|
798
|
+
const threadId = row.telegramThreadId;
|
|
799
|
+
|
|
800
|
+
switch (data.action) {
|
|
801
|
+
case "accept": {
|
|
802
|
+
if (row.status !== "pending_approval") {
|
|
803
|
+
return { ok: false, error: `Cannot accept issue in status '${row.status}'` };
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
logger.info("Fix accepted", { source: row.source, id: row.sourceId });
|
|
807
|
+
|
|
808
|
+
let acceptFailureReason: string | undefined;
|
|
809
|
+
try {
|
|
810
|
+
try {
|
|
811
|
+
await pushBranch(row.worktreePath!, row.branch!);
|
|
812
|
+
} catch (err) {
|
|
813
|
+
acceptFailureReason = "accept:push_failed";
|
|
814
|
+
throw err;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
let prUrl: string;
|
|
818
|
+
try {
|
|
819
|
+
const prBody = buildPRBody(row);
|
|
820
|
+
prUrl = await buildPRUrl(
|
|
821
|
+
row.worktreePath!,
|
|
822
|
+
row.branch!,
|
|
823
|
+
project.baseBranch,
|
|
824
|
+
`fix: ${row.title}`,
|
|
825
|
+
prBody,
|
|
826
|
+
);
|
|
827
|
+
} catch (err) {
|
|
828
|
+
acceptFailureReason = "accept:pr_failed";
|
|
829
|
+
throw err;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
this.dbUpdate(row.id, { status: "accepted", prUrl });
|
|
833
|
+
|
|
834
|
+
await removeWorktree(project.repoPath, row.worktreePath!, row.branch!);
|
|
835
|
+
|
|
836
|
+
if (adapter && adapter.onFixAccepted) {
|
|
837
|
+
const issue = this.rowToIssue(row);
|
|
838
|
+
await adapter.onFixAccepted(issue, prUrl);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if (ws.telegram) {
|
|
842
|
+
if (row.telegramMessageId) {
|
|
843
|
+
await ws.telegram.editMessageRemoveKeyboard(
|
|
844
|
+
row.telegramMessageId,
|
|
845
|
+
`โ
<b>ACCEPTED</b> โ ${esc(row.title)}`,
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
if (threadId) {
|
|
849
|
+
await ws.telegram.sendThreadMessage(threadId, `โ
<b>ACCEPTED</b>\n<a href="${esc(prUrl)}">Create PR</a>`);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
this.queue.resume();
|
|
854
|
+
logger.info("Queue resumed");
|
|
855
|
+
return { ok: true, issue: this.db.getById(row.id)! };
|
|
856
|
+
} catch (err) {
|
|
857
|
+
logger.error("Accept failed", { error: String(err) });
|
|
858
|
+
this.dbUpdate(row.id, { status: "failed", failureReason: acceptFailureReason });
|
|
859
|
+
if (ws.telegram) {
|
|
860
|
+
if (row.telegramMessageId) {
|
|
861
|
+
await ws.telegram.editMessageRemoveKeyboard(
|
|
862
|
+
row.telegramMessageId,
|
|
863
|
+
`โ <b>ACCEPT FAILED</b> โ ${esc(row.title)}`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
if (threadId) {
|
|
867
|
+
const msgId = await ws.telegram.sendFailedMessage(row.source, row.sourceId, String(err), threadId, acceptFailureReason);
|
|
868
|
+
if (msgId) this.dbUpdate(row.id, { telegramMessageId: msgId });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
this.queue.resume();
|
|
872
|
+
logger.info("Queue resumed");
|
|
873
|
+
return { ok: false, error: String(err) };
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
case "discard": {
|
|
878
|
+
logger.info("Fix discarded", { source: row.source, id: row.sourceId });
|
|
879
|
+
this.dbUpdate(row.id, { status: "discarded" });
|
|
880
|
+
|
|
881
|
+
if (row.worktreePath && row.branch) {
|
|
882
|
+
await removeWorktree(project.repoPath, row.worktreePath, row.branch);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (adapter && adapter.onFixDiscarded) {
|
|
886
|
+
await adapter.onFixDiscarded(this.rowToIssue(row));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (ws.telegram) {
|
|
890
|
+
if (row.telegramMessageId) {
|
|
891
|
+
await ws.telegram.editMessageRemoveKeyboard(
|
|
892
|
+
row.telegramMessageId,
|
|
893
|
+
`๐ <b>DISCARDED</b> โ ${esc(row.title)}`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
if (threadId) {
|
|
897
|
+
await ws.telegram.closeThread(threadId, `๐ <b>DISCARDED</b> โ ${esc(row.title)}`);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
case "skip": {
|
|
904
|
+
logger.info("Issue skipped", { source: row.source, id: row.sourceId });
|
|
905
|
+
this.dbUpdate(row.id, { status: "skipped" });
|
|
906
|
+
|
|
907
|
+
if (row.worktreePath && row.branch) {
|
|
908
|
+
await removeWorktree(project.repoPath, row.worktreePath, row.branch);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (ws.telegram) {
|
|
912
|
+
if (row.telegramMessageId) {
|
|
913
|
+
await ws.telegram.editMessageRemoveKeyboard(
|
|
914
|
+
row.telegramMessageId,
|
|
915
|
+
`โญ <b>SKIPPED</b> โ ${esc(row.title)}`,
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
if (threadId) {
|
|
919
|
+
await ws.telegram.closeThread(threadId, `โญ <b>SKIPPED</b> โ ${esc(row.title)}`);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
break;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
case "retry": {
|
|
926
|
+
if (row.status !== "failed" && row.status !== "triaged_not_actionable") {
|
|
927
|
+
return { ok: false, error: `Cannot retry issue in status '${row.status}'` };
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
logger.info("Retrying issue", { source: row.source, id: row.sourceId });
|
|
931
|
+
|
|
932
|
+
if (ws.telegram && row.telegramMessageId) {
|
|
933
|
+
await ws.telegram.editMessageRemoveKeyboard(row.telegramMessageId, `๐ Retrying...`);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (row.triageVerdict === "fixable") {
|
|
937
|
+
// Triage already done โ skip straight to fix confirmation
|
|
938
|
+
this.dbUpdateStatus(row.id, "pending_confirmation");
|
|
939
|
+
const freshRow = this.db.get(row.source, row.sourceId)!;
|
|
940
|
+
await this.pendingConfirmation(row.id, freshRow, threadId, ws);
|
|
941
|
+
// Skip queue.resume() โ waiting for "Start Fix"
|
|
942
|
+
return { ok: true, issue: this.db.getById(row.id)! };
|
|
943
|
+
} else {
|
|
944
|
+
// Re-triage from scratch โ clear stale triage data
|
|
945
|
+
this.dbUpdate(row.id, {
|
|
946
|
+
status: "new",
|
|
947
|
+
triageVerdict: null,
|
|
948
|
+
triageReason: null,
|
|
949
|
+
triagePlan: null,
|
|
950
|
+
triageConfidence: null,
|
|
951
|
+
sessionId: null,
|
|
952
|
+
failureReason: null,
|
|
953
|
+
triageDurationMs: null,
|
|
954
|
+
triageInputTokens: null,
|
|
955
|
+
triageOutputTokens: null,
|
|
956
|
+
triageCostUsd: null,
|
|
957
|
+
});
|
|
958
|
+
const freshRow = this.db.get(row.source, row.sourceId)!;
|
|
959
|
+
const retryIssue = this.rowToIssue(freshRow);
|
|
960
|
+
this.queue.enqueue(
|
|
961
|
+
`${row.source}:${row.sourceId}`,
|
|
962
|
+
{ issue: retryIssue, row: freshRow, project, adapter: ws.adapters.get(row.source) ?? null, workspaceKey: row.workspaceKey },
|
|
963
|
+
retryIssue.severity as Severity,
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
case "fix": {
|
|
970
|
+
if (row.status !== "pending_confirmation") {
|
|
971
|
+
return { ok: false, error: `Cannot start fix for issue in status '${row.status}'` };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
logger.info("Fix confirmed, enqueuing", { source: row.source, id: row.sourceId });
|
|
975
|
+
|
|
976
|
+
if (ws.telegram && row.telegramMessageId) {
|
|
977
|
+
await ws.telegram.editMessageRemoveKeyboard(
|
|
978
|
+
row.telegramMessageId,
|
|
979
|
+
`๐ <b>STARTED</b> โ ${esc(row.title)}`,
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
this.dbUpdateStatus(row.id, "triaged_actionable");
|
|
984
|
+
const freshRow = this.db.get(row.source, row.sourceId)!;
|
|
985
|
+
const fixIssue = this.rowToIssue(freshRow);
|
|
986
|
+
this.queue.enqueue(
|
|
987
|
+
`${row.source}:${row.sourceId}`,
|
|
988
|
+
{ issue: fixIssue, row: freshRow, project, adapter: ws.adapters.get(row.source) ?? null, workspaceKey: row.workspaceKey },
|
|
989
|
+
fixIssue.severity as Severity,
|
|
990
|
+
);
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Resume queue after any callback action
|
|
996
|
+
this.queue.resume();
|
|
997
|
+
logger.info("Queue resumed");
|
|
998
|
+
return { ok: true, issue: this.db.getById(row.id)! };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private async handleCallback(data: CallbackData, workspaceKey: string) {
|
|
1002
|
+
// Look up the issue to verify workspace
|
|
1003
|
+
const row = this.db.get(data.source, data.sourceId);
|
|
1004
|
+
if (!row) {
|
|
1005
|
+
logger.warn("Callback for unknown issue", { action: data.action, source: data.source, sourceId: data.sourceId });
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
await this.executeAction(data);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private async resendPendingApprovals() {
|
|
1012
|
+
const pending = this.db.getByStatus("pending_approval");
|
|
1013
|
+
const resent = pending.filter((row) => {
|
|
1014
|
+
if (row.telegramMessageId) return false;
|
|
1015
|
+
return row.telegramThreadId;
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
for (const row of resent) {
|
|
1019
|
+
const ws = this.getWorkspaceRuntime(row.workspaceKey);
|
|
1020
|
+
if (!ws?.telegram) continue;
|
|
1021
|
+
|
|
1022
|
+
const msgId = await ws.telegram.sendApproval(
|
|
1023
|
+
row,
|
|
1024
|
+
row.fixSummary ?? "",
|
|
1025
|
+
row.telegramThreadId!,
|
|
1026
|
+
);
|
|
1027
|
+
if (msgId) {
|
|
1028
|
+
this.dbUpdate(row.id, { telegramMessageId: msgId });
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (resent.length) {
|
|
1032
|
+
logger.info("Re-sent pending approvals", { count: resent.length });
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
private rowToIssue(row: IssueRow): NormalizedIssue {
|
|
1037
|
+
return {
|
|
1038
|
+
source: row.source,
|
|
1039
|
+
sourceId: row.sourceId,
|
|
1040
|
+
externalUrl: row.externalUrl,
|
|
1041
|
+
projectKey: row.projectKey,
|
|
1042
|
+
title: row.title,
|
|
1043
|
+
body: row.body,
|
|
1044
|
+
severity: row.severity as Severity,
|
|
1045
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private getIntegrationsStatus(): Array<{ workspace: string; telegram: boolean; sources: string[] }> {
|
|
1050
|
+
const result: Array<{ workspace: string; telegram: boolean; sources: string[] }> = [];
|
|
1051
|
+
for (const [key, ws] of this.workspaces) {
|
|
1052
|
+
result.push({
|
|
1053
|
+
workspace: key,
|
|
1054
|
+
telegram: ws.telegram !== null,
|
|
1055
|
+
sources: [...ws.adapters.keys()],
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
return result;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
getDB(): IssueDB {
|
|
1062
|
+
return this.db;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function buildPRBody(row: IssueRow): string {
|
|
1067
|
+
const lines: string[] = [];
|
|
1068
|
+
|
|
1069
|
+
if (row.externalUrl) {
|
|
1070
|
+
lines.push("## Source");
|
|
1071
|
+
lines.push(row.externalUrl);
|
|
1072
|
+
lines.push("");
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
lines.push("## Summary");
|
|
1076
|
+
lines.push(row.fixSummary ?? "Automated changes.");
|
|
1077
|
+
lines.push("");
|
|
1078
|
+
|
|
1079
|
+
lines.push("## Test plan");
|
|
1080
|
+
lines.push("- [ ] Verify the changes resolve the issue");
|
|
1081
|
+
lines.push("- [ ] Run existing tests");
|
|
1082
|
+
lines.push("- [ ] Manual QA");
|
|
1083
|
+
lines.push("");
|
|
1084
|
+
|
|
1085
|
+
lines.push("---");
|
|
1086
|
+
lines.push("๐ค Generated with [Claude Code](https://claude.com/claude-code) ยท Enhanced by [Relay](https://github.com/adriandmitroca/relay)");
|
|
1087
|
+
|
|
1088
|
+
return lines.join("\n");
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// When run directly
|
|
1092
|
+
if (import.meta.main) {
|
|
1093
|
+
const configPath = process.argv[2] || join(process.cwd(), "config.json");
|
|
1094
|
+
const daemon = new Daemon(configPath);
|
|
1095
|
+
await daemon.start();
|
|
1096
|
+
}
|