ticketlens 0.11.0 → 0.12.1

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
@@ -73,7 +73,7 @@ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config
73
73
 
74
74
  | Command | Description |
75
75
  |---------|-------------|
76
- | `ticketlens init` | Guided wizard — Jira, GitHub Issues, or Linear — live connection test, optional settings |
76
+ | `ticketlens init` | Guided wizard — Jira, GitHub Issues, or Linear — live connection test; pick ticket prefixes and triage statuses straight from your instance |
77
77
  | `ticketlens switch` | Arrow-key panel to switch between configured profiles |
78
78
  | `ticketlens config [--profile=NAME]` | Edit any field on an existing profile — always re-validates the connection |
79
79
  | `ticketlens profiles` | List all configured profiles (alias: `ticketlens ls`) |
@@ -81,7 +81,7 @@ Tip: `tl` works everywhere `ticketlens` does — running `tl`/`ticketlens config
81
81
 
82
82
  `init` collects: profile name, tracker type (Jira / GitHub Issues / Linear), URL or workspace, credentials (masked), and optional ticket prefixes, project paths, and triage statuses. On connection failure, a retry menu lets you fix credentials, URL, or skip — all inputs pre-populated. If your Jira instance sits behind a VPN and resolves to a private/internal address, you'll be asked to confirm you trust that connection before it's allowed through — a one-time confirmation, remembered per profile and scoped to that exact host (changing the URL asks again). `config` is tracker-aware and always re-validates the connection after edits.
83
83
 
84
- `config` uses merge semantics: new ticket prefixes and triage statuses are added to existing lists, never replaced. Partial matching resolves `QA` to `QA Testing` if that's the status in your Jira.
84
+ **No more guessing prefixes or status names** — on Jira, once the connection test passes, ticket prefixes and triage statuses are picked from live multi-select lists fetched from your instance (its actual projects and statuses), with sensible defaults pre-checked. Space toggles, `a` toggles all, Enter confirms. When editing with `config`, your current values come pre-selected and unchecking removes them; anything configured that no longer exists on the server is flagged `(not on server)` so you can clean it up — or keep it — deliberately. In non-interactive shells, or if the lists can't be fetched (press Esc to choose this any time), the wizard falls back to classic free-text entry with live validation — free-text `config` entries keep the old add-only merge, and partial matching still resolves `QA` to `QA Testing` if that's the status in your Jira.
85
85
 
86
86
  ---
87
87
 
@@ -879,6 +879,7 @@ npm test
879
879
  See [ROADMAP.md](ROADMAP.md) for the full plan.
880
880
 
881
881
  Recently shipped:
882
+ - **Prefix & status pickers** (`ticketlens init` / `config`, v0.12.0) — ticket prefixes and triage statuses are picked from your Jira instance's live project/status lists via multi-select; editing pre-selects current values, unchecking removes them, and stale entries are flagged `(not on server)`
882
883
  - **Collision detection** (`ticketlens collisions`) — shows which files your branch shares with teammates' in-flight branches; `--push` auto-sends git branch data to the team snapshot. Team tier
883
884
  - **Shareable triage snapshot** (`ticketlens triage --share`) — generates a 24h signed URL; recipients open it in any browser, no account needed. Team tier
884
885
  - **Compliance push** (`ticketlens triage --push`) — enriches the team snapshot with per-ticket compliance status and coverage from the local ledger. Pro tier
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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.11.0 -->
1
+ <!-- jtb-skill-version: 0.12.1 -->
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.
@@ -10,7 +10,7 @@ import { existsSync, mkdirSync } from 'node:fs';
10
10
  import { createStyler } from './ansi.mjs';
11
11
  import { createSession } from './banner.mjs';
12
12
  import { classifyError } from './error-classifier.mjs';
13
- import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
13
+ import { fetchCurrentUser, fetchStatuses, fetchProjects } from './jira-client.mjs';
14
14
  import { resolveAdapter } from './resolve-adapter.mjs';
15
15
  import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.mjs';
16
16
  import { promptSelect } from './select-prompt.mjs';
@@ -20,6 +20,7 @@ import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
20
20
  import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
21
21
  import { isLicensed } from './license.mjs';
22
22
  import { siteBase } from './api-utils.mjs';
23
+ import { pickTicketPrefixes, pickTriageStatuses, DEFAULT_TRIAGE_STATUSES } from './wizard-pickers.mjs';
23
24
 
24
25
  const RETRY_OPTIONS = [
25
26
  { label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
@@ -272,19 +273,39 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
272
273
  // ── Optional settings ──────────────────────────────────────────────────────
273
274
  stream.write(`\n ${s.dim('──── Optional ────')}\n\n`);
274
275
 
275
- // Ticket prefixes
276
- const curPrefixes = (profile.ticketPrefixes || []).join(', ');
277
- const prefixInput = await promptText(
278
- s.dim('Ticket prefixes') + s.dim(curPrefixes ? ` [current: ${curPrefixes}]:` : ' (e.g. PROJ,OPS — press Enter to skip):'),
279
- { stream }
280
- );
281
- const existing = new Set(profile.ticketPrefixes || []);
282
- if (prefixInput) {
283
- for (const v of prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)) {
284
- existing.add(v);
276
+ // Shared env for post-connection Jira lookups (pickers + fallback validation)
277
+ const jiraEnv = {
278
+ JIRA_BASE_URL: url,
279
+ JIRA_EMAIL: email,
280
+ JIRA_API_TOKEN: auth !== 'pat' ? token : '',
281
+ JIRA_PAT: auth === 'pat' ? token : '',
282
+ };
283
+ const jiraApiVersion = auth === 'cloud' ? 3 : 2;
284
+
285
+ // Ticket prefixes — picked from the instance's project list with current
286
+ // values pre-checked (replace semantics: deselect removes); legacy add-only
287
+ // free text when the picker can't run (non-TTY, fetch failure, Esc, non-Jira)
288
+ let ticketPrefixes = isJira
289
+ ? await pickTicketPrefixes({
290
+ fetchProjects: () => fetchProjects({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
291
+ current: profile.ticketPrefixes || [],
292
+ stream,
293
+ })
294
+ : null;
295
+ if (ticketPrefixes === null) {
296
+ const curPrefixes = (profile.ticketPrefixes || []).join(', ');
297
+ const prefixInput = await promptText(
298
+ s.dim('Ticket prefixes') + s.dim(curPrefixes ? ` [current: ${curPrefixes}]:` : ' (e.g. PROJ,OPS — press Enter to skip):'),
299
+ { stream }
300
+ );
301
+ const existing = new Set(profile.ticketPrefixes || []);
302
+ if (prefixInput) {
303
+ for (const v of prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)) {
304
+ existing.add(v);
305
+ }
285
306
  }
307
+ ticketPrefixes = [...existing];
286
308
  }
287
- const ticketPrefixes = [...existing];
288
309
 
289
310
  // Project path
290
311
  const home = homedir();
@@ -318,76 +339,83 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
318
339
  }
319
340
  }
320
341
 
321
- // ── Triage statuses (merge semantics) ────────────────────────────────────
342
+ // ── Triage statuses ────────────────────────────────────────────────────────
322
343
  const currentStatuses = profile.triageStatuses?.length
323
344
  ? profile.triageStatuses
324
- : ['In Progress', 'Code Review', 'QA'];
325
- const curStatusesStr = currentStatuses.join(', ');
345
+ : DEFAULT_TRIAGE_STATUSES;
346
+
347
+ // Picked from the instance's status list with current values pre-checked
348
+ // (replace semantics: deselect removes, stale entries kept unless unchecked);
349
+ // legacy add-only merge free text when the picker can't run
350
+ let triageStatuses = isJira
351
+ ? await pickTriageStatuses({
352
+ fetchStatuses: () => fetchStatuses({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
353
+ current: currentStatuses,
354
+ stream,
355
+ })
356
+ : null;
326
357
 
327
- const statusInput = await promptText(
328
- s.dim('Add triage statuses') + s.dim(` [current: ${curStatusesStr} — Enter to keep]:`),
329
- { stream }
330
- );
358
+ if (triageStatuses === null) {
359
+ triageStatuses = currentStatuses;
360
+ const curStatusesStr = currentStatuses.join(', ');
331
361
 
332
- let triageStatuses = currentStatuses;
362
+ const statusInput = await promptText(
363
+ s.dim('Add triage statuses') + s.dim(` [current: ${curStatusesStr} — Enter to keep]:`),
364
+ { stream }
365
+ );
333
366
 
334
- if (statusInput) {
335
- const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
336
- const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
337
- const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
367
+ if (statusInput) {
368
+ const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
369
+ const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
370
+ const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
338
371
 
339
- if (toValidate.length > 0) {
340
- stream.write(` ${s.dim('Validating new statuses...')}\n`);
341
- try {
342
- let available;
343
- if (isJira) {
344
- const validateEnv = {
345
- JIRA_BASE_URL: url,
346
- JIRA_EMAIL: email,
347
- JIRA_API_TOKEN: auth !== 'pat' ? token : '',
348
- JIRA_PAT: auth === 'pat' ? token : '',
349
- };
350
- available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2, allowPrivateIp: isTrustedForCurrentUrl() });
351
- } else {
352
- available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
353
- }
372
+ if (toValidate.length > 0) {
373
+ stream.write(` ${s.dim('Validating new statuses...')}\n`);
374
+ try {
375
+ let available;
376
+ if (isJira) {
377
+ available = await fetchStatuses({ env: jiraEnv, apiVersion: jiraApiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
378
+ } else {
379
+ available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
380
+ }
354
381
 
355
- const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
356
- stream.write('\x1b[A\r\x1b[2K');
382
+ const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
383
+ stream.write('\x1b[A\r\x1b[2K');
357
384
 
358
- const toAdd = [];
359
- for (const name of toValidate) {
360
- if (available.includes(name)) {
361
- toAdd.push(name);
362
- stream.write(` ${s.green('✔')} ${name}\n`);
363
- } else {
364
- const exact = lowerMap.get(name.toLowerCase());
365
- if (exact) {
366
- stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
367
- toAdd.push(exact);
385
+ const toAdd = [];
386
+ for (const name of toValidate) {
387
+ if (available.includes(name)) {
388
+ toAdd.push(name);
389
+ stream.write(` ${s.green('✔')} ${name}\n`);
368
390
  } else {
369
- const partial = available.find(a =>
370
- a.toLowerCase().includes(name.toLowerCase()) ||
371
- name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
372
- );
373
- if (partial) {
374
- stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(partial)}\n`);
375
- toAdd.push(partial);
391
+ const exact = lowerMap.get(name.toLowerCase());
392
+ if (exact) {
393
+ stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
394
+ toAdd.push(exact);
376
395
  } else {
377
- stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found — skipped)')}\n`);
396
+ const partial = available.find(a =>
397
+ a.toLowerCase().includes(name.toLowerCase()) ||
398
+ name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
399
+ );
400
+ if (partial) {
401
+ stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(partial)}\n`);
402
+ toAdd.push(partial);
403
+ } else {
404
+ stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found — skipped)')}\n`);
405
+ }
378
406
  }
