ticketlens 0.40.0 → 0.41.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 +26 -1
- package/skills/jtb/scripts/lib/help.mjs +16 -7
- package/skills/jtb/scripts/lib/jira-client.mjs +33 -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/secret-scanner.mjs +11 -1
- package/skills/jtb/scripts/lib/ticket-command.mjs +141 -18
- package/skills/jtb/scripts/lib/ticket-metadata-cache.mjs +49 -2
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, 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
|
|
@@ -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
|
|
@@ -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
|
},
|
|
@@ -105,7 +105,17 @@ const HARD_REJECT_PATTERNS = [
|
|
|
105
105
|
{ name: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
|
|
106
106
|
];
|
|
107
107
|
|
|
108
|
-
|
|
108
|
+
// Bounded quantifiers (RFC 5321-realistic: 64-char local part, 253-char
|
|
109
|
+
// domain, 24-char TLD — no real email exceeds these) are load-bearing, not
|
|
110
|
+
// cosmetic: an unbounded `+` here is a textbook O(n^2) ReDoS on any long
|
|
111
|
+
// token with no '@' — the unanchored regex tries every starting position,
|
|
112
|
+
// and at each one backtracks the whole remaining length one char at a time
|
|
113
|
+
// before giving up. Found 2026-09-23 via adversarial testing of 49e: a
|
|
114
|
+
// 500KB error message with no '@' hung the process for minutes. Affects
|
|
115
|
+
// every caller of scanForSecrets (note add/patch, Recall push, and now
|
|
116
|
+
// error-reporter.mjs) — fixed once here rather than length-capping each
|
|
117
|
+
// call site, since the regex itself is the actual bug.
|
|
118
|
+
const EMAIL_RE = /[a-zA-Z0-9._%+-]{1,64}@[a-zA-Z0-9.-]{1,253}\.[a-zA-Z]{2,24}/;
|
|
109
119
|
|
|
110
120
|
// Shared between CODE_FILENAME_RE below and FILENAME_REFERENCE_RE further
|
|
111
121
|
// down, the same way WHITESPACE_CLASS is shared across the whitespace
|
|
@@ -16,7 +16,7 @@ import { resolveConnection, findProfilesByPrefix } from './profile-resolver.mjs'
|
|
|
16
16
|
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
17
17
|
import { checkCooldown, recordAction } 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
21
|
import { TICKET_KEY_PATTERN, normalizeTicketKey } from './cli.mjs';
|
|
22
22
|
import { scoreCandidates } from './duplicate-scorer.mjs';
|
|
@@ -436,13 +436,69 @@ export async function runTicketTransition(cmdArgs, {
|
|
|
436
436
|
}
|
|
437
437
|
|
|
438
438
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
|
|
439
|
+
* Derives the project-key portion of a ticket key — used only to key the
|
|
440
|
+
* local assignable-users cache, never for validation. Jira's own API stays
|
|
441
|
+
* the source of truth for whether the ticket key is real; a ticket key
|
|
442
|
+
* this can't parse just means caching is skipped, not a hard failure.
|
|
443
|
+
*/
|
|
444
|
+
function projectKeyFromTicket(ticketKey) {
|
|
445
|
+
const hyphenIndex = ticketKey.lastIndexOf('-');
|
|
446
|
+
return hyphenIndex > 0 ? ticketKey.slice(0, hyphenIndex) : null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Resolves a free-text `--to` query into assignable-user candidates,
|
|
451
|
+
* checking the local cache (3-day TTL, per project+query — ROADMAP 61)
|
|
452
|
+
* before calling the adapter. Read-only: never assigns, never touches
|
|
453
|
+
* cooldown. Exported for direct unit testing, same convention as
|
|
454
|
+
* `resolveTicketAdapter`.
|
|
455
|
+
*/
|
|
456
|
+
export async function resolveAssigneeCandidates(adapter, ticketKey, query, {
|
|
457
|
+
profileName,
|
|
458
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
459
|
+
readMetadataCacheFn = readMetadataCache,
|
|
460
|
+
writeMetadataCacheFn = writeMetadataCache,
|
|
461
|
+
} = {}) {
|
|
462
|
+
const projectKey = projectKeyFromTicket(ticketKey);
|
|
463
|
+
const cached = readMetadataCacheFn(profileName, configDir);
|
|
464
|
+
|
|
465
|
+
if (projectKey) {
|
|
466
|
+
const normalizedQuery = normalizeAssigneeQuery(query);
|
|
467
|
+
const cachedCandidates = cached?.assignableUsersByProject?.[projectKey]?.[normalizedQuery];
|
|
468
|
+
const cachedAt = cached?.assignableUsersFetchedAt?.[projectKey]?.[normalizedQuery];
|
|
469
|
+
if (cachedCandidates && isFresh(cachedAt, SINGLE_PROJECT_TTL_MS)) {
|
|
470
|
+
return cachedCandidates;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const candidates = await adapter.searchAssignableUsers(ticketKey, query);
|
|
475
|
+
|
|
476
|
+
if (projectKey) {
|
|
477
|
+
const { assignableUsersByProject, assignableUsersFetchedAt } = mergeAssignableUsers(cached, projectKey, query, candidates);
|
|
478
|
+
writeMetadataCacheFn(profileName, {
|
|
479
|
+
projects: cached?.projects ?? [],
|
|
480
|
+
issueTypesByProject: cached?.issueTypesByProject ?? {},
|
|
481
|
+
issueTypesFetchedAt: cached?.issueTypesFetchedAt ?? {},
|
|
482
|
+
projectsFetchedAt: cached?.projectsFetchedAt ?? null,
|
|
483
|
+
assignableUsersByProject,
|
|
484
|
+
assignableUsersFetchedAt,
|
|
485
|
+
}, configDir);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return candidates;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* `--to=me` self-assigns immediately — unchanged fast path, no discovery,
|
|
493
|
+
* no confirm. Any other `--to` resolves a real person first (ROADMAP 61):
|
|
494
|
+
* Jira Cloud only (Server/DC's assignable-user search is unverified —
|
|
495
|
+
* refuses cleanly rather than guessing), and only executes when exactly
|
|
496
|
+
* one candidate matches AND --confirm is given, mirroring `transition`'s
|
|
497
|
+
* list-then-confirm shape — notifying a colleague deserves the same
|
|
498
|
+
* reviewed-before-write gate as a workflow-state change.
|
|
444
499
|
*
|
|
445
|
-
* @param {string[]} cmdArgs - [ticketKey, '--to=me']
|
|
500
|
+
* @param {string[]} cmdArgs - [ticketKey, '--to=me'] or [ticketKey, '--to=...', '--confirm']
|
|
501
|
+
* @param {boolean} [cliHints] - see runTicketTransitionList's cliHints doc
|
|
446
502
|
* @returns {Promise<{ ok: boolean }>}
|
|
447
503
|
*/
|
|
448
504
|
export async function runTicketAssign(cmdArgs, {
|
|
@@ -454,17 +510,89 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
454
510
|
checkCooldownFn = checkCooldown,
|
|
455
511
|
recordActionFn = recordAction,
|
|
456
512
|
logActionFn = logAction,
|
|
513
|
+
readMetadataCacheFn = readMetadataCache,
|
|
514
|
+
writeMetadataCacheFn = writeMetadataCache,
|
|
457
515
|
actor = os.userInfo().username,
|
|
516
|
+
cliHints = true,
|
|
458
517
|
} = {}) {
|
|
459
|
-
const usage =
|
|
518
|
+
const usage = cliHints
|
|
519
|
+
? 'Usage: ticketlens assign TICKET-KEY --to=me | --to="name or email" --confirm\n'
|
|
520
|
+
: 'Usage: assign requires ticket and to; a to other than "me" also needs confirm: true to execute.\n';
|
|
460
521
|
if (!requireLicense(isLicensedFn, configDir, 'ticketlens assign', stream)) return { ok: false };
|
|
461
522
|
|
|
462
523
|
const ticketKey = requireTicketKey(cmdArgs, usage, stream);
|
|
463
524
|
if (!ticketKey) return { ok: false };
|
|
464
525
|
|
|
465
526
|
const to = parseFlag(cmdArgs, 'to');
|
|
466
|
-
if (to
|
|
467
|
-
stream.write(
|
|
527
|
+
if (!to) {
|
|
528
|
+
stream.write(usage);
|
|
529
|
+
return { ok: false };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (to === 'me') {
|
|
533
|
+
const cooldown = checkCooldownFn(ticketKey, 'assign', { configDir });
|
|
534
|
+
if (cooldown.active) {
|
|
535
|
+
stream.write(` Skipped — ${ticketKey} was already assigned ${Math.ceil(cooldown.remainingMs / 1000)}s ago. Wait a moment before retrying.\n`);
|
|
536
|
+
return { ok: false };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
540
|
+
if (!resolved) return { ok: false };
|
|
541
|
+
const { adapter } = resolved;
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
const result = await adapter.assignToSelf(ticketKey);
|
|
545
|
+
recordActionFn(ticketKey, 'assign', { configDir });
|
|
546
|
+
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: result.assignee } }, { configDir });
|
|
547
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
548
|
+
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
|
|
549
|
+
return { ok: true };
|
|
550
|
+
} catch (err) {
|
|
551
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
552
|
+
return { ok: false };
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
557
|
+
if (!resolved) return { ok: false };
|
|
558
|
+
const { adapter, conn } = resolved;
|
|
559
|
+
|
|
560
|
+
if (adapter.type !== 'jira') {
|
|
561
|
+
stream.write(` Assigning to another developer is Jira-only right now — ${adapter.type} is not supported.\n`);
|
|
562
|
+
return { ok: false };
|
|
563
|
+
}
|
|
564
|
+
if (conn.auth !== 'cloud') {
|
|
565
|
+
stream.write(' Assigning to another developer needs Jira Cloud — Server/DC is not supported yet.\n');
|
|
566
|
+
return { ok: false };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
570
|
+
let candidates;
|
|
571
|
+
try {
|
|
572
|
+
candidates = await resolveAssigneeCandidates(adapter, ticketKey, to, { profileName: conn.profileName, configDir, readMetadataCacheFn, writeMetadataCacheFn });
|
|
573
|
+
} catch (err) {
|
|
574
|
+
stream.write(formatWriteFailure(ticketKey, err));
|
|
575
|
+
return { ok: false };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (candidates.length === 0) {
|
|
579
|
+
stream.write(` No assignable user found matching "${to}" for ${s.brand(s.bold(ticketKey))}.\n`);
|
|
580
|
+
return { ok: false };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (candidates.length > 1) {
|
|
584
|
+
stream.write(` ${candidates.length} users match "${to}" — narrow the query:\n\n`);
|
|
585
|
+
for (const c of candidates) stream.write(` ${s.brand('●')} ${c.displayName} ${s.dim(c.accountId)}\n`);
|
|
586
|
+
return { ok: false };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const [candidate] = candidates;
|
|
590
|
+
|
|
591
|
+
if (!cmdArgs.includes('--confirm')) {
|
|
592
|
+
stream.write(` Match: ${s.bold(candidate.displayName)} ${s.dim(candidate.accountId)}\n`);
|
|
593
|
+
stream.write(cliHints
|
|
594
|
+
? `\n Run again with --to="${to}" --confirm to execute.\n`
|
|
595
|
+
: `\n Call again with to="${to}" and confirm: true to execute.\n`);
|
|
468
596
|
return { ok: false };
|
|
469
597
|
}
|
|
470
598
|
|
|
@@ -474,16 +602,11 @@ export async function runTicketAssign(cmdArgs, {
|
|
|
474
602
|
return { ok: false };
|
|
475
603
|
}
|
|
476
604
|
|
|
477
|
-
const resolved = resolveTicketAdapter(ticketKey, cmdArgs, { configDir, resolveConnectionFn, resolveAdapterFn, stream });
|
|
478
|
-
if (!resolved) return { ok: false };
|
|
479
|
-
const { adapter } = resolved;
|
|
480
|
-
|
|
481
605
|
try {
|
|
482
|
-
|
|
606
|
+
await adapter.assignToUser(ticketKey, candidate.accountId);
|
|
483
607
|
recordActionFn(ticketKey, 'assign', { configDir });
|
|
484
|
-
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee:
|
|
485
|
-
|
|
486
|
-
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(result.assignee)}.\n`);
|
|
608
|
+
logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: candidate.displayName, accountId: candidate.accountId } }, { configDir });
|
|
609
|
+
stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(candidate.displayName)}.\n`);
|
|
487
610
|
return { ok: true };
|
|
488
611
|
} catch (err) {
|
|
489
612
|
stream.write(formatWriteFailure(ticketKey, err));
|
|
@@ -21,7 +21,17 @@
|
|
|
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}}
|
|
24
26
|
* }
|
|
27
|
+
*
|
|
28
|
+
* assignableUsersByProject/assignableUsersFetchedAt (ROADMAP 61) share this
|
|
29
|
+
* file rather than a new one — same profile-scoped Jira-lookup cache, same
|
|
30
|
+
* 3-day freshness bar as a single-project issue-types lookup. Nested one
|
|
31
|
+
* level deeper than issueTypesByProject because Jira's own
|
|
32
|
+
* `user/assignable/search` has no "list everyone" mode (query is required
|
|
33
|
+
* unless accountId is given) — there is no per-project roster to cache,
|
|
34
|
+
* only per-(project, query) result pages, each with its own clock.
|
|
25
35
|
*/
|
|
26
36
|
|
|
27
37
|
import fs from 'node:fs';
|
|
@@ -62,6 +72,39 @@ export function mergeProjectIssueTypes(cached, projectKey, types, fetchedAt = ne
|
|
|
62
72
|
return { issueTypesByProject, issueTypesFetchedAt };
|
|
63
73
|
}
|
|
64
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Trim + lowercase — the one normalization every assignable-users cache
|
|
77
|
+
* read and write must agree on, so "Jane", " jane ", and "JANE" share one
|
|
78
|
+
* cache entry instead of three.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeAssigneeQuery(query) {
|
|
81
|
+
return String(query).trim().toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Merges one (project, query) assignable-users search result into an
|
|
86
|
+
* existing (possibly null) cached map — same shape and same
|
|
87
|
+
* Object.create(null) defense as mergeProjectIssueTypes, one level deeper
|
|
88
|
+
* since a project can have many independently-fresh cached queries. Both
|
|
89
|
+
* projectKey and the normalized query are unvalidated values reaching a
|
|
90
|
+
* key position.
|
|
91
|
+
*/
|
|
92
|
+
export function mergeAssignableUsers(cached, projectKey, query, candidates, fetchedAt = new Date().toISOString()) {
|
|
93
|
+
const normalizedQuery = normalizeAssigneeQuery(query);
|
|
94
|
+
|
|
95
|
+
const assignableUsersByProject = Object.assign(Object.create(null), cached?.assignableUsersByProject ?? {});
|
|
96
|
+
const projectQueries = Object.assign(Object.create(null), assignableUsersByProject[projectKey] ?? {});
|
|
97
|
+
projectQueries[normalizedQuery] = candidates;
|
|
98
|
+
assignableUsersByProject[projectKey] = projectQueries;
|
|
99
|
+
|
|
100
|
+
const assignableUsersFetchedAt = Object.assign(Object.create(null), cached?.assignableUsersFetchedAt ?? {});
|
|
101
|
+
const projectQueryTimestamps = Object.assign(Object.create(null), assignableUsersFetchedAt[projectKey] ?? {});
|
|
102
|
+
projectQueryTimestamps[normalizedQuery] = fetchedAt;
|
|
103
|
+
assignableUsersFetchedAt[projectKey] = projectQueryTimestamps;
|
|
104
|
+
|
|
105
|
+
return { assignableUsersByProject, assignableUsersFetchedAt };
|
|
106
|
+
}
|
|
107
|
+
|
|
65
108
|
/**
|
|
66
109
|
* Returns the absolute path to the ticket-metadata cache file for a profile.
|
|
67
110
|
*/
|
|
@@ -84,7 +127,7 @@ export function metadataCachePath(profileName, configDir = DEFAULT_CONFIG_DIR) {
|
|
|
84
127
|
* @param {string|null} profileName
|
|
85
128
|
* @param {string} [configDir]
|
|
86
129
|
* @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}
|
|
130
|
+
* @returns {{ projects: {key:string,name:string}[], issueTypesByProject: object, issueTypesFetchedAt: object, assignableUsersByProject: object, assignableUsersFetchedAt: object, projectsFetchedAt: string|null, fetchedAt: string } | null}
|
|
88
131
|
*/
|
|
89
132
|
export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, ttlMs = METADATA_TTL_MS) {
|
|
90
133
|
const filePath = metadataCachePath(profileName, configDir);
|
|
@@ -107,6 +150,8 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
107
150
|
projects: data.projects ?? [],
|
|
108
151
|
issueTypesByProject: data.issueTypesByProject ?? {},
|
|
109
152
|
issueTypesFetchedAt: data.issueTypesFetchedAt ?? {},
|
|
153
|
+
assignableUsersByProject: data.assignableUsersByProject ?? {},
|
|
154
|
+
assignableUsersFetchedAt: data.assignableUsersFetchedAt ?? {},
|
|
110
155
|
projectsFetchedAt: data.projectsFetchedAt ?? null,
|
|
111
156
|
fetchedAt: data.fetchedAt,
|
|
112
157
|
};
|
|
@@ -119,7 +164,7 @@ export function readMetadataCache(profileName, configDir = DEFAULT_CONFIG_DIR, t
|
|
|
119
164
|
* updates (e.g. a single-project fetch merging into an existing
|
|
120
165
|
* multi-project cache) — this function persists exactly what it's given.
|
|
121
166
|
*/
|
|
122
|
-
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
167
|
+
export function writeMetadataCache(profileName, { projects = [], issueTypesByProject = {}, issueTypesFetchedAt = {}, assignableUsersByProject = {}, assignableUsersFetchedAt = {}, projectsFetchedAt = null } = {}, configDir = DEFAULT_CONFIG_DIR) {
|
|
123
168
|
const filePath = metadataCachePath(profileName, configDir);
|
|
124
169
|
try {
|
|
125
170
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -129,6 +174,8 @@ export function writeMetadataCache(profileName, { projects = [], issueTypesByPro
|
|
|
129
174
|
projects,
|
|
130
175
|
issueTypesByProject,
|
|
131
176
|
issueTypesFetchedAt,
|
|
177
|
+
assignableUsersByProject,
|
|
178
|
+
assignableUsersFetchedAt,
|
|
132
179
|
}));
|
|
133
180
|
} catch {
|
|
134
181
|
// Non-fatal
|