@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/src/db.ts ADDED
@@ -0,0 +1,718 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { logger } from "./utils/logger.ts";
3
+ import type { Config, WorkspaceConfig, ProjectConfig, SentrySourceConfig, AsanaSourceConfig, LinearSourceConfig, JiraSourceConfig } from "./config.ts";
4
+
5
+ export type IssueStatus =
6
+ | "new"
7
+ | "triaging"
8
+ | "triaged_actionable"
9
+ | "triaged_not_actionable"
10
+ | "pending_confirmation"
11
+ | "working"
12
+ | "pending_approval"
13
+ | "accepted"
14
+ | "discarded"
15
+ | "skipped"
16
+ | "failed";
17
+
18
+ export interface IssueRow {
19
+ id: number;
20
+ source: string;
21
+ sourceId: string;
22
+ projectKey: string;
23
+ workspaceKey: string;
24
+ externalUrl: string;
25
+ title: string;
26
+ body: string;
27
+ severity: string;
28
+ status: IssueStatus;
29
+ triageVerdict: string | null;
30
+ triageReason: string | null;
31
+ triagePlan: string | null;
32
+ triageConfidence: number | null;
33
+ fixSummary: string | null;
34
+ diffSummary: string | null;
35
+ diffPatch: string | null;
36
+ worktreePath: string | null;
37
+ branch: string | null;
38
+ prUrl: string | null;
39
+ telegramMessageId: number | null;
40
+ telegramThreadId: number | null;
41
+ sessionId: string | null;
42
+ failureReason: string | null;
43
+ triageDurationMs: number | null;
44
+ triageInputTokens: number | null;
45
+ triageOutputTokens: number | null;
46
+ triageCostUsd: number | null;
47
+ fixDurationMs: number | null;
48
+ fixInputTokens: number | null;
49
+ fixOutputTokens: number | null;
50
+ fixCostUsd: number | null;
51
+ metadata: string | null;
52
+ createdAt: string;
53
+ updatedAt: string;
54
+ }
55
+
56
+ const SCHEMA = `
57
+ CREATE TABLE IF NOT EXISTS issues (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ source TEXT NOT NULL,
60
+ source_id TEXT NOT NULL,
61
+ project_key TEXT NOT NULL,
62
+ workspace_key TEXT NOT NULL DEFAULT '',
63
+ external_url TEXT NOT NULL,
64
+ title TEXT NOT NULL,
65
+ body TEXT NOT NULL,
66
+ severity TEXT NOT NULL DEFAULT 'medium',
67
+ status TEXT NOT NULL DEFAULT 'new',
68
+ triage_verdict TEXT,
69
+ triage_reason TEXT,
70
+ triage_plan TEXT,
71
+ triage_confidence REAL,
72
+ fix_summary TEXT,
73
+ diff_summary TEXT,
74
+ diff_patch TEXT,
75
+ worktree_path TEXT,
76
+ branch TEXT,
77
+ pr_url TEXT,
78
+ telegram_message_id INTEGER,
79
+ telegram_thread_id INTEGER,
80
+ session_id TEXT,
81
+ failure_reason TEXT,
82
+ triage_duration_ms INTEGER,
83
+ triage_input_tokens INTEGER,
84
+ triage_output_tokens INTEGER,
85
+ triage_cost_usd REAL,
86
+ fix_duration_ms INTEGER,
87
+ fix_input_tokens INTEGER,
88
+ fix_output_tokens INTEGER,
89
+ fix_cost_usd REAL,
90
+ metadata TEXT,
91
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
92
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
93
+ UNIQUE(source, source_id)
94
+ );
95
+
96
+ CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
97
+ CREATE INDEX IF NOT EXISTS idx_issues_source ON issues(source, source_id);
98
+ CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_key);
99
+ CREATE INDEX IF NOT EXISTS idx_issues_workspace ON issues(workspace_key);
100
+ `;
101
+
102
+ function toRow(raw: Record<string, unknown>): IssueRow {
103
+ return {
104
+ id: raw.id as number,
105
+ source: raw.source as string,
106
+ sourceId: raw.source_id as string,
107
+ projectKey: raw.project_key as string,
108
+ workspaceKey: raw.workspace_key as string,
109
+ externalUrl: raw.external_url as string,
110
+ title: raw.title as string,
111
+ body: raw.body as string,
112
+ severity: raw.severity as string,
113
+ status: raw.status as IssueStatus,
114
+ triageVerdict: raw.triage_verdict as string | null,
115
+ triageReason: raw.triage_reason as string | null,
116
+ triagePlan: raw.triage_plan as string | null,
117
+ triageConfidence: raw.triage_confidence as number | null,
118
+ fixSummary: raw.fix_summary as string | null,
119
+ diffSummary: raw.diff_summary as string | null,
120
+ diffPatch: raw.diff_patch as string | null,
121
+ worktreePath: raw.worktree_path as string | null,
122
+ branch: raw.branch as string | null,
123
+ prUrl: raw.pr_url as string | null,
124
+ telegramMessageId: raw.telegram_message_id as number | null,
125
+ telegramThreadId: raw.telegram_thread_id as number | null,
126
+ sessionId: raw.session_id as string | null,
127
+ failureReason: raw.failure_reason as string | null,
128
+ triageDurationMs: raw.triage_duration_ms as number | null,
129
+ triageInputTokens: raw.triage_input_tokens as number | null,
130
+ triageOutputTokens: raw.triage_output_tokens as number | null,
131
+ triageCostUsd: raw.triage_cost_usd as number | null,
132
+ fixDurationMs: raw.fix_duration_ms as number | null,
133
+ fixInputTokens: raw.fix_input_tokens as number | null,
134
+ fixOutputTokens: raw.fix_output_tokens as number | null,
135
+ fixCostUsd: raw.fix_cost_usd as number | null,
136
+ metadata: raw.metadata as string | null,
137
+ createdAt: raw.created_at as string,
138
+ updatedAt: raw.updated_at as string,
139
+ };
140
+ }
141
+
142
+ export class IssueDB {
143
+ private db: Database;
144
+ private stmts: {
145
+ upsert: ReturnType<Database["prepare"]>;
146
+ get: ReturnType<Database["prepare"]>;
147
+ exists: ReturnType<Database["prepare"]>;
148
+ updateStatus: ReturnType<Database["prepare"]>;
149
+ getByStatus: ReturnType<Database["prepare"]>;
150
+ getRecent: ReturnType<Database["prepare"]>;
151
+ };
152
+
153
+ constructor(dbPath: string) {
154
+ this.db = new Database(dbPath);
155
+ this.db.exec("PRAGMA journal_mode = WAL;");
156
+ this.db.exec("PRAGMA busy_timeout = 5000;");
157
+ this.db.exec(SCHEMA);
158
+
159
+ this.stmts = {
160
+ upsert: this.db.prepare(`
161
+ INSERT INTO issues (source, source_id, project_key, workspace_key, external_url, title, body, severity, metadata)
162
+ VALUES ($source, $sourceId, $projectKey, $workspaceKey, $externalUrl, $title, $body, $severity, $metadata)
163
+ ON CONFLICT(source, source_id) DO UPDATE SET
164
+ title = excluded.title,
165
+ body = excluded.body,
166
+ updated_at = datetime('now')
167
+ RETURNING *
168
+ `),
169
+ get: this.db.prepare("SELECT * FROM issues WHERE source = $source AND source_id = $sourceId"),
170
+ exists: this.db.prepare("SELECT 1 FROM issues WHERE source = $source AND source_id = $sourceId"),
171
+ updateStatus: this.db.prepare(`
172
+ UPDATE issues SET status = $status, updated_at = datetime('now') WHERE id = $id
173
+ `),
174
+ getByStatus: this.db.prepare("SELECT * FROM issues WHERE status = $status ORDER BY created_at DESC"),
175
+ getRecent: this.db.prepare("SELECT * FROM issues ORDER BY updated_at DESC LIMIT $limit"),
176
+ };
177
+
178
+ logger.debug("Database initialized", { path: dbPath });
179
+ }
180
+
181
+ upsert(issue: {
182
+ source: string;
183
+ sourceId: string;
184
+ projectKey: string;
185
+ workspaceKey: string;
186
+ externalUrl: string;
187
+ title: string;
188
+ body: string;
189
+ severity: string;
190
+ metadata?: Record<string, unknown>;
191
+ }): IssueRow {
192
+ const raw = this.stmts.upsert.get({
193
+ $source: issue.source,
194
+ $sourceId: issue.sourceId,
195
+ $projectKey: issue.projectKey,
196
+ $workspaceKey: issue.workspaceKey,
197
+ $externalUrl: issue.externalUrl,
198
+ $title: issue.title,
199
+ $body: issue.body,
200
+ $severity: issue.severity,
201
+ $metadata: issue.metadata ? JSON.stringify(issue.metadata) : null,
202
+ }) as Record<string, unknown>;
203
+ return toRow(raw);
204
+ }
205
+
206
+ get(source: string, sourceId: string): IssueRow | null {
207
+ const raw = this.stmts.get.get({ $source: source, $sourceId: sourceId }) as Record<string, unknown> | null;
208
+ return raw ? toRow(raw) : null;
209
+ }
210
+
211
+ exists(source: string, sourceId: string): boolean {
212
+ return this.stmts.exists.get({ $source: source, $sourceId: sourceId }) !== null;
213
+ }
214
+
215
+ updateStatus(id: number, status: IssueStatus) {
216
+ this.stmts.updateStatus.run({ $id: id, $status: status });
217
+ }
218
+
219
+ update(id: number, fields: Partial<Record<string, unknown>>) {
220
+ const sets: string[] = ["updated_at = datetime('now')"];
221
+ const params: Record<string, unknown> = { $id: id };
222
+
223
+ const fieldMap: Record<string, string> = {
224
+ status: "status",
225
+ triageVerdict: "triage_verdict",
226
+ triageReason: "triage_reason",
227
+ triagePlan: "triage_plan",
228
+ triageConfidence: "triage_confidence",
229
+ fixSummary: "fix_summary",
230
+ diffSummary: "diff_summary",
231
+ diffPatch: "diff_patch",
232
+ worktreePath: "worktree_path",
233
+ branch: "branch",
234
+ prUrl: "pr_url",
235
+ telegramMessageId: "telegram_message_id",
236
+ telegramThreadId: "telegram_thread_id",
237
+ sessionId: "session_id",
238
+ failureReason: "failure_reason",
239
+ triageDurationMs: "triage_duration_ms",
240
+ triageInputTokens: "triage_input_tokens",
241
+ triageOutputTokens: "triage_output_tokens",
242
+ triageCostUsd: "triage_cost_usd",
243
+ fixDurationMs: "fix_duration_ms",
244
+ fixInputTokens: "fix_input_tokens",
245
+ fixOutputTokens: "fix_output_tokens",
246
+ fixCostUsd: "fix_cost_usd",
247
+ };
248
+
249
+ for (const [key, value] of Object.entries(fields)) {
250
+ const col = fieldMap[key];
251
+ if (!col) continue;
252
+ sets.push(`${col} = $${key}`);
253
+ params[`$${key}`] = value;
254
+ }
255
+
256
+ this.db.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE id = $id`).run(params);
257
+ }
258
+
259
+ getByStatus(status: IssueStatus): IssueRow[] {
260
+ const rows = this.stmts.getByStatus.all({ $status: status }) as Record<string, unknown>[];
261
+ return rows.map(toRow);
262
+ }
263
+
264
+ getRecent(limit = 20): IssueRow[] {
265
+ const rows = this.stmts.getRecent.all({ $limit: limit }) as Record<string, unknown>[];
266
+ return rows.map(toRow);
267
+ }
268
+
269
+ getStats(): Record<string, number> {
270
+ const rows = this.db
271
+ .prepare("SELECT status, COUNT(*) as count FROM issues GROUP BY status")
272
+ .all() as { status: string; count: number }[];
273
+ const stats: Record<string, number> = {};
274
+ for (const r of rows) stats[r.status] = r.count;
275
+ return stats;
276
+ }
277
+
278
+ getStatsBySource(workspaceKey?: string): Array<{ source: string; status: string; count: number }> {
279
+ const where = workspaceKey ? "WHERE workspace_key = $workspaceKey" : "";
280
+ const params = workspaceKey ? { $workspaceKey: workspaceKey } : {};
281
+ return this.db
282
+ .prepare(`SELECT source, status, COUNT(*) as count FROM issues ${where} GROUP BY source, status`)
283
+ .all(params) as Array<{ source: string; status: string; count: number }>;
284
+ }
285
+
286
+ getById(id: number): IssueRow | null {
287
+ const raw = this.db.prepare("SELECT * FROM issues WHERE id = $id").get({ $id: id }) as Record<string, unknown> | null;
288
+ return raw ? toRow(raw) : null;
289
+ }
290
+
291
+ getFiltered(opts: { status?: string; source?: string; workspaceKey?: string; limit?: number }): IssueRow[] {
292
+ const conditions: string[] = [];
293
+ const params: Record<string, unknown> = {};
294
+
295
+ if (opts.status) {
296
+ conditions.push("status = $status");
297
+ params.$status = opts.status;
298
+ }
299
+ if (opts.source) {
300
+ conditions.push("source = $source");
301
+ params.$source = opts.source;
302
+ }
303
+ if (opts.workspaceKey) {
304
+ conditions.push("workspace_key = $workspaceKey");
305
+ params.$workspaceKey = opts.workspaceKey;
306
+ }
307
+
308
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
309
+ const limit = opts.limit ?? 50;
310
+ const rows = this.db
311
+ .prepare(`SELECT * FROM issues ${where} ORDER BY updated_at DESC LIMIT ${limit}`)
312
+ .all(params) as Record<string, unknown>[];
313
+ return rows.map(toRow);
314
+ }
315
+
316
+ resetStuckIssues(): IssueRow[] {
317
+ const stuck = this.db.prepare(`
318
+ SELECT * FROM issues WHERE status IN ('triaging', 'working')
319
+ `).all() as Record<string, unknown>[];
320
+
321
+ if (stuck.length > 0) {
322
+ // Issues that were working go back to pending_confirmation (require user re-confirmation)
323
+ this.db.prepare(`
324
+ UPDATE issues SET status = 'pending_confirmation', updated_at = datetime('now')
325
+ WHERE status = 'working'
326
+ `).run();
327
+ // Issues that were triaging go back to new
328
+ this.db.prepare(`
329
+ UPDATE issues SET status = 'new', updated_at = datetime('now')
330
+ WHERE status = 'triaging'
331
+ `).run();
332
+ logger.warn("Re-queued interrupted issues", { count: stuck.length });
333
+ }
334
+
335
+ return stuck.map(toRow);
336
+ }
337
+
338
+ close() {
339
+ this.db.close();
340
+ }
341
+
342
+ getDatabase(): Database {
343
+ return this.db;
344
+ }
345
+ }
346
+
347
+ // ─── Config DB ───
348
+
349
+ const CONFIG_SCHEMA = `
350
+ CREATE TABLE IF NOT EXISTS settings (
351
+ key TEXT PRIMARY KEY,
352
+ value TEXT NOT NULL
353
+ );
354
+
355
+ CREATE TABLE IF NOT EXISTS workspaces (
356
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
357
+ key TEXT UNIQUE NOT NULL,
358
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
359
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
360
+ );
361
+
362
+ CREATE TABLE IF NOT EXISTS projects (
363
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
364
+ workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
365
+ key TEXT UNIQUE NOT NULL,
366
+ repo_path TEXT NOT NULL,
367
+ base_branch TEXT NOT NULL DEFAULT 'main',
368
+ test_command TEXT,
369
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
370
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
371
+ );
372
+
373
+ CREATE TABLE IF NOT EXISTS source_configs (
374
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
375
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
376
+ type TEXT NOT NULL,
377
+ config TEXT NOT NULL,
378
+ enabled INTEGER NOT NULL DEFAULT 1,
379
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
380
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
381
+ UNIQUE(project_id, type)
382
+ );
383
+
384
+ CREATE TABLE IF NOT EXISTS telegram_configs (
385
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
386
+ workspace_id INTEGER NOT NULL UNIQUE REFERENCES workspaces(id) ON DELETE CASCADE,
387
+ bot_token TEXT NOT NULL,
388
+ chat_id TEXT NOT NULL,
389
+ enabled INTEGER NOT NULL DEFAULT 1,
390
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
391
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
392
+ );
393
+ `;
394
+
395
+ const DEFAULT_SETTINGS: Record<string, string> = {
396
+ pollIntervalSeconds: "300",
397
+ maxConcurrency: "2",
398
+ claudeTimeout: "300000",
399
+ triageTimeout: "120000",
400
+ triage: "true",
401
+ logLevel: "info",
402
+ allowedTools: '["Read","Write","Edit","Bash","Glob","Grep"]',
403
+ };
404
+
405
+ export interface WorkspaceRow {
406
+ id: number;
407
+ key: string;
408
+ created_at: string;
409
+ updated_at: string;
410
+ }
411
+
412
+ export interface ProjectRow {
413
+ id: number;
414
+ workspace_id: number;
415
+ key: string;
416
+ repo_path: string;
417
+ base_branch: string;
418
+ test_command: string | null;
419
+ created_at: string;
420
+ updated_at: string;
421
+ }
422
+
423
+ export interface SourceConfigRow {
424
+ id: number;
425
+ project_id: number;
426
+ type: string;
427
+ config: string;
428
+ enabled: number;
429
+ created_at: string;
430
+ updated_at: string;
431
+ }
432
+
433
+ export interface TelegramConfigRow {
434
+ id: number;
435
+ workspace_id: number;
436
+ bot_token: string;
437
+ chat_id: string;
438
+ enabled: number;
439
+ created_at: string;
440
+ updated_at: string;
441
+ }
442
+
443
+ export class ConfigDB {
444
+ private db: Database;
445
+
446
+ constructor(db: Database) {
447
+ this.db = db;
448
+ this.db.exec("PRAGMA foreign_keys = ON;");
449
+ this.db.exec(CONFIG_SCHEMA);
450
+ this.seedDefaults();
451
+ }
452
+
453
+ private seedDefaults() {
454
+ const existing = this.db.prepare("SELECT COUNT(*) as count FROM settings").get() as { count: number };
455
+ if (existing.count === 0) {
456
+ const stmt = this.db.prepare("INSERT OR IGNORE INTO settings (key, value) VALUES ($key, $value)");
457
+ for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
458
+ stmt.run({ $key: key, $value: value });
459
+ }
460
+ }
461
+ }
462
+
463
+ // ─── Settings ───
464
+
465
+ getSettings(): Record<string, string> {
466
+ const rows = this.db.prepare("SELECT key, value FROM settings").all() as { key: string; value: string }[];
467
+ const result: Record<string, string> = {};
468
+ for (const r of rows) result[r.key] = r.value;
469
+ return result;
470
+ }
471
+
472
+ getSetting(key: string): string | null {
473
+ const row = this.db.prepare("SELECT value FROM settings WHERE key = $key").get({ $key: key }) as { value: string } | null;
474
+ return row?.value ?? null;
475
+ }
476
+
477
+ updateSettings(settings: Record<string, string>) {
478
+ const stmt = this.db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ($key, $value)");
479
+ for (const [key, value] of Object.entries(settings)) {
480
+ stmt.run({ $key: key, $value: value });
481
+ }
482
+ }
483
+
484
+ // ─── Workspaces ───
485
+
486
+ getWorkspaces(): WorkspaceRow[] {
487
+ return this.db.prepare("SELECT * FROM workspaces ORDER BY id").all() as WorkspaceRow[];
488
+ }
489
+
490
+ getWorkspace(id: number): WorkspaceRow | null {
491
+ return this.db.prepare("SELECT * FROM workspaces WHERE id = $id").get({ $id: id }) as WorkspaceRow | null;
492
+ }
493
+
494
+ createWorkspace(key: string): WorkspaceRow {
495
+ return this.db.prepare("INSERT INTO workspaces (key) VALUES ($key) RETURNING *").get({ $key: key }) as WorkspaceRow;
496
+ }
497
+
498
+ updateWorkspace(id: number, key: string): void {
499
+ this.db.prepare("UPDATE workspaces SET key = $key, updated_at = datetime('now') WHERE id = $id").run({ $id: id, $key: key });
500
+ }
501
+
502
+ deleteWorkspace(id: number): void {
503
+ this.db.prepare("DELETE FROM workspaces WHERE id = $id").run({ $id: id });
504
+ }
505
+
506
+ // ─── Projects ───
507
+
508
+ getProjects(workspaceId: number): ProjectRow[] {
509
+ return this.db.prepare("SELECT * FROM projects WHERE workspace_id = $wsId ORDER BY id").all({ $wsId: workspaceId }) as ProjectRow[];
510
+ }
511
+
512
+ getProject(id: number): ProjectRow | null {
513
+ return this.db.prepare("SELECT * FROM projects WHERE id = $id").get({ $id: id }) as ProjectRow | null;
514
+ }
515
+
516
+ createProject(workspaceId: number, data: { key: string; repoPath: string; baseBranch: string; testCommand?: string }): ProjectRow {
517
+ return this.db.prepare(
518
+ "INSERT INTO projects (workspace_id, key, repo_path, base_branch, test_command) VALUES ($wsId, $key, $repoPath, $baseBranch, $testCommand) RETURNING *",
519
+ ).get({
520
+ $wsId: workspaceId,
521
+ $key: data.key,
522
+ $repoPath: data.repoPath,
523
+ $baseBranch: data.baseBranch,
524
+ $testCommand: data.testCommand ?? null,
525
+ }) as ProjectRow;
526
+ }
527
+
528
+ updateProject(id: number, data: { key?: string; repoPath?: string; baseBranch?: string; testCommand?: string | null }): void {
529
+ const sets: string[] = ["updated_at = datetime('now')"];
530
+ const params: Record<string, unknown> = { $id: id };
531
+ if (data.key !== undefined) { sets.push("key = $key"); params.$key = data.key; }
532
+ if (data.repoPath !== undefined) { sets.push("repo_path = $repoPath"); params.$repoPath = data.repoPath; }
533
+ if (data.baseBranch !== undefined) { sets.push("base_branch = $baseBranch"); params.$baseBranch = data.baseBranch; }
534
+ if (data.testCommand !== undefined) { sets.push("test_command = $testCommand"); params.$testCommand = data.testCommand; }
535
+ this.db.prepare(`UPDATE projects SET ${sets.join(", ")} WHERE id = $id`).run(params);
536
+ }
537
+
538
+ deleteProject(id: number): void {
539
+ this.db.prepare("DELETE FROM projects WHERE id = $id").run({ $id: id });
540
+ }
541
+
542
+ // ─── Source Configs ───
543
+
544
+ getSourceConfigs(projectId: number): SourceConfigRow[] {
545
+ return this.db.prepare("SELECT * FROM source_configs WHERE project_id = $pId ORDER BY id").all({ $pId: projectId }) as SourceConfigRow[];
546
+ }
547
+
548
+ getSourceConfig(id: number): SourceConfigRow | null {
549
+ return this.db.prepare("SELECT * FROM source_configs WHERE id = $id").get({ $id: id }) as SourceConfigRow | null;
550
+ }
551
+
552
+ upsertSourceConfig(projectId: number, type: string, config: Record<string, unknown>, enabled = true): SourceConfigRow {
553
+ return this.db.prepare(
554
+ `INSERT INTO source_configs (project_id, type, config, enabled)
555
+ VALUES ($pId, $type, $config, $enabled)
556
+ ON CONFLICT(project_id, type) DO UPDATE SET
557
+ config = excluded.config, enabled = excluded.enabled, updated_at = datetime('now')
558
+ RETURNING *`,
559
+ ).get({
560
+ $pId: projectId,
561
+ $type: type,
562
+ $config: JSON.stringify(config),
563
+ $enabled: enabled ? 1 : 0,
564
+ }) as SourceConfigRow;
565
+ }
566
+
567
+ updateSourceConfig(id: number, data: { config?: Record<string, unknown>; enabled?: boolean }): void {
568
+ const sets: string[] = ["updated_at = datetime('now')"];
569
+ const params: Record<string, unknown> = { $id: id };
570
+ if (data.config !== undefined) { sets.push("config = $config"); params.$config = JSON.stringify(data.config); }
571
+ if (data.enabled !== undefined) { sets.push("enabled = $enabled"); params.$enabled = data.enabled ? 1 : 0; }
572
+ this.db.prepare(`UPDATE source_configs SET ${sets.join(", ")} WHERE id = $id`).run(params);
573
+ }
574
+
575
+ deleteSourceConfig(id: number): void {
576
+ this.db.prepare("DELETE FROM source_configs WHERE id = $id").run({ $id: id });
577
+ }
578
+
579
+ // ─── Telegram Configs ───
580
+
581
+ getTelegramConfig(workspaceId: number): TelegramConfigRow | null {
582
+ return this.db.prepare("SELECT * FROM telegram_configs WHERE workspace_id = $wsId").get({ $wsId: workspaceId }) as TelegramConfigRow | null;
583
+ }
584
+
585
+ upsertTelegramConfig(workspaceId: number, botToken: string, chatId: string, enabled = true): TelegramConfigRow {
586
+ return this.db.prepare(
587
+ `INSERT INTO telegram_configs (workspace_id, bot_token, chat_id, enabled)
588
+ VALUES ($wsId, $botToken, $chatId, $enabled)
589
+ ON CONFLICT(workspace_id) DO UPDATE SET
590
+ bot_token = excluded.bot_token, chat_id = excluded.chat_id, enabled = excluded.enabled, updated_at = datetime('now')
591
+ RETURNING *`,
592
+ ).get({
593
+ $wsId: workspaceId,
594
+ $botToken: botToken,
595
+ $chatId: chatId,
596
+ $enabled: enabled ? 1 : 0,
597
+ }) as TelegramConfigRow;
598
+ }
599
+
600
+ updateTelegramEnabled(workspaceId: number, enabled: boolean): void {
601
+ this.db.prepare("UPDATE telegram_configs SET enabled = $enabled, updated_at = datetime('now') WHERE workspace_id = $wsId").run({
602
+ $wsId: workspaceId,
603
+ $enabled: enabled ? 1 : 0,
604
+ });
605
+ }
606
+
607
+ deleteTelegramConfig(workspaceId: number): void {
608
+ this.db.prepare("DELETE FROM telegram_configs WHERE workspace_id = $wsId").run({ $wsId: workspaceId });
609
+ }
610
+
611
+ // ─── Config Check & Import ───
612
+
613
+ hasConfig(): boolean {
614
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM workspaces").get() as { count: number };
615
+ return row.count > 0;
616
+ }
617
+
618
+ importFromJson(config: Config): void {
619
+ this.db.exec("BEGIN TRANSACTION");
620
+ try {
621
+ // Import settings
622
+ const settingsMap: Record<string, string> = {
623
+ pollIntervalSeconds: String(config.pollIntervalSeconds),
624
+ maxConcurrency: String(config.maxConcurrency),
625
+ claudeTimeout: String(config.claudeTimeout),
626
+ triageTimeout: String(config.triageTimeout),
627
+ triage: String(config.triage),
628
+ logLevel: config.logLevel,
629
+ allowedTools: JSON.stringify(config.allowedTools),
630
+ };
631
+ this.updateSettings(settingsMap);
632
+
633
+ // Import workspaces
634
+ for (const ws of config.workspaces) {
635
+ const wsRow = this.createWorkspace(ws.key);
636
+
637
+ if (ws.telegram) {
638
+ this.upsertTelegramConfig(wsRow.id, ws.telegram.botToken, ws.telegram.chatId);
639
+ }
640
+
641
+ for (const proj of ws.projects) {
642
+ const projRow = this.createProject(wsRow.id, {
643
+ key: proj.key,
644
+ repoPath: proj.repoPath,
645
+ baseBranch: proj.baseBranch,
646
+ testCommand: proj.testCommand,
647
+ });
648
+
649
+ if (proj.sources.sentry) {
650
+ this.upsertSourceConfig(projRow.id, "sentry", proj.sources.sentry as unknown as Record<string, unknown>);
651
+ }
652
+ if (proj.sources.asana) {
653
+ this.upsertSourceConfig(projRow.id, "asana", proj.sources.asana as unknown as Record<string, unknown>);
654
+ }
655
+ if (proj.sources.linear) {
656
+ this.upsertSourceConfig(projRow.id, "linear", proj.sources.linear as unknown as Record<string, unknown>);
657
+ }
658
+ if (proj.sources.jira) {
659
+ this.upsertSourceConfig(projRow.id, "jira", proj.sources.jira as unknown as Record<string, unknown>);
660
+ }
661
+ }
662
+ }
663
+
664
+ this.db.exec("COMMIT");
665
+ logger.info("Imported config from JSON to DB", { workspaces: config.workspaces.length });
666
+ } catch (err) {
667
+ this.db.exec("ROLLBACK");
668
+ throw err;
669
+ }
670
+ }
671
+
672
+ toConfig(): Config {
673
+ const settings = this.getSettings();
674
+ const workspaces: WorkspaceConfig[] = [];
675
+
676
+ for (const ws of this.getWorkspaces()) {
677
+ const telegram = this.getTelegramConfig(ws.id);
678
+ const projects: ProjectConfig[] = [];
679
+
680
+ for (const proj of this.getProjects(ws.id)) {
681
+ const sources: ProjectConfig["sources"] = {};
682
+ for (const src of this.getSourceConfigs(proj.id)) {
683
+ if (!src.enabled) continue;
684
+ const parsed = JSON.parse(src.config);
685
+ if (src.type === "sentry") sources.sentry = parsed as SentrySourceConfig;
686
+ if (src.type === "asana") sources.asana = parsed as AsanaSourceConfig;
687
+ if (src.type === "linear") sources.linear = parsed as LinearSourceConfig;
688
+ if (src.type === "jira") sources.jira = parsed as JiraSourceConfig;
689
+ }
690
+
691
+ projects.push({
692
+ key: proj.key,
693
+ repoPath: proj.repo_path,
694
+ baseBranch: proj.base_branch,
695
+ testCommand: proj.test_command ?? undefined,
696
+ sources,
697
+ });
698
+ }
699
+
700
+ const wsConfig: WorkspaceConfig = { key: ws.key, projects };
701
+ if (telegram?.enabled) {
702
+ wsConfig.telegram = { botToken: telegram.bot_token, chatId: telegram.chat_id };
703
+ }
704
+ workspaces.push(wsConfig);
705
+ }
706
+
707
+ return {
708
+ workspaces,
709
+ pollIntervalSeconds: parseInt(settings.pollIntervalSeconds ?? "300"),
710
+ maxConcurrency: parseInt(settings.maxConcurrency ?? "2"),
711
+ claudeTimeout: parseInt(settings.claudeTimeout ?? "300000"),
712
+ triageTimeout: parseInt(settings.triageTimeout ?? "120000"),
713
+ triage: settings.triage !== "false",
714
+ logLevel: (settings.logLevel ?? "info") as Config["logLevel"],
715
+ allowedTools: JSON.parse(settings.allowedTools ?? '["Read","Write","Edit","Bash","Glob","Grep"]'),
716
+ };
717
+ }
718
+ }