379
407
  }
380
408
  }
381
- }
382
409
 
383
- triageStatuses = [...currentStatuses, ...toAdd];
384
- if (toAdd.length > 0) {
385
- stream.write(` ${s.dim('Updated list:')} ${triageStatuses.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
410
+ triageStatuses = [...currentStatuses, ...toAdd];
411
+ if (toAdd.length > 0) {
412
+ stream.write(` ${s.dim('Updated list:')} ${triageStatuses.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
413
+ }
414
+ } catch {
415
+ stream.write('\x1b[A\r\x1b[2K');
416
+ // Tracker unreachable — add without validation, deduped
417
+ triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
386
418
  }
387
- } catch {
388
- stream.write('\x1b[A\r\x1b[2K');
389
- // Tracker unreachable — add without validation, deduped
390
- triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
391
419
  }
392
420
  }
393
421
  }
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync } from 'node:fs';
9
9
  import { createStyler } from './ansi.mjs';
10
10
  import { createSession } from './banner.mjs';
11
11
  import { classifyError } from './error-classifier.mjs';
12
- import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
12
+ import { fetchCurrentUser, fetchStatuses, fetchProjects } from './jira-client.mjs';
13
13
  import { loadProfiles, saveProfile, saveDefault } from './profile-resolver.mjs';
