ticketlens 0.23.0 → 0.25.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 +32 -4
- package/bin/ticketlens.mjs +41 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +28 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +101 -1
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +54 -5
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +129 -2
- package/skills/jtb/scripts/lib/adf-converter.mjs +19 -0
- package/skills/jtb/scripts/lib/cli.mjs +12 -0
- package/skills/jtb/scripts/lib/help.mjs +84 -5
- package/skills/jtb/scripts/lib/jira-client.mjs +117 -1
- package/skills/jtb/scripts/lib/mcp-server.mjs +113 -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 +290 -0
|
@@ -54,6 +54,9 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
54
54
|
` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
|
|
55
55
|
` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
|
|
56
56
|
` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
|
|
57
|
+
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
58
|
+
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
59
|
+
` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
|
|
57
60
|
'',
|
|
58
61
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
59
62
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -592,11 +595,14 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
592
595
|
'',
|
|
593
596
|
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')} ${s.dim('[Pro]')}`,
|
|
594
597
|
'',
|
|
595
|
-
` Start an MCP (Model Context Protocol) stdio server exposing Recall
|
|
596
|
-
` native tools — ${s.cyan('recall_add')}
|
|
597
|
-
`
|
|
598
|
-
` ${s.cyan('note add')}/${s.cyan('recall')}
|
|
599
|
-
`
|
|
598
|
+
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
599
|
+
` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
|
|
600
|
+
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')} — for any MCP-compatible AI harness, not just`,
|
|
601
|
+
` Claude Code. Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/`,
|
|
602
|
+
` ${s.cyan('transition')}/${s.cyan('assign')} above: same Pro gate, same local vault/tracker writes, same`,
|
|
603
|
+
` team sync. ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true\`;`,
|
|
604
|
+
` ${s.cyan('ticket_assign')} is currently self-assign only.`,
|
|
605
|
+
` Long-running — exits when the client closes stdin.`,
|
|
600
606
|
'',
|
|
601
607
|
` ${s.bold('OPTIONS')}`,
|
|
602
608
|
'',
|
|
@@ -623,6 +629,79 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
623
629
|
stream.write(lines.join('\n') + '\n');
|
|
624
630
|
}
|
|
625
631
|
|
|
632
|
+
export function printCommentHelp({ stream = process.stdout } = {}) {
|
|
633
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
634
|
+
const lines = [
|
|
635
|
+
'',
|
|
636
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('comment')} ${s.dim('TICKET-KEY --body="..."')} ${s.dim('[Pro]')}`,
|
|
637
|
+
'',
|
|
638
|
+
` Post a comment directly to the ticket in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
639
|
+
` Writes to the real tracker — this is not a local Recall note.`,
|
|
640
|
+
'',
|
|
641
|
+
` ${s.bold('OPTIONS')}`,
|
|
642
|
+
'',
|
|
643
|
+
` ${s.brand('--body')}=${s.dim('TEXT')} Comment body ${s.dim('(required)')}`,
|
|
644
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
645
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
646
|
+
'',
|
|
647
|
+
` ${s.bold('EXAMPLES')}`,
|
|
648
|
+
'',
|
|
649
|
+
` ${s.dim('$')} ticketlens comment PROD-123 --body="Looks good, merging."`,
|
|
650
|
+
'',
|
|
651
|
+
];
|
|
652
|
+
stream.write(lines.join('\n') + '\n');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function printTransitionHelp({ stream = process.stdout } = {}) {
|
|
656
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
657
|
+
const lines = [
|
|
658
|
+
'',
|
|
659
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('transition')} ${s.dim('TICKET-KEY [--target="..." --confirm]')} ${s.dim('[Pro]')}`,
|
|
660
|
+
'',
|
|
661
|
+
` Move a ticket to a new status in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
662
|
+
` Called with just a ticket key, lists the tracker's current valid options`,
|
|
663
|
+
` without changing anything. Add ${s.brand('--target')} and ${s.brand('--confirm')} together to execute.`,
|
|
664
|
+
'',
|
|
665
|
+
` ${s.bold('OPTIONS')}`,
|
|
666
|
+
'',
|
|
667
|
+
` ${s.brand('--target')}=${s.dim('NAME')} Target status/transition name ${s.dim('(from the list, case-insensitive)')}`,
|
|
668
|
+
` ${s.brand('--confirm')} Required alongside --target to actually execute`,
|
|
669
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
670
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
671
|
+
'',
|
|
672
|
+
` ${s.bold('EXAMPLES')}`,
|
|
673
|
+
'',
|
|
674
|
+
` ${s.dim('$')} ticketlens transition PROD-123`,
|
|
675
|
+
` ${s.dim('$')} ticketlens transition PROD-123 --target="Done" --confirm`,
|
|
676
|
+
'',
|
|
677
|
+
];
|
|
678
|
+
stream.write(lines.join('\n') + '\n');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function printAssignHelp({ stream = process.stdout } = {}) {
|
|
682
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
683
|
+
const lines = [
|
|
684
|
+
'',
|
|
685
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('assign')} ${s.dim('TICKET-KEY --to=me')} ${s.dim('[Pro]')}`,
|
|
686
|
+
'',
|
|
687
|
+
` Assign a ticket to yourself directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
688
|
+
` Self-assign only for now — ${s.brand('--to')} must be ${s.brand('me')}. Assigning to someone else`,
|
|
689
|
+
` isn't supported yet.`,
|
|
690
|
+
'',
|
|
691
|
+
` ${s.bold('OPTIONS')}`,
|
|
692
|
+
'',
|
|
693
|
+
` ${s.brand('--to')}=${s.dim('me')} Required — only "me" is currently supported`,
|
|
694
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
695
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
696
|
+
'',
|
|
697
|
+
` ${s.bold('EXAMPLES')}`,
|
|
698
|
+
'',
|
|
699
|
+
` ${s.dim('$')} ticketlens assign PROD-123 --to=me`,
|
|
700
|
+
'',
|
|
701
|
+
];
|
|
702
|
+
stream.write(lines.join('\n') + '\n');
|
|
703
|
+
}
|
|
704
|
+
|
|
626
705
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
627
706
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
628
707
|
const lines = [
|
|
@@ -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,122 @@ 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
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Sets the issue's assignee. `assignee` is sent verbatim — the caller
|
|
499
|
+
* (jira-adapter.mjs) resolves the right shape for the API version:
|
|
500
|
+
* `{ accountId }` for Cloud (v3), `{ name }` for Server/DC (v2), since
|
|
501
|
+
* Cloud has no username concept and Server/DC has no accountId.
|
|
502
|
+
*/
|
|
503
|
+
export async function assignIssue(ticketKey, assignee, opts = {}) {
|
|
504
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
505
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
506
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
507
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/assignee`;
|
|
508
|
+
|
|
509
|
+
const fetchOpts = {
|
|
510
|
+
method: 'PUT',
|
|
511
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
512
|
+
body: JSON.stringify(assignee),
|
|
513
|
+
};
|
|
514
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
515
|
+
|
|
516
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
517
|
+
if (!response.ok) {
|
|
518
|
+
let details;
|
|
519
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
520
|
+
const err = new Error(`Jira API error ${response.status} assigning ${ticketKey}`);
|
|
521
|
+
err.status = response.status;
|
|
522
|
+
err.details = details;
|
|
523
|
+
throw err;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
411
527
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
412
528
|
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
529
|
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
@@ -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, runTicketAssign } from './ticket-command.mjs';
|
|
23
27
|
|
|
24
28
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
25
29
|
|
|
@@ -49,6 +53,43 @@ 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
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'ticket_assign',
|
|
83
|
+
description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
88
|
+
to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
|
|
89
|
+
},
|
|
90
|
+
required: ['ticket', 'to'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
52
93
|
];
|
|
53
94
|
|
|
54
95
|
function jsonRpcResult(id, result) {
|
|
@@ -112,14 +153,73 @@ async function callRecallSearch(args, { configDir, runRecallFn }) {
|
|
|
112
153
|
return ok ? { content } : { isError: true, content };
|
|
113
154
|
}
|
|
114
155
|
|
|
156
|
+
/**
|
|
157
|
+
* `ticket`/`body` become single opaque cmdArgs elements (`--body=${body}`),
|
|
158
|
+
* same reasoning as buildNoteAddArgs above — a body containing literal
|
|
159
|
+
* `--confirm` or `--target=` text stays inert since parseFlag only matches
|
|
160
|
+
* a whole array element via startsWith, never scans inside one.
|
|
161
|
+
*/
|
|
162
|
+
async function callTicketComment(args, { configDir, runTicketCommentFn }) {
|
|
163
|
+
if (!args.ticket) {
|
|
164
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
165
|
+
}
|
|
166
|
+
if (!args.body) {
|
|
167
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
|
|
168
|
+
}
|
|
169
|
+
const capture = capturingStream();
|
|
170
|
+
const { ok } = await runTicketCommentFn([args.ticket, `--body=${args.body}`], { configDir, stream: capture });
|
|
171
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
172
|
+
return ok ? { content } : { isError: true, content };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* No `target` → discovery only, dispatched to the read-only list function —
|
|
177
|
+
* never touches the mutating path. `target` present → dispatched to the
|
|
178
|
+
* executing function, which itself still refuses without `confirm: true`
|
|
179
|
+
* (the MCP layer doesn't pre-empt that check, so the same refusal message
|
|
180
|
+
* a CLI user sees is what a calling AI harness sees too).
|
|
181
|
+
*/
|
|
182
|
+
async function callTicketTransition(args, { configDir, runTicketTransitionListFn, runTicketTransitionFn }) {
|
|
183
|
+
if (!args.ticket) {
|
|
184
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
185
|
+
}
|
|
186
|
+
const capture = capturingStream();
|
|
187
|
+
if (!args.target) {
|
|
188
|
+
const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture });
|
|
189
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
190
|
+
return ok ? { content } : { isError: true, content };
|
|
191
|
+
}
|
|
192
|
+
const cmdArgs = [args.ticket, `--target=${args.target}`];
|
|
193
|
+
if (args.confirm === true) cmdArgs.push('--confirm');
|
|
194
|
+
const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture });
|
|
195
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
196
|
+
return ok ? { content } : { isError: true, content };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
|
|
200
|
+
if (!args.ticket) {
|
|
201
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
202
|
+
}
|
|
203
|
+
if (!args.to) {
|
|
204
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: to' }] };
|
|
205
|
+
}
|
|
206
|
+
const capture = capturingStream();
|
|
207
|
+
const { ok } = await runTicketAssignFn([args.ticket, `--to=${args.to}`], { configDir, stream: capture });
|
|
208
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
209
|
+
return ok ? { content } : { isError: true, content };
|
|
210
|
+
}
|
|
211
|
+
|
|
115
212
|
async function handleToolsCall(params, deps) {
|
|
116
213
|
const { name, arguments: args = {} } = params ?? {};
|
|
117
214
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
118
215
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
216
|
+
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
217
|
+
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
218
|
+
if (name === 'ticket_assign') return callTicketAssign(args, deps);
|
|
119
219
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
120
220
|
}
|
|
121
221
|
|
|
122
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
|
|
222
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn }) {
|
|
123
223
|
let msg;
|
|
124
224
|
try {
|
|
125
225
|
msg = JSON.parse(raw);
|
|
@@ -149,7 +249,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
|
|
|
149
249
|
|
|
150
250
|
if (method === 'tools/call') {
|
|
151
251
|
try {
|
|
152
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn });
|
|
252
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
|
|
153
253
|
return jsonRpcResult(id, result);
|
|
154
254
|
} catch (err) {
|
|
155
255
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -172,6 +272,10 @@ export function runMcpServer({
|
|
|
172
272
|
stdout = process.stdout,
|
|
173
273
|
runNoteAddFn = runNoteAdd,
|
|
174
274
|
runRecallFn = runRecall,
|
|
275
|
+
runTicketCommentFn = runTicketComment,
|
|
276
|
+
runTicketTransitionListFn = runTicketTransitionList,
|
|
277
|
+
runTicketTransitionFn = runTicketTransition,
|
|
278
|
+
runTicketAssignFn = runTicketAssign,
|
|
175
279
|
} = {}) {
|
|
176
280
|
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
177
281
|
// process — an unhandled 'error' event on either stream would otherwise
|
|
@@ -191,7 +295,7 @@ export function runMcpServer({
|
|
|
191
295
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
192
296
|
// server would hang on shutdown instead of exiting.
|
|
193
297
|
queue = queue.then(async () => {
|
|
194
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn });
|
|
298
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
|
|
195
299
|
if (response) stdout.write(response);
|
|
196
300
|
}).catch(() => {});
|
|
197
301
|
});
|
|
@@ -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, assignToSelf: 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
|
+
}
|