ticketlens 0.27.0 → 0.31.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 +24 -6
- package/bin/ticketlens.mjs +33 -6
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -6
- 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 +12 -0
- package/skills/jtb/scripts/lib/cloud-keys.mjs +25 -0
- package/skills/jtb/scripts/lib/config.mjs +6 -3
- package/skills/jtb/scripts/lib/confirm.mjs +29 -0
- package/skills/jtb/scripts/lib/help.mjs +120 -11
- package/skills/jtb/scripts/lib/jira-client.mjs +128 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +99 -5
- package/skills/jtb/scripts/lib/note-command.mjs +11 -1
- package/skills/jtb/scripts/lib/recall-vault.mjs +2 -2
- package/skills/jtb/scripts/lib/run-collisions.mjs +9 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +3 -3
- 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
|
@@ -42,10 +42,13 @@ export function getPackageMeta() {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Human-readable relative time from an ISO date string.
|
|
47
|
+
* @param {{ now?: () => Date }} [opts] - injectable clock, for deterministic tests only
|
|
48
|
+
*/
|
|
49
|
+
export function timeAgo(dateStr, { now = () => new Date() } = {}) {
|
|
47
50
|
if (!dateStr) return '';
|
|
48
|
-
const diff =
|
|
51
|
+
const diff = now().getTime() - new Date(dateStr).getTime();
|
|
49
52
|
const mins = Math.floor(diff / 60000);
|
|
50
53
|
if (mins < 60) return `${mins}m ago`;
|
|
51
54
|
const hours = Math.floor(mins / 60);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive y/N confirmation gate for destructive, unrecoverable actions
|
|
3
|
+
* (deleting a Recall note, removing a stored cloud-provider key, etc).
|
|
4
|
+
* Enter with no input aborts — an accidental keystroke never destroys data.
|
|
5
|
+
* Non-interactive callers must pass forceYes explicitly (--yes/-y at the CLI).
|
|
6
|
+
*/
|
|
7
|
+
export async function confirmDestructive(action, opts = {}) {
|
|
8
|
+
const { stdin = process.stdin, stream = process.stderr, forceYes = false } = opts;
|
|
9
|
+
|
|
10
|
+
if (forceYes) return true;
|
|
11
|
+
|
|
12
|
+
if (!stdin.isTTY || !stdin.setRawMode) {
|
|
13
|
+
stream.write(' ✖ Non-interactive mode: pass --yes to confirm without a prompt.\n');
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
stream.write(` ${action} — this cannot be restored. Continue? y/N `);
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
stdin.setRawMode(true);
|
|
20
|
+
stdin.resume();
|
|
21
|
+
stdin.once('data', buf => {
|
|
22
|
+
stdin.setRawMode(false);
|
|
23
|
+
stdin.pause();
|
|
24
|
+
const confirmed = buf.toString().toLowerCase() === 'y';
|
|
25
|
+
stream.write(confirmed ? 'y\n' : 'N\n');
|
|
26
|
+
resolve(confirmed);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -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;
|
|
@@ -545,11 +574,12 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
|
|
|
545
574
|
` Code session — not typically invoked by hand. Every note it writes gets the`,
|
|
546
575
|
` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
|
|
547
576
|
'',
|
|
548
|
-
` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY]')} ${s.dim('[Pro]')}`,
|
|
577
|
+
` ${s.bold('ticketlens note delete')} ${s.dim('--id="..." [--ticket=KEY] [--yes]')} ${s.dim('[Pro]')}`,
|
|
549
578
|
'',
|
|
550
579
|
` Removes a note from your local vault. Local only — if this note was already`,
|
|
551
580
|
` pushed to a team, teammates who pulled it keep their copy; deleting it there`,
|
|
552
|
-
` too is a manager action from the Console (Admin > Recall)
|
|
581
|
+
` too is a manager action from the Console (Admin > Recall). In TTY mode,`,
|
|
582
|
+
` prompts for confirmation before deleting. Pass ${s.cyan('--yes')} (or ${s.cyan('-y')}) to skip the prompt.`,
|
|
553
583
|
'',
|
|
554
584
|
];
|
|
555
585
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -599,13 +629,16 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
599
629
|
'',
|
|
600
630
|
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
601
631
|
` 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
|
|
632
|
+
` ${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`,
|
|
633
|
+
` MCP-compatible AI harness, not just Claude Code. Thin adapter over the same`,
|
|
634
|
+
` 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:`,
|
|
635
|
+
` same Pro gate, same local vault/tracker writes, same team sync. ${s.cyan('ticket_transition')} is`,
|
|
636
|
+
` destructive when called with \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')} is currently`,
|
|
637
|
+
` self-assign only; ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the`,
|
|
638
|
+
` source issue as a duplicate — different semantics than Jira/Linear's relationship-only`,
|
|
639
|
+
` add; ${s.cyan('ticket_update')} has no priority field on GitHub and can partially succeed;`,
|
|
640
|
+
` ${s.cyan('ticket_create')} has no ticket key to target — --profile/the default profile picks the`,
|
|
641
|
+
` tracker, and it fabricates a real item, the highest blast radius of this family.`,
|
|
609
642
|
` Long-running — exits when the client closes stdin.`,
|
|
610
643
|
'',
|
|
611
644
|
` ${s.bold('OPTIONS')}`,
|
|
@@ -765,6 +798,81 @@ export function printLinkHelp({ stream = process.stdout } = {}) {
|
|
|
765
798
|
stream.write(lines.join('\n') + '\n');
|
|
766
799
|
}
|
|
767
800
|
|
|
801
|
+
export function printUpdateHelp({ stream = process.stdout } = {}) {
|
|
802
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
803
|
+
const lines = [
|
|
804
|
+
'',
|
|
805
|
+
` ${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]')}`,
|
|
806
|
+
'',
|
|
807
|
+
` Update a narrow, named field set on a ticket (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
808
|
+
` At least one field is required. Labels are add/remove, never a wholesale`,
|
|
809
|
+
` replace — an unnamed label is left alone, not dropped.`,
|
|
810
|
+
'',
|
|
811
|
+
` ${s.bold('GitHub has no priority field')}: passing ${s.brand('--priority')} against a GitHub-tracked`,
|
|
812
|
+
` ticket is refused up front rather than silently ignored.`,
|
|
813
|
+
'',
|
|
814
|
+
` A write can partially succeed (e.g. title updates but a label doesn't`,
|
|
815
|
+
` resolve) — the result always reports exactly what landed.`,
|
|
816
|
+
'',
|
|
817
|
+
` ${s.bold('OPTIONS')}`,
|
|
818
|
+
'',
|
|
819
|
+
` ${s.brand('--title')}=${s.dim('TEXT')} New title/summary`,
|
|
820
|
+
` ${s.brand('--description')}=${s.dim('TEXT')} New description`,
|
|
821
|
+
` ${s.brand('--add-labels')}=${s.dim('a,b')} Labels to add ${s.dim('(comma-separated)')}`,
|
|
822
|
+
` ${s.brand('--remove-labels')}=${s.dim('c')} Labels to remove ${s.dim('(comma-separated)')}`,
|
|
823
|
+
` ${s.brand('--priority')}=${s.dim('NAME')} New priority ${s.dim('(not supported on GitHub)')}`,
|
|
824
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
825
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
826
|
+
'',
|
|
827
|
+
` ${s.bold('EXAMPLES')}`,
|
|
828
|
+
'',
|
|
829
|
+
` ${s.dim('$')} ticketlens update PROD-123 --title="Fix login on mobile"`,
|
|
830
|
+
` ${s.dim('$')} ticketlens update PROD-123 --add-labels=urgent,backend --remove-labels=stale`,
|
|
831
|
+
` ${s.dim('$')} ticketlens update PROD-123 --priority="High"`,
|
|
832
|
+
'',
|
|
833
|
+
];
|
|
834
|
+
stream.write(lines.join('\n') + '\n');
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function printCreateHelp({ stream = process.stdout } = {}) {
|
|
838
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
839
|
+
const lines = [
|
|
840
|
+
'',
|
|
841
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('create')} ${s.dim('--project=... [--type=...] --summary=... [--description=...]')} ${s.dim('[Pro]')}`,
|
|
842
|
+
'',
|
|
843
|
+
` Create a new ticket in a tracker (Jira/GitHub/Linear) with a fixed minimal`,
|
|
844
|
+
` field set — no arbitrary custom fields. ${s.dim('[Pro]')} Unlike every other ticket-write`,
|
|
845
|
+
` command, there is no existing ticket to operate on — the target tracker is`,
|
|
846
|
+
` picked by ${s.brand('--profile')} (or your default profile), not a ticket key.`,
|
|
847
|
+
'',
|
|
848
|
+
` ${s.bold('--project is tracker-specific')}: the Jira project key or Linear team key.`,
|
|
849
|
+
` Required for Jira/Linear. GitHub ignores it — its target repo is already`,
|
|
850
|
+
` fixed by the profile.`,
|
|
851
|
+
'',
|
|
852
|
+
` ${s.bold('--type is Jira-only')}: the issue type, e.g. "Task" or "Bug". Required for`,
|
|
853
|
+
` Jira. GitHub/Linear have no equivalent concept and ignore it if given.`,
|
|
854
|
+
'',
|
|
855
|
+
` This is the highest-blast-radius command in the ticket-write family — a bad`,
|
|
856
|
+
` ${s.brand('--project')}/${s.brand('--type')} fabricates a real, hard-to-walk-back item in a live tracker.`,
|
|
857
|
+
'',
|
|
858
|
+
` ${s.bold('OPTIONS')}`,
|
|
859
|
+
'',
|
|
860
|
+
` ${s.brand('--project')}=${s.dim('KEY')} Project key (Jira) or team key (Linear) ${s.dim('(required, except GitHub)')}`,
|
|
861
|
+
` ${s.brand('--type')}=${s.dim('NAME')} Issue type ${s.dim('(Jira only, required there)')}`,
|
|
862
|
+
` ${s.brand('--summary')}=${s.dim('TEXT')} Ticket title/summary ${s.dim('(required)')}`,
|
|
863
|
+
` ${s.brand('--description')}=${s.dim('TEXT')} Ticket description`,
|
|
864
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
865
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
866
|
+
'',
|
|
867
|
+
` ${s.bold('EXAMPLES')}`,
|
|
868
|
+
'',
|
|
869
|
+
` ${s.dim('$')} ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile"`,
|
|
870
|
+
` ${s.dim('$')} ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team`,
|
|
871
|
+
'',
|
|
872
|
+
];
|
|
873
|
+
stream.write(lines.join('\n') + '\n');
|
|
874
|
+
}
|
|
875
|
+
|
|
768
876
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
769
877
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
770
878
|
const lines = [
|
|
@@ -1156,7 +1264,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
|
|
|
1156
1264
|
'',
|
|
1157
1265
|
` ${s.brand('list')} List configured providers`,
|
|
1158
1266
|
` ${s.brand('add')} ${s.dim('<provider> <key>')} Add or replace an API key`,
|
|
1159
|
-
` ${s.brand('remove')} ${s.dim('<provider>')}
|
|
1267
|
+
` ${s.brand('remove')} ${s.dim('<provider>')} ${s.dim('[--yes]')} Remove a provider's key — prompts for confirmation`,
|
|
1160
1268
|
` ${s.brand('test')} ${s.dim('<provider>')} Send a test request through the provider`,
|
|
1161
1269
|
` ${s.brand('priority')} ${s.dim('<provider> <N>')} Set priority (lower = tried first)`,
|
|
1162
1270
|
` ${s.brand('timeout')} ${s.dim('<provider> <seconds>')} Set per-request timeout`,
|
|
@@ -1164,6 +1272,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
|
|
|
1164
1272
|
` ${s.bold('OPTIONS')}`,
|
|
1165
1273
|
'',
|
|
1166
1274
|
` ${s.brand('--timeout')}=${s.dim('N')} Timeout in seconds when adding a key ${s.dim('(default: 5)')}`,
|
|
1275
|
+
` ${s.brand('--yes')}, ${s.brand('-y')} Skip the confirmation prompt when removing a key`,
|
|
1167
1276
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1168
1277
|
'',
|
|
1169
1278
|
` ${s.bold('PROVIDERS')}`,
|
|
@@ -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
|
|
|
@@ -36,7 +36,7 @@ const TOOLS = [
|
|
|
36
36
|
properties: {
|
|
37
37
|
title: { type: 'string', description: 'Short one-line title.' },
|
|
38
38
|
ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
|
|
39
|
-
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags.' },
|
|
39
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags derived from this note\'s actual content — the specific technology, error type, root cause, or affected component (e.g. "retry-backoff", "null-pointer", "auth-middleware"). Never the project name or a generic category word like "gotcha" or "bug" — those provide no search signal to someone else looking for this note later.' },
|
|
40
40
|
body: { type: 'string', description: 'The note body — one or more paragraphs.' },
|
|
41
41
|
},
|
|
42
42
|
required: ['title', 'body'],
|
|
@@ -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
|
});
|
|
@@ -19,6 +19,7 @@ import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mj
|
|
|
19
19
|
import { extractText } from './attachment-text.mjs';
|
|
20
20
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
21
21
|
import { createStyler } from './ansi.mjs';
|
|
22
|
+
import { confirmDestructive } from './confirm.mjs';
|
|
22
23
|
|
|
23
24
|
function defaultListAttachments(configDir, ticketKey) {
|
|
24
25
|
const cacheDir = path.join(configDir, 'cache', ticketKey);
|
|
@@ -226,8 +227,10 @@ export async function runNotePatch(cmdArgs, {
|
|
|
226
227
|
export async function runNoteDelete(cmdArgs, {
|
|
227
228
|
configDir = DEFAULT_CONFIG_DIR,
|
|
228
229
|
stream = process.stderr,
|
|
230
|
+
stdin = process.stdin,
|
|
229
231
|
isLicensedFn = isLicensed,
|
|
230
232
|
deleteNoteFn = deleteNote,
|
|
233
|
+
confirmFn = confirmDestructive,
|
|
231
234
|
} = {}) {
|
|
232
235
|
if (!isLicensedFn('pro', configDir)) {
|
|
233
236
|
showUpgradePrompt('pro', 'ticketlens note', { stream });
|
|
@@ -236,7 +239,7 @@ export async function runNoteDelete(cmdArgs, {
|
|
|
236
239
|
|
|
237
240
|
const id = parseFlag(cmdArgs, 'id');
|
|
238
241
|
if (!id) {
|
|
239
|
-
stream.write('Usage: ticketlens note delete --id="..." [--ticket=KEY]\n');
|
|
242
|
+
stream.write('Usage: ticketlens note delete --id="..." [--ticket=KEY] [--yes|-y]\n');
|
|
240
243
|
return { deleted: false };
|
|
241
244
|
}
|
|
242
245
|
|
|
@@ -246,6 +249,13 @@ export async function runNoteDelete(cmdArgs, {
|
|
|
246
249
|
return { deleted: false };
|
|
247
250
|
}
|
|
248
251
|
|
|
252
|
+
const forceYes = cmdArgs.includes('--yes') || cmdArgs.includes('-y');
|
|
253
|
+
const confirmed = await confirmFn(`Delete note (${id})`, { stdin, stream, forceYes });
|
|
254
|
+
if (!confirmed) {
|
|
255
|
+
stream.write(' Aborted — note was not deleted.\n');
|
|
256
|
+
return { deleted: false };
|
|
257
|
+
}
|
|
258
|
+
|
|
249
259
|
const { deleted } = deleteNoteFn({ external_id: id, tickets: ticketKey ? [ticketKey] : [] }, { configDir });
|
|
250
260
|
stream.write(deleted
|
|
251
261
|
? ` Deleted note (${id}) — local vault only; see help for team-synced notes.\n`
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { randomBytes } from 'node:crypto';
|
|
17
|
-
import { DEFAULT_CONFIG_DIR, escapeLeadingHeading } from './config.mjs';
|
|
17
|
+
import { DEFAULT_CONFIG_DIR, escapeLeadingHeading, timeAgo } from './config.mjs';
|
|
18
18
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
19
19
|
import { parseFrontmatter, serializeFrontmatter } from './frontmatter.mjs';
|
|
20
20
|
|
|
@@ -348,7 +348,7 @@ export function rebuildIndex(prefix, { configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
|
348
348
|
// content is lower trust than a user's own local notes, so it needs the same
|
|
349
349
|
// heading-injection guard already applied in brief-assembler.mjs/styled-assembler.mjs.
|
|
350
350
|
const ticketList = n.tickets.length > 0 ? ` — ${escapeLeadingHeading(n.tickets.join(', '))}` : '';
|
|
351
|
-
lines.push(`- [[${escapeLeadingHeading(n.title)}]]${ticketList} — ${n.created
|
|
351
|
+
lines.push(`- [[${escapeLeadingHeading(n.title)}]]${ticketList} — ${timeAgo(n.created)}`);
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
writeFileAtomically(indexPath, lines.join('\n') + '\n');
|
|
@@ -71,3 +71,12 @@ export async function runCollisions(args = [], opts = {}) {
|
|
|
71
71
|
return { ok: false };
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
// Run if invoked directly
|
|
76
|
+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, ''));
|
|
77
|
+
if (isMain) {
|
|
78
|
+
runCollisions(process.argv.slice(2)).catch(err => {
|
|
79
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -115,7 +115,7 @@ export function styleRecallResults(digests, opts = {}) {
|
|
|
115
115
|
if (!styled) {
|
|
116
116
|
const entries = digests.map(d => {
|
|
117
117
|
const ticketList = d.tickets?.length > 0 ? ` (${escapeLeadingHeading(d.tickets.join(', '))})` : '';
|
|
118
|
-
const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${d.created
|
|
118
|
+
const summary = `${escapeLeadingHeading(d.title)}${ticketList} — ${timeAgo(d.created)} [${d.id}]`;
|
|
119
119
|
return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
|
|
120
120
|
});
|
|
121
121
|
return entries.join(full ? '\n\n' : '\n');
|
|
@@ -124,9 +124,9 @@ export function styleRecallResults(digests, opts = {}) {
|
|
|
124
124
|
const s = createStyler({ forceColor: true });
|
|
125
125
|
const entries = digests.map(d => {
|
|
126
126
|
const ticketList = d.tickets?.length > 0 ? ` ${s.dim(`(${escapeLeadingHeading(d.tickets.join(', '))})`)}` : '';
|
|
127
|
-
const
|
|
127
|
+
const ago = s.dim(timeAgo(d.created));
|
|
128
128
|
const id = s.dim(`[${d.id}]`);
|
|
129
|
-
const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${
|
|
129
|
+
const summary = `${s.brand('●')} ${s.bold(escapeLeadingHeading(d.title))}${ticketList} ${s.dim('—')} ${ago} ${id}`;
|
|
130
130
|
return full ? `${summary}\n${escapeLeadingHeading(d.body)}` : summary;
|
|
131
131
|
});
|
|
132
132
|
return entries.join(full ? '\n\n' : '\n');
|