ticketlens 0.22.1 → 0.24.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.
@@ -4,7 +4,7 @@
4
4
  * Supports v2 (Server/DC) and v3 (Cloud) API versions.
5
5
  */
6
6
 
7
- import { adfToText } from './adf-converter.mjs';
7
+ import { adfToText, textToAdf } from './adf-converter.mjs';
8
8
  import { lookup as dnsLookup } from 'node:dns/promises';
9
9
 
10
10
  function toText(value) {
@@ -408,6 +408,92 @@ export async function fetchRemoteLinks(ticketKey, opts = {}) {
408
408
  .map(link => ({ url: link.object.url, title: link.object.title ?? null }));
409
409
  }
410
410
 
411
+ /**
412
+ * Adds a comment to an issue. Cloud (v3) rejects a plain string body
413
+ * outright and requires ADF; Server/DC (v2) accepts plain text directly —
414
+ * same apiVersion branch point every other write/read here already uses.
415
+ */
416
+ export async function postComment(ticketKey, body, opts = {}) {
417
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
418
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
419
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
420
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/comment`;
421
+
422
+ const payload = { body: apiVersion === 3 ? textToAdf(body) : body };
423
+ const fetchOpts = {
424
+ method: 'POST',
425
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
426
+ body: JSON.stringify(payload),
427
+ };
428
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
429
+
430
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
431
+ if (!response.ok) {
432
+ const err = new Error(`Jira API error ${response.status} commenting on ${ticketKey}`);
433
+ err.status = response.status;
434
+ throw err;
435
+ }
436
+ const raw = await response.json();
437
+ return { id: raw.id, url: raw.self ?? null };
438
+ }
439
+
440
+ /**
441
+ * Discovers the transitions actually available for this specific issue
442
+ * right now (workflow-dependent, varies per project/status) — never
443
+ * hardcode transition names or ids, they aren't stable across projects.
444
+ */
445
+ export async function getTransitions(ticketKey, opts = {}) {
446
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
447
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
448
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
449
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/transitions`;
450
+
451
+ const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
452
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
453
+
454
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
455
+ if (!response.ok) {
456
+ const err = new Error(`Jira API error ${response.status} fetching transitions for ${ticketKey}`);
457
+ err.status = response.status;
458
+ throw err;
459
+ }
460
+ const raw = await response.json();
461
+ return (raw.transitions ?? []).map(t => ({ id: t.id, name: t.name, to: t.to?.name ?? null }));
462
+ }
463
+
464
+ /**
465
+ * Executes a transition by id. Callers must resolve the id via
466
+ * getTransitions() immediately before calling this — never pass a
467
+ * remembered/stale id, since transitions are workflow- and
468
+ * time-of-status-dependent (see ticket-command.mjs's re-validation).
469
+ * A transition requiring a mandatory screen field the caller didn't
470
+ * supply returns Jira's own 400 with the field list — surfaced as-is,
471
+ * never silently swallowed or auto-filled.
472
+ */
473
+ export async function postTransition(ticketKey, transitionId, opts = {}) {
474
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
475
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
476
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
477
+ const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/transitions`;
478
+
479
+ const fetchOpts = {
480
+ method: 'POST',
481
+ headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
482
+ body: JSON.stringify({ transition: { id: transitionId } }),
483
+ };
484
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
485
+
486
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
487
+ if (!response.ok) {
488
+ let details;
489
+ try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
490
+ const err = new Error(`Jira API error ${response.status} transitioning ${ticketKey}`);
491
+ err.status = response.status;
492
+ err.details = details;
493
+ throw err;
494
+ }
495
+ }
496
+
411
497
  export async function fetchTicket(ticketKey, opts = {}) {
412
498
  const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), depth = 1, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false, _visited = new Set(), _currentDepth = 0 } = opts;
413
499
  validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Registers `ticketlens mcp` into the current project's `.mcp.json` so the
3
+ * user doesn't have to hand-write the JSON. Project-scoped only — Claude
4
+ * Code's MCP config has no global/user-level equivalent (confirmed by
5
+ * inspecting a real ~/.claude.json: no top-level `mcpServers` key, it's
6
+ * nested per-project). Never touches ~/.claude.json directly.
7
+ *
8
+ * Mirrors hooks-setup.mjs's already-proven safe-merge pattern: malformed
9
+ * JSON is left untouched (never overwritten), the write is atomic (temp
10
+ * file + rename — real guarantee on POSIX, still the best available
11
+ * cross-platform approach on Windows), and every other key in the file is
12
+ * preserved exactly. Registration itself is never license-gated — same as
13
+ * `ticketlens mcp`/`note add`/`recall`, which all exist for everyone and
14
+ * gate only at actual invocation.
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
20
+ import { isLicensed } from './license.mjs';
21
+ import { createStyler } from './ansi.mjs';
22
+
23
+ const ENTRY_NAME = 'ticketlens';
24
+
25
+ function readConfig(configPath) {
26
+ if (!existsSync(configPath)) return { ok: true, config: {} };
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(readFileSync(configPath, 'utf8'));
30
+ } catch {
31
+ return { ok: false, reason: `${configPath} is not valid JSON — left untouched. Fix or remove it, then retry.` };
32
+ }
33
+ // Valid JSON whose top level isn't a plain object (array/null/number/string/
34
+ // boolean) is still valid to parse — `typeof null === 'object'` and arrays
35
+ // are objects too, so both need an explicit check, not just "did it parse."
36
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
37
+ return { ok: false, reason: `${configPath}'s top level is not a JSON object — left untouched. Fix or remove it, then retry.` };
38
+ }
39
+ if (parsed.mcpServers !== undefined && (typeof parsed.mcpServers !== 'object' || parsed.mcpServers === null || Array.isArray(parsed.mcpServers))) {
40
+ return { ok: false, reason: `${configPath}'s "mcpServers" key is not an object — left untouched. Fix or remove it, then retry.` };
41
+ }
42
+ return { ok: true, config: parsed };
43
+ }
44
+
45
+ /**
46
+ * @returns {{ written: boolean, dryRun: boolean, path: string, alreadyCorrect?: boolean, reason?: string }}
47
+ */
48
+ export function mcpInstall({
49
+ cwd = process.cwd(),
50
+ isLicensedFn = isLicensed,
51
+ configDir = DEFAULT_CONFIG_DIR,
52
+ stream = process.stdout,
53
+ dryRun = false,
54
+ } = {}) {
55
+ const configPath = join(cwd, '.mcp.json');
56
+ const s = createStyler({ isTTY: stream.isTTY });
57
+
58
+ const read = readConfig(configPath);
59
+ if (!read.ok) {
60
+ stream.write(` ${s.dim('✗')} ${read.reason}\n`);
61
+ return { written: false, dryRun, path: configPath, reason: read.reason };
62
+ }
63
+
64
+ const config = read.config;
65
+ config.mcpServers ??= {};
66
+ const desired = { command: ENTRY_NAME, args: ['mcp'] };
67
+ const existing = config.mcpServers[ENTRY_NAME];
68
+ const alreadyCorrect = existing !== undefined && JSON.stringify(existing) === JSON.stringify(desired);
69
+ config.mcpServers[ENTRY_NAME] = desired;
70
+
71
+ const serialized = JSON.stringify(config, null, 2) + '\n';
72
+
73
+ if (dryRun) {
74
+ stream.write(` Would write ${configPath}:\n\n${serialized}\n`);
75
+ return { written: false, dryRun: true, path: configPath, alreadyCorrect };
76
+ }
77
+
78
+ if (alreadyCorrect) {
79
+ stream.write(` ${s.dim('✔')} Already registered in ${configPath}\n`);
80
+ } else {
81
+ const tmpPath = `${configPath}.${process.pid}.tmp`;
82
+ writeFileSync(tmpPath, serialized, 'utf8');
83
+ renameSync(tmpPath, configPath); // atomic on POSIX; best available cross-platform approach on Windows
84
+ stream.write(` ${s.brand('✔')} Registered "ticketlens" in ${configPath}\n`);
85
+ }
86
+
87
+ if (!isLicensedFn('pro', configDir)) {
88
+ stream.write(` ${s.dim('Note: recall_add/recall_search require a Pro license — see')} ${s.cyan('ticketlens license')}${s.dim('.')}\n`);
89
+ }
90
+
91
+ return { written: !alreadyCorrect, dryRun: false, path: configPath, alreadyCorrect };
92
+ }
@@ -3,12 +3,15 @@
3
3
  *
