ticketlens 0.24.0 → 0.25.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 +10 -5
- package/bin/ticketlens.mjs +13 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +10 -6
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +22 -1
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +22 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +28 -2
- package/skills/jtb/scripts/lib/cli.mjs +4 -0
- package/skills/jtb/scripts/lib/help.mjs +30 -4
- package/skills/jtb/scripts/lib/jira-client.mjs +30 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +31 -4
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +1 -1
- package/skills/jtb/scripts/lib/ticket-command.mjs +54 -0
package/README.md
CHANGED
|
@@ -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`, `recall_search`, `ticket_comment`, and `
|
|
414
|
+
**Any MCP-capable AI harness:** `ticketlens mcp` starts a stdio [MCP](https://modelcontextprotocol.io) server exposing `recall_add`, `recall_search`, `ticket_comment`, `ticket_transition`, and `ticket_assign` 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,19 +419,22 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
419
419
|
|
|
420
420
|
---
|
|
421
421
|
|
|
422
|
-
### Comment &
|
|
422
|
+
### Comment, Transition & Assign
|
|
423
423
|
|
|
424
424
|
```bash
|
|
425
425
|
ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
|
|
426
426
|
ticketlens transition PROJ-123 # List valid transitions (read-only)
|
|
427
427
|
ticketlens transition PROJ-123 --target="Done" --confirm # Execute the transition
|
|
428
|
+
ticketlens assign PROJ-123 --to=me # Assign the ticket to yourself
|
|
428
429
|
```
|
|
429
430
|
|
|
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
|
+
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`/`ticket_assign` MCP tools. Requires a Pro license.
|
|
431
432
|
|
|
432
433
|
`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
|
|
|
434
|
-
|
|
435
|
+
`ticketlens assign` is self-assign only for now — `--to` must be `me`. Assigning to someone else needs a per-tracker user-lookup step this doesn't do yet, so it's deliberately out of scope until that's built.
|
|
436
|
+
|
|
437
|
+
All three 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 writes aren't naturally idempotent, so a timed-out attempt is surfaced to you instead of silently repeated.
|
|
435
438
|
|
|
436
439
|
---
|
|
437
440
|
|
|
@@ -712,10 +715,11 @@ ticketlens mcp # Start the MCP stdio server (reca
|
|
|
712
715
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
713
716
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
714
717
|
|
|
715
|
-
# ── Comment &
|
|
718
|
+
# ── Comment, Transition & Assign ─────────────────────────────────────────────
|
|
716
719
|
ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
|
|
717
720
|
ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
|
|
718
721
|
ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
|
|
722
|
+
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
|
|
719
723
|
|
|
720
724
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
721
725
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -800,6 +804,7 @@ ticketlens recall settings # Show effective retry-queue settings,
|
|
|
800
804
|
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools)
|
|
801
805
|
ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
|
|
802
806
|
ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
|
|
807
|
+
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
|
|
803
808
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
804
809
|
```
|
|
805
810
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
printCollisionsHelp, printStatsHelp,
|
|
29
29
|
printCloudKeysHelp,
|
|
30
30
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
31
|
-
printCommentHelp, printTransitionHelp,
|
|
31
|
+
printCommentHelp, printTransitionHelp, printAssignHelp,
|
|
32
32
|
} from '../skills/jtb/scripts/lib/help.mjs';
|
|
33
33
|
import { runStats } from '../skills/jtb/scripts/lib/run-stats.mjs';
|
|
34
34
|
import { createStyler } from '../skills/jtb/scripts/lib/ansi.mjs';
|
|
@@ -766,6 +766,18 @@ switch (command) {
|
|
|
766
766
|
break;
|
|
767
767
|
}
|
|
768
768
|
|
|
769
|
+
case 'assign': {
|
|
770
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printAssignHelp(); break; }
|
|
771
|
+
const { runTicketAssign } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
772
|
+
runTicketAssign(cmdArgs).then(({ ok }) => {
|
|
773
|
+
if (!ok) process.exitCode = 1;
|
|
774
|
+
}).catch(err => {
|
|
775
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
776
|
+
process.exitCode = 1;
|
|
777
|
+
});
|
|
778
|
+
break;
|
|
779
|
+
}
|
|
780
|
+
|
|
769
781
|
case 'help':
|
|
770
782
|
default: {
|
|
771
783
|
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.25.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.
|
|
@@ -63,6 +63,7 @@ Fetches a Jira ticket and produces a structured brief with code references, then
|
|
|
63
63
|
/jtb comment PROD-1234 --body="..." # post a comment to the tracker (Pro)
|
|
64
64
|
/jtb transition PROD-1234 # list the tracker's current valid transitions (Pro)
|
|
65
65
|
/jtb transition PROD-1234 --target="Done" --confirm # execute the transition (Pro)
|
|
66
|
+
/jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
|
|
66
67
|
```
|
|
67
68
|
|
|
68
69
|
## Prerequisites
|
|
@@ -263,23 +264,26 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
|
|
|
263
264
|
|
|
264
265
|
---
|
|
265
266
|
|
|
266
|
-
## Comment &
|
|
267
|
+
## Comment, Transition & Assign — write back to the tracker (Pro)
|
|
267
268
|
|
|
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
|
|
269
|
+
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, moved, or assigned — never as a routine end-of-session action the way Recall capture is.
|
|
269
270
|
|
|
270
271
|
```bash
|
|
271
272
|
ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
272
273
|
ticketlens transition PROD-1234 # list valid transitions — read-only
|
|
273
274
|
ticketlens transition PROD-1234 --target="Done" --confirm # execute
|
|
275
|
+
ticketlens assign PROD-1234 --to=me # assign to yourself
|
|
274
276
|
```
|
|
275
277
|
|
|
276
278
|
`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
279
|
|
|
278
|
-
|
|
280
|
+
`assign` is self-assign only — `--to` must be `me`. There is no way to assign to anyone else yet; don't attempt a workaround (e.g. via `comment`) if the user asks for that — tell them it isn't supported.
|
|
279
281
|
|
|
280
|
-
|
|
282
|
+
All three 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.
|
|
281
283
|
|
|
282
|
-
|
|
284
|
+
**Pick exactly one path per action — never both.** If this harness has TicketLens's MCP server configured (`ticketlens mcp` — `ticket_comment`/`ticket_transition`/`ticket_assign` 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.
|
|
285
|
+
|
|
286
|
+
Requires a Pro license — on Free, all three no-op with an upgrade hint on stderr.
|
|
283
287
|
|
|
284
288
|
---
|
|
285
289
|
|
|
@@ -123,7 +123,7 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
123
123
|
});
|
|
124
124
|
if (!res.ok) throw new Error(`GitHub API error ${res.status} fetching current user`);
|
|
125
125
|
const raw = await res.json();
|
|
126
|
-
return { displayName: raw.name || raw.login, email: raw.email ?? null };
|
|
126
|
+
return { displayName: raw.name || raw.login, email: raw.email ?? null, login: raw.login };
|
|
127
127
|
},
|
|
128
128
|
|
|
129
129
|
async searchTickets(_query, opts = {}) {
|
|
@@ -187,5 +187,26 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
187
187
|
if (!res.ok) await throwGitHubWriteError(res, 'transitioning', key);
|
|
188
188
|
return { executed: true, to: match.to };
|
|
189
189
|
},
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Self-assign only — GitHub's assignees field is an array, but this
|
|
193
|
+
* always replaces it with exactly the caller, mirroring the other
|
|
194
|
+
* adapters' self-assign scope (arbitrary-user assignment deferred).
|
|
195
|
+
*/
|
|
196
|
+
async assignToSelf(key, opts = {}) {
|
|
197
|
+
const me = await this.fetchCurrentUser(opts);
|
|
198
|
+
if (!me.login) {
|
|
199
|
+
throw new Error(`Cannot determine current user's login — GitHub did not return it for this connection.`);
|
|
200
|
+
}
|
|
201
|
+
const number = parseInt(key.split('-').pop(), 10);
|
|
202
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
|
|
203
|
+
method: 'PATCH',
|
|
204
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
205
|
+
body: JSON.stringify({ assignees: [me.login] }),
|
|
206
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
207
|
+
});
|
|
208
|
+
if (!res.ok) await throwGitHubWriteError(res, 'assigning', key);
|
|
209
|
+
return { assignee: me.displayName ?? me.login };
|
|
210
|
+
},
|
|
190
211
|
};
|
|
191
212
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue } from '../jira-client.mjs';
|
|
2
2
|
import { buildJiraEnv } from '../config.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -43,5 +43,26 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
43
43
|
await postTransition(key, match.id, { ...base, ...opts });
|
|
44
44
|
return { executed: true, to: match.to ?? match.name };
|
|
45
45
|
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Self-assign only — arbitrary-user assignment would need a
|
|
49
|
+
* user-search API this codebase doesn't have yet. Reuses
|
|
50
|
+
* fetchCurrentUser, which already returns both accountId (Cloud)
|
|
51
|
+
* and name (Server/DC).
|
|
52
|
+
*/
|
|
53
|
+
async assignToSelf(key, opts = {}) {
|
|
54
|
+
const me = await fetchCurrentUser({ ...base, ...opts });
|
|
55
|
+
const field = apiVersion === 3 ? 'accountId' : 'name';
|
|
56
|
+
const value = me[field];
|
|
57
|
+
// Jira's PUT /issue/{key}/assignee treats a null identity field as
|
|
58
|
+
// "unassign", not an error — it returns 204 either way. Never send
|
|
59
|
+
// it: that would silently unassign the ticket while this command
|
|
60
|
+
// reports success.
|
|
61
|
+
if (!value) {
|
|
62
|
+
throw new Error(`Cannot determine current user's ${field} — Jira did not return it for this connection.`);
|
|
63
|
+
}
|
|
64
|
+
await assignIssue(key, { [field]: value }, { ...base, ...opts });
|
|
65
|
+
return { assignee: me.displayName ?? value };
|
|
66
|
+
},
|
|
46
67
|
};
|
|
47
68
|
}
|
|
@@ -127,12 +127,12 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
127
127
|
async fetchCurrentUser(opts = {}) {
|
|
128
128
|
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
129
129
|
const data = await gql(
|
|
130
|
-
`{ viewer { name email } }`,
|
|
130
|
+
`{ viewer { id name email } }`,
|
|
131
131
|
{},
|
|
132
132
|
{ token, fetcher, signal },
|
|
133
133
|
);
|
|
134
134
|
const v = data.viewer;
|
|
135
|
-
return { displayName: v.name, email: v.email ?? null };
|
|
135
|
+
return { displayName: v.name, email: v.email ?? null, id: v.id };
|
|
136
136
|
},
|
|
137
137
|
|
|
138
138
|
async searchTickets(_query, opts = {}) {
|
|
@@ -231,5 +231,31 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
231
231
|
}
|
|
232
232
|
return { executed: true, to: match.to };
|
|
233
233
|
},
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Self-assign only — arbitrary-user assignment would need a
|
|
237
|
+
* user-search query this codebase doesn't have yet.
|
|
238
|
+
*/
|
|
239
|
+
async assignToSelf(key, opts = {}) {
|
|
240
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
241
|
+
const me = await this.fetchCurrentUser(opts);
|
|
242
|
+
if (!me.id) {
|
|
243
|
+
throw new Error(`Cannot determine current user's id — Linear did not return it for this connection.`);
|
|
244
|
+
}
|
|
245
|
+
const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
|
|
246
|
+
const data = await gql(
|
|
247
|
+
`mutation ($id: String!, $assigneeId: String!) {
|
|
248
|
+
issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
|
|
249
|
+
success
|
|
250
|
+
}
|
|
251
|
+
}`,
|
|
252
|
+
{ id: info.id, assigneeId: me.id },
|
|
253
|
+
{ token, fetcher, signal },
|
|
254
|
+
);
|
|
255
|
+
if (!data.issueUpdate?.success) {
|
|
256
|
+
throw new Error(`Linear issueUpdate reported success:false assigning ${key}`);
|
|
257
|
+
}
|
|
258
|
+
return { assignee: me.displayName ?? me.id };
|
|
259
|
+
},
|
|
234
260
|
};
|
|
235
261
|
}
|
|
@@ -138,6 +138,10 @@ export function parseCommand(args) {
|
|
|
138
138
|
return { command: 'transition', args: args.slice(1) };
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
if (first === 'assign') {
|
|
142
|
+
return { command: 'assign', args: args.slice(1) };
|
|
143
|
+
}
|
|
144
|
+
|
|
141
145
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
142
146
|
return { command: 'fetch', args };
|
|
143
147
|
}
|
|
@@ -56,6 +56,7 @@ export function printHelp({ stream = process.stdout } = {}) {
|
|
|
56
56
|
` ${s.brand('ticketlens')} mcp Start the MCP stdio server for Recall ${s.dim('[Pro]')}`,
|
|
57
57
|
` ${s.brand('ticketlens')} comment ${s.dim('<TICKET-KEY> --body=...')} Post a comment to the tracker ${s.dim('[Pro]')}`,
|
|
58
58
|
` ${s.brand('ticketlens')} transition ${s.dim('<TICKET-KEY> [--target=... --confirm]')} Move ticket status ${s.dim('[Pro]')}`,
|
|
59
|
+
` ${s.brand('ticketlens')} assign ${s.dim('<TICKET-KEY> --to=me')} Assign a ticket to yourself ${s.dim('[Pro]')}`,
|
|
59
60
|
'',
|
|
60
61
|
` ${s.brand('ticketlens')} delete ${s.dim('<PROFILE-NAME>')} Remove a profile`,
|
|
61
62
|
` ${s.brand('ticketlens')} activate ${s.dim('<KEY>')} Activate a license key`,
|
|
@@ -596,10 +597,11 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
596
597
|
'',
|
|
597
598
|
` Start an MCP (Model Context Protocol) stdio server exposing Recall and`,
|
|
598
599
|
` 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
|
|
600
|
-
` Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}
|
|
601
|
-
` above: same Pro gate, same local vault/tracker writes, same
|
|
602
|
-
` ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true
|
|
600
|
+
` ${s.cyan('ticket_transition')}, ${s.cyan('ticket_assign')} — for any MCP-compatible AI harness, not just`,
|
|
601
|
+
` Claude Code. Thin adapter over the same code as ${s.cyan('note add')}/${s.cyan('recall')}/${s.cyan('comment')}/`,
|
|
602
|
+
` ${s.cyan('transition')}/${s.cyan('assign')} above: same Pro gate, same local vault/tracker writes, same`,
|
|
603
|
+
` team sync. ${s.cyan('ticket_transition')} is destructive when called with \`target\`+\`confirm: true\`;`,
|
|
604
|
+
` ${s.cyan('ticket_assign')} is currently self-assign only.`,
|
|
603
605
|
` Long-running — exits when the client closes stdin.`,
|
|
604
606
|
'',
|
|
605
607
|
` ${s.bold('OPTIONS')}`,
|
|
@@ -676,6 +678,30 @@ export function printTransitionHelp({ stream = process.stdout } = {}) {
|
|
|
676
678
|
stream.write(lines.join('\n') + '\n');
|
|
677
679
|
}
|
|
678
680
|
|
|
681
|
+
export function printAssignHelp({ stream = process.stdout } = {}) {
|
|
682
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
683
|
+
const lines = [
|
|
684
|
+
'',
|
|
685
|
+
` ${s.bold(s.brand('ticketlens'))} ${s.bold('assign')} ${s.dim('TICKET-KEY --to=me')} ${s.dim('[Pro]')}`,
|
|
686
|
+
'',
|
|
687
|
+
` Assign a ticket to yourself directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
688
|
+
` Self-assign only for now — ${s.brand('--to')} must be ${s.brand('me')}. Assigning to someone else`,
|
|
689
|
+
` isn't supported yet.`,
|
|
690
|
+
'',
|
|
691
|
+
` ${s.bold('OPTIONS')}`,
|
|
692
|
+
'',
|
|
693
|
+
` ${s.brand('--to')}=${s.dim('me')} Required — only "me" is currently supported`,
|
|
694
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
695
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
696
|
+
'',
|
|
697
|
+
` ${s.bold('EXAMPLES')}`,
|
|
698
|
+
'',
|
|
699
|
+
` ${s.dim('$')} ticketlens assign PROD-123 --to=me`,
|
|
700
|
+
'',
|
|
701
|
+
];
|
|
702
|
+
stream.write(lines.join('\n') + '\n');
|
|
703
|
+
}
|
|
704
|
+
|
|
679
705
|
export function printSwitchHelp({ stream = process.stdout } = {}) {
|
|
680
706
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
681
707
|
const lines = [
|
|
@@ -494,6 +494,36 @@ export async function postTransition(ticketKey, transitionId, opts = {}) {
|
|
|
494
494
|
}
|
|
495
495
|
}
|
|
496
496
|
|
|
497
|
+
/**
|
|
498
|
+
* Sets the issue's assignee. `assignee` is sent verbatim — the caller
|
|
499
|
+
* (jira-adapter.mjs) resolves the right shape for the API version:
|
|
500
|
+
* `{ accountId }` for Cloud (v3), `{ name }` for Server/DC (v2), since
|
|
501
|
+
* Cloud has no username concept and Server/DC has no accountId.
|
|
502
|
+
*/
|
|
503
|
+
export async function assignIssue(ticketKey, assignee, opts = {}) {
|
|
504
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
505
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
506
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
507
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(ticketKey)}/assignee`;
|
|
508
|
+
|
|
509
|
+
const fetchOpts = {
|
|
510
|
+
method: 'PUT',
|
|
511
|
+
headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' },
|
|
512
|
+
body: JSON.stringify(assignee),
|
|
513
|
+
};
|
|
514
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
515
|
+
|
|
516
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
517
|
+
if (!response.ok) {
|
|
518
|
+
let details;
|
|
519
|
+
try { details = await response.json(); } catch { /* body not JSON — fall through with no details */ }
|
|
520
|
+
const err = new Error(`Jira API error ${response.status} assigning ${ticketKey}`);
|
|
521
|
+
err.status = response.status;
|
|
522
|
+
err.details = details;
|
|
523
|
+
throw err;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
497
527
|
export async function fetchTicket(ticketKey, opts = {}) {
|
|
498
528
|
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;
|
|
499
529
|
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
@@ -23,7 +23,7 @@ import readline from 'node:readline';
|
|
|
23
23
|
import { DEFAULT_CONFIG_DIR, getVersion } from './config.mjs';
|
|
24
24
|
import { runNoteAdd } from './note-command.mjs';
|
|
25
25
|
import { runRecall } from './recall-command.mjs';
|
|
26
|
-
import { runTicketComment, runTicketTransitionList, runTicketTransition } from './ticket-command.mjs';
|
|
26
|
+
import { runTicketComment, runTicketTransitionList, runTicketTransition, runTicketAssign } from './ticket-command.mjs';
|
|
27
27
|
|
|
28
28
|
const PROTOCOL_VERSION = '2025-11-25';
|
|
29
29
|
|
|
@@ -78,6 +78,18 @@ const TOOLS = [
|
|
|
78
78
|
required: ['ticket'],
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
|
+
{
|
|
82
|
+
name: 'ticket_assign',
|
|
83
|
+
description: 'Assign a ticket to yourself in its tracker (Jira/GitHub/Linear). Self-assign only — assigning to someone else is not supported yet. Requires a TicketLens Pro license.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
88
|
+
to: { type: 'string', description: 'Who to assign to — currently only "me" is accepted.' },
|
|
89
|
+
},
|
|
90
|
+
required: ['ticket', 'to'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
81
93
|
];
|
|
82
94
|
|
|
83
95
|
function jsonRpcResult(id, result) {
|
|
@@ -184,16 +196,30 @@ async function callTicketTransition(args, { configDir, runTicketTransitionListFn
|
|
|
184
196
|
return ok ? { content } : { isError: true, content };
|
|
185
197
|
}
|
|
186
198
|
|
|
199
|
+
async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
|
|
200
|
+
if (!args.ticket) {
|
|
201
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: ticket' }] };
|
|
202
|
+
}
|
|
203
|
+
if (!args.to) {
|
|
204
|
+
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: to' }] };
|
|
205
|
+
}
|
|
206
|
+
const capture = capturingStream();
|
|
207
|
+
const { ok } = await runTicketAssignFn([args.ticket, `--to=${args.to}`], { configDir, stream: capture });
|
|
208
|
+
const content = [{ type: 'text', text: capture.text }];
|
|
209
|
+
return ok ? { content } : { isError: true, content };
|
|
210
|
+
}
|
|
211
|
+
|
|
187
212
|
async function handleToolsCall(params, deps) {
|
|
188
213
|
const { name, arguments: args = {} } = params ?? {};
|
|
189
214
|
if (name === 'recall_add') return callRecallAdd(args, deps);
|
|
190
215
|
if (name === 'recall_search') return callRecallSearch(args, deps);
|
|
191
216
|
if (name === 'ticket_comment') return callTicketComment(args, deps);
|
|
192
217
|
if (name === 'ticket_transition') return callTicketTransition(args, deps);
|
|
218
|
+
if (name === 'ticket_assign') return callTicketAssign(args, deps);
|
|
193
219
|
return { isError: true, content: [{ type: 'text', text: `Unknown tool: ${name}` }] };
|
|
194
220
|
}
|
|
195
221
|
|
|
196
|
-
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn }) {
|
|
222
|
+
async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn }) {
|
|
197
223
|
let msg;
|
|
198
224
|
try {
|
|
199
225
|
msg = JSON.parse(raw);
|
|
@@ -223,7 +249,7 @@ async function handleMessage(raw, { configDir, runNoteAddFn, runRecallFn, runTic
|
|
|
223
249
|
|
|
224
250
|
if (method === 'tools/call') {
|
|
225
251
|
try {
|
|
226
|
-
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
|
|
252
|
+
const result = await handleToolsCall(params, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
|
|
227
253
|
return jsonRpcResult(id, result);
|
|
228
254
|
} catch (err) {
|
|
229
255
|
return jsonRpcError(id ?? null, -32603, `Internal error: ${err.message}`);
|
|
@@ -249,6 +275,7 @@ export function runMcpServer({
|
|
|
249
275
|
runTicketCommentFn = runTicketComment,
|
|
250
276
|
runTicketTransitionListFn = runTicketTransitionList,
|
|
251
277
|
runTicketTransitionFn = runTicketTransition,
|
|
278
|
+
runTicketAssignFn = runTicketAssign,
|
|
252
279
|
} = {}) {
|
|
253
280
|
// A client can disconnect mid-write (EPIPE) at any time on a long-lived
|
|
254
281
|
// process — an unhandled 'error' event on either stream would otherwise
|
|
@@ -268,7 +295,7 @@ export function runMcpServer({
|
|
|
268
295
|
// never resolving (a dropped rejection isn't a resolution) — the
|
|
269
296
|
// server would hang on shutdown instead of exiting.
|
|
270
297
|
queue = queue.then(async () => {
|
|
271
|
-
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn });
|
|
298
|
+
const response = await handleMessage(line, { configDir, runNoteAddFn, runRecallFn, runTicketCommentFn, runTicketTransitionListFn, runTicketTransitionFn, runTicketAssignFn });
|
|
272
299
|
if (response) stdout.write(response);
|
|
273
300
|
}).catch(() => {});
|
|
274
301
|
});
|
|
@@ -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, addComment: Function, getTransitions: Function, transition: Function }}
|
|
22
|
+
* @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function, addComment: Function, getTransitions: Function, transition: Function, assignToSelf: Function }}
|
|
23
23
|
*/
|
|
24
24
|
export function resolveAdapter(conn, opts = {}) {
|
|
25
25
|
const type = detectTrackerType(conn?.baseUrl);
|
|
@@ -234,3 +234,57 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
234
234
|
return { ok: false };
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Self-assign only — `--to` currently only accepts the literal "me".
|
|
240
|
+
* Arbitrary-user assignment needs a per-tracker user-search step this
|
|
241
|
+
* codebase doesn't have yet; kept as an explicit, rejected value now so
|
|
242
|
+
* a future `--to=someone@else.com` doesn't silently redefine what a
|
|
243
|
+
* bare/missing --to means today.
|
|
244
|
+
*
|
|
245
|
+
* @param {string[]} cmdArgs - [ticketKey, '--to=me']
|
|
246
|
+
* @returns {Promise<{ ok: boolean }>}
|
|
247
|
+
*/
|
|
248
|
+
export async function runTicketAssign(cmdArgs, {
|
|
249
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
250
|
+
stream = process.stderr,
|
|
251
|
+
isLicensedFn = isLicensed,
|
|
252
|
+
resolveConnectionFn = resolveConnection,
|
|
253
|
+
resolveAdapterFn = resolveAdapter,
|
|
254
|
+
checkCooldownFn = checkCooldown,
|
|
255
|
+
recordActionFn = recordAction,
|
|
256
|
+
logActionFn = logAction,
|
|
257
|
+
actor = os.userInfo().username,
|
|
258
|
+
} = {}) {
|
|
259
|
+
const usage = 'Usage: ticketlens assign TICKET-KEY --to=me\n';
|
|
260
|
+
if (!requireLicense(isLicensedFn, configDir, 'ticketlens assign', stream)) return { ok: false };
|
|
261
|
+
|
|
262
|
+
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
263
|
+
if (!ticketKey) return { ok: false };
|
|
264
|
+
|
|
265
|
+
const to = parseFlag(cmdArgs, 'to');
|
|
266
|
+
if (to !== 'me') {
|
|
267
|
+
stream.write(to ? ` --to="${to}" is not yet supported — only --to=me (self-assign) is available.\n` : usage);
|
|
268
|
+
return { ok: false };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const cooldown = checkCooldownFn(ticketKey, 'assign', { configDir });
|
|
272
|
+
if (cooldown.active) {
|
|
273
|
+
stream.write(` Skipped — ${ticketKey} was already assigned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
274
|
+
return { ok: false };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const adapter = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
278
|
+
if (!adapter) return { ok: false };
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const result = await adapter.assignToSelf(ticketKey);
|
|
282
|
+
recordActionFn(ticketKey, 'assign', { configDir });
|
|
283
|
+
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
|
|
284
|
+
stream.write(` ${ticketKey} assigned to ${result.assignee}.\n`);
|
|
285
|
+
return { ok: true };
|
|
286
|
+
} catch (err) {
|
|
287
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
288
|
+
return { ok: false };
|
|
289
|
+
}
|
|
290
|
+
}
|