@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.
- package/CHANGELOG.md +35 -0
- package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
- package/dist/web/assets/{api-settings-C__hxGX2.js → api-settings-2eTz4SgY.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
- package/dist/web/assets/chat-tab-DYf6U6UF.js +10 -0
- package/dist/web/assets/code-editor-BPxBeu0S.js +8 -0
- package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BCkYHDUy.js} +1 -1
- package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
- package/dist/web/assets/{database-viewer-CvQc1PZH.js → database-viewer-CCe8qa1Q.js} +2 -2
- package/dist/web/assets/{diff-viewer-x7kjfVYW.js → diff-viewer-DIjzWvaG.js} +1 -1
- package/dist/web/assets/{esm-K1XIK4vc.js → esm-B99v94EE.js} +1 -1
- package/dist/web/assets/{extension-store-3yZYn07W.js → extension-store-CkyOvGbF.js} +1 -1
- package/dist/web/assets/extension-webview-HY8XueLo.js +3 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
- package/dist/web/assets/index-DpRxWGjM.js +26 -0
- package/dist/web/assets/index-iZHWllzQ.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-ySD5z855.js +1 -0
- package/dist/web/assets/{input-ClhO__YM.js → input-CHRMley8.js} +1 -1
- package/dist/web/assets/{keybindings-store-C9KsBH7z.js → keybindings-store-CpP5_miA.js} +1 -1
- package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
- package/dist/web/assets/{markdown-renderer-CKmmrUuy.js → markdown-renderer-BQV0AIm5.js} +3 -3
- package/dist/web/assets/packet-RMMSAZCW-CLxaXgIf.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-C9wPZfkn.js +1 -0
- package/dist/web/assets/port-forwarding-tab-DPmTpfFX.js +1 -0
- package/dist/web/assets/{postgres-viewer-YkljtDWX.js → postgres-viewer-BUSNt_7x.js} +3 -3
- package/dist/web/assets/{project-store-BYmQ0fDC.js → project-store-CczGNZyf.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
- package/dist/web/assets/{scroll-area-DW7L4Gnc.js → scroll-area-DwWF9FpN.js} +1 -1
- package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
- package/dist/web/assets/settings-tab-DHBG5O0C.js +1 -0
- package/dist/web/assets/{sql-query-editor-CM_qEhaX.js → sql-query-editor-CVEi0jLM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-f6ZJHIzh.js → sqlite-viewer-B7WnFN29.js} +1 -1
- package/dist/web/assets/{tab-store-B3M9hjho.js → tab-store-Jvy1eZGM.js} +1 -1
- package/dist/web/assets/{terminal-tab-CVdfvDSK.js → terminal-tab-1K4ijyNe.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
- package/dist/web/assets/{use-monaco-theme-CvV5vy_F.js → use-monaco-theme-kjiAwvOp.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-CwOSbfhN.js → vendor-mermaid-CylkVm4U.js} +3 -3
- package/dist/web/index.html +16 -16
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +29 -5
- package/docs/project-changelog.md +31 -1
- package/docs/system-architecture.md +106 -1
- package/package.json +1 -1
- package/packages/ext-git-graph/src/webview-html.ts +8 -7
- package/src/cli/commands/jira-cmd.ts +92 -0
- package/src/cli/commands/jira-watcher-cmd.ts +149 -0
- package/src/index.ts +3 -0
- package/src/server/index.ts +19 -0
- package/src/server/routes/files.ts +15 -0
- package/src/server/routes/fs-browse.ts +40 -1
- package/src/server/routes/jira-config-routes.ts +74 -0
- package/src/server/routes/jira-watcher-routes.ts +316 -0
- package/src/server/routes/jira.ts +7 -0
- package/src/server/ws/chat.ts +21 -0
- package/src/services/db.service.ts +65 -1
- package/src/services/file.service.ts +42 -0
- package/src/services/jira-api-client.ts +216 -0
- package/src/services/jira-config.service.ts +83 -0
- package/src/services/jira-debug-session.service.ts +240 -0
- package/src/services/jira-watcher-db.service.ts +195 -0
- package/src/services/jira-watcher.service.ts +159 -0
- package/src/services/notification.service.ts +6 -0
- package/src/types/jira.ts +128 -0
- package/src/web/app.tsx +15 -12
- package/src/web/components/chat/chat-tab.tsx +32 -1
- package/src/web/components/chat/message-input.tsx +56 -5
- package/src/web/components/explorer/file-tree.tsx +70 -19
- package/src/web/components/extensions/extension-webview.tsx +24 -10
- package/src/web/components/jira/jira-config-form.tsx +109 -0
- package/src/web/components/jira/jira-debug-prompt-dialog.tsx +58 -0
- package/src/web/components/jira/jira-filter-builder.tsx +197 -0
- package/src/web/components/jira/jira-panel.tsx +201 -0
- package/src/web/components/jira/jira-results-panel.tsx +184 -0
- package/src/web/components/jira/jira-settings-section.tsx +58 -0
- package/src/web/components/jira/jira-status-badge.tsx +18 -0
- package/src/web/components/jira/jira-ticket-card.tsx +144 -0
- package/src/web/components/jira/jira-ticket-detail.tsx +153 -0
- package/src/web/components/jira/jira-watcher-form.tsx +154 -0
- package/src/web/components/jira/jira-watcher-list.tsx +98 -0
- package/src/web/components/layout/mobile-drawer.tsx +18 -5
- package/src/web/components/layout/sidebar.tsx +20 -3
- package/src/web/components/settings/settings-tab.tsx +20 -3
- package/src/web/components/shared/markdown-code-block.tsx +5 -3
- package/src/web/components/ui/file-browser-picker.tsx +88 -1
- package/src/web/hooks/use-chat.ts +6 -0
- package/src/web/lib/ws-client.ts +10 -3
- package/src/web/stores/jira-store.ts +198 -0
- package/src/web/stores/settings-store.ts +17 -2
- package/src/web/styles/globals.css +7 -0
- package/vite.config.ts +5 -66
- package/bun.lock +0 -2062
- package/bunfig.toml +0 -2
- package/dist/web/assets/ai-settings-section-D2rONDPd.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DmL1WyG-.js +0 -1
- package/dist/web/assets/chat-tab-Dki1pz84.js +0 -10
- package/dist/web/assets/code-editor-D3AAT8nI.js +0 -8
- package/dist/web/assets/extension-webview-BFd0USXC.js +0 -3
- package/dist/web/assets/gitGraph-HDMCJU4V-D8vKfkjC.js +0 -1
- package/dist/web/assets/index-DPnjO2FY.css +0 -2
- package/dist/web/assets/index-DuEUN2Eg.js +0 -26
- package/dist/web/assets/info-3K5VOQVL-VG29MIoT.js +0 -1
- package/dist/web/assets/keybindings-store-BkZjvU9J.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-Bl_WpvPc.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BVpLpAIy.js +0 -1
- package/dist/web/assets/port-forwarding-tab-BUH9aImG.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-CJGco43I.js +0 -1
- package/dist/web/assets/settings-store-D9CflsKU.js +0 -2
- package/dist/web/assets/settings-tab-DfPjX9uY.js +0 -1
- package/dist/web/assets/square-nsMa3iMk.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-BsOrObtE.js +0 -1
- /package/dist/web/assets/{api-client-Bn-Pi9k5.js → api-client-C3tXCh0r.js} +0 -0
- /package/dist/web/assets/{csv-parser--2WJNgS7.js → csv-parser-BAa56Nnn.js} +0 -0
- /package/dist/web/assets/{dist-im4ynINo.js → dist-On3hz9_g.js} +0 -0
- /package/dist/web/assets/{katex-CKoArbIw.js → katex-Bbu770d9.js} +0 -0
- /package/dist/web/assets/{lib-D_kRA9p6.js → lib-BqkcKGFq.js} +0 -0
- /package/dist/web/assets/{react-GqWghJ-L.js → react-BkWDCPD7.js} +0 -0
- /package/dist/web/assets/{sql-completion-provider-C3cq9j99.js → sql-completion-provider-D3acAhav.js} +0 -0
- /package/dist/web/assets/{table-Dq575bPF.js → table-DbSviOmw.js} +0 -0
- /package/dist/web/assets/{text-wrap-Cn6BNQfq.js → text-wrap-DzvCTq_i.js} +0 -0
- /package/dist/web/assets/{trash-2-CJYoLw7Q.js → trash-2-BgDIBl6f.js} +0 -0
- /package/dist/web/assets/{utils-CTg5uAYR.js → utils-ChWX7pZv.js} +0 -0
- /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>[] = [];
|