4
4
  * A pure transport adapter: parses JSON-RPC 2.0 off stdin, translates
5
5
  * `tools/call` arguments into the exact args/dependency shape `runNoteAdd`/
6
- * `runRecall` already accept, and captures their human-readable `stream`
7
- * output into the JSON-RPC response instead of a real stream. Zero new
8
- * validation, licensing, or vault logic — both tools funnel through the
9
- * same functions the CLI's `note add`/`recall` commands already use, so
10
- * every existing gate (license, secret scan, structural check, retry
11
- * queue) applies identically here.
6
+ * `runRecall`/`runTicketComment`/`runTicketTransitionList`/
7
+ * `runTicketTransition` already accept, and captures their human-readable
8
+ * `stream` output into the JSON-RPC response instead of a real stream. Zero
9
+ * new validation, licensing, or vault logic — every tool funnels through
10
+ * the same functions the CLI's `note add`/`recall`/`comment`/`transition`
11
+ * commands already use, so every existing gate (license, secret scan,
12
+ * structural check, retry queue, cooldown, audit log) applies identically
13
+ * here. Ticket-writing tools must never import an adapter directly — doing
14
+ * so would fully bypass the Pro+ gate that lives in ticket-command.mjs.
12
15
  *
13
16
  * Per the MCP stdio transport spec, the server MUST NOT write anything to
