ticketlens 0.42.1 → 0.42.2

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 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 --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.
502
+ `ticketlens assign --to=me` self-assigns immediately, no `--confirm` needed. Any other `--to` (a name or email) assigns to another developer on Jira (Cloud and Server/DC) — GitHub/Linear 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,7 +1121,8 @@ 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
+ - **`ticketlens update --priority`** — a bad priority name now tells you the project's real, current valid options instead of a bare tracker error
1125
+ - **`ticketlens assign --to="name or email"`** — assign a ticket to another developer, not just yourself. Jira (Cloud + Server/DC); searches real assignable users first, lists matches, executes only on one confirmed match. Pro tier
1125
1126
  - **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
1126
1127
  - **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
1127
1128
  - **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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.42.1",
3
+ "version": "0.42.2",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,4 +1,4 @@
1
- <!-- jtb-skill-version: 0.46.0 -->
1
+ <!-- jtb-skill-version: 0.47.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.
@@ -438,7 +438,7 @@ ticketlens transition PROD-1234 # list valid transi
438
438
  ticketlens transition PROD-1234 --target="Done" --confirm # execute
439
439
  ticketlens assign PROD-1234 --to=me # assign to yourself — immediate, no --confirm
440
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)
441
+ ticketlens assign PROD-1234 --to="Jane Dev" --confirm # execute — only if exactly one match (Jira only)
442
442
  ticketlens duplicates PROD-1234 # find likely duplicates — read-only
443
443
  ticketlens link PROD-1234 PROD-5678 # list valid link types — read-only
444
444
  ticketlens link PROD-1234 PROD-5678 --type="Duplicate" --confirm # execute the link
@@ -453,7 +453,7 @@ ticketlens worklog PROD-1234=1h30m PROD-5678=45m --comment="Sprint work" --confi
453
453
 
454
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.
455
455
 
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.
456
+ `assign` — `--to=me` self-assigns immediately, no `--confirm` needed. Any other `--to` (a name or email) assigns to another developer on Jira (Cloud and Server/DC) — GitHub and Linear 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) — tell the user it isn't supported yet.
457
457
 
458
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.
459
459
 
@@ -85,27 +85,33 @@ export function createJiraAdapter(conn, { fetcher = globalThis.fetch } = {}) {
85
85
 
86
86
  /**
87
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).
88
+ * read-only, never assigns. Cloud (v3) and Server/DC (v2) both use
89
+ * `GET .../user/assignable/search`; `fetchAssignableUsers` is already
90
+ * apiVersion-generic (ROADMAP 65) and normalizes both shapes into
91
+ * {accountId, name, displayName} — Cloud populates accountId, Server/DC
92
+ * populates name, this method just passes that through unmodified.
91
93
  * Caching (3-day TTL, per project+query) is the caller's job — same
92
94
  * split as `listIssueTypes`, which is also cache-agnostic here.
93
95
  */
94
96
  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
97
  const users = await fetchAssignableUsers(key, query, { ...base, ...opts });
99
- return users.map(u => ({ accountId: u.accountId, displayName: u.displayName ?? u.name ?? u.accountId }));
98
+ return users.map(u => ({ accountId: u.accountId, name: u.name, displayName: u.displayName ?? u.name ?? u.accountId }));
100
99
  },
101
100
 
102
101
  /**
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.
102
+ * Executes an assignment to a resolved candidate (from
103
+ * `searchAssignableUsers`) — same apiVersion field-resolution pattern
104
+ * `assignToSelf` already uses: accountId for Cloud, name for Server/DC
105
+ * (ROADMAP 65). Never sends a null identity field — Jira's PUT treats
106
+ * that as "unassign", not an error (same reasoning as `assignToSelf`).
106
107
  */