14
14
  import { resolveAdapter } from './resolve-adapter.mjs';
15
15
  import { promptSelect } from './select-prompt.mjs';
@@ -18,6 +18,7 @@ import { renderWordmark } from './wordmark.mjs';
18
18
  import { printQuickStart } from './quick-start-panel.mjs';
19
19
  import { DEFAULT_CONFIG_DIR, hostnameOf } from './config.mjs';
20
20
  import { SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
21
+ import { pickTicketPrefixes, pickTriageStatuses, DEFAULT_TRIAGE_STATUSES } from './wizard-pickers.mjs';
21
22
 
22
23
  const RETRY_OPTIONS = [
23
24
  { label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
@@ -468,13 +469,20 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
468
469
  // ── Optional settings ───────────────────────────────────────────────────
469
470
  stream.write(`\n ${s.dim('──── Optional (press Enter to skip) ────')}\n\n`);
470
471
 
471
- // Ticket prefixes
472
- const prefixInput = await promptText(
473
- s.dim('Ticket prefixes') + s.dim(' (e.g. PROJ,OPS):'), { stream }
474
- );
475
- const ticketPrefixes = prefixInput
476
- ? prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
477
- : [];
472
+ // Ticket prefixes — picked from the instance's project list; free-text
473
+ // fallback when the picker can't run (non-TTY, fetch failure, Esc)
474
+ let ticketPrefixes = await pickTicketPrefixes({
475
+ fetchProjects: () => fetchProjects({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
476
+ stream,
477
+ });
478
+ if (ticketPrefixes === null) {
479
+ const prefixInput = await promptText(
480
+ s.dim('Ticket prefixes') + s.dim(' (e.g. PROJ,OPS):'), { stream }
481
+ );
482
+ ticketPrefixes = prefixInput
483
+ ? prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
484
+ : [];
485
+ }
478
486
 
479
487
  // Project path (single — used for auto-profile detection from cwd)
480
488
  const home = homedir();
@@ -507,45 +515,55 @@ async function _run({ configDir, stream, s, showBanner, showQuickStart }) {
507
515
  }
508
516
  }
509
517
 
510
- // Triage statuses — validate against Jira's actual status names
511
- const DEFAULT_TRIAGE = 'In Progress, Code Review, QA';
512
- const statusInput = await promptText(
513
- s.dim('Triage statuses') + s.dim(` [default: ${DEFAULT_TRIAGE}]:`), { stream }
514
- );
515
- let triageStatuses = statusInput
516
- ? statusInput.split(',').map(v => v.trim()).filter(Boolean)
517
- : DEFAULT_TRIAGE.split(',').map(v => v.trim());
518
+ // Triage statuses — picked from the instance's status list with the
519
+ // defaults pre-checked (stale defaults dropped, not user data); free-text
520
+ // + validate fallback when the picker can't run (non-TTY, fetch failure, Esc)
521
+ let triageStatuses = await pickTriageStatuses({
522
+ fetchStatuses: () => fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() }),
523
+ current: DEFAULT_TRIAGE_STATUSES,
524
+ preserveMissing: false,
525
+ stream,
526
+ });
527
+ if (triageStatuses === null) {
528
+ const DEFAULT_TRIAGE = DEFAULT_TRIAGE_STATUSES.join(', ');
529
+ const statusInput = await promptText(
530
+ s.dim('Triage statuses') + s.dim(` [default: ${DEFAULT_TRIAGE}]:`), { stream }
531
+ );
532
+ triageStatuses = statusInput
533
+ ? statusInput.split(',').map(v => v.trim()).filter(Boolean)
534
+ : [...DEFAULT_TRIAGE_STATUSES];
518
535
 
519
- stream.write(` ${s.dim('Validating statuses...')}\n`);
520
- try {
521
- const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
522
- const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
523
- stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
524
- const corrected = [];
525
- let hasIssues = false;
526
- for (const name of triageStatuses) {
527
- if (available.includes(name)) {
528
- corrected.push(name);
529
- } else {
530
- const match = lowerMap.get(name.toLowerCase());
531
- if (match) {
532
- stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(match)}\n`);
533
- corrected.push(match);
534
- hasIssues = true;
536
+ stream.write(` ${s.dim('Validating statuses...')}\n`);
537
+ try {
538
+ const available = await fetchStatuses({ env, apiVersion, allowPrivateIp: isTrustedForCurrentUrl() });
539
+ const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
540
+ stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
541
+ const corrected = [];
542
+ let hasIssues = false;
543
+ for (const name of triageStatuses) {
544
+ if (available.includes(name)) {
545
+ corrected.push(name);
535
546
  } else {
536
- stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found in this Jira instance)')}\n`);
537
- hasIssues = true;
547
+ const match = lowerMap.get(name.toLowerCase());
548
+ if (match) {
549
+ stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(match)}\n`);
550
+ corrected.push(match);
551
+ hasIssues = true;
552
+ } else {
553
+ stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found in this Jira instance)')}\n`);
554
+ hasIssues = true;
555
+ }
538
556
  }
539
557
  }
558
+ if (!hasIssues) {
559
+ stream.write(` ${s.green('✔')} Statuses validated\n`);
560
+ } else if (corrected.length > 0) {
561
+ stream.write(` ${s.dim('Using:')} ${corrected.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
562
+ }
563
+ triageStatuses = corrected;
564
+ } catch {
565
+ stream.write('\x1b[A\r\x1b[2K'); // clear silently if Jira call fails
540
566
  }
541
- if (!hasIssues) {
542
- stream.write(` ${s.green('✔')} Statuses validated\n`);
543
- } else if (corrected.length > 0) {
544
- stream.write(` ${s.dim('Using:')} ${corrected.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
545
- }
546
- triageStatuses = corrected;
547
- } catch {
548
- stream.write('\x1b[A\r\x1b[2K'); // clear silently if Jira call fails
549
567
  }
550
568
 
551
569
  // ── Save ──────────────────────────────────────────────────────────────
@@ -318,6 +318,50 @@ export async function fetchStatuses(opts = {}) {
318
318
  return [...new Set(raw.map(s => s.name))].sort();
319
319
  }
320
320
 
321
+ /**
322
+ * Lists projects visible to the authenticated user as [{key, name}], key-sorted.
323
+ * v2 (Server/DC): GET /project returns the full array in one response.
324
+ * v3 (Cloud): GET /project/search is paginated — walked until isLast, capped
325
+ * at MAX_PROJECT_PAGES to bound the walk against a misbehaving server.
326
+ */
327
+ export async function fetchProjects(opts = {}) {
328
+ const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), apiVersion = 2, timeoutMs = 10_000, allowPrivateIp = false } = opts;
329
+ validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
330
+ const baseUrl = env.JIRA_BASE_URL.replace(/\/$/, '');
331
+ const headers = { ...buildAuthHeader(env), 'Content-Type': 'application/json' };
332
+
333
+ async function getJson(url) {
334
+ const fetchOpts = { headers };
335
+ if (timeoutMs) fetchOpts.signal = AbortSignal.timeout(timeoutMs);
336
+ const response = await guardedFetch(url, fetchOpts, { fetcher, lookup, allowPrivateIp });
337
+ if (!response.ok) {
338
+ const err = new Error(`Jira API error ${response.status} fetching projects`);
339
+ err.status = response.status;
340
+ throw err;
341
+ }
342
+ return response.json();
343
+ }
344
+
345
+ const projects = [];
346
+ if (apiVersion >= 3) {
347
+ const MAX_PROJECT_PAGES = 10;
348
+ const PAGE_SIZE = 50;
349
+ let startAt = 0;
350
+ for (let page = 0; page < MAX_PROJECT_PAGES; page++) {
351
+ const params = new URLSearchParams({ startAt: String(startAt), maxResults: String(PAGE_SIZE) });
352
+ const raw = await getJson(`${baseUrl}/rest/api/3/project/search?${params}`);
353
+ const values = raw.values ?? [];
354
+ for (const p of values) projects.push({ key: p.key, name: p.name ?? '' });
355
+ if (raw.isLast || values.length === 0) break;
356
+ startAt += values.length;
357
+ }
358
+ } else {
359
+ const raw = await getJson(`${baseUrl}/rest/api/2/project`);
360
+ for (const p of raw) projects.push({ key: p.key, name: p.name ?? '' });
361
+ }
362
+ return projects.sort((a, b) => a.key.localeCompare(b.key));
363
+ }
364
+
321
365
  export async function searchTickets(jql, opts = {}) {
322
366
  const { env = process.env, fetcher = globalThis.fetch, lookup = defaultLookupFor(fetcher), maxResults = 50, apiVersion = 2, timeoutMs = 10_000, expandChangelog = false, allowPrivateIp = false } = opts;
323
367
  validateBaseUrl(env.JIRA_BASE_URL, allowPrivateIp);
@@ -30,9 +30,8 @@ export const visLen = (str) => str.replace(ANSI_RE, '').length;
30
30
  * then drain. Call this — and await it — before starting any new raw-mode
31
31
  * prompt.
32
32
  */
33
- export function flushStdin() {
33
+ export function flushStdin(stdin = process.stdin) {
34
34
  return new Promise((resolve) => {
35
- const stdin = process.stdin;
36
35
  if (typeof stdin.read !== 'function' || typeof stdin.resume !== 'function') {
37
36
  resolve();
38
37
  return;
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Reusable raw-mode selector primitive and simple promptSelect helper.
3
- * Used by profile-picker.mjs, profile-switcher.mjs, and init-wizard.mjs.
2
+ * Reusable raw-mode selector primitives: single-select and multi-select.
3
+ * Used by profile-picker.mjs, profile-switcher.mjs, init-wizard.mjs,
4
+ * config-wizard.mjs, and wizard-pickers.mjs.
4
5
  */
5
6
 
6
7
  import { createStyler } from './ansi.mjs';
@@ -16,10 +17,15 @@ import { flushStdin } from './prompt-helpers.mjs';
16
17
  * @param {number} [opts.initialIndex=0]
17
18
  * @param {(index: number) => number} opts.renderFn
18
19
  * @param {NodeJS.WriteStream} [opts.stream=process.stderr]
20
+ * @param {NodeJS.ReadStream} [opts.stdin=process.stdin] injectable for tests
21
+ * @param {(key: string, index: number) => 'veto'|boolean} [opts.onKey]
22
+ * Hook for extra keys. Called for Enter before submit — return 'veto' to
23
+ * swallow it and keep the selector open. For any other unhandled key,
24
+ * return truthy to re-render. Falsy = key ignored.
19
25
  * @returns {Promise<number|null>} selected index, or null if cancelled
20
26
  */
21
- export function runRawSelect({ count, initialIndex = 0, renderFn, stream = process.stderr }) {
22
- if (!stream.isTTY || !process.stdin.setRawMode) return Promise.resolve(null);
27
+ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = process.stderr, stdin = process.stdin, onKey }) {
28
+ if (!stream.isTTY || !stdin.setRawMode) return Promise.resolve(null);
23
29
 
24
30
  let selected = initialIndex;
25
31
  let lineCount = 0;
@@ -34,8 +40,7 @@ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = proce
34
40
  }
35
41
 
36
42
  stream.write('\x1b[?25l');
37
- return flushStdin().then(() => new Promise((resolve) => {
38
- const stdin = process.stdin;
43
+ return flushStdin(stdin).then(() => new Promise((resolve) => {
39
44
  const wasRaw = stdin.isRaw;
40
45
 
41
46
  function cleanup() {
@@ -52,7 +57,11 @@ export function runRawSelect({ count, initialIndex = 0, renderFn, stream = proce
52
57
  }
53
58
  if (key === '\x1b[A' && selected > 0) { selected--; render(); return; }
54
59
  if (key === '\x1b[B' && selected < count - 1) { selected++; render(); return; }
55
- if (key === '\r' || key === '\n') { cleanup(); erase(); resolve(selected); return; }
60
+ if (key === '\r' || key === '\n') {
61
+ if (onKey && onKey(key, selected) === 'veto') { render(); return; }
62
+ cleanup(); erase(); resolve(selected); return;
63
+ }
64
+ if (onKey && onKey(key, selected)) { render(); return; }
56
65
  }
57
66
 
58
67
  stdin.setRawMode(true);
@@ -105,3 +114,88 @@ export function promptSelect(items, opts = {}) {
105
114
 
106
115
  return runRawSelect({ count: items.length, initialIndex, renderFn, stream });
107
116
  }
117
+
118
+ /**
119
+ * Arrow-key multi-select with checkboxes and a scrolling viewport.
120
+ * Space toggles the highlighted row, `a` toggles all, Enter confirms,
121
+ * Esc cancels (returns null — callers treat that as "enter manually").
122
+ *
123
+ * @param {Array<{label: string}>} items
124
+ * @param {object} [opts]
125
+ * @param {number[]} [opts.initialSelected=[]] pre-checked item indices
126
+ * @param {number} [opts.minSelected=0] Enter is vetoed below this count
127
+ * @param {number} [opts.maxVisible=10] viewport height for long lists
128
+ * @param {string} [opts.hint]
129
+ * @param {NodeJS.WriteStream} [opts.stream]
130
+ * @param {NodeJS.ReadStream} [opts.stdin] injectable for tests
131
+ * @returns {Promise<number[]|null>} checked indices in list order, or null if cancelled
132
+ */
133
+ export function promptMultiSelect(items, opts = {}) {
134
+ const {
135
+ stream = process.stderr,
136
+ stdin = process.stdin,
137
+ hint = '↑/↓ move Space toggle a all Enter confirm Esc manual entry',
138
+ initialSelected = [],
139
+ minSelected = 0,
140
+ maxVisible = 10,
141
+ } = opts;
142
+
143
+ const s = createStyler({ isTTY: stream.isTTY });
144
+
145
+ if (!stream.isTTY || !stdin.setRawMode) {
146
+ for (const item of items) stream.write(` ${s.cyan('›')} ${item.label}\n`);
147
+ return Promise.resolve(null);
148
+ }
149
+
150
+ const checked = new Set(initialSelected.filter(i => i >= 0 && i < items.length));
151
+ let scrollTop = 0;
152
+ let flash = '';
153
+
154
+ function renderFn(selected) {
155
+ if (selected >= scrollTop + maxVisible) scrollTop = selected - maxVisible + 1;
156
+ if (selected < scrollTop) scrollTop = selected;
157
+ const visibleEnd = Math.min(scrollTop + maxVisible, items.length);
158
+
159
+ const lines = [];
160
+ for (let i = scrollTop; i < visibleEnd; i++) {
161
+ const isSelected = i === selected;
162
+ const marker = isSelected ? s.blue('❯') : ' ';
163
+ const box = checked.has(i) ? s.green('◉') : s.dim('○');
164
+ const label = isSelected ? s.bold(s.blue(items[i].label)) : items[i].label;
165
+ lines.push(` ${marker} ${box} ${label}`);
166
+ }
167
+ if (items.length > maxVisible) {
168
+ lines.push(` ${s.dim(`${scrollTop + 1}-${visibleEnd} of ${items.length}`)}`);
169
+ }
170
+ lines.push('');
171
+ lines.push(` ${s.dim(hint)}${flash ? ' ' + s.yellow(flash) : ''}`);
172
+ stream.write(lines.join('\n') + '\n');
173
+ return lines.length;
174
+ }
175
+
176
+ function onKey(key, selected) {
177
+ if (key === ' ') {
178
+ flash = '';
179
+ if (checked.has(selected)) checked.delete(selected);
180
+ else checked.add(selected);
181
+ return true;
182
+ }
183
+ if (key === 'a' || key === 'A') {
184
+ flash = '';
185
+ if (checked.size === items.length) checked.clear();
186
+ else for (let i = 0; i < items.length; i++) checked.add(i);
187
+ return true;
188
+ }
189
+ if (key === '\r' || key === '\n') {
190
+ if (checked.size < minSelected) {
191
+ flash = `select at least ${minSelected}`;
192
+ return 'veto';
193
+ }
194
+ return false;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ return runRawSelect({ count: items.length, renderFn, stream, stdin, onKey })
200
+ .then(result => (result === null ? null : [...checked].sort((a, b) => a - b)));
201
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared prefix/status picker steps for init-wizard.mjs and config-wizard.mjs.
3
+ * Live-fetched multi-select over the connected Jira instance's projects and
4
+ * statuses. Every picker returns string[] on confirm, or null when the picker
5
+ * cannot run (non-TTY, fetch failure, empty server list) or the user hits Esc
6
+ * — callers treat null as "use the legacy free-text prompt".
7
+ */
8
+
9
+ import { createStyler } from './ansi.mjs';
10
+ import { promptMultiSelect } from './select-prompt.mjs';
11
+
12
+ export const DEFAULT_TRIAGE_STATUSES = ['In Progress', 'Code Review', 'QA'];
13
+
14
+ /**
15
+ * Builds picker rows from server values plus the user's current selections.
16
+ * Matching is case-insensitive; rows echo the server's canonical casing so a
17
+ * confirm fixes case drift in the profile. Current values missing from the
18
+ * server are appended as marked, pre-checked rows when preserveMissing is on
19
+ * (edit flows — user data is never silently dropped) and skipped when off
20
+ * (init flows — stale defaults are not user data).
21
+ */
22
+ function buildRows({ serverValues, labels, current, preserveMissing, s }) {
23
+ const values = [...serverValues];
24
+ const rows = serverValues.map((v, i) => ({ label: labels[i] }));
25
+ const serverLower = new Set(serverValues.map(v => v.toLowerCase()));
26
+
27
+ const staleSeen = new Set(); // legacy free-text merge never deduped within itself
28
+ for (const cur of current) {
29
+ const lower = cur.toLowerCase();
30
+ if (preserveMissing && !serverLower.has(lower) && !staleSeen.has(lower)) {
31
+ staleSeen.add(lower);
32
+ values.push(cur);
33
+ rows.push({ label: `${cur} ${s.dim('(not on server)')}` });
34
+ }
35
+ }
36
+
37
+ const currentLower = new Set(current.map(c => c.toLowerCase()));
38
+ const initialSelected = values
39
+ .map((v, i) => (currentLower.has(v.toLowerCase()) ? i : -1))
40
+ .filter(i => i >= 0);
41
+
42
+ return { values, rows, initialSelected };
43
+ }
44
+
45
+ /**
46
+ * Fetches via fetchFn behind a clearable progress line. Returns the list, or
47
+ * null after printing a fallback warning (raw error details stay internal —
48
+ * the connection already tested fine, so this is a transient/permission issue).
49
+ */
50
+ async function fetchForPicker(fetchFn, { what, stream, s }) {
51
+ stream.write(` ${s.dim(`Fetching ${what}...`)}\n`);
52
+ try {
53
+ const list = await fetchFn();
54
+ stream.write('\x1b[A\r\x1b[2K');
55
+ return list;
56
+ } catch {
57
+ stream.write('\x1b[A\r\x1b[2K');
58
+ stream.write(` ${s.yellow('~')} Could not fetch ${what} — enter manually\n`);
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function writeConfirmation(chosen, { stream, s }) {
64
+ stream.write(` ${s.green('✔')} ${chosen.length ? chosen.map(v => s.cyan(v)).join(s.dim(', ')) : s.dim('none')}\n`);
65
+ }
66
+
67
+ /**
68
+ * Multi-select over the instance's projects; returns the chosen project keys.
69
+ *
70
+ * @param {object} opts
71
+ * @param {() => Promise<Array<{key: string, name: string}>>} opts.fetchProjects
72
+ * pre-bound by the caller — env/apiVersion/allowPrivateIp trust stays there
73
+ * @param {string[]} [opts.current=[]] pre-checked prefixes (edit flow)
74
+ * @param {boolean} [opts.preserveMissing=true]
75
+ * @returns {Promise<string[]|null>} chosen keys, or null → free-text fallback
76
+ */
77
+ export async function pickTicketPrefixes({ fetchProjects, current = [], preserveMissing = true, stream = process.stderr, stdin = process.stdin } = {}) {
78
+ const s = createStyler({ isTTY: stream.isTTY });
79
+ if (!stream.isTTY || !stdin.setRawMode) return null;
80
+
81
+ const projects = await fetchForPicker(fetchProjects, { what: 'projects', stream, s });
82
+ if (projects === null || projects.length === 0) return null;
83
+
84
+ const { values, rows, initialSelected } = buildRows({
85
+ serverValues: projects.map(p => p.key),
86
+ labels: projects.map(p => (p.name ? `${p.key} ${s.dim('—')} ${p.name}` : p.key)),
87
+ current,
88
+ preserveMissing,
89
+ s,
90
+ });
91
+
92
+ stream.write(` ${s.dim('Ticket prefixes')} ${s.dim('(projects on this instance)')}\n\n`);
93
+ const picked = await promptMultiSelect(rows, { initialSelected, stream, stdin });
94
+ if (picked === null) return null;
95
+
96
+ const chosen = picked.map(i => values[i]);
97
+ writeConfirmation(chosen, { stream, s });
98
+ return chosen;
99
+ }
100
+
101
+ /**
102
+ * Multi-select over the instance's status names; at least one required.
103
+ *
104
+ * @param {object} opts
105
+ * @param {() => Promise<string[]>} opts.fetchStatuses pre-bound by the caller
106
+ * @param {string[]} [opts.current=[]] pre-checked statuses
107
+ * @param {boolean} [opts.preserveMissing=true]
108
+ * @returns {Promise<string[]|null>} chosen names, or null → free-text fallback
109
+ */
110
+ export async function pickTriageStatuses({ fetchStatuses, current = [], preserveMissing = true, stream = process.stderr, stdin = process.stdin } = {}) {
111
+ const s = createStyler({ isTTY: stream.isTTY });
112
+ if (!stream.isTTY || !stdin.setRawMode) return null;
113
+
114
+ const available = await fetchForPicker(fetchStatuses, { what: 'statuses', stream, s });
115
+ if (available === null || available.length === 0) return null;
116
+
117
+ const { values, rows, initialSelected } = buildRows({
118
+ serverValues: available,
119
+ labels: available,
120
+ current,
121
+ preserveMissing,
122
+ s,
123
+ });
124
+
125
+ stream.write(` ${s.dim('Triage statuses')} ${s.dim('(statuses on this instance)')}\n\n`);
126
+ const picked = await promptMultiSelect(rows, { initialSelected, minSelected: 1, stream, stdin });
127
+ if (picked === null) return null;
128
+
129
+ const chosen = picked.map(i => values[i]);
130
+ writeConfirmation(chosen, { stream, s });
131
+ return chosen;
132
+ }