14
17
  * stdout that isn't a valid MCP message — every wrapped function's output
@@ -20,6 +23,7 @@ import readline from 'node:readline';
20
23
  import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
21
24
  import { runNoteAdd } from './note-command.mjs';
22
25
  import { runRecall } from './recall-command.mjs';
26
+ import { runTicketComment, runTicketTransitionList, runTicketTransition } from './ticket-command.mjs';
23
27
 
24
28
  const PROTOCOL_VERSION = '2025-11-25';
25
29
 
@@ -49,6 +53,31 @@ const TOOLS = [
49
53
  required: ['query'],
50
54
  },
51
55
  },
56
+ {
57
+ name: 'ticket_comment',
58
+ description: 'Post a comment to a ticket in its tracker (Jira/GitHub/Linear). Destructive — writes directly to the live tracker, not a local Recall note. Requires a TicketLens Pro license.',
59
+ inputSchema: {
60
+ type: 'object',
61
+ properties: {
62
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
63
+ body: { type: 'string', description: 'Comment body.' },
64
+ },
65
+ required: ['ticket', 'body'],
66
+ },
67
+ },
68
+ {
69
+ name: 'ticket_transition',
70
+ description: 'List or execute a ticket status transition in its tracker (Jira/GitHub/Linear). Called with only `ticket`, lists the tracker\'s current valid options without changing anything. Destructive when `target` and `confirm: true` are both given — requires confirmation and writes directly to the live tracker. Requires a TicketLens Pro license.',
71
+ inputSchema: {
72
+ type: 'object',
73
+ properties: {
74
+ ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
75
+ target: { type: 'string', description: 'Target status/transition name. Omit to just list the tracker\'s current valid options.' },
76
+ confirm: { type: 'boolean', description: 'Must be true, alongside `target`, to actually execute the transition — a nudge and audit trail, not just a formality.' },
77
+ },
78
+ required: ['ticket'],
79
+ },
80
+ },
52
81
  ];
53
82
 
