ticketlens 0.26.0 → 0.30.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 +28 -5
- package/bin/ticketlens.mjs +43 -2
- package/package.json +1 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +130 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +67 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +192 -0
- package/skills/jtb/scripts/lib/cli.mjs +12 -0
- package/skills/jtb/scripts/lib/help.mjs +149 -7
- package/skills/jtb/scripts/lib/jira-client.mjs +183 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +141 -4
- package/skills/jtb/scripts/lib/ticket-command.mjs +400 -7
- package/skills/jtb/scripts/lib/ticket-create-enrichment.mjs +80 -0
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +84 -0
|
@@ -53,11 +53,14 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
53
53
|
` ${s.brand('ticketlens')} recall ${s.dim('<query|TICKET-KEY>')} Search your saved Recall notes ${s.dim('[Pro]')}`,
|
|
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
|
-
` ${s.brand('ticketlens')} mcp Start the MCP stdio server
|
|
56
|
+
` ${s.brand('ticketlens')} mcp Start the MCP stdio server (Recall + ticket writes) ${s.dim('[Pro]')}`,
|
|
57
57
|
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
58
58
|
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
59
59
|
` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
|
|
60
60
|
` ${s.brand('ticketlens')} duplicates ${s.dim('<TICKET-KEY> [--threshold=N]')} Find likely duplicate tickets ${s.dim('[Pro]')}`,
|
|
61
|
+
` ${s.brand('ticketlens')} link ${s.dim('<SOURCE> <TARGET> [--type=... --confirm]')} Link two tickets ${s.dim('[Pro]')}`,
|
|
62
|
+
` ${s.brand('ticketlens')} update ${s.dim('<TICKET-KEY> [--title=... --description=... --priority=... --add-labels=... --remove-labels=...]')} Update fields ${s.dim('[Pro]')}`,
|
|
63
|
+
` ${s.brand('ticketlens')} create ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} Create a new ticket ${s.dim('[Pro]')}`,
|
|
61
64
|
'',
|
|
62
65
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
63
66
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -178,6 +181,7 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
|
178
181
|
` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
|
|
179
182
|
` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
|
|
180
183
|
` ${s.brand('--template')}=${s.dim('SLUG')} Apply a brief template ${s.dim('(full|quick|code-review, or custom [Team])')}`,
|
|
184
|
+
` ${s.brand('--budget')}=${s.dim('N')} Trim brief to fit a token budget ${s.dim('[Pro]')}`,
|
|
181
185
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
182
186
|
'',
|
|
183
187
|
` ${s.bold('EXAMPLES')}`,
|
|
@@ -193,6 +197,32 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
|
193
197
|
stream.write(lines.join('\n') + '\n');
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
export function printHistoryHelp({ stream = process.stdout } = {}) {
|
|
201
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
202
|
+
const lines = [
|
|
203
|
+
'',
|
|
204
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('history')} ${s.dim('TICKET-KEY')} ${s.dim('[Pro]')}`,
|
|
205
|
+
'',
|
|
206
|
+
` Show this ticket's urgency timeline from your local triage history — ${s.dim('[Pro]')}`,
|
|
207
|
+
` every prior triage scan that surfaced it, with the urgency level and reason`,
|
|
208
|
+
` computed at that point in time. Read-only, entirely local — no network call.`,
|
|
209
|
+
'',
|
|
210
|
+
` ${s.bold('ARGUMENTS')}`,
|
|
211
|
+
'',
|
|
212
|
+
` ${s.brand('TICKET-KEY')} The ticket to show history for ${s.dim('(required)')}`,
|
|
213
|
+
'',
|
|
214
|
+
` ${s.bold('OPTIONS')}`,
|
|
215
|
+
'',
|
|
216
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
217
|
+
'',
|
|
218
|
+
` ${s.bold('EXAMPLES')}`,
|
|
219
|
+
'',
|
|
220
|
+
` ${s.dim('$')} ticketlens history PROJ-123`,
|
|
221
|
+
'',
|
|
222
|
+
];
|
|
223
|
+
stream.write(lines.join('\n') + '\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
196
226
|
const ANSI_RE_HELP = /\x1b\[[0-9;]*m/g;
|
|
197
227
|
function padRightVis(str, len) {
|
|
198
228
|
const vis = str.replace(ANSI_RE_HELP, '').length;
|
|
@@ -598,12 +628,16 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
598
628
|
'',
|
|
599
629
|
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
600
630
|
` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
|
|
601
|
-
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')} — for any
|
|
602
|
-
` harness, not just Claude Code. Thin adapter over the same
|
|
603
|
-
` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')} above
|
|
604
|
-
` local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is
|
|
605
|
-
` called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently
|
|
606
|
-
` ${s.cyan('ticket_duplicates')} is read-only
|
|
631
|
+
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')}, ${s.cyan('ticket_update')}, ${s.cyan('ticket_create')} — for any`,
|
|
632
|
+
` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
|
|
633
|
+
` code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')}/${s.cyan('update')}/${s.cyan('create')} above:`,
|
|
634
|
+
` same Pro gate, same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is`,
|
|
635
|
+
` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
|
|
636
|
+
` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
|
|
637
|
+
` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
|
|
638
|
+
` add; ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
|
|
639
|
+
` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
|
|
640
|
+
` tracker, and it fabricates a real item, the highest blast radius of this family.`,
|
|
607
641
|
` Long-running — exits when the client closes stdin.`,
|
|
608
642
|
'',
|
|
609
643
|
` ${s.bold('OPTIONS')}`,
|
|
@@ -730,6 +764,114 @@ export function printDuplicatesHelp({ stream = process.stdout } = {}) {
|
|
|
730
764
|
stream.write(lines.join('\n') + '\n');
|
|
731
765
|
}
|
|
732
766
|
|
|
767
|
+
export function printLinkHelp({ stream = process.stdout } = {}) {
|
|
768
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
769
|
+
const lines = [
|
|
770
|
+
'',
|
|
771
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('link')} ${s.dim('SOURCE-KEY TARGET-KEY [--type="..." --confirm]')} ${s.dim('[Pro]')}`,
|
|
772
|
+
'',
|
|
773
|
+
` Link two tickets in their tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
774
|
+
` Called with just SOURCE and TARGET, lists the tracker's current valid`,
|
|
775
|
+
` link types without changing anything. Add ${s.brand('--type')} and ${s.brand('--confirm')} together to execute.`,
|
|
776
|
+
'',
|
|
777
|
+
` ${s.bold('Direction matters')}: SOURCE "types" TARGET — e.g. \`link A B --type=Duplicate\` means`,
|
|
778
|
+
` A duplicates B, not the other way around.`,
|
|
779
|
+
'',
|
|
780
|
+
` ${s.bold('GitHub is different')}: it has no generic link relationship. Linking on a`,
|
|
781
|
+
` GitHub-tracked ticket CLOSES SOURCE as a duplicate of TARGET — a state`,
|
|
782
|
+
` change, not just a relationship add like Jira/Linear.`,
|
|
783
|
+
'',
|
|
784
|
+
` ${s.bold('OPTIONS')}`,
|
|
785
|
+
'',
|
|
786
|
+
` ${s.brand('--type')}=${s.dim('NAME')} Link type ${s.dim('(from the list, case-insensitive; GitHub only supports "duplicate")')}`,
|
|
787
|
+
` ${s.brand('--confirm')} Required alongside --type to actually execute`,
|
|
788
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
789
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
790
|
+
'',
|
|
791
|
+
` ${s.bold('EXAMPLES')}`,
|
|
792
|
+
'',
|
|
793
|
+
` ${s.dim('$')} ticketlens link PROD-123 PROD-456`,
|
|
794
|
+
` ${s.dim('$')} ticketlens link PROD-123 PROD-456 --type="Duplicate" --confirm`,
|
|
795
|
+
'',
|
|
796
|
+
];
|
|
797
|
+
stream.write(lines.join('\n') + '\n');
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function printUpdateHelp({ stream = process.stdout } = {}) {
|
|
801
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
802
|
+
const lines = [
|
|
803
|
+
'',
|
|
804
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('update')} ${s.dim('TICKET-KEY [--title=...] [--description=...] [--add-labels=a,b] [--remove-labels=c] [--priority=...]')} ${s.dim('[Pro]')}`,
|
|
805
|
+
'',
|
|
806
|
+
` Update a narrow, named field set on a ticket (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
807
|
+
` At least one field is required. Labels are add/remove, never a wholesale`,
|
|
808
|
+
` replace — an unnamed label is left alone, not dropped.`,
|
|
809
|
+
'',
|
|
810
|
+
` ${s.bold('GitHub has no priority field')}: passing ${s.brand('--priority')} against a GitHub-tracked`,
|
|
811
|
+
` ticket is refused up front rather than silently ignored.`,
|
|
812
|
+
'',
|
|
813
|
+
` A write can partially succeed (e.g. title updates but a label doesn't`,
|
|
814
|
+
` resolve) — the result always reports exactly what landed.`,
|
|
815
|
+
'',
|
|
816
|
+
` ${s.bold('OPTIONS')}`,
|
|
817
|
+
'',
|
|
818
|
+
` ${s.brand('--title')}=${s.dim('TEXT')} New title/summary`,
|
|
819
|
+
` ${s.brand('--description')}=${s.dim('TEXT')} New description`,
|
|
820
|
+
` ${s.brand('--add-labels')}=${s.dim('a,b')} Labels to add ${s.dim('(comma-separated)')}`,
|
|
821
|
+
` ${s.brand('--remove-labels')}=${s.dim('c')} Labels to remove ${s.dim('(comma-separated)')}`,
|
|
822
|
+
` ${s.brand('--priority')}=${s.dim('NAME')} New priority ${s.dim('(not supported on GitHub)')}`,
|
|
823
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
824
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
825
|
+
'',
|
|
826
|
+
` ${s.bold('EXAMPLES')}`,
|
|
827
|
+
'',
|
|
828
|
+
` ${s.dim('$')} ticketlens update PROD-123 --title="Fix login on mobile"`,
|
|
829
|
+
` ${s.dim('$')} ticketlens update PROD-123 --add-labels=urgent,backend --remove-labels=stale`,
|
|
830
|
+
` ${s.dim('$')} ticketlens update PROD-123 --priority="High"`,
|
|
831
|
+
'',
|
|
832
|
+
];
|
|
833
|
+
stream.write(lines.join('\n') + '\n');
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export function printCreateHelp({ stream = process.stdout } = {}) {
|
|
837
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
838
|
+
const lines = [
|
|
839
|
+
'',
|
|
840
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('create')} ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} ${s.dim('[Pro]')}`,
|
|
841
|
+
'',
|
|
842
|
+
` Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal`,
|
|
843
|
+
` field set — no arbitrary custom fields. ${s.dim('[Pro]')} Unlike every other ticket-write`,
|
|
844
|
+
` command, there is no existing ticket to operate on — the target tracker is`,
|
|
845
|
+
` picked by ${s.brand('--profile')} (or your default profile), not a ticket key.`,
|
|
846
|
+
'',
|
|
847
|
+
` ${s.bold('--project is tracker-specific')}: the Jira project key or Linear team key.`,
|
|
848
|
+
` Required for Jira/Linear. GitHub ignores it — its target repo is already`,
|
|
849
|
+
` fixed by the profile.`,
|
|
850
|
+
'',
|
|
851
|
+
` ${s.bold('--type is Jira-only')}: the issue type, e.g. "Task" or "Bug". Required for`,
|
|
852
|
+
` Jira. GitHub/Linear have no equivalent concept and ignore it if given.`,
|
|
853
|
+
'',
|
|
854
|
+
` This is the highest-blast-radius command in the ticket-write family — a bad`,
|
|
855
|
+
` ${s.brand('--project')}/${s.brand('--type')} fabricates a real, hard-to-walk-back item in a live tracker.`,
|
|
856
|
+
'',
|
|
857
|
+
` ${s.bold('OPTIONS')}`,
|
|
858
|
+
'',
|
|
859
|
+
` ${s.brand('--project')}=${s.dim('KEY')} Project key (Jira) or team key (Linear) ${s.dim('(required, except GitHub)')}`,
|
|
860
|
+
` ${s.brand('--type')}=${s.dim('NAME')} Issue type ${s.dim('(Jira only, required there)')}`,
|
|
861
|
+
` ${s.brand('--summary')}=${s.dim('TEXT')} Ticket title/summary ${s.dim('(required)')}`,
|
|
862
|
+
` ${s.brand('--description')}=${s.dim('TEXT')} Ticket description`,
|
|
863
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
864
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
865
|
+
'',
|
|
866
|
+
` ${s.bold('EXAMPLES')}`,
|
|
867
|
+
'',
|
|
868
|
+
` ${s.dim('$')} ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile"`,
|
|
869
|
+
` ${s.dim('$')} ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team`,
|
|
870
|
+
'',
|
|
871
|
+
];
|
|
872
|
+
stream.write(lines.join('\n') + '\n');
|
|
873
|
+
}
|
|
874
|
+
|
|
733
875
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
734
876
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
735
877
|
const lines = [
|
|
@@ -502,6 +502,61 @@ export async function postTransition(ticketKey, transitionId, opts = {}) {
|
|
|
502
502
|
}
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
/**
|
|
506
|
+
* Lists this Jira instance's issue link types (id/name/inward/outward).
|
|
507
|
+
* Link type names are per-instance customizable — never hardcode or cache
|
|
508
|
+
* this list; callers must re-fetch fresh before every link, same principle
|
|
509
|
+
* as getTransitions().
|
|
510
|
+
*/
|
|
511
|
+
export async function getIssueLinkTypes(opts = {}) {
|
|
512
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
513
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
514
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
515
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issueLinkType`;
|
|
516
|
+
|
|
517
|
+
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
518
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
519
|
+
|
|
520
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
521
|
+
if (!response.ok) {
|
|
522
|
+
const err = new Error(`Jira API error ${response.status} fetching issue link types`);
|
|
523
|
+
err.status = response.status;
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
const raw = await response.json();
|
|
527
|
+
return (raw.issueLinkTypes ?? []).map(t => ({ id: t.id, name: t.name, inward: t.inward, outward: t.outward }));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Creates a link between two issues. `sourceKey` is the outwardIssue (the
|
|
532
|
+
* subject of the type's outward verb, e.g. "Duplicates"), `targetKey` is
|
|
533
|
+
* the inwardIssue — direction matters and is the caller's responsibility
|
|
534
|
+
* to get right (ticket-command.mjs documents the convention).
|
|
535
|
+
*/
|
|
536
|
+
export async function postIssueLink(sourceKey, targetKey, typeName, opts = {}) {
|
|
537
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
538
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
539
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
540
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issueLink`;
|
|
541
|
+
|
|
542
|
+
const fetchOpts = {
|
|
543
|
+
method: 'POST',
|
|
544
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
545
|
+
body: JSON.stringify({ type: { name: typeName }, outwardIssue: { key: sourceKey }, inwardIssue: { key: targetKey } }),
|
|
546
|
+
};
|
|
547
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
548
|
+
|
|
549
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
550
|
+
if (!response.ok) {
|
|
551
|
+
let details;
|
|
552
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
553
|
+
const err = new Error(`Jira API error ${response.status} linking ${sourceKey} to ${targetKey}`);
|
|
554
|
+
err.status = response.status;
|
|
555
|
+
err.details = details;
|
|
556
|
+
throw err;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
505
560
|
/**
|
|
506
561
|
* Sets the issue's assignee. `assignee` is sent verbatim — the caller
|
|
507
562
|
* (jira-adapter.mjs) resolves the right shape for the API version:
|
|
@@ -532,6 +587,134 @@ export async function assignIssue(ticketKey, assignee, opts = {}) {
|
|
|
532
587
|
}
|
|
533
588
|
}
|
|
534
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Updates a narrow, named field set on an issue. `fields` (summary,
|
|
592
|
+
* description, priority) uses plain SET semantics — the same shape Jira's
|
|
593
|
+
* own GET returns, same convention as assignIssue/postIssueLink. Labels use
|
|
594
|
+
* a separate verb-based `update.labels` array (`{add}`/`{remove}`) — Jira's
|
|
595
|
+
* REST API has no plain-replace shape for multi-value fields, only ADD/SET/
|
|
596
|
+
* REMOVE operations, a genuinely different mechanic from fields. Both keys
|
|
597
|
+
* can coexist in one request as long as a given field only appears in one
|
|
598
|
+
* of them — never both — which callers naturally satisfy since labels only
|
|
599
|
+
* ever go through `update`.
|
|
600
|
+
*/
|
|
601
|
+
export async function updateIssue(ticketKey, { summary, description, priority, addLabels, removeLabels } = {}, opts = {}) {
|
|
602
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
603
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
604
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
605
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}`;
|
|
606
|
+
|
|
607
|
+
const fields = {};
|
|
608
|
+
if (summary !== undefined) fields.summary = summary;
|
|
609
|
+
if (description !== undefined) fields.description = apiVersion === 3 ? textToAdf(description) : description;
|
|
610
|
+
if (priority !== undefined) fields.priority = { name: priority };
|
|
611
|
+
|
|
612
|
+
const labelOps = [
|
|
613
|
+
...(addLabels ?? []).map(label => ({ add: label })),
|
|
614
|
+
...(removeLabels ?? []).map(label => ({ remove: label })),
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
const body = {};
|
|
618
|
+
if (Object.keys(fields).length > 0) body.fields = fields;
|
|
619
|
+
if (labelOps.length > 0) body.update = { labels: labelOps };
|
|
620
|
+
|
|
621
|
+
const fetchOpts = {
|
|
622
|
+
method: 'PUT',
|
|
623
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
624
|
+
body: JSON.stringify(body),
|
|
625
|
+
};
|
|
626
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
627
|
+
|
|
628
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
629
|
+
if (!response.ok) {
|
|
630
|
+
let details;
|
|
631
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
632
|
+
const err = new Error(`Jira API error ${response.status} updating ${ticketKey}`);
|
|
633
|
+
err.status = response.status;
|
|
634
|
+
err.details = details;
|
|
635
|
+
throw err;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Discovers the issue types actually configured for a project — used only
|
|
641
|
+
* to enrich a ticket_create failure message with real, current options,
|
|
642
|
+
* never as a client-side pre-validation step (issuetype validity is still
|
|
643
|
+
* surfaced via the tracker's own 400, same design choice as createIssue
|
|
644
|
+
* itself). A single, unpaginated call: real-world Jira projects rarely
|
|
645
|
+
* exceed a handful to a few dozen issue types (well under the endpoint's
|
|
646
|
+
* own default maxResults=50), and a truncated list here would only ever
|
|
647
|
+
* under-inform an error message, never break the actual write path — full
|
|
648
|
+
* pagination (as fetchProjects needs, since an org's total project count
|
|
649
|
+
* has no such natural ceiling) would be disproportionate complexity for
|
|
650
|
+
* an enrichment-only feature.
|
|
651
|
+
*
|
|
652
|
+
* v2 (Server/DC) and v3 (Cloud) return genuinely different response
|
|
653
|
+
* envelopes for this same resource — confirmed directly against
|
|
654
|
+
* Atlassian's own Cloud OpenAPI spec and a live-downloaded Server/DC WADL,
|
|
655
|
+
* not assumed just because the URL pattern matches: v3 nests results under
|
|
656
|
+
* `issueTypes`, v2 under `values` (same distinction fetchProjects already
|
|
657
|
+
* makes for its own project-listing endpoint, for the same underlying
|
|
658
|
+
* reason — the two API generations were never response-shape-compatible).
|
|
659
|
+
*/
|
|
660
|
+
export async function fetchIssueTypes(projectKey, opts = {}) {
|
|
661
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
662
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
663
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
664
|
+
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
665
|
+
|
|
666
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes`;
|
|
667
|
+
const fetchOpts = { headers };
|
|
668
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
669
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
670
|
+
|
|
671
|
+
if (!response.ok) {
|
|
672
|
+
const err = new Error(`Jira API error ${response.status} fetching issue types for ${projectKey}`);
|
|
673
|
+
err.status = response.status;
|
|
674
|
+
throw err;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const raw = await response.json();
|
|
678
|
+
const values = apiVersion >= 3 ? (raw.issueTypes ?? []) : (raw.values ?? []);
|
|
679
|
+
return values.map(t => ({ id: t.id, name: t.name }));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Creates a new issue. `project`/`type` are passed straight through as
|
|
684
|
+
* Jira's own {key}/{name} references — issue types are project-configurable,
|
|
685
|
+
* so validity is never pre-checked here; an invalid one surfaces Jira's own
|
|
686
|
+
* 400 with details, same design choice already made for updateIssue's
|
|
687
|
+
* priority field. No separate discovery call, no client-side field list.
|
|
688
|
+
*/
|
|
689
|
+
export async function createIssue({ project, type, summary, description } = {}, opts = {}) {
|
|
690
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
691
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
692
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
693
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue`;
|
|
694
|
+
|
|
695
|
+
const fields = { project: { key: project }, issuetype: { name: type }, summary };
|
|
696
|
+
if (description !== undefined) fields.description = apiVersion === 3 ? textToAdf(description) : description;
|
|
697
|
+
|
|
698
|
+
const fetchOpts = {
|
|
699
|
+
method: 'POST',
|
|
700
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
701
|
+
body: JSON.stringify({ fields }),
|
|
702
|
+
};
|
|
703
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
704
|
+
|
|
705
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
706
|
+
if (!response.ok) {
|
|
707
|
+
let details;
|
|
708
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
709
|
+
const err = new Error(`Jira API error ${response.status} creating an issue in ${project}`);
|
|
710
|
+
err.status = response.status;
|
|
711
|
+
err.details = details;
|
|
712
|
+
throw err;
|
|
713
|
+
}
|
|
714
|
+
const raw = await response.json();
|
|
715
|
+
return { key: raw.key, id: raw.id, url: raw.self ?? null };
|
|
716
|
+
}
|
|
717
|
+
|
|
535
718
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
536
719
|
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;
|
|
537
720
|
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
@@ -23,7 +23,7 @@ import readline from 'node:readline';
|
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
24
|
import { runNoteAdd } from './note-command.mjs';
|
|
25
25
|
import { runRecall } from './recall-command.mjs';
|
|
26
|
-
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates } from './ticket-command.mjs';
|
|
26
|
+
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign, runTicketDuplicates, runTicketLinkList, runTicketLink, runTicketUpdate, runTicketCreate } from './ticket-command.mjs';
|
|
27
27
|
|
|
28
28
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
29
|
|
|
@@ -102,6 +102,50 @@ const TOOLS = [
|
|
|
102
102
|
required: ['ticket'],
|
|
103
103
|
},
|
|
104
104
|
},
|
|
105
|
+
{
|
|
106
|
+
name: 'ticket_link',
|
|
107
|
+
description: 'List or execute a link between two tickets in their tracker (Jira/GitHub/Linear). Called with only `ticket`/`target`, lists the tracker\'s current valid link types without changing anything. Destructive when `type` and `confirm: true` are both given — writes directly to the live tracker. Direction matters: `ticket` "types" `target` (e.g. ticket duplicates target). On GitHub, executing CLOSES `ticket` as a duplicate of `target` — a state change, not just a relationship add like Jira/Linear. Requires a TicketLens Pro license.',
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
ticket: { type: 'string', description: 'Source ticket key, e.g. PROJ-123 — the one that "types" target.' },
|
|
112
|
+
target: { type: 'string', description: 'Target ticket key, e.g. PROJ-456.' },
|
|
113
|
+
type: { type: 'string', description: 'Link type name (from the list). Omit to just list the tracker\'s current valid options. GitHub only supports "duplicate".' },
|
|
114
|
+
confirm: { type: 'boolean', description: 'Must be true, alongside `type`, to actually execute the link — a nudge and audit trail, not just a formality.' },
|
|
115
|
+
},
|
|
116
|
+
required: ['ticket', 'target'],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'ticket_update',
|
|
121
|
+
description: 'Update a narrow, named field set on a ticket in its tracker (Jira/GitHub/Linear) — title, description, labels, priority. Labels are add/remove, never a wholesale replace: an unnamed existing label is left alone. No discovery step and no confirm required — these are reversible metadata edits, not workflow-state changes. GitHub has no priority field; passing `priority` for a GitHub-tracked ticket is refused. A call can partially succeed (e.g. title updates but a label does not resolve) — the result reports exactly what landed. Requires a TicketLens Pro license.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
126
|
+
title: { type: 'string', description: 'New title/summary. Omit to leave unchanged.' },
|
|
127
|
+
description: { type: 'string', description: 'New description. Omit to leave unchanged.' },
|
|
128
|
+
addLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to add. Existing labels not named here are left alone.' },
|
|
129
|
+
removeLabels: { type: 'array', items: { type: 'string' }, description: 'Labels to remove.' },
|
|
130
|
+
priority: { type: 'string', description: 'New priority name, e.g. "High". Not supported on GitHub.' },
|
|
131
|
+
},
|
|
132
|
+
required: ['ticket'],
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'ticket_create',
|
|
137
|
+
description: 'Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal field set — no arbitrary custom fields. Architecturally unlike every other ticket-write tool: there is no existing ticket to target, so the target tracker/project is picked by the connection profile rather than a ticket key. `project` is the Jira project key or Linear team key — required for both, ignored on GitHub (its repo is fixed by the profile). `type` is the Jira issue type — required for Jira only, ignored elsewhere. Highest blast radius of the ticket-write family: a bad project/type fabricates a real, hard-to-walk-back item in a live tracker. Requires a TicketLens Pro license.',
|
|
138
|
+
inputSchema: {
|
|
139
|
+
type: 'object',
|
|
140
|
+
properties: {
|
|
141
|
+
project: { type: 'string', description: 'Jira project key or Linear team key. Required for Jira/Linear; ignored on GitHub.' },
|
|
142
|
+
type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
|
|
143
|
+
summary: { type: 'string', description: 'Ticket title/summary.' },
|
|
144
|
+
description: { type: 'string', description: 'Ticket description. Omit for none.' },
|
|
145
|
+
},
|
|
146
|
+
required: ['summary'],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
105
149
|
];
|
|
106
150
|
|
|
107
151
|
function jsonRpcResult(id, result) {
|
|
@@ -233,6 +277,92 @@ async function callTicketDuplicates(args, { configDir, runTicketDuplicatesFn })
|
|
|
233
277
|
return ok ? { content } : { isError: true, content };
|
|
234
278
|
}
|
|
235
279
|
|
|
280
|
+
/**
|
|
281
|
+
* No `type` → discovery only, dispatched to the read-only list function —
|
|
282
|
+
* never touches the mutating path. `type` present → dispatched to the
|
|
283
|
+
* executing function, which itself still refuses without `confirm: true`
|
|
284
|
+
* (the MCP layer doesn't pre-empt that check, same as ticket_transition).
|
|
285
|
+
*/
|
|
286
|
+
async function callTicketLink(args, { configDir, runTicketLinkListFn, runTicketLinkFn }) {
|
|
287
|
+
if (!args.ticket) {
|
|
288
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
289
|
+
}
|
|
290
|
+
if (!args.target) {
|
|
291
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: target' }] };
|
|
292
|
+
}
|
|
293
|
+
const capture = capturingStream();
|
|
294
|
+
if (!args.type) {
|
|
295
|
+
const { ok } = await runTicketLinkListFn([args.ticket, args.target], { configDir, stream: capture });
|
|
296
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
297
|
+
return ok ? { content } : { isError: true, content };
|
|
298
|
+
}
|
|
299
|
+
const cmdArgs = [args.ticket, args.target, `--type=${args.type}`];
|
|
300
|
+
if (args.confirm === true) cmdArgs.push('--confirm');
|
|
301
|
+
const { ok } = await runTicketLinkFn(cmdArgs, { configDir, stream: capture });
|
|
302
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
303
|
+
return ok ? { content } : { isError: true, content };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `title`/`description`/`priority` each become one opaque cmdArgs element,
|
|
308
|
+
* same reasoning as buildNoteAddArgs/callTicketComment above. `addLabels`/
|
|
309
|
+
* `removeLabels` arrive as arrays per the MCP schema and are comma-joined
|
|
310
|
+
* into a single element (same convention buildNoteAddArgs already uses for
|
|
311
|
+
* `tags`) — parsed back apart by ticket-command.mjs's existing split(',')
|
|
312
|
+
* handling, never by re-splitting a string this function builds itself.
|
|
313
|
+
*/
|
|
314
|
+
function buildTicketUpdateArgs(args) {
|
|
315
|
+
const cmdArgs = [args.ticket];
|
|
316
|
+
if (args.title !== undefined) cmdArgs.push(`--title=${args.title}`);
|
|
317
|
+
if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
|
|
318
|
+
if (args.priority !== undefined) cmdArgs.push(`--priority=${args.priority}`);
|
|
319
|
+
if (Array.isArray(args.addLabels) && args.addLabels.length > 0) cmdArgs.push(`--add-labels=${args.addLabels.join(',')}`);
|
|
320
|
+
if (Array.isArray(args.removeLabels) && args.removeLabels.length > 0) cmdArgs.push(`--remove-labels=${args.removeLabels.join(',')}`);
|
|
321
|
+
return cmdArgs;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* No list-then-act split, unlike ticket_transition/ticket_link — update has
|
|
326
|
+
* no discovery step, so there is exactly one dispatch path. Whether at
|
|
327
|
+
* least one field was actually given is runTicketUpdateFn's own concern
|
|
328
|
+
* (same "don't pre-empt the underlying command" principle as ticket_link's
|
|
329
|
+
* --confirm check).
|
|
330
|
+
*/
|
|
331
|
+
async function callTicketUpdate(args, { configDir, runTicketUpdateFn }) {
|
|
332
|
+
if (!args.ticket) {
|
|
333
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
334
|
+
}
|
|
335
|
+
const capture = capturingStream();
|
|
336
|
+
const { ok } = await runTicketUpdateFn(buildTicketUpdateArgs(args), { configDir, stream: capture });
|
|
337
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
338
|
+
return ok ? { content } : { isError: true, content };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* `project`/`type`/`description` each become one opaque cmdArgs element,
|
|
343
|
+
* same reasoning as buildNoteAddArgs/callTicketComment above. Unlike every
|
|
344
|
+
* other ticket-write tool, there is no `ticket` argument — creation has no
|
|
345
|
+
* existing ticket to target.
|
|
346
|
+
*/
|
|
347
|
+
function buildTicketCreateArgs(args) {
|
|
348
|
+
const cmdArgs = [];
|
|
349
|
+
if (args.project !== undefined) cmdArgs.push(`--project=${args.project}`);
|
|
350
|
+
if (args.type !== undefined) cmdArgs.push(`--type=${args.type}`);
|
|
351
|
+
cmdArgs.push(`--summary=${args.summary}`);
|
|
352
|
+
if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
|
|
353
|
+
return cmdArgs;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function callTicketCreate(args, { configDir, runTicketCreateFn }) {
|
|
357
|
+
if (!args.summary) {
|
|
358
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: summary' }] };
|
|
359
|
+
}
|
|
360
|
+
const capture = capturingStream();
|
|
361
|
+
const { ok } = await runTicketCreateFn(buildTicketCreateArgs(args), { configDir, stream: capture });
|
|
362
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
363
|
+
return ok ? { content } : { isError: true, content };
|
|
364
|
+
}
|
|
365
|
+
|
|
236
366
|
async function handleToolsCall(params, deps) {
|
|
237
367
|
const { name, arguments: args = {} } = params ?? {};
|
|
238
368
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
@@ -241,10 +371,13 @@ async function handleToolsCall(params, deps) {
|
|
|
241
371
|
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
242
372
|
if (name === 'ticket_assign') return callTicketAssign(args, deps);
|
|
243
373
|
if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
|
|
374
|
+
if (name === 'ticket_link') return callTicketLink(args, deps);
|
|
375
|
+
if (name === 'ticket_update') return callTicketUpdate(args, deps);
|
|
376
|
+
if (name === 'ticket_create') return callTicketCreate(args, deps);
|
|
244
377
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
245
378
|
}
|
|
246
379
|
|
|
247
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn }) {
|
|
380
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
248
381
|
let msg;
|
|
249
382
|
try {
|
|
250
383
|
msg = JSON.parse(raw);
|
|
@@ -274,7 +407,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
|
|
|
274
407
|
|
|
275
408
|
if (method === 'tools/call') {
|
|
276
409
|
try {
|
|
277
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
|
|
410
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
278
411
|
return jsonRpcResult(id, result);
|
|
279
412
|
} catch (err) {
|
|
280
413
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -302,6 +435,10 @@ export function runMcpServer({
|
|
|
302
435
|
runTicketTransitionFn = runTicketTransition,
|
|
303
436
|
runTicketAssignFn = runTicketAssign,
|
|
304
437
|
runTicketDuplicatesFn = runTicketDuplicates,
|
|
438
|
+
runTicketLinkListFn = runTicketLinkList,
|
|
439
|
+
runTicketLinkFn = runTicketLink,
|
|
440
|
+
runTicketUpdateFn = runTicketUpdate,
|
|
441
|
+
runTicketCreateFn = runTicketCreate,
|
|
305
442
|
} = {}) {
|
|
306
443
|
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
307
444
|
// process — an unhandled 'error' event on either stream would otherwise
|
|
@@ -321,7 +458,7 @@ export function runMcpServer({
|
|
|
321
458
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
322
459
|
// server would hang on shutdown instead of exiting.
|
|
323
460
|
queue = queue.then(async () => {
|
|
324
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn });
|
|
461
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
325
462
|
if (response) stdout.write(response);
|
|
326
463
|
}).catch(() => {});
|
|
327
464
|
});
|