ticketlens 0.26.0 → 0.30.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 +28 -5
- package/bin/ticketlens.mjs +43 -2
- package/package.json +1 -1
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +130 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +67 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +192 -0
- package/skills/jtb/scripts/lib/cli.mjs +12 -0
- package/skills/jtb/scripts/lib/help.mjs +149 -7
- package/skills/jtb/scripts/lib/jira-client.mjs +183 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +141 -4
- package/skills/jtb/scripts/lib/ticket-command.mjs +400 -7
- package/skills/jtb/scripts/lib/ticket-create-enrichment.mjs +80 -0
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +84 -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`, `ticket_transition`, `ticket_assign`, 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`, `ticket_assign`, `ticket_duplicates`, `ticket_link`, `ticket_update`, and `ticket_create` 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,7 +419,7 @@ Every note is scanned before saving — anything shaped like a real secret (API
|
|
|
419
419
|
|
|
420
420
|
---
|
|
421
421
|
|
|
422
|
-
### Comment, Transition, Assign &
|
|
422
|
+
### Comment, Transition, Assign, Duplicates, Link, Update & Create
|
|
423
423
|
|
|
424
424
|
```bash
|
|
425
425
|
ticketlens comment PROJ-123 --body="Looks good, merging." # Post a comment to the tracker
|
|
@@ -427,9 +427,15 @@ ticketlens transition PROJ-123 # List valid transit
|
|
|
427
427
|
ticketlens transition PROJ-123 --target="Done" --confirm # Execute the transition
|
|
428
428
|
ticketlens assign PROJ-123 --to=me # Assign the ticket to yourself
|
|
429
429
|
ticketlens duplicates PROJ-123 # Find likely duplicates (read-only)
|
|
430
|
+
ticketlens link PROJ-123 PROJ-456 # List valid link types (read-only)
|
|
431
|
+
ticketlens link PROJ-123 PROJ-456 --type="Duplicate" --confirm # Execute the link
|
|
432
|
+
ticketlens update PROJ-123 --title="Fix login on mobile" # Update title/description/labels/priority
|
|
433
|
+
ticketlens update PROJ-123 --add-labels=urgent,backend --remove-labels=stale
|
|
434
|
+
ticketlens create --project=PROJ --type="Task" --summary="Fix login on mobile" # Create a new ticket
|
|
435
|
+
ticketlens create --project=ENG --summary="New Linear issue" --profile=linear-team
|
|
430
436
|
```
|
|
431
437
|
|
|
432
|
-
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`/`ticket_duplicates` MCP tools. Requires a Pro license.
|
|
438
|
+
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`/`ticket_duplicates`/`ticket_link`/`ticket_update`/`ticket_create` MCP tools. Requires a Pro license.
|
|
433
439
|
|
|
434
440
|
`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.
|
|
435
441
|
|
|
@@ -437,7 +443,15 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
|
|
|
437
443
|
|
|
438
444
|
`ticketlens duplicates` is read-only — it never links or changes anything, just lists likely matches in the same project. No tracker (Jira/GitHub/Linear) scores similarity server-side, so ranking happens locally from title/description word overlap; treat a match as a nudge to check manually, not a verdict. `--threshold=N` (0–1, default 0.35) controls how loose a match counts.
|
|
439
445
|
|
|
440
|
-
|
|
446
|
+
`ticketlens link SOURCE-KEY TARGET-KEY` links two tickets — direction matters: SOURCE "types" TARGET (e.g. `link A B --type=Duplicate` means A duplicates B, not the other way around). With just the two keys it lists the tracker's current valid link types without changing anything — always fetched live for Jira, since link type names are per-instance configurable there. GitHub is different from Jira/Linear: it has no generic link relationship, so linking on a GitHub-tracked ticket *closes SOURCE as a duplicate of TARGET* — a state change, not just a relationship add — and prints an explicit warning immediately before that happens, on top of the same `--confirm` gate.
|
|
447
|
+
|
|
448
|
+
`ticketlens update TICKET-KEY` updates a narrow, named field set — title, description, labels, priority. At least one field is required. Labels are always add/remove (`--add-labels=a,b` / `--remove-labels=c`), never a wholesale replace — an unnamed existing label is left alone, never silently dropped. No `--confirm` needed: unlike transition/link, update has no discovery step and only makes reversible metadata edits, the same risk tier as `assign`. GitHub has no priority field on issues, so `--priority` against a GitHub-tracked ticket is refused up front. Each tracker's label mechanics genuinely differ — Jira and Linear apply everything in one atomic call; GitHub's title/description and each label operation are independent, so a call can partially succeed (e.g. the title updates but one label name doesn't resolve) — the result always reports exactly which fields landed.
|
|
449
|
+
|
|
450
|
+
`ticketlens create` creates a new ticket with a fixed minimal field set — no arbitrary custom fields. Unlike every other write command, there's no existing ticket to target, so `--profile` (or your default profile) picks the tracker instead of a ticket key. `--project` is the Jira project key or Linear team key — required for both, ignored on GitHub since its target repo is already fixed by the profile. `--type` is Jira's issue type (e.g. `"Task"`, `"Bug"`) — required for Jira, ignored elsewhere. No `--confirm` gate, same risk tier as `update`/`assign` — but this is the highest-blast-radius command in the whole family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker, so an invalid value surfaces the tracker's own error rather than a silent guess.
|
|
451
|
+
|
|
452
|
+
**A bad `--project`/`--type` gets a better error, automatically.** If create fails because the project or issue type doesn't exist, TicketLens fetches your tracker's real, current project list (and, for Jira, the real issue types for that project) and shows them alongside the failure — e.g. `Known creatable projects: CNV1, ECNT.` — rather than a bare tracker error. This is reactive only: it never runs on a successful create, never auto-retries the write, and is cached locally per profile for 24h so a burst of failed attempts doesn't re-fetch every time.
|
|
453
|
+
|
|
454
|
+
All six write actions (comment/transition/assign/link/update/create) 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. `duplicates` has neither, since nothing is written.
|
|
441
455
|
|
|
442
456
|
---
|
|
443
457
|
|
|
@@ -718,13 +732,19 @@ ticketlens mcp # Start the MCP stdio server (reca
|
|
|
718
732
|
ticketlens mcp install # Register it into the current project's .mcp.json
|
|
719
733
|
ticketlens mcp install --dry-run # Preview the registration without writing
|
|
720
734
|
|
|
721
|
-
# ── Comment, Transition, Assign &
|
|
735
|
+
# ── Comment, Transition, Assign, Duplicates, Link, Update & Create ──────────────
|
|
722
736
|
ticketlens comment CNV1-2 --body="Looks good, merging." # Post a comment to the tracker [Pro]
|
|
723
737
|
ticketlens transition CNV1-2 # List valid transitions (read-only) [Pro]
|
|
724
738
|
ticketlens transition CNV1-2 --target="Done" --confirm # Execute the transition [Pro]
|
|
725
739
|
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself [Pro]
|
|
726
740
|
ticketlens duplicates CNV1-2 # Find likely duplicates (read-only) [Pro]
|
|
727
741
|
ticketlens duplicates CNV1-2 --threshold=0.5 # Tighten the match threshold [Pro]
|
|
742
|
+
ticketlens link CNV1-2 CNV1-3 # List valid link types (read-only) [Pro]
|
|
743
|
+
ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Execute the link [Pro]
|
|
744
|
+
ticketlens update CNV1-2 --title="New title" # Update title/description/labels/priority [Pro]
|
|
745
|
+
ticketlens update CNV1-2 --add-labels=urgent --remove-labels=stale # Add/remove labels [Pro]
|
|
746
|
+
ticketlens create --project=CNV1 --type="Task" --summary="New ticket" # Create a new ticket [Pro]
|
|
747
|
+
ticketlens create --project=ENG --summary="New issue" --profile=linear-team # Create on a different profile [Pro]
|
|
728
748
|
|
|
729
749
|
# ── Stats ──────────────────────────────────────────────────────────────────────
|
|
730
750
|
ticketlens stats # Response-time metrics from local history
|
|
@@ -811,6 +831,9 @@ ticketlens comment CNV1-2 --body="..." # Post a comment to the tracker
|
|
|
811
831
|
ticketlens transition CNV1-2 --target="Done" --confirm # Transition ticket status
|
|
812
832
|
ticketlens assign CNV1-2 --to=me # Assign the ticket to yourself
|
|
813
833
|
ticketlens duplicates CNV1-2 # Find likely duplicates (read-only)
|
|
834
|
+
ticketlens link CNV1-2 CNV1-3 --type="Duplicate" --confirm # Link two tickets
|
|
835
|
+
ticketlens update CNV1-2 --title="..." # Update title/description/labels/priority
|
|
836
|
+
ticketlens create --project=CNV1 --type="Task" --summary="..." # Create a new ticket
|
|
814
837
|
ticketlens activate YOUR-LICENSE-KEY # Activate Pro license
|
|
815
838
|
```
|
|
816
839
|
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import { activateLicense, checkLicense, revalidateIfStale, isLicensed, showUpgra
|
|
|
18
18
|
import { deleteProfile, loadProfiles, saveCredentialKey } from '../skills/jtb/scripts/lib/profile-resolver.mjs';
|
|
19
19
|
import { run as runCache } from '../skills/jtb/scripts/lib/cache-manager.mjs';
|
|
20
20
|
import {
|
|
21
|
-
printHelp, printProfiles,
|
|
21
|
+
printHelp, printProfiles, printHistoryHelp,
|
|
22
22
|
printLoginHelp, printLogoutHelp, printSyncHelp,
|
|
23
23
|
printActivateHelp, printLicenseHelp, printDeleteHelp,
|
|
24
24
|
printProfilesHelp, printScheduleHelp,
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
printCollisionsHelp, printStatsHelp,
|
|
29
29
|
printCloudKeysHelp,
|
|
30
30
|
printNoteHelp, printRecallHelp, printMcpHelp,
|
|
31
|
-
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp,
|
|
31
|
+
printCommentHelp, printTransitionHelp, printAssignHelp, printDuplicatesHelp, printLinkHelp, printUpdateHelp, printCreateHelp,
|
|
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';
|
|
@@ -134,6 +134,7 @@ switch (command) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
case 'history': {
|
|
137
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printHistoryHelp(); break; }
|
|
137
138
|
if (!isLicensed('pro')) { showUpgradePrompt('pro', 'ticketlens history'); break; }
|
|
138
139
|
const ticketKey = cmdArgs[0];
|
|
139
140
|
if (!ticketKey || ticketKey.startsWith('-')) {
|
|
@@ -790,6 +791,46 @@ switch (command) {
|
|
|
790
791
|
break;
|
|
791
792
|
}
|
|
792
793
|
|
|
794
|
+
case 'link': {
|
|
795
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printLinkHelp(); break; }
|
|
796
|
+
const { runTicketLinkList, runTicketLink } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
797
|
+
// No --type → discovery only, never mutates. --type present → execute
|
|
798
|
+
// (runTicketLink itself still refuses without --confirm).
|
|
799
|
+
const hasType = cmdArgs.some(a => a.startsWith('--type='));
|
|
800
|
+
const runFn = hasType ? runTicketLink : runTicketLinkList;
|
|
801
|
+
runFn(cmdArgs).then(({ ok }) => {
|
|
802
|
+
if (!ok) process.exitCode = 1;
|
|
803
|
+
}).catch(err => {
|
|
804
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
805
|
+
process.exitCode = 1;
|
|
806
|
+
});
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
case 'update': {
|
|
811
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printUpdateHelp(); break; }
|
|
812
|
+
const { runTicketUpdate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
813
|
+
runTicketUpdate(cmdArgs).then(({ ok }) => {
|
|
814
|
+
if (!ok) process.exitCode = 1;
|
|
815
|
+
}).catch(err => {
|
|
816
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
817
|
+
process.exitCode = 1;
|
|
818
|
+
});
|
|
819
|
+
break;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
case 'create': {
|
|
823
|
+
if (cmdArgs.includes('--help') || cmdArgs.includes('-h')) { printCreateHelp(); break; }
|
|
824
|
+
const { runTicketCreate } = await import('../skills/jtb/scripts/lib/ticket-command.mjs');
|
|
825
|
+
runTicketCreate(cmdArgs).then(({ ok }) => {
|
|
826
|
+
if (!ok) process.exitCode = 1;
|
|
827
|
+
}).catch(err => {
|
|
828
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
829
|
+
process.exitCode = 1;
|
|
830
|
+
});
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
|
|
793
834
|
case 'help':
|
|
794
835
|
default: {
|
|
795
836
|
const isInteractive = args.length === 0 && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
package/package.json
CHANGED
|
@@ -29,6 +29,7 @@ export function parseGitHubRepo(baseUrl) {
|
|
|
29
29
|
export function normalizeGitHubIssue(raw, comments = [], keyPrefix = 'GH') {
|
|
30
30
|
return {
|
|
31
31
|
key: `${keyPrefix}-${raw.number}`,
|
|
32
|
+
id: raw.id,
|
|
32
33
|
summary: raw.title,
|
|
33
34
|
type: 'Issue',
|
|
34
35
|
status: raw.state,
|
|
@@ -240,5 +241,134 @@ export function createGitHubAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
240
241
|
.filter(item => item.number !== sourceNumber)
|
|
241
242
|
.map(item => normalizeGitHubIssue(item, [], keyPrefix));
|
|
242
243
|
},
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* GitHub has no generic link-type concept — "duplicate" (via closing
|
|
247
|
+
* the source issue) is the only relationship it supports natively.
|
|
248
|
+
*/
|
|
249
|
+
async getLinkTypes() {
|
|
250
|
+
return ['duplicate'];
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* GitHub's only real "link" action closes sourceKey as a duplicate of
|
|
255
|
+
* targetKey — asymmetric and state-changing, unlike Jira/Linear's pure
|
|
256
|
+
* relationship-add. Resolves targetKey's internal id via fetchTicket
|
|
257
|
+
* (GitHub's duplicate_issue_id wants the internal id, not the
|
|
258
|
+
* repo-local number).
|
|
259
|
+
*/
|
|
260
|
+
async linkTo(sourceKey, targetKey, typeName, opts = {}) {
|
|
261
|
+
if (typeName.toLowerCase() !== 'duplicate') {
|
|
262
|
+
throw new Error(`GitHub only supports linking as a duplicate — got type "${typeName}".`);
|
|
263
|
+
}
|
|
264
|
+
const target = await this.fetchTicket(targetKey, opts);
|
|
265
|
+
const sourceNumber = parseInt(sourceKey.split('-').pop(), 10);
|
|
266
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${sourceNumber}`, {
|
|
267
|
+
method: 'PATCH',
|
|
268
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ state: 'closed', state_reason: 'duplicate', duplicate_issue_id: target.id }),
|
|
270
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) await throwGitHubWriteError(res, 'linking', sourceKey);
|
|
273
|
+
return { executed: true, closedAsDuplicateOf: targetKey };
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Unlike Jira/Linear's single atomic mutation, GitHub genuinely has no
|
|
278
|
+
* one-call way to do this: title/description share a PATCH, but labels
|
|
279
|
+
* use their own dedicated additive (POST) and per-label (DELETE)
|
|
280
|
+
* endpoints — never the full-replace PUT, which would force an unsafe
|
|
281
|
+
* read-then-write race. Each operation is independent and best-effort:
|
|
282
|
+
* one failing must never prevent the others from being attempted, and
|
|
283
|
+
* the caller needs to know exactly which fields actually landed.
|
|
284
|
+
*/
|
|
285
|
+
async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
|
|
286
|
+
if (priority !== undefined) {
|
|
287
|
+
throw new Error('GitHub Issues have no native priority field — cannot update priority.');
|
|
288
|
+
}
|
|
289
|
+
const number = parseInt(key.split('-').pop(), 10);
|
|
290
|
+
const signal = () => AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
291
|
+
const applied = {};
|
|
292
|
+
const errors = {};
|
|
293
|
+
|
|
294
|
+
if (title !== undefined || description !== undefined) {
|
|
295
|
+
try {
|
|
296
|
+
const body = {};
|
|
297
|
+
if (title !== undefined) body.title = title;
|
|
298
|
+
if (description !== undefined) body.body = description;
|
|
299
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}`, {
|
|
300
|
+
method: 'PATCH',
|
|
301
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
302
|
+
body: JSON.stringify(body),
|
|
303
|
+
signal: signal(),
|
|
304
|
+
});
|
|
305
|
+
if (!res.ok) await throwGitHubWriteError(res, 'updating', key);
|
|
306
|
+
if (title !== undefined) applied.title = true;
|
|
307
|
+
if (description !== undefined) applied.description = true;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (title !== undefined) errors.title = err;
|
|
310
|
+
if (description !== undefined) errors.description = err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (addLabels?.length) {
|
|
315
|
+
try {
|
|
316
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/labels`, {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
319
|
+
body: JSON.stringify({ labels: addLabels }),
|
|
320
|
+
signal: signal(),
|
|
321
|
+
});
|
|
322
|
+
if (!res.ok) await throwGitHubWriteError(res, 'adding labels to', key);
|
|
323
|
+
applied.addLabels = addLabels;
|
|
324
|
+
} catch (err) {
|
|
325
|
+
errors.addLabels = err;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (removeLabels?.length) {
|
|
330
|
+
const removed = [];
|
|
331
|
+
const removeErrors = {};
|
|
332
|
+
for (const label of removeLabels) {
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues/${number}/labels/${encodeURIComponent(label)}`, {
|
|
335
|
+
method: 'DELETE',
|
|
336
|
+
headers,
|
|
337
|
+
signal: signal(),
|
|
338
|
+
});
|
|
339
|
+
// A 404 here means the label was already absent — the caller's
|
|
340
|
+
// goal ("this label is gone") is already true, so this is
|
|
341
|
+
// treated as success, not a failure to surface.
|
|
342
|
+
if (!res.ok && res.status !== 404) await throwGitHubWriteError(res, 'removing a label from', key);
|
|
343
|
+
removed.push(label);
|
|
344
|
+
} catch (err) {
|
|
345
|
+
removeErrors[label] = err;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (removed.length) applied.removeLabels = removed;
|
|
349
|
+
if (Object.keys(removeErrors).length) errors.removeLabels = removeErrors;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return { applied, errors };
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `project`/`type` are ignored — GitHub has no such concepts here: the
|
|
357
|
+
* target repo is fixed by the profile's baseUrl, and issues have no
|
|
358
|
+
* type field in this MVP scope.
|
|
359
|
+
*/
|
|
360
|
+
async createTicket({ summary, description } = {}, opts = {}) {
|
|
361
|
+
const body = { title: summary };
|
|
362
|
+
if (description !== undefined) body.body = description;
|
|
363
|
+
const res = await fetcher(`${GITHUB_API}/repos/${owner}/${repo}/issues`, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
366
|
+
body: JSON.stringify(body),
|
|
367
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 10_000),
|
|
368
|
+
});
|
|
369
|
+
if (!res.ok) await throwGitHubWriteError(res, 'creating an issue in', `${owner}/${repo}`);
|
|
370
|
+
const raw = await res.json();
|
|
371
|
+
return { key: `${keyPrefix}-${raw.number}`, id: String(raw.id), url: raw.html_url ?? null };
|
|
372
|
+
},
|
|
243
373
|
};
|
|
244
374
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, postComment, getTransitions, postTransition, assignIssue, escapeJql } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue } from '../jira-client.mjs';
|
|
2
2
|
import { buildJiraEnv } from '../config.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -83,5 +83,71 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
83
83
|
const jql = `project = "${escapeJql(project)}" AND key != "${escapeJql(sourceKey)}" AND text ~ "${escapeJql(searchText)}" ORDER BY updated DESC`;
|
|
84
84
|
return searchTickets(jql, { ...base, ...opts });
|
|
85
85
|
},
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Always fetched fresh — link type names are per-instance customizable
|
|
89
|
+
* in Jira, same "never trust a stale list" principle as getTransitions.
|
|
90
|
+
* Returns just names (matches GitHub/Linear's plain-string shape) so
|
|
91
|
+
* runTicketLinkList can render any tracker's list uniformly.
|
|
92
|
+
*/
|
|
93
|
+
async getLinkTypes(opts = {}) {
|
|
94
|
+
const types = await getIssueLinkTypes({ ...base, ...opts });
|
|
95
|
+
return types.map(t => t.name);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Always re-fetches link types fresh and resolves `typeName` against
|
|
100
|
+
* them before executing — a caller can never blind-POST a stale or
|
|
101
|
+
* guessed type name, same principle as transition().
|
|
102
|
+
* sourceKey is the outwardIssue, targetKey is the inwardIssue — direction matters.
|
|
103
|
+
*/
|
|
104
|
+
async linkTo(sourceKey, targetKey, typeName, opts = {}) {
|
|
105
|
+
const types = await getIssueLinkTypes({ ...base, ...opts });
|
|
106
|
+
const match = types.find(t => t.name.toLowerCase() === typeName.toLowerCase());
|
|
107
|
+
if (!match) {
|
|
108
|
+
return { executed: false, reason: 'not-found', options: types.map(t => t.name) };
|
|
109
|
+
}
|
|
110
|
+
await postIssueLink(sourceKey, targetKey, match.name, { ...base, ...opts });
|
|
111
|
+
return { executed: true };
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Jira does this in a single atomic PUT (fields + update.labels in one
|
|
116
|
+
* request, confirmed via Jira's own docs) — unlike GitHub, there is no
|
|
117
|
+
* per-field partial-failure surface here: either the whole call
|
|
118
|
+
* succeeds and every requested field is applied, or it throws and
|
|
119
|
+
* ticket-command.mjs's existing formatWriteFailure handles it exactly
|
|
120
|
+
* like every other write. Priority-name validity is not pre-checked —
|
|
121
|
+
* an invalid name surfaces Jira's own 400 with details, same design
|
|
122
|
+
* choice already made for ticket_create's issuetype field.
|
|
123
|
+
*/
|
|
124
|
+
async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
|
|
125
|
+
await updateIssue(key, { summary: title, description, priority, addLabels, removeLabels }, { ...base, ...opts });
|
|
126
|
+
const applied = {};
|
|
127
|
+
if (title !== undefined) applied.title = true;
|
|
128
|
+
if (description !== undefined) applied.description = true;
|
|
129
|
+
if (priority !== undefined) applied.priority = priority;
|
|
130
|
+
if (addLabels?.length) applied.addLabels = addLabels;
|
|
131
|
+
if (removeLabels?.length) applied.removeLabels = removeLabels;
|
|
132
|
+
return { applied, errors: {} };
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* project/type are passed straight through — never pre-validated
|
|
137
|
+
* client-side, same design choice already made for updateFields'
|
|
138
|
+
* priority field. An invalid issuetype surfaces Jira's own 400.
|
|
139
|
+
*/
|
|
140
|
+
createTicket: ({ project, type, summary, description } = {}, opts = {}) =>
|
|
141
|
+
createIssue({ project, type, summary, description }, { ...base, ...opts }),
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Real, currently-creatable projects for this token — used to enrich a
|
|
145
|
+
* ticket_create failure message with actual options, never to
|
|
146
|
+
* pre-validate before writing.
|
|
147
|
+
*/
|
|
148
|
+
listCreatableProjects: (opts = {}) => fetchProjects({ ...base, ...opts }),
|
|
149
|
+
|
|
150
|
+
/** Real, currently-configured issue types for one project. */
|
|
151
|
+
listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
|
|
86
152
|
};
|
|
87
153
|
}
|
|
@@ -4,6 +4,9 @@ const LINEAR_API = 'https://api.linear.app/graphql';
|
|
|
4
4
|
|
|
5
5
|
const PRIORITY_LABELS = { 1: 'Urgent', 2: 'High', 3: 'Medium', 4: 'Low' };
|
|
6
6
|
|
|
7
|
+
/** Linear's IssueRelationType enum — fixed schema-level values, confirmed via GraphQL introspection. */
|
|
8
|
+
const LINK_TYPES = ['blocks', 'duplicate', 'related'];
|
|
9
|
+
|
|
7
10
|
const ISSUE_FIELDS = `
|
|
8
11
|
identifier
|
|
9
12
|
title
|
|
@@ -87,6 +90,17 @@ async function fetchIssueStateInfo(key, { token, fetcher, signal }) {
|
|
|
87
90
|
return node;
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
/** Splits a list of label names into those found in `byName` (with their resolved id) and those not. */
|
|
94
|
+
function resolveLabelNames(names, byName) {
|
|
95
|
+
const resolved = [];
|
|
96
|
+
const missing = [];
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
const id = byName.get(name.toLowerCase());
|
|
99
|
+
if (id) resolved.push({ id, name }); else missing.push(name);
|
|
100
|
+
}
|
|
101
|
+
return { resolved, missing };
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
|
|
91
105
|
const data = await gql(
|
|
92
106
|
`query ($teamId: ID!) {
|
|
@@ -295,5 +309,183 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
295
309
|
.filter(node => node.identifier !== sourceKey)
|
|
296
310
|
.map(normalizeLinearIssue);
|
|
297
311
|
},
|
|
312
|
+
|
|
313
|
+
/** Fixed schema-level enum (confirmed via GraphQL introspection) — never per-instance configurable, unlike Jira's link types. */
|
|
314
|
+
async getLinkTypes() {
|
|
315
|
+
return LINK_TYPES;
|
|
316
|
+
},
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Validates typeName against the fixed enum before ever touching the
|
|
320
|
+
* network — an invalid type must never reach gql(), which throws a
|
|
321
|
+
* plain Error with no .status, and would otherwise be misclassified
|
|
322
|
+
* as a network/timeout failure by classifyWriteFailure. Resolves both
|
|
323
|
+
* issues' internal UUIDs via fetchIssueStateInfo — issueRelationCreate
|
|
324
|
+
* needs the UUID, never the human identifier. Explicitly checks
|
|
325
|
+
* `success`: Linear can return HTTP 200 with no top-level GraphQL
|
|
326
|
+
* errors and still report success:false.
|
|
327
|
+
*/
|
|
328
|
+
async linkTo(sourceKey, targetKey, typeName, opts = {}) {
|
|
329
|
+
const normalizedType = typeName.toLowerCase();
|
|
330
|
+
if (!LINK_TYPES.includes(normalizedType)) {
|
|
331
|
+
return { executed: false, reason: 'not-found', options: LINK_TYPES };
|
|
332
|
+
}
|
|
333
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
334
|
+
const source = await fetchIssueStateInfo(sourceKey, { token, fetcher, signal });
|
|
335
|
+
const target = await fetchIssueStateInfo(targetKey, { token, fetcher, signal });
|
|
336
|
+
const data = await gql(
|
|
337
|
+
`mutation ($issueId: String!, $relatedIssueId: String!, $type: IssueRelationType!) {
|
|
338
|
+
issueRelationCreate(input: { issueId: $issueId, relatedIssueId: $relatedIssueId, type: $type }) {
|
|
339
|
+
success
|
|
340
|
+
}
|
|
341
|
+
}`,
|
|
342
|
+
{ issueId: source.id, relatedIssueId: target.id, type: normalizedType },
|
|
343
|
+
{ token, fetcher, signal },
|
|
344
|
+
);
|
|
345
|
+
if (!data.issueRelationCreate?.success) {
|
|
346
|
+
throw new Error(`Linear issueRelationCreate reported success:false linking ${sourceKey} to ${targetKey}`);
|
|
347
|
+
}
|
|
348
|
+
return { executed: true };
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Unlike Jira's freeform label strings, Linear's addedLabelIds/
|
|
353
|
+
* removedLabelIds (IssueUpdateInput) take real label UUIDs and never
|
|
354
|
+
* auto-create a missing one — a name must be resolved against the
|
|
355
|
+
* issue's own team first (same shape as fetchTeamWorkflowStates).
|
|
356
|
+
* Resolution failures (unknown label name, unresolvable priority name)
|
|
357
|
+
* are collected into `errors` and simply excluded from the mutation
|
|
358
|
+
* input rather than blocking it — whatever DID resolve still lands in
|
|
359
|
+
* one atomic issueUpdate call. Empty input skips the mutation entirely
|
|
360
|
+
* (nothing valid to send). Priority is accepted as a display name for
|
|
361
|
+
* cross-tracker consistency and reverse-mapped via the same
|
|
362
|
+
* PRIORITY_LABELS table normalizeLinearIssue already uses for reads.
|
|
363
|
+
*/
|
|
364
|
+
async updateFields(key, { title, description, priority, addLabels, removeLabels } = {}, opts = {}) {
|
|
365
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
366
|
+
const info = await fetchIssueStateInfo(key, { token, fetcher, signal });
|
|
367
|
+
|
|
368
|
+
const input = {};
|
|
369
|
+
const applied = {};
|
|
370
|
+
const errors = {};
|
|
371
|
+
|
|
372
|
+
if (title !== undefined) input.title = title;
|
|
373
|
+
if (description !== undefined) input.description = description;
|
|
374
|
+
|
|
375
|
+
let resolvedPriorityLabel;
|
|
376
|
+
if (priority !== undefined) {
|
|
377
|
+
const normalized = priority.trim().toLowerCase();
|
|
378
|
+
if (normalized === 'none') {
|
|
379
|
+
input.priority = 0;
|
|
380
|
+
resolvedPriorityLabel = 'None';
|
|
381
|
+
} else {
|
|
382
|
+
const entry = Object.entries(PRIORITY_LABELS).find(([, label]) => label.toLowerCase() === normalized);
|
|
383
|
+
if (entry) {
|
|
384
|
+
input.priority = Number(entry[0]);
|
|
385
|
+
resolvedPriorityLabel = entry[1];
|
|
386
|
+
} else {
|
|
387
|
+
errors.priority = { reason: 'not-found', options: ['None', ...Object.values(PRIORITY_LABELS)] };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let addLabelsResolved, addLabelsMissing, removeLabelsResolved, removeLabelsMissing;
|
|
393
|
+
if (addLabels?.length || removeLabels?.length) {
|
|
394
|
+
const labelData = await gql(
|
|
395
|
+
`query ($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }, first: 250) { nodes { id name } } }`,
|
|
396
|
+
{ teamId: info.team.id },
|
|
397
|
+
{ token, fetcher, signal },
|
|
398
|
+
);
|
|
399
|
+
const byName = new Map((labelData.issueLabels?.nodes ?? []).map(l => [l.name.toLowerCase(), l.id]));
|
|
400
|
+
|
|
401
|
+
if (addLabels?.length) {
|
|
402
|
+
({ resolved: addLabelsResolved, missing: addLabelsMissing } = resolveLabelNames(addLabels, byName));
|
|
403
|
+
if (addLabelsResolved.length) input.addedLabelIds = addLabelsResolved.map(l => l.id);
|
|
404
|
+
if (addLabelsMissing.length) errors.addLabels = { reason: 'not-found', missing: addLabelsMissing };
|
|
405
|
+
}
|
|
406
|
+
if (removeLabels?.length) {
|
|
407
|
+
({ resolved: removeLabelsResolved, missing: removeLabelsMissing } = resolveLabelNames(removeLabels, byName));
|
|
408
|
+
if (removeLabelsResolved.length) input.removedLabelIds = removeLabelsResolved.map(l => l.id);
|
|
409
|
+
if (removeLabelsMissing.length) errors.removeLabels = { reason: 'not-found', missing: removeLabelsMissing };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (Object.keys(input).length === 0) {
|
|
414
|
+
return { applied, errors };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const data = await gql(
|
|
418
|
+
`mutation ($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { success } }`,
|
|
419
|
+
{ id: info.id, input },
|
|
420
|
+
{ token, fetcher, signal },
|
|
421
|
+
);
|
|
422
|
+
if (!data.issueUpdate?.success) {
|
|
423
|
+
throw new Error(`Linear issueUpdate reported success:false updating ${key}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (title !== undefined) applied.title = true;
|
|
427
|
+
if (description !== undefined) applied.description = true;
|
|
428
|
+
if (input.priority !== undefined) applied.priority = resolvedPriorityLabel;
|
|
429
|
+
if (addLabelsResolved?.length) applied.addLabels = addLabelsResolved.map(l => l.name);
|
|
430
|
+
if (removeLabelsResolved?.length) applied.removeLabels = removeLabelsResolved.map(l => l.name);
|
|
431
|
+
|
|
432
|
+
return { applied, errors };
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* `project` is the team's short key (e.g. "ENG") — mutations need the
|
|
437
|
+
* UUID, never the key, so it's resolved via TeamFilter.key first
|
|
438
|
+
* (confirmed against Linear's own official generated GraphQL schema).
|
|
439
|
+
* No discovery call on an unresolvable key, same "surface a clear
|
|
440
|
+
* terminal error" choice already made for Jira's issuetype — there is
|
|
441
|
+
* no partial-creation concept here, unlike updateFields' best-effort
|
|
442
|
+
* shape. `type` has no Linear equivalent and is intentionally ignored.
|
|
443
|
+
*/
|
|
444
|
+
async createTicket({ project, summary, description } = {}, opts = {}) {
|
|
445
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
446
|
+
const teamData = await gql(
|
|
447
|
+
`query ($key: String!) { teams(filter: { key: { eq: $key } }, first: 1) { nodes { id } } }`,
|
|
448
|
+
{ key: project },
|
|
449
|
+
{ token, fetcher, signal },
|
|
450
|
+
);
|
|
451
|
+
const team = teamData.teams?.nodes?.[0];
|
|
452
|
+
if (!team) {
|
|
453
|
+
const err = new Error(`Linear team not found for project "${project}".`);
|
|
454
|
+
// Marked, not message-sniffed — ticket-command.mjs's cache-refresh
|
|
455
|
+
// enrichment needs a clean way to detect "the project/team didn't
|
|
456
|
+
// resolve" without parsing this string.
|
|
457
|
+
err.code = 'PROJECT_NOT_FOUND';
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
const input = { teamId: team.id, title: summary };
|
|
461
|
+
if (description !== undefined) input.description = description;
|
|
462
|
+
const data = await gql(
|
|
463
|
+
`mutation ($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { identifier id url } } }`,
|
|
464
|
+
{ input },
|
|
465
|
+
{ token, fetcher, signal },
|
|
466
|
+
);
|
|
467
|
+
if (!data.issueCreate?.success) {
|
|
468
|
+
throw new Error(`Linear issueCreate reported success:false creating an issue in team "${project}".`);
|
|
469
|
+
}
|
|
470
|
+
const issue = data.issueCreate.issue;
|
|
471
|
+
return { key: issue.identifier, id: issue.id, url: issue.url ?? null };
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Real, currently-accessible teams for this token — unfiltered, used
|
|
476
|
+
* only to enrich a ticket_create failure message with actual options
|
|
477
|
+
* (Jira calls the equivalent concept "projects"; --project already
|
|
478
|
+
* conflates the two at the CLI level, so the shape matches for a
|
|
479
|
+
* uniform cache).
|
|
480
|
+
*/
|
|
481
|
+
async listCreatableProjects(opts = {}) {
|
|
482
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
483
|
+
const data = await gql(
|
|
484
|
+
`query { teams(first: 250) { nodes { key name } } }`,
|
|
485
|
+
{},
|
|
486
|
+
{ token, fetcher, signal },
|
|
487
|
+
);
|
|
488
|
+
return (data.teams?.nodes ?? []).map(t => ({ key: t.key, name: t.name }));
|
|
489
|
+
},
|
|
298
490
|
};
|
|
299
491
|
}
|
|
@@ -146,6 +146,18 @@ export function parseCommand(args) {
|
|
|
146
146
|
return { command: 'duplicates', args: args.slice(1) };
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
if (first === 'link') {
|
|
150
|
+
return { command: 'link', args: args.slice(1) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (first === 'update') {
|
|
154
|
+
return { command: 'update', args: args.slice(1) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (first === 'create') {
|
|
158
|
+
return { command: 'create', args: args.slice(1) };
|
|
159
|
+
}
|
|
160
|
+
|
|
149
161
|
// Anything that looks like a ticket key or any non-flag arg → fetch
|
|
150
162
|
return { command: 'fetch', args };
|
|
151
163
|
}
|