ticketlens 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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})` : `[${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
+ }
@@ -670,20 +670,24 @@ export function printCommentHelp({ stream = process.stdout } = {}) {
670
670
  const s = createStyler({ isTTY: stream.isTTY });
671
671
  const lines = [
672
672
  '',
673
- ` ${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]')}`,
674
674
  '',
675
675
  ` Post a comment directly to the ticket in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
676
676
  ` Writes to the real tracker — this is not a local Recall note.`,
677
677
  '',
678
678
  ` ${s.bold('OPTIONS')}`,
679
679
  '',
680
- ` ${s.brand('--body')}=${s.dim('TEXT')} Comment body ${s.dim('(required)')}`,
681
- ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
682
- ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
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`,
683
686
  '',
684
687
  ` ${s.bold('EXAMPLES')}`,
685
688
  '',
686
689
  ` ${s.dim('$')} ticketlens comment PROD-123 --body="Looks good, merging."`,
690
+ ` ${s.dim('$')} ticketlens comment PROD-123 --body="See screenshot" --attach=./bug.png`,
687
691
  '',
688
692
  ];
689
693
  stream.write(lines.join('\n') + '\n');
@@ -861,6 +865,8 @@ export function printCreateHelp({ stream = process.stdout } = {}) {
861
865
  ` ${s.brand('--type')}=${s.dim('NAME')} Issue type ${s.dim('(Jira only, required there)')}`,
862
866
  ` ${s.brand('--summary')}=${s.dim('TEXT')} Ticket title/summary ${s.dim('(required)')}`,
863
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.`,
864
870
  ` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
865
871
  ` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
866
872
  '',
@@ -868,6 +874,7 @@ export function printCreateHelp({ stream = process.stdout } = {}) {
868
874
  '',
869
875
  ` ${s.dim('$')} ticketlens create --project=PROD --type="Task" --summary="Fix login on mobile"`,
870
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`,
871
878
  '',
872
879
  ];
873
880
  stream.write(lines.join('\n') + '\n');
@@ -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
- const payload = { body: apiVersion === 3 ? textToAdf(body) : body };
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' },
@@ -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([args.ticket, `--body=${args.body}`], { configDir, stream: capture });
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
 
@@ -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, body);
271
+ const result = await adapter.addComment(ticketKey, finalBody, extraAdfNodes.length ? { extraAdfNodes } : {});
231
272
  recordActionFn(ticketKey, 'comment', { configDir });
232
- logActionFn({ ticketKey, action: 'comment', actor, tracker: adapter.type, detail: { id: result.id } }, { configDir });
233
- stream.write(` Comment posted to ${ticketKey}${result.url ? ` (${result.url})` : ''}\n`);
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
- stream.write(formatWriteFailure(ticketKey, err));
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
- stream.write(` Created ${result.key}${result.url ? ` (${result.url})` : ''}\n`);
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
  }