107
- async assignToUser(key, accountId, opts = {}) {
108
- await assignIssue(key, { accountId }, { ...base, ...opts });
108
+ async assignToUser(key, candidate, opts = {}) {
109
+ const field = apiVersion === 3 ? 'accountId' : 'name';
110
+ const value = candidate[field];
111
+ if (!value) {
112
+ throw new Error(`Cannot resolve candidate's ${field} — Jira did not return it for this connection.`);
113
+ }
114
+ await assignIssue(key, { [field]: value }, { ...base, ...opts });
109
115
  },
110
116
 
111
117
  /**
@@ -671,7 +671,7 @@ export function printMcpHelp({ stream = process.stdout } = {}) {
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
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`,
674
+ ` no confirm needed — assigning to another developer (Jira only) resolves`,
675
675
  ` \`to\` first and needs \`confirm: true\` once it matches exactly one candidate;`,
676
676
  ` ${s.cyan('ticket_worklog')} is Jira-only, logs as you only, and writes nothing without`,
677
677
  ` \`confirm: true\` (it previews instead) — a worklog cannot be deleted through TicketLens;`,
@@ -782,8 +782,8 @@ export function printAssignHelp({ stream = process.stdout } = {}) {
782
782
  '',
783
783
  ` Assign a ticket directly in its tracker (Jira/GitHub/Linear). ${s.dim('[Pro]')}`,
784
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:`,
785
+ ` ${s.brand('--to')} (a name or email) assigns to another developer on Jira`,
786
+ ` (Cloud and Server/DC). Resolves the name/email against real assignable users first:`,
787
787
  ` without --confirm it lists the match(es) instead of assigning; with`,
788
788
  ` --confirm it executes only if exactly one candidate matched. Results are`,
789
789
  ` cached locally per project for 3 days.`,
@@ -622,6 +622,14 @@ export async function assignIssue(ticketKey, assignee, opts = {}) {
622
622
  * published spec — there is no "list everyone assignable" mode), so this
623
623
  * throws early on an empty query rather than sending a request guaranteed
624
624
  * to fail.
625
+ *
626
+ * Server/DC (v2) also gets the legacy `username` param alongside `query` —
627
+ * live-verified against a real Server/DC instance (ROADMAP 65): that
628
+ * instance silently ignores `query` and only filters on `username`, so
629
+ * `query`-only returned every assignable user regardless of input.
630
+ * Sending both is safe on Cloud too (Cloud simply never receives the extra
631
+ * param, this branch is v2-only) and on any Server/DC version that does
632
+ * honor `query` (an extra recognized param narrows further, never loosens).
625
633
  */
626
634
  export async function fetchAssignableUsers(ticketKey, query, opts = {}) {
627
635
  const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false, maxResults = 20 } = opts;
@@ -630,7 +638,9 @@ export async function fetchAssignableUsers(ticketKey, query, opts = {}) {
630
638
  }
631
639
  validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
632
640
  const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
633
- const params = new URLSearchParams({ issueKey: ticketKey, query, maxResults: String(maxResults) });
641
+ const paramFields = { issueKey: ticketKey, query, maxResults: String(maxResults) };
642
+ if (apiVersion === 2) paramFields.username = query;
643
+ const params = new URLSearchParams(paramFields);
634
644
  const url = `${baseUrl}/rest/api/${apiVersion}/user/assignable/search?${params}`;
635
645
 
636
646
  const fetchOpts = { headers: { ...buildAuthHeader(env), 'Content-Type': 'application/json' } };
@@ -239,12 +239,12 @@ export const TOOLS = [
239
239
  },
240
240
  {
241
241
  name: 'ticket_assign',
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.',
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 on Jira (Cloud and Server/DC); GitHub and Linear 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: '"me" to self-assign, or a name/email to search for and assign to another developer (Jira Cloud only).' },
247
+ to: { type: 'string', description: '"me" to self-assign, or a name/email to search for and assign to another developer (Jira only).' },
248
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"`.' },
249
249
  },
250
250
  required: ['ticket', 'to'],
@@ -538,10 +538,9 @@ export async function resolveAssigneeCandidates(adapter, ticketKey, query, {
538
538
 
539
539
  /**
540
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
541
+ * no confirm. Any other `--to` resolves a real person first (ROADMAP 61,
542
+ * extended to Server/DC by ROADMAP 65) and only executes when exactly one
543
+ * candidate matches AND --confirm is given, mirroring `transition`'s
545
544
  * list-then-confirm shape — notifying a colleague deserves the same
546
545
  * reviewed-before-write gate as a workflow-state change.
547
546
  *
@@ -611,10 +610,6 @@ export async function runTicketAssign(cmdArgs, {
611
610
  stream.write(` Assigning to another developer is Jira-only right now — ${adapter.type} is not supported.\n`);
612
611
  return { ok: false };
613
612
  }
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
- }
618
613
 
619
614
  const s = createStyler({ isTTY: stream.isTTY });
620
615
  let candidates;
@@ -632,14 +627,14 @@ export async function runTicketAssign(cmdArgs, {
632
627
 
633
628
  if (candidates.length > 1) {
634
629
  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`);
630
+ for (const c of candidates) stream.write(` ${s.brand('●')} ${c.displayName} ${s.dim(c.accountId ?? c.name)}\n`);
636
631
  return { ok: false };
637
632
  }
638
633
 
639
634
  const [candidate] = candidates;
640
635
 
641
636
  if (!cmdArgs.includes('--confirm')) {
642
- stream.write(` Match: ${s.bold(candidate.displayName)} ${s.dim(candidate.accountId)}\n`);
637
+ stream.write(` Match: ${s.bold(candidate.displayName)} ${s.dim(candidate.accountId ?? candidate.name)}\n`);
643
638
  stream.write(cliHints
644
639
  ? `\n Run again with --to="${to}" --confirm to execute.\n`
645
640
  : `\n Call again with to="${to}" and confirm: true to execute.\n`);
@@ -655,8 +650,8 @@ export async function runTicketAssign(cmdArgs, {
655
650
  }
656
651
 
657
652
  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 });
653
+ await adapter.assignToUser(ticketKey, candidate);
654
+ logActionFn({ ticketKey, action: 'assign', actor, tracker: adapter.type, detail: { assignee: candidate.displayName, accountId: candidate.accountId, name: candidate.name } }, { configDir });
660
655
  stream.write(` ${s.green('✔')} ${s.brand(s.bold(ticketKey))} assigned to ${s.bold(candidate.displayName)}.\n`);
661
656
  return { ok: true };
662
657
  } catch (err) {