ticketlens 0.11.0 → 0.12.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/package.json +1 -1
- package/skills/jtb/SKILL.md +1 -1
- package/skills/jtb/scripts/lib/config-wizard.mjs +95 -67
- package/skills/jtb/scripts/lib/init-wizard.mjs +60 -42
- package/skills/jtb/scripts/lib/jira-client.mjs +44 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +1 -2
- package/skills/jtb/scripts/lib/select-prompt.mjs +101 -7
- package/skills/jtb/scripts/lib/wizard-pickers.mjs +132 -0
package/package.json
CHANGED
package/skills/jtb/SKILL.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<!-- jtb-skill-version: 0.
|
|
1
|
+
<!-- jtb-skill-version: 0.12.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.
|
|
@@ -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
|
-
//
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
342
|
+
// ── Triage statuses ────────────────────────────────────────────────────────
|
|
322
343
|
const currentStatuses = profile.triageStatuses?.length
|
|
323
344
|
? profile.triageStatuses
|
|
324
|
-
:
|
|
325
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
);
|
|
358
|
+
if (triageStatuses === null) {
|
|
359
|
+
triageStatuses = currentStatuses;
|
|
360
|
+
const curStatusesStr = currentStatuses.join(', ');
|
|
331
361
|
|
|
332
|
-
|
|
362
|
+
const statusInput = await promptText(
|
|
363
|
+
s.dim('Add triage statuses') + s.dim(` [current: ${curStatusesStr} — Enter to keep]:`),
|
|
364
|
+
{ stream }
|
|
365
|
+
);
|
|
333
366
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
356
|
-
|
|
382
|
+
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
383
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
357
384
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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 —
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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
|
|
3
|
-
* Used by profile-picker.mjs, profile-switcher.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 || !
|
|
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') {
|
|
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
|
+
}
|