ticketlens 0.1.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/LICENSE +21 -0
- package/README.md +457 -0
- package/bin/ticketlens.mjs +376 -0
- package/package.json +37 -0
- package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
- package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
- package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
- package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
- package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
- package/skills/jtb/scripts/lib/ansi.mjs +87 -0
- package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
- package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
- package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
- package/skills/jtb/scripts/lib/banner.mjs +201 -0
- package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
- package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
- package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
- package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
- package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
- package/skills/jtb/scripts/lib/cli.mjs +87 -0
- package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
- package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
- package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
- package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
- package/skills/jtb/scripts/lib/config.mjs +63 -0
- package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
- package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
- package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
- package/skills/jtb/scripts/lib/help.mjs +253 -0
- package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
- package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
- package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
- package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
- package/skills/jtb/scripts/lib/ledger.mjs +96 -0
- package/skills/jtb/scripts/lib/license.mjs +195 -0
- package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
- package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
- package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
- package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
- package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
- package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
- package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
- package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
- package/skills/jtb/scripts/lib/spinner.mjs +44 -0
- package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
- package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
- package/skills/jtb/scripts/lib/sync.mjs +119 -0
- package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
- package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
- package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
- package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
- package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
- package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts code references (file paths, methods, classes, SHAs, branches, etc.)
|
|
3
|
+
* from Jira ticket text (descriptions, comments).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Module-level constants — compiled once, reused across all calls
|
|
7
|
+
const RE_FILE_PATHS = /(?:^|[\s,;(])((?:\/|[a-zA-Z0-9_.-]+\/)[a-zA-Z0-9_.\/-]*\.[a-zA-Z0-9]+)/gm;
|
|
8
|
+
const RE_METHODS = /\b([a-zA-Z_][a-zA-Z0-9_]*)\(\)/g;
|
|
9
|
+
const RE_CLASSES = /\b([A-Z][a-zA-Z0-9]*(?:_[A-Z][a-zA-Z0-9]*)+)\b/g;
|
|
10
|
+
const RE_SHA_FULL = /\b([0-9a-f]{40})\b/g;
|
|
11
|
+
const RE_SHA_SHORT = /\b([0-9a-f]{7})\b/g;
|
|
12
|
+
const RE_SHA_HAS_DIGIT = /[0-9]/;
|
|
13
|
+
const RE_SHA_HAS_ALPHA = /[a-f]/;
|
|
14
|
+
const RE_SVN = /\b(r\d+)\b/g;
|
|
15
|
+
const RE_BRANCHES = /\b((?:feature|bugfix|hotfix|release|fix)\/[a-zA-Z0-9_.-]+-[a-zA-Z0-9_.-]+(?:-[a-zA-Z0-9_.-]+)*)\b/g;
|
|
16
|
+
const RE_NAMESPACES = /\b([A-Z][a-zA-Z0-9]*(?:\\[A-Z][a-zA-Z0-9]*)+)\b/g;
|
|
17
|
+
|
|
18
|
+
export function extractFilePaths(text) {
|
|
19
|
+
if (!text) return [];
|
|
20
|
+
const results = [];
|
|
21
|
+
let match;
|
|
22
|
+
RE_FILE_PATHS.lastIndex = 0;
|
|
23
|
+
while ((match = RE_FILE_PATHS.exec(text)) !== null) {
|
|
24
|
+
results.push(match[1]);
|
|
25
|
+
}
|
|
26
|
+
return [...new Set(results)];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function extractMethodNames(text) {
|
|
30
|
+
if (!text) return [];
|
|
31
|
+
const results = [];
|
|
32
|
+
let match;
|
|
33
|
+
RE_METHODS.lastIndex = 0;
|
|
34
|
+
while ((match = RE_METHODS.exec(text)) !== null) {
|
|
35
|
+
results.push(match[1]);
|
|
36
|
+
}
|
|
37
|
+
return [...new Set(results)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function extractClassNames(text) {
|
|
41
|
+
if (!text) return [];
|
|
42
|
+
// Matches PascalCase names (MyClass) and underscore-separated (Zend_Controller_Action)
|
|
43
|
+
const results = [];
|
|
44
|
+
let match;
|
|
45
|
+
RE_CLASSES.lastIndex = 0;
|
|
46
|
+
while ((match = RE_CLASSES.exec(text)) !== null) {
|
|
47
|
+
results.push(match[1]);
|
|
48
|
+
}
|
|
49
|
+
return [...new Set(results)];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function extractShas(text) {
|
|
53
|
+
if (!text) return [];
|
|
54
|
+
const results = [];
|
|
55
|
+
let match;
|
|
56
|
+
RE_SHA_FULL.lastIndex = 0;
|
|
57
|
+
while ((match = RE_SHA_FULL.exec(text)) !== null) {
|
|
58
|
+
results.push(match[1]);
|
|
59
|
+
}
|
|
60
|
+
// 7-char short SHAs (must contain at least one digit and one letter to avoid false positives)
|
|
61
|
+
RE_SHA_SHORT.lastIndex = 0;
|
|
62
|
+
while ((match = RE_SHA_SHORT.exec(text)) !== null) {
|
|
63
|
+
if (RE_SHA_HAS_DIGIT.test(match[1]) && RE_SHA_HAS_ALPHA.test(match[1])) {
|
|
64
|
+
results.push(match[1]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(results)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function extractSvnRevisions(text) {
|
|
71
|
+
if (!text) return [];
|
|
72
|
+
const results = [];
|
|
73
|
+
let match;
|
|
74
|
+
RE_SVN.lastIndex = 0;
|
|
75
|
+
while ((match = RE_SVN.exec(text)) !== null) {
|
|
76
|
+
results.push(match[1]);
|
|
77
|
+
}
|
|
78
|
+
return [...new Set(results)];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function extractBranches(text) {
|
|
82
|
+
if (!text) return [];
|
|
83
|
+
const results = [];
|
|
84
|
+
let match;
|
|
85
|
+
RE_BRANCHES.lastIndex = 0;
|
|
86
|
+
while ((match = RE_BRANCHES.exec(text)) !== null) {
|
|
87
|
+
results.push(match[1]);
|
|
88
|
+
}
|
|
89
|
+
return [...new Set(results)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function extractNamespaces(text) {
|
|
93
|
+
if (!text) return [];
|
|
94
|
+
const results = [];
|
|
95
|
+
let match;
|
|
96
|
+
RE_NAMESPACES.lastIndex = 0;
|
|
97
|
+
while ((match = RE_NAMESPACES.exec(text)) !== null) {
|
|
98
|
+
results.push(match[1]);
|
|
99
|
+
}
|
|
100
|
+
return [...new Set(results)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function extractCodeReferences(text) {
|
|
104
|
+
return {
|
|
105
|
+
filePaths: extractFilePaths(text),
|
|
106
|
+
methods: extractMethodNames(text),
|
|
107
|
+
classes: extractClassNames(text),
|
|
108
|
+
shas: extractShas(text),
|
|
109
|
+
svnRevisions: extractSvnRevisions(text),
|
|
110
|
+
branches: extractBranches(text),
|
|
111
|
+
namespaces: extractNamespaces(text),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const TICKET_KEY_RE = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
4
|
+
const SPAWN_OPTS = { encoding: 'utf8', timeout: 10_000 };
|
|
5
|
+
|
|
6
|
+
function run(execFn, cmd, args, cwd) {
|
|
7
|
+
const result = execFn(cmd, args, { ...SPAWN_OPTS, cwd });
|
|
8
|
+
return result.status === 0 ? (result.stdout || '') : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function findLinkedCommits(ticketKey, opts = {}) {
|
|
12
|
+
if (!TICKET_KEY_RE.test(ticketKey)) {
|
|
13
|
+
throw new Error(`Invalid ticket key: ${ticketKey}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const execFn = opts.execFn ?? spawnSync;
|
|
17
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
18
|
+
|
|
19
|
+
// git log: last 100 commits, one line each
|
|
20
|
+
const logOut = run(execFn, 'git', ['log', '--oneline', '-100', '--all'], cwd) ?? '';
|
|
21
|
+
const commits = logOut
|
|
22
|
+
.split('\n')
|
|
23
|
+
.filter(line => line.includes(ticketKey))
|
|
24
|
+
.map(line => line.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
|
|
27
|
+
// git branch: all local + remote branches
|
|
28
|
+
const branchOut = run(execFn, 'git', ['branch', '--all'], cwd) ?? '';
|
|
29
|
+
const branches = branchOut
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map(line => line.replace(/^\*?\s+/, '').trim())
|
|
32
|
+
.filter(name => name.includes(ticketKey));
|
|
33
|
+
|
|
34
|
+
// git diff HEAD: current working diff
|
|
35
|
+
const diffOut = run(execFn, 'git', ['diff', 'HEAD'], cwd);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
commits,
|
|
39
|
+
branches,
|
|
40
|
+
diff: diffOut || null,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { isLicensed, showUpgradePrompt } from './license.mjs';
|
|
3
|
+
import { checkUsage, incrementUsage, FREE_LIMIT } from './usage-tracker.mjs';
|
|
4
|
+
import { extractRequirements } from './requirement-extractor.mjs';
|
|
5
|
+
import { findLinkedCommits } from './commit-linker.mjs';
|
|
6
|
+
import { analyzeDiff } from './diff-analyzer.mjs';
|
|
7
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
8
|
+
import { appendLedger } from './ledger.mjs';
|
|
9
|
+
|
|
10
|
+
const STATUS_ICON = { FOUND: '✔', PARTIAL: '~', NOT_FOUND: '✖' };
|
|
11
|
+
|
|
12
|
+
function formatReport({ ticketKey, requirements, analysis, usage, isPro }) {
|
|
13
|
+
const { results, coveragePercent } = analysis;
|
|
14
|
+
const lines = [
|
|
15
|
+
'',
|
|
16
|
+
` Compliance Check — ${ticketKey}`,
|
|
17
|
+
` ${'─'.repeat(50)}`,
|
|
18
|
+
'',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
if (requirements.length === 0) {
|
|
22
|
+
lines.push(' No acceptance criteria found in ticket description.');
|
|
23
|
+
lines.push(' Add a "Acceptance Criteria" section or Given/When/Then statements.');
|
|
24
|
+
lines.push('');
|
|
25
|
+
return lines.join('\n');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const { requirement, status, evidence } of results) {
|
|
29
|
+
const icon = STATUS_ICON[status] ?? '?';
|
|
30
|
+
lines.push(` ${icon} ${requirement}`);
|
|
31
|
+
if (evidence) lines.push(` └─ ${evidence}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push(` Coverage: ${coveragePercent}% (${results.filter(r => r.status === 'FOUND').length}/${results.length} requirements found)`);
|
|
36
|
+
lines.push('');
|
|
37
|
+
|
|
38
|
+
if (!isPro) {
|
|
39
|
+
const remaining = FREE_LIMIT - (usage.count + 1); // +1 = this check (already incremented)
|
|
40
|
+
lines.push(` Free tier: ${remaining} compliance check${remaining !== 1 ? 's' : ''} remaining this month.`);
|
|
41
|
+
lines.push(' Upgrade to Pro for unlimited checks.');
|
|
42
|
+
lines.push('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function runComplianceCheck({
|
|
49
|
+
brief,
|
|
50
|
+
ticketKey,
|
|
51
|
+
configDir = DEFAULT_CONFIG_DIR,
|
|
52
|
+
stream = process.stderr,
|
|
53
|
+
isLicensedFn = isLicensed,
|
|
54
|
+
showUpgradeFn = showUpgradePrompt,
|
|
55
|
+
checkUsageFn = checkUsage,
|
|
56
|
+
incrementUsageFn = incrementUsage,
|
|
57
|
+
extractRequirementsFn = extractRequirements,
|
|
58
|
+
findLinkedCommitsFn = findLinkedCommits,
|
|
59
|
+
analyzeDiffFn = analyzeDiff,
|
|
60
|
+
appendLedgerFn = appendLedger,
|
|
61
|
+
execFn = spawnSync,
|
|
62
|
+
}) {
|
|
63
|
+
const isPro = isLicensedFn('pro', configDir);
|
|
64
|
+
const usage = checkUsageFn(configDir);
|
|
65
|
+
|
|
66
|
+
if (!isPro && !usage.canUse) {
|
|
67
|
+
showUpgradeFn('pro', '--compliance', { stream });
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
incrementUsageFn(configDir);
|
|
72
|
+
|
|
73
|
+
const requirements = extractRequirementsFn(brief);
|
|
74
|
+
const { diff } = findLinkedCommitsFn(ticketKey, { cwd: process.cwd() });
|
|
75
|
+
const analysis = analyzeDiffFn(requirements, diff);
|
|
76
|
+
|
|
77
|
+
const report = formatReport({ ticketKey, requirements, analysis, usage, isPro });
|
|
78
|
+
const coveragePercent = analysis.coveragePercent;
|
|
79
|
+
const missing = analysis.results
|
|
80
|
+
.filter(r => r.status === 'NOT_FOUND')
|
|
81
|
+
.map(r => r.requirement);
|
|
82
|
+
|
|
83
|
+
if (isPro) {
|
|
84
|
+
const gitEmail = execFn('git', ['config', 'user.email'], { encoding: 'utf8' }).stdout?.trim() ?? 'unknown';
|
|
85
|
+
appendLedgerFn(
|
|
86
|
+
{ ticketKey, commitSha: 'HEAD', author: gitEmail || 'unknown', coverage: coveragePercent, missing },
|
|
87
|
+
{ configDir, isPro }
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { report, coveragePercent, noCriteria: requirements.length === 0 };
|
|
92
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticketlens config — Edit settings for an existing profile.
|
|
3
|
+
* Pre-populates every field with the current value; Enter keeps it unchanged.
|
|
4
|
+
* Triage statuses use merge semantics: new entries are added, existing ones preserved.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { createStyler } from './ansi.mjs';
|
|
11
|
+
import { createSession } from './banner.mjs';
|
|
12
|
+
import { classifyError } from './error-classifier.mjs';
|
|
13
|
+
import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
|
|
14
|
+
import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.mjs';
|
|
15
|
+
import { promptSelect } from './select-prompt.mjs';
|
|
16
|
+
import { parseAge } from './cache-manager.mjs';
|
|
17
|
+
import { DEFAULT_BRIEF_TTL } from './brief-cache.mjs';
|
|
18
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
19
|
+
import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
|
|
20
|
+
import { isLicensed } from './license.mjs';
|
|
21
|
+
|
|
22
|
+
const RETRY_OPTIONS = [
|
|
23
|
+
{ label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
|
|
24
|
+
{ label: 'Edit credentials', sublabel: 'Change email / token', value: 'creds' },
|
|
25
|
+
{ label: 'Edit from URL', sublabel: 'Change URL, auth type, or credentials', value: 'url' },
|
|
26
|
+
{ label: 'Skip', sublabel: 'Abandon connection changes — no changes saved', value: 'skip' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {}) {
|
|
32
|
+
const stream = process.stderr;
|
|
33
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
34
|
+
const bc = s.cyan;
|
|
35
|
+
|
|
36
|
+
const config = loadProfiles(configDir);
|
|
37
|
+
if (!config || Object.keys(config.profiles).length === 0) {
|
|
38
|
+
stream.write(` ${s.red('✖')} No profiles configured. Run ${s.cyan('ticketlens init')} first.\n`);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target = profileName || config.default || Object.keys(config.profiles)[0];
|
|
44
|
+
const profile = config.profiles[target];
|
|
45
|
+
if (!profile) {
|
|
46
|
+
stream.write(` ${s.red('✖')} Profile "${target}" not found.\n`);
|
|
47
|
+
const names = Object.keys(config.profiles);
|
|
48
|
+
stream.write(` ${s.dim('Available:')} ${names.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const creds = loadCredentials(configDir);
|
|
54
|
+
const profileCreds = creds[target] || {};
|
|
55
|
+
const hostname = (() => { try { return new URL(profile.baseUrl).hostname; } catch { return profile.baseUrl; } })();
|
|
56
|
+
|
|
57
|
+
// Header box
|
|
58
|
+
const headerLines = [
|
|
59
|
+
`Editing profile ${s.bold(s.cyan(`"${target}"`))}`,
|
|
60
|
+
`${s.dim('Server:')} ${hostname}`,
|
|
61
|
+
];
|
|
62
|
+
const innerWidth = headerLines.reduce((max, l) => Math.max(max, visLen(l)), 0) + 4;
|
|
63
|
+
stream.write('\n');
|
|
64
|
+
stream.write(bc('╭' + '─'.repeat(innerWidth) + '╮') + '\n');
|
|
65
|
+
for (const line of headerLines) {
|
|
66
|
+
const pad = innerWidth - visLen(line) - 1;
|
|
67
|
+
stream.write(bc('│') + ' ' + line + ' '.repeat(Math.max(0, pad)) + bc('│') + '\n');
|
|
68
|
+
}
|
|
69
|
+
stream.write(bc('╰' + '─'.repeat(innerWidth) + '╯') + '\n');
|
|
70
|
+
|
|
71
|
+
// ── Connection ─────────────────────────────────────────────────────────────
|
|
72
|
+
stream.write(`\n ${s.dim('──── Connection ────')}\n\n`);
|
|
73
|
+
|
|
74
|
+
// Working copies — mutated by the retry loop if the user edits
|
|
75
|
+
let url = profile.baseUrl;
|
|
76
|
+
let auth = profile.auth;
|
|
77
|
+
let email = profile.email || '';
|
|
78
|
+
const existingToken = profileCreds.pat || profileCreds.apiToken || '';
|
|
79
|
+
let token = existingToken;
|
|
80
|
+
|
|
81
|
+
// ── URL ───────────────────────────────────────────────────────────────────
|
|
82
|
+
const urlTyped = await promptText(
|
|
83
|
+
s.dim('Jira URL') + s.dim(` [current: ${profile.baseUrl}]:`),
|
|
84
|
+
{ stream, defaultValue: profile.baseUrl }
|
|
85
|
+
);
|
|
86
|
+
if (urlTyped !== profile.baseUrl) {
|
|
87
|
+
// Auto-prefix https:// when the user omits the protocol
|
|
88
|
+
url = /^https?:\/\//i.test(urlTyped)
|
|
89
|
+
? urlTyped.replace(/\/$/, '')
|
|
90
|
+
: `https://${urlTyped.replace(/\/$/, '')}`;
|
|
91
|
+
if (url !== urlTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Auth type ─────────────────────────────────────────────────────────────
|
|
95
|
+
const isCloud = /\.atlassian\.net(\/|$)/i.test(url);
|
|
96
|
+
if (isCloud && auth !== 'cloud') {
|
|
97
|
+
auth = 'cloud';
|
|
98
|
+
stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n`);
|
|
99
|
+
} else if (!isCloud) {
|
|
100
|
+
const currentIdx = SERVER_AUTH_TYPES.findIndex(a => a.value === auth);
|
|
101
|
+
stream.write(`\n ${s.dim('Auth type:')}\n\n`);
|
|
102
|
+
const authIdx = await promptSelect(SERVER_AUTH_TYPES, {
|
|
103
|
+
stream,
|
|
104
|
+
hint: '↑/↓ select Enter confirm',
|
|
105
|
+
initialIndex: Math.max(0, currentIdx),
|
|
106
|
+
});
|
|
107
|
+
if (authIdx !== null) auth = SERVER_AUTH_TYPES[authIdx].value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Email / username ──────────────────────────────────────────────────────
|
|
111
|
+
if (auth === 'cloud' || auth === 'basic') {
|
|
112
|
+
const emailHint = email ? s.dim(` [current: ${email}]`) : '';
|
|
113
|
+
const emailLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + emailHint + s.dim(':');
|
|
114
|
+
email = await promptText(emailLabel, {
|
|
115
|
+
stream,
|
|
116
|
+
defaultValue: email,
|
|
117
|
+
validate: (v) => {
|
|
118
|
+
if (!v) return 'Cannot be empty.';
|
|
119
|
+
if (auth === 'cloud' && !v.includes('@')) return 'Enter a valid email address.';
|
|
120
|
+
return null;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Token / PAT / password ────────────────────────────────────────────────
|
|
126
|
+
const tokenHint = existingToken ? s.dim(' [keep existing]') : '';
|
|
127
|
+
const tokenLabel = (auth === 'cloud'
|
|
128
|
+
? s.dim('API token')
|
|
129
|
+
: auth === 'pat'
|
|
130
|
+
? s.dim('Personal access token')
|
|
131
|
+
: s.dim('Password')) + tokenHint + s.dim(':');
|
|
132
|
+
token = await promptSecret(tokenLabel, { stream, existingValue: existingToken });
|
|
133
|
+
const tokenChanged = token !== existingToken;
|
|
134
|
+
|
|
135
|
+
// ── Connection test (only when something changed) ─────────────────────────
|
|
136
|
+
const connectionChanged = url !== profile.baseUrl
|
|
137
|
+
|| auth !== profile.auth
|
|
138
|
+
|| email !== (profile.email || '')
|
|
139
|
+
|| tokenChanged;
|
|
140
|
+
|
|
141
|
+
let connected = !connectionChanged;
|
|
142
|
+
|
|
143
|
+
if (connectionChanged) {
|
|
144
|
+
let startFrom = 'test';
|
|
145
|
+
|
|
146
|
+
setupLoop: while (true) {
|
|
147
|
+
// Re-prompt URL + auth
|
|
148
|
+
if (startFrom === 'url') {
|
|
149
|
+
stream.write('\n');
|
|
150
|
+
const reTyped = await promptText(
|
|
151
|
+
s.dim('Jira URL') + s.dim(` [current: ${url}]:`),
|
|
152
|
+
{ stream, defaultValue: url }
|
|
153
|
+
);
|
|
154
|
+
if (reTyped !== url) {
|
|
155
|
+
url = /^https?:\/\//i.test(reTyped)
|
|
156
|
+
? reTyped.replace(/\/$/, '')
|
|
157
|
+
: `https://${reTyped.replace(/\/$/, '')}`;
|
|
158
|
+
if (url !== reTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
|
|
159
|
+
}
|
|
160
|
+
const reCloud = /\.atlassian\.net(\/|$)/i.test(url);
|
|
161
|
+
if (reCloud) {
|
|
162
|
+
auth = 'cloud';
|
|
163
|
+
stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n\n`);
|
|
164
|
+
} else if (auth === 'cloud') {
|
|
165
|
+
stream.write(`\n ${s.dim('Auth type:')}\n\n`);
|
|
166
|
+
const idx = await promptSelect(SERVER_AUTH_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
|
|
167
|
+
if (idx !== null) auth = SERVER_AUTH_TYPES[idx].value;
|
|
168
|
+
}
|
|
169
|
+
startFrom = 'creds';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Re-prompt email + token (pre-populated)
|
|
173
|
+
if (startFrom === 'creds') {
|
|
174
|
+
if (auth === 'cloud' || auth === 'basic') {
|
|
175
|
+
const eHint = email ? s.dim(` [current: ${email}]`) : '';
|
|
176
|
+
const eLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + eHint + s.dim(':');
|
|
177
|
+
email = await promptText(eLabel, {
|
|
178
|
+
stream,
|
|
179
|
+
defaultValue: email,
|
|
180
|
+
validate: (v) => {
|
|
181
|
+
if (!v) return 'Cannot be empty.';
|
|
182
|
+
if (auth === 'cloud' && !v.includes('@')) return 'Enter a valid email address.';
|
|
183
|
+
return null;
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const tHint = token ? s.dim(' [keep existing]') : '';
|
|
188
|
+
const tLabel = (auth === 'cloud' ? s.dim('API token') : auth === 'pat' ? s.dim('Personal access token') : s.dim('Password')) + tHint + s.dim(':');
|
|
189
|
+
token = await promptSecret(tLabel, { stream, existingValue: token });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Test connection
|
|
193
|
+
const testEnv = {
|
|
194
|
+
JIRA_BASE_URL: url,
|
|
195
|
+
JIRA_EMAIL: email,
|
|
196
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
197
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
198
|
+
};
|
|
199
|
+
const testVersion = auth === 'cloud' ? 3 : 2;
|
|
200
|
+
const session = createSession({ baseUrl: url, profileName: target, email: email || undefined, pat: auth === 'pat' ? token : undefined }, { stream });
|
|
201
|
+
stream.write('\n');
|
|
202
|
+
session.spin('Testing connection...');
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await fetchCurrentUser({ env: testEnv, apiVersion: testVersion });
|
|
206
|
+
session.connected();
|
|
207
|
+
connected = true;
|
|
208
|
+
break setupLoop;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
session.failed();
|
|
211
|
+
const classified = classifyError(err, { baseUrl: url, profileName: target });
|
|
212
|
+
session.footer(classified.message, 'error', classified.hint);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
|
|
216
|
+
const retryIdx = await promptSelect(RETRY_OPTIONS, { stream, hint: '↑/↓ select Enter confirm' });
|
|
217
|
+
if (retryIdx === null || RETRY_OPTIONS[retryIdx].value === 'skip') break setupLoop;
|
|
218
|
+
startFrom = RETRY_OPTIONS[retryIdx].value;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!connected) {
|
|
222
|
+
stream.write(`\n ${s.yellow('○')} Connection changes discarded. ${s.dim('Run')} ${s.cyan('ticketlens config')} ${s.dim('again to retry.')}\n\n`);
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Optional settings ──────────────────────────────────────────────────────
|
|
229
|
+
stream.write(`\n ${s.dim('──── Optional ────')}\n\n`);
|
|
230
|
+
|
|
231
|
+
// Ticket prefixes
|
|
232
|
+
const curPrefixes = (profile.ticketPrefixes || []).join(', ');
|
|
233
|
+
const prefixInput = await promptText(
|
|
234
|
+
s.dim('Ticket prefixes') + s.dim(curPrefixes ? ` [current: ${curPrefixes}]:` : ' (e.g. PROJ,OPS — press Enter to skip):'),
|
|
235
|
+
{ stream }
|
|
236
|
+
);
|
|
237
|
+
const existing = new Set(profile.ticketPrefixes || []);
|
|
238
|
+
if (prefixInput) {
|
|
239
|
+
for (const v of prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)) {
|
|
240
|
+
existing.add(v);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const ticketPrefixes = [...existing];
|
|
244
|
+
|
|
245
|
+
// Project path (single — used for auto-profile detection from cwd)
|
|
246
|
+
const home = homedir();
|
|
247
|
+
const cwd = process.cwd();
|
|
248
|
+
const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
|
|
249
|
+
const curPath = (profile.projectPaths || [])[0] || '';
|
|
250
|
+
const pathInput = await promptText(
|
|
251
|
+
s.dim('Project path') + s.dim(curPath ? ` [current: ${curPath}]:` : ` [${cwdDisplay}]:`),
|
|
252
|
+
{ stream }
|
|
253
|
+
);
|
|
254
|
+
const rawPath = (pathInput.trim() || curPath || cwdDisplay).replace(/\/+$/, '');
|
|
255
|
+
|
|
256
|
+
const projectPaths = [];
|
|
257
|
+
if (rawPath) {
|
|
258
|
+
const expanded = rawPath.startsWith('~') ? join(home, rawPath.slice(1)) : rawPath;
|
|
259
|
+
if (existsSync(expanded)) {
|
|
260
|
+
projectPaths.push(rawPath);
|
|
261
|
+
stream.write(` ${s.green('✔')} ${rawPath}\n`);
|
|
262
|
+
} else {
|
|
263
|
+
stream.write(` ${s.yellow('○')} ${s.dim(rawPath)} — directory not found\n`);
|
|
264
|
+
const doCreate = await promptYN(`Create ${rawPath}?`, { stream });
|
|
265
|
+
if (doCreate) {
|
|
266
|
+
try {
|
|
267
|
+
mkdirSync(expanded, { recursive: true });
|
|
268
|
+
projectPaths.push(rawPath);
|
|
269
|
+
stream.write(` ${s.green('✔')} Created\n`);
|
|
270
|
+
} catch (mkErr) {
|
|
271
|
+
stream.write(` ${s.red('✖')} Could not create: ${mkErr.message}\n`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Triage statuses (merge semantics) ────────────────────────────────────
|
|
278
|
+
// Typing new values ADDS them to the current list (with partial matching).
|
|
279
|
+
// Existing statuses are never removed by this prompt.
|
|
280
|
+
const currentStatuses = profile.triageStatuses?.length
|
|
281
|
+
? profile.triageStatuses
|
|
282
|
+
: ['In Progress', 'Code Review', 'QA'];
|
|
283
|
+
const curStatusesStr = currentStatuses.join(', ');
|
|
284
|
+
|
|
285
|
+
const statusInput = await promptText(
|
|
286
|
+
s.dim('Add triage statuses') + s.dim(` [current: ${curStatusesStr} — Enter to keep]:`),
|
|
287
|
+
{ stream }
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
let triageStatuses = currentStatuses;
|
|
291
|
+
|
|
292
|
+
if (statusInput) {
|
|
293
|
+
const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
|
|
294
|
+
const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
|
|
295
|
+
// Only validate statuses that aren't already in the list
|
|
296
|
+
const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
|
|
297
|
+
|
|
298
|
+
if (toValidate.length > 0) {
|
|
299
|
+
const validateEnv = {
|
|
300
|
+
JIRA_BASE_URL: url,
|
|
301
|
+
JIRA_EMAIL: email,
|
|
302
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
303
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
304
|
+
};
|
|
305
|
+
const validateApiVersion = auth === 'cloud' ? 3 : 2;
|
|
306
|
+
|
|
307
|
+
stream.write(` ${s.dim('Validating new statuses...')}\n`);
|
|
308
|
+
try {
|
|
309
|
+
const available = await fetchStatuses({ env: validateEnv, apiVersion: validateApiVersion });
|
|
310
|
+
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
311
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
312
|
+
|
|
313
|
+
const toAdd = [];
|
|
314
|
+
for (const name of toValidate) {
|
|
315
|
+
if (available.includes(name)) {
|
|
316
|
+
toAdd.push(name);
|
|
317
|
+
stream.write(` ${s.green('✔')} ${name}\n`);
|
|
318
|
+
} else {
|
|
319
|
+
// Case-insensitive exact match
|
|
320
|
+
const exact = lowerMap.get(name.toLowerCase());
|
|
321
|
+
if (exact) {
|
|
322
|
+
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
|
|
323
|
+
toAdd.push(exact);
|
|
324
|
+
} else {
|
|
325
|
+
// Partial match: "QA" → "QA Testing"
|
|
326
|
+
const partial = available.find(a =>
|
|
327
|
+
a.toLowerCase().includes(name.toLowerCase()) ||
|
|
328
|
+
name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
|
|
329
|
+
);
|
|
330
|
+
if (partial) {
|
|
331
|
+
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(partial)}\n`);
|
|
332
|
+
toAdd.push(partial);
|
|
333
|
+
} else {
|
|
334
|
+
stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found — skipped)')}\n`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
triageStatuses = [...currentStatuses, ...toAdd];
|
|
341
|
+
if (toAdd.length > 0) {
|
|
342
|
+
stream.write(` ${s.dim('Updated list:')} ${triageStatuses.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
346
|
+
// Jira unreachable — add without validation, deduped
|
|
347
|
+
const toAddRaw = toValidate.filter(n => !existingLower.has(n.toLowerCase()));
|
|
348
|
+
triageStatuses = [...currentStatuses, ...toAddRaw];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── Cache TTL (Pro feature) ───────────────────────────────────────────────
|
|
354
|
+
let cacheTtl = DEFAULT_BRIEF_TTL;
|
|
355
|
+
if (isLicensed('pro', configDir)) {
|
|
356
|
+
const curTtl = profile.cacheTtl || DEFAULT_BRIEF_TTL;
|
|
357
|
+
const ttlInput = await promptText(
|
|
358
|
+
s.dim('Brief cache TTL') + s.dim(` [current: ${curTtl} — e.g. 4h, 7d, 30d, 0 to disable — Enter to keep]:`),
|
|
359
|
+
{ stream, validate: (v) => {
|
|
360
|
+
if (!v || v === curTtl) return null;
|
|
361
|
+
if (v === '0') return null;
|
|
362
|
+
return parseAge(v) === null ? 'Use a number followed by h, d, w, m, or y (e.g. 4h, 7d, 30d)' : null;
|
|
363
|
+
} }
|
|
364
|
+
);
|
|
365
|
+
cacheTtl = ttlInput || curTtl;
|
|
366
|
+
} else {
|
|
367
|
+
stream.write(` ${s.dim('○')} Brief cache TTL: ${s.dim('4h')} ${s.dim('·')} ${s.cyan('Pro')} ${s.dim('unlocks configurable TTL → ticketlens.dev/pricing')}\n`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Save ───────────────────────────────────────────────────────────────────
|
|
371
|
+
const updated = {
|
|
372
|
+
...profile,
|
|
373
|
+
baseUrl: url,
|
|
374
|
+
auth,
|
|
375
|
+
triageStatuses,
|
|
376
|
+
};
|
|
377
|
+
if (email) updated.email = email;
|
|
378
|
+
if (ticketPrefixes.length > 0) updated.ticketPrefixes = ticketPrefixes;
|
|
379
|
+
else delete updated.ticketPrefixes;
|
|
380
|
+
if (projectPaths.length > 0) updated.projectPaths = projectPaths;
|
|
381
|
+
else delete updated.projectPaths;
|
|
382
|
+
if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
|
|
383
|
+
else delete updated.cacheTtl;
|
|
384
|
+
|
|
385
|
+
// Only write credentials file if the token actually changed
|
|
386
|
+
const credData = (token !== existingToken)
|
|
387
|
+
? (auth === 'pat' ? { pat: token } : { apiToken: token })
|
|
388
|
+
: {};
|
|
389
|
+
|
|
390
|
+
saveProfile(target, updated, credData, configDir);
|
|
391
|
+
stream.write(`\n ${s.green('✔')} Profile ${s.bold(s.cyan(`"${target}"`))} updated.\n\n`);
|
|
392
|
+
}
|