ticketlens 0.30.0 → 0.32.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 +3 -1
- package/bin/ticketlens.mjs +6 -4
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -6
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +52 -0
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +74 -0
- package/skills/jtb/scripts/lib/adf-converter.mjs +26 -0
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +1 -1
- package/skills/jtb/scripts/lib/attachment-uploader.mjs +96 -0
- package/skills/jtb/scripts/lib/cli.mjs +4 -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 +16 -7
- package/skills/jtb/scripts/lib/jira-attachment-client.mjs +80 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +10 -3
- package/skills/jtb/scripts/lib/mcp-server.mjs +7 -2
- 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 +93 -6
package/README.md
CHANGED
|
@@ -413,7 +413,9 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
413
413
|
|
|
414
414
|
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` as native tools — any MCP-compatible AI assistant, not just Claude Code, can call them directly instead of constructing a shell command. It's a thin adapter over the exact same code as the CLI commands above — same Pro gate, same secret scan/local vault/tracker writes, same team sync — nothing is reimplemented. Point your harness's MCP config at it: `{ "command": "ticketlens", "args": ["mcp"] }` — or run `ticketlens mcp install` in a project to write that entry into its `.mcp.json` for you (creates the file if it doesn't exist, merges in if it does — never touches any other entry already there; `--dry-run` to preview first).
|
|
415
415
|
|
|
416
|
-
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead.
|
|
416
|
+
`note add`'s save confirmation and `recall`'s search results are styled by default in a terminal; add `--plain` to either for bare, pipe-safe output. `recall` always shows each note's file ID (e.g. `[1784135399545-fe01c4.md]`) so you can open it directly (`cat ~/.ticketlens/recall/<PREFIX>/<id>`), or pass `--full` to print the full body content inline instead. Each result shows a relative time (`2h ago`, `3d ago`) rather than a bare date — the full-precision timestamp is always in the note file's own frontmatter.
|
|
417
|
+
|
|
418
|
+
**Tags matter for search relevance.** `--tags=a,b` accepts anything, but a generic tag (the project name, "gotcha", "bug") gives future search almost nothing to match on. Tag with what the note is actually *about* — the specific technology, error type, or root cause (`retry-backoff`, `null-pointer`, `auth-middleware`) — so it surfaces when someone else hits the same problem.
|
|
417
419
|
|
|
418
420
|
**Gaps** — every `ticketlens PROJ-123` brief also diffs the ticket's own description against its linked tickets (from the depth traversal you already requested) and its own downloaded attachments, looking for requirements mentioned there but missing here. Anything uncovered shows up under a `## Gaps` section, citing exactly where it came from — a linked ticket key or an attachment filename — as evidence, never an instruction to act on. Nothing is saved anywhere; it's recomputed fresh on every fetch. Requires a Pro license, same as Recall. No network call beyond what the brief already made.
|
|
419
421
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -542,7 +542,7 @@ switch (command) {
|
|
|
542
542
|
case 'cloud-keys': {
|
|
543
543
|
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCloudKeysHelp(); break; }
|
|
544
544
|
|
|
545
|
-
const { listCloudKeys, addCloudKey,
|
|
545
|
+
const { listCloudKeys, addCloudKey, runCloudKeysRemove, setPriority, setTimeout_, testCloudKey } =
|
|
546
546
|
await import('../skills/jtb/scripts/lib/cloud-keys.mjs');
|
|
547
547
|
|
|
548
548
|
const cliToken = readCliToken();
|
|
@@ -592,12 +592,14 @@ switch (command) {
|
|
|
592
592
|
if (subCmd === 'remove') {
|
|
593
593
|
const provider = cmdArgs[1];
|
|
594
594
|
if (!provider) {
|
|
595
|
-
process.stderr.write('Usage: ticketlens cloud-keys remove <provider
|
|
595
|
+
process.stderr.write('Usage: ticketlens cloud-keys remove <provider> [--yes|-y]\n');
|
|
596
596
|
process.exitCode = 1;
|
|
597
597
|
return;
|
|
598
598
|
}
|
|
599
|
-
|
|
600
|
-
|
|
599
|
+
const forceYes = cmdArgs.includes('--yes') || cmdArgs.includes('-y');
|
|
600
|
+
const { removed } = await runCloudKeysRemove(cfg, provider, { forceYes });
|
|
601
|
+
if (removed) process.stdout.write(`${s.brand('✓')} ${provider} key removed.\n`);
|
|
602
|
+
else process.exitCode = 1;
|
|
601
603
|
return;
|
|
602
604
|
}
|
|
603
605
|
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.27.0 -->
|
|
2
2
|
---
|
|
3
3
|
name: jtb
|
|
4
4
|
description: Fetch a Jira ticket's full context (description, comments, linked issues, code references) and assemble a structured TicketBrief for implementation planning. Use when user types /jtb, mentions a Jira ticket key, or wants to plan work from a Jira ticket.
|
|
@@ -66,6 +66,8 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
66
66
|
/jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
**Destructive commands** (`note delete`, `cloud-keys remove`, `delete <profile>`) prompt for interactive y/N confirmation and refuse outright in a non-interactive shell unless `--yes` is passed — there is no way to silently skip this. Only pass `--yes` when the user has explicitly asked for that specific deletion in this conversation (their message *is* the confirmation); never add it to route around the prompt for a deletion you decided to make on your own.
|
|
70
|
+
|
|
69
71
|
## Prerequisites
|
|
70
72
|
|
|
71
73
|
TicketLens supports two connection methods — check in this order:
|
|
@@ -91,7 +93,7 @@ If the first argument is `triage`:
|
|
|
91
93
|
|
|
92
94
|
Run:
|
|
93
95
|
```bash
|
|
94
|
-
|
|
96
|
+
ticketlens triage $EXTRA_ARGS
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
Where `$EXTRA_ARGS` are any flags passed (e.g. `--stale=3 --status=QA --profile=acme`).
|
|
@@ -112,7 +114,7 @@ If the first argument is `collisions`:
|
|
|
112
114
|
|
|
113
115
|
Run:
|
|
114
116
|
```bash
|
|
115
|
-
|
|
117
|
+
ticketlens collisions $EXTRA_ARGS
|
|
116
118
|
```
|
|
117
119
|
|
|
118
120
|
Where `$EXTRA_ARGS` are any flags passed (e.g. `--json`, `--plain`).
|
|
@@ -136,7 +138,7 @@ Follow the Prerequisites section above:
|
|
|
136
138
|
|
|
137
139
|
Run:
|
|
138
140
|
```bash
|
|
139
|
-
|
|
141
|
+
ticketlens "$TICKET_KEY" $EXTRA_ARGS
|
|
140
142
|
```
|
|
141
143
|
|
|
142
144
|
Where `$TICKET_KEY` is the first argument (e.g. `PROD-1234`) and `$EXTRA_ARGS` are any flags passed (e.g. `--depth=0`).
|
|
@@ -234,9 +236,11 @@ echo "The body text of the note, one or more paragraphs." | \
|
|
|
234
236
|
ticketlens note add --title="Short title" --ticket=TICKET-KEY --tags=a,b
|
|
235
237
|
```
|
|
236
238
|
|
|
239
|
+
**Choosing tags:** derive them 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`. A tag like `jtb` or `ticketlens` tells a future search nothing that the ticket/project context doesn't already say; a tag like `retry-backoff` is what actually surfaces this note when someone else hits the same problem. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
|
|
240
|
+
|
|
237
241
|
To search saved notes directly (outside of automatic brief injection): `ticketlens recall "<query>"`.
|
|
238
242
|
|
|
239
|
-
**Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (
|
|
243
|
+
**Pick exactly one path per capture — never both.** If this harness has TicketLens's MCP server configured (tools named `recall_add`/`recall_search` — often shown as `mcp__ticketlens__recall_add` — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same secret scan, same vault, same team sync, just no shell command to construct. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list. If they're absent because this project has never registered the server, tell the user once: `ticketlens mcp install` writes (or merges into) this project's `.mcp.json` — don't run it yourself unprompted, since it changes what your harness auto-connects to on next launch, and the user should be the one deciding that. Calling both for the same insight creates two near-duplicate notes (no dedup exists between the two paths) and, with team sync on, two separate pushes for a manager to review.
|
|
240
244
|
|
|
241
245
|
### Quality loop (Pro, in-session only)
|
|
242
246
|
|
|
@@ -284,7 +288,7 @@ ticketlens duplicates PROD-1234 # find likely dupl
|
|
|
284
288
|
|
|
285
289
|
The three write actions (comment/transition/assign) have a short local debounce (10s) against an accidental double-fire, and every write is appended to a local audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — surface the failure to the user rather than silently re-attempting, since a ticket write isn't naturally idempotent the way a Recall note save is. `duplicates` has neither, since nothing is written.
|
|
286
290
|
|
|
287
|
-
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (
|
|
291
|
+
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (tools named `ticket_comment`/`ticket_transition`/`ticket_assign`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` — often shown as `mcp__ticketlens__ticket_comment` etc. — visible in your tool list), **use those tools, not the bash commands above** — same license gate, same cooldown, same audit log. Only fall back to the bash form when the MCP tools are genuinely absent from your tool list; if that's because this project has never registered the server, see the `ticketlens mcp install` note above (Recall section) — same guidance applies here.
|
|
288
292
|
|
|
289
293
|
Requires a Pro license — on Free, all four no-op with an upgrade hint on stderr.
|
|
290
294
|
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue } from '../jira-client.mjs';
|
|
2
|
+
import { uploadAttachment, resolveMediaId } from '../jira-attachment-client.mjs';
|
|
3
|
+
import { readAttachments } from '../attachment-uploader.mjs';
|
|
4
|
+
import { buildMediaNode } from '../adf-converter.mjs';
|
|
2
5
|
import { buildJiraEnv } from '../config.mjs';
|
|
3
6
|
|
|
4
7
|
/**
|
|
@@ -149,5 +152,54 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
149
152
|
|
|
150
153
|
/** Real, currently-configured issue types for one project. */
|
|
151
154
|
listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Best-effort, per-file: one bad path or one failed upload never blocks
|
|
158
|
+
* the rest (same `{applied/uploaded, errors}` shape convention as
|
|
159
|
+
* updateFields' GitHub label loop). Two different inline-thumbnail
|
|
160
|
+
* mechanisms per apiVersion, both real:
|
|
161
|
+
* - Server/DC (v2, plain-string bodies): legacy wiki markup
|
|
162
|
+
* `!filename|thumbnail!`, resolved by filename — returned as
|
|
163
|
+
* `inlineMarkup`, a plain string the caller appends to body text.
|
|
164
|
+
* - Cloud (v3, ADF): a real `mediaSingle`/`media` ADF node — returned
|
|
165
|
+
* as `adfMediaNode`, an object the caller threads through
|
|
166
|
+
* `postComment`'s `extraAdfNodes`. Requires one extra call
|
|
167
|
+
* (`resolveMediaId`) to resolve the Media Services UUID; if that
|
|
168
|
+
* fails, `adfMediaNode` stays null — the classic attachment above
|
|
169
|
+
* already succeeded and is genuinely visible on the issue either
|
|
170
|
+
* way, so this failure is swallowed, not surfaced as an error.
|
|
171
|
+
* Both are image-only; non-image files get neither.
|
|
172
|
+
*/
|
|
173
|
+
async attachFiles(key, filePaths, opts = {}) {
|
|
174
|
+
const { files, droppedCount } = readAttachments(filePaths);
|
|
175
|
+
const uploaded = [];
|
|
176
|
+
const errors = [];
|
|
177
|
+
for (const f of files) {
|
|
178
|
+
if (!f.ok) {
|
|
179
|
+
errors.push({ path: f.path, message: f.error });
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const result = await uploadAttachment(key, f, { ...base, ...opts });
|
|
184
|
+
const isImage = f.mimeType.startsWith('image/');
|
|
185
|
+
let inlineMarkup = null;
|
|
186
|
+
let adfMediaNode = null;
|
|
187
|
+
if (isImage && apiVersion === 2) {
|
|
188
|
+
inlineMarkup = `!${result.filename}|thumbnail!`;
|
|
189
|
+
} else if (isImage && apiVersion === 3 && result.url) {
|
|
190
|
+
try {
|
|
191
|
+
const mediaId = await resolveMediaId(result.url, { ...base, ...opts });
|
|
192
|
+
adfMediaNode = buildMediaNode(mediaId, key);
|
|
193
|
+
} catch {
|
|
194
|
+
// Enhancement only — see doc comment above.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
uploaded.push({ filename: result.filename, size: result.size, url: result.url, inlineMarkup, adfMediaNode });
|
|
198
|
+
} catch (err) {
|
|
199
|
+
errors.push({ path: f.path, message: err.message });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { uploaded, errors, droppedCount };
|
|
203
|
+
},
|
|
152
204
|
};
|
|
153
205
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { tokenize } from '../duplicate-scorer.mjs';
|
|
2
|
+
import { readAttachments } from '../attachment-uploader.mjs';
|
|
3
|
+
import { isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from '../jira-client.mjs';
|
|
2
4
|
|
|
3
5
|
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
4
6
|
|
|
@@ -487,5 +489,77 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
487
489
|
);
|
|
488
490
|
return (data.teams?.nodes ?? []).map(t => ({ key: t.key, name: t.name }));
|
|
489
491
|
},
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Linear's fileUpload mutation is workspace-scoped, not issue-scoped —
|
|
495
|
+
* unlike Jira, there is no issue key involved in the upload itself, so
|
|
496
|
+
* this works identically whether the target issue already exists
|
|
497
|
+
* (comment) or was just created (create). Two-step, both officially
|
|
498
|
+
* documented: request a signed PUT URL, then PUT the bytes directly to
|
|
499
|
+
* it. Linear renders any Markdown image URL inline automatically — no
|
|
500
|
+
* separate node-graph system the way Jira's ADF has, so this is the one
|
|
501
|
+
* tracker in this family with a fully working, gap-free thumbnail path.
|
|
502
|
+
* Best-effort per file, same `{uploaded, errors}` shape as Jira's
|
|
503
|
+
* attachFiles.
|
|
504
|
+
*/
|
|
505
|
+
async attachFiles(key, filePaths, opts = {}) {
|
|
506
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 30_000);
|
|
507
|
+
const { lookup = defaultLookupFor(fetcher), allowPrivateIp = false } = opts;
|
|
508
|
+
const { files, droppedCount } = readAttachments(filePaths);
|
|
509
|
+
const uploaded = [];
|
|
510
|
+
const errors = [];
|
|
511
|
+
for (const f of files) {
|
|
512
|
+
if (!f.ok) {
|
|
513
|
+
errors.push({ path: f.path, message: f.error });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const data = await gql(
|
|
518
|
+
`mutation ($contentType: String!, $filename: String!, $size: Int!) {
|
|
519
|
+
fileUpload(contentType: $contentType, filename: $filename, size: $size) {
|
|
520
|
+
success
|
|
521
|
+
uploadFile { uploadUrl assetUrl headers { key value } }
|
|
522
|
+
}
|
|
523
|
+
}`,
|
|
524
|
+
{ contentType: f.mimeType, filename: f.filename, size: f.size },
|
|
525
|
+
{ token, fetcher, signal },
|
|
526
|
+
);
|
|
527
|
+
const target = data.fileUpload?.uploadFile;
|
|
528
|
+
if (!data.fileUpload?.success || !target) {
|
|
529
|
+
errors.push({ path: f.path, message: 'Linear fileUpload did not return an upload target' });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
if (!isSafeRedirectUrl(target.uploadUrl)) {
|
|
533
|
+
errors.push({ path: f.path, message: 'refusing an unsafe upload URL returned by Linear (non-HTTPS or a private/internal host)' });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
// DNS-rebinding guard, same as every other server-supplied URL
|
|
537
|
+
// this codebase connects to (see jira-client.mjs's guardedFetch) —
|
|
538
|
+
// isSafeRedirectUrl above only checks the hostname string; this
|
|
539
|
+
// resolves it. redirect:'manual' + the explicit 3xx refusal below
|
|
540
|
+
// mirrors guardedFetch's "never follow a redirect on a write" rule.
|
|
541
|
+
await validateResolvedHost(new URL(target.uploadUrl).hostname, lookup, allowPrivateIp);
|
|
542
|
+
const putHeaders = Object.fromEntries((target.headers ?? []).map(h => [h.key, h.value]));
|
|
543
|
+
const putRes = await fetcher(target.uploadUrl, { method: 'PUT', headers: putHeaders, body: f.buffer, signal, redirect: 'manual' });
|
|
544
|
+
if (putRes.status >= 300 && putRes.status < 400) {
|
|
545
|
+
errors.push({ path: f.path, message: `upload PUT redirected unexpectedly (status ${putRes.status}) — refusing to follow` });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (!putRes.ok) {
|
|
549
|
+
errors.push({ path: f.path, message: `upload PUT failed with ${putRes.status}` });
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
uploaded.push({
|
|
553
|
+
filename: f.filename,
|
|
554
|
+
size: f.size,
|
|
555
|
+
url: target.assetUrl,
|
|
556
|
+
inlineMarkup: f.mimeType.startsWith('image/') ? `` : `[${f.filename}](${target.assetUrl})`,
|
|
557
|
+
});
|
|
558
|
+
} catch (err) {
|
|
559
|
+
errors.push({ path: f.path, message: err.message });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return { uploaded, errors, droppedCount };
|
|
563
|
+
},
|
|
490
564
|
};
|
|
491
565
|
}
|
|
@@ -23,6 +23,32 @@ export function textToAdf(text) {
|
|
|
23
23
|
};
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Builds an ADF mediaSingle+media node embedding an already-uploaded Jira
|
|
28
|
+
* attachment as a real inline image. `collection` does NOT need to be
|
|
29
|
+
* Jira's actual internal Media Services collection — real-instance
|
|
30
|
+
* verification against a live Jira Cloud site confirmed the image renders
|
|
31
|
+
* correctly regardless of the collection value given (including the
|
|
32
|
+
* ticket key, used here as a stable value requiring no extra lookup);
|
|
33
|
+
* the content-scoped access token embedded when resolving `id` is what
|
|
34
|
+
* actually grants read access, not this field.
|
|
35
|
+
*/
|
|
36
|
+
export function buildMediaNode(mediaId, collection) {
|
|
37
|
+
return {
|
|
38
|
+
type: 'mediaSingle',
|
|
39
|
+
attrs: { layout: 'center' },
|
|
40
|
+
content: [{ type: 'media', attrs: { type: 'file', id: mediaId, collection } }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Appends block-level nodes (e.g. a media node) after an ADF doc's existing
|
|
46
|
+
* content, without mutating the original doc.
|
|
47
|
+
*/
|
|
48
|
+
export function appendNodesToAdf(adfDoc, extraNodes) {
|
|
49
|
+
return { ...adfDoc, content: [...adfDoc.content, ...extraNodes] };
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
export function adfToText(value) {
|
|
27
53
|
if (value == null) return '';
|
|
28
54
|
if (typeof value === 'string') return value;
|
|
@@ -159,7 +159,7 @@ function makeResult(attachment, localPath, skipReason, error) {
|
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
function sanitizeFilename(filename) {
|
|
162
|
+
export function sanitizeFilename(filename) {
|
|
163
163
|
// Strip directory components, replace unsafe chars, preserve extension
|
|
164
164
|
return path.basename(filename).replace(/[^a-zA-Z0-9._\-]/g, '_');
|
|
165
165
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates and reads local files for upload to a tracker (Jira attachment
|
|
3
|
+
* API, Linear fileUpload). Shared across trackers — the read/validate step
|
|
4
|
+
* is identical regardless of where the bytes end up.
|
|
5
|
+
*
|
|
6
|
+
* No path allowlist: the caller (a human, or an AI harness the human is
|
|
7
|
+
* directing) is trusted to supply a legitimate path — the same trust
|
|
8
|
+
* boundary already extended to every other free-text write field in this
|
|
9
|
+
* ticket-write family (comment bodies, summaries). This was a deliberate,
|
|
10
|
+
* reviewed choice, not an oversight — see the security-reviewer pass for
|
|
11
|
+
* this feature.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { sanitizeFilename } from './attachment-downloader.mjs';
|
|
17
|
+
|
|
18
|
+
export const MAX_ATTACHMENTS = 20; // mirrors attachment-downloader.mjs's download-side cap
|
|
19
|
+
export const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB — same
|
|
20
|
+
export const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB aggregate per call — bounds worst-case memory use across a whole batch
|
|
21
|
+
|
|
22
|
+
const MIME_TYPES = {
|
|
23
|
+
'.png': 'image/png',
|
|
24
|
+
'.jpg': 'image/jpeg',
|
|
25
|
+
'.jpeg': 'image/jpeg',
|
|
26
|
+
'.gif': 'image/gif',
|
|
27
|
+
'.webp': 'image/webp',
|
|
28
|
+
'.pdf': 'application/pdf',
|
|
29
|
+
'.txt': 'text/plain',
|
|
30
|
+
'.log': 'text/plain',
|
|
31
|
+
'.md': 'text/markdown',
|
|
32
|
+
'.csv': 'text/csv',
|
|
33
|
+
'.json': 'application/json',
|
|
34
|
+
'.zip': 'application/zip',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function mimeTypeFor(filePath) {
|
|
38
|
+
return MIME_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Size is checked via a stat call BEFORE reading the file into memory — a
|
|
43
|
+
* path to a huge file is rejected without ever buffering it.
|
|
44
|
+
*
|
|
45
|
+
* @returns {{ path: string, filename: string, buffer: Buffer, mimeType: string, size: number }
|
|
46
|
+
* | { path: string, error: 'not-found'|'not-a-file'|'empty'|'too-large' }}
|
|
47
|
+
*/
|
|
48
|
+
export function readAttachmentFile(filePath) {
|
|
49
|
+
let stat;
|
|
50
|
+
try {
|
|
51
|
+
stat = fs.statSync(filePath);
|
|
52
|
+
} catch {
|
|
53
|
+
return { path: filePath, error: 'not-found' };
|
|
54
|
+
}
|
|
55
|
+
if (!stat.isFile()) return { path: filePath, error: 'not-a-file' };
|
|
56
|
+
if (stat.size === 0) return { path: filePath, error: 'empty' };
|
|
57
|
+
if (stat.size > MAX_FILE_BYTES) return { path: filePath, error: 'too-large' };
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
path: filePath,
|
|
61
|
+
filename: sanitizeFilename(path.basename(filePath)),
|
|
62
|
+
buffer: fs.readFileSync(filePath),
|
|
63
|
+
mimeType: mimeTypeFor(filePath),
|
|
64
|
+
size: stat.size,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Reads a batch of paths, best-effort — one bad path never blocks the rest.
|
|
70
|
+
* Paths beyond MAX_ATTACHMENTS are dropped and counted, not silently read.
|
|
71
|
+
* A cheap pre-stat tracks the running total so a file that would push the
|
|
72
|
+
* batch over MAX_TOTAL_BYTES is rejected without ever being buffered — same
|
|
73
|
+
* "reject before read" principle as the per-file size cap. The pre-stat's
|
|
74
|
+
* own errors are ignored here; readAttachmentFile below produces the real,
|
|
75
|
+
* specific error (not-found/not-a-file/etc.) for those paths.
|
|
76
|
+
*
|
|
77
|
+
* @param {string[]} paths
|
|
78
|
+
* @returns {{ files: Array<{ok: boolean} & (ReturnType<typeof readAttachmentFile>)>, droppedCount: number }}
|
|
79
|
+
*/
|
|
80
|
+
export function readAttachments(paths) {
|
|
81
|
+
const capped = paths.slice(0, MAX_ATTACHMENTS);
|
|
82
|
+
const files = [];
|
|
83
|
+
let totalBytes = 0;
|
|
84
|
+
for (const p of capped) {
|
|
85
|
+
let precheckSize = 0;
|
|
86
|
+
try { precheckSize = fs.statSync(p).size; } catch { /* handled below */ }
|
|
87
|
+
if (precheckSize > 0 && totalBytes + precheckSize > MAX_TOTAL_BYTES) {
|
|
88
|
+
files.push({ ok: false, path: p, error: 'total-size-exceeded' });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const result = readAttachmentFile(p);
|
|
92
|
+
if (!result.error) totalBytes += result.size;
|
|
93
|
+
files.push({ ok: !result.error, ...result });
|
|
94
|
+
}
|
|
95
|
+
return { files, droppedCount: paths.length - capped.length };
|
|
96
|
+
}
|
|
@@ -130,6 +130,10 @@ export function parseCommand(args) {
|
|
|
130
130
|
return { command: 'mcp', args: args.slice(1) };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
if (first === 'cloud-keys') {
|
|
134
|
+
return { command: 'cloud-keys', args: args.slice(1) };
|
|
135
|
+
}
|
|
136
|
+
|
|
133
137
|
if (first === 'comment') {
|
|
134
138
|
return { command: 'comment', args: args.slice(1) };
|
|
135
139
|
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* All operations require a CLI token (set via `ticketlens login`).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { confirmDestructive } from './confirm.mjs';
|
|
8
|
+
|
|
7
9
|
const SUPPORTED_PROVIDERS = ['groq', 'anthropic', 'openai'];
|
|
8
10
|
|
|
9
11
|
function apiBase(config) {
|
|
@@ -84,6 +86,29 @@ export async function removeCloudKey(config, provider) {
|
|
|
84
86
|
await fetchApi(config, `/v1/ai-providers/${target.id}`, 'DELETE');
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* CLI-facing wrapper around removeCloudKey — gates the irreversible remote
|
|
91
|
+
* deletion behind a y/N confirmation (or --yes) before calling it.
|
|
92
|
+
*/
|
|
93
|
+
export async function runCloudKeysRemove(config, provider, opts = {}) {
|
|
94
|
+
const {
|
|
95
|
+
stream = process.stderr,
|
|
96
|
+
stdin = process.stdin,
|
|
97
|
+
forceYes = false,
|
|
98
|
+
confirmFn = confirmDestructive,
|
|
99
|
+
removeCloudKeyFn = removeCloudKey,
|
|
100
|
+
} = opts;
|
|
101
|
+
|
|
102
|
+
const confirmed = await confirmFn(`Remove the ${provider} key`, { stdin, stream, forceYes });
|
|
103
|
+
if (!confirmed) {
|
|
104
|
+
stream.write(' Aborted — key was not removed.\n');
|
|
105
|
+
return { removed: false };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
await removeCloudKeyFn(config, provider);
|
|
109
|
+
return { removed: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
87
112
|
export async function setPriority(config, provider, priority) {
|
|
88
113
|
const target = await findProvider(config, provider);
|
|
89
114
|
await fetchApi(config, `/v1/ai-providers/${target.id}`, 'PUT', { priority: Number(priority) });
|
|
@@ -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
|
+
}
|
|
@@ -574,11 +574,12 @@ export function printNoteHelp({ stream = process.stdout } = {}) {
|
|
|
574
574
|
` Code session — not typically invoked by hand. Every note it writes gets the`,
|
|
575
575
|
` same structural and secret-scan checks ${s.brand('note add')} applies to user input.`,
|
|
576
576
|
'',
|
|
577
|
-
` ${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]')}`,
|
|
578
578
|
'',
|
|
579
579
|
` Removes a note from your local vault. Local only — if this note was already`,
|
|
580
580
|
` pushed to a team, teammates who pulled it keep their copy; deleting it there`,
|
|
581
|
-
` 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.`,
|
|
582
583
|
'',
|
|
583
584
|
];
|
|
584
585
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -669,20 +670,24 @@ export function printCommentHelp({ stream = process.stdout } = {}) {
|
|
|
669
670
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
670
671
|
const lines = [
|
|
671
672
|
'',
|
|
672
|
-
` ${s.bold(s.brand('ticketlens'))} ${s.bold('comment')} ${s.dim('TICKET-KEY --body="..."')} ${s.dim('[Pro]')}`,
|
|
673
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('comment')} ${s.dim('TICKET-KEY --body="..." [--attach=path1,path2]')} ${s.dim('[Pro]')}`,
|
|
673
674
|
'',
|
|
674
675
|
` Post a comment directly to the ticket in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
675
676
|
` Writes to the real tracker — this is not a local Recall note.`,
|
|
676
677
|
'',
|
|
677
678
|
` ${s.bold('OPTIONS')}`,
|
|
678
679
|
'',
|
|
679
|
-
` ${s.brand('--body')}=${s.dim('TEXT')}
|
|
680
|
-
` ${s.brand('--
|
|
681
|
-
`
|
|
680
|
+
` ${s.brand('--body')}=${s.dim('TEXT')} Comment body ${s.dim('(required)')}`,
|
|
681
|
+
` ${s.brand('--attach')}=${s.dim('PATHS')} Comma-separated local file paths to attach ${s.dim('(optional)')}`,
|
|
682
|
+
` Images render as an inline thumbnail on Jira and Linear.`,
|
|
683
|
+
` Not supported on GitHub — no attachment upload API exists there.`,
|
|
684
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
685
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
682
686
|
'',
|
|
683
687
|
` ${s.bold('EXAMPLES')}`,
|
|
684
688
|
'',
|
|
685
689
|
` ${s.dim('$')} ticketlens comment PROD-123 --body="Looks good, merging."`,
|
|
690
|
+
` ${s.dim('$')} ticketlens comment PROD-123 --body="See screenshot" --attach=./bug.png`,
|
|
686
691
|
'',
|
|
687
692
|
];
|
|
688
693
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -860,6 +865,8 @@ export function printCreateHelp({ stream = process.stdout } = {}) {
|
|
|
860
865
|
` ${s.brand('--type')}=${s.dim('NAME')} Issue type ${s.dim('(Jira only, required there)')}`,
|
|
861
866
|
` ${s.brand('--summary')}=${s.dim('TEXT')} Ticket title/summary ${s.dim('(required)')}`,
|
|
862
867
|
` ${s.brand('--description')}=${s.dim('TEXT')} Ticket description`,
|
|
868
|
+
` ${s.brand('--attach')}=${s.dim('PATHS')} Comma-separated local file paths to attach, uploaded after creation`,
|
|
869
|
+
` ${s.dim('(optional)')}. Not supported on GitHub — no attachment upload API exists there.`,
|
|
863
870
|
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
864
871
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
865
872
|
'',
|
|
@@ -867,6 +874,7 @@ export function printCreateHelp({ stream = process.stdout } = {}) {
|
|
|
867
874
|
'',
|
|
868
875
|
` ${s.dim('$')} ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile"`,
|
|
869
876
|
` ${s.dim('$')} ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team`,
|
|
877
|
+
` ${s.dim('$')} ticketlens create --project=PROD --type="Bug" --summary="Broken layout" --attach=./screenshot.png`,
|
|
870
878
|
'',
|
|
871
879
|
];
|
|
872
880
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -1263,7 +1271,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
|
|
|
1263
1271
|
'',
|
|
1264
1272
|
` ${s.brand('list')} List configured providers`,
|
|
1265
1273
|
` ${s.brand('add')} ${s.dim('<provider> <key>')} Add or replace an API key`,
|
|
1266
|
-
` ${s.brand('remove')} ${s.dim('<provider>')}
|
|
1274
|
+
` ${s.brand('remove')} ${s.dim('<provider>')} ${s.dim('[--yes]')} Remove a provider's key — prompts for confirmation`,
|
|
1267
1275
|
` ${s.brand('test')} ${s.dim('<provider>')} Send a test request through the provider`,
|
|
1268
1276
|
` ${s.brand('priority')} ${s.dim('<provider> <N>')} Set priority (lower = tried first)`,
|
|
1269
1277
|
` ${s.brand('timeout')} ${s.dim('<provider> <seconds>')} Set per-request timeout`,
|
|
@@ -1271,6 +1279,7 @@ export function printCloudKeysHelp({ stream = process.stdout } = {}) {
|
|
|
1271
1279
|
` ${s.bold('OPTIONS')}`,
|
|
1272
1280
|
'',
|
|
1273
1281
|
` ${s.brand('--timeout')}=${s.dim('N')} Timeout in seconds when adding a key ${s.dim('(default: 5)')}`,
|
|
1282
|
+
` ${s.brand('--yes')}, ${s.brand('-y')} Skip the confirmation prompt when removing a key`,
|
|
1274
1283
|
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
1275
1284
|
'',
|
|
1276
1285
|
` ${s.bold('PROVIDERS')}`,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uploads a file to a Jira issue's attachments. Multipart/form-data, not
|
|
3
|
+
* JSON — the one write in this codebase with a genuinely different request
|
|
4
|
+
* shape from every other Jira write (comment/transition/assign/link/update/
|
|
5
|
+
* create all send `Content-Type: application/json`). Reuses the same
|
|
6
|
+
* guardedFetch/validateBaseUrl/buildAuthHeader SSRF/auth guards as every
|
|
7
|
+
* other call in jira-client.mjs, imported rather than duplicated.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { guardedFetch, validateBaseUrl, buildAuthHeader, defaultLookupFor, validateResolvedHost } from './jira-client.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} ticketKey
|
|
14
|
+
* @param {{ filename: string, buffer: Buffer, mimeType: string }} file - from attachment-uploader.mjs's readAttachmentFile
|
|
15
|
+
* @param {object} opts
|
|
16
|
+
* @returns {Promise<{ id: string, filename: string, size: number, url: string|null }>}
|
|
17
|
+
*/
|
|
18
|
+
export async function uploadAttachment(ticketKey, file, opts = {}) {
|
|
19
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 30_000, allowPrivateIp = false } = opts;
|
|
20
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
21
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
22
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/attachments`;
|
|
23
|
+
|
|
24
|
+
const form = new FormData();
|
|
25
|
+
form.append('file', new Blob([file.buffer], { type: file.mimeType }), file.filename);
|
|
26
|
+
|
|
27
|
+
// No Content-Type header here — FormData sets its own multipart boundary.
|
|
28
|
+
// Every other write in jira-client.mjs sets 'Content-Type': 'application/json';
|
|
29
|
+
// copying that here would silently break the upload.
|
|
30
|
+
const fetchOpts = {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { ...buildAuthHeader(env), 'X-Atlassian-Token': 'no-check' },
|
|
33
|
+
body: form,
|
|
34
|
+
};
|
|
35
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
36
|
+
|
|
37
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const err = new Error(`Jira API error ${response.status} attaching ${file.filename} to ${ticketKey}`);
|
|
40
|
+
err.status = response.status;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
const raw = await response.json();
|
|
44
|
+
const uploaded = (Array.isArray(raw) ? raw[0] : raw) ?? {};
|
|
45
|
+
return {
|
|
46
|
+
id: uploaded.id,
|
|
47
|
+
filename: uploaded.filename ?? file.filename,
|
|
48
|
+
size: uploaded.size ?? file.buffer.length,
|
|
49
|
+
url: uploaded.content ?? null,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves the Media Services UUID for an already-uploaded attachment —
|
|
55
|
+
* a completely different ID space from the classic attachment id above,
|
|
56
|
+
* and required to embed the attachment as real inline media in an ADF
|
|
57
|
+
* comment (see adf-converter.mjs's buildMediaNode). The only documented
|
|
58
|
+
* way to get it: a manual-redirect GET on the attachment's content URL,
|
|
59
|
+
* whose Location header points to
|
|
60
|
+
* `https://api.media.atlassian.com/file/{UUID}/binary?token=...` — the
|
|
61
|
+
* UUID is parsed out of that path without ever following the redirect
|
|
62
|
+
* (no need to actually download the file just to discard it).
|
|
63
|
+
*
|
|
64
|
+
* Real-instance-verified against a live Jira Cloud site: the resulting
|
|
65
|
+
* media node renders as a genuine inline thumbnail — see buildMediaNode's
|
|
66
|
+
* doc comment for what was confirmed about the `collection` attribute.
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveMediaId(contentUrl, opts = {}) {
|
|
69
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), allowPrivateIp = false } = opts;
|
|
70
|
+
await validateResolvedHost(new URL(contentUrl).hostname, lookup, allowPrivateIp);
|
|
71
|
+
const response = await fetcher(contentUrl, { headers: buildAuthHeader(env), redirect: 'manual' });
|
|
72
|
+
if (response.status < 300 || response.status >= 400) {
|
|
73
|
+
throw new Error(`Expected a redirect resolving the media id for ${contentUrl}, got ${response.status}`);
|
|
74
|
+
}
|
|
75
|
+
const location = response.headers.get('location');
|
|
76
|
+
if (!location) throw new Error(`Media content redirect for ${contentUrl} had no Location header`);
|
|
77
|
+
const match = new URL(location).pathname.match(/\/file\/([^/]+)\/binary/);
|
|
78
|
+
if (!match) throw new Error(`Could not parse a media uuid from redirect target for ${contentUrl}`);
|
|
79
|
+
return match[1];
|
|
80
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Supports v2 (Server/DC) and v3 (Cloud) API versions.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { adfToText, textToAdf } from './adf-converter.mjs';
|
|
7
|
+
import { adfToText, textToAdf, appendNodesToAdf } from './adf-converter.mjs';
|
|
8
8
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
9
9
|
|
|
10
10
|
function toText(value) {
|
|
@@ -420,14 +420,21 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
|
|
|
420
420
|
* Adds a comment to an issue. Cloud (v3) rejects a plain string body
|
|
421
421
|
* outright and requires ADF; Server/DC (v2) accepts plain text directly —
|
|
422
422
|
* same apiVersion branch point every other write/read here already uses.
|
|
423
|
+
*
|
|
424
|
+
* `extraAdfNodes` (Cloud only — a v2 string body has no ADF structure to
|
|
425
|
+
* append to) lets a caller embed real inline media (e.g. an uploaded
|
|
426
|
+
* attachment's mediaSingle node from adf-converter.mjs's buildMediaNode)
|
|
427
|
+
* after the text content, in the same atomic comment write.
|
|
423
428
|
*/
|
|
424
429
|
export async function postComment(ticketKey, body, opts = {}) {
|
|
425
|
-
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
430
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false, extraAdfNodes = [] } = opts;
|
|
426
431
|
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
427
432
|
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
428
433
|
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/comment`;
|
|
429
434
|
|
|
430
|
-
|
|
435
|
+
let payloadBody = apiVersion === 3 ? textToAdf(body) : body;
|
|
436
|
+
if (apiVersion === 3 && extraAdfNodes.length) payloadBody = appendNodesToAdf(payloadBody, extraAdfNodes);
|
|
437
|
+
const payload = { body: payloadBody };
|
|
431
438
|
const fetchOpts = {
|
|
432
439
|
method: 'POST',
|
|
433
440
|
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
@@ -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'],
|
|
@@ -61,6 +61,7 @@ const TOOLS = [
|
|
|
61
61
|
properties: {
|
|
62
62
|
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
63
63
|
body: { type: 'string', description: 'Comment body.' },
|
|
64
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach — images render as a real inline thumbnail in the posted comment on both Jira (Cloud and Server/Data Center) and Linear. Not supported on GitHub — no PAT-compatible upload API exists there.' },
|
|
64
65
|
},
|
|
65
66
|
required: ['ticket', 'body'],
|
|
66
67
|
},
|
|
@@ -142,6 +143,7 @@ const TOOLS = [
|
|
|
142
143
|
type: { type: 'string', description: 'Jira issue type, e.g. "Task" or "Bug". Required for Jira only; ignored on GitHub/Linear.' },
|
|
143
144
|
summary: { type: 'string', description: 'Ticket title/summary.' },
|
|
144
145
|
description: { type: 'string', description: 'Ticket description. Omit for none.' },
|
|
146
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach, uploaded after the ticket is created. On Linear the image is automatically linked into the description. On Jira it becomes a real, visible attachment on the issue, but is not embedded inline in the initial description (use ticket_comment afterward for an inline thumbnail). Not supported on GitHub.' },
|
|
145
147
|
},
|
|
146
148
|
required: ['summary'],
|
|
147
149
|
},
|
|
@@ -222,8 +224,10 @@ async function callTicketComment(args, { configDir, runTicketCommentFn }) {
|
|
|
222
224
|
if (!args.body) {
|
|
223
225
|
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
|
|
224
226
|
}
|
|
227
|
+
const cmdArgs = [args.ticket, `--body=${args.body}`];
|
|
228
|
+
if (args.attachments?.length) cmdArgs.push(`--attach=${args.attachments.join(',')}`);
|
|
225
229
|
const capture = capturingStream();
|
|
226
|
-
const { ok } = await runTicketCommentFn(
|
|
230
|
+
const { ok } = await runTicketCommentFn(cmdArgs, { configDir, stream: capture });
|
|
227
231
|
const content = [{ type: 'text', text: capture.text }];
|
|
228
232
|
return ok ? { content } : { isError: true, content };
|
|
229
233
|
}
|
|
@@ -350,6 +354,7 @@ function buildTicketCreateArgs(args) {
|
|
|
350
354
|
if (args.type !== undefined) cmdArgs.push(`--type=${args.type}`);
|
|
351
355
|
cmdArgs.push(`--summary=${args.summary}`);
|
|
352
356
|
if (args.description !== undefined) cmdArgs.push(`--description=${args.description}`);
|
|
357
|
+
if (args.attachments?.length) cmdArgs.push(`--attach=${args.attachments.join(',')}`);
|
|
353
358
|
return cmdArgs;
|
|
354
359
|
}
|
|
355
360
|
|
|
@@ -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');
|
|
@@ -20,11 +20,39 @@ import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.m
|
|
|
20
20
|
import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-enrichment.mjs';
|
|
21
21
|
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
22
22
|
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
23
|
+
import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
|
|
23
24
|
|
|
24
25
|
function parseFlag(cmdArgs, name) {
|
|
25
26
|
return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
function parseAttachPaths(cmdArgs) {
|
|
30
|
+
const raw = parseFlag(cmdArgs, 'attach');
|
|
31
|
+
return raw ? raw.split(',').map(p => p.trim()).filter(Boolean) : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* GitHub has no PAT-compatible public API for uploading issue/comment
|
|
36
|
+
* assets (confirmed via research — the only upload endpoint requires a
|
|
37
|
+
* browser session, not a token). Refused before the adapter is ever
|
|
38
|
+
* called, same pattern already used for GitHub's --priority refusal in
|
|
39
|
+
* ticket_update, rather than silently no-op-ing.
|
|
40
|
+
*/
|
|
41
|
+
function refuseGithubAttachments(adapter, attachPaths, stream) {
|
|
42
|
+
if (!attachPaths.length || adapter.type !== 'github') return false;
|
|
43
|
+
stream.write(' Note: GitHub does not support file attachments via the API — no supported way to upload issue/comment assets exists. Continuing without --attach.\n');
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatAttachSummary(attachResult) {
|
|
48
|
+
if (!attachResult) return '';
|
|
49
|
+
const lines = [];
|
|
50
|
+
for (const u of attachResult.uploaded) lines.push(` Attached ${u.filename}${u.url ? ` (${u.url})` : ''}\n`);
|
|
51
|
+
for (const e of attachResult.errors) lines.push(` Failed to attach ${e.path}: ${e.message}\n`);
|
|
52
|
+
if (attachResult.droppedCount > 0) lines.push(` ${attachResult.droppedCount} attachment(s) dropped — exceeds the ${MAX_ATTACHMENTS}-file limit per call.\n`);
|
|
53
|
+
return lines.join('');
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
/**
|
|
29
57
|
* Distinguishes retryable/terminal/rate-limited write failures so CLI and
|
|
30
58
|
* MCP callers get the same actionable signal instead of a generic catch —
|
|
@@ -205,7 +233,7 @@ export async function runTicketComment(cmdArgs, {
|
|
|
205
233
|
logActionFn = logAction,
|
|
206
234
|
actor = os.userInfo().username,
|
|
207
235
|
} = {}) {
|
|
208
|
-
const usage = 'Usage: ticketlens comment TICKET-KEY --body="..."\n';
|
|
236
|
+
const usage = 'Usage: ticketlens comment TICKET-KEY --body="..." [--attach=path1,path2]\n';
|
|
209
237
|
if (!requireLicense(isLicensedFn, configDir, 'ticketlens comment', stream)) return { ok: false };
|
|
210
238
|
|
|
211
239
|
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
@@ -216,6 +244,7 @@ export async function runTicketComment(cmdArgs, {
|
|
|
216
244
|
stream.write(usage);
|
|
217
245
|
return { ok: false };
|
|
218
246
|
}
|
|
247
|
+
const attachPaths = parseAttachPaths(cmdArgs);
|
|
219
248
|
|
|
220
249
|
const cooldown = checkCooldownFn(ticketKey, 'comment', { configDir });
|
|
221
250
|
if (cooldown.active) {
|
|
@@ -226,14 +255,32 @@ export async function runTicketComment(cmdArgs, {
|
|
|
226
255
|
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
227
256
|
if (!adapter) return { ok: false };
|
|
228
257
|
|
|
258
|
+
// Uploaded BEFORE the comment write so a tracker capable of inline
|
|
259
|
+
// rendering (Jira Server/DC via wiki markup, Jira Cloud via a real ADF
|
|
260
|
+
// media node, Linear via Markdown) can fold it into the same atomic
|
|
261
|
+
// comment post rather than needing a second edit call.
|
|
262
|
+
let attachResult = null;
|
|
263
|
+
if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
|
|
264
|
+
attachResult = await adapter.attachFiles(ticketKey, attachPaths);
|
|
265
|
+
}
|
|
266
|
+
const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
|
|
267
|
+
const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
|
|
268
|
+
const extraAdfNodes = (attachResult?.uploaded ?? []).filter(a => a.adfMediaNode).map(a => a.adfMediaNode);
|
|
269
|
+
|
|
229
270
|
try {
|
|
230
|
-
const result = await adapter.addComment(ticketKey,
|
|
271
|
+
const result = await adapter.addComment(ticketKey, finalBody, extraAdfNodes.length ? { extraAdfNodes } : {});
|
|
231
272
|
recordActionFn(ticketKey, 'comment', { configDir });
|
|
232
|
-
|
|
233
|
-
|
|
273
|
+
// attachPaths (every path attempted, raw) plus attachedFilenames (what
|
|
274
|
+
// actually landed) — a partial attach failure is reconstructable from
|
|
275
|
+
// the difference between the two, not just silently absent from audit.
|
|
276
|
+
logActionFn({ ticketKey, action: 'comment', actor, tracker: adapter.type, detail: { id: result.id, attachPaths, attachedFilenames: (attachResult?.uploaded ?? []).map(a => a.filename) } }, { configDir });
|
|
277
|
+
stream.write(` Comment posted to ${ticketKey}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult));
|
|
234
278
|
return { ok: true };
|
|
235
279
|
} catch (err) {
|
|
236
|
-
|
|
280
|
+
// Attachments (if any) genuinely landed on the tracker before this
|
|
281
|
+
// write was attempted — formatAttachSummary is still shown here so a
|
|
282
|
+
// caller retrying the whole command doesn't blindly re-upload them.
|
|
283
|
+
stream.write(formatWriteFailure(ticketKey, err) + formatAttachSummary(attachResult));
|
|
237
284
|
return { ok: false };
|
|
238
285
|
}
|
|
239
286
|
}
|
|
@@ -705,9 +752,11 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
705
752
|
const project = parseFlag(cmdArgs, 'project');
|
|
706
753
|
const type = parseFlag(cmdArgs, 'type');
|
|
707
754
|
const description = parseFlag(cmdArgs, 'description');
|
|
755
|
+
const attachPaths = parseAttachPaths(cmdArgs);
|
|
708
756
|
|
|
709
757
|
const adapter = resolveTicketAdapter(undefined, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
710
758
|
if (!adapter) return { ok: false };
|
|
759
|
+
const attachRefused = refuseGithubAttachments(adapter, attachPaths, stream);
|
|
711
760
|
|
|
712
761
|
if (adapter.type !== 'github' && !project) {
|
|
713
762
|
stream.write(` --project is required for ${adapter.type === 'jira' ? 'Jira (project key)' : 'Linear (team key)'}.\n`);
|
|
@@ -758,6 +807,44 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
758
807
|
} catch (bookkeepingErr) {
|
|
759
808
|
stream.write(` Warning: ${result.key} was created but could not be logged: ${bookkeepingErr.message}\n`);
|
|
760
809
|
}
|
|
761
|
-
|
|
810
|
+
|
|
811
|
+
// Attachments upload AFTER creation — Jira/Linear both need a real issue
|
|
812
|
+
// key to attach to (Jira strictly; Linear's fileUpload doesn't, but the
|
|
813
|
+
// same ordering is kept uniform across trackers for simplicity). The
|
|
814
|
+
// ticket has already landed, so nothing in this block may ever cause
|
|
815
|
+
// runTicketCreate to report the create itself as failed — wrapped in its
|
|
816
|
+
// own try/catch, mirroring the bookkeeping block above.
|
|
817
|
+
let attachResult = null;
|
|
818
|
+
if (attachPaths.length && !attachRefused) {
|
|
819
|
+
try {
|
|
820
|
+
attachResult = await adapter.attachFiles(result.key, attachPaths);
|
|
821
|
+
|
|
822
|
+
// Linear has no separate attachment list on an issue — unlike Jira's
|
|
823
|
+
// classic attachment (real regardless of description text), an
|
|
824
|
+
// uploaded Linear asset is only ever associated with the issue by
|
|
825
|
+
// referencing its URL in a text field. Without this follow-up edit,
|
|
826
|
+
// the file would be uploaded to Linear's storage but completely
|
|
827
|
+
// orphaned from the ticket. Best-effort: if this edit fails, the
|
|
828
|
+
// asset is still genuinely uploaded, just not linked — reported as
|
|
829
|
+
// an error entry, not a lost/misreported create.
|
|
830
|
+
const inlineSnippets = attachResult.uploaded.filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
|
|
831
|
+
if (inlineSnippets && adapter.type === 'linear') {
|
|
832
|
+
try {
|
|
833
|
+
await adapter.updateFields(result.key, { description: description ? `${description}\n\n${inlineSnippets}` : inlineSnippets });
|
|
834
|
+
} catch (linkErr) {
|
|
835
|
+
attachResult = { ...attachResult, errors: [...attachResult.errors, { path: '(description update)', message: `uploaded but failed to link into the ticket description: ${linkErr.message}` }] };
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
try {
|
|
840
|
+
logActionFn({ ticketKey: result.key, action: 'create', actor, tracker: adapter.type, detail: { attachPaths, attachedFilenames: attachResult.uploaded.map(a => a.filename) } }, { configDir });
|
|
841
|
+
} catch { /* best-effort, same as the primary bookkeeping above */ }
|
|
842
|
+
} catch (attachErr) {
|
|
843
|
+
stream.write(` Warning: ${result.key} was created but attaching files failed: ${attachErr.message}\n`);
|
|
844
|
+
attachResult = null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
stream.write(` Created ${result.key}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult));
|
|
762
849
|
return { ok: true, key: result.key };
|
|
763
850
|
}
|