ticketlens 0.43.4 → 0.43.5
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 +2 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +2 -2
- package/skills/jtb/scripts/fetch-ticket.mjs +5 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +2 -2
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +2 -2
- package/skills/jtb/scripts/lib/attachment-caps.mjs +22 -0
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +5 -3
- package/skills/jtb/scripts/lib/attachment-uploader.mjs +7 -6
- package/skills/jtb/scripts/lib/license.mjs +1 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +2 -2
- package/skills/jtb/scripts/lib/note-command.mjs +6 -5
- package/skills/jtb/scripts/lib/ticket-command.mjs +4 -4
package/README.md
CHANGED
|
@@ -469,7 +469,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
469
469
|
|
|
470
470
|
**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.
|
|
471
471
|
|
|
472
|
-
**Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file
|
|
472
|
+
**Local file attachments.** `note add --attach=path1,path2` (or the `recall_add` MCP tool's `attachments` array) saves a screenshot or file alongside the note, in your local vault (`~/.ticketlens/recall/<PREFIX>/<note-id>/`) — same 10 MB/file and 50 MB/call caps as ticket attachments, with a per-call file cap of 10 on Free and 50 on Pro, Team, and Enterprise (also applied to `fetch` attachment downloads). `note patch --attach=path1,path2` (or `recall_update`'s `attachments` array) attaches more files to an existing note later — appended to what's already there, never replacing it; patch is local-vault only, so these are never pushed even if the note's original attachments were. With Team Recall sync active, the attachment syncs too — visible and downloadable from Console > Admin > Recall — but the sync path caps at 12 MB/call (the backend's request-size limit, lower than the 50 MB local-save cap). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until it's pushed within the cap. Attachments with plain-text content (detected from the actual bytes, not the filename) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way.
|
|
473
473
|
|
|
474
474
|
**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.
|
|
475
475
|
|
|
@@ -1122,6 +1122,7 @@ npm test
|
|
|
1122
1122
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
1123
1123
|
|
|
1124
1124
|
Recently shipped:
|
|
1125
|
+
- **Tiered attachment cap** — per-call attachment count is now 10 on Free and 50 on Pro, Team and Enterprise (was a flat 20), for uploads, `fetch` downloads and Recall sync. Excess files are reported, not silently dropped
|
|
1125
1126
|
- **`ticketlens pr --open`** — opens a real GitHub PR compare page prefilled with the same description `pr` already prints, instead of just printing it. GitHub only, no stored write-credential. Pro tier
|
|
1126
1127
|
- **`review`/`compliance` fixes** — branch diff now uses merge-base semantics, no longer picks up unrelated unmerged-main files; AC parser now recognizes Jira wiki `Requirements`/`How to test` headers, not just literal "Acceptance Criteria"
|
|
1127
1128
|
- **`ticketlens update --priority`** — a bad priority name now tells you the project's real, current valid options instead of a bare tracker error
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.49.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.
|
|
@@ -376,7 +376,7 @@ echo "Retry client swallowed 429s silently; added backoff and a warning log on t
|
|
|
376
376
|
|
|
377
377
|
**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. A tag that just restates the title in different words, or one you can't trace to a specific sentence in the body, gives that same zero signal — if you can't point to the exact phrase that justifies it, drop it. Same rule whether you're constructing the bash command above or calling `recall_add` directly — see its tool description for the same guidance.
|
|
378
378
|
|
|
379
|
-
**Attaching local files.** `note add --attach=path1,path2` (Pro) saves a screenshot or file alongside the note — same 10 MB/file
|
|
379
|
+
**Attaching local files.** `note add --attach=path1,path2` (Pro) saves a screenshot or file alongside the note — same 10 MB/file and 50 MB/call caps as `ticket_create`/`ticket_comment`'s `--attach` for the local save. File-count cap per call: 10 on Free, 50 on Pro, Team, and Enterprise (same for `fetch` attachment downloads; excess files are reported, not silently dropped). If this account is entitled and the note syncs to a team (Team Recall sync active), the attachment syncs with it — visible and downloadable from Console > Admin > Recall, not just text-only. The sync path has a lower 12 MB/call cap than the local save (bounded by the backend's request-size limit, not the CLI). Going over it fails the whole push, not just the attachment — the note stays saved locally, but neither its text nor the attachment reaches the team until pushed within the cap. Attachments with plain-text content (checked by actually decoding the bytes, not by filename extension — renaming a text file to `.png` doesn't skip this) go through the same secret scan as the note body before syncing; a rejected scan blocks the whole push the same way. The `recall_add` MCP tool has a matching `attachments` array parameter — prefer it over the bash form when available, same rule as the rest of this section.
|
|
380
380
|
|
|
381
381
|
**Attaching a browser screenshot.** No separate capture path exists — `--attach` already takes any local file path, and a browser-automation tool's screenshot action (e.g. Chrome DevTools MCP's `take_screenshot`, Playwright's `browser_take_screenshot`) writes to a path you choose when you pass it a `filePath`/output-path argument. Save there, then attach it like any other file:
|
|
382
382
|
```bash
|
|
@@ -23,6 +23,7 @@ import { readTextAttachments } from './lib/handoff-assembler.mjs';
|
|
|
23
23
|
import { handleUnknownFlags } from './lib/arg-validator.mjs';
|
|
24
24
|
import { TICKET_KEY_PATTERN, normalizeTicketKey } from './lib/cli.mjs';
|
|
25
25
|
import { downloadAttachments } from './lib/attachment-downloader.mjs';
|
|
26
|
+
import { attachmentCapFor, attachmentLimitNotice } from './lib/attachment-caps.mjs';
|
|
26
27
|
import { readBriefCache, writeBriefCache, readSummaryCache, writeSummaryCache, briefCacheAge, BRIEF_TTL_MS } from './lib/brief-cache.mjs';
|
|
27
28
|
import { recordTokensSaved } from './lib/activity-counter.mjs';
|
|
28
29
|
import { readCliToken } from './lib/cli-auth.mjs';
|
|
@@ -1372,7 +1373,9 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1372
1373
|
printErrFn(`Downloading ${downloadable.length} ${noun}…\n`);
|
|
1373
1374
|
// attachment-downloader is Jira-specific — it needs raw auth headers from buildJiraEnv
|
|
1374
1375
|
const jiraEnv = buildJiraEnv(conn);
|
|
1376
|
+
const maxAttachments = attachmentCapFor(configDir);
|
|
1375
1377
|
ticket.localAttachments = await downloadAttachments(ticket, {
|
|
1378
|
+
maxAttachments,
|
|
1376
1379
|
env: jiraEnv,
|
|
1377
1380
|
fetcher,
|
|
1378
1381
|
noCache: args.includes('--no-cache'),
|
|
@@ -1385,6 +1388,8 @@ export async function run(args, envOrOpts = process.env, fetcher = globalThis.fe
|
|
|
1385
1388
|
if (downloaded > 0) parts.push(`${downloaded} downloaded`);
|
|
1386
1389
|
if (cached > 0) parts.push(`${cached} cached`);
|
|
1387
1390
|
if (parts.length > 0) printErrFn(` ✓ ${parts.join(', ')}\n`);
|
|
1391
|
+
const overCap = ticket.localAttachments.filter(r => r.skipReason === 'limit').length;
|
|
1392
|
+
if (overCap > 0) printErrFn(` ${attachmentLimitNotice(overCap, maxAttachments, { verb: 'not downloaded', scope: 'ticket' })}\n`);
|
|
1388
1393
|
printErrFn('\n');
|
|
1389
1394
|
}
|
|
1390
1395
|
}
|
|
@@ -263,7 +263,7 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
263
263
|
* Both are image-only; non-image files get neither.
|
|
264
264
|
*/
|
|
265
265
|
async attachFiles(key, filePaths, opts = {}) {
|
|
266
|
-
const { files, droppedCount } = readAttachments(filePaths);
|
|
266
|
+
const { files, droppedCount, cap } = readAttachments(filePaths, opts.maxAttachments);
|
|
267
267
|
const uploaded = [];
|
|
268
268
|
const errors = [];
|
|
269
269
|
for (const f of files) {
|
|
@@ -291,7 +291,7 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
291
291
|
errors.push({ path: f.path, message: err.message });
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
|
-
return { uploaded, errors, droppedCount };
|
|
294
|
+
return { uploaded, errors, droppedCount, cap };
|
|
295
295
|
},
|
|
296
296
|
};
|
|
297
297
|
}
|
|
@@ -538,7 +538,7 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
538
538
|
async attachFiles(key, filePaths, opts = {}) {
|
|
539
539
|
const signal = AbortSignal.timeout(opts.timeoutMs ?? 30_000);
|
|
540
540
|
const { lookup = defaultLookupFor(fetcher), allowPrivateIp = false } = opts;
|
|
541
|
-
const { files, droppedCount } = readAttachments(filePaths);
|
|
541
|
+
const { files, droppedCount, cap } = readAttachments(filePaths, opts.maxAttachments);
|
|
542
542
|
const uploaded = [];
|
|
543
543
|
const errors = [];
|
|
544
544
|
for (const f of files) {
|
|
@@ -592,7 +592,7 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
592
592
|
errors.push({ path: f.path, message: err.message });
|
|
593
593
|
}
|
|
594
594
|
}
|
|
595
|
-
return { uploaded, errors, droppedCount };
|
|
595
|
+
return { uploaded, errors, droppedCount, cap };
|
|
596
596
|
},
|
|
597
597
|
};
|
|
598
598
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-call attachment count caps, by tier. Byte caps (10 MB/file, 50 MB/call)
|
|
3
|
+
* are unchanged and live next to the code that enforces them.
|
|
4
|
+
* Mirrors ticketlens-api's RecallAttachmentStorage::maxFilesFor().
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isLicensed } from './license.mjs';
|
|
8
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
9
|
+
|
|
10
|
+
export const FREE_MAX_ATTACHMENTS = 10;
|
|
11
|
+
export const PAID_MAX_ATTACHMENTS = 50;
|
|
12
|
+
|
|
13
|
+
export function attachmentCapFor(configDir = DEFAULT_CONFIG_DIR, isLicensedFn = isLicensed) {
|
|
14
|
+
return isLicensedFn('pro', configDir) ? PAID_MAX_ATTACHMENTS : FREE_MAX_ATTACHMENTS;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function attachmentLimitNotice(count, cap, { verb = 'dropped', scope = 'call' } = {}) {
|
|
18
|
+
const base = `${count} attachment(s) ${verb} — exceeds the ${cap}-file limit per ${scope}.`;
|
|
19
|
+
return cap < PAID_MAX_ATTACHMENTS
|
|
20
|
+
? `${base} Upgrade to Pro for up to ${PAID_MAX_ATTACHMENTS}.`
|
|
21
|
+
: base;
|
|
22
|
+
}
|
|
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { buildAuthHeader, isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from './jira-client.mjs';
|
|
10
10
|
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
11
|
-
|
|
11
|
+
import { attachmentCapFor } from './attachment-caps.mjs';
|
|
12
12
|
const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
13
13
|
const MAX_REDIRECT_HOPS = 2;
|
|
14
14
|
|
|
@@ -57,6 +57,7 @@ async function followValidatedRedirects(initialUrl, fetcher, lookup, headers, al
|
|
|
57
57
|
* @param {Function} opts.fetcher fetch-compatible function (default: globalThis.fetch)
|
|
58
58
|
* @param {string} opts.configDir Base config dir (default: ~/.ticketlens)
|
|
59
59
|
* @param {boolean} opts.noCache Force re-download even if cached (default: false)
|
|
60
|
+
* @param {number} opts.maxAttachments File-count cap (default: the tier cap for configDir)
|
|
60
61
|
* @param {Function} opts.onProgress Optional callback(msg: string) for progress lines
|
|
61
62
|
* @param {boolean} opts.allowPrivateIp Skip the private-IP DNS check on the initial hop only (default: false)
|
|
62
63
|
*
|
|
@@ -71,6 +72,7 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
71
72
|
noCache = false,
|
|
72
73
|
onProgress = null,
|
|
73
74
|
allowPrivateIp = false,
|
|
75
|
+
maxAttachments = attachmentCapFor(configDir),
|
|
74
76
|
} = opts;
|
|
75
77
|
|
|
76
78
|
const attachments = (ticket.attachments ?? []).filter(a => a.content);
|
|
@@ -82,8 +84,8 @@ export async function downloadAttachments(ticket, opts = {}) {
|
|
|
82
84
|
const jiraOrigin = env.JIRA_BASE_URL ? new URL(env.JIRA_BASE_URL).origin : null;
|
|
83
85
|
|
|
84
86
|
// Attachments beyond the limit are bulk-skipped
|
|
85
|
-
const capped = attachments.slice(0,
|
|
86
|
-
const limited = attachments.slice(
|
|
87
|
+
const capped = attachments.slice(0, maxAttachments);
|
|
88
|
+
const limited = attachments.slice(maxAttachments).map(a => makeResult(a, null, 'limit', null));
|
|
87
89
|
|
|
88
90
|
// Pre-classify: resolve immediate results, collect pending downloads
|
|
89
91
|
const results = new Array(capped.length);
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { sanitizeFilename } from './attachment-downloader.mjs';
|
|
17
|
+
import { attachmentCapFor } from './attachment-caps.mjs';
|
|
17
18
|
|
|
18
|
-
export const MAX_ATTACHMENTS = 20; // mirrors attachment-downloader.mjs's download-side cap
|
|
19
19
|
export const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB — same
|
|
20
20
|
export const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB aggregate per call — bounds worst-case memory use across a whole batch
|
|
21
21
|
|
|
@@ -67,7 +67,7 @@ export function readAttachmentFile(filePath) {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Reads a batch of paths, best-effort — one bad path never blocks the rest.
|
|
70
|
-
* Paths beyond
|
|
70
|
+
* Paths beyond the tier's file cap are dropped and counted, not silently read.
|
|
71
71
|
* A cheap pre-stat tracks the running total so a file that would push the
|
|
72
72
|
* batch over MAX_TOTAL_BYTES is rejected without ever being buffered — same
|
|
73
73
|
* "reject before read" principle as the per-file size cap. The pre-stat's
|
|
@@ -75,10 +75,11 @@ export function readAttachmentFile(filePath) {
|
|
|
75
75
|
* specific error (not-found/not-a-file/etc.) for those paths.
|
|
76
76
|
*
|
|
77
77
|
* @param {string[]} paths
|
|
78
|
-
* @
|
|
78
|
+
* @param {number} [maxAttachments] file-count cap; defaults to the caller's tier cap
|
|
79
|
+
* @returns {{ files: Array<{ok: boolean} & (ReturnType<typeof readAttachmentFile>)>, droppedCount: number, cap: number }}
|
|
79
80
|
*/
|
|
80
|
-
export function readAttachments(paths) {
|
|
81
|
-
const capped = paths.slice(0,
|
|
81
|
+
export function readAttachments(paths, maxAttachments = attachmentCapFor()) {
|
|
82
|
+
const capped = paths.slice(0, maxAttachments);
|
|
82
83
|
const files = [];
|
|
83
84
|
let totalBytes = 0;
|
|
84
85
|
for (const p of capped) {
|
|
@@ -92,5 +93,5 @@ export function readAttachments(paths) {
|
|
|
92
93
|
if (!result.error) totalBytes += result.size;
|
|
93
94
|
files.push({ ok: !result.error, ...result });
|
|
94
95
|
}
|
|
95
|
-
return { files, droppedCount: paths.length - capped.length };
|
|
96
|
+
return { files, droppedCount: paths.length - capped.length, cap: maxAttachments };
|
|
96
97
|
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
// Mixed into the HMAC key so that knowing the license key alone is not sufficient
|
|
15
15
|
// to forge a valid signature — an attacker also needs this constant from the source.
|
|
16
16
|
export const LICENSE_HMAC_SALT = 'tl-lic-v1';
|
|
17
|
-
export const LICENSE_TIERS = { free: 0, pro: 1, team: 2 };
|
|
17
|
+
export const LICENSE_TIERS = { free: 0, pro: 1, team: 2, enterprise: 3 };
|
|
18
18
|
const LICENSE_FILE = 'license.json';
|
|
19
19
|
const REVALIDATION_DAYS = 7; // attempt background revalidation after this many days
|
|
20
20
|
export const GRACE_DAYS = 30; // treat license as invalid if not revalidated within this window
|
|
@@ -168,7 +168,7 @@ export const TOOLS = [
|
|
|
168
168
|
ticket: { type: 'string', description: 'Optional ticket key, e.g. PROJ-123.' },
|
|
169
169
|
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. A tag that just restates the title in different words, or one you cannot trace to a specific sentence in the body, gives that same zero signal — if you cannot point to the exact phrase that justifies it, drop it.' },
|
|
170
170
|
body: { type: 'string', description: 'The note body — brief, not a paragraph. ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the save, but prints a non-blocking warning.' },
|
|
171
|
-
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.), saved next to the note in the local vault. Same 10MB/file
|
|
171
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.), saved next to the note in the local vault. Same 10MB/file and 50MB/call caps as ticket_comment/ticket_create for the local save; file count is capped at 10 on Free, 50 on Pro/Team/Enterprise. If this account is entitled and the note syncs to a team, attachments sync too — visible and downloadable from Console > Admin > Recall — but the sync path has a lower 12MB/call cap than the local save (backend request-size limit). Going over it fails the WHOLE push (note text included), not just the attachment; the note stays saved locally either way.' },
|
|
172
172
|
},
|
|
173
173
|
required: ['title', 'body'],
|
|
174
174
|
},
|
|
@@ -183,7 +183,7 @@ export const TOOLS = [
|
|
|
183
183
|
body: { type: 'string', description: 'The replacement note body — brief, not a paragraph. ~20 (strict), 30 (balanced), or 50 (loose) words max — scales with recallStrictness — going over doesn\'t block the update, but prints a non-blocking warning.' },
|
|
184
184
|
ticket: { type: 'string', description: 'Ticket key the note is about, e.g. PROJ-123. Optional — narrows the search when omitted, the vault is searched across all ticket prefixes.' },
|
|
185
185
|
expectMtime: { type: 'number', description: 'The note file\'s mtime (ms) last observed by the caller. If the note changed since then, the patch is a no-op rather than an overwrite — optimistic concurrency, not a hard requirement.' },
|
|
186
|
-
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.) — appended to whatever the note already has, never replacing existing attachments. Same 10MB/file
|
|
186
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Local file paths to attach (screenshots, logs, etc.) — appended to whatever the note already has, never replacing existing attachments. Same 10MB/file and 50MB/call caps as recall_add; file count is capped at 10 on Free, 50 on Pro/Team/Enterprise. Local vault only — recall_update never syncs to a team backend, so this never pushes.' },
|
|
187
187
|
},
|
|
188
188
|
required: ['id', 'body'],
|
|
189
189
|
},
|
|
@@ -18,7 +18,8 @@ import { pushNote } from './recall-sync.mjs';
|
|
|
18
18
|
import { enqueueNote, isRetryableFailure, maybeAutoFlush } from './recall-queue.mjs';
|
|
19
19
|
import { incrementDraftKept, incrementDraftDeleted } from './activity-counter.mjs';
|
|
20
20
|
import { extractText } from './attachment-text.mjs';
|
|
21
|
-
import { readAttachments
|
|
21
|
+
import { readAttachments } from './attachment-uploader.mjs';
|
|
22
|
+
import { attachmentCapFor, attachmentLimitNotice, PAID_MAX_ATTACHMENTS } from './attachment-caps.mjs';
|
|
22
23
|
import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
|
|
23
24
|
import { createStyler } from './ansi.mjs';
|
|
24
25
|
import { confirmDestructive } from './confirm.mjs';
|
|
@@ -43,14 +44,14 @@ function parseAttachPaths(cmdArgs) {
|
|
|
43
44
|
* best-effort philosophy as ticket_create/comment's --attach: one bad path
|
|
44
45
|
* never blocks the note from being saved.
|
|
45
46
|
*/
|
|
46
|
-
function summarizeAttachments({ files, droppedCount }) {
|
|
47
|
+
function summarizeAttachments({ files, droppedCount, cap = PAID_MAX_ATTACHMENTS }) {
|
|
47
48
|
const saved = [];
|
|
48
49
|
const warnings = [];
|
|
49
50
|
for (const f of files) {
|
|
50
51
|
if (f.ok) saved.push({ filename: f.filename, buffer: f.buffer });
|
|
51
52
|
else warnings.push(` Skipped attachment ${f.path}: ${ATTACHMENT_ERROR_MESSAGES[f.error] ?? f.error}\n`);
|
|
52
53
|
}
|
|
53
|
-
if (droppedCount > 0) warnings.push(` ${droppedCount
|
|
54
|
+
if (droppedCount > 0) warnings.push(` ${attachmentLimitNotice(droppedCount, cap)}\n`);
|
|
54
55
|
return { saved, warnings };
|
|
55
56
|
}
|
|
56
57
|
|
|
@@ -185,7 +186,7 @@ export async function runNoteAdd(cmdArgs, {
|
|
|
185
186
|
|
|
186
187
|
const attachPaths = parseAttachPaths(cmdArgs);
|
|
187
188
|
const { saved: attachments, warnings: attachWarnings } = attachPaths.length > 0
|
|
188
|
-
? summarizeAttachments(readAttachmentsFn(attachPaths))
|
|
189
|
+
? summarizeAttachments(readAttachmentsFn(attachPaths, attachmentCapFor(configDir, isLicensedFn)))
|
|
189
190
|
: { saved: [], warnings: [] };
|
|
190
191
|
for (const warning of attachWarnings) stream.write(warning);
|
|
191
192
|
|
|
@@ -303,7 +304,7 @@ export async function runNotePatch(cmdArgs, {
|
|
|
303
304
|
|
|
304
305
|
const attachPaths = parseAttachPaths(cmdArgs);
|
|
305
306
|
const { saved: attachments, warnings: attachWarnings } = attachPaths.length > 0
|
|
306
|
-
? summarizeAttachments(readAttachmentsFn(attachPaths))
|
|
307
|
+
? summarizeAttachments(readAttachmentsFn(attachPaths, attachmentCapFor(configDir, isLicensedFn)))
|
|
307
308
|
: { saved: [], warnings: [] };
|
|
308
309
|
for (const warning of attachWarnings) stream.write(warning);
|
|
309
310
|
|
|
@@ -21,7 +21,7 @@ import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-e
|
|
|
21
21
|
import { enrichUpdateFailure } from './ticket-update-enrichment.mjs';
|
|
22
22
|
import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
|
|
23
23
|
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
24
|
-
import {
|
|
24
|
+
import { attachmentCapFor, attachmentLimitNotice, PAID_MAX_ATTACHMENTS } from './attachment-caps.mjs';
|
|
25
25
|
import { createStyler } from './ansi.mjs';
|
|
26
26
|
|
|
27
27
|
export function parseFlag(cmdArgs, name) {
|
|
@@ -90,7 +90,7 @@ function formatAttachSummary(attachResult, s) {
|
|
|
90
90
|
const lines = [];
|
|
91
91
|
for (const u of attachResult.uploaded) lines.push(` ${s.green('✔')} Attached ${s.bold(u.filename)}${u.url ? ` (${u.url})` : ''}\n`);
|
|
92
92
|
for (const e of attachResult.errors) lines.push(` Failed to attach ${e.path}: ${e.message}\n`);
|
|
93
|
-
if (attachResult.droppedCount > 0) lines.push(` ${attachResult.droppedCount
|
|
93
|
+
if (attachResult.droppedCount > 0) lines.push(` ${attachmentLimitNotice(attachResult.droppedCount, attachResult.cap ?? PAID_MAX_ATTACHMENTS)}\n`);
|
|
94
94
|
return lines.join('');
|
|
95
95
|
}
|
|
96
96
|
|
|
@@ -345,7 +345,7 @@ export async function runTicketComment(cmdArgs, {
|
|
|
345
345
|
let attachResult = null;
|
|
346
346
|
try {
|
|
347
347
|
if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
|
|
348
|
-
attachResult = await adapter.attachFiles(ticketKey, attachPaths);
|
|
348
|
+
attachResult = await adapter.attachFiles(ticketKey, attachPaths, { maxAttachments: attachmentCapFor(configDir, isLicensedFn) });
|
|
349
349
|
}
|
|
350
350
|
const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
|
|
351
351
|
const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
|
|
@@ -1135,7 +1135,7 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
1135
1135
|
let attachResult = null;
|
|
1136
1136
|
if (attachPaths.length && !attachRefused) {
|
|
1137
1137
|
try {
|
|
1138
|
-
attachResult = await adapter.attachFiles(result.key, attachPaths);
|
|
1138
|
+
attachResult = await adapter.attachFiles(result.key, attachPaths, { maxAttachments: attachmentCapFor(configDir, isLicensedFn) });
|
|
1139
1139
|
|
|
1140
1140
|
// Linear has no separate attachment list on an issue — unlike Jira's
|
|
1141
1141
|
// classic attachment (real regardless of description text), an
|