@hienlh/ppm 0.10.5 → 0.11.1

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.
Files changed (122) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
  3. package/dist/web/assets/{api-settings-C__hxGX2.js → api-settings-2eTz4SgY.js} +1 -1
  4. package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
  5. package/dist/web/assets/chat-tab-DYf6U6UF.js +10 -0
  6. package/dist/web/assets/code-editor-BPxBeu0S.js +8 -0
  7. package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BCkYHDUy.js} +1 -1
  8. package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
  9. package/dist/web/assets/{database-viewer-CvQc1PZH.js → database-viewer-CCe8qa1Q.js} +2 -2
  10. package/dist/web/assets/{diff-viewer-x7kjfVYW.js → diff-viewer-DIjzWvaG.js} +1 -1
  11. package/dist/web/assets/{esm-K1XIK4vc.js → esm-B99v94EE.js} +1 -1
  12. package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-CkyOvGbF.js} +1 -1
  13. package/dist/web/assets/extension-webview-HY8XueLo.js +3 -0
  14. package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
  15. package/dist/web/assets/index-DpRxWGjM.js +26 -0
  16. package/dist/web/assets/index-iZHWllzQ.css +2 -0
  17. package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
  18. package/dist/web/assets/{input-ClhO__YM.js → input-CHRMley8.js} +1 -1
  19. package/dist/web/assets/{keybindings-store-C9KsBH7z.js → keybindings-store-CpP5_miA.js} +1 -1
  20. package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
  21. package/dist/web/assets/{markdown-renderer-CKmmrUuy.js → markdown-renderer-BQV0AIm5.js} +3 -3
  22. package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
  23. package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
  24. package/dist/web/assets/port-forwarding-tab-DPmTpfFX.js +1 -0
  25. package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-BUSNt_7x.js} +3 -3
  26. package/dist/web/assets/{project-store-BYmQ0fDC.js → project-store-CczGNZyf.js} +1 -1
  27. package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
  28. package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-DwWF9FpN.js} +1 -1
  29. package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
  30. package/dist/web/assets/settings-tab-DHBG5O0C.js +1 -0
  31. package/dist/web/assets/{sql-query-editor-CM_qEhaX.js → sql-query-editor-CVEi0jLM.js} +1 -1
  32. package/dist/web/assets/{sqlite-viewer-f6ZJHIzh.js → sqlite-viewer-B7WnFN29.js} +1 -1
  33. package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
  34. package/dist/web/assets/{terminal-tab-CVdfvDSK.js → terminal-tab-1K4ijyNe.js} +1 -1
  35. package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
  36. package/dist/web/assets/{use-monaco-theme-CvV5vy_F.js → use-monaco-theme-kjiAwvOp.js} +1 -1
  37. package/dist/web/assets/{vendor-mermaid-CwOSbfhN.js → vendor-mermaid-CylkVm4U.js} +3 -3
  38. package/dist/web/index.html +16 -16
  39. package/dist/web/sw.js +1 -1
  40. package/docs/codebase-summary.md +29 -5
  41. package/docs/project-changelog.md +31 -1
  42. package/docs/system-architecture.md +106 -1
  43. package/package.json +1 -1
  44. package/packages/ext-git-graph/src/webview-html.ts +8 -7
  45. package/src/cli/commands/jira-cmd.ts +92 -0
  46. package/src/cli/commands/jira-watcher-cmd.ts +149 -0
  47. package/src/index.ts +3 -0
  48. package/src/server/index.ts +19 -0
  49. package/src/server/routes/files.ts +15 -0
  50. package/src/server/routes/fs-browse.ts +40 -1
  51. package/src/server/routes/jira-config-routes.ts +74 -0
  52. package/src/server/routes/jira-watcher-routes.ts +316 -0
  53. package/src/server/routes/jira.ts +7 -0
  54. package/src/server/ws/chat.ts +21 -0
  55. package/src/services/db.service.ts +65 -1
  56. package/src/services/file.service.ts +42 -0
  57. package/src/services/jira-api-client.ts +216 -0
  58. package/src/services/jira-config.service.ts +83 -0
  59. package/src/services/jira-debug-session.service.ts +240 -0
  60. package/src/services/jira-watcher-db.service.ts +195 -0
  61. package/src/services/jira-watcher.service.ts +159 -0
  62. package/src/services/notification.service.ts +6 -0
  63. package/src/types/jira.ts +128 -0
  64. package/src/web/app.tsx +15 -12
  65. package/src/web/components/chat/chat-tab.tsx +32 -1
  66. package/src/web/components/chat/message-input.tsx +56 -5
  67. package/src/web/components/explorer/file-tree.tsx +70 -19
  68. package/src/web/components/extensions/extension-webview.tsx +24 -10
  69. package/src/web/components/jira/jira-config-form.tsx +109 -0
  70. package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
  71. package/src/web/components/jira/jira-filter-builder.tsx +197 -0
  72. package/src/web/components/jira/jira-panel.tsx +201 -0
  73. package/src/web/components/jira/jira-results-panel.tsx +184 -0
  74. package/src/web/components/jira/jira-settings-section.tsx +58 -0
  75. package/src/web/components/jira/jira-status-badge.tsx +18 -0
  76. package/src/web/components/jira/jira-ticket-card.tsx +144 -0
  77. package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
  78. package/src/web/components/jira/jira-watcher-form.tsx +154 -0
  79. package/src/web/components/jira/jira-watcher-list.tsx +98 -0
  80. package/src/web/components/layout/mobile-drawer.tsx +18 -5
  81. package/src/web/components/layout/sidebar.tsx +20 -3
  82. package/src/web/components/settings/settings-tab.tsx +20 -3
  83. package/src/web/components/shared/markdown-code-block.tsx +5 -3
  84. package/src/web/components/ui/file-browser-picker.tsx +88 -1
  85. package/src/web/hooks/use-chat.ts +6 -0
  86. package/src/web/lib/ws-client.ts +10 -3
  87. package/src/web/stores/jira-store.ts +198 -0
  88. package/src/web/stores/settings-store.ts +17 -2
  89. package/src/web/styles/globals.css +7 -0
  90. package/vite.config.ts +5 -66
  91. package/bun.lock +0 -2062
  92. package/bunfig.toml +0 -2
  93. package/dist/web/assets/ai-settings-section-D2rONDPd.js +0 -1
  94. package/dist/web/assets/architecture-PBZL5I3N-DmL1WyG-.js +0 -1
  95. package/dist/web/assets/chat-tab-Dki1pz84.js +0 -10
  96. package/dist/web/assets/code-editor-D3AAT8nI.js +0 -8
  97. package/dist/web/assets/extension-webview-BFd0USXC.js +0 -3
  98. package/dist/web/assets/gitGraph-HDMCJU4V-D8vKfkjC.js +0 -1
  99. package/dist/web/assets/index-DPnjO2FY.css +0 -2
  100. package/dist/web/assets/index-DuEUN2Eg.js +0 -26
  101. package/dist/web/assets/info-3K5VOQVL-VG29MIoT.js +0 -1
  102. package/dist/web/assets/keybindings-store-BkZjvU9J.js +0 -1
  103. package/dist/web/assets/packet-RMMSAZCW-Bl_WpvPc.js +0 -1
  104. package/dist/web/assets/pie-UPGHQEXC-BVpLpAIy.js +0 -1
  105. package/dist/web/assets/port-forwarding-tab-BUH9aImG.js +0 -1
  106. package/dist/web/assets/radar-KQ55EAFF-CJGco43I.js +0 -1
  107. package/dist/web/assets/settings-store-D9CflsKU.js +0 -2
  108. package/dist/web/assets/settings-tab-DfPjX9uY.js +0 -1
  109. package/dist/web/assets/square-nsMa3iMk.js +0 -1
  110. package/dist/web/assets/treemap-KZPCXAKY-BsOrObtE.js +0 -1
  111. /package/dist/web/assets/{api-client-Bn-Pi9k5.js → api-client-C3tXCh0r.js} +0 -0
  112. /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
  113. /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
  114. /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
  115. /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
  116. /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
  117. /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
  118. /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
  119. /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
  120. /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
  121. /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
  122. /package/dist/web/assets/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