54
83
  function jsonRpcResult(id, result) {
@@ -112,14 +141,59 @@ async function callRecallSearch(args, { configDir, runRecallFn }) {
112
141
  return ok ? { content } : { isError: true, content };
113
142
  }
114
143
 
144
+ /**
145
+ * `ticket`/`body` become single opaque cmdArgs elements (`--body=${body}`),
146
+ * same reasoning as buildNoteAddArgs above — a body containing literal
147
+ * `--confirm` or `--target=` text stays inert since parseFlag only matches
148
+ * a whole array element via startsWith, never scans inside one.
149
+ */
150
+ async function callTicketComment(args, { configDir, runTicketCommentFn }) {
151
+ if (!args.ticket) {
152
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
153
+ }
154
+ if (!args.body) {
155
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: body' }] };
156
+ }
157
+ const capture = capturingStream();
158
+ const { ok } = await runTicketCommentFn([args.ticket, `--body=${args.body}`], { configDir, stream: capture });
159
+ const content = [{ type: 'text', text: capture.text }];
160
+ return ok ? { content } : { isError: true, content };
161
+ }
162
+
163
+ /**
164
+ * No `target` → discovery only, dispatched to the read-only list function —
165
+ * never touches the mutating path. `target` present → dispatched to the
166
+ * executing function, which itself still refuses without `confirm: true`
167
+ * (the MCP layer doesn't pre-empt that check, so the same refusal message
168
+ * a CLI user sees is what a calling AI harness sees too).
169
+ */
170
+ async function callTicketTransition(args, { configDir, runTicketTransitionListFn, runTicketTransitionFn }) {
171
+ if (!args.ticket) {
172
+ return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
173
+ }
174
+ const capture = capturingStream();
175
+ if (!args.target) {
176
+ const { ok } = await runTicketTransitionListFn([args.ticket], { configDir, stream: capture });
177
+ const content = [{ type: 'text', text: capture.text }];
178
+ return ok ? { content } : { isError: true, content };
179
+ }
180
+ const cmdArgs = [args.ticket, `--target=${args.target}`];
181
+ if (args.confirm === true) cmdArgs.push('--confirm');
182
+ const { ok } = await runTicketTransitionFn(cmdArgs, { configDir, stream: capture });
183
+ const content = [{ type: 'text', text: capture.text }];
184
+ return ok ? { content } : { isError: true, content };
185
+ }
186
+
115
187
  async function handleToolsCall(params, deps) {
116
188
  const { name, arguments: args = {} } = params ?? {};
117
189
  if (name === 'recall_add') return callRecallAdd(args, deps);
118
190
  if (name === 'recall_search') return callRecallSearch(args, deps);
191
+ if (name === 'ticket_comment') return callTicketComment(args, deps);
192
+ if (name === 'ticket_transition') return callTicketTransition(args, deps);
119
193
  return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
120
194
  }
121
195
 
122
- async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
196
+ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn }) {
123
197
  let msg;
124
198
  try {
125
199
  msg = JSON.parse(raw);
@@ -149,7 +223,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn }) {
149
223
 
150
224
  if (method === 'tools/call') {
151
225
  try {
152
- const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn });
226
+ const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
153
227
  return jsonRpcResult(id, result);
154
228
  } catch (err) {
155
229
  return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
@@ -172,6 +246,9 @@ export function runMcpServer({
172
246
  stdout = process.stdout,
173
247
  runNoteAddFn = runNoteAdd,
174
248
  runRecallFn = runRecall,
249
+ runTicketCommentFn = runTicketComment,
250
+ runTicketTransitionListFn = runTicketTransitionList,
251
+ runTicketTransitionFn = runTicketTransition,
175
252
  } = {}) {
176
253
  // A client can disconnect mid-write (EPIPE) at any time on a long-lived
177
254
  // process — an unhandled 'error' event on either stream would otherwise
@@ -191,7 +268,7 @@ export function runMcpServer({
191
268
  // never resolving (a dropped rejection isn't a resolution) — the
192
269
  // server would hang on shutdown instead of exiting.
193
270
  queue = queue.then(async () => {
194
- const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn });
271
+ const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
195
272
  if (response) stdout.write(response);
196
273
  }).catch(() => {});
197
274
  });
@@ -19,7 +19,7 @@ export function detectTrackerType(baseUrl) {
19
19
  * Instantiates the correct tracker adapter for a resolved connection.
20
20
  * @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
21
21
  * @param {{ fetcher?: Function }} [opts]
22
- * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function }}
22
+ * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function }}
23
23
  */
