ticketlens 0.27.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 +21 -5
- package/bin/ticketlens.mjs +27 -2
- package/package.json +1 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +98 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +40 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +151 -0
- package/skills/jtb/scripts/lib/api-utils.mjs +1 -1
- package/skills/jtb/scripts/lib/cli.mjs +8 -0
- package/skills/jtb/scripts/lib/help.mjs +115 -8
- package/skills/jtb/scripts/lib/jira-client.mjs +128 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +98 -4
- package/skills/jtb/scripts/lib/ticket-command.mjs +257 -1
- package/skills/jtb/scripts/lib/ticket-create-enrichment.mjs +80 -0
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +84 -0
|
@@ -53,12 +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
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]')}`,
|
|
62
64
|
'',
|
|
63
65
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
64
66
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -179,6 +181,7 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
|
179
181
|
` ${s.brand('--cloud')} Route AI request through TicketLens API ${s.dim('[Pro]')}`,
|
|
180
182
|
` ${s.brand('--provider')}=${s.dim('NAME')} Force AI provider ${s.dim('(anthropic|openai|groq)')}`,
|
|
181
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]')}`,
|
|
182
185
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
183
186
|
'',
|
|
184
187
|
` ${s.bold('EXAMPLES')}`,
|
|
@@ -194,6 +197,32 @@ export function printFetchHelp({ stream = process.stdout } = {}) {
|
|
|
194
197
|
stream.write(lines.join('\n') + '\n');
|
|
195
198
|
}
|
|
196
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
|
+
|
|
197
226
|
const ANSI_RE_HELP = /\x1b\[[0-9;]*m/g;
|
|
198
227
|
function padRightVis(str, len) {
|
|
199
228
|
const vis = str.replace(ANSI_RE_HELP, '').length;
|
|
@@ -599,13 +628,16 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
599
628
|
'',
|
|
600
629
|
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
601
630
|
` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
|
|
602
|
-
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')}, ${s.cyan('ticket_duplicates')}, ${s.cyan('ticket_link')} — for any
|
|
603
|
-
` AI harness, not just Claude Code. Thin adapter over the same
|
|
604
|
-
` ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}/${s.cyan('assign')}/${s.cyan('duplicates')}/${s.cyan('link')} above
|
|
605
|
-
` same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is
|
|
606
|
-
` when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently
|
|
607
|
-
` only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the
|
|
608
|
-
` issue as a duplicate — different semantics than Jira/Linear's relationship-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.`,
|
|
609
641
|
` Long-running — exits when the client closes stdin.`,
|
|
610
642
|
'',
|
|
611
643
|
` ${s.bold('OPTIONS')}`,
|
|
@@ -765,6 +797,81 @@ export function printLinkHelp({ stream = process.stdout } = {}) {
|
|
|
765
797
|
stream.write(lines.join('\n') + '\n');
|
|
766
798
|
}
|
|
767
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
|
+
|
|
768
875
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
769
876
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
770
877
|
const lines = [
|
|
@@ -587,6 +587,134 @@ export async function assignIssue(ticketKey, assignee, opts = {}) {
|
|
|
587
587
|
}
|
|
588
588
|
}
|
|
589
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
|
+
|
|
590
718
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
591
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;
|
|
592
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, runTicketLinkList, runTicketLink } 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
|
|
|
@@ -116,6 +116,36 @@ const TOOLS = [
|
|
|
116
116
|
required: ['ticket', 'target'],
|
|
117
117
|
},
|
|
118
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
|
+
},
|
|
119
149
|
];
|
|
120
150
|
|
|
121
151
|
function jsonRpcResult(id, result) {
|
|
@@ -273,6 +303,66 @@ async function callTicketLink(args, { configDir, runTicketLinkListFn, runTicketL
|
|
|
273
303
|
return ok ? { content } : { isError: true, content };
|
|
274
304
|
}
|
|
275
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
|
+
|
|
276
366
|
async function handleToolsCall(params, deps) {
|
|
277
367
|
const { name, arguments: args = {} } = params ?? {};
|
|
278
368
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
@@ -282,10 +372,12 @@ async function handleToolsCall(params, deps) {
|
|
|
282
372
|
if (name === 'ticket_assign') return callTicketAssign(args, deps);
|
|
283
373
|
if (name === 'ticket_duplicates') return callTicketDuplicates(args, deps);
|
|
284
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);
|
|
285
377
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
286
378
|
}
|
|
287
379
|
|
|
288
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn }) {
|
|
380
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn }) {
|
|
289
381
|
let msg;
|
|
290
382
|
try {
|
|
291
383
|
msg = JSON.parse(raw);
|
|
@@ -315,7 +407,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
|
|
|
315
407
|
|
|
316
408
|
if (method === 'tools/call') {
|
|
317
409
|
try {
|
|
318
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
|
|
410
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
319
411
|
return jsonRpcResult(id, result);
|
|
320
412
|
} catch (err) {
|
|
321
413
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -345,6 +437,8 @@ export function runMcpServer({
|
|
|
345
437
|
runTicketDuplicatesFn = runTicketDuplicates,
|
|
346
438
|
runTicketLinkListFn = runTicketLinkList,
|
|
347
439
|
runTicketLinkFn = runTicketLink,
|
|
440
|
+
runTicketUpdateFn = runTicketUpdate,
|
|
441
|
+
runTicketCreateFn = runTicketCreate,
|
|
348
442
|
} = {}) {
|
|
349
443
|
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
350
444
|
// process — an unhandled 'error' event on either stream would otherwise
|
|
@@ -364,7 +458,7 @@ export function runMcpServer({
|
|
|
364
458
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
365
459
|
// server would hang on shutdown instead of exiting.
|
|
366
460
|
queue = queue.then(async () => {
|
|
367
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn });
|
|
461
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn, runTicketDuplicatesFn, runTicketLinkListFn, runTicketLinkFn, runTicketUpdateFn, runTicketCreateFn });
|
|
368
462
|
if (response) stdout.write(response);
|
|
369
463
|
}).catch(() => {});
|
|
370
464
|
});
|