ticketlens 0.40.1 → 0.42.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 +2 -1
- package/package.json +1 -1
- package/skills/jtb/SKILL.md +6 -4
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +54 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +39 -6
- package/skills/jtb/scripts/lib/help.mjs +16 -7
- package/skills/jtb/scripts/lib/jira-client.mjs +73 -0
- package/skills/jtb/scripts/lib/mcp-server.mjs +3 -1
- package/skills/jtb/scripts/lib/mcp-tool-schemas.mjs +3 -2
- package/skills/jtb/scripts/lib/ticket-command.mjs +270 -72
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +103 -2
- package/skills/jtb/scripts/lib/ticket-update-enrichment.mjs +66 -0
package/README.md
CHANGED
|
@@ -499,7 +499,7 @@ Write directly to the ticket in its real tracker — Jira, GitHub, or Linear —
|
|
|
499
499
|
|
|
500
500
|
`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.
|
|
501
501
|
|
|
502
|
-
`ticketlens assign`
|
|
502
|
+
`ticketlens assign --to=me` self-assigns immediately, no `--confirm` needed. Any other `--to` (a name or email) assigns to another developer — Jira Cloud only for now, GitHub/Linear/Server-DC refuse cleanly. It searches Jira's real assignable-user list first: without `--confirm` it only lists the matching candidate(s), never assigns; with `--confirm` it executes only if exactly one candidate still matches (0 or 2+ always refuses). Matches are cached locally per project for 3 days.
|
|
503
503
|
|
|
504
504
|
`ticketlens worklog` (or `tl worklog`) logs time on Jira tickets, always as you.
|
|
505
505
|
|
|
@@ -1121,6 +1121,7 @@ npm test
|
|
|
1121
1121
|
See [ROADMAP.md](ROADMAP.md) for the full plan.
|
|
1122
1122
|
|
|
1123
1123
|
Recently shipped:
|
|
1124
|
+
- **`ticketlens assign --to="name or email"`** — assign a ticket to another developer, not just yourself. Jira Cloud only for now; searches real assignable users first, lists matches, executes only on one confirmed match. Pro tier
|
|
1124
1125
|
- **Console: Recall "select all N matching" bulk delete** — Gmail-style banner deletes every note matching your search/filters across all pages, not just the current one
|
|
1125
1126
|
- **Recall Stop-hook fix** — the end-of-session Recall reminder no longer fires because of file writes; only real ticket writes (comment, transition, assign, update) arm it. Also hardened against malformed transcripts and a session-id path-collision bug found by adversarial testing
|
|
1126
1127
|
- **Console: no repeat request on same-page menu clicks** — sidebar links, the header gear and Settings tabs skip the request when they point at the page you are on
|
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.46.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.
|
|
@@ -436,7 +436,9 @@ ticketlens comment PROD-1234 --body="Fixed in a2f9c1, deployed to staging."
|
|
|
436
436
|
ticketlens comment PROD-1234 --body="See screenshot" --attach=./bug.png # attach local files
|
|
437
437
|
ticketlens transition PROD-1234 # list valid transitions — read-only
|
|
438
438
|
ticketlens transition PROD-1234 --target="Done" --confirm # execute
|
|
439
|
-
ticketlens assign PROD-1234 --to=me # assign to yourself
|
|
439
|
+
ticketlens assign PROD-1234 --to=me # assign to yourself — immediate, no --confirm
|
|
440
|
+
ticketlens assign PROD-1234 --to="Jane Dev" # search assignable users — read-only, lists match(es)
|
|
441
|
+
ticketlens assign PROD-1234 --to="Jane Dev" --confirm # execute — only if exactly one match (Jira Cloud only)
|
|
440
442
|
ticketlens duplicates PROD-1234 # find likely duplicates — read-only
|
|
441
443
|
ticketlens link PROD-1234 PROD-5678 # list valid link types — read-only
|
|
442
444
|
ticketlens link PROD-1234 PROD-5678 --type="Duplicate" --confirm # execute the link
|
|
@@ -451,13 +453,13 @@ ticketlens worklog PROD-1234=1h30m PROD-5678=45m --comment="Sprint work" --confi
|
|
|
451
453
|
|
|
452
454
|
`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.
|
|
453
455
|
|
|
454
|
-
`assign`
|
|
456
|
+
`assign` — `--to=me` self-assigns immediately, no `--confirm` needed. Any other `--to` (a name or email) assigns to another developer, Jira Cloud only for now — GitHub, Linear, and Jira Server/DC refuse cleanly rather than guess at unverified API behavior. It searches Jira's real assignable-user list first: called without `--confirm` it never assigns, only lists the matching candidate(s) (name + accountId, cached locally per project for 3 days); called again with the same `--to` and `--confirm`, it executes only if exactly one candidate still matches — 0 or 2+ matches always refuses, never guesses which person was meant. Never attempt a workaround (e.g. via `comment`) for a tracker `assign` refuses (GitHub/Linear/Server-DC) — tell the user it isn't supported yet.
|
|
455
457
|
|
|
456
458
|
`duplicates` lists likely-duplicate tickets in the same project. On Jira, any ticket already linked as a "Duplicate" is always listed first — that's a confirmed relationship a human already recorded, not a heuristic. Everything else is ranked by local title/description overlap — no tracker scores similarity server-side, so treat those as a nudge for the user to check manually, never as a confirmed duplicate to act on unprompted (e.g. don't auto-close or auto-comment based on a match). `--threshold=N` (0–1, default 0.35) tightens or loosens what counts as a text-match — it has no effect on Jira-linked duplicates, which are always shown. An empty result is the same approximation in the other direction — the local scorer can miss a real duplicate too, so don't treat "no likely duplicates found" as proof none exist.
|
|
457
459
|
|
|
458
460
|
`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). Called with just the two keys, it lists the tracker's current valid link types without changing anything — never guess `--type`; always list first, then use one of the names shown. GitHub has no generic link relationship, so linking on a GitHub-tracked ticket *closes SOURCE as a duplicate of TARGET* — a real state change, not just a relationship add — and prints an explicit warning immediately before that happens, on top of the same `--confirm` gate.
|
|
459
461
|
|
|
460
|
-
`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 — these are reversible metadata edits, same risk tier as
|
|
462
|
+
`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 — these are reversible metadata edits, same risk tier as self-assign.
|
|
461
463
|
|
|
462
464
|
`create` makes a brand-new ticket — there's no existing ticket to target, so `--project` (Jira project key / Linear team key) and `--type` (Jira issue type, ignored elsewhere) pick the destination instead of a ticket key. This is the highest-blast-radius command in the family: a bad `--project`/`--type` fabricates a real, hard-to-walk-back item in a live tracker. No `--confirm` gate — double-check the values with the user before calling it, since an invalid value surfaces the tracker's own error rather than a silent guess.
|
|
463
465
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, postComment, getTransitions, postTransition, assignIssue, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue, DEFAULT_SEARCH_FIELDS } from '../jira-client.mjs';
|
|
1
|
+
import { fetchTicket, fetchCurrentUser, searchTickets, fetchStatuses, fetchProjects, fetchIssueTypes, fetchProjectPriorities, postComment, getTransitions, postTransition, assignIssue, fetchAssignableUsers, escapeJql, getIssueLinkTypes, postIssueLink, updateIssue, createIssue, DEFAULT_SEARCH_FIELDS } from '../jira-client.mjs';
|
|
2
2
|
import { postWorklog } from '../jira-worklog-client.mjs';
|
|
3
3
|
import { uploadAttachment, resolveMediaId } from '../jira-attachment-client.mjs';
|
|
4
4
|
import { readAttachments } from '../attachment-uploader.mjs';
|
|
@@ -83,6 +83,31 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
83
83
|
return { assignee: me.displayName ?? value };
|
|
84
84
|
},
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Resolves a free-text name/email into candidate assignable users —
|
|
88
|
+
* read-only, never assigns. Cloud only (`apiVersion === 3`): Jira
|
|
89
|
+
* Server/DC's equivalent endpoint semantics are unverified, so this
|
|
90
|
+
* refuses rather than guess at request/response shape (ROADMAP 61).
|
|
91
|
+
* Caching (3-day TTL, per project+query) is the caller's job — same
|
|
92
|
+
* split as `listIssueTypes`, which is also cache-agnostic here.
|
|
93
|
+
*/
|
|
94
|
+
async searchAssignableUsers(key, query, opts = {}) {
|
|
95
|
+
if (apiVersion !== 3) {
|
|
96
|
+
throw new Error('Assigning to another developer needs Jira Cloud — Server/DC is not supported yet.');
|
|
97
|
+
}
|
|
98
|
+
const users = await fetchAssignableUsers(key, query, { ...base, ...opts });
|
|
99
|
+
return users.map(u => ({ accountId: u.accountId, displayName: u.displayName ?? u.name ?? u.accountId }));
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Executes an assignment to a resolved accountId — always Cloud
|
|
104
|
+
* (`searchAssignableUsers` is the only path that produces one), so no
|
|
105
|
+
* apiVersion branch is needed here the way `assignToSelf` has.
|
|
106
|
+
*/
|
|
107
|
+
async assignToUser(key, accountId, opts = {}) {
|
|
108
|
+
await assignIssue(key, { accountId }, { ...base, ...opts });
|
|
109
|
+
},
|
|
110
|
+
|
|
86
111
|
/**
|
|
87
112
|
* Candidate search for duplicate-ticket detection. Scoped to the same
|
|
88
113
|
* project as `sourceKey` (derived from its own prefix) and excludes it
|
|
@@ -186,6 +211,34 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
186
211
|
/** Real, currently-configured issue types for one project. */
|
|
187
212
|
listIssueTypes: (projectKey, opts = {}) => fetchIssueTypes(projectKey, { ...base, ...opts }),
|
|
188
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Real, currently-configured priority options for one project — used
|
|
216
|
+
* only to enrich an `update` failure message, never to pre-validate
|
|
217
|
+
* (see fetchProjectPriorities' own doc for why). Priority has no
|
|
218
|
+
* dedicated list-per-project endpoint, so this walks the project's
|
|
219
|
+
* issue types until one returns a non-empty priority list. NOT every
|
|
220
|
+
* issue type has one: confirmed live against corenexus that a
|
|
221
|
+
* team-managed project's Epic type has no `priority` field in its
|
|
222
|
+
* field metadata at all (real fields returned: assignee, description,
|
|
223
|
+
* labels, ... — priority absent), while its Task type does. An
|
|
224
|
+
* earlier version picked types[0] unconditionally and silently
|
|
225
|
+
* returned [] whenever that happened to be an Epic — caught by a live
|
|
226
|
+
* CLI run against CNV1-34, not by any unit test (every unit test used
|
|
227
|
+
* a hand-picked type). Stops at the first success rather than trying
|
|
228
|
+
* every type, since priority is otherwise scheme-shared across a
|
|
229
|
+
* project's types by Jira's default. Returns [] only once every type
|
|
230
|
+
* has been tried — this can only ever make an error message less
|
|
231
|
+
* informative, never a new way for `update` to fail.
|
|
232
|
+
*/
|
|
233
|
+
async listPriorities(projectKey, opts = {}) {
|
|
234
|
+
const types = await fetchIssueTypes(projectKey, { ...base, ...opts });
|
|
235
|
+
for (const type of types) {
|
|
236
|
+
const priorities = await fetchProjectPriorities(projectKey, type.id, { ...base, ...opts });
|
|
237
|
+
if (priorities.length) return priorities;
|
|
238
|
+
}
|
|
239
|
+
return [];
|
|
240
|
+
},
|
|
241
|
+
|
|
189
242
|
/**
|
|
190
243
|
* Best-effort, per-file: one bad path or one failed upload never blocks
|
|
191
244
|
* the rest (same `{applied/uploaded, errors}` shape convention as
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tokenize } from '../duplicate-scorer.mjs';
|
|
2
2
|
import { readAttachments } from '../attachment-uploader.mjs';
|
|
3
3
|
import { isSafeRedirectUrl, validateResolvedHost, defaultLookupFor } from '../jira-client.mjs';
|
|
4
|
+
import { SINGLE_PROJECT_TTL_MS, isFresh, mergeTeamLabels } from '../ticket-metadata-cache.mjs';
|
|
4
5
|
|
|
5
6
|
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
6
7
|
|
|
@@ -103,6 +104,22 @@ function resolveLabelNames(names, byName) {
|
|
|
103
104
|
return { resolved, missing };
|
|
104
105
|
}
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Real, currently-configured labels for one team — extracted from
|
|
109
|
+
* updateFields' own inline query so the cache read-through wrapper around
|
|
110
|
+
* it (see updateFields below) and this raw fetch stay independently
|
|
111
|
+
* testable. Returns {id, name} to match ticket-metadata-cache.mjs's
|
|
112
|
+
* documented labelsByTeam shape exactly.
|
|
113
|
+
*/
|
|
114
|
+
async function fetchTeamLabels(teamId, { token, fetcher, signal }) {
|
|
115
|
+
const data = await gql(
|
|
116
|
+
`query ($teamId: ID!) { issueLabels(filter: { team: { id: { eq: $teamId } } }, first: 250) { nodes { id name } } }`,
|
|
117
|
+
{ teamId },
|
|
118
|
+
{ token, fetcher, signal },
|
|
119
|
+
);
|
|
120
|
+
return (data.issueLabels?.nodes ?? []).map(l => ({ id: l.id, name: l.name }));
|
|
121
|
+
}
|
|
122
|
+
|
|
106
123
|
async function fetchTeamWorkflowStates(teamId, { token, fetcher, signal }) {
|
|
107
124
|
const data = await gql(
|
|
108
125
|
`query ($teamId: ID!) {
|
|
@@ -393,12 +410,28 @@ export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
|
393
410
|
|
|
394
411
|
let addLabelsResolved, addLabelsMissing, removeLabelsResolved, removeLabelsMissing;
|
|
395
412
|
if (addLabels?.length || removeLabels?.length) {
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
413
|
+
const teamId = info.team.id;
|
|
414
|
+
const { readMetadataCacheFn, writeMetadataCacheFn, profileName, configDir } = opts;
|
|
415
|
+
const cached = readMetadataCacheFn ? readMetadataCacheFn(profileName, configDir) : null;
|
|
416
|
+
// Keyed on the timestamp existing, not on the cached array having
|
|
417
|
+
// entries — a team with genuinely zero labels configured caches
|
|
418
|
+
// `[]`, and gating on `.length` would re-fetch on every single
|
|
419
|
+
// subsequent call, never respecting SINGLE_PROJECT_TTL_MS. Same
|
|
420
|
+
// fix as ticket-update-enrichment.mjs's prioritiesByProject check.
|
|
421
|
+
const hasFreshLabels = cached?.labelsByTeam?.[teamId] !== undefined
|
|
422
|
+
&& isFresh(cached?.labelsFetchedAt?.[teamId], SINGLE_PROJECT_TTL_MS);
|
|
423
|
+
|
|
424
|
+
let labels;
|
|
425
|
+
if (hasFreshLabels) {
|
|
426
|
+
labels = cached.labelsByTeam[teamId];
|
|
427
|
+
} else {
|
|
428
|
+
labels = await fetchTeamLabels(teamId, { token, fetcher, signal });
|
|
429
|
+
if (writeMetadataCacheFn) {
|
|
430
|
+
const { labelsByTeam, labelsFetchedAt } = mergeTeamLabels(cached, teamId, labels);
|
|
431
|
+
writeMetadataCacheFn(profileName, { ...cached, labelsByTeam, labelsFetchedAt }, configDir);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const byName = new Map(labels.map(l => [l.name.toLowerCase(), l.id]));
|
|
402
435
|
|
|
403
436
|
if (addLabels?.length) {
|
|
404
437
|
({ resolved: addLabelsResolved, missing: addLabelsMissing } = resolveLabelNames(addLabels, byName));
|
|
@@ -670,7 +670,9 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
|
|
|
670
670
|
` ${s.cyan('issue_types')} is Free and Jira-only — Linear/GitHub profiles get a clear`,
|
|
671
671
|
` "not available" instead of an empty result — every other tool needs Pro.`,
|
|
672
672
|
` ${s.cyan('ticket_transition')} is destructive when called with`,
|
|
673
|
-
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')}
|
|
673
|
+
` \`target\`+\`confirm: true\`; ${s.cyan('ticket_assign')}'s \`to: "me"\` self-assigns immediately,`,
|
|
674
|
+
` no confirm needed — assigning to another developer (Jira Cloud only) resolves`,
|
|
675
|
+
` \`to\` first and needs \`confirm: true\` once it matches exactly one candidate;`,
|
|
674
676
|
` ${s.cyan('ticket_worklog')} is Jira-only, logs as you only, and writes nothing without`,
|
|
675
677
|
` \`confirm: true\` (it previews instead) — a worklog cannot be deleted through TicketLens;`,
|
|
676
678
|
` ${s.cyan('ticket_duplicates')} is read-only; ${s.cyan('ticket_link')} on GitHub closes the source issue as a`,
|
|
@@ -778,19 +780,26 @@ export function printAssignHelp({ stream = process.stdout } = {}) {
|
|
|
778
780
|
'',
|
|
779
781
|
` ${s.bold(s.brand('ticketlens'))} ${s.bold('assign')} ${s.dim('TICKET-KEY --to=me')} ${s.dim('[Pro]')}`,
|
|
780
782
|
'',
|
|
781
|
-
` Assign a ticket
|
|
782
|
-
`
|
|
783
|
-
`
|
|
783
|
+
` Assign a ticket directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
|
|
784
|
+
` ${s.brand('--to=me')} self-assigns immediately, no --confirm needed. Any other`,
|
|
785
|
+
` ${s.brand('--to')} (a name or email) assigns to another developer — Jira Cloud`,
|
|
786
|
+
` only for now. Resolves the name/email against real assignable users first:`,
|
|
787
|
+
` without --confirm it lists the match(es) instead of assigning; with`,
|
|
788
|
+
` --confirm it executes only if exactly one candidate matched. Results are`,
|
|
789
|
+
` cached locally per project for 3 days.`,
|
|
784
790
|
'',
|
|
785
791
|
` ${s.bold('OPTIONS')}`,
|
|
786
792
|
'',
|
|
787
|
-
` ${s.brand('--to')}=${s.dim('me')}
|
|
788
|
-
` ${s.brand('--
|
|
789
|
-
` ${s.brand('
|
|
793
|
+
` ${s.brand('--to')}=${s.dim('me|"name or email"')} Required`,
|
|
794
|
+
` ${s.brand('--confirm')} Execute an assign-to-other match ${s.dim('(not needed for --to=me)')}`,
|
|
795
|
+
` ${s.brand('--profile')}=${s.dim('NAME')} Connection profile to use ${s.dim('(optional)')}`,
|
|
796
|
+
` ${s.brand('-h')}, ${s.brand('--help')} Show this help`,
|
|
790
797
|
'',
|
|
791
798
|
` ${s.bold('EXAMPLES')}`,
|
|
792
799
|
'',
|
|
793
800
|
` ${s.dim('$')} ticketlens assign PROD-123 --to=me`,
|
|
801
|
+
` ${s.dim('$')} ticketlens assign PROD-123 --to="Jane Dev"`,
|
|
802
|
+
` ${s.dim('$')} ticketlens assign PROD-123 --to="Jane Dev" --confirm`,
|
|
794
803
|
'',
|
|
795
804
|
];
|
|
796
805
|
stream.write(lines.join('\n') + '\n');
|
|
@@ -613,6 +613,39 @@ export async function assignIssue(ticketKey, assignee, opts = {}) {
|
|
|
613
613
|
}
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Searches for users assignable to a ticket — resolves a caller-typed
|
|
618
|
+
* name/email into a real accountId before assignIssue can use it.
|
|
619
|
+
*
|
|
620
|
+
* `query` is required by Jira's own API: `GET .../user/assignable/search`
|
|
621
|
+
* 400s unless `query` or `accountId` is given (verified against Atlassian's
|
|
622
|
+
* published spec — there is no "list everyone assignable" mode), so this
|
|
623
|
+
* throws early on an empty query rather than sending a request guaranteed
|
|
624
|
+
* to fail.
|
|
625
|
+
*/
|
|
626
|
+
export async function fetchAssignableUsers(ticketKey, query, opts = {}) {
|
|
627
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false, maxResults = 20 } = opts;
|
|
628
|
+
if (!query) {
|
|
629
|
+
throw new Error('fetchAssignableUsers requires a non-empty query — Jira has no "list all assignable users" mode.');
|
|
630
|
+
}
|
|
631
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
632
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
633
|
+
const params = new URLSearchParams({ issueKey: ticketKey, query, maxResults: String(maxResults) });
|
|
634
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/user/assignable/search?${params}`;
|
|
635
|
+
|
|
636
|
+
const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
|
|
637
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
638
|
+
|
|
639
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
640
|
+
if (!response.ok) {
|
|
641
|
+
const err = new Error(`Jira API error ${response.status} searching assignable users for ${ticketKey}`);
|
|
642
|
+
err.status = response.status;
|
|
643
|
+
throw err;
|
|
644
|
+
}
|
|
645
|
+
const raw = await response.json();
|
|
646
|
+
return raw.map(u => ({ accountId: u.accountId ?? null, name: u.name ?? null, displayName: u.displayName ?? null }));
|
|
647
|
+
}
|
|
648
|
+
|
|
616
649
|
/**
|
|
617
650
|
* Updates a narrow, named field set on an issue. `fields` (summary,
|
|
618
651
|
* description, priority) uses plain SET semantics — the same shape Jira's
|
|
@@ -705,6 +738,46 @@ export async function fetchIssueTypes(projectKey, opts = {}) {
|
|
|
705
738
|
return values.map(t => ({ id: t.id, name: t.name }));
|
|
706
739
|
}
|
|
707
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Discovers the priority scheme actually configured for a project — used
|
|
743
|
+
* only to enrich an `update` failure message with real, current options,
|
|
744
|
+
* never as a client-side pre-validation step (same design choice as
|
|
745
|
+
* fetchIssueTypes' own doc comment). Confirmed live against a Cloud
|
|
746
|
+
* instance (corenexus) and a Server/DC instance (advent) that a project's
|
|
747
|
+
* priority list lives on this per-issuetype field-metadata sub-resource —
|
|
748
|
+
* there is no separate global-priority-list call this codebase already
|
|
749
|
+
* makes. Same v2/v3 envelope split as fetchIssueTypes' own top-level list
|
|
750
|
+
* call: v3 nests the field array under `fields`, v2 under `values` (a
|
|
751
|
+
* paginated-list envelope, maxResults/startAt/total/isLast). An earlier
|
|
752
|
+
* version of this function assumed the two were identical here — an
|
|
753
|
+
* artifact of an exploratory probe script that used a `??` fallback
|
|
754
|
+
* across both keys, which happened to mask the real difference. Caught
|
|
755
|
+
* live against advent (v2): a real Bug-type priority list came back
|
|
756
|
+
* empty under `fields`, present under `values`.
|
|
757
|
+
*/
|
|
758
|
+
export async function fetchProjectPriorities(projectKey, issueTypeId, opts = {}) {
|
|
759
|
+
const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
|
|
760
|
+
validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
|
|
761
|
+
const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
|
|
762
|
+
const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
|
|
763
|
+
|
|
764
|
+
const url = `${baseUrl}/rest/api/${apiVersion}/issue/createmeta/${encodeURIComponent(projectKey)}/issuetypes/${encodeURIComponent(issueTypeId)}`;
|
|
765
|
+
const fetchOpts = { headers };
|
|
766
|
+
if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
|
|
767
|
+
const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
|
|
768
|
+
|
|
769
|
+
if (!response.ok) {
|
|
770
|
+
const err = new Error(`Jira API error ${response.status} fetching priorities for ${projectKey}`);
|
|
771
|
+
err.status = response.status;
|
|
772
|
+
throw err;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const raw = await response.json();
|
|
776
|
+
const fieldList = apiVersion >= 3 ? (raw.fields ?? []) : (raw.values ?? []);
|
|
777
|
+
const priorityField = fieldList.find(f => f.fieldId === 'priority');
|
|
778
|
+
return (priorityField?.allowedValues ?? []).map(p => ({ id: p.id, name: p.name }));
|
|
779
|
+
}
|
|
780
|
+
|
|
708
781
|
/**
|
|
709
782
|
* Creates a new issue. `project`/`type` are passed straight through as
|
|
710
783
|
* Jira's own {key}/{name} references — issue types are project-configurable,
|
|
@@ -522,7 +522,9 @@ async function callTicketAssign(args, { configDir, runTicketAssignFn }) {
|
|
|
522
522
|
return { isError: true, content: [{ type: 'text', text: 'Missing required argument: to' }] };
|
|
523
523
|
}
|
|
524
524
|
const capture = capturingStream();
|
|
525
|
-
const
|
|
525
|
+
const cmdArgs = [args.ticket, `--to=${args.to}`];
|
|
526
|
+
if (args.confirm === true) cmdArgs.push('--confirm');
|
|
527
|
+
const { ok } = await runTicketAssignFn(cmdArgs, { configDir, stream: capture, cliHints: false });
|
|
526
528
|
const content = [{ type: 'text', text: capture.text }];
|
|
527
529
|
return ok ? { content } : { isError: true, content };
|
|
528
530
|
}
|
|
@@ -239,12 +239,13 @@ export const TOOLS = [
|
|
|
239
239
|
},
|
|
240
240
|
{
|
|
241
241
|
name: 'ticket_assign',
|
|
242
|
-
description: 'Assign a ticket
|
|
242
|
+
description: 'Assign a ticket in its tracker (Jira/GitHub/Linear). `to: "me"` self-assigns immediately, no confirm needed. Any other `to` (a name or email) assigns to another developer — Jira Cloud only for now; GitHub, Linear, and Jira Server/DC are refused. Resolves `to` against real assignable users before writing: called without `confirm: true`, it never assigns — it returns the matching candidate(s) (name + accountId) instead, so the exact person can be confirmed first. Called again with the same `to` and `confirm: true`, it executes only if `to` still resolves to exactly one candidate; 0 or 2+ matches always refuses, never guesses. Matches are cached locally per ticket\'s project for 3 days. Requires a TicketLens Pro license.',
|
|
243
243
|
inputSchema: {
|
|
244
244
|
type: 'object',
|
|
245
245
|
properties: {
|
|
246
246
|
ticket: { type: 'string', description: 'Ticket key, e.g. PROJ-123.' },
|
|
247
|
-
to: { type: 'string', description: '
|
|
247
|
+
to: { type: 'string', description: '"me" to self-assign, or a name/email to search for and assign to another developer (Jira Cloud only).' },
|
|
248
|
+
confirm: { type: 'boolean', description: 'Must be true, alongside a `to` that resolves to exactly one match, to actually execute an assign-to-other. Not needed for `to: "me"`.' },
|
|
248
249
|
},
|
|
249
250
|
required: ['ticket', 'to'],
|
|
250
251
|
},
|
|
@@ -14,10 +14,11 @@ import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
|
14
14
|
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
15
15
|
import { resolveConnection, findProfilesByPrefix } from './profile-resolver.mjs';
|
|
16
16
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
17
|
-
import {
|
|
17
|
+
import { claimAction, releaseAction } from './ticket-action-cooldown.mjs';
|
|
18
18
|
import { logAction } from './ticket-action-log.mjs';
|
|
19
|
-
import { readMetadataCache, writeMetadataCache } from './ticket-metadata-cache.mjs';
|
|
19
|
+
import { readMetadataCache, writeMetadataCache, isFresh, SINGLE_PROJECT_TTL_MS, mergeAssignableUsers, normalizeAssigneeQuery } from './ticket-metadata-cache.mjs';
|
|
20
20
|
import { detectProjectOrTypeError, enrichCreateFailure } from './ticket-create-enrichment.mjs';
|
|
21
|
+
import { enrichUpdateFailure } from './ticket-update-enrichment.mjs';
|
|
21
22
|
import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
|
|
22
23
|
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
23
24
|
import { MAX_ATTACHMENTS } from './attachment-uploader.mjs';
|
|
@@ -32,6 +33,45 @@ function parseAttachPaths(cmdArgs) {
|
|
|
32
33
|
return raw ? raw.split(',').map(p => p.trim()).filter(Boolean) : [];
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Best-effort release of a claimAction cooldown claim (Backlog #34/ROADMAP
|
|
38
|
+
* 57) — releaseAction can itself throw (lock contention, same as any
|
|
39
|
+
* withLock caller in ticket-action-cooldown.mjs). A release is always
|
|
40
|
+
* called while already reporting some other outcome (a write failure, a
|
|
41
|
+
* refusal, an executed:false result); letting a lock-contention error
|
|
42
|
+
* replace that outcome would be strictly worse than just leaving the claim
|
|
43
|
+
* in place a few seconds longer — it expires on its own. Same "best
|
|
44
|
+
* effort — the claim expires by itself" reasoning already applied to
|
|
45
|
+
* ticket-worklog.mjs's own releaseClaim helper, DRY'd here across every
|
|
46
|
+
* write in this family instead of duplicated per function.
|
|
47
|
+
*/
|
|
48
|
+
export function safeRelease(releaseActionFn, ticketKey, action, configDir) {
|
|
49
|
+
try { releaseActionFn(ticketKey, action, { configDir }); } catch { /* best effort — the claim expires by itself */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* claimAction can itself throw — lock contention on the same 2s deadline
|
|
54
|
+
* as any other withLock caller in ticket-action-cooldown.mjs. Left
|
|
55
|
+
* unguarded, that throw would fall through to the outer CLI/MCP error
|
|
56
|
+
* handler and be misreported as a real failure (and could trip the
|
|
57
|
+
* opt-in error-reporting pipeline) for what is actually correct, benign
|
|
58
|
+
* contention — the write is still safely refused either way, but the
|
|
59
|
+
* graceful "Skipped" UX this whole family is built around would be lost.
|
|
60
|
+
* Same guard ticket-worklog.mjs's own claimOrSkip already has; returns a
|
|
61
|
+
* `lockError` string instead of a boolean flag so each call site can
|
|
62
|
+
* fold it into its existing skip-message wording without a second
|
|
63
|
+
* lookup. Caught in code review before shipping — not exercised by the
|
|
64
|
+
* original 4-way live concurrency trial, which stayed under the 2s
|
|
65
|
+
* deadline.
|
|
66
|
+
*/
|
|
67
|
+
export function safeClaim(claimActionFn, ticketKey, action, configDir) {
|
|
68
|
+
try {
|
|
69
|
+
return claimActionFn(ticketKey, action, { configDir });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { claimed: false, remainingMs: 0, lockError: err.message };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
35
75
|
/**
|
|
36
76
|
* GitHub has no PAT-compatible public API for uploading issue/comment
|
|
37
77
|
* assets (confirmed via research — the only upload endpoint requires a
|
|
@@ -263,8 +303,8 @@ export async function runTicketComment(cmdArgs, {
|
|
|
263
303
|
isLicensedFn = isLicensed,
|
|
264
304
|
resolveConnectionFn = resolveConnection,
|
|
265
305
|
resolveAdapterFn = resolveAdapter,
|
|
266
|
-
|
|
267
|
-
|
|
306
|
+
claimActionFn = claimAction,
|
|
307
|
+
releaseActionFn = releaseAction,
|
|
268
308
|
logActionFn = logAction,
|
|
269
309
|
actor = os.userInfo().username,
|
|
270
310
|
} = {}) {
|
|
@@ -281,32 +321,36 @@ export async function runTicketComment(cmdArgs, {
|
|
|
281
321
|
}
|
|
282
322
|
const attachPaths = parseAttachPaths(cmdArgs);
|
|
283
323
|
|
|
284
|
-
const
|
|
285
|
-
if (
|
|
286
|
-
stream.write(
|
|
324
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'comment', configDir);
|
|
325
|
+
if (!claim.claimed) {
|
|
326
|
+
stream.write(claim.lockError
|
|
327
|
+
? ` ${ticketKey} not commented — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
328
|
+
: ` Skipped — a comment was already posted to ${ticketKey} ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
287
329
|
return { ok: false };
|
|
288
330
|
}
|
|
289
331
|
|
|
290
332
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
291
|
-
if (!resolved) return { ok: false };
|
|
333
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'comment', configDir); return { ok: false }; }
|
|
292
334
|
const { adapter } = resolved;
|
|
293
335
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
294
336
|
|
|
295
337
|
// Uploaded BEFORE the comment write so a tracker capable of inline
|
|
296
338
|
// rendering (Jira Server/DC via wiki markup, Jira Cloud via a real ADF
|
|
297
339
|
// media node, Linear via Markdown) can fold it into the same atomic
|
|
298
|
-
// comment post rather than needing a second edit call.
|
|
340
|
+
// comment post rather than needing a second edit call. Inside the same
|
|
341
|
+
// try/catch as addComment — every adapter's attachFiles is documented to
|
|
342
|
+
// never throw (per-file errors are caught internally), but that is an
|
|
343
|
+
// implicit contract, not something to leave an un-released claim on if
|
|
344
|
+
// it were ever violated (caught in code review before shipping).
|
|
299
345
|
let attachResult = null;
|
|
300
|
-
if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
|
|
301
|
-
attachResult = await adapter.attachFiles(ticketKey, attachPaths);
|
|
302
|
-
}
|
|
303
|
-
const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
|
|
304
|
-
const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
|
|
305
|
-
const extraAdfNodes = (attachResult?.uploaded ?? []).filter(a => a.adfMediaNode).map(a => a.adfMediaNode);
|
|
306
|
-
|
|
307
346
|
try {
|
|
347
|
+
if (attachPaths.length && !refuseGithubAttachments(adapter, attachPaths, stream)) {
|
|
348
|
+
attachResult = await adapter.attachFiles(ticketKey, attachPaths);
|
|
349
|
+
}
|
|
350
|
+
const inlineSnippets = (attachResult?.uploaded ?? []).filter(a => a.inlineMarkup).map(a => a.inlineMarkup).join('\n\n');
|
|
351
|
+
const finalBody = inlineSnippets ? `${body}\n\n${inlineSnippets}` : body;
|
|
352
|
+
const extraAdfNodes = (attachResult?.uploaded ?? []).filter(a => a.adfMediaNode).map(a => a.adfMediaNode);
|
|
308
353
|
const result = await adapter.addComment(ticketKey, finalBody, extraAdfNodes.length ? { extraAdfNodes } : {});
|
|
309
|
-
recordActionFn(ticketKey, 'comment', { configDir });
|
|
310
354
|
// attachPaths (every path attempted, raw) plus attachedFilenames (what
|
|
311
355
|
// actually landed) — a partial attach failure is reconstructable from
|
|
312
356
|
// the difference between the two, not just silently absent from audit.
|
|
@@ -314,6 +358,7 @@ export async function runTicketComment(cmdArgs, {
|
|
|
314
358
|
stream.write(` ${s.green('✔')} Comment posted to ${s.brand(s.bold(ticketKey))}${result.url ? ` (${result.url})` : ''}\n` + formatAttachSummary(attachResult, s));
|
|
315
359
|
return { ok: true };
|
|
316
360
|
} catch (err) {
|
|
361
|
+
safeRelease(releaseActionFn, ticketKey, 'comment', configDir);
|
|
317
362
|
// Attachments (if any) genuinely landed on the tracker before this
|
|
318
363
|
// write was attempted — formatAttachSummary is still shown here so a
|
|
319
364
|
// caller retrying the whole command doesn't blindly re-upload them.
|
|
@@ -383,8 +428,8 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
383
428
|
isLicensedFn = isLicensed,
|
|
384
429
|
resolveConnectionFn = resolveConnection,
|
|
385
430
|
resolveAdapterFn = resolveAdapter,
|
|
386
|
-
|
|
387
|
-
|
|
431
|
+
claimActionFn = claimAction,
|
|
432
|
+
releaseActionFn = releaseAction,
|
|
388
433
|
logActionFn = logAction,
|
|
389
434
|
actor = os.userInfo().username,
|
|
390
435
|
cliHints = true,
|
|
@@ -407,42 +452,101 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
407
452
|
return { ok: false };
|
|
408
453
|
}
|
|
409
454
|
|
|
410
|
-
const
|
|
411
|
-
if (
|
|
412
|
-
stream.write(
|
|
455
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'transition', configDir);
|
|
456
|
+
if (!claim.claimed) {
|
|
457
|
+
stream.write(claim.lockError
|
|
458
|
+
? ` ${ticketKey} not transitioned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
459
|
+
: ` Skipped — ${ticketKey} was already transitioned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
413
460
|
return { ok: false };
|
|
414
461
|
}
|
|
415
462
|
|
|
416
463
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
417
|
-
if (!resolved) return { ok: false };
|
|
464
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'transition', configDir); return { ok: false }; }
|
|
418
465
|
const { adapter } = resolved;
|
|
419
466
|
|
|
420
467
|
try {
|
|
421
468
|
const result = await adapter.transition(ticketKey, target);
|
|
422
469
|
if (!result.executed) {
|
|
470
|
+
safeRelease(releaseActionFn, ticketKey, 'transition', configDir);
|
|
423
471
|
const optionsHint = result.options?.length ? ` Valid options: ${result.options.map(o => o.name).join(', ')}.` : '';
|
|
424
472
|
stream.write(` Not transitioned — ${result.reason}.${optionsHint}\n`);
|
|
425
473
|
return { ok: false, reason: result.reason };
|
|
426
474
|
}
|
|
427
|
-
recordActionFn(ticketKey, 'transition', { configDir });
|
|
428
475
|
logActionFn({ ticketKey, action: 'transition', actor, tracker: adapter.type, detail: { to: result.to } }, { configDir });
|
|
429
476
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
430
477
|
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} transitioned to ${s.bold(`"${result.to}"`)}.\n`);
|
|
431
478
|
return { ok: true };
|
|
432
479
|
} catch (err) {
|
|
480
|
+
safeRelease(releaseActionFn, ticketKey, 'transition', configDir);
|
|
433
481
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
434
482
|
return { ok: false };
|
|
435
483
|
}
|
|
436
484
|
}
|
|
437
485
|
|
|
438
486
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
|
|
487
|
+
* Derives the project-key portion of a ticket key — used only to key the
|
|
488
|
+
* local assignable-users cache, never for validation. Jira's own API stays
|
|
489
|
+
* the source of truth for whether the ticket key is real; a ticket key
|
|
490
|
+
* this can't parse just means caching is skipped, not a hard failure.
|
|
491
|
+
*/
|
|
492
|
+
function projectKeyFromTicket(ticketKey) {
|
|
493
|
+
const hyphenIndex = ticketKey.lastIndexOf('-');
|
|
494
|
+
return hyphenIndex > 0 ? ticketKey.slice(0, hyphenIndex) : null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Resolves a free-text `--to` query into assignable-user candidates,
|
|
499
|
+
* checking the local cache (3-day TTL, per project+query — ROADMAP 61)
|
|
500
|
+
* before calling the adapter. Read-only: never assigns, never touches
|
|
501
|
+
* cooldown. Exported for direct unit testing, same convention as
|
|
502
|
+
* `resolveTicketAdapter`.
|
|
503
|
+
*/
|
|
504
|
+
export async function resolveAssigneeCandidates(adapter, ticketKey, query, {
|
|
505
|
+
profileName,
|
|
506
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
507
|
+
readMetadataCacheFn = readMetadataCache,
|
|
508
|
+
writeMetadataCacheFn = writeMetadataCache,
|
|
509
|
+
} = {}) {
|
|
510
|
+
const projectKey = projectKeyFromTicket(ticketKey);
|
|
511
|
+
const cached = readMetadataCacheFn(profileName, configDir);
|
|
512
|
+
|
|
513
|
+
if (projectKey) {
|
|
514
|
+
const normalizedQuery = normalizeAssigneeQuery(query);
|
|
515
|
+
const cachedCandidates = cached?.assignableUsersByProject?.[projectKey]?.[normalizedQuery];
|
|
516
|
+
const cachedAt = cached?.assignableUsersFetchedAt?.[projectKey]?.[normalizedQuery];
|
|
517
|
+
if (cachedCandidates && isFresh(cachedAt, SINGLE_PROJECT_TTL_MS)) {
|
|
518
|
+
return cachedCandidates;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const candidates = await adapter.searchAssignableUsers(ticketKey, query);
|
|
523
|
+
|
|
524
|
+
if (projectKey) {
|
|
525
|
+
const { assignableUsersByProject, assignableUsersFetchedAt } = mergeAssignableUsers(cached, projectKey, query, candidates);
|
|
526
|
+
writeMetadataCacheFn(profileName, {
|
|
527
|
+
projects: cached?.projects ?? [],
|
|
528
|
+
issueTypesByProject: cached?.issueTypesByProject ?? {},
|
|
529
|
+
issueTypesFetchedAt: cached?.issueTypesFetchedAt ?? {},
|
|
530
|
+
projectsFetchedAt: cached?.projectsFetchedAt ?? null,
|
|
531
|
+
assignableUsersByProject,
|
|
532
|
+
assignableUsersFetchedAt,
|
|
533
|
+
}, configDir);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return candidates;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* `--to=me` self-assigns immediately — unchanged fast path, no discovery,
|
|
541
|
+
* no confirm. Any other `--to` resolves a real person first (ROADMAP 61):
|
|
542
|
+
* Jira Cloud only (Server/DC's assignable-user search is unverified —
|
|
543
|
+
* refuses cleanly rather than guessing), and only executes when exactly
|
|
544
|
+
* one candidate matches AND --confirm is given, mirroring `transition`'s
|
|
545
|
+
* list-then-confirm shape — notifying a colleague deserves the same
|
|
546
|
+
* reviewed-before-write gate as a workflow-state change.
|
|
444
547
|
*
|
|
445
|
-
* @param {string[]} cmdArgs - [ticketKey, '--to=me']
|
|
548
|
+
* @param {string[]} cmdArgs - [ticketKey, '--to=me'] or [ticketKey, '--to=...', '--confirm']
|
|
549
|
+
* @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
|
|
446
550
|
* @returns {Promise<{ ok: boolean }>}
|
|
447
551
|
*/
|
|
448
552
|
export async function runTicketAssign(cmdArgs, {
|
|
@@ -451,41 +555,112 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
451
555
|
isLicensedFn = isLicensed,
|
|
452
556
|
resolveConnectionFn = resolveConnection,
|
|
453
557
|
resolveAdapterFn = resolveAdapter,
|
|
454
|
-
|
|
455
|
-
|
|
558
|
+
claimActionFn = claimAction,
|
|
559
|
+
releaseActionFn = releaseAction,
|
|
456
560
|
logActionFn = logAction,
|
|
561
|
+
readMetadataCacheFn = readMetadataCache,
|
|
562
|
+
writeMetadataCacheFn = writeMetadataCache,
|
|
457
563
|
actor = os.userInfo().username,
|
|
564
|
+
cliHints = true,
|
|
458
565
|
} = {}) {
|
|
459
|
-
const usage =
|
|
566
|
+
const usage = cliHints
|
|
567
|
+
? 'Usage: ticketlens assign TICKET-KEY --to=me | --to="name or email" --confirm\n'
|
|
568
|
+
: 'Usage: assign requires ticket and to; a to other than "me" also needs confirm: true to execute.\n';
|
|
460
569
|
if (!requireLicense(isLicensedFn, configDir, 'ticketlens assign', stream)) return { ok: false };
|
|
461
570
|
|
|
462
571
|
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
463
572
|
if (!ticketKey) return { ok: false };
|
|
464
573
|
|
|
465
574
|
const to = parseFlag(cmdArgs, 'to');
|
|
466
|
-
if (to
|
|
467
|
-
stream.write(
|
|
575
|
+
if (!to) {
|
|
576
|
+
stream.write(usage);
|
|
468
577
|
return { ok: false };
|
|
469
578
|
}
|
|
470
579
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
580
|
+
if (to === 'me') {
|
|
581
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'assign', configDir);
|
|
582
|
+
if (!claim.claimed) {
|
|
583
|
+
stream.write(claim.lockError
|
|
584
|
+
? ` ${ticketKey} not assigned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
585
|
+
: ` Skipped — ${ticketKey} was already assigned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
586
|
+
return { ok: false };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
590
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'assign', configDir); return { ok: false }; }
|
|
591
|
+
const { adapter } = resolved;
|
|
592
|
+
|
|
593
|
+
try {
|
|
594
|
+
const result = await adapter.assignToSelf(ticketKey);
|
|
595
|
+
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
|
|
596
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
597
|
+
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
|
|
598
|
+
return { ok: true };
|
|
599
|
+
} catch (err) {
|
|
600
|
+
safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
|
|
601
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
602
|
+
return { ok: false };
|
|
603
|
+
}
|
|
475
604
|
}
|
|
476
605
|
|
|
477
606
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
478
607
|
if (!resolved) return { ok: false };
|
|
479
|
-
const { adapter } = resolved;
|
|
608
|
+
const { adapter, conn } = resolved;
|
|
609
|
+
|
|
610
|
+
if (adapter.type !== 'jira') {
|
|
611
|
+
stream.write(` Assigning to another developer is Jira-only right now — ${adapter.type} is not supported.\n`);
|
|
612
|
+
return { ok: false };
|
|
613
|
+
}
|
|
614
|
+
if (conn.auth !== 'cloud') {
|
|
615
|
+
stream.write(' Assigning to another developer needs Jira Cloud — Server/DC is not supported yet.\n');
|
|
616
|
+
return { ok: false };
|
|
617
|
+
}
|
|
480
618
|
|
|
619
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
620
|
+
let candidates;
|
|
481
621
|
try {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
622
|
+
candidates = await resolveAssigneeCandidates(adapter, ticketKey, to, { profileName: conn.profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
|
|
623
|
+
} catch (err) {
|
|
624
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
625
|
+
return { ok: false };
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (candidates.length === 0) {
|
|
629
|
+
stream.write(` No assignable user found matching "${to}" for ${s.brand(s.bold(ticketKey))}.\n`);
|
|
630
|
+
return { ok: false };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (candidates.length > 1) {
|
|
634
|
+
stream.write(` ${candidates.length} users match "${to}" — narrow the query:\n\n`);
|
|
635
|
+
for (const c of candidates) stream.write(` ${s.brand('●')} ${c.displayName} ${s.dim(c.accountId)}\n`);
|
|
636
|
+
return { ok: false };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const [candidate] = candidates;
|
|
640
|
+
|
|
641
|
+
if (!cmdArgs.includes('--confirm')) {
|
|
642
|
+
stream.write(` Match: ${s.bold(candidate.displayName)} ${s.dim(candidate.accountId)}\n`);
|
|
643
|
+
stream.write(cliHints
|
|
644
|
+
? `\n Run again with --to="${to}" --confirm to execute.\n`
|
|
645
|
+
: `\n Call again with to="${to}" and confirm: true to execute.\n`);
|
|
646
|
+
return { ok: false };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'assign', configDir);
|
|
650
|
+
if (!claim.claimed) {
|
|
651
|
+
stream.write(claim.lockError
|
|
652
|
+
? ` ${ticketKey} not assigned — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
653
|
+
: ` Skipped — ${ticketKey} was already assigned ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
654
|
+
return { ok: false };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
await adapter.assignToUser(ticketKey, candidate.accountId);
|
|
659
|
+
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: candidate.displayName, accountId: candidate.accountId } }, { configDir });
|
|
660
|
+
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(candidate.displayName)}.\n`);
|
|
487
661
|
return { ok: true };
|
|
488
662
|
} catch (err) {
|
|
663
|
+
safeRelease(releaseActionFn, ticketKey, 'assign', configDir);
|
|
489
664
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
490
665
|
return { ok: false };
|
|
491
666
|
}
|
|
@@ -653,8 +828,8 @@ export async function runTicketLink(cmdArgs, {
|
|
|
653
828
|
isLicensedFn = isLicensed,
|
|
654
829
|
resolveConnectionFn = resolveConnection,
|
|
655
830
|
resolveAdapterFn = resolveAdapter,
|
|
656
|
-
|
|
657
|
-
|
|
831
|
+
claimActionFn = claimAction,
|
|
832
|
+
releaseActionFn = releaseAction,
|
|
658
833
|
logActionFn = logAction,
|
|
659
834
|
actor = os.userInfo().username,
|
|
660
835
|
cliHints = true,
|
|
@@ -680,17 +855,20 @@ export async function runTicketLink(cmdArgs, {
|
|
|
680
855
|
}
|
|
681
856
|
|
|
682
857
|
const cooldownKey = `${sourceKey}:${targetKey}`;
|
|
683
|
-
const
|
|
684
|
-
if (
|
|
685
|
-
stream.write(
|
|
858
|
+
const claim = safeClaim(claimActionFn, cooldownKey, 'link', configDir);
|
|
859
|
+
if (!claim.claimed) {
|
|
860
|
+
stream.write(claim.lockError
|
|
861
|
+
? ` ${sourceKey} not linked — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
862
|
+
: ` Skipped — ${sourceKey} was already linked to ${targetKey} ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
686
863
|
return { ok: false };
|
|
687
864
|
}
|
|
688
865
|
|
|
689
866
|
const resolved = resolveTicketAdapter(sourceKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
690
|
-
if (!resolved) return { ok: false };
|
|
867
|
+
if (!resolved) { safeRelease(releaseActionFn, cooldownKey, 'link', configDir); return { ok: false }; }
|
|
691
868
|
const { adapter } = resolved;
|
|
692
869
|
|
|
693
870
|
if (adapter.type === 'github' && type.toLowerCase() !== 'duplicate') {
|
|
871
|
+
safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
|
|
694
872
|
stream.write(` GitHub only supports linking as a duplicate — no generic link types. Got type "${type}".\n`);
|
|
695
873
|
return { ok: false };
|
|
696
874
|
}
|
|
@@ -701,11 +879,11 @@ export async function runTicketLink(cmdArgs, {
|
|
|
701
879
|
try {
|
|
702
880
|
const result = await adapter.linkTo(sourceKey, targetKey, type);
|
|
703
881
|
if (!result.executed) {
|
|
882
|
+
safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
|
|
704
883
|
const optionsHint = result.options?.length ? ` Valid options: ${result.options.join(', ')}.` : '';
|
|
705
884
|
stream.write(` Not linked — ${result.reason}.${optionsHint}\n`);
|
|
706
885
|
return { ok: false, reason: result.reason };
|
|
707
886
|
}
|
|
708
|
-
recordActionFn(cooldownKey, 'link', { configDir });
|
|
709
887
|
logActionFn({ ticketKey: sourceKey, action: 'link', actor, tracker: adapter.type, detail: { targetKey, type } }, { configDir });
|
|
710
888
|
const s = createStyler({ isTTY: stream.isTTY });
|
|
711
889
|
stream.write(
|
|
@@ -715,6 +893,7 @@ export async function runTicketLink(cmdArgs, {
|
|
|
715
893
|
);
|
|
716
894
|
return { ok: true };
|
|
717
895
|
} catch (err) {
|
|
896
|
+
safeRelease(releaseActionFn, cooldownKey, 'link', configDir);
|
|
718
897
|
stream.write(formatWriteFailure(sourceKey, err));
|
|
719
898
|
return { ok: false };
|
|
720
899
|
}
|
|
@@ -747,9 +926,11 @@ export async function runTicketUpdate(cmdArgs, {
|
|
|
747
926
|
isLicensedFn = isLicensed,
|
|
748
927
|
resolveConnectionFn = resolveConnection,
|
|
749
928
|
resolveAdapterFn = resolveAdapter,
|
|
750
|
-
|
|
751
|
-
|
|
929
|
+
claimActionFn = claimAction,
|
|
930
|
+
releaseActionFn = releaseAction,
|
|
752
931
|
logActionFn = logAction,
|
|
932
|
+
readMetadataCacheFn = readMetadataCache,
|
|
933
|
+
writeMetadataCacheFn = writeMetadataCache,
|
|
753
934
|
actor = os.userInfo().username,
|
|
754
935
|
} = {}) {
|
|
755
936
|
const usage = 'Usage: ticketlens update TICKET-KEY [--title="..."] [--description="..."] [--add-labels=a,b] [--remove-labels=c] [--priority="High"]\n';
|
|
@@ -771,34 +952,45 @@ export async function runTicketUpdate(cmdArgs, {
|
|
|
771
952
|
return { ok: false };
|
|
772
953
|
}
|
|
773
954
|
|
|
774
|
-
const
|
|
775
|
-
if (
|
|
776
|
-
stream.write(
|
|
955
|
+
const claim = safeClaim(claimActionFn, ticketKey, 'update', configDir);
|
|
956
|
+
if (!claim.claimed) {
|
|
957
|
+
stream.write(claim.lockError
|
|
958
|
+
? ` ${ticketKey} not updated — could not take the cooldown lock (${claim.lockError}). Nothing was sent; safe to retry.\n`
|
|
959
|
+
: ` Skipped — ${ticketKey} was already updated ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
777
960
|
return { ok: false };
|
|
778
961
|
}
|
|
779
962
|
|
|
780
963
|
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
781
|
-
if (!resolved) return { ok: false };
|
|
782
|
-
const { adapter } = resolved;
|
|
964
|
+
if (!resolved) { safeRelease(releaseActionFn, ticketKey, 'update', configDir); return { ok: false }; }
|
|
965
|
+
const { adapter, conn } = resolved;
|
|
783
966
|
|
|
784
967
|
if (adapter.type === 'github' && priority !== undefined) {
|
|
968
|
+
safeRelease(releaseActionFn, ticketKey, 'update', configDir);
|
|
785
969
|
stream.write(` GitHub Issues have no native priority field — cannot update priority on ${ticketKey}. Remove --priority and retry.\n`);
|
|
786
970
|
return { ok: false };
|
|
787
971
|
}
|
|
788
972
|
|
|
789
973
|
try {
|
|
790
|
-
const result = await adapter.updateFields(ticketKey, { title, description, priority, addLabels, removeLabels });
|
|
974
|
+
const result = await adapter.updateFields(ticketKey, { title, description, priority, addLabels, removeLabels }, { readMetadataCacheFn, writeMetadataCacheFn, profileName: conn.profileName, configDir });
|
|
791
975
|
const hasApplied = Object.keys(result.applied).length > 0;
|
|
792
976
|
const hasErrors = Object.keys(result.errors).length > 0;
|
|
793
977
|
|
|
794
978
|
if (hasApplied) {
|
|
795
|
-
recordActionFn(ticketKey, 'update', { configDir });
|
|
796
979
|
logActionFn({ ticketKey, action: 'update', actor, tracker: adapter.type, detail: { ...result.applied, failed: Object.keys(result.errors) } }, { configDir });
|
|
980
|
+
} else {
|
|
981
|
+
// Nothing landed — release so a caller fixing the failed field(s) can
|
|
982
|
+
// retry immediately, same "release on definite failure" rule as every
|
|
983
|
+
// other write in this family. A partial success (hasApplied &&
|
|
984
|
+
// hasErrors) deliberately keeps the claim armed, unchanged from the
|
|
985
|
+
// old recordActionFn-only-when-hasApplied behavior.
|
|
986
|
+
safeRelease(releaseActionFn, ticketKey, 'update', configDir);
|
|
797
987
|
}
|
|
798
988
|
stream.write(formatUpdateResult(ticketKey, result, createStyler({ isTTY: stream.isTTY })));
|
|
799
989
|
return hasErrors ? { ok: false, applied: result.applied, errors: result.errors } : { ok: true, applied: result.applied };
|
|
800
990
|
} catch (err) {
|
|
801
|
-
|
|
991
|
+
safeRelease(releaseActionFn, ticketKey, 'update', configDir);
|
|
992
|
+
const enrichment = await enrichUpdateFailure(err, { adapter, projectKey: projectKeyFromTicket(ticketKey), profileName: conn.profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
|
|
993
|
+
stream.write(formatWriteFailure(ticketKey, err) + enrichment);
|
|
802
994
|
return { ok: false };
|
|
803
995
|
}
|
|
804
996
|
}
|
|
@@ -833,8 +1025,8 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
833
1025
|
isLicensedFn = isLicensed,
|
|
834
1026
|
resolveConnectionFn = resolveConnection,
|
|
835
1027
|
resolveAdapterFn = resolveAdapter,
|
|
836
|
-
|
|
837
|
-
|
|
1028
|
+
claimActionFn = claimAction,
|
|
1029
|
+
releaseActionFn = releaseAction,
|
|
838
1030
|
logActionFn = logAction,
|
|
839
1031
|
readMetadataCacheFn = readMetadataCache,
|
|
840
1032
|
writeMetadataCacheFn = writeMetadataCache,
|
|
@@ -899,9 +1091,11 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
899
1091
|
// text that can themselves contain ":", which would let two genuinely
|
|
900
1092
|
// different tuples collide onto the same cooldown key.
|
|
901
1093
|
const cooldownKey = `create:${JSON.stringify([project ?? '', type ?? '', summary])}`;
|
|
902
|
-
const
|
|
903
|
-
if (
|
|
904
|
-
stream.write(
|
|
1094
|
+
const claim = safeClaim(claimActionFn, cooldownKey, 'create', configDir);
|
|
1095
|
+
if (!claim.claimed) {
|
|
1096
|
+
stream.write(claim.lockError
|
|
1097
|
+
? ` Nothing was created — could not take the cooldown lock (${claim.lockError}). Safe to retry.\n`
|
|
1098
|
+
: ` Skipped — a ticket with this summary was already created ${Math.ceil(claim.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
905
1099
|
return { ok: false };
|
|
906
1100
|
}
|
|
907
1101
|
|
|
@@ -909,6 +1103,9 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
909
1103
|
try {
|
|
910
1104
|
result = await adapter.createTicket({ project, type, summary, description });
|
|
911
1105
|
} catch (err) {
|
|
1106
|
+
// Definite failure — no ticket was created, so the claim is released to
|
|
1107
|
+
// let a corrected retry through immediately (Backlog #34/ROADMAP 57).
|
|
1108
|
+
safeRelease(releaseActionFn, cooldownKey, 'create', configDir);
|
|
912
1109
|
// profileName is only resolved when this failure is actually
|
|
913
1110
|
// project/issuetype-shaped — not on every failure, and never on the
|
|
914
1111
|
// success path — since it exists solely to scope the enrichment cache.
|
|
@@ -922,12 +1119,13 @@ export async function runTicketCreate(cmdArgs, {
|
|
|
922
1119
|
}
|
|
923
1120
|
|
|
924
1121
|
// The write already landed — a real, external, hard-to-walk-back ticket
|
|
925
|
-
// now exists. From here on, nothing may report this as a failed write
|
|
926
|
-
// cooldown
|
|
927
|
-
//
|
|
928
|
-
//
|
|
1122
|
+
// now exists. From here on, nothing may report this as a failed write.
|
|
1123
|
+
// The cooldown claim was already recorded atomically at claim time above
|
|
1124
|
+
// (no separate recordActionFn call needed here); only the audit log is
|
|
1125
|
+
// best-effort — a logging failure must never make a real success look
|
|
1126
|
+
// like a failure (which risks a caller retrying and fabricating a
|
|
1127
|
+
// genuine duplicate).
|
|
929
1128
|
try {
|
|
930
|
-
recordActionFn(cooldownKey, 'create', { configDir });
|
|
931
1129
|
logActionFn({ ticketKey: result.key, action: 'create', actor, tracker: adapter.type, detail: { project, type } }, { configDir });
|
|
932
1130
|
} catch (bookkeepingErr) {
|
|
933
1131
|
stream.write(` Warning: ${result.key} was created but could not be logged: ${bookkeepingErr.message}\n`);
|
|
@@ -21,7 +21,35 @@
|
|
|
21
21
|
* projects: [{key, name}],
|
|
22
22
|
* issueTypesByProject: {KEY: [{id, name}]},
|
|
23
23
|
* issueTypesFetchedAt: {KEY: iso timestamp} // per-project, either access pattern
|
|
24
|
+
* assignableUsersByProject: {KEY: {normalizedQuery: [{accountId, displayName}]}},
|
|
25
|
+
* assignableUsersFetchedAt: {KEY: {normalizedQuery: iso timestamp}},
|
|
26
|
+
* prioritiesByProject: {KEY: [{id, name}]},
|
|
27
|
+
* prioritiesFetchedAt: {KEY: iso timestamp},
|
|
28
|
+
* labelsByTeam: {TEAM_ID: [{id, name}]},
|
|
29
|
+
* labelsFetchedAt: {TEAM_ID: iso timestamp}
|
|
24
30
|
* }
|
|
31
|
+
*
|
|
32
|
+
* assignableUsersByProject/assignableUsersFetchedAt (ROADMAP 61) share this
|
|
33
|
+
* file rather than a new one — same profile-scoped Jira-lookup cache, same
|
|
34
|
+
* 3-day freshness bar as a single-project issue-types lookup. Nested one
|
|
35
|
+
* level deeper than issueTypesByProject because Jira's own
|
|
36
|
+
* `user/assignable/search` has no "list everyone" mode (query is required
|
|
37
|
+
* unless accountId is given) — there is no per-project roster to cache,
|
|
38
|
+
* only per-(project, query) result pages, each with its own clock.
|
|
39
|
+
*
|
|
40
|
+
* prioritiesByProject/prioritiesFetchedAt (Backlog #34/ROADMAP 57 follow-up)
|
|
41
|
+
* are per-project like issueTypesByProject, not global — confirmed live
|
|
42
|
+
* against both a Cloud instance (corenexus) and a Server/DC instance
|
|
43
|
+
* (advent) that priority schemes are project-scoped, not instance-wide.
|
|
44
|
+
* Reactive-enrichment-only, same 3-day SINGLE_PROJECT_TTL_MS bar, same
|
|
45
|
+
* design as issue-types: never blocks a write, only enriches the error
|
|
46
|
+
* message after Jira's own 400.
|
|
47
|
+
*
|
|
48
|
+
* labelsByTeam/labelsFetchedAt is Linear-only — Linear requires labels to
|
|
49
|
+
* pre-exist as real objects (unlike Jira's freeform labels), and its own
|
|
50
|
+
* `updateFields` re-fetches the full team label list via GraphQL on every
|
|
51
|
+
* single call with no caching. Keyed by team id (Linear's label scope),
|
|
52
|
+
* same 3-day freshness bar.
|
|
25
53
|
*/
|
|
26
54
|
|
|
27
55
|
import fs from 'node:fs';
|
|
@@ -62,6 +90,67 @@ export function mergeProjectIssueTypes(cached, projectKey, types, fetchedAt = ne
|
|
|
62
90
|
return { issueTypesByProject, issueTypesFetchedAt };
|
|
63
91
|
}
|
|
64
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Trim + lowercase — the one normalization every assignable-users cache
|
|
95
|
+
* read and write must agree on, so "Jane", " jane ", and "JANE" share one
|
|
96
|
+
* cache entry instead of three.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeAssigneeQuery(query) {
|
|
99
|
+
return String(query).trim().toLowerCase();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Merges one (project, query) assignable-users search result into an
|
|
104
|
+
* existing (possibly null) cached map — same shape and same
|
|
105
|
+
* Object.create(null) defense as mergeProjectIssueTypes, one level deeper
|
|
106
|
+
* since a project can have many independently-fresh cached queries. Both
|
|
107
|
+
* projectKey and the normalized query are unvalidated values reaching a
|
|
108
|
+
* key position.
|
|
109
|
+
*/
|
|
110
|
+
export function mergeAssignableUsers(cached, projectKey, query, candidates, fetchedAt = new Date().toISOString()) {
|
|
111
|
+
const normalizedQuery = normalizeAssigneeQuery(query);
|
|
112
|
+
|
|
113
|
+
const assignableUsersByProject = Object.assign(Object.create(null), cached?.assignableUsersByProject ?? {});
|
|
114
|
+
const projectQueries = Object.assign(Object.create(null), assignableUsersByProject[projectKey] ?? {});
|
|
115
|
+
projectQueries[normalizedQuery] = candidates;
|
|
116
|
+
assignableUsersByProject[projectKey] = projectQueries;
|
|
117
|
+
|
|
118
|
+
const assignableUsersFetchedAt = Object.assign(Object.create(null), cached?.assignableUsersFetchedAt ?? {});
|
|
119
|
+
const projectQueryTimestamps = Object.assign(Object.create(null), assignableUsersFetchedAt[projectKey] ?? {});
|
|
120
|
+
projectQueryTimestamps[normalizedQuery] = fetchedAt;
|
|
121
|
+
assignableUsersFetchedAt[projectKey] = projectQueryTimestamps;
|
|
122
|
+
|
|
123
|
+
return { assignableUsersByProject, assignableUsersFetchedAt };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Merges one project's priority list into an existing (possibly null)
|
|
128
|
+
* cached map — same shape and Object.create(null) defense as
|
|
129
|
+
* mergeProjectIssueTypes, for the same reason (projectKey is an
|
|
130
|
+
* unvalidated CLI/tracker value reaching a key position).
|
|
131
|
+
*/
|
|
132
|
+
export function mergeProjectPriorities(cached, projectKey, priorities, fetchedAt = new Date().toISOString()) {
|
|
133
|
+
const prioritiesByProject = Object.assign(Object.create(null), cached?.prioritiesByProject ?? {});
|
|
134
|
+
prioritiesByProject[projectKey] = priorities;
|
|
135
|
+
const prioritiesFetchedAt = Object.assign(Object.create(null), cached?.prioritiesFetchedAt ?? {});
|
|
136
|
+
prioritiesFetchedAt[projectKey] = fetchedAt;
|
|
137
|
+
return { prioritiesByProject, prioritiesFetchedAt };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Merges one team's label list into an existing (possibly null) cached
|
|
142
|
+
* map — same shape and Object.create(null) defense as
|
|
143
|
+
* mergeProjectIssueTypes; teamId is Linear's own UUID, not user-supplied,
|
|
144
|
+
* but the same defensive pattern is kept for consistency with its siblings.
|
|
145
|
+
*/
|
|
146
|
+
export function mergeTeamLabels(cached, teamId, labels, fetchedAt = new Date().toISOString()) {
|
|
147
|
+
const labelsByTeam = Object.assign(Object.create(null), cached?.labelsByTeam ?? {});
|
|
148
|
+
labelsByTeam[teamId] = labels;
|
|
149
|
+
const labelsFetchedAt = Object.assign(Object.create(null), cached?.labelsFetchedAt ?? {});
|
|
150
|
+
labelsFetchedAt[teamId] = fetchedAt;
|
|
151
|
+
return { labelsByTeam, labelsFetchedAt };
|
|
152
|
+
}
|
|
153
|
+
|
|
65
154
|
/**
|
|
66
155
|
* Returns the absolute path to the ticket-metadata cache file for a profile.
|
|
67
156
|
*/
|
|
@@ -84,7 +173,7 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
84
173
|
* @param {string|null} profileName
|
|
85
174
|
* @param {string} [configDir]
|
|
86
175
|
* @param {number} [ttlMs] - override TTL in ms for this file's own GC deletion; defaults to METADATA_TTL_MS (7d)
|
|
87
|
-
* @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
|
|
176
|
+
* @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, assignableUsersByProject: object, assignableUsersFetchedAt: object, prioritiesByProject: object, prioritiesFetchedAt: object, labelsByTeam: object, labelsFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
|
|
88
177
|
*/
|
|
89
178
|
export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
|
|
90
179
|
const filePath = metadataCachePath(profileName, configDir);
|
|
@@ -107,6 +196,12 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
107
196
|
projects: data.projects ?? [],
|
|
108
197
|
issueTypesByProject: data.issueTypesByProject ?? {},
|
|
109
198
|
issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
|
|
199
|
+
assignableUsersByProject: data.assignableUsersByProject ?? {},
|
|
200
|
+
assignableUsersFetchedAt: data.assignableUsersFetchedAt ?? {},
|
|
201
|
+
prioritiesByProject: data.prioritiesByProject ?? {},
|
|
202
|
+
prioritiesFetchedAt: data.prioritiesFetchedAt ?? {},
|
|
203
|
+
labelsByTeam: data.labelsByTeam ?? {},
|
|
204
|
+
labelsFetchedAt: data.labelsFetchedAt ?? {},
|
|
110
205
|
projectsFetchedAt: data.projectsFetchedAt ?? null,
|
|
111
206
|
fetchedAt: data.fetchedAt,
|
|
112
207
|
};
|
|
@@ -119,7 +214,7 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
119
214
|
* updates (e.g. a single-project fetch merging into an existing
|
|
120
215
|
* multi-project cache) — this function persists exactly what it's given.
|
|
121
216
|
*/
|
|
122
|
-
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
217
|
+
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, assignableUsersByProject = {}, assignableUsersFetchedAt = {}, prioritiesByProject = {}, prioritiesFetchedAt = {}, labelsByTeam = {}, labelsFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
123
218
|
const filePath = metadataCachePath(profileName, configDir);
|
|
124
219
|
try {
|
|
125
220
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -129,6 +224,12 @@ export function writeMetadataCache(profileName, { projects = [], issueTypesByPro
|
|
|
129
224
|
projects,
|
|
130
225
|
issueTypesByProject,
|
|
131
226
|
issueTypesFetchedAt,
|
|
227
|
+
assignableUsersByProject,
|
|
228
|
+
assignableUsersFetchedAt,
|
|
229
|
+
prioritiesByProject,
|
|
230
|
+
prioritiesFetchedAt,
|
|
231
|
+
labelsByTeam,
|
|
232
|
+
labelsFetchedAt,
|
|
132
233
|
}));
|
|
133
234
|
} catch {
|
|
134
235
|
// Non-fatal
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failure-message enrichment for `ticket_update`'s priority field —
|
|
3
|
+
* reactive only, never runs on the success path. Same structure as
|
|
4
|
+
* ticket-create-enrichment.mjs's project/issuetype enrichment; kept as a
|
|
5
|
+
* separate file (not merged into that one) since the two enrich different
|
|
6
|
+
* fields for a different write and share no state beyond the cache module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SINGLE_PROJECT_TTL_MS, isFresh, mergeProjectPriorities } from './ticket-metadata-cache.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detects whether an update failure is shaped like a bad priority name —
|
|
13
|
+
* the only case this enrichment applies to. Jira surfaces this via its
|
|
14
|
+
* own real `err.details.errors.priority` key, confirmed by direct
|
|
15
|
+
* observation against a live Cloud instance (corenexus) and a live
|
|
16
|
+
* Server/DC instance (advent) on 2026-09-24. Anything else (rate limits,
|
|
17
|
+
* network errors, generic 4xx/5xx, a title/description/label failure)
|
|
18
|
+
* returns false — enrichment never applies there.
|
|
19
|
+
*/
|
|
20
|
+
export function detectPriorityError(err) {
|
|
21
|
+
return Boolean(err?.details?.errors && 'priority' in err.details.errors);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort failure-message enrichment for ticket_update's priority
|
|
26
|
+
* field — reactive only, never runs on the success path or for a
|
|
27
|
+
* non-priority failure. Reuses a cached per-project priority listing when
|
|
28
|
+
* fresh (no extra network call); refreshes it when missing/stale. A
|
|
29
|
+
* refresh failure is swallowed entirely and nothing is written to the
|
|
30
|
+
* cache: this can only ever make an error message MORE informative, never
|
|
31
|
+
* introduce a new way for ticketlens update to fail or a new way to
|
|
32
|
+
* poison the cache. Jira-only — GitHub has no priority field (refused
|
|
33
|
+
* upstream in ticket-command.mjs) and Linear already returns a structured,
|
|
34
|
+
* local `{reason:'not-found', options}` error with no network round trip.
|
|
35
|
+
*
|
|
36
|
+
* Freshness is keyed on the timestamp existing, not on the cached array
|
|
37
|
+
* having entries — a project with no priority-capable issue type at all
|
|
38
|
+
* genuinely caches `[]`, and `[].length` is falsy, so gating on length
|
|
39
|
+
* would re-walk every issue type (adapter.listPriorities' own sequential
|
|
40
|
+
* per-type loop) on every single subsequent failed update, forever,
|
|
41
|
+
* never respecting SINGLE_PROJECT_TTL_MS. Caught in code review before
|
|
42
|
+
* shipping — the sibling ticket-create-enrichment.mjs has this same
|
|
43
|
+
* length-gated pattern for issueTypesByProject, not fixed here since its
|
|
44
|
+
* refresh is a single call, not a sequential loop.
|
|
45
|
+
*/
|
|
46
|
+
export async function enrichUpdateFailure(err, { adapter, projectKey, profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn }) {
|
|
47
|
+
if (!detectPriorityError(err) || adapter.type !== 'jira' || !projectKey) return '';
|
|
48
|
+
|
|
49
|
+
let cached = readMetadataCacheFn(profileName, configDir);
|
|
50
|
+
const hasFreshPriorities = cached?.prioritiesByProject?.[projectKey] !== undefined
|
|
51
|
+
&& isFresh(cached?.prioritiesFetchedAt?.[projectKey], SINGLE_PROJECT_TTL_MS);
|
|
52
|
+
|
|
53
|
+
if (!hasFreshPriorities) {
|
|
54
|
+
try {
|
|
55
|
+
const { prioritiesByProject, prioritiesFetchedAt } = mergeProjectPriorities(cached, projectKey, await adapter.listPriorities(projectKey));
|
|
56
|
+
cached = { ...cached, prioritiesByProject, prioritiesFetchedAt };
|
|
57
|
+
writeMetadataCacheFn(profileName, cached, configDir);
|
|
58
|
+
} catch {
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const priorities = cached?.prioritiesByProject?.[projectKey];
|
|
64
|
+
if (!priorities?.length) return '';
|
|
65
|
+
return ` Known priorities for ${projectKey}: ${priorities.map(p => p.name).join(', ')}.\n`;
|
|
66
|
+
}
|