ticketlens 0.22.1 → 0.24.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/README.md +29 -4
- package/bin/ticketlens.mjs +35 -0
- package/package.json +1 -1
- package/scripts/postinstall.mjs +1 -0
- package/skills/jtb/SKILL.md +24 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +79 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +33 -5
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +101 -0
- package/skills/jtb/scripts/lib/adf-converter.mjs +19 -0
- package/skills/jtb/scripts/lib/cli.mjs +8 -0
- package/skills/jtb/scripts/lib/help.mjs +67 -5
- package/skills/jtb/scripts/lib/jira-client.mjs +87 -1
- package/skills/jtb/scripts/lib/mcp-install.mjs +92 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +86 -9
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +1 -1
- package/skills/jtb/scripts/lib/ticket-action-cooldown.mjs +72 -0
- package/skills/jtb/scripts/lib/ticket-action-log.mjs +72 -0
- package/skills/jtb/scripts/lib/ticket-command.mjs +236 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Supports v2 (Server/DC) and v3 (Cloud) API versions.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { adfToText } from './adf-converter.mjs';
|
|
7
|
+
import { adfToText, textToAdf } from './adf-converter.mjs';
|
|
8
8
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
9
9
|
|
|
10
10
|
function toText(value) {
|
|
@@ -408,6 +408,92 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
408
408
|
.map(link => ({ url: link.object.url, title: link.object.title ?? null }));
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Adds a comment to an issue. Cloud (v3) rejects a plain string body
|
|
413
|
+
* outright and requires ADF; Server/DC (v2) accepts plain text directly —
|
|
414
|
+
* same apiVersion branch point every other write/read here already uses.
|
|
415
|
+
*/
|
|
416
|
+
export async function postComment(ticketKey, body, opts = {}) {
|
|
417
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
418
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
419
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
420
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/comment`;
|
|
421
|
+
|
|
422
|
+
const payload = { body: apiVersion === 3 ? textToAdf(body) : body };
|
|
423
|
+
const fetchOpts = {
|
|
424
|
+
method: 'POST',
|
|
425
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
426
|
+
body: JSON.stringify(payload),
|
|
427
|
+
};
|
|
428
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
429
|
+
|
|
430
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
431
|
+
if (!response.ok) {
|
|
432
|
+
const err = new Error(`Jira API error ${response.status} commenting on ${ticketKey}`);
|
|
433
|
+
err.status = response.status;
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
const raw = await response.json();
|
|
437
|
+
return { id: raw.id, url: raw.self ?? null };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Discovers the transitions actually available for this specific issue
|
|
442
|
+
* right now (workflow-dependent, varies per project/status) — never
|
|
443
|
+
* hardcode transition names or ids, they aren't stable across projects.
|
|
444
|
+
*/
|
|
445
|
+
export async function getTransitions(ticketKey, opts = {}) {
|
|
446
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
447
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
448
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
449
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/transitions`;
|
|
450
|
+
|
|
451
|
+
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
452
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
453
|
+
|
|
454
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
const err = new Error(`Jira API error ${response.status} fetching transitions for ${ticketKey}`);
|
|
457
|
+
err.status = response.status;
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
const raw = await response.json();
|
|
461
|
+
return (raw.transitions ?? []).map(t => ({ id: t.id, name: t.name, to: t.to?.name ?? null }));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Executes a transition by id. Callers must resolve the id via
|
|
466
|
+
* getTransitions() immediately before calling this — never pass a
|
|
467
|
+
* remembered/stale id, since transitions are workflow- and
|
|
468
|
+
* time-of-status-dependent (see ticket-command.mjs's re-validation).
|
|
469
|
+
* A transition requiring a mandatory screen field the caller didn't
|
|
470
|
+
* supply returns Jira's own 400 with the field list — surfaced as-is,
|
|
471
|
+
* never silently swallowed or auto-filled.
|
|
472
|
+
*/
|
|
473
|
+
export async function postTransition(ticketKey, transitionId, opts = {}) {
|
|
474
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
475
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
476
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
477
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/transitions`;
|
|
478
|
+
|
|
479
|
+
const fetchOpts = {
|
|
480
|
+
method: 'POST',
|
|
481
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
482
|
+
body: JSON.stringify({ transition: { id: transitionId } }),
|
|
483
|
+
};
|
|
484
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
485
|
+
|
|
486
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
487
|
+
if (!response.ok) {
|
|
488
|
+
let details;
|
|
489
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
490
|
+
const err = new Error(`Jira API error ${response.status} transitioning ${ticketKey}`);
|
|
491
|
+
err.status = response.status;
|
|
492
|
+
err.details = details;
|
|
493
|
+
throw err;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
411
497
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
412
498
|
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
|
|
413
499
|
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers `ticketlens mcp` into the current project's `.mcp.json` so the
|
|
3
|
+
* user doesn't have to hand-write the JSON. Project-scoped only — Claude
|
|
4
|
+
* Code's MCP config has no global/user-level equivalent (confirmed by
|
|
5
|
+
* inspecting a real ~/.claude.json: no top-level `mcpServers` key, it's
|
|
6
|
+
* nested per-project). Never touches ~/.claude.json directly.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors hooks-setup.mjs's already-proven safe-merge pattern: malformed
|
|
9
|
+
* JSON is left untouched (never overwritten), the write is atomic (temp
|
|
10
|
+
* file + rename — real guarantee on POSIX, still the best available
|
|
11
|
+
* cross-platform approach on Windows), and every other key in the file is
|
|
12
|
+
* preserved exactly. Registration itself is never license-gated — same as
|
|
13
|
+
* `ticketlens mcp`/`note add`/`recall`, which all exist for everyone and
|
|
14
|
+
* gate only at actual invocation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
20
|
+
import { isLicensed } from './license.mjs';
|
|
21
|
+
import { createStyler } from './ansi.mjs';
|
|
22
|
+
|
|
23
|
+
const ENTRY_NAME = 'ticketlens';
|
|
24
|
+
|
|
25
|
+
function readConfig(configPath) {
|
|
26
|
+
if (!existsSync(configPath)) return { ok: true, config: {} };
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return { ok: false, reason: `${configPath} is not valid JSON — left untouched. Fix or remove it, then retry.` };
|
|
32
|
+
}
|
|
33
|
+
// Valid JSON whose top level isn't a plain object (array/null/number/string/
|
|
34
|
+
// boolean) is still valid to parse — `typeof null === 'object'` and arrays
|
|
35
|
+
// are objects too, so both need an explicit check, not just "did it parse."
|
|
36
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
37
|
+
return { ok: false, reason: `${configPath}'s top level is not a JSON object — left untouched. Fix or remove it, then retry.` };
|
|
38
|
+
}
|
|
39
|
+
if (parsed.mcpServers !== undefined && (typeof parsed.mcpServers !== 'object' || parsed.mcpServers === null || Array.isArray(parsed.mcpServers))) {
|
|
40
|
+
return { ok: false, reason: `${configPath}'s "mcpServers" key is not an object — left untouched. Fix or remove it, then retry.` };
|
|
41
|
+
}
|
|
42
|
+
return { ok: true, config: parsed };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @returns {{ written: boolean, dryRun: boolean, path: string, alreadyCorrect?: boolean, reason?: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function mcpInstall({
|
|
49
|
+
cwd = process.cwd(),
|
|
50
|
+
isLicensedFn = isLicensed,
|
|
51
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
52
|
+
stream = process.stdout,
|
|
53
|
+
dryRun = false,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const configPath = join(cwd, '.mcp.json');
|
|
56
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
57
|
+
|
|
58
|
+
const read = readConfig(configPath);
|
|
59
|
+
if (!read.ok) {
|
|
60
|
+
stream.write(` ${s.dim('✗')} ${read.reason}\n`);
|
|
61
|
+
return { written: false, dryRun, path: configPath, reason: read.reason };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const config = read.config;
|
|
65
|
+
config.mcpServers ??= {};
|
|
66
|
+
const desired = { command: ENTRY_NAME, args: ['mcp'] };
|
|
67
|
+
const existing = config.mcpServers[ENTRY_NAME];
|
|
68
|
+
const alreadyCorrect = existing !== undefined && JSON.stringify(existing) === JSON.stringify(desired);
|
|
69
|
+
config.mcpServers[ENTRY_NAME] = desired;
|
|
70
|
+
|
|
71
|
+
const serialized = JSON.stringify(config, null, 2) + '\n';
|
|
72
|
+
|
|
73
|
+
if (dryRun) {
|
|
74
|
+
stream.write(` Would write ${configPath}:\n\n${serialized}\n`);
|
|
75
|
+
return { written: false, dryRun: true, path: configPath, alreadyCorrect };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (alreadyCorrect) {
|
|
79
|
+
stream.write(` ${s.dim('✔')} Already registered in ${configPath}\n`);
|
|
80
|
+
} else {
|
|
81
|
+
const tmpPath = `${configPath}.${process.pid}.tmp`;
|
|
82
|
+
writeFileSync(tmpPath, serialized, 'utf8');
|
|
83
|
+
renameSync(tmpPath, configPath); // atomic on POSIX; best available cross-platform approach on Windows
|
|
84
|
+
stream.write(` ${s.brand('✔')} Registered "ticketlens" in ${configPath}\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!isLicensedFn('pro', configDir)) {
|
|
88
|
+
stream.write(` ${s.dim('Note: recall_add/recall_search require a Pro license — see')} ${s.cyan('ticketlens license')}${s.dim('.')}\n`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { written: !alreadyCorrect, dryRun: false, path: configPath, alreadyCorrect };
|
|
92
|
+
}
|
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A pure transport adapter: parses JSON-RPC 2.0 off stdin, translates
|
|
5
5
|
* `tools/call` arguments into the exact args/dependency shape `runNoteAdd`/
|
|
6
|
-
* `runRecall
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* `runRecall`/`runTicketComment`/`runTicketTransitionList`/
|
|
7
|
+
* `runTicketTransition` already accept, and captures their human-readable
|
|
8
|
+
* `stream` output into the JSON-RPC response instead of a real stream. Zero
|
|
9
|
+
* new validation, licensing, or vault logic — every tool funnels through
|
|
10
|
+
* the same functions the CLI's `note add`/`recall`/`comment`/`transition`
|
|
11
|
+
* commands already use, so every existing gate (license, secret scan,
|
|
12
|
+
* structural check, retry queue, cooldown, audit log) applies identically
|
|
13
|
+
* here. Ticket-writing tools must never import an adapter directly — doing
|
|
14
|
+
* so would fully bypass the Pro+ gate that lives in ticket-command.mjs.
|
|
12
15
|
*
|
|
13
16
|
* Per the MCP stdio transport spec, the server MUST NOT write anything to
|
|
14
17
|
* stdout that isn't a valid MCP message — every wrapped function's output
|
|
@@ -20,6 +23,7 @@ import readline from 'node:readline';
|
|
|
20
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
21
24
|
import { runNoteAdd } from './note-command.mjs';
|
|
22
25
|
import { runRecall } from './recall-command.mjs';
|
|
26
|
+
import { runTicketComment, runTicketTransitionList, runTicketTransition } from './ticket-command.mjs';
|
|
23
27
|
|
|
24
28
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
25
29
|
|
|
@@ -49,6 +53,31 @@ const TOOLS = [
|
|
|
49
53
|
required: ['query'],
|
|
50
54
|
},
|
|
51
55
|
},
|
|
56
|
+
{
|
|
57
|
+
name: 'ticket_comment',
|
|
58
|
+
description: 'Post a comment to a ticket in its tracker (Jira/GitHub/Linear). Destructive — writes directly to the live tracker, not a local Recall note. Requires a TicketLens Pro license.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
63
|
+
body: { type: 'string', description: 'Comment body.' },
|
|
64
|
+
},
|
|
65
|
+
required: ['ticket', 'body'],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'ticket_transition',
|
|
70
|
+
description: 'List or execute a ticket status transition in its tracker (Jira/GitHub/Linear). Called with only `ticket`, lists the tracker\'s current valid options without changing anything. Destructive when `target` and `confirm: true` are both given — requires confirmation and writes directly to the live tracker. Requires a TicketLens Pro license.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
75
|
+
target: { type: 'string', description: 'Target status/transition name. Omit to just list the tracker\'s current valid options.' },
|
|
76
|
+
confirm: { type: 'boolean', description: 'Must be true, alongside `target`, to actually execute the transition — a nudge and audit trail, not just a formality.' },
|
|
77
|
+
},
|
|
78
|
+
required: ['ticket'],
|
|
79
|
+
},
|
|
80
|
+
},
|
|
52
81
|
];
|
|
53
82
|
|
|
54
83
|
function jsonRpcResult(id, result) {
|
|
@@ -112,14 +141,59 @@ async function callRecallSearch(args, { configDir, runRecallFn }) {
|
|
|
112
141
|
return ok ? { content } : { isError: true, content };
|
|
113
142
|
}
|
|
114
143
|
|
|
144
|
+
/**
|
|
145
|
+
* `ticket`/`body` become single opaque cmdArgs elements (`--body=${body}`),
|
|
146
|
+
* same reasoning as buildNoteAddArgs above — a body containing literal
|
|
147
|
+
* `--confirm` or `--target=` text stays inert since parseFlag only matches
|
|
148
|
+
* a whole array element via startsWith, never scans inside one.
|
|
149
|
+
*/
|
|
150
|
+
async function callTicketComment(args, { configDir, runTicketCommentFn }) {
|
|
151
|
+
if (!args.ticket) {
|
|
152
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
153
|
+
}
|
|
154
|
+
if (!args.body) {
|
|
155
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
|
|
156
|
+
}
|
|
157
|
+
const capture = capturingStream();
|
|
158
|
+
const { ok } = await runTicketCommentFn([args.ticket, `--body=${args.body}`], { configDir, stream: capture });
|
|
159
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
160
|
+
return ok ? { content } : { isError: true, content };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* No `target` → discovery only, dispatched to the read-only list function —
|
|
165
|
+
* never touches the mutating path. `target` present → dispatched to the
|
|
166
|
+
* executing function, which itself still refuses without `confirm: true`
|
|
167
|
+
* (the MCP layer doesn't pre-empt that check, so the same refusal message
|
|
168
|
+
* a CLI user sees is what a calling AI harness sees too).
|
|
169
|
+
*/
|
|
170
|
+
async function callTicketTransition(args, { configDir, runTicketTransitionListFn, runTicketTransitionFn }) {
|
|
171
|
+
if (!args.ticket) {
|
|
172
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
173
|
+
}
|
|
174
|
+
const capture = capturingStream();
|
|
175
|
+
if (!args.target) {
|
|
176
|
+
const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture });
|
|
177
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
178
|
+
return ok ? { content } : { isError: true, content };
|
|
179
|
+
}
|
|
180
|
+
const cmdArgs = [args.ticket, `--target=${args.target}`];
|
|
181
|
+
if (args.confirm === true) cmdArgs.push('--confirm');
|
|
182
|
+
const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture });
|
|
183
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
184
|
+
return ok ? { content } : { isError: true, content };
|
|
185
|
+
}
|
|
186
|
+
|
|
115
187
|
async function handleToolsCall(params, deps) {
|
|
116
188
|
const { name, arguments: args = {} } = params ?? {};
|
|
117
189
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
118
190
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
191
|
+
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
192
|
+
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
119
193
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
120
194
|
}
|
|
121
195
|
|
|
122
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
|
|
196
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn }) {
|
|
123
197
|
let msg;
|
|
124
198
|
try {
|
|
125
199
|
msg = JSON.parse(raw);
|
|
@@ -149,7 +223,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
|
|
|
149
223
|
|
|
150
224
|
if (method === 'tools/call') {
|
|
151
225
|
try {
|
|
152
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn });
|
|
226
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
|
|
153
227
|
return jsonRpcResult(id, result);
|
|
154
228
|
} catch (err) {
|
|
155
229
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -172,6 +246,9 @@ export function runMcpServer({
|
|
|
172
246
|
stdout = process.stdout,
|
|
173
247
|
runNoteAddFn = runNoteAdd,
|
|
174
248
|
runRecallFn = runRecall,
|
|
249
|
+
runTicketCommentFn = runTicketComment,
|
|
250
|
+
runTicketTransitionListFn = runTicketTransitionList,
|
|
251
|
+
runTicketTransitionFn = runTicketTransition,
|
|
175
252
|
} = {}) {
|
|
176
253
|
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
177
254
|
// process — an unhandled 'error' event on either stream would otherwise
|
|
@@ -191,7 +268,7 @@ export function runMcpServer({
|
|
|
191
268
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
192
269
|
// server would hang on shutdown instead of exiting.
|
|
193
270
|
queue = queue.then(async () => {
|
|
194
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn });
|
|
271
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
|
|
195
272
|
if (response) stdout.write(response);
|
|
196
273
|
}).catch(() => {});
|
|
197
274
|
});
|
|
@@ -19,7 +19,7 @@ export function detectTrackerType(baseUrl) {
|
|
|
19
19
|
* Instantiates the correct tracker adapter for a resolved connection.
|
|
20
20
|
* @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
|
|
21
21
|
* @param {{ fetcher?: Function }} [opts]
|
|
22
|
-
* @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function }}
|
|
22
|
+
* @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function }}
|
|
23
23
|
*/
|
|
24
24
|
export function resolveAdapter(conn, opts = {}) {
|
|
25
25
|
const type = detectTrackerType(conn?.baseUrl);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local debounce guard against accidentally firing the same ticket write
|
|
3
|
+
* twice in quick succession (a flaky agent retry loop, a doubled CLI
|
|
4
|
+
* invocation, a human hitting enter twice). This is not a security boundary
|
|
5
|
+
* and not a notification-style cooldown — just enough of a window to catch a
|
|
6
|
+
* true double-fire without blocking a deliberate follow-up action moments
|
|
7
|
+
* later.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately a separate file from ticket-action-log.mjs (the append-only
|
|
10
|
+
* audit trail): this file is small and read on every write attempt, so it
|
|
11
|
+
* must stay O(1) to check. A shared file would force scanning full history
|
|
12
|
+
* per check, or risk one read-modify-write clobbering the other's data.
|
|
13
|
+
* Same read-modify-write-no-lock tradeoff already accepted by
|
|
14
|
+
* recall-queue.mjs for the same reason — low-frequency, single-user CLI.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
20
|
+
import { writeFileAtomically } from './recall-vault.mjs';
|
|
21
|
+
|
|
22
|
+
const COOLDOWN_FILE = 'ticket-action-cooldown.json';
|
|
23
|
+
|
|
24
|
+
/** Default debounce window: long enough to catch a true double-fire, short enough to never block a deliberate follow-up action. */
|
|
25
|
+
export const DEFAULT_COOLDOWN_MS = 10_000;
|
|
26
|
+
|
|
27
|
+
function cooldownPath(configDir) {
|
|
28
|
+
return path.join(configDir, COOLDOWN_FILE);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readCooldowns(configDir) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(cooldownPath(configDir), 'utf8'));
|
|
34
|
+
return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cooldownKey(ticketKey, action) {
|
|
41
|
+
return `${ticketKey}:${action}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} ticketKey
|
|
46
|
+
* @param {string} action
|
|
47
|
+
* @param {{ configDir?: string, cooldownMs?: number, now?: () => number }} [opts]
|
|
48
|
+
* @returns {{ active: boolean, remainingMs: number }}
|
|
49
|
+
*/
|
|
50
|
+
export function checkCooldown(ticketKey, action, {
|
|
51
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
52
|
+
cooldownMs = DEFAULT_COOLDOWN_MS,
|
|
53
|
+
now = () => Date.now(),
|
|
54
|
+
} = {}) {
|
|
55
|
+
const lastAt = readCooldowns(configDir)[cooldownKey(ticketKey, action)];
|
|
56
|
+
if (!lastAt) return { active: false, remainingMs: 0 };
|
|
57
|
+
|
|
58
|
+
const elapsed = now() - new Date(lastAt).getTime();
|
|
59
|
+
const remainingMs = cooldownMs - elapsed;
|
|
60
|
+
return remainingMs > 0 ? { active: true, remainingMs } : { active: false, remainingMs: 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {string} ticketKey
|
|
65
|
+
* @param {string} action
|
|
66
|
+
* @param {{ configDir?: string, now?: () => number }} [opts]
|
|
67
|
+
*/
|
|
68
|
+
export function recordAction(ticketKey, action, { configDir = DEFAULT_CONFIG_DIR, now = () => Date.now() } = {}) {
|
|
69
|
+
const cooldowns = readCooldowns(configDir);
|
|
70
|
+
cooldowns[cooldownKey(ticketKey, action)] = new Date(now()).toISOString();
|
|
71
|
+
writeFileAtomically(cooldownPath(configDir), JSON.stringify(cooldowns));
|
|
72
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only forensic audit trail for ticket writes (comment/transition).
|
|
3
|
+
* Every entry is JSON-serialized on its own line — comment bodies are
|
|
4
|
+
* attacker/AI-controlled free text, and a raw newline embedded in one could
|
|
5
|
+
* otherwise forge a fake extra log line (same bug class already fixed once
|
|
6
|
+
* in this codebase for brief section filenames). JSON.stringify escapes
|
|
7
|
+
* embedded newlines as \n within the string, so one JSON.parse per line
|
|
8
|
+
* always recovers exactly one real entry.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately a separate file from ticket-action-cooldown.mjs — this one
|
|
11
|
+
* only ever grows and is never read on the hot path of a write attempt, so
|
|
12
|
+
* its O(n) full-file nature doesn't matter to normal command latency.
|
|
13
|
+
*
|
|
14
|
+
* ticketKey is validated by the caller (ticket-command.mjs, via
|
|
15
|
+
* TICKET_KEY_PATTERN) before it ever reaches here — this module re-validates
|
|
16
|
+
* defensively since a log line is a place a malformed key could otherwise do
|
|
17
|
+
* real damage (path traversal has no surface here since the log file path
|
|
18
|
+
* never incorporates ticketKey, but a stray literal newline inside an
|
|
19
|
+
* unvalidated key would reintroduce exactly the forgeable-line risk this
|
|
20
|
+
* file exists to prevent).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
26
|
+
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
27
|
+
|
|
28
|
+
const LOG_FILE = 'ticket-action-log.jsonl';
|
|
29
|
+
|
|
30
|
+
function logPath(configDir) {
|
|
31
|
+
return path.join(configDir, LOG_FILE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{ ticketKey: string, action: 'comment'|'transition', actor: string, tracker: string, detail?: object }} entry
|
|
36
|
+
* @param {{ configDir?: string, now?: () => Date }} [opts]
|
|
37
|
+
*/
|
|
38
|
+
export function logAction({ ticketKey, action, actor, tracker, detail = {} }, {
|
|
39
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
40
|
+
now = () => new Date(),
|
|
41
|
+
} = {}) {
|
|
42
|
+
if (!TICKET_KEY_PATTERN.test(ticketKey)) {
|
|
43
|
+
throw new Error(`Refusing to log malformed ticket key: ${JSON.stringify(ticketKey)}`);
|
|
44
|
+
}
|
|
45
|
+
const line = JSON.stringify({ ticketKey, action, actor, tracker, detail, at: now().toISOString() });
|
|
46
|
+
fs.appendFileSync(logPath(configDir), line + '\n', 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} [configDir]
|
|
51
|
+
* @returns {object[]} parsed entries, oldest first — a corrupt individual
|
|
52
|
+
* line is skipped rather than failing the whole read, since this is an
|
|
53
|
+
* append-only historical record and one bad line must not hide the rest.
|
|
54
|
+
*/
|
|
55
|
+
export function readActionLog(configDir = DEFAULT_CONFIG_DIR) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = fs.readFileSync(logPath(configDir), 'utf8');
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const entries = [];
|
|
63
|
+
for (const line of raw.split('\n')) {
|
|
64
|
+
if (!line.trim()) continue;
|
|
65
|
+
try {
|
|
66
|
+
entries.push(JSON.parse(line));
|
|
67
|
+
} catch {
|
|
68
|
+
// Skip a corrupt line — never let one bad append hide the rest of the trail.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return entries;
|
|
72
|
+
}
|