ticketlens 0.23.0 → 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.
- package/README.md +27 -4
- package/bin/ticketlens.mjs +29 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +24 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +79 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +33 -5
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +101 -0
- package/skills/jtb/scripts/lib/adf-converter.mjs +19 -0
- package/skills/jtb/scripts/lib/cli.mjs +8 -0
- package/skills/jtb/scripts/lib/help.mjs +58 -5
- package/skills/jtb/scripts/lib/jira-client.mjs +87 -1
- package/skills/jtb/scripts/lib/mcp-server.mjs +86 -9
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +1 -1
- package/skills/jtb/scripts/lib/ticket-action-cooldown.mjs +72 -0
- package/skills/jtb/scripts/lib/ticket-action-log.mjs +72 -0
- package/skills/jtb/scripts/lib/ticket-command.mjs +236 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
> Your AI assistant shouldn't need to read your tickets.
|
|
16
16
|
|
|
17
|
-
>
|
|
17
|
+
> Reads your tickets, tells you which ones need you right now — and now writes back too: post comments and transition status directly from your terminal or AI session.
|
|
18
18
|
|
|
19
19
|
<div align="center"><img src="docs/demos/fetch.gif" alt="ticketlens CNV1-2 demo" width="700" /></div>
|
|
20
20
|
|
|
@@ -411,7 +411,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
411
411
|
|
|
412
412
|
**Removing a note:** `ticketlens note delete --id="..." [--ticket=KEY]` removes a note from your local vault. Local only — if it was already pushed to a team, teammates who pulled it keep their copy; deleting it there too is a manager action from the Console (Admin > Recall).
|
|
413
413
|
|
|
414
|
-
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add` and `
|
|
414
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, and `ticket_transition` 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
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.
|
|
417
417
|
|
|
@@ -419,6 +419,22 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
419
419
|
|
|
420
420
|
---
|
|
421
421
|
|
|
422
|
+
### Comment & Transition
|
|
423
|
+
|
|
424
|
+
```bash
|
|
425
|
+
ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
|
|
426
|
+
ticketlens transition PROJ-123 # List valid transitions (read-only)
|
|
427
|
+
ticketlens transition PROJ-123 --target="Done" --confirm # Execute the transition
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Write directly to the ticket in its real tracker — Jira, GitHub, or Linear — from your terminal or an AI session via `ticket_comment`/`ticket_transition` MCP tools. Requires a Pro license.
|
|
431
|
+
|
|
432
|
+
`ticketlens transition` with just a ticket key lists the tracker's current valid options without changing anything (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Add both `--target` and `--confirm` to execute — `--confirm` is a deliberate two-step gate: a behavioral nudge and forensic trail, not a hard security guarantee. Every write, once resolved, is re-validated against the tracker's current state immediately before executing — never a blind write against a stale option.
|
|
433
|
+
|
|
434
|
+
Both actions have a short local debounce (10s) against an accidental double-fire (a flaky retry, hitting enter twice), and every successful write is appended to a local, append-only audit log (`~/.ticketlens/ticket-action-log.jsonl`). A write that times out is never retried automatically — unlike Recall notes, ticket comments/transitions aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated.
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
422
438
|
### Response-Time Stats
|
|
423
439
|
|
|
424
440
|
```bash
|
|
@@ -692,10 +708,15 @@ ticketlens recall CNV1-2 # Search saved notes by ticket key
|
|
|
692
708
|
ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
|
|
693
709
|
ticketlens recall sync # Retry any notes stuck in the local queue [Team+]
|
|
694
710
|
ticketlens recall settings # Show effective retry-queue settings, fetched live [Team+]
|
|
695
|
-
ticketlens mcp # Start the MCP stdio server (
|
|
711
|
+
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools) [Pro]
|
|
696
712
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
697
713
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
698
714
|
|
|
715
|
+
# ── Comment & Transition ─────────────────────────────────────────────────────
|
|
716
|
+
ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
|
|
717
|
+
ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
|
|
718
|
+
ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
|
|
719
|
+
|
|
699
720
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
700
721
|
ticketlens stats # Response-time metrics from local history
|
|
701
722
|
ticketlens stats --profile=acme # Metrics for a specific profile
|
|
@@ -776,7 +797,9 @@ ticketlens note delete --id="..." # Remove a note from your local vault
|
|
|
776
797
|
ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
|
|
777
798
|
ticketlens recall sync # Retry any notes stuck in the local queue
|
|
778
799
|
ticketlens recall settings # Show effective retry-queue settings, fetched live
|
|
779
|
-
ticketlens mcp # Start the MCP stdio server (
|
|
800
|
+
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools)
|
|
801
|
+
ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
|
|
802
|
+
ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
|
|
780
803
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
781
804
|
```
|
|
782
805
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
printCollisionsHelp, printStatsHelp,
|
|
29
29
|
printCloudKeysHelp,
|
|
30
30
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
31
|
+
printCommentHelp, printTransitionHelp,
|
|
31
32
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
32
33
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
33
34
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
@@ -737,6 +738,34 @@ switch (command) {
|
|
|
737
738
|
break;
|
|
738
739
|
}
|
|
739
740
|
|
|
741
|
+
case 'comment': {
|
|
742
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCommentHelp(); break; }
|
|
743
|
+
const { runTicketComment } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
744
|
+
runTicketComment(cmdArgs).then(({ ok }) => {
|
|
745
|
+
if (!ok) process.exitCode = 1;
|
|
746
|
+
}).catch(err => {
|
|
747
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
748
|
+
process.exitCode = 1;
|
|
749
|
+
});
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
case 'transition': {
|
|
754
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printTransitionHelp(); break; }
|
|
755
|
+
const { runTicketTransitionList, runTicketTransition } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
756
|
+
// No --target → discovery only, never mutates. --target present → execute
|
|
757
|
+
// (runTicketTransition itself still refuses without --confirm).
|
|
758
|
+
const hasTarget = cmdArgs.some(a => a.startsWith('--target='));
|
|
759
|
+
const runFn = hasTarget ? runTicketTransition : runTicketTransitionList;
|
|
760
|
+
runFn(cmdArgs).then(({ ok }) => {
|
|
761
|
+
if (!ok) process.exitCode = 1;
|
|
762
|
+
}).catch(err => {
|
|
763
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
764
|
+
process.exitCode = 1;
|
|
765
|
+
});
|
|
766
|
+
break;
|
|
767
|
+
}
|
|
768
|
+
|
|
740
769
|
case 'help':
|
|
741
770
|
default: {
|
|
742
771
|
const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.24.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.
|
|
@@ -60,6 +60,9 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
60
60
|
/jtb recall PROD-1234 # search saved Recall notes (Pro)
|
|
61
61
|
/jtb recall sync # retry any notes stuck in the local queue (Team+)
|
|
62
62
|
/jtb recall settings # show effective retry-queue settings, fetched live (Team+)
|
|
63
|
+
/jtb comment PROD-1234 --body="..." # post a comment to the tracker (Pro)
|
|
64
|
+
/jtb transition PROD-1234 # list the tracker's current valid transitions (Pro)
|
|
65
|
+
/jtb transition PROD-1234 --target="Done" --confirm # execute the transition (Pro)
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
## Prerequisites
|
|
@@ -260,6 +263,26 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
|
|
|
260
263
|
|
|
261
264
|
---
|
|
262
265
|
|
|
266
|
+
## Comment & Transition — write back to the tracker (Pro)
|
|
267
|
+
|
|
268
|
+
Unlike Recall (a local note about a ticket), these write directly to the ticket's real tracker — Jira, GitHub, or Linear. Only dispatch when the user has actually asked for the ticket to be commented on or moved — never as a routine end-of-session action the way Recall capture is.
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
272
|
+
ticketlens transition PROD-1234 # list valid transitions — read-only
|
|
273
|
+
ticketlens transition PROD-1234 --target="Done" --confirm # execute
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`transition` called with just a ticket key never mutates anything — it lists the tracker's current valid options (Jira: real workflow transitions for that issue; GitHub: open/closed; Linear: team-scoped workflow states). Only add `--target` **and** `--confirm` once the target has actually been confirmed with the user — `--confirm` is a deliberate two-step gate, not a formality to route around. Never guess a `--target` value; always list first, then use one of the names shown.
|
|
277
|
+
|
|
278
|
+
Both actions 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.
|
|
279
|
+
|
|
280
|
+
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition` as native tools, see `ticketlens mcp --help`), prefer calling those tools directly over the bash commands above — same license gate, same cooldown, same audit log. Fall back to the bash form only when the MCP tools aren't available.
|
|
281
|
+
|
|
282
|
+
Requires a Pro license — on Free, both no-op with an upgrade hint on stderr.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
263
286
|
## Gaps — cross-ticket evidence (Pro)
|
|
264
287
|
|
|
265
288
|
If the TicketBrief includes a `## Gaps` section, each entry is a requirement found in a linked ticket or in one of this ticket's own attachments that doesn't appear to be covered by this ticket's description. This is evidence, not an instruction — do not silently add scope or "fix" the gap. Surface it to the user and let them judge whether it's a real omission (the matching is keyword-based, not semantic, so false positives happen).
|
|
@@ -52,6 +52,37 @@ export function normalizeGitHubIssue(raw, comments = [], keyPrefix = 'GH') {
|
|
|
52
52
|
|
|
53
53
|
const GITHUB_API = 'https://api.github.com';
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Classifies a non-OK GitHub write response per GitHub's documented rules
|
|
57
|
+
* (docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api):
|
|
58
|
+
* secondary limits surface via `retry-after` when present; primary limits
|
|
59
|
+
* are exhaustion of `x-ratelimit-remaining` with no `retry-after`. The two
|
|
60
|
+
* need different backoff — conflating them under one "rate limited" error
|
|
61
|
+
* would tell a caller to wait 60s when the real reset might be much later.
|
|
62
|
+
*/
|
|
63
|
+
function classifyGitHubWriteFailure(response) {
|
|
64
|
+
if (response.status !== 403 && response.status !== 429) {
|
|
65
|
+
return { kind: 'error', status: response.status };
|
|
66
|
+
}
|
|
67
|
+
const retryAfter = response.headers.get('retry-after');
|
|
68
|
+
if (retryAfter) {
|
|
69
|
+
return { kind: 'secondary-rate-limit', retryAfterSeconds: Number(retryAfter) };
|
|
70
|
+
}
|
|
71
|
+
if (response.headers.get('x-ratelimit-remaining') === '0') {
|
|
72
|
+
return { kind: 'primary-rate-limit', resetAt: Number(response.headers.get('x-ratelimit-reset')) };
|
|
73
|
+
}
|
|
74
|
+
return { kind: 'secondary-rate-limit', retryAfterSeconds: 60 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function throwGitHubWriteError(response, action, key) {
|
|
78
|
+
const classification = classifyGitHubWriteFailure(response);
|
|
79
|
+
const err = new Error(`GitHub API error ${response.status} ${action} ${key}`);
|
|
80
|
+
err.status = response.status;
|
|
81
|
+
err.rateLimit = classification.kind !== 'error' ? classification : undefined;
|
|
82
|
+
try { err.details = await response.json(); } catch { /* body not JSON */ }
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
/**
|
|
56
87
|
* Returns a tracker adapter backed by the GitHub Issues REST API.
|
|
57
88
|
* Profile baseUrl must be https://github.com/OWNER/REPO.
|
|
@@ -108,5 +139,53 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
108
139
|
async fetchStatuses() {
|
|
109
140
|
return ['open', 'closed'];
|
|
110
141
|
},
|
|
142
|
+
|
|
143
|
+
async addComment(key, body, opts = {}) {
|
|
144
|
+
const number = parseInt(key.split('-').pop(), 10);
|
|
145
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/comments`, {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify({ body }),
|
|
149
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) await throwGitHubWriteError(res, 'commenting on', key);
|
|
152
|
+
const raw = await res.json();
|
|
153
|
+
return { id: String(raw.id), url: raw.html_url ?? null };
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* GitHub issues have exactly one valid opposite state — not a
|
|
158
|
+
* discoverable workflow like Jira. Reports it in the same
|
|
159
|
+
* {id, name, to} shape as Jira's getTransitions for a uniform
|
|
160
|
+
* cross-tracker adapter contract.
|
|
161
|
+
*/
|
|
162
|
+
async getTransitions(key, opts = {}) {
|
|
163
|
+
const ticket = await this.fetchTicket(key, opts);
|
|
164
|
+
return ticket.status === 'open'
|
|
165
|
+
? [{ id: 'closed', name: 'Close issue', to: 'closed' }]
|
|
166
|
+
: [{ id: 'open', name: 'Reopen issue', to: 'open' }];
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async transition(key, target, opts = {}) {
|
|
170
|
+
const ticket = await this.fetchTicket(key, opts);
|
|
171
|
+
const t = String(target).toLowerCase();
|
|
172
|
+
if (t === ticket.status) {
|
|
173
|
+
return { executed: false, reason: 'already-in-target-state', options: await this.getTransitions(key, opts) };
|
|
174
|
+
}
|
|
175
|
+
const options = await this.getTransitions(key, opts);
|
|
176
|
+
const match = options.find(o => o.id === t || o.name.toLowerCase() === t || o.to === t);
|
|
177
|
+
if (!match) {
|
|
178
|
+
return { executed: false, reason: 'not-found', options };
|
|
179
|
+
}
|
|
180
|
+
const number = parseInt(key.split('-').pop(), 10);
|
|
181
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
|
|
182
|
+
method: 'PATCH',
|
|
183
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
184
|
+
body: JSON.stringify({ state: match.to }),
|
|
185
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
186
|
+
});
|
|
187
|
+
if (!res.ok) await throwGitHubWriteError(res, 'transitioning', key);
|
|
188
|
+
return { executed: true, to: match.to };
|
|
189
|
+
},
|
|
111
190
|
};
|
|
112
191
|
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition } from '../jira-client.mjs';
|
|
2
2
|
import { buildJiraEnv } from '../config.mjs';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Finds the option in a fresh transitions list matching a caller-given
|
|
6
|
+
* target — by id (exact) or by name/to-name (case-insensitive). Never
|
|
7
|
+
* trusts a caller-supplied id without confirming it's still a real,
|
|
8
|
+
* currently-valid option for this exact issue right now.
|
|
9
|
+
*/
|
|
10
|
+
function resolveTransitionTarget(options, target) {
|
|
11
|
+
const t = String(target).toLowerCase();
|
|
12
|
+
return options.find(o => o.id === String(target) || o.name.toLowerCase() === t || (o.to ?? '').toLowerCase() === t);
|
|
13
|
+
}
|
|
14
|
+
|
|
4
15
|
/**
|
|
5
16
|
* Returns a tracker adapter backed by the Jira REST API.
|
|
6
17
|
* Binds connection credentials so callers never touch jira-client directly.
|
|
@@ -8,12 +19,29 @@ import { buildJiraEnv } from '../config.mjs';
|
|
|
8
19
|
export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
9
20
|
const env = buildJiraEnv(conn);
|
|
10
21
|
const apiVersion = conn.auth === 'cloud' ? 3 : 2;
|
|
22
|
+
const base = { env, fetcher, apiVersion, allowPrivateIp: conn.allowPrivateIp };
|
|
11
23
|
|
|
12
24
|
return {
|
|
13
25
|
type: 'jira',
|
|
14
|
-
fetchTicket: (key, opts = {}) => fetchTicket(key, {
|
|
15
|
-
fetchCurrentUser: (opts = {}) => fetchCurrentUser({
|
|
16
|
-
searchTickets: (query, opts = {}) => searchTickets(query, {
|
|
17
|
-
fetchStatuses: (opts = {}) => fetchStatuses({
|
|
26
|
+
fetchTicket: (key, opts = {}) => fetchTicket(key, { ...base, ...opts }),
|
|
27
|
+
fetchCurrentUser: (opts = {}) => fetchCurrentUser({ ...base, ...opts }),
|
|
28
|
+
searchTickets: (query, opts = {}) => searchTickets(query, { ...base, ...opts }),
|
|
29
|
+
fetchStatuses: (opts = {}) => fetchStatuses({ ...base, ...opts }),
|
|
30
|
+
addComment: (key, body, opts = {}) => postComment(key, body, { ...base, ...opts }),
|
|
31
|
+
getTransitions: (key, opts = {}) => getTransitions(key, { ...base, ...opts }),
|
|
32
|
+
/**
|
|
33
|
+
* Always re-fetches transitions fresh and resolves `target` against
|
|
34
|
+
* them before executing — a caller can never blind-POST a stale or
|
|
35
|
+
* guessed transition id, even if they try.
|
|
36
|
+
*/
|
|
37
|
+
async transition(key, target, opts = {}) {
|
|
38
|
+
const options = await getTransitions(key, { ...base, ...opts });
|
|
39
|
+
const match = resolveTransitionTarget(options, target);
|
|
40
|
+
if (!match) {
|
|
41
|
+
return { executed: false, reason: 'not-found', options };
|
|
42
|
+
}
|
|
43
|
+
await postTransition(key, match.id, { ...base, ...opts });
|
|
44
|
+
return { executed: true, to: match.to ?? match.name };
|
|
45
|
+
},
|
|
18
46
|
};
|
|
19
47
|
}
|
|
@@ -65,6 +65,39 @@ async function gql(query, variables, { token, fetcher, signal }) {
|
|
|
65
65
|
return data;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Resolves a human identifier (e.g. "ENG-123") to the issue's internal id
|
|
70
|
+
* plus its current state and team — mutations require the UUID id, never
|
|
71
|
+
* the identifier string (confirmed against Linear's own SDK docs).
|
|
72
|
+
*/
|
|
73
|
+
async function fetchIssueStateInfo(key, { token, fetcher, signal }) {
|
|
74
|
+
const data = await gql(
|
|
75
|
+
`query ($id: String!) {
|
|
76
|
+
issues(filter: { identifier: { eq: $id } }, first: 1) {
|
|
77
|
+
nodes { id state { id name } team { id } }
|
|
78
|
+
}
|
|
79
|
+
}`,
|
|
80
|
+
{ id: key },
|
|
81
|
+
{ token, fetcher, signal },
|
|
82
|
+
);
|
|
83
|
+
const node = data.issues?.nodes?.[0];
|
|
84
|
+
if (!node) throw new Error(`Linear issue not found: ${key}`);
|
|
85
|
+
return node;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
|
|
89
|
+
const data = await gql(
|
|
90
|
+
`query ($teamId: ID!) {
|
|
91
|
+
workflowStates(filter: { team: { id: { eq: $teamId } } }, first: 50) {
|
|
92
|
+
nodes { id name }
|
|
93
|
+
}
|
|
94
|
+
}`,
|
|
95
|
+
{ teamId },
|
|
96
|
+
{ token, fetcher, signal },
|
|
97
|
+
);
|
|
98
|
+
return data.workflowStates?.nodes ?? [];
|
|
99
|
+
}
|
|
100
|
+
|
|
68
101
|
/**
|
|
69
102
|
* Returns a tracker adapter backed by the Linear GraphQL API.
|
|
70
103
|
* Profile baseUrl must contain linear.app. Auth token stored as apiToken in credentials.json.
|
|
@@ -130,5 +163,73 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
130
163
|
);
|
|
131
164
|
return (data.workflowStates?.nodes ?? []).map(s => s.name);
|
|
132
165
|
},
|
|
166
|
+
|
|
167
|
+
async addComment(key, body, opts = {}) {
|
|
168
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
169
|
+
const { id: issueId } = await fetchIssueStateInfo(key, { token, fetcher, signal });
|
|
170
|
+
const data = await gql(
|
|
171
|
+
`mutation ($issueId: String!, $body: String!) {
|
|
172
|
+
commentCreate(input: { issueId: $issueId, body: $body }) {
|
|
173
|
+
success
|
|
174
|
+
comment { id url }
|
|
175
|
+
}
|
|
176
|
+
}`,
|
|
177
|
+
{ issueId, body },
|
|
178
|
+
{ token, fetcher, signal },
|
|
179
|
+
);
|
|
180
|
+
if (!data.commentCreate?.success) {
|
|
181
|
+
throw new Error(`Linear commentCreate reported success:false for ${key}`);
|
|
182
|
+
}
|
|
183
|
+
return { id: data.commentCreate.comment.id, url: data.commentCreate.comment.url ?? null };
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Scoped to the issue's own team — Linear's workflow states are
|
|
188
|
+
* per-team, so an unscoped list would offer states from teams this
|
|
189
|
+
* issue can never actually move into.
|
|
190
|
+
*/
|
|
191
|
+
async getTransitions(key, opts = {}) {
|
|
192
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
193
|
+
const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
|
|
194
|
+
const states = await fetchTeamWorkflowStates(info.team.id, { token, fetcher, signal });
|
|
195
|
+
return states
|
|
196
|
+
.filter(s => s.id !== info.state?.id)
|
|
197
|
+
.map(s => ({ id: s.id, name: s.name, to: s.name }));
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Always re-resolves the issue's current state and team-scoped
|
|
202
|
+
* options fresh before executing — a caller can never blind-mutate
|
|
203
|
+
* with a stale stateId. Explicitly checks `success` on the mutation
|
|
204
|
+
* payload: Linear can return HTTP 200 with no top-level GraphQL
|
|
205
|
+
* `errors` and still report success:false (e.g. permission denial),
|
|
206
|
+
* so absence of `errors` alone does not mean the mutation applied.
|
|
207
|
+
*/
|
|
208
|
+
async transition(key, target, opts = {}) {
|
|
209
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
210
|
+
const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
|
|
211
|
+
const states = await fetchTeamWorkflowStates(info.team.id, { token, fetcher, signal });
|
|
212
|
+
const t = String(target).toLowerCase();
|
|
213
|
+
const options = states
|
|
214
|
+
.filter(s => s.id !== info.state?.id)
|
|
215
|
+
.map(s => ({ id: s.id, name: s.name, to: s.name }));
|
|
216
|
+
const match = options.find(o => o.id === String(target) || o.name.toLowerCase() === t);
|
|
217
|
+
if (!match) {
|
|
218
|
+
return { executed: false, reason: 'not-found', options };
|
|
219
|
+
}
|
|
220
|
+
const data = await gql(
|
|
221
|
+
`mutation ($id: String!, $stateId: String!) {
|
|
222
|
+
issueUpdate(id: $id, input: { stateId: $stateId }) {
|
|
223
|
+
success
|
|
224
|
+
}
|
|
225
|
+
}`,
|
|
226
|
+
{ id: info.id, stateId: match.id },
|
|
227
|
+
{ token, fetcher, signal },
|
|
228
|
+
);
|
|
229
|
+
if (!data.issueUpdate?.success) {
|
|
230
|
+
return { executed: false, reason: 'mutation-rejected', options };
|
|
231
|
+
}
|
|
232
|
+
return { executed: true, to: match.to };
|
|
233
|
+
},
|
|
133
234
|
};
|
|
134
235
|
}
|
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
* comment bodies are ADF objects instead of plain text strings.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Converts plain text to a minimal ADF document — one paragraph node per
|
|
9
|
+
* blank-line-separated block, one text node per paragraph. Jira Cloud (API
|
|
10
|
+
* v3) rejects a plain string comment body outright; Server/DC (v2) accepts
|
|
11
|
+
* one directly. No rich-text/markdown conversion — deliberately minimal,
|
|
12
|
+
* matching the narrow-schema scope of ticket_comment (no formatting inputs
|
|
13
|
+
* accepted, so none need representing here).
|
|
14
|
+
*/
|
|
15
|
+
export function textToAdf(text) {
|
|
16
|
+
const paragraphs = (text || '').split('\n\n').filter(Boolean);
|
|
17
|
+
return {
|
|
18
|
+
version: 1,
|
|
19
|
+
type: 'doc',
|
|
20
|
+
content: paragraphs.length > 0
|
|
21
|
+
? paragraphs.map(p => ({ type: 'paragraph', content: [{ type: 'text', text: p }] }))
|
|
22
|
+
: [{ type: 'paragraph', content: [] }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
7
26
|
export function adfToText(value) {
|
|
8
27
|
if (value == null) return '';
|
|
9
28
|
if (typeof value === 'string') return value;
|
|
@@ -130,6 +130,14 @@ export function parseCommand(args) {
|
|
|
130
130
|
return { command: 'mcp', args: args.slice(1) };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
if (first === 'comment') {
|
|
134
|
+
return { command: 'comment', args: args.slice(1) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (first === 'transition') {
|
|
138
|
+
return { command: 'transition', args: args.slice(1) };
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
134
142
|
return { command: 'fetch', args };
|
|
135
143
|
}
|
|
@@ -54,6 +54,8 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
54
54
|
` ${s.brand('ticketlens')} recall sync Retry any notes stuck in the local queue ${s.dim('[Team+]')}`,
|
|
55
55
|
` ${s.brand('ticketlens')} recall settings Show effective retry-queue settings, fetched live ${s.dim('[Team+]')}`,
|
|
56
56
|
` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
|
|
57
|
+
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
58
|
+
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
57
59
|
'',
|
|
58
60
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
59
61
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -592,11 +594,13 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
592
594
|
'',
|
|
593
595
|
` ${s.bold(s.brand('ticketlens'))} ${s.bold('mcp')} ${s.dim('[Pro]')}`,
|
|
594
596
|
'',
|
|
595
|
-
` Start an MCP (Model Context Protocol) stdio server exposing Recall
|
|
596
|
-
` native tools — ${s.cyan('recall_add')}
|
|
597
|
-
` harness, not just Claude Code
|
|
598
|
-
` ${s.cyan('note add')}/${s.cyan('recall')}
|
|
599
|
-
`
|
|
597
|
+
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
598
|
+
` ticket writes as native tools — ${s.cyan('recall_add')}, ${s.cyan('recall_search')}, ${s.cyan('ticket_comment')},`,
|
|
599
|
+
` ${s.cyan('ticket_transition')} — for any MCP-compatible AI harness, not just Claude Code.`,
|
|
600
|
+
` Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/${s.cyan('transition')}`,
|
|
601
|
+
` above: same Pro gate, same local vault/tracker writes, same team sync.`,
|
|
602
|
+
` ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true\`.`,
|
|
603
|
+
` Long-running — exits when the client closes stdin.`,
|
|
600
604
|
'',
|
|
601
605
|
` ${s.bold('OPTIONS')}`,
|
|
602
606
|
'',
|
|
@@ -623,6 +627,55 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
623
627
|
stream.write(lines.join('\n') + '\n');
|
|
624
628
|
}
|
|
625
629
|
|
|
630
|
+
export function printCommentHelp({ stream = process.stdout } = {}) {
|
|
631
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
632
|
+
const lines = [
|
|
633
|
+
'',
|
|
634
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('comment')} ${s.dim('TICKET-KEY --body="..."')} ${s.dim('[Pro]')}`,
|
|
635
|
+
'',
|
|
636
|
+
` Post a comment directly to the ticket in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
637
|
+
` Writes to the real tracker — this is not a local Recall note.`,
|
|
638
|
+
'',
|
|
639
|
+
` ${s.bold('OPTIONS')}`,
|
|
640
|
+
'',
|
|
641
|
+
` ${s.brand('--body')}=${s.dim('TEXT')} Comment body ${s.dim('(required)')}`,
|
|
642
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
643
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
644
|
+
'',
|
|
645
|
+
` ${s.bold('EXAMPLES')}`,
|
|
646
|
+
'',
|
|
647
|
+
` ${s.dim('$')} ticketlens comment PROD-123 --body="Looks good, merging."`,
|
|
648
|
+
'',
|
|
649
|
+
];
|
|
650
|
+
stream.write(lines.join('\n') + '\n');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function printTransitionHelp({ stream = process.stdout } = {}) {
|
|
654
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
655
|
+
const lines = [
|
|
656
|
+
'',
|
|
657
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('transition')} ${s.dim('TICKET-KEY [--target="..." --confirm]')} ${s.dim('[Pro]')}`,
|
|
658
|
+
'',
|
|
659
|
+
` Move a ticket to a new status in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
660
|
+
` Called with just a ticket key, lists the tracker's current valid options`,
|
|
661
|
+
` without changing anything. Add ${s.brand('--target')} and ${s.brand('--confirm')} together to execute.`,
|
|
662
|
+
'',
|
|
663
|
+
` ${s.bold('OPTIONS')}`,
|
|
664
|
+
'',
|
|
665
|
+
` ${s.brand('--target')}=${s.dim('NAME')} Target status/transition name ${s.dim('(from the list, case-insensitive)')}`,
|
|
666
|
+
` ${s.brand('--confirm')} Required alongside --target to actually execute`,
|
|
667
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
668
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
669
|
+
'',
|
|
670
|
+
` ${s.bold('EXAMPLES')}`,
|
|
671
|
+
'',
|
|
672
|
+
` ${s.dim('$')} ticketlens transition PROD-123`,
|
|
673
|
+
` ${s.dim('$')} ticketlens transition PROD-123 --target="Done" --confirm`,
|
|
674
|
+
'',
|
|
675
|
+
];
|
|
676
|
+
stream.write(lines.join('\n') + '\n');
|
|
677
|
+
}
|
|
678
|
+
|
|
626
679
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
627
680
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
628
681
|
const lines = [
|
|
@@ -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);
|
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implements `tl comment` and `tl transition`. Both are Pro+-gated writes to
|
|
3
|
+
* the underlying tracker (Jira/GitHub/Linear), consistent with the rest of
|
|
4
|
+
* the Recall/MCP family. `transition` is split into two functions from the
|
|
5
|
+
* start — list (read-only discovery) and execute (requires a resolved
|
|
6
|
+
* --target + --confirm) — rather than one function branching internally,
|
|
7
|
+
* so each independently matches the established runX(cmdArgs, deps) -> {ok}
|
|
8
|
+
* single-decision shape. `--confirm` is a behavioral nudge and audit trail,
|
|
9
|
+
* not a hard security guarantee — framed that way deliberately, not oversold.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
14
|
+
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
15
|
+
import { resolveConnection } from './profile-resolver.mjs';
|
|
16
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
17
|
+
import { checkCooldown, recordAction } from './ticket-action-cooldown.mjs';
|
|
18
|
+
import { logAction } from './ticket-action-log.mjs';
|
|
19
|
+
import { TICKET_KEY_PATTERN } from './cli.mjs';
|
|
20
|
+
|
|
21
|
+
function parseFlag(cmdArgs, name) {
|
|
22
|
+
return cmdArgs.find(a => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Distinguishes retryable/terminal/rate-limited write failures so CLI and
|
|
27
|
+
* MCP callers get the same actionable signal instead of a generic catch —
|
|
28
|
+
* mirrors recall-queue.mjs's isRetryableFailure/pushNote pairing. Never
|
|
29
|
+
* auto-retries a timeout itself: a timed-out write may have already landed
|
|
30
|
+
* server-side, unlike Recall's idempotent-by-external_id notes.
|
|
31
|
+
*
|
|
32
|
+
* @param {Error & { status?: number, rateLimit?: object }} err
|
|
33
|
+
* @returns {{ kind: 'rate-limited'|'network-or-timeout'|'server-error'|'terminal', [key: string]: unknown }}
|
|
34
|
+
*/
|
|
35
|
+
export function classifyWriteFailure(err) {
|
|
36
|
+
if (err?.rateLimit) {
|
|
37
|
+
return { kind: 'rate-limited', detail: err.rateLimit };
|
|
38
|
+
}
|
|
39
|
+
if (err?.status === undefined) {
|
|
40
|
+
return { kind: 'network-or-timeout' };
|
|
41
|
+
}
|
|
42
|
+
if (err.status >= 500) {
|
|
43
|
+
return { kind: 'server-error', status: err.status };
|
|
44
|
+
}
|
|
45
|
+
return { kind: 'terminal', status: err.status, details: err.details };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatWriteFailure(ticketKey, err) {
|
|
49
|
+
const classification = classifyWriteFailure(err);
|
|
50
|
+
switch (classification.kind) {
|
|
51
|
+
case 'rate-limited': {
|
|
52
|
+
const wait = classification.detail.retryAfterSeconds ?? null;
|
|
53
|
+
return wait
|
|
54
|
+
? ` Rate limited by the tracker — retry ${ticketKey} after ~${wait}s.\n`
|
|
55
|
+
: ` Rate limited by the tracker — try ${ticketKey} again later.\n`;
|
|
56
|
+
}
|
|
57
|
+
case 'network-or-timeout':
|
|
58
|
+
return ` Network error or timeout writing to ${ticketKey} — not retried automatically (a timed-out write may have already landed). Check the ticket before retrying.\n`;
|
|
59
|
+
case 'server-error':
|
|
60
|
+
return ` Tracker returned a server error (${classification.status}) for ${ticketKey}. Try again later.\n`;
|
|
61
|
+
default:
|
|
62
|
+
return ` Failed to write to ${ticketKey}: ${err.message}\n`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function requireLicense(isLicensedFn, configDir, commandName, stream) {
|
|
67
|
+
if (isLicensedFn('pro', configDir)) return true;
|
|
68
|
+
showUpgradePrompt('pro', commandName, { stream });
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requireTicketKey(cmdArgs, usage, stream) {
|
|
73
|
+
const ticketKey = cmdArgs[0];
|
|
74
|
+
if (!ticketKey || ticketKey.startsWith('--') || !TICKET_KEY_PATTERN.test(ticketKey)) {
|
|
75
|
+
stream.write(usage);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return ticketKey;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream }) {
|
|
82
|
+
const profileName = parseFlag(cmdArgs, 'profile');
|
|
83
|
+
const conn = resolveConnectionFn(ticketKey, { configDir, profileName });
|
|
84
|
+
if (!conn.baseUrl) {
|
|
85
|
+
stream.write(` No connection configured for ${ticketKey}. Run \`ticketlens init\`.\n`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return resolveAdapterFn(conn);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string[]} cmdArgs - [ticketKey, ...flags], e.g. ["PROJ-1", '--body=Looks good']
|
|
93
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
94
|
+
*/
|
|
95
|
+
export async function runTicketComment(cmdArgs, {
|
|
96
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
97
|
+
stream = process.stderr,
|
|
98
|
+
isLicensedFn = isLicensed,
|
|
99
|
+
resolveConnectionFn = resolveConnection,
|
|
100
|
+
resolveAdapterFn = resolveAdapter,
|
|
101
|
+
checkCooldownFn = checkCooldown,
|
|
102
|
+
recordActionFn = recordAction,
|
|
103
|
+
logActionFn = logAction,
|
|
104
|
+
actor = os.userInfo().username,
|
|
105
|
+
} = {}) {
|
|
106
|
+
const usage = 'Usage: ticketlens comment TICKET-KEY --body="..."\n';
|
|
107
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens comment', stream)) return { ok: false };
|
|
108
|
+
|
|
109
|
+
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
110
|
+
if (!ticketKey) return { ok: false };
|
|
111
|
+
|
|
112
|
+
const body = parseFlag(cmdArgs, 'body');
|
|
113
|
+
if (!body) {
|
|
114
|
+
stream.write(usage);
|
|
115
|
+
return { ok: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const cooldown = checkCooldownFn(ticketKey, 'comment', { configDir });
|
|
119
|
+
if (cooldown.active) {
|
|
120
|
+
stream.write(` Skipped — a comment was already posted to ${ticketKey} ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
121
|
+
return { ok: false };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
125
|
+
if (!adapter) return { ok: false };
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const result = await adapter.addComment(ticketKey, body);
|
|
129
|
+
recordActionFn(ticketKey, 'comment', { configDir });
|
|
130
|
+
logActionFn({ ticketKey, action: 'comment', actor, tracker: adapter.type, detail: { id: result.id } }, { configDir });
|
|
131
|
+
stream.write(` Comment posted to ${ticketKey}${result.url ? ` (${result.url})` : ''}\n`);
|
|
132
|
+
return { ok: true };
|
|
133
|
+
} catch (err) {
|
|
134
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
135
|
+
return { ok: false };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Discovery only — never mutates. Lists the tracker's current valid
|
|
141
|
+
* transition options for the ticket.
|
|
142
|
+
*
|
|
143
|
+
* @param {string[]} cmdArgs - [ticketKey]
|
|
144
|
+
* @returns {Promise<{ ok: boolean, options?: object[] }>}
|
|
145
|
+
*/
|
|
146
|
+
export async function runTicketTransitionList(cmdArgs, {
|
|
147
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
148
|
+
stream = process.stderr,
|
|
149
|
+
isLicensedFn = isLicensed,
|
|
150
|
+
resolveConnectionFn = resolveConnection,
|
|
151
|
+
resolveAdapterFn = resolveAdapter,
|
|
152
|
+
} = {}) {
|
|
153
|
+
const usage = 'Usage: ticketlens transition TICKET-KEY [--target="..." --confirm]\n';
|
|
154
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens transition', stream)) return { ok: false };
|
|
155
|
+
|
|
156
|
+
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
157
|
+
if (!ticketKey) return { ok: false };
|
|
158
|
+
|
|
159
|
+
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
160
|
+
if (!adapter) return { ok: false };
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const options = await adapter.getTransitions(ticketKey);
|
|
164
|
+
if (options.length === 0) {
|
|
165
|
+
stream.write(` No valid transitions available for ${ticketKey}.\n`);
|
|
166
|
+
return { ok: true, options: [] };
|
|
167
|
+
}
|
|
168
|
+
stream.write(` Valid transitions for ${ticketKey}:\n`);
|
|
169
|
+
for (const o of options) stream.write(` - ${o.name}\n`);
|
|
170
|
+
stream.write(` Run again with --target="<name>" --confirm to execute.\n`);
|
|
171
|
+
return { ok: true, options };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
174
|
+
return { ok: false };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Executes a transition. Requires both --target and --confirm — a target
|
|
180
|
+
* without --confirm is treated as incomplete input, never silently executed.
|
|
181
|
+
*
|
|
182
|
+
* @param {string[]} cmdArgs - [ticketKey, '--target=...', '--confirm']
|
|
183
|
+
* @returns {Promise<{ ok: boolean, reason?: string }>}
|
|
184
|
+
*/
|
|
185
|
+
export async function runTicketTransition(cmdArgs, {
|
|
186
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
187
|
+
stream = process.stderr,
|
|
188
|
+
isLicensedFn = isLicensed,
|
|
189
|
+
resolveConnectionFn = resolveConnection,
|
|
190
|
+
resolveAdapterFn = resolveAdapter,
|
|
191
|
+
checkCooldownFn = checkCooldown,
|
|
192
|
+
recordActionFn = recordAction,
|
|
193
|
+
logActionFn = logAction,
|
|
194
|
+
actor = os.userInfo().username,
|
|
195
|
+
} = {}) {
|
|
196
|
+
const usage = 'Usage: ticketlens transition TICKET-KEY --target="..." --confirm\n';
|
|
197
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens transition', stream)) return { ok: false };
|
|
198
|
+
|
|
199
|
+
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
200
|
+
if (!ticketKey) return { ok: false };
|
|
201
|
+
|
|
202
|
+
const target = parseFlag(cmdArgs, 'target');
|
|
203
|
+
if (!target) {
|
|
204
|
+
stream.write(usage);
|
|
205
|
+
return { ok: false };
|
|
206
|
+
}
|
|
207
|
+
if (!cmdArgs.includes('--confirm')) {
|
|
208
|
+
stream.write(` Refusing to transition ${ticketKey} to "${target}" without --confirm. Re-run with --confirm once you've reviewed the target.\n`);
|
|
209
|
+
return { ok: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const cooldown = checkCooldownFn(ticketKey, 'transition', { configDir });
|
|
213
|
+
if (cooldown.active) {
|
|
214
|
+
stream.write(` Skipped — ${ticketKey} was already transitioned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
215
|
+
return { ok: false };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
219
|
+
if (!adapter) return { ok: false };
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const result = await adapter.transition(ticketKey, target);
|
|
223
|
+
if (!result.executed) {
|
|
224
|
+
const optionsHint = result.options?.length ? ` Valid options: ${result.options.map(o => o.name).join(', ')}.` : '';
|
|
225
|
+
stream.write(` Not transitioned — ${result.reason}.${optionsHint}\n`);
|
|
226
|
+
return { ok: false, reason: result.reason };
|
|
227
|
+
}
|
|
228
|
+
recordActionFn(ticketKey, 'transition', { configDir });
|
|
229
|
+
logActionFn({ ticketKey, action: 'transition', actor, tracker: adapter.type, detail: { to: result.to } }, { configDir });
|
|
230
|
+
stream.write(` ${ticketKey} transitioned to "${result.to}".\n`);
|
|
231
|
+
return { ok: true };
|
|
232
|
+
} catch (err) {
|
|
233
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
234
|
+
return { ok: false };
|
|
235
|
+
}
|
|
236
|
+
}
|