@@ -0,0 +1,240 @@
1
+ import { chatService } from "./chat.service.ts";
2
+ import { getDb } from "./db.service.ts";
3
+ import { updateResultStatus, getResultById } from "./jira-watcher-db.service.ts";
4
+ import { notificationService } from "./notification.service.ts";
5
+ import { forwardEventToSession } from "../server/ws/chat.ts";
6
+ import type { JiraWatcherRow } from "../types/jira.ts";
7
+ import type { PermissionMode } from "../types/config.ts";
8
+
9
+ const MAX_CONCURRENT = 2;
10
+ const MAX_PER_PROJECT = 1;
11
+
12
+ interface QueueItem {
13
+ resultId: number;
14
+ prompt?: string;
15
+ resume?: boolean; // reuse existing session instead of creating new
16
+ }
17
+
18
+ class JiraDebugSessionService {
19
+ private queue: QueueItem[] = [];
20
+ private active = new Map<number, AbortController>(); // resultId → abort
21
+ private activeByProject = new Map<string, number>(); // projectPath → count
22
+ private enqueuedIds = new Set<number>(); // guard against double-enqueue
23
+
24
+ /** Reset zombie results from previous server run (running/queued with no active process) */
25
+ init(): void {
26
+ const zombies = getDb().query(
27
+ `UPDATE jira_watch_results SET status = 'failed', ai_summary = 'Server restarted during debug'
28
+ WHERE status IN ('running', 'queued') AND deleted = 0 RETURNING id, issue_key`,
29
+ ).all() as { id: number; issue_key: string }[];
30
+ if (zombies.length > 0) {
31
+ console.log(`[jira-debug] Reset ${zombies.length} zombie results: ${zombies.map((z) => z.issue_key).join(", ")}`);
32
+ }
33
+ }
34
+
35
+ /** Enqueue a result for debug. Accepts "pending" or "failed" (retry). */
36
+ enqueue(resultId: number, promptOverride?: string): void {
37
+ if (this.enqueuedIds.has(resultId)) return; // prevent double-enqueue
38
+ const result = getResultById(resultId);
39
+ if (!result) throw new Error("Result not found");
40
+ if (result.status !== "pending" && result.status !== "failed") {
41
+ throw new Error(`Result status is "${result.status}", expected "pending" or "failed"`);
42
+ }
43
+
44
+ this.enqueuedIds.add(resultId);
45
+ updateResultStatus(resultId, "queued");
46
+ this.broadcastStatusChange(resultId, result.issueKey, "queued");
47
+ this.queue.push({ resultId, prompt: promptOverride });
48
+ this.processQueue();
49
+ }
50
+
51
+ /** Resume a failed session that already has a sessionId */
52
+ resumeDebug(resultId: number, prompt?: string): void {
53
+ if (this.enqueuedIds.has(resultId)) return;
54
+ const result = getResultById(resultId);
55
+ if (!result) throw new Error("Result not found");
56
+ if (result.status !== "failed") throw new Error("Only failed results can be resumed");
57
+ if (!result.sessionId) throw new Error("No session to resume — use debug instead");
58
+
59
+ this.enqueuedIds.add(resultId);
60
+ updateResultStatus(resultId, "queued");
61
+ this.broadcastStatusChange(resultId, result.issueKey, "queued");
62
+ this.queue.push({
63
+ resultId,
64
+ prompt: prompt ?? "Continue debugging. The previous session was interrupted before completing.",
65
+ resume: true,
66
+ });
67
+ this.processQueue();
68
+ }
69
+
70
+ /** Cancel a running or queued debug session */
71
+ cancelDebug(resultId: number): boolean {
72
+ // Remove from queue if still queued
73
+ const qIdx = this.queue.findIndex((q) => q.resultId === resultId);
74
+ if (qIdx >= 0) {
75
+ this.queue.splice(qIdx, 1);
76
+ this.enqueuedIds.delete(resultId);
77
+ const result = getResultById(resultId);
78
+ updateResultStatus(resultId, "failed", { aiSummary: "Cancelled by user" });
79
+ if (result) this.broadcastStatusChange(resultId, result.issueKey, "failed");
80
+ return true;
81
+ }
82
+ // Abort if actively running
83
+ const abort = this.active.get(resultId);
84
+ if (!abort) return false;
85
+ abort.abort();
86
+ const result = getResultById(resultId);
87
+ updateResultStatus(resultId, "failed", { aiSummary: "Cancelled by user" });
88
+ if (result) this.broadcastStatusChange(resultId, result.issueKey, "failed");
89
+ return true;
90
+ }
91
+
92
+ // [H2 fix] Iterate through queue to avoid head-of-line blocking
93
+ private processQueue(): void {
94
+ let i = 0;
95
+ while (i < this.queue.length && this.active.size < MAX_CONCURRENT) {
96
+ const item = this.queue[i]!;
97
+ const project = this.resolveProjectInfo(item.resultId);
98
+ if (!project) { this.queue.splice(i, 1); continue; }
99
+
100
+ const projectCount = this.activeByProject.get(project.path) ?? 0;
101
+ if (projectCount >= MAX_PER_PROJECT) { i++; continue; }
102
+
103
+ this.queue.splice(i, 1);
104
+ this.runDebugSession(item.resultId, item.prompt, project, item.resume).catch((e) => {
105
+ console.error(`[jira-debug] session error resultId=${item.resultId}:`, e.message);
106
+ });
107
+ }
108
+ }
109
+
110
+ // [H3 fix] Single method for project lookup — used by both processQueue and runDebugSession
111
+ private resolveProjectInfo(resultId: number): { path: string; name: string } | null {
112
+ return getDb().query(`
113
+ SELECT p.path, p.name FROM jira_watch_results r
114
+ JOIN jira_watchers w ON w.id = r.watcher_id
115
+ JOIN jira_config c ON c.id = w.jira_config_id
116
+ JOIN projects p ON p.id = c.project_id
117
+ WHERE r.id = ?
118
+ `).get(resultId) as { path: string; name: string } | null;
119
+ }
120
+
121
+ private resolveWatcherForResult(resultId: number): JiraWatcherRow | null {
122
+ return getDb().query(`
123
+ SELECT w.* FROM jira_watch_results r
124
+ JOIN jira_watchers w ON w.id = r.watcher_id
125
+ WHERE r.id = ?
126
+ `).get(resultId) as JiraWatcherRow | null;
127
+ }
128
+
129
+ private async runDebugSession(
130
+ resultId: number, promptOverride: string | undefined,
131
+ project: { path: string; name: string },
132
+ resume?: boolean,
133
+ ): Promise<void> {
134
+ const result = getResultById(resultId);
135
+ if (!result) return;
136
+
137
+ // Build prompt
138
+ let prompt: string;
139
+ if (promptOverride) {
140
+ prompt = promptOverride;
141
+ } else {
142
+ const watcher = this.resolveWatcherForResult(resultId);
143
+ if (watcher?.prompt_template) {
144
+ prompt = watcher.prompt_template
145
+ .replace(/\{issue_key\}/g, result.issueKey)
146
+ .replace(/\{summary\}/g, result.issueSummary ?? "")
147
+ .replace(/\{description\}/g, "(fetched from Jira)")
148
+ .replace(/\{status\}/g, "")
149
+ .replace(/\{priority\}/g, "");
150
+ } else {
151
+ prompt = `Debug Jira issue ${result.issueKey}: ${result.issueSummary ?? "No summary"}`;
152
+ }
153
+ }
154
+
155
+ // Track concurrency
156
+ const abort = new AbortController();
157
+ this.active.set(resultId, abort);
158
+ this.activeByProject.set(project.path, (this.activeByProject.get(project.path) ?? 0) + 1);
159
+
160
+ updateResultStatus(resultId, "running");
161
+ this.broadcastStatusChange(resultId, result.issueKey, "running");
162
+
163
+ try {
164
+ // Resume: reuse existing session (SDK can resume from disk even after restart)
165
+ let session: { id: string; providerId: string };
166
+ if (resume && result.sessionId) {
167
+ // SDK provider's sendMessage auto-resumes sessions from disk via resumeSession()
168
+ // We just need the sessionId and the correct providerId
169
+ const existing = chatService.getSession(result.sessionId);
170
+ session = existing
171
+ ? { id: existing.id, providerId: existing.providerId }
172
+ : { id: result.sessionId, providerId: "claude" }; // default provider
173
+ } else {
174
+ session = await chatService.createSession(undefined, {
175
+ projectPath: project.path,
176
+ projectName: project.name,
177
+ title: `[Jira Debug] ${result.issueKey}: ${(result.issueSummary ?? "").slice(0, 50)}`,
178
+ });
179
+ }
180
+
181
+ // Persist sessionId immediately so UI can show "Open" button while running
182
+ updateResultStatus(resultId, "running", { sessionId: session.id });
183
+ this.broadcastStatusChange(resultId, result.issueKey, "running", session.id);
184
+
185
+ // bypassPermissions: automated debug sessions run without user approval (same as PPMBot)
186
+ const opts = { permissionMode: "bypassPermissions" as PermissionMode };
187
+ const events = chatService.sendMessage(session.providerId, session.id, prompt, opts);
188
+
189
+ let lastAssistantText = "";
190
+ for await (const event of events) {
191
+ if (abort.signal.aborted) break;
192
+ if (event.type === "text") lastAssistantText = event.content;
193
+ // Forward events to any connected WS client viewing this session
194
+ forwardEventToSession(session.id, event);
195
+ }
196
+
197
+ if (abort.signal.aborted) return;
198
+
199
+ const aiSummary = lastAssistantText.slice(0, 500) || "Debug session completed (no text output)";
200
+ updateResultStatus(resultId, "done", { sessionId: session.id, aiSummary });
201
+
202
+ // Broadcast WS event + notification
203
+ this.broadcastStatusChange(resultId, result.issueKey, "done", session.id);
204
+
205
+ notificationService.broadcast("done", {
206
+ title: `Jira: ${result.issueKey}`,
207
+ body: aiSummary.slice(0, 200),
208
+ project: project.name,
209
+ sessionId: session.id,
210
+ }).catch(() => {});
211
+ } catch (e: any) {
212
+ if (!abort.signal.aborted) {
213
+ updateResultStatus(resultId, "failed", { aiSummary: e.message?.slice(0, 300) ?? "Unknown error" });
214
+ this.broadcastStatusChange(resultId, result.issueKey, "failed");
215
+ }
216
+ } finally {
217
+ this.cleanup(resultId, project.path);
218
+ this.processQueue();
219
+ }
220
+ }
221
+
222
+ private broadcastStatusChange(resultId: number, issueKey: string, status: string, sessionId?: string): void {
223
+ notificationService.broadcastWs({
224
+ type: "jira:status_change",
225
+ resultId, issueKey, status, sessionId,
226
+ }).catch(() => {});
227
+ }
228
+
229
+ // [C1 fix] Idempotent cleanup — safe to call multiple times
230
+ private cleanup(resultId: number, projectPath: string): void {
231
+ if (!this.active.has(resultId)) return; // already cleaned up
232
+ this.active.delete(resultId);
233
+ this.enqueuedIds.delete(resultId);
234
+ const count = (this.activeByProject.get(projectPath) ?? 1) - 1;
235
+ if (count <= 0) this.activeByProject.delete(projectPath);
236
+ else this.activeByProject.set(projectPath, count);
237
+ }
238
+ }
239
+
240
+ export const jiraDebugService = new JiraDebugSessionService();
@@ -0,0 +1,195 @@
1
+ import { getDb } from "./db.service.ts";
2
+ import type {
3
+ JiraWatcherRow, JiraWatchResultRow,
4
+ JiraWatcher, JiraWatchResult, JiraWatcherMode, JiraResultStatus,
5
+ } from "../types/jira.ts";
6
+
7
+ // ── Row → API mappers ─────────────────────────────────────────────────
8
+
9
+ export function rowToWatcher(row: JiraWatcherRow): JiraWatcher {
10
+ return {
11
+ id: row.id,
12
+ jiraConfigId: row.jira_config_id,
13
+ name: row.name,
14
+ jql: row.jql,
15
+ promptTemplate: row.prompt_template,
16
+ enabled: row.enabled === 1,
17
+ mode: row.mode as JiraWatcherMode,
18
+ intervalMs: row.interval_ms,
19
+ lastPolledAt: row.last_polled_at,
20
+ createdAt: row.created_at,
21
+ };
22
+ }
23
+
24
+ export function rowToResult(row: JiraWatchResultRow): JiraWatchResult {
25
+ return {
26
+ id: row.id,
27
+ watcherId: row.watcher_id,
28
+ issueKey: row.issue_key,
29
+ issueSummary: row.issue_summary,
30
+ issueUpdated: row.issue_updated,
31
+ sessionId: row.session_id,
32
+ status: row.status,
33
+ aiSummary: row.ai_summary,
34
+ source: row.source,
35
+ readAt: row.read_at,
36
+ triggeredBy: row.triggered_by,
37
+ createdAt: row.created_at,
38
+ };
39
+ }
40
+
41
+ // ── Watcher CRUD ──────────────────────────────────────────────────────
42
+
43
+ export function createWatcher(
44
+ configId: number, name: string, jql: string,
45
+ opts?: { promptTemplate?: string; intervalMs?: number; mode?: JiraWatcherMode },
46
+ ): JiraWatcher {
47
+ const result = getDb().query(`
48
+ INSERT INTO jira_watchers (jira_config_id, name, jql, prompt_template, interval_ms, mode)
49
+ VALUES (?, ?, ?, ?, ?, ?) RETURNING *
50
+ `).get(
51
+ configId, name, jql,
52
+ opts?.promptTemplate ?? null,
53
+ opts?.intervalMs ?? 120000,
54
+ opts?.mode ?? "debug",
55
+ ) as JiraWatcherRow;
56
+ return rowToWatcher(result);
57
+ }
58
+
59
+ export function updateWatcher(
60
+ id: number,
61
+ fields: Partial<{ name: string; jql: string; promptTemplate: string | null; intervalMs: number; enabled: boolean; mode: JiraWatcherMode }>,
62
+ ): JiraWatcher | null {
63
+ const sets: string[] = [];
64
+ const params: (string | number | null)[] = [];
65
+ if (fields.name !== undefined) { sets.push("name = ?"); params.push(fields.name); }
66
+ if (fields.jql !== undefined) { sets.push("jql = ?"); params.push(fields.jql); }
67
+ if (fields.promptTemplate !== undefined) { sets.push("prompt_template = ?"); params.push(fields.promptTemplate); }
68
+ if (fields.intervalMs !== undefined) { sets.push("interval_ms = ?"); params.push(fields.intervalMs); }
69
+ if (fields.enabled !== undefined) { sets.push("enabled = ?"); params.push(fields.enabled ? 1 : 0); }
70
+ if (fields.mode !== undefined) { sets.push("mode = ?"); params.push(fields.mode); }
71
+ if (sets.length === 0) return getWatcherById(id);
72
+ params.push(id);
73
+ getDb().query(`UPDATE jira_watchers SET ${sets.join(", ")} WHERE id = ?`).run(...params);
74
+ return getWatcherById(id);
75
+ }
76
+
77
+ export function deleteWatcher(id: number): boolean {
78
+ return getDb().query("DELETE FROM jira_watchers WHERE id = ?").run(id).changes > 0;
79
+ }
80
+
81
+ export function getWatcherById(id: number): JiraWatcher | null {
82
+ const row = getDb().query("SELECT * FROM jira_watchers WHERE id = ?").get(id) as JiraWatcherRow | null;
83
+ return row ? rowToWatcher(row) : null;
84
+ }
85
+
86
+ export function getWatchersByConfigId(configId: number): JiraWatcher[] {
87
+ const rows = getDb().query("SELECT * FROM jira_watchers WHERE jira_config_id = ? ORDER BY id")
88
+ .all(configId) as JiraWatcherRow[];
89
+ return rows.map(rowToWatcher);
90
+ }
91
+
92
+ export function getAllEnabledWatchers(): JiraWatcherRow[] {
93
+ return getDb().query("SELECT * FROM jira_watchers WHERE enabled = 1")
94
+ .all() as JiraWatcherRow[];
95
+ }
96
+
97
+ // ── Result CRUD ───────────────────────────────────────────────────────
98
+
99
+ /** Insert a result. Resurrects soft-deleted duplicates. Returns true if row was inserted or resurrected. */
100
+ export function insertResult(
101
+ watcherId: number | null, issueKey: string,
102
+ issueSummary: string | null, issueUpdated: string | null,
103
+ source: "watcher" | "manual" = "watcher",
104
+ triggeredBy: "auto" | "manual" = "auto",
105
+ ): { inserted: boolean; resultId: number | null } {
106
+ try {
107
+ const row = getDb().query(`
108
+ INSERT INTO jira_watch_results (watcher_id, issue_key, issue_summary, issue_updated, source, triggered_by)
109
+ VALUES (?, ?, ?, ?, ?, ?) RETURNING id
110
+ `).get(watcherId, issueKey, issueSummary, issueUpdated, source, triggeredBy) as { id: number } | null;
111
+ return { inserted: true, resultId: row?.id ?? null };
112
+ } catch (e: any) {
113
+ if (e.message?.includes("UNIQUE constraint")) {
114
+ // Resurrect soft-deleted row if it exists
115
+ const resurrected = getDb().query(`
116
+ UPDATE jira_watch_results
117
+ SET deleted = 0, status = 'pending', session_id = NULL, ai_summary = NULL,
118
+ read_at = NULL, triggered_by = ?, source = ?, created_at = datetime('now')
119
+ WHERE watcher_id IS ? AND issue_key = ? AND issue_updated IS ? AND deleted = 1
120
+ RETURNING id
121
+ `).get(triggeredBy, source, watcherId, issueKey, issueUpdated) as { id: number } | null;
122
+ if (resurrected) return { inserted: true, resultId: resurrected.id };
123
+ return { inserted: false, resultId: null };
124
+ }
125
+ throw e;
126
+ }
127
+ }
128
+
129
+ export function updateResultStatus(
130
+ resultId: number,
131
+ status: JiraResultStatus,
132
+ updates?: { sessionId?: string; aiSummary?: string },
133
+ ): void {
134
+ const sets = ["status = ?"];
135
+ const params: (string | number)[] = [status];
136
+ if (updates?.sessionId !== undefined) { sets.push("session_id = ?"); params.push(updates.sessionId); }
137
+ if (updates?.aiSummary !== undefined) { sets.push("ai_summary = ?"); params.push(updates.aiSummary); }
138
+ params.push(resultId);
139
+ getDb().query(`UPDATE jira_watch_results SET ${sets.join(", ")} WHERE id = ?`).run(...params);
140
+ }
141
+
142
+ export function getResultsByWatcherId(
143
+ watcherId?: number,
144
+ opts?: { status?: string; limit?: number; offset?: number; includeDeleted?: boolean },
145
+ ): JiraWatchResult[] {
146
+ const clauses: string[] = [];
147
+ const params: (string | number)[] = [];
148
+ if (watcherId !== undefined) { clauses.push("watcher_id = ?"); params.push(watcherId); }
149
+ if (opts?.status) { clauses.push("status = ?"); params.push(opts.status); }
150
+ if (!opts?.includeDeleted) clauses.push("deleted = 0");
151
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
152
+ const limit = opts?.limit ?? 50;
153
+ const offset = opts?.offset ?? 0;
154
+ const rows = getDb()
155
+ .query(`SELECT * FROM jira_watch_results ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
156
+ .all(...params, limit, offset) as JiraWatchResultRow[];
157
+ return rows.map(rowToResult);
158
+ }
159
+
160
+ export function getResultById(id: number): JiraWatchResult | null {
161
+ const row = getDb().query("SELECT * FROM jira_watch_results WHERE id = ?")
162
+ .get(id) as JiraWatchResultRow | null;
163
+ return row ? rowToResult(row) : null;
164
+ }
165
+
166
+ export function softDeleteResult(id: number): boolean {
167
+ return getDb().query("UPDATE jira_watch_results SET deleted = 1 WHERE id = ?").run(id).changes > 0;
168
+ }
169
+
170
+ export function getRunningResults(): JiraWatchResultRow[] {
171
+ return getDb().query("SELECT * FROM jira_watch_results WHERE status = 'running'")
172
+ .all() as JiraWatchResultRow[];
173
+ }
174
+
175
+ export function getWatcherStats(): Record<JiraResultStatus, number> {
176
+ const rows = getDb().query(
177
+ "SELECT status, COUNT(*) as count FROM jira_watch_results WHERE deleted = 0 GROUP BY status",
178
+ ).all() as Array<{ status: JiraResultStatus; count: number }>;
179
+ const stats: Record<string, number> = { pending: 0, queued: 0, running: 0, done: 0, failed: 0 };
180
+ for (const r of rows) stats[r.status] = r.count;
181
+ return stats as Record<JiraResultStatus, number>;
182
+ }
183
+
184
+ export function markResultRead(resultId: number): boolean {
185
+ return getDb().query(
186
+ "UPDATE jira_watch_results SET read_at = datetime('now') WHERE id = ? AND read_at IS NULL",
187
+ ).run(resultId).changes > 0;
188
+ }
189
+
190
+ export function getUnreadCount(): number {
191
+ const row = getDb().query(
192
+ "SELECT COUNT(*) as count FROM jira_watch_results WHERE status = 'done' AND read_at IS NULL AND deleted = 0",
193
+ ).get() as { count: number };
194
+ return row.count;
195
+ }
@@ -0,0 +1,159 @@
1
+ import { searchIssues, JiraApiError, getRateLimitState } from "./jira-api-client.ts";
2
+ import { getDecryptedCredentials } from "./jira-config.service.ts";
3
+ import { getAllEnabledWatchers, insertResult } from "./jira-watcher-db.service.ts";
4
+ import { jiraDebugService } from "./jira-debug-session.service.ts";
5
+ import { getDb } from "./db.service.ts";
6
+ import { notificationService } from "./notification.service.ts";
7
+ import type { JiraWatcherRow, JiraIssue } from "../types/jira.ts";
8
+
9
+ const INTERVAL_MIN = 30_000; // 30s
10
+ const INTERVAL_MAX = 3_600_000; // 60m
11
+ const RATE_LIMIT_PAUSE_MS = 300_000; // 5min
12
+
13
+ export function clampInterval(ms: number): number {
14
+ return Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, ms));
15
+ }
16
+
17
+ class JiraWatcherService {
18
+ private activeTimers = new Map<number, Timer>();
19
+
20
+ async startAll(): Promise<void> {
21
+ const watchers = getAllEnabledWatchers();
22
+ for (const w of watchers) this.startWatcher(w.id, w.interval_ms);
23
+ if (watchers.length) console.log(`[jira] Started ${watchers.length} watcher(s)`);
24
+ }
25
+
26
+ stopAll(): void {
27
+ for (const [id, timer] of this.activeTimers) {
28
+ clearInterval(timer);
29
+ this.activeTimers.delete(id);
30
+ }
31
+ }
32
+
33
+ startWatcher(id: number, intervalMs: number): void {
34
+ if (this.activeTimers.has(id)) this.stopWatcher(id);
35
+ const interval = clampInterval(intervalMs);
36
+ const timer = setInterval(() => this.pollWatcher(id).catch((e) =>
37
+ console.warn(`[jira] Poll error watcher ${id}:`, e.message),
38
+ ), interval);
39
+ this.activeTimers.set(id, timer);
40
+ }
41
+
42
+ stopWatcher(id: number): void {
43
+ const timer = this.activeTimers.get(id);
44
+ if (timer) { clearInterval(timer); this.activeTimers.delete(id); }
45
+ }
46
+
47
+ isRunning(id: number): boolean {
48
+ return this.activeTimers.has(id);
49
+ }
50
+
51
+ async pollWatcher(watcherId: number, source: "auto" | "manual" = "auto"): Promise<number> {
52
+ const watcher = getDb()
53
+ .query("SELECT * FROM jira_watchers WHERE id = ?")
54
+ .get(watcherId) as JiraWatcherRow | null;
55
+ if (!watcher) return 0;
56
+
57
+ const creds = getDecryptedCredentials(watcher.jira_config_id);
58
+ if (!creds) { console.warn(`[jira] No credentials for config ${watcher.jira_config_id}`); return 0; }
59
+
60
+ // Check rate limit pause
61
+ const rlState = getRateLimitState(creds.baseUrl);
62
+ if (rlState.pausedUntil && Date.now() < rlState.pausedUntil) return 0;
63
+
64
+ const isFirstPoll = !watcher.last_polled_at;
65
+
66
+ try {
67
+ // First auto-poll = baseline only: just set last_polled_at, skip inserts
68
+ // Manual pull always fetches everything
69
+ if (isFirstPoll && source === "auto") {
70
+ await searchIssues(creds, watcher.jql); // validate JQL works
71
+ getDb().query("UPDATE jira_watchers SET last_polled_at = datetime('now') WHERE id = ?").run(watcherId);
72
+ console.log(`[jira] Watcher "${watcher.name}": baseline poll done (skipped inserts)`);
73
+ return 0;
74
+ }
75
+
76
+ const response = await searchIssues(creds, watcher.jql);
77
+ let newCount = 0;
78
+ const newResultIds: number[] = [];
79
+
80
+ for (const issue of response.issues) {
81
+ let inserted: boolean, resultId: number | null;
82
+ try {
83
+ ({ inserted, resultId } = insertResult(
84
+ watcher.id, issue.key,
85
+ issue.fields.summary, issue.fields.updated,
86
+ "watcher", source,
87
+ ));
88
+ } catch (e: any) {
89
+ console.error(`[jira] insertResult FK error for watcher ${watcher.id}, issue ${issue.key}:`, e.message);
90
+ throw e;
91
+ }
92
+ if (inserted && resultId) {
93
+ newCount++;
94
+ newResultIds.push(resultId);
95
+
96
+ if (watcher.mode === "notify") {
97
+ notificationService.broadcast("done", {
98
+ title: `Jira: ${issue.key}`,
99
+ body: issue.fields.summary,
100
+ project: "", sessionId: "",
101
+ }).catch(() => {});
102
+ }
103
+ }
104
+ }
105
+
106
+ // Auto-enqueue debug for new issues (≤5 to avoid flooding)
107
+ if (watcher.mode === "debug" && source === "auto" && newResultIds.length > 0 && newResultIds.length <= 5) {
108
+ for (const rid of newResultIds) {
109
+ try { jiraDebugService.enqueue(rid); } catch (e: any) {
110
+ console.warn(`[jira] enqueue debug error resultId=${rid}:`, e.message);
111
+ }
112
+ }
113
+ }
114
+
115
+ // Update last_polled_at
116
+ getDb().query("UPDATE jira_watchers SET last_polled_at = datetime('now') WHERE id = ?").run(watcherId);
117
+ if (newCount) console.log(`[jira] Watcher "${watcher.name}": ${newCount} new issue(s)`);
118
+ return newCount;
119
+ } catch (e) {
120
+ if (e instanceof JiraApiError && e.status === 429) {
121
+ console.warn(`[jira] Rate limited — pausing watchers for config ${watcher.jira_config_id}`);
122
+ this.pauseConfigWatchers(watcher.jira_config_id);
123
+ }
124
+ throw e;
125
+ }
126
+ }
127
+
128
+ // ── Internal helpers ──────────────────────────────────────────────
129
+
130
+ buildPrompt(watcher: JiraWatcherRow, issue: JiraIssue): string {
131
+ if (watcher.prompt_template) {
132
+ return watcher.prompt_template
133
+ .replace(/\{issue_key\}/g, issue.key)
134
+ .replace(/\{summary\}/g, issue.fields.summary)
135
+ .replace(/\{description\}/g, issue.fields.description ?? "(no description)")
136
+ .replace(/\{status\}/g, issue.fields.status.name)
137
+ .replace(/\{priority\}/g, issue.fields.priority?.name ?? "None");
138
+ }
139
+ return `Debug Jira issue ${issue.key}: ${issue.fields.summary}\n\nDescription:\n${issue.fields.description ?? "(no description)"}`;
140
+ }
141
+
142
+ private pauseConfigWatchers(configId: number): void {
143
+ const watchers = getDb()
144
+ .query("SELECT id FROM jira_watchers WHERE jira_config_id = ? AND enabled = 1")
145
+ .all(configId) as Array<{ id: number }>;
146
+ for (const w of watchers) this.stopWatcher(w.id);
147
+ // Re-start after pause
148
+ setTimeout(() => {
149
+ for (const w of watchers) {
150
+ const row = getDb().query("SELECT * FROM jira_watchers WHERE id = ? AND enabled = 1")
151
+ .get(w.id) as JiraWatcherRow | null;
152
+ if (row) this.startWatcher(row.id, row.interval_ms);
153
+ }
154
+ }, RATE_LIMIT_PAUSE_MS);
155
+ }
156
+
157
+ }
158
+
159
+ export const jiraWatcherService = new JiraWatcherService();
@@ -13,6 +13,12 @@ export interface NotificationPayload {
13
13
  }
14
14
 
15
15
  class NotificationService {
16
+ /** Broadcast event to all connected WebSocket clients */
17
+ async broadcastWs(event: unknown): Promise<void> {
18
+ const { broadcastGlobalEvent } = await import("../server/ws/chat.ts");
19
+ broadcastGlobalEvent(event);
20
+ }
21
+
16
22
  /** Broadcast notification to all channels (push, telegram). Fire-and-forget. */
17
23
  async broadcast(_type: NotificationType, payload: NotificationPayload): Promise<void> {
18
24
  const tasks: Promise<void>[] = [];