ticketlens 0.23.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 +32 -4
- package/bin/ticketlens.mjs +41 -0
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +28 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +101 -1
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +54 -5
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +129 -2
- package/skills/jtb/scripts/lib/adf-converter.mjs +19 -0
- package/skills/jtb/scripts/lib/cli.mjs +12 -0
- package/skills/jtb/scripts/lib/help.mjs +84 -5
- package/skills/jtb/scripts/lib/jira-client.mjs +117 -1
- package/skills/jtb/scripts/lib/mcp-server.mjs +113 -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 +290 -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`, `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,6 +419,25 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
419
419
|
|
|
420
420
|
---
|
|
421
421
|
|
|
422
|
+
### Comment, Transition & Assign
|
|
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
|
+
ticketlens assign PROJ-123 --to=me # Assign the ticket to yourself
|
|
429
|
+
```
|
|
430
|
+
|
|
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.
|
|
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.
|
|
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.
|
|
438
|
+
|
|
439
|
+
---
|
|
440
|
+
|
|
422
441
|
### Response-Time Stats
|
|
423
442
|
|
|
424
443
|
```bash
|
|
@@ -692,10 +711,16 @@ ticketlens recall CNV1-2 # Search saved notes by ticket key
|
|
|
692
711
|
ticketlens recall "retry backoff" # Free-text search across all notes [Pro]
|
|
693
712
|
ticketlens recall sync # Retry any notes stuck in the local queue [Team+]
|
|
694
713
|
ticketlens recall settings # Show effective retry-queue settings, fetched live [Team+]
|
|
695
|
-
ticketlens mcp # Start the MCP stdio server (
|
|
714
|
+
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools) [Pro]
|
|
696
715
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
697
716
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
698
717
|
|
|
718
|
+
# ── Comment, Transition & Assign ─────────────────────────────────────────────
|
|
719
|
+
ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
|
|
720
|
+
ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
|
|
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]
|
|
723
|
+
|
|
699
724
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
700
725
|
ticketlens stats # Response-time metrics from local history
|
|
701
726
|
ticketlens stats --profile=acme # Metrics for a specific profile
|
|
@@ -776,7 +801,10 @@ ticketlens note delete --id="..." # Remove a note from your local vault
|
|
|
776
801
|
ticketlens recall <query|TICKET-KEY> # Search your saved Recall notes
|
|
777
802
|
ticketlens recall sync # Retry any notes stuck in the local queue
|
|
778
803
|
ticketlens recall settings # Show effective retry-queue settings, fetched live
|
|
779
|
-
ticketlens mcp # Start the MCP stdio server (
|
|
804
|
+
ticketlens mcp # Start the MCP stdio server (recall/ticket write tools)
|
|
805
|
+
ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
|
|
806
|
+
ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
|
|
807
|
+
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
|
|
780
808
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
781
809
|
```
|
|
782
810
|
|
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, printAssignHelp,
|
|
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,46 @@ 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
|
+
|
|
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
|
+
|
|
740
781
|
case 'help':
|
|
741
782
|
default: {
|
|
742
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.
|
|
@@ -60,6 +60,10 @@ 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)
|
|
66
|
+
/jtb assign PROD-1234 --to=me # assign the ticket to yourself (Pro)
|
|
63
67
|
```
|
|
64
68
|
|
|
65
69
|
## Prerequisites
|
|
@@ -260,6 +264,29 @@ Recall notes are stored locally at `~/.ticketlens/recall/`. On a Pro account wit
|
|
|
260
264
|
|
|
261
265
|
---
|
|
262
266
|
|
|
267
|
+
## Comment, Transition & Assign — write back to the tracker (Pro)
|
|
268
|
+
|
|
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.
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
273
|
+
ticketlens transition PROD-1234 # list valid transitions — read-only
|
|
274
|
+
ticketlens transition PROD-1234 --target="Done" --confirm # execute
|
|
275
|
+
ticketlens assign PROD-1234 --to=me # assign to yourself
|
|
276
|
+
```
|
|
277
|
+
|
|
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.
|
|
279
|
+
|
|
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.
|
|
281
|
+
|
|
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.
|
|
283
|
+
|
|
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.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
263
290
|
## Gaps — cross-ticket evidence (Pro)
|
|
264
291
|
|
|
265
292
|
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.
|
|
@@ -92,7 +123,7 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
92
123
|
});
|
|
93
124
|
if (!res.ok) throw new Error(`GitHub API error ${res.status} fetching current user`);
|
|
94
125
|
const raw = await res.json();
|
|
95
|
-
return { displayName: raw.name || raw.login, email: raw.email ?? null };
|
|
126
|
+
return { displayName: raw.name || raw.login, email: raw.email ?? null, login: raw.login };
|
|
96
127
|
},
|
|
97
128
|
|
|
98
129
|
async searchTickets(_query, opts = {}) {
|
|
@@ -108,5 +139,74 @@ 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
|
+
},
|
|
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
|
+
},
|
|
111
211
|
};
|
|
112
212
|
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses } 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
|
+
/**
|
|
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,50 @@ 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
|
+
},
|
|
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
|
+
},
|
|
18
67
|
};
|
|
19
68
|
}
|
|
@@ -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.
|
|
@@ -94,12 +127,12 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
94
127
|
async fetchCurrentUser(opts = {}) {
|
|
95
128
|
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
96
129
|
const data = await gql(
|
|
97
|
-
`{ viewer { name email } }`,
|
|
130
|
+
`{ viewer { id name email } }`,
|
|
98
131
|
{},
|
|
99
132
|
{ token, fetcher, signal },
|
|
100
133
|
);
|
|
101
134
|
const v = data.viewer;
|
|
102
|
-
return { displayName: v.name, email: v.email ?? null };
|
|
135
|
+
return { displayName: v.name, email: v.email ?? null, id: v.id };
|
|
103
136
|
},
|
|
104
137
|
|
|
105
138
|
async searchTickets(_query, opts = {}) {
|
|
@@ -130,5 +163,99 @@ 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
|
+
},
|
|
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
|
+
},
|
|
133
260
|
};
|
|
134
261
|
}
|
|
@@ -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,18 @@ 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
|
+
|
|
141
|
+
if (first === 'assign') {
|
|
142
|
+
return { command: 'assign', args: args.slice(1) };
|
|
143
|
+
}
|
|
144
|
+
|
|
133
145
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
134
146
|
return { command: 'fetch', args };
|
|
135
147
|
}
|