@hienlh/ppm 0.10.5 → 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 +29 -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-CbguR_l0.js +10 -0
- package/dist/web/assets/code-editor-DbZP0Dnj.js +8 -0
- package/dist/web/assets/{conflict-editor-Bxq4QiW1.js → conflict-editor-BzrH1UpC.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-CqMOv2Sg.js} +2 -2
- package/dist/web/assets/{diff-viewer-x7kjfVYW.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/{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-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-YkljtDWX.js → postgres-viewer-YKyNjTLp.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-2tdZuQIn.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-Fx9qDD4-.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-BxljmYb7.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 +9 -0
- 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
package/src/server/ws/chat.ts
CHANGED
|
@@ -54,12 +54,33 @@ export function hasActiveClient(): boolean {
|
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Broadcast event to ALL connected WebSocket clients across all sessions */
|
|
58
|
+
export function broadcastGlobalEvent(event: unknown): void {
|
|
59
|
+
const json = JSON.stringify(event);
|
|
60
|
+
for (const entry of activeSessions.values()) {
|
|
61
|
+
for (const ws of entry.clients) {
|
|
62
|
+
try { ws.send(json); } catch {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
/** Remove a client from the session, cleaning up its ping interval */
|
|
58
68
|
function evictClient(entry: SessionEntry, ws: ChatWsSocket): void {
|
|
59
69
|
clearClientPing(entry, ws);
|
|
60
70
|
entry.clients.delete(ws);
|
|
61
71
|
}
|
|
62
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Forward an event to connected WS clients for a session (if any).
|
|
75
|
+
* Used by background processes (e.g. Jira debug) that run sessions server-side
|
|
76
|
+
* but want to stream events to any frontend client viewing that session.
|
|
77
|
+
*/
|
|
78
|
+
export function forwardEventToSession(sessionId: string, event: unknown): void {
|
|
79
|
+
const entry = activeSessions.get(sessionId);
|
|
80
|
+
if (!entry || entry.clients.size === 0) return; // no connected clients, silently drop
|
|
81
|
+
bufferAndBroadcast(sessionId, event);
|
|
82
|
+
}
|
|
83
|
+
|
|
63
84
|
/** Broadcast event to all connected clients for a session */
|
|
64
85
|
function broadcast(sessionId: string, event: unknown): void {
|
|
65
86
|
const entry = activeSessions.get(sessionId);
|
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { mkdirSync, existsSync } from "node:fs";
|
|
4
4
|
import { encrypt, decrypt } from "../lib/account-crypto.ts";
|
|
5
5
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
6
|
-
const CURRENT_SCHEMA_VERSION =
|
|
6
|
+
const CURRENT_SCHEMA_VERSION = 18;
|
|
7
7
|
|
|
8
8
|
let db: Database | null = null;
|
|
9
9
|
let dbProfile: string | null = null;
|
|
@@ -491,6 +491,70 @@ function runMigrations(database: Database): void {
|
|
|
491
491
|
PRAGMA user_version = 17;
|
|
492
492
|
`);
|
|
493
493
|
}
|
|
494
|
+
|
|
495
|
+
if (current < 18) {
|
|
496
|
+
database.exec(`
|
|
497
|
+
CREATE TABLE IF NOT EXISTS jira_config (
|
|
498
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
499
|
+
project_id INTEGER UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
|
500
|
+
base_url TEXT NOT NULL,
|
|
501
|
+
email TEXT NOT NULL,
|
|
502
|
+
api_token_encrypted TEXT NOT NULL,
|
|
503
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
CREATE TABLE IF NOT EXISTS jira_watchers (
|
|
507
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
508
|
+
jira_config_id INTEGER NOT NULL REFERENCES jira_config(id) ON DELETE CASCADE,
|
|
509
|
+
name TEXT NOT NULL,
|
|
510
|
+
jql TEXT NOT NULL,
|
|
511
|
+
prompt_template TEXT,
|
|
512
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
513
|
+
mode TEXT NOT NULL DEFAULT 'debug',
|
|
514
|
+
interval_ms INTEGER NOT NULL DEFAULT 120000,
|
|
515
|
+
last_polled_at TEXT,
|
|
516
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
517
|
+
);
|
|
518
|
+
CREATE INDEX IF NOT EXISTS idx_jira_watchers_config
|
|
519
|
+
ON jira_watchers(jira_config_id);
|
|
520
|
+
CREATE INDEX IF NOT EXISTS idx_jira_watchers_enabled
|
|
521
|
+
ON jira_watchers(enabled);
|
|
522
|
+
|
|
523
|
+
CREATE TABLE IF NOT EXISTS jira_watch_results (
|
|
524
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
525
|
+
watcher_id INTEGER REFERENCES jira_watchers(id) ON DELETE CASCADE,
|
|
526
|
+
issue_key TEXT NOT NULL,
|
|
527
|
+
issue_summary TEXT,
|
|
528
|
+
issue_updated TEXT,
|
|
529
|
+
session_id TEXT,
|
|
530
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
531
|
+
ai_summary TEXT,
|
|
532
|
+
source TEXT NOT NULL DEFAULT 'watcher',
|
|
533
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
534
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
535
|
+
UNIQUE(watcher_id, issue_key, issue_updated)
|
|
536
|
+
);
|
|
537
|
+
CREATE INDEX IF NOT EXISTS idx_jira_results_watcher
|
|
538
|
+
ON jira_watch_results(watcher_id, deleted);
|
|
539
|
+
CREATE INDEX IF NOT EXISTS idx_jira_results_status
|
|
540
|
+
ON jira_watch_results(status);
|
|
541
|
+
|
|
542
|
+
PRAGMA user_version = 18;
|
|
543
|
+
`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (current < 19) {
|
|
547
|
+
database.exec(`
|
|
548
|
+
ALTER TABLE jira_watch_results ADD COLUMN read_at TEXT;
|
|
549
|
+
ALTER TABLE jira_watch_results ADD COLUMN triggered_by TEXT DEFAULT 'auto';
|
|
550
|
+
-- Cleanup stale running results from bot_task era
|
|
551
|
+
UPDATE jira_watch_results SET status = 'failed', ai_summary = 'Stale: migrated from bot_task flow' WHERE status = 'running';
|
|
552
|
+
-- Index for unread count query (status='done' AND read_at IS NULL AND deleted=0)
|
|
553
|
+
CREATE INDEX IF NOT EXISTS idx_jira_results_unread
|
|
554
|
+
ON jira_watch_results(status, read_at) WHERE deleted = 0;
|
|
555
|
+
PRAGMA user_version = 19;
|
|
556
|
+
`);
|
|
557
|
+
}
|
|
494
558
|
}
|
|
495
559
|
|
|
496
560
|
// ---------------------------------------------------------------------------
|
|
@@ -127,6 +127,48 @@ class FileService {
|
|
|
127
127
|
return nodes;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/** Search project for files matching a given filename (exact, case-sensitive) */
|
|
131
|
+
resolveFilename(projectPath: string, filename: string, maxResults = 20): FileNode[] {
|
|
132
|
+
const ig = loadGitignore(projectPath);
|
|
133
|
+
const matches: FileNode[] = [];
|
|
134
|
+
this.walkForFilename(projectPath, projectPath, filename, ig, matches, maxResults);
|
|
135
|
+
return matches;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private walkForFilename(
|
|
139
|
+
rootPath: string,
|
|
140
|
+
dirPath: string,
|
|
141
|
+
targetName: string,
|
|
142
|
+
ig: Ignore,
|
|
143
|
+
results: FileNode[],
|
|
144
|
+
maxResults: number,
|
|
145
|
+
): void {
|
|
146
|
+
if (results.length >= maxResults) return;
|
|
147
|
+
if (!existsSync(dirPath)) return;
|
|
148
|
+
|
|
149
|
+
let entries;
|
|
150
|
+
try { entries = readdirSync(dirPath, { withFileTypes: true }); }
|
|
151
|
+
catch { return; }
|
|
152
|
+
|
|
153
|
+
for (const entry of entries) {
|
|
154
|
+
if (results.length >= maxResults) return;
|
|
155
|
+
if (this.isExcluded(entry.name)) continue;
|
|
156
|
+
|
|
157
|
+
const fullPath = join(dirPath, entry.name);
|
|
158
|
+
const relPath = relative(rootPath, fullPath);
|
|
159
|
+
const relPosix = relPath.split("\\").join("/");
|
|
160
|
+
|
|
161
|
+
const checkPath = entry.isDirectory() ? `${relPosix}/` : relPosix;
|
|
162
|
+
if (ig.ignores(checkPath) || ig.ignores(relPosix)) continue;
|
|
163
|
+
|
|
164
|
+
if (entry.isDirectory()) {
|
|
165
|
+
this.walkForFilename(rootPath, fullPath, targetName, ig, results, maxResults);
|
|
166
|
+
} else if (entry.name === targetName) {
|
|
167
|
+
results.push({ name: entry.name, path: relPath, type: "file" });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
130
172
|
/** Read file content with encoding detection */
|
|
131
173
|
readFile(
|
|
132
174
|
projectPath: string,
|
|
@@ -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
|
+
}
|