@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,216 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JiraCredentials,
|
|
3
|
+
JiraIssue,
|
|
4
|
+
JiraSearchResponse,
|
|
5
|
+
JiraTransition,
|
|
6
|
+
JiraRateLimitState,
|
|
7
|
+
} from "../types/jira.ts";
|
|
8
|
+
|
|
9
|
+
// ── Error class ───────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export class JiraApiError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
message: string,
|
|
14
|
+
public status: number,
|
|
15
|
+
public retryAfter?: number,
|
|
16
|
+
public rateLimitRemaining?: number,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "JiraApiError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Rate limit state per config (keyed by baseUrl) ────────────────────
|
|
24
|
+
|
|
25
|
+
const rateLimitStates = new Map<string, JiraRateLimitState>();
|
|
26
|
+
|
|
27
|
+
export function getRateLimitState(baseUrl: string): JiraRateLimitState {
|
|
28
|
+
return rateLimitStates.get(baseUrl) ?? {
|
|
29
|
+
remaining: null, limit: null, resetAt: null,
|
|
30
|
+
backingOff: false, pausedUntil: null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function extractRateLimits(headers: Headers, baseUrl: string): void {
|
|
35
|
+
const remaining = headers.get("x-ratelimit-remaining");
|
|
36
|
+
const limit = headers.get("x-ratelimit-limit");
|
|
37
|
+
const state = getRateLimitState(baseUrl);
|
|
38
|
+
if (remaining !== null) state.remaining = parseInt(remaining, 10);
|
|
39
|
+
if (limit !== null) state.limit = parseInt(limit, 10);
|
|
40
|
+
// Check if backing off needed
|
|
41
|
+
if (state.remaining !== null && state.limit !== null && state.limit > 0) {
|
|
42
|
+
state.backingOff = state.remaining / state.limit < 0.2;
|
|
43
|
+
}
|
|
44
|
+
rateLimitStates.set(baseUrl, state);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Auth helper ───────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function buildAuthHeader(creds: JiraCredentials): string {
|
|
50
|
+
return "Basic " + Buffer.from(`${creds.email}:${creds.token}`).toString("base64");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Core fetch wrapper ────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
async function jiraFetch<T>(
|
|
56
|
+
creds: JiraCredentials,
|
|
57
|
+
method: string,
|
|
58
|
+
path: string,
|
|
59
|
+
body?: unknown,
|
|
60
|
+
): Promise<T> {
|
|
61
|
+
const url = `${creds.baseUrl.replace(/\/$/, "")}${path}`;
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(url, {
|
|
67
|
+
method,
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: buildAuthHeader(creds),
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
Accept: "application/json",
|
|
72
|
+
},
|
|
73
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
extractRateLimits(res.headers, creds.baseUrl);
|
|
78
|
+
|
|
79
|
+
if (res.status === 429) {
|
|
80
|
+
const retryAfter = parseInt(res.headers.get("retry-after") ?? "300", 10);
|
|
81
|
+
const state = getRateLimitState(creds.baseUrl);
|
|
82
|
+
state.pausedUntil = Date.now() + retryAfter * 1000;
|
|
83
|
+
rateLimitStates.set(creds.baseUrl, state);
|
|
84
|
+
throw new JiraApiError("Rate limited by Jira", 429, retryAfter);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (res.status === 204) return undefined as T; // void responses (PUT update)
|
|
88
|
+
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const text = await res.text().catch(() => "");
|
|
91
|
+
throw new JiraApiError(
|
|
92
|
+
`Jira API ${res.status}: ${text.slice(0, 200)}`,
|
|
93
|
+
res.status,
|
|
94
|
+
undefined,
|
|
95
|
+
getRateLimitState(creds.baseUrl).remaining ?? undefined,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (await res.json()) as T;
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
const DEFAULT_FIELDS = "summary,status,priority,assignee,description,updated,created";
|
|
108
|
+
|
|
109
|
+
export async function searchIssues(
|
|
110
|
+
creds: JiraCredentials,
|
|
111
|
+
jql: string,
|
|
112
|
+
fields = DEFAULT_FIELDS,
|
|
113
|
+
maxResults = 50,
|
|
114
|
+
nextPageToken?: string,
|
|
115
|
+
): Promise<JiraSearchResponse> {
|
|
116
|
+
const body: Record<string, unknown> = {
|
|
117
|
+
jql, fields: fields.split(","), maxResults,
|
|
118
|
+
};
|
|
119
|
+
if (nextPageToken) body.nextPageToken = nextPageToken;
|
|
120
|
+
return jiraFetch<JiraSearchResponse>(creds, "POST", "/rest/api/3/search/jql", body);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function getIssue(
|
|
124
|
+
creds: JiraCredentials,
|
|
125
|
+
issueKey: string,
|
|
126
|
+
fields = DEFAULT_FIELDS,
|
|
127
|
+
): Promise<JiraIssue> {
|
|
128
|
+
return jiraFetch<JiraIssue>(creds, "GET", `/rest/api/3/issue/${issueKey}?fields=${fields}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function updateIssue(
|
|
132
|
+
creds: JiraCredentials,
|
|
133
|
+
issueKey: string,
|
|
134
|
+
fields: Record<string, unknown>,
|
|
135
|
+
): Promise<void> {
|
|
136
|
+
await jiraFetch<void>(creds, "PUT", `/rest/api/3/issue/${issueKey}`, { fields });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function getTransitions(
|
|
140
|
+
creds: JiraCredentials,
|
|
141
|
+
issueKey: string,
|
|
142
|
+
): Promise<JiraTransition[]> {
|
|
143
|
+
const res = await jiraFetch<{ transitions: JiraTransition[] }>(
|
|
144
|
+
creds, "GET", `/rest/api/3/issue/${issueKey}/transitions`,
|
|
145
|
+
);
|
|
146
|
+
return res.transitions;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function transitionIssue(
|
|
150
|
+
creds: JiraCredentials,
|
|
151
|
+
issueKey: string,
|
|
152
|
+
transitionId: string,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
await jiraFetch<void>(creds, "POST", `/rest/api/3/issue/${issueKey}/transitions`, {
|
|
155
|
+
transition: { id: transitionId },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function testConnection(creds: JiraCredentials): Promise<boolean> {
|
|
160
|
+
// Use bounded JQL — unbounded queries return 400 on /search/jql
|
|
161
|
+
await searchIssues(creds, "created >= -30d ORDER BY created DESC", "summary", 1);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Escape JQL special characters in user input */
|
|
166
|
+
function escapeJql(value: string): string {
|
|
167
|
+
// Remove control characters, escape JQL reserved chars
|
|
168
|
+
return value
|
|
169
|
+
.replace(/[\x00-\x1f]/g, "")
|
|
170
|
+
.replace(/[\\'"{}()\[\]+\-&|!~*?^]/g, "\\$&");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function searchText(
|
|
174
|
+
creds: JiraCredentials,
|
|
175
|
+
query: string,
|
|
176
|
+
maxResults = 20,
|
|
177
|
+
): Promise<JiraSearchResponse> {
|
|
178
|
+
const jql = `text ~ "${escapeJql(query)}" ORDER BY updated DESC`;
|
|
179
|
+
return searchIssues(creds, jql, DEFAULT_FIELDS, maxResults);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Fetch Jira project list for filter builder */
|
|
183
|
+
export async function getProjects(
|
|
184
|
+
creds: JiraCredentials,
|
|
185
|
+
): Promise<Array<{ key: string; name: string }>> {
|
|
186
|
+
return jiraFetch<Array<{ key: string; name: string }>>(
|
|
187
|
+
creds, "GET", "/rest/api/3/project/search?maxResults=100",
|
|
188
|
+
).then((res: any) => (res.values ?? res).map((p: any) => ({ key: p.key, name: p.name })));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Fetch metadata for filter builder (issue types, priorities, statuses) */
|
|
192
|
+
export async function getFieldOptions(
|
|
193
|
+
creds: JiraCredentials,
|
|
194
|
+
fieldName: "issuetype" | "priority" | "status",
|
|
195
|
+
): Promise<Array<{ id: string; name: string }>> {
|
|
196
|
+
if (fieldName === "issuetype") {
|
|
197
|
+
return jiraFetch<Array<{ id: string; name: string }>>(creds, "GET", "/rest/api/3/issuetype");
|
|
198
|
+
}
|
|
199
|
+
if (fieldName === "priority") {
|
|
200
|
+
return jiraFetch<Array<{ id: string; name: string }>>(creds, "GET", "/rest/api/3/priority");
|
|
201
|
+
}
|
|
202
|
+
// statuses
|
|
203
|
+
return jiraFetch<Array<any>>(creds, "GET", "/rest/api/3/status")
|
|
204
|
+
.then((list) => list.map((s: any) => ({ id: s.id, name: s.name })));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Fetch assignable users for filter builder */
|
|
208
|
+
export async function getAssignableUsers(
|
|
209
|
+
creds: JiraCredentials,
|
|
210
|
+
): Promise<Array<{ accountId: string; displayName: string }>> {
|
|
211
|
+
return jiraFetch<Array<any>>(creds, "GET", "/rest/api/3/users/search?maxResults=100")
|
|
212
|
+
.then((list) => list
|
|
213
|
+
.filter((u: any) => u.accountType === "atlassian")
|
|
214
|
+
.map((u: any) => ({ accountId: u.accountId, displayName: u.displayName })),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { getDb } from "./db.service.ts";
|
|
2
|
+
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
3
|
+
import type { JiraConfigRow, JiraConfig, JiraCredentials } from "../types/jira.ts";
|
|
4
|
+
|
|
5
|
+
// ── Row → API mapper ──────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function rowToConfig(row: JiraConfigRow): JiraConfig {
|
|
8
|
+
return {
|
|
9
|
+
id: row.id,
|
|
10
|
+
projectId: row.project_id,
|
|
11
|
+
baseUrl: row.base_url,
|
|
12
|
+
email: row.email,
|
|
13
|
+
hasToken: !!row.api_token_encrypted,
|
|
14
|
+
createdAt: row.created_at,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export function getConfigByProjectId(projectId: number): JiraConfig | null {
|
|
21
|
+
const row = getDb()
|
|
22
|
+
.query("SELECT * FROM jira_config WHERE project_id = ?")
|
|
23
|
+
.get(projectId) as JiraConfigRow | null;
|
|
24
|
+
return row ? rowToConfig(row) : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getConfigById(id: number): JiraConfigRow | null {
|
|
28
|
+
return getDb()
|
|
29
|
+
.query("SELECT * FROM jira_config WHERE id = ?")
|
|
30
|
+
.get(id) as JiraConfigRow | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getAllConfigs(): JiraConfig[] {
|
|
34
|
+
const rows = getDb()
|
|
35
|
+
.query("SELECT * FROM jira_config ORDER BY id")
|
|
36
|
+
.all() as JiraConfigRow[];
|
|
37
|
+
return rows.map(rowToConfig);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function upsertConfig(
|
|
41
|
+
projectId: number,
|
|
42
|
+
baseUrl: string,
|
|
43
|
+
email: string,
|
|
44
|
+
plainToken?: string,
|
|
45
|
+
): JiraConfig {
|
|
46
|
+
if (plainToken) {
|
|
47
|
+
// Full upsert with new token
|
|
48
|
+
const encrypted = encrypt(plainToken);
|
|
49
|
+
getDb().query(`
|
|
50
|
+
INSERT INTO jira_config (project_id, base_url, email, api_token_encrypted)
|
|
51
|
+
VALUES (?, ?, ?, ?)
|
|
52
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
53
|
+
base_url = excluded.base_url,
|
|
54
|
+
email = excluded.email,
|
|
55
|
+
api_token_encrypted = excluded.api_token_encrypted
|
|
56
|
+
`).run(projectId, baseUrl, email, encrypted);
|
|
57
|
+
} else {
|
|
58
|
+
// Update URL/email only, preserve existing token
|
|
59
|
+
getDb().query(`
|
|
60
|
+
UPDATE jira_config SET base_url = ?, email = ? WHERE project_id = ?
|
|
61
|
+
`).run(baseUrl, email, projectId);
|
|
62
|
+
}
|
|
63
|
+
return getConfigByProjectId(projectId)!;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function deleteConfig(projectId: number): boolean {
|
|
67
|
+
const result = getDb()
|
|
68
|
+
.query("DELETE FROM jira_config WHERE project_id = ?")
|
|
69
|
+
.run(projectId);
|
|
70
|
+
return result.changes > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getDecryptedCredentials(configId: number): JiraCredentials | null {
|
|
74
|
+
const row = getConfigById(configId);
|
|
75
|
+
if (!row) return null;
|
|
76
|
+
try {
|
|
77
|
+
const token = decrypt(row.api_token_encrypted);
|
|
78
|
+
return { baseUrl: row.base_url, email: row.email, token };
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.warn(`[jira] Failed to decrypt token for config ${configId}:`, (e as Error).message);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -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();
|