24
24
  export function resolveAdapter(conn, opts = {}) {
25
25
  const type = detectTrackerType(conn?.baseUrl);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Local debounce guard against accidentally firing the same ticket write
3
+ * twice in quick succession (a flaky agent retry loop, a doubled CLI
4
+ * invocation, a human hitting enter twice). This is not a security boundary
5
+ * and not a notification-style cooldown — just enough of a window to catch a
6
+ * true double-fire without blocking a deliberate follow-up action moments
7
+ * later.
8
+ *
9
+ * Deliberately a separate file from ticket-action-log.mjs (the append-only
10
+ * audit trail): this file is small and read on every write attempt, so it
11
+ * must stay O(1) to check. A shared file would force scanning full history
12
+ * per check, or risk one read-modify-write clobbering the other's data.
13
+ * Same read-modify-write-no-lock tradeoff already accepted by
14
+ * recall-queue.mjs for the same reason — low-frequency, single-user CLI.
15
+ */
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
20
+ import { writeFileAtomically } from './recall-vault.mjs';
21
+
22
+ const COOLDOWN_FILE = 'ticket-action-cooldown.json';
23
+
24
+ /** Default debounce window: long enough to catch a true double-fire, short enough to never block a deliberate follow-up action. */
25
+ export const DEFAULT_COOLDOWN_MS = 10_000;
26
+
27
+ function cooldownPath(configDir) {
28
+ return path.join(configDir, COOLDOWN_FILE);
29
+ }
30
+
31
+ function readCooldowns(configDir) {
32
+ try {
33
+ const parsed = JSON.parse(fs.readFileSync(cooldownPath(configDir), 'utf8'));
34
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ function cooldownKey(ticketKey, action) {
41
+ return `${ticketKey}:${action}`;
42
+ }
43
+
44
+ /**
45
+ * @param {string} ticketKey
46
+ * @param {string} action
47
+ * @param {{ configDir?: string, cooldownMs?: number, now?: () => number }} [opts]
48
+ * @returns {{ active: boolean, remainingMs: number }}
49
+ */
50
+ export function checkCooldown(ticketKey, action, {
51
+ configDir = DEFAULT_CONFIG_DIR,
52
+ cooldownMs = DEFAULT_COOLDOWN_MS,
53
+ now = () => Date.now(),
54
+ } = {}) {
55
+ const lastAt = readCooldowns(configDir)[cooldownKey(ticketKey, action)];
56
+ if (!lastAt) return { active: false, remainingMs: 0 };
57
+
58
+ const elapsed = now() - new Date(lastAt).getTime();
59
+ const remainingMs = cooldownMs - elapsed;
60
+ return remainingMs > 0 ? { active: true, remainingMs } : { active: false, remainingMs: 0 };
61
+ }
62
+
63
+ /**
64
+ * @param {string} ticketKey
65
+ * @param {string} action
66
+ * @param {{ configDir?: string, now?: () => number }} [opts]
67
+ */
68
+ export function recordAction(ticketKey, action, { configDir = DEFAULT_CONFIG_DIR, now = () => Date.now() } = {}) {
69
+ const cooldowns = readCooldowns(configDir);
70
+ cooldowns[cooldownKey(ticketKey, action)] = new Date(now()).toISOString();
71
+ writeFileAtomically(cooldownPath(configDir), JSON.stringify(cooldowns));
72
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Append-only forensic audit trail for ticket writes (comment/transition).
3
+ * Every entry is JSON-serialized on its own line — comment bodies are
4
+ * attacker/AI-controlled free text, and a raw newline embedded in one could
5
+ * otherwise forge a fake extra log line (same bug class already fixed once
6
+ * in this codebase for brief section filenames). JSON.stringify escapes
7
+ * embedded newlines as \n within the string, so one JSON.parse per line
8
+ * always recovers exactly one real entry.
9
+ *
10
+ * Deliberately a separate file from ticket-action-cooldown.mjs — this one
11
+ * only ever grows and is never read on the hot path of a write attempt, so
12
+ * its O(n) full-file nature doesn't matter to normal command latency.
13
+ *
14
+ * ticketKey is validated by the caller (ticket-command.mjs, via
15
+ * TICKET_KEY_PATTERN) before it ever reaches here — this module re-validates
16
+ * defensively since a log line is a place a malformed key could otherwise do
17
+ * real damage (path traversal has no surface here since the log file path
18
+ * never incorporates ticketKey, but a stray literal newline inside an
19
+ * unvalidated key would reintroduce exactly the forgeable-line risk this
20
+ * file exists to prevent).
21
+ */
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
26
+ import { TICKET_KEY_PATTERN } from './cli.mjs';
27
+
28
+ const LOG_FILE = 'ticket-action-log.jsonl';
29
+
30
+ function logPath(configDir) {
31
+ return path.join(configDir, LOG_FILE);
32
+ }
33
+
34
+ /**
35
+ * @param {{ ticketKey: string, action: 'comment'|'transition', actor: string, tracker: string, detail?: object }} entry
36
+ * @param {{ configDir?: string, now?: () => Date }} [opts]
37
+ */
38
+ export function logAction({ ticketKey, action, actor, tracker, detail = {} }, {
39
+ configDir = DEFAULT_CONFIG_DIR,
40
+ now = () => new Date(),
41
+ } = {}) {
42
+ if (!TICKET_KEY_PATTERN.test(ticketKey)) {
43
+ throw new Error(`Refusing to log malformed ticket key: ${JSON.stringify(ticketKey)}`);
44
+ }
45
+ const line = JSON.stringify({ ticketKey, action, actor, tracker, detail, at: now().toISOString() });
46
+ fs.appendFileSync(logPath(configDir), line + '\n', 'utf8');
47
+ }
48
+
49
+ /**
50
+ * @param {string} [configDir]
51
+ * @returns {object[]} parsed entries, oldest first — a corrupt individual
52
+ * line is skipped rather than failing the whole read, since this is an
53
+ * append-only historical record and one bad line must not hide the rest.
54
+ */
55
+ export function readActionLog(configDir = DEFAULT_CONFIG_DIR) {
56
+ let raw;
57
+ try {
58
+ raw = fs.readFileSync(logPath(configDir), 'utf8');
59
+ } catch {
60
+ return [];
61
+ }
62
+ const entries = [];
63
+ for (const line of raw.split('\n')) {
64
+ if (!line.trim()) continue;
65
+ try {
66
+ entries.push(JSON.parse(line));
67
+ } catch {
68
+ // Skip a corrupt line — never let one bad append hide the rest of the trail.
69
+ }
70
+ }
71
+ return entries;
72
+ }