@hienlh/ppm 0.10.4 → 0.11.0
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 +39 -0
- package/dist/web/assets/ai-settings-section-D2vqiydT.js +1 -0
- package/dist/web/assets/{api-settings-CoKe_BdR.js → api-settings-2eTz4SgY.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-BRW4VwMk.js +1 -0
- package/dist/web/assets/chat-tab-CbguR_l0.js +10 -0
- package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
- package/dist/web/assets/{conflict-editor-HvxI1A29.js → conflict-editor-BzrH1UpC.js} +3 -3
- package/dist/web/assets/{csv-preview-BizIVMyb.js → csv-preview-D37K2LRd.js} +1 -1
- package/dist/web/assets/{database-viewer-BgCXPc4e.js → database-viewer-CqMOv2Sg.js} +2 -2
- package/dist/web/assets/{diff-viewer-blzXAJHd.js → diff-viewer-B6a2oYYn.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-CZr_fvOm.js +3 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-Bt68dqWT.js +1 -0
- package/dist/web/assets/index-C68PuiOm.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/{keybindings-store-D2N-Tq4N.js → keybindings-store-CpP5_miA.js} +1 -1
- package/dist/web/assets/keybindings-store-qfYScgY0.js +1 -0
- package/dist/web/assets/{markdown-renderer-Hcj-59AX.js → markdown-renderer-BhNYbXCp.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-Dw9MUu5a.js +1 -0
- package/dist/web/assets/{postgres-viewer-BEUI1N1X.js → postgres-viewer-YKyNjTLp.js} +3 -3
- package/dist/web/assets/{project-store-Ciq-cK1O.js → project-store-CczGNZyf.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DxEpzVN_.js +1 -0
- package/dist/web/assets/settings-store-CuYjM0FF.js +2 -0
- package/dist/web/assets/settings-tab-2tdZuQIn.js +1 -0
- package/dist/web/assets/{sql-query-editor-DZ9xskL8.js → sql-query-editor-CVEi0jLM.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-sQs615K6.js → sqlite-viewer-Fx9qDD4-.js} +1 -1
- package/dist/web/assets/{tab-store-DZbiYk7y.js → tab-store-Jvy1eZGM.js} +1 -1
- package/dist/web/assets/terminal-tab-BxljmYb7.js +1 -0
- package/dist/web/assets/treemap-KZPCXAKY-yelcZZqO.js +1 -0
- package/dist/web/assets/{use-monaco-theme-OY18iXNi.js → use-monaco-theme-kjiAwvOp.js} +1 -1
- package/dist/web/assets/{vendor-mermaid-B2SLgECS.js → vendor-mermaid-CylkVm4U.js} +3 -3
- package/dist/web/index.html +13 -13
- 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/extension.ts +11 -4
- package/packages/ext-git-graph/src/webview-html.ts +25 -11
- 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/extension-host-worker.ts +3 -2
- package/src/services/extension.service.ts +4 -2
- 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/services/supervisor-state.ts +13 -1
- package/src/services/supervisor.ts +4 -3
- 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 +9 -0
- package/src/web/components/extensions/extension-webview.tsx +31 -13
- 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/report-bug.ts +3 -2
- package/src/web/lib/ws-client.ts +14 -6
- package/src/web/stores/jira-store.ts +198 -0
- package/src/web/stores/settings-store.ts +24 -5
- 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-LMO_cfIW.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-CUZIB1Vq.js +0 -1
- package/dist/web/assets/chat-tab-By7krQ3s.js +0 -10
- package/dist/web/assets/code-editor-BoKL57Co.js +0 -8
- package/dist/web/assets/extension-webview-Dvk_61ON.js +0 -3
- package/dist/web/assets/gitGraph-HDMCJU4V-CtOMUphQ.js +0 -1
- package/dist/web/assets/index-DPnjO2FY.css +0 -2
- package/dist/web/assets/index-EgCQVN13.js +0 -26
- package/dist/web/assets/info-3K5VOQVL-BCrPCWGY.js +0 -1
- package/dist/web/assets/keybindings-store-C7No6mtl.js +0 -1
- package/dist/web/assets/packet-RMMSAZCW-D_OqB-zi.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-WUHpLNJz.js +0 -1
- package/dist/web/assets/port-forwarding-tab-CUgwDn_5.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-HQIIecVM.js +0 -1
- package/dist/web/assets/settings-store-B470PCWf.js +0 -2
- package/dist/web/assets/settings-tab-BGvgK51L.js +0 -1
- package/dist/web/assets/square-nsMa3iMk.js +0 -1
- package/dist/web/assets/terminal-tab-CUyHmiHH.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-0wLgUUTz.js +0 -1
- /package/dist/web/assets/{api-client-o_6TmLGC.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/{vendor-xterm-ejLe7-tK.js → vendor-xterm-B9BUAFKA.js} +0 -0
|
@@ -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>[] = [];
|
|
@@ -55,7 +55,19 @@ export function updateStatus(patch: Record<string, unknown>) {
|
|
|
55
55
|
try {
|
|
56
56
|
const data = { ...readStatus(), ...patch };
|
|
57
57
|
atomicWriteJson(STATUS_FILE(), data);
|
|
58
|
-
} catch {
|
|
58
|
+
} catch (e) {
|
|
59
|
+
// Log to stderr so failures are visible in ppm.log
|
|
60
|
+
try { process.stderr.write(`[updateStatus] Failed to write status.json: ${e}\n`); } catch {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Full write — replaces entire status.json (use at supervisor startup to clear stale data) */
|
|
65
|
+
export function writeStatus(data: Record<string, unknown>) {
|
|
66
|
+
try {
|
|
67
|
+
atomicWriteJson(STATUS_FILE(), data);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
try { process.stderr.write(`[writeStatus] Failed to write status.json: ${e}\n`); } catch {}
|
|
70
|
+
}
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
// ─── Command file protocol ─────────────────────────────────────────────
|
|
@@ -15,7 +15,7 @@ import { isCompiledBinary } from "./autostart-generator.ts";
|
|
|
15
15
|
import {
|
|
16
16
|
type SupervisorState,
|
|
17
17
|
getState, setState, waitForResume, triggerResume,
|
|
18
|
-
readAndDeleteCmd, readStatus, updateStatus,
|
|
18
|
+
readAndDeleteCmd, readStatus, updateStatus, writeStatus,
|
|
19
19
|
STATUS_FILE, PID_FILE,
|
|
20
20
|
} from "./supervisor-state.ts";
|
|
21
21
|
import { startStoppedPage, stopStoppedPage } from "./supervisor-stopped-page.ts";
|
|
@@ -767,11 +767,12 @@ export async function runSupervisor(opts: {
|
|
|
767
767
|
log("ERROR", `Unhandled rejection: ${reason}`);
|
|
768
768
|
});
|
|
769
769
|
|
|
770
|
-
//
|
|
770
|
+
// Full write to clear any stale data from previous runs (different port, dead PIDs, etc.)
|
|
771
771
|
writeFileSync(PID_FILE(), String(process.pid));
|
|
772
|
-
|
|
772
|
+
writeStatus({
|
|
773
773
|
supervisorPid: process.pid, port: opts.port, host: opts.host, availableVersion: null,
|
|
774
774
|
state: "running", pausedAt: null, pauseReason: null, lastCrashError: null,
|
|
775
|
+
pid: null, tunnelPid: null, shareUrl: null,
|
|
775
776
|
});
|
|
776
777
|
|
|
777
778
|
// Build __serve__ args
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ── DB row types (snake_case, matches SQLite columns) ─────────────────
|
|
2
|
+
|
|
3
|
+
export interface JiraConfigRow {
|
|
4
|
+
id: number;
|
|
5
|
+
project_id: number;
|
|
6
|
+
base_url: string;
|
|
7
|
+
email: string;
|
|
8
|
+
api_token_encrypted: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface JiraWatcherRow {
|
|
13
|
+
id: number;
|
|
14
|
+
jira_config_id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
jql: string;
|
|
17
|
+
prompt_template: string | null;
|
|
18
|
+
enabled: number; // 0 | 1
|
|
19
|
+
mode: string; // "debug" | "notify"
|
|
20
|
+
interval_ms: number;
|
|
21
|
+
last_polled_at: string | null;
|
|
22
|
+
created_at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface JiraWatchResultRow {
|
|
26
|
+
id: number;
|
|
27
|
+
watcher_id: number | null;
|
|
28
|
+
issue_key: string;
|
|
29
|
+
issue_summary: string | null;
|
|
30
|
+
issue_updated: string | null;
|
|
31
|
+
session_id: string | null;
|
|
32
|
+
status: JiraResultStatus;
|
|
33
|
+
ai_summary: string | null;
|
|
34
|
+
source: string; // "watcher" | "manual"
|
|
35
|
+
deleted: number; // 0 | 1
|
|
36
|
+
read_at: string | null;
|
|
37
|
+
triggered_by: string; // "auto" | "manual"
|
|
38
|
+
created_at: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type JiraResultStatus = "pending" | "queued" | "running" | "done" | "failed";
|
|
42
|
+
export type JiraWatcherMode = "debug" | "notify";
|
|
43
|
+
|
|
44
|
+
// ── API response types (camelCase for frontend) ───────────────────────
|
|
45
|
+
|
|
46
|
+
export interface JiraConfig {
|
|
47
|
+
id: number;
|
|
48
|
+
projectId: number;
|
|
49
|
+
baseUrl: string;
|
|
50
|
+
email: string;
|
|
51
|
+
hasToken: boolean; // never expose actual token
|
|
52
|
+
createdAt: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface JiraWatcher {
|
|
56
|
+
id: number;
|
|
57
|
+
jiraConfigId: number;
|
|
58
|
+
name: string;
|
|
59
|
+
jql: string;
|
|
60
|
+
promptTemplate: string | null;
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
mode: JiraWatcherMode;
|
|
63
|
+
intervalMs: number;
|
|
64
|
+
lastPolledAt: string | null;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface JiraWatchResult {
|
|
69
|
+
id: number;
|
|
70
|
+
watcherId: number | null;
|
|
71
|
+
issueKey: string;
|
|
72
|
+
issueSummary: string | null;
|
|
73
|
+
issueUpdated: string | null;
|
|
74
|
+
sessionId: string | null;
|
|
75
|
+
status: JiraResultStatus;
|
|
76
|
+
aiSummary: string | null;
|
|
77
|
+
source: string;
|
|
78
|
+
readAt: string | null;
|
|
79
|
+
triggeredBy: string;
|
|
80
|
+
createdAt: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Jira Cloud API response shapes (subset we use) ────────────────────
|
|
84
|
+
|
|
85
|
+
export interface JiraIssue {
|
|
86
|
+
key: string;
|
|
87
|
+
id: string;
|
|
88
|
+
fields: {
|
|
89
|
+
summary: string;
|
|
90
|
+
description: string | null;
|
|
91
|
+
status: { name: string; id: string };
|
|
92
|
+
priority: { name: string; id: string } | null;
|
|
93
|
+
assignee: { accountId: string; displayName: string; emailAddress?: string } | null;
|
|
94
|
+
updated: string;
|
|
95
|
+
created: string;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface JiraSearchResponse {
|
|
100
|
+
issues: JiraIssue[];
|
|
101
|
+
total: number;
|
|
102
|
+
maxResults: number;
|
|
103
|
+
nextPageToken?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface JiraTransition {
|
|
107
|
+
id: string;
|
|
108
|
+
name: string;
|
|
109
|
+
to: { name: string };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Credentials (internal, decrypted for API calls) ───────────────────
|
|
113
|
+
|
|
114
|
+
export interface JiraCredentials {
|
|
115
|
+
baseUrl: string;
|
|
116
|
+
email: string;
|
|
117
|
+
token: string; // plaintext (decrypted)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Rate limit tracking ───────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export interface JiraRateLimitState {
|
|
123
|
+
remaining: number | null;
|
|
124
|
+
limit: number | null;
|
|
125
|
+
resetAt: string | null;
|
|
126
|
+
backingOff: boolean;
|
|
127
|
+
pausedUntil: number | null; // epoch ms
|
|
128
|
+
}
|
package/src/web/app.tsx
CHANGED
|
@@ -178,19 +178,22 @@ export function App() {
|
|
|
178
178
|
|
|
179
179
|
useProjectStore.getState().setActiveProject(target);
|
|
180
180
|
|
|
181
|
+
// Switch panel layout to target project BEFORE opening URL tabs.
|
|
182
|
+
// Without this, autoOpenFromUrl creates tabs in the __global__ layout
|
|
183
|
+
// which get lost when the switchProject effect fires after render.
|
|
184
|
+
useTabStore.getState().switchProject(target.name);
|
|
185
|
+
|
|
181
186
|
// Auto-open target tab from URL (type-based)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
});
|
|
187
|
+
if (urlState.tabType) {
|
|
188
|
+
autoOpenFromUrl(urlState.tabType, urlState.tabIdentifier, target!.name);
|
|
189
|
+
}
|
|
190
|
+
// Legacy: ?openChat= query param
|
|
191
|
+
if (urlState.openChat) {
|
|
192
|
+
autoOpenFromUrl("chat", urlState.openChat, target!.name);
|
|
193
|
+
const url = new URL(window.location.href);
|
|
194
|
+
url.searchParams.delete("openChat");
|
|
195
|
+
window.history.replaceState(null, "", url.pathname);
|
|
196
|
+
}
|
|
194
197
|
});
|
|
195
198
|
}, [authState, fetchProjects]);
|
|
196
199
|
|
|
@@ -57,6 +57,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
57
57
|
// Drag-and-drop state
|
|
58
58
|
const [isDragging, setIsDragging] = useState(false);
|
|
59
59
|
const [externalFiles, setExternalFiles] = useState<File[] | null>(null);
|
|
60
|
+
const [externalPaths, setExternalPaths] = useState<string[] | null>(null);
|
|
61
|
+
const [disambiguateItems, setDisambiguateItems] = useState<FileNode[] | null>(null);
|
|
60
62
|
const dragCounterRef = useRef(0);
|
|
61
63
|
|
|
62
64
|
// Use tab's own project, not global activeProject (keep-alive: hidden tabs must not react to switches)
|
|
@@ -285,6 +287,16 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
285
287
|
setSlashFilter("");
|
|
286
288
|
}, []);
|
|
287
289
|
|
|
290
|
+
// --- Disambiguation picker handler (OS drag resolve with multiple matches) ---
|
|
291
|
+
const handleDisambiguate = useCallback((matches: FileNode[]) => {
|
|
292
|
+
setDisambiguateItems(matches);
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
const handleDisambiguateSelect = useCallback((item: FileNode) => {
|
|
296
|
+
setExternalPaths([item.path]);
|
|
297
|
+
setDisambiguateItems(null);
|
|
298
|
+
}, []);
|
|
299
|
+
|
|
288
300
|
// --- File picker handlers ---
|
|
289
301
|
const handleFileStateChange = useCallback((visible: boolean, filter: string) => {
|
|
290
302
|
setShowFilePicker(visible);
|
|
@@ -307,7 +319,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
307
319
|
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
308
320
|
e.preventDefault();
|
|
309
321
|
dragCounterRef.current++;
|
|
310
|
-
if (e.dataTransfer.types.includes("Files")) {
|
|
322
|
+
if (e.dataTransfer.types.includes("application/x-ppm-path") || e.dataTransfer.types.includes("Files")) {
|
|
311
323
|
setIsDragging(true);
|
|
312
324
|
}
|
|
313
325
|
}, []);
|
|
@@ -329,6 +341,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
329
341
|
dragCounterRef.current = 0;
|
|
330
342
|
setIsDragging(false);
|
|
331
343
|
|
|
344
|
+
// Check for internal file tree drag (custom MIME) first
|
|
345
|
+
const ppmPath = e.dataTransfer.getData("application/x-ppm-path");
|
|
346
|
+
if (ppmPath) {
|
|
347
|
+
setExternalPaths([ppmPath]);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
332
351
|
const files = Array.from(e.dataTransfer.files);
|
|
333
352
|
if (files.length > 0) {
|
|
334
353
|
setExternalFiles(files);
|
|
@@ -422,6 +441,15 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
422
441
|
onClose={handleFileClose}
|
|
423
442
|
visible={showFilePicker}
|
|
424
443
|
/>
|
|
444
|
+
{disambiguateItems && (
|
|
445
|
+
<FilePicker
|
|
446
|
+
items={disambiguateItems}
|
|
447
|
+
filter=""
|
|
448
|
+
onSelect={handleDisambiguateSelect}
|
|
449
|
+
onClose={() => setDisambiguateItems(null)}
|
|
450
|
+
visible={true}
|
|
451
|
+
/>
|
|
452
|
+
)}
|
|
425
453
|
|
|
426
454
|
{/* Input */}
|
|
427
455
|
<MessageInput
|
|
@@ -438,6 +466,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
438
466
|
onFileItemsLoaded={setFileItems}
|
|
439
467
|
fileSelected={fileSelected}
|
|
440
468
|
externalFiles={externalFiles}
|
|
469
|
+
externalPaths={externalPaths}
|
|
470
|
+
onExternalPathsConsumed={() => setExternalPaths(null)}
|
|
471
|
+
onDisambiguate={handleDisambiguate}
|
|
441
472
|
permissionMode={permissionMode}
|
|
442
473
|
onModeChange={setPermissionMode}
|
|
443
474
|
providerId={providerId}
|