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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git hook installer for ticketlens compliance gate.
|
|
3
|
+
* Installs a pre-push hook that blocks pushes when compliance coverage
|
|
4
|
+
* is below the configured threshold.
|
|
5
|
+
*
|
|
6
|
+
* Named exports only. Injectable fsModule and platform for testability.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
const GUARD = '# ticketlens-compliance-gate';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate the sh script content for the pre-push hook.
|
|
16
|
+
*
|
|
17
|
+
* @param {{ threshold?: number }} [opts]
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function generateHookScript({ threshold = 80 } = {}) {
|
|
21
|
+
// Coerce to bounded integer to prevent shell injection via the threshold param
|
|
22
|
+
const safeThreshold = Math.max(0, Math.min(100, Math.floor(Number(threshold))));
|
|
23
|
+
if (!Number.isFinite(safeThreshold)) {
|
|
24
|
+
throw new Error('threshold must be a number between 0 and 100');
|
|
25
|
+
}
|
|
26
|
+
threshold = safeThreshold;
|
|
27
|
+
return [
|
|
28
|
+
'#!/bin/sh',
|
|
29
|
+
GUARD,
|
|
30
|
+
'BRANCH=$(git symbolic-ref HEAD 2>/dev/null | sed \'s|refs/heads/||\')',
|
|
31
|
+
'KEY=$(echo "$BRANCH" | grep -oE \'[A-Z][A-Z0-9]+-[0-9]+\' | head -1)',
|
|
32
|
+
'[ -z "$KEY" ] && exit 0',
|
|
33
|
+
`ticketlens compliance "$KEY" || { echo "Push blocked: compliance < ${threshold}% for $KEY"; exit 1; }`,
|
|
34
|
+
].join('\n') + '\n';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Install the compliance gate as a git pre-push hook.
|
|
39
|
+
*
|
|
40
|
+
* @param {{
|
|
41
|
+
* cwd?: string,
|
|
42
|
+
* threshold?: number,
|
|
43
|
+
* fsModule?: typeof import('node:fs'),
|
|
44
|
+
* platform?: string,
|
|
45
|
+
* }} [opts]
|
|
46
|
+
* @returns {{ installed: true, path: string } | { skipped: true, reason: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function installHook({ cwd = process.cwd(), threshold = 80, fsModule = fs, platform = process.platform } = {}) {
|
|
49
|
+
if (platform === 'win32') {
|
|
50
|
+
return { skipped: true, reason: 'Not supported on Windows' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hooksDir = join(cwd, '.git', 'hooks');
|
|
54
|
+
if (!fsModule.existsSync(hooksDir)) {
|
|
55
|
+
throw new Error(`.git/hooks/ directory not found at ${hooksDir}. Is this a git repository?`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hookPath = join(hooksDir, 'pre-push');
|
|
59
|
+
|
|
60
|
+
// Read existing content — empty string when file is absent
|
|
61
|
+
let existing = '';
|
|
62
|
+
try {
|
|
63
|
+
existing = fsModule.readFileSync(hookPath, 'utf8');
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.code !== 'ENOENT') throw err;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Idempotency: skip append if guard is already present
|
|
69
|
+
if (!existing.includes(GUARD)) {
|
|
70
|
+
const block = '\n' + generateHookScript({ threshold });
|
|
71
|
+
fsModule.writeFileSync(hookPath, existing + block, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
fsModule.chmodSync(hookPath, 0o755);
|
|
75
|
+
|
|
76
|
+
// Write .ticketlens-hooks.json in cwd
|
|
77
|
+
const configPath = join(cwd, '.ticketlens-hooks.json');
|
|
78
|
+
fsModule.writeFileSync(configPath, JSON.stringify({ complianceThreshold: threshold }) + '\n', 'utf8');
|
|
79
|
+
|
|
80
|
+
return { installed: true, path: hookPath };
|
|
81
|
+
}
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ticketlens init — Interactive setup wizard.
|
|
3
|
+
* Guides the user through configuring one or more Jira profiles.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
9
|
+
import { createStyler } from './ansi.mjs';
|
|
10
|
+
import { createSession } from './banner.mjs';
|
|
11
|
+
import { classifyError } from './error-classifier.mjs';
|
|
12
|
+
import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
|
|
13
|
+
import { loadProfiles, saveProfile, saveDefault } from './profile-resolver.mjs';
|
|
14
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
15
|
+
import { promptSelect } from './select-prompt.mjs';
|
|
16
|
+
import { runSwitch } from './profile-switcher.mjs';
|
|
17
|
+
import { DEFAULT_CONFIG_DIR } from './config.mjs';
|
|
18
|
+
import { visLen, SERVER_AUTH_TYPES, promptText, promptSecret, promptYN } from './prompt-helpers.mjs';
|
|
19
|
+
|
|
20
|
+
const RETRY_OPTIONS = [
|
|
21
|
+
{ label: 'Retry', sublabel: 'Try again — same credentials (e.g. VPN just connected)', value: 'retry' },
|
|
22
|
+
{ label: 'Edit credentials', sublabel: 'Change email / token', value: 'creds' },
|
|
23
|
+
{ label: 'Edit from URL', sublabel: 'Change URL, auth type, or credentials', value: 'url' },
|
|
24
|
+
{ label: 'Skip this profile', sublabel: 'Abandon — move to next step', value: 'skip' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// ── Protocol probe ────────────────────────────────────────────────────────────
|
|
28
|
+
// When the user types a bare hostname (no https:// or http://), try https first
|
|
29
|
+
// then http. Any HTTP response (even 401) means the server is reachable there.
|
|
30
|
+
|
|
31
|
+
async function probeProtocol(host, { stream, s }) {
|
|
32
|
+
for (const scheme of ['https', 'http']) {
|
|
33
|
+
const url = `${scheme}://${host}`;
|
|
34
|
+
stream.write(` ${s.dim(`○ Probing ${url}...`)}\n`);
|
|
35
|
+
try {
|
|
36
|
+
await globalThis.fetch(`${url}/rest/api/2/serverInfo`, {
|
|
37
|
+
signal: AbortSignal.timeout(5000),
|
|
38
|
+
});
|
|
39
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
40
|
+
stream.write(` ${s.green('✔')} Using ${url}\n`);
|
|
41
|
+
return url;
|
|
42
|
+
} catch {
|
|
43
|
+
stream.write('\x1b[A\r\x1b[2K');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Both unreachable — default to https and let the connection test surface the error
|
|
47
|
+
const fallback = `https://${host}`;
|
|
48
|
+
stream.write(` ${s.yellow('~')} Could not probe server — will try ${fallback}\n`);
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Main wizard ───────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export async function run({ configDir = DEFAULT_CONFIG_DIR } = {}) {
|
|
55
|
+
const stream = process.stderr;
|
|
56
|
+
const s = createStyler({ isTTY: stream.isTTY });
|
|
57
|
+
|
|
58
|
+
// Ensure cursor is restored and stdin is clean on Ctrl+C at any point
|
|
59
|
+
function onSigint() {
|
|
60
|
+
stream.write('\x1b[?25h'); // restore cursor
|
|
61
|
+
if (process.stdin.isRaw) process.stdin.setRawMode(false);
|
|
62
|
+
stream.write('\n');
|
|
63
|
+
process.exit(130);
|
|
64
|
+
}
|
|
65
|
+
process.on('SIGINT', onSigint);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await _run({ configDir, stream, s });
|
|
69
|
+
} finally {
|
|
70
|
+
process.removeListener('SIGINT', onSigint);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function _run({ configDir, stream, s }) {
|
|
75
|
+
// Welcome box
|
|
76
|
+
const headerLines = [
|
|
77
|
+
`${s.bold(s.cyan('◆ TicketLens'))} — Setup Wizard`,
|
|
78
|
+
`${s.dim("Let's configure your tracker connection.")}`,
|
|
79
|
+
];
|
|
80
|
+
const innerWidth = headerLines.reduce((max, l) => Math.max(max, visLen(l)), 0) + 4;
|
|
81
|
+
const bc = s.cyan;
|
|
82
|
+
stream.write('\n');
|
|
83
|
+
stream.write(bc('╭' + '─'.repeat(innerWidth) + '╮') + '\n');
|
|
84
|
+
for (const line of headerLines) {
|
|
85
|
+
const pad = innerWidth - visLen(line) - 1;
|
|
86
|
+
stream.write(bc('│') + ' ' + line + ' '.repeat(Math.max(0, pad)) + bc('│') + '\n');
|
|
87
|
+
}
|
|
88
|
+
stream.write(bc('╰' + '─'.repeat(innerWidth) + '╯') + '\n');
|
|
89
|
+
|
|
90
|
+
let addedCount = 0;
|
|
91
|
+
let addAnother = true;
|
|
92
|
+
|
|
93
|
+
while (addAnother) {
|
|
94
|
+
stream.write('\n');
|
|
95
|
+
|
|
96
|
+
// ── Profile name ──────────────────────────────────────────────────────────
|
|
97
|
+
const profileName = await promptText(s.dim('Profile name') + s.dim(' (e.g. work, acme):'), {
|
|
98
|
+
stream,
|
|
99
|
+
validate: (v) => {
|
|
100
|
+
if (!v) return 'Profile name cannot be empty.';
|
|
101
|
+
if (!/^[a-z0-9_-]+$/.test(v)) return 'Use lowercase letters, numbers, hyphens, and underscores only.';
|
|
102
|
+
const current = loadProfiles(configDir);
|
|
103
|
+
if (current && current.profiles[v]) return `Profile "${v}" already exists. Choose a different name.`;
|
|
104
|
+
return null;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── Tracker type ──────────────────────────────────────────────────────────
|
|
109
|
+
const TRACKER_TYPES = [
|
|
110
|
+
{ label: 'Jira', sublabel: 'Jira Cloud, Server, or Data Center', value: 'jira' },
|
|
111
|
+
{ label: 'GitHub', sublabel: 'GitHub Issues (github.com)', value: 'github' },
|
|
112
|
+
];
|
|
113
|
+
stream.write(`\n ${s.dim('Tracker type:')}\n\n`);
|
|
114
|
+
const trackerIndex = await promptSelect(TRACKER_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
|
|
115
|
+
if (trackerIndex === null) {
|
|
116
|
+
stream.write(` ${s.dim('Cancelled.')}\n`);
|
|
117
|
+
addAnother = await promptYN('Configure another connection?', { stream });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const trackerType = TRACKER_TYPES[trackerIndex].value;
|
|
121
|
+
stream.write(` ${s.green('✔')} ${TRACKER_TYPES[trackerIndex].label}\n`);
|
|
122
|
+
|
|
123
|
+
let connected = false;
|
|
124
|
+
|
|
125
|
+
if (trackerType === 'github') {
|
|
126
|
+
let ghUrl = '', ghToken = '';
|
|
127
|
+
|
|
128
|
+
githubLoop: while (true) {
|
|
129
|
+
stream.write(`\n ${s.dim('Repository URL')}\n\n`);
|
|
130
|
+
const typed = await promptText(
|
|
131
|
+
s.dim('Repo URL') + s.dim(' (e.g. https://github.com/acme/widgets):'),
|
|
132
|
+
{
|
|
133
|
+
stream,
|
|
134
|
+
defaultValue: ghUrl,
|
|
135
|
+
validate: (v) => {
|
|
136
|
+
if (!v) return 'URL cannot be empty.';
|
|
137
|
+
if (!/github\.com\/[^/]+\/[^/]+/.test(v)) return 'Must be a GitHub repo URL — e.g. https://github.com/acme/widgets';
|
|
138
|
+
return null;
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
ghUrl = typed.replace(/\/$/, '');
|
|
143
|
+
stream.write(` ${s.green('✔')} ${ghUrl}\n`);
|
|
144
|
+
|
|
145
|
+
const tokenHint = ghToken ? s.dim(' [keep existing]') : '';
|
|
146
|
+
ghToken = await promptSecret(
|
|
147
|
+
s.dim('Personal access token') + tokenHint + s.dim(':'),
|
|
148
|
+
{ stream, existingValue: ghToken }
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const ghConn = { baseUrl: ghUrl, apiToken: ghToken, ticketPrefixes: ['GH'] };
|
|
152
|
+
const ghSession = createSession({ baseUrl: ghUrl, profileName }, { stream });
|
|
153
|
+
stream.write('\n');
|
|
154
|
+
ghSession.spin('Testing connection...');
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const ghAdapter = resolveAdapter(ghConn);
|
|
158
|
+
await ghAdapter.fetchCurrentUser();
|
|
159
|
+
ghSession.connected();
|
|
160
|
+
connected = true;
|
|
161
|
+
break githubLoop;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
ghSession.failed();
|
|
164
|
+
const classified = classifyError(err, { baseUrl: ghUrl, profileName });
|
|
165
|
+
ghSession.footer(classified.message, 'error', classified.hint);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const GH_RETRY = [
|
|
169
|
+
{ label: 'Retry', sublabel: 'Try again — same credentials', value: 'retry' },
|
|
170
|
+
{ label: 'Edit token', sublabel: 'Change personal access token', value: 'creds' },
|
|
171
|
+
{ label: 'Edit URL', sublabel: 'Change repository URL', value: 'url' },
|
|
172
|
+
{ label: 'Skip', sublabel: 'Abandon — move to next step', value: 'skip' },
|
|
173
|
+
];
|
|
174
|
+
stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
|
|
175
|
+
const ghRetryIndex = await promptSelect(GH_RETRY, { stream, hint: '↑/↓ select Enter confirm' });
|
|
176
|
+
if (ghRetryIndex === null || GH_RETRY[ghRetryIndex].value === 'skip') break githubLoop;
|
|
177
|
+
if (GH_RETRY[ghRetryIndex].value === 'url') continue githubLoop;
|
|
178
|
+
const rHint = ghToken ? s.dim(' [keep existing]') : '';
|
|
179
|
+
ghToken = await promptSecret(
|
|
180
|
+
s.dim('Personal access token') + rHint + s.dim(':'),
|
|
181
|
+
{ stream, existingValue: ghToken }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (connected) {
|
|
186
|
+
stream.write(`\n ${s.dim('──── Optional (press Enter to skip) ────')}\n\n`);
|
|
187
|
+
|
|
188
|
+
const prefixRaw = await promptText(s.dim('Key prefix') + s.dim(' [GH]:'), { stream });
|
|
189
|
+
const ticketPrefixes = prefixRaw
|
|
190
|
+
? prefixRaw.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
|
|
191
|
+
: ['GH'];
|
|
192
|
+
|
|
193
|
+
const home = homedir();
|
|
194
|
+
const cwd = process.cwd();
|
|
195
|
+
const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
|
|
196
|
+
const pathInput = await promptText(
|
|
197
|
+
s.dim('Project path') + s.dim(` [${cwdDisplay}]:`), { stream }
|
|
198
|
+
);
|
|
199
|
+
const rawPath = (pathInput.trim() || cwdDisplay).replace(/\/+$/, '');
|
|
200
|
+
const projectPaths = [];
|
|
201
|
+
if (rawPath) {
|
|
202
|
+
const expanded = rawPath.startsWith('~') ? join(home, rawPath.slice(1)) : rawPath;
|
|
203
|
+
if (existsSync(expanded)) {
|
|
204
|
+
projectPaths.push(rawPath);
|
|
205
|
+
stream.write(` ${s.green('✔')} ${rawPath}\n`);
|
|
206
|
+
} else {
|
|
207
|
+
stream.write(` ${s.yellow('○')} ${s.dim(rawPath)} — directory not found\n`);
|
|
208
|
+
const doCreate = await promptYN(`Create ${rawPath}?`, { stream });
|
|
209
|
+
if (doCreate) {
|
|
210
|
+
try {
|
|
211
|
+
mkdirSync(expanded, { recursive: true });
|
|
212
|
+
projectPaths.push(rawPath);
|
|
213
|
+
stream.write(` ${s.green('✔')} Created\n`);
|
|
214
|
+
} catch (mkErr) {
|
|
215
|
+
stream.write(` ${s.red('✖')} Could not create: ${mkErr.message}\n`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const profileData = {
|
|
222
|
+
baseUrl: ghUrl,
|
|
223
|
+
auth: 'github',
|
|
224
|
+
ticketPrefixes,
|
|
225
|
+
...(projectPaths.length > 0 ? { projectPaths } : {}),
|
|
226
|
+
};
|
|
227
|
+
saveProfile(profileName, profileData, { apiToken: ghToken }, configDir);
|
|
228
|
+
addedCount++;
|
|
229
|
+
stream.write(`\n ${s.green('✔')} Profile ${s.bold(s.cyan(`"${profileName}"`))} saved.\n`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (trackerType === 'jira') {
|
|
234
|
+
// ── Setup loop — URL → auth → credentials → test → retry ─────────────────
|
|
235
|
+
//
|
|
236
|
+
// startFrom controls which step to resume from on each iteration:
|
|
237
|
+
// 'url' — re-prompt URL + auth type, then fall through to creds
|
|
238
|
+
// 'creds' — re-prompt email/token (pre-populated), then test
|
|
239
|
+
// 'retry' — skip all prompts, rebuild env from current values, test immediately
|
|
240
|
+
let baseUrl = '', authType = '', email = '', token = '';
|
|
241
|
+
let env = {}, apiVersion = 2;
|
|
242
|
+
let startFrom = 'url';
|
|
243
|
+
|
|
244
|
+
setupLoop: while (true) {
|
|
245
|
+
// ── URL + auth type ────────────────────────────────────────────────────
|
|
246
|
+
if (startFrom === 'url') {
|
|
247
|
+
stream.write(`\n ${s.dim('Jira URL:')}\n\n`);
|
|
248
|
+
const urlSuggestions = [
|
|
249
|
+
{ label: `https://${profileName}.atlassian.net`, sublabel: 'Cloud · atlassian.net', value: `https://${profileName}.atlassian.net` },
|
|
250
|
+
{ label: `https://jira.${profileName}.com`, sublabel: 'Server/DC · self-hosted', value: `https://jira.${profileName}.com` },
|
|
251
|
+
{ label: 'Enter a different URL…', sublabel: 'Type your own', value: null },
|
|
252
|
+
];
|
|
253
|
+
const urlIndex = await promptSelect(urlSuggestions, { stream, hint: '↑/↓ select Enter confirm' });
|
|
254
|
+
if (urlIndex === null) {
|
|
255
|
+
stream.write(` ${s.dim('Cancelled.')}\n`);
|
|
256
|
+
break setupLoop;
|
|
257
|
+
}
|
|
258
|
+
let rawUrl;
|
|
259
|
+
if (urlSuggestions[urlIndex].value === null) {
|
|
260
|
+
stream.write('\n');
|
|
261
|
+
const typed = await promptText(
|
|
262
|
+
s.dim('Jira URL') + s.dim(' (e.g. jira.company.com or https://jira.company.com):'),
|
|
263
|
+
{
|
|
264
|
+
stream,
|
|
265
|
+
defaultValue: baseUrl, // pre-fill with previously typed URL if any
|
|
266
|
+
validate: (v) => (v ? null : 'URL cannot be empty.'),
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
// Auto-detect protocol when the user omits it
|
|
270
|
+
if (!/^https?:\/\//i.test(typed)) {
|
|
271
|
+
rawUrl = await probeProtocol(typed.replace(/\/$/, ''), { stream, s });
|
|
272
|
+
} else {
|
|
273
|
+
rawUrl = typed;
|
|
274
|
+
stream.write(` ${s.green('✔')} ${rawUrl}\n`);
|
|
275
|
+
}
|
|
276
|
+
} else {
|
|
277
|
+
rawUrl = urlSuggestions[urlIndex].value;
|
|
278
|
+
stream.write(` ${s.green('✔')} ${rawUrl}\n`);
|
|
279
|
+
}
|
|
280
|
+
baseUrl = rawUrl.replace(/\/$/, '');
|
|
281
|
+
|
|
282
|
+
const isCloud = /\.atlassian\.net(\/|$)/i.test(baseUrl);
|
|
283
|
+
if (isCloud) {
|
|
284
|
+
authType = 'cloud';
|
|
285
|
+
stream.write(`\n ${s.green('✔')} Jira Cloud detected — using email + API token\n\n`);
|
|
286
|
+
} else {
|
|
287
|
+
stream.write(`\n ${s.dim('Auth type:')}\n\n`);
|
|
288
|
+
const serverAuthIndex = await promptSelect(SERVER_AUTH_TYPES, {
|
|
289
|
+
stream,
|
|
290
|
+
hint: '↑/↓ select Enter confirm',
|
|
291
|
+
});
|
|
292
|
+
if (serverAuthIndex === null) {
|
|
293
|
+
stream.write(` ${s.dim('Cancelled.')}\n`);
|
|
294
|
+
break setupLoop;
|
|
295
|
+
}
|
|
296
|
+
authType = SERVER_AUTH_TYPES[serverAuthIndex].value;
|
|
297
|
+
stream.write(` ${s.green('✔')} ${SERVER_AUTH_TYPES[serverAuthIndex].label}\n\n`);
|
|
298
|
+
}
|
|
299
|
+
startFrom = 'creds';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Email / username + token / password ────────────────────────────────
|
|
303
|
+
// Pre-populated from previous attempt — Enter keeps the existing value.
|
|
304
|
+
if (startFrom === 'creds') {
|
|
305
|
+
if (authType === 'cloud' || authType === 'basic') {
|
|
306
|
+
const emailHint = email ? s.dim(` [current: ${email}]`) : '';
|
|
307
|
+
const emailLabel = (authType === 'cloud' ? s.dim('Email') : s.dim('Username')) + emailHint + s.dim(':');
|
|
308
|
+
email = await promptText(emailLabel, {
|
|
309
|
+
stream,
|
|
310
|
+
defaultValue: email,
|
|
311
|
+
validate: (v) => {
|
|
312
|
+
if (!v) return 'Cannot be empty.';
|
|
313
|
+
if (authType === 'cloud' && !v.includes('@')) return 'Enter a valid email address.';
|
|
314
|
+
return null;
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
const tokenHint = token ? s.dim(' [keep existing]') : '';
|
|
319
|
+
const tokenLabel = (authType === 'cloud'
|
|
320
|
+
? s.dim('API token')
|
|
321
|
+
: authType === 'pat'
|
|
322
|
+
? s.dim('Personal access token')
|
|
323
|
+
: s.dim('Password')) + tokenHint + s.dim(':');
|
|
324
|
+
token = await promptSecret(tokenLabel, { stream, existingValue: token });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Test connection ────────────────────────────────────────────────────
|
|
328
|
+
env = {
|
|
329
|
+
JIRA_BASE_URL: baseUrl,
|
|
330
|
+
JIRA_EMAIL: email,
|
|
331
|
+
JIRA_API_TOKEN: authType !== 'pat' ? token : '',
|
|
332
|
+
JIRA_PAT: authType === 'pat' ? token : '',
|
|
333
|
+
};
|
|
334
|
+
apiVersion = authType === 'cloud' ? 3 : 2;
|
|
335
|
+
|
|
336
|
+
const session = createSession({
|
|
337
|
+
baseUrl,
|
|
338
|
+
profileName,
|
|
339
|
+
email: email || undefined,
|
|
340
|
+
pat: authType === 'pat' ? token : undefined,
|
|
341
|
+
}, { stream });
|
|
342
|
+
|
|
343
|
+
stream.write('\n');
|
|
344
|
+
session.spin('Testing connection...');
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
await fetchCurrentUser({ env, apiVersion });
|
|
348
|
+
session.connected();
|
|
349
|
+
connected = true;
|
|
350
|
+
break setupLoop;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
session.failed();
|
|
353
|
+
const classified = classifyError(err, { baseUrl, profileName });
|
|
354
|
+
session.footer(classified.message, 'error', classified.hint);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Retry options ──────────────────────────────────────────────────────
|
|
358
|
+
stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
|
|
359
|
+
const retryIndex = await promptSelect(RETRY_OPTIONS, { stream, hint: '↑/↓ select Enter confirm' });
|
|
360
|
+
if (retryIndex === null || RETRY_OPTIONS[retryIndex].value === 'skip') break setupLoop;
|
|
361
|
+
startFrom = RETRY_OPTIONS[retryIndex].value;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (connected) {
|
|
365
|
+
// ── Optional settings ───────────────────────────────────────────────────
|
|
366
|
+
stream.write(`\n ${s.dim('──── Optional (press Enter to skip) ────')}\n\n`);
|
|
367
|
+
|
|
368
|
+
// Ticket prefixes
|
|
369
|
+
const prefixInput = await promptText(
|
|
370
|
+
s.dim('Ticket prefixes') + s.dim(' (e.g. PROJ,OPS):'), { stream }
|
|
371
|
+
);
|
|
372
|
+
const ticketPrefixes = prefixInput
|
|
373
|
+
? prefixInput.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
|
|
374
|
+
: [];
|
|
375
|
+
|
|
376
|
+
// Project path (single — used for auto-profile detection from cwd)
|
|
377
|
+
const home = homedir();
|
|
378
|
+
const cwd = process.cwd();
|
|
379
|
+
const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
|
|
380
|
+
const pathInput = await promptText(
|
|
381
|
+
s.dim('Project path') + s.dim(` [${cwdDisplay}]:`), { stream }
|
|
382
|
+
);
|
|
383
|
+
const rawPath = (pathInput.trim() || cwdDisplay).replace(/\/+$/, '');
|
|
384
|
+
const projectPaths = [];
|
|
385
|
+
if (rawPath) {
|
|
386
|
+
const expanded = rawPath.startsWith('~')
|
|
387
|
+
? join(home, rawPath.slice(1))
|
|
388
|
+
: rawPath;
|
|
389
|
+
if (existsSync(expanded)) {
|
|
390
|
+
projectPaths.push(rawPath);
|
|
391
|
+
stream.write(` ${s.green('✔')} ${rawPath}\n`);
|
|
392
|
+
} else {
|
|
393
|
+
stream.write(` ${s.yellow('○')} ${s.dim(rawPath)} — directory not found\n`);
|
|
394
|
+
const doCreate = await promptYN(`Create ${rawPath}?`, { stream });
|
|
395
|
+
if (doCreate) {
|
|
396
|
+
try {
|
|
397
|
+
mkdirSync(expanded, { recursive: true });
|
|
398
|
+
projectPaths.push(rawPath);
|
|
399
|
+
stream.write(` ${s.green('✔')} Created\n`);
|
|
400
|
+
} catch (mkErr) {
|
|
401
|
+
stream.write(` ${s.red('✖')} Could not create: ${mkErr.message}\n`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Triage statuses — validate against Jira's actual status names
|
|
408
|
+
const DEFAULT_TRIAGE = 'In Progress, Code Review, QA';
|
|
409
|
+
const statusInput = await promptText(
|
|
410
|
+
s.dim('Triage statuses') + s.dim(` [default: ${DEFAULT_TRIAGE}]:`), { stream }
|
|
411
|
+
);
|
|
412
|
+
let triageStatuses = statusInput
|
|
413
|
+
? statusInput.split(',').map(v => v.trim()).filter(Boolean)
|
|
414
|
+
: DEFAULT_TRIAGE.split(',').map(v => v.trim());
|
|
415
|
+
|
|
416
|
+
stream.write(` ${s.dim('Validating statuses...')}\n`);
|
|
417
|
+
try {
|
|
418
|
+
const available = await fetchStatuses({ env, apiVersion });
|
|
419
|
+
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
420
|
+
stream.write('\x1b[A\r\x1b[2K'); // clear "Validating..." line
|
|
421
|
+
const corrected = [];
|
|
422
|
+
let hasIssues = false;
|
|
423
|
+
for (const name of triageStatuses) {
|
|
424
|
+
if (available.includes(name)) {
|
|
425
|
+
corrected.push(name);
|
|
426
|
+
} else {
|
|
427
|
+
const match = lowerMap.get(name.toLowerCase());
|
|
428
|
+
if (match) {
|
|
429
|
+
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(match)}\n`);
|
|
430
|
+
corrected.push(match);
|
|
431
|
+
hasIssues = true;
|
|
432
|
+
} else {
|
|
433
|
+
stream.write(` ${s.red('✖')} ${name} ${s.dim('(not found in this Jira instance)')}\n`);
|
|
434
|
+
hasIssues = true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!hasIssues) {
|
|
439
|
+
stream.write(` ${s.green('✔')} Statuses validated\n`);
|
|
440
|
+
} else if (corrected.length > 0) {
|
|
441
|
+
stream.write(` ${s.dim('Using:')} ${corrected.map(n => s.cyan(n)).join(s.dim(', '))}\n`);
|
|
442
|
+
}
|
|
443
|
+
triageStatuses = corrected;
|
|
444
|
+
} catch {
|
|
445
|
+
stream.write('\x1b[A\r\x1b[2K'); // clear silently if Jira call fails
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Save ──────────────────────────────────────────────────────────────
|
|
449
|
+
const profileData = {
|
|
450
|
+
baseUrl,
|
|
451
|
+
auth: authType,
|
|
452
|
+
...(email ? { email } : {}),
|
|
453
|
+
...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
|
|
454
|
+
...(projectPaths.length > 0 ? { projectPaths } : {}),
|
|
455
|
+
triageStatuses,
|
|
456
|
+
};
|
|
457
|
+
const credData = authType === 'pat' ? { pat: token } : { apiToken: token };
|
|
458
|
+
saveProfile(profileName, profileData, credData, configDir);
|
|
459
|
+
addedCount++;
|
|
460
|
+
stream.write(`\n ${s.green('✔')} Profile ${s.bold(s.cyan(`"${profileName}"`))} saved.\n`);
|
|
461
|
+
}
|
|
462
|
+
} // end if jira
|
|
463
|
+
|
|
464
|
+
addAnother = await promptYN('Configure another connection?', { stream });
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (addedCount === 0) {
|
|
468
|
+
stream.write(`\n ${s.dim('No profiles saved. Run')} ${s.cyan('ticketlens init')} ${s.dim('to try again.')}\n\n`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── Select active profile ─────────────────────────────────────────────────
|
|
473
|
+
const finalConfig = loadProfiles(configDir);
|
|
474
|
+
const allNames = finalConfig ? Object.keys(finalConfig.profiles) : [];
|
|
475
|
+
|
|
476
|
+
if (allNames.length === 1) {
|
|
477
|
+
await saveDefault(allNames[0], configDir);
|
|
478
|
+
} else if (allNames.length > 1) {
|
|
479
|
+
stream.write(`\n ${s.dim('Select your active profile:')}\n\n`);
|
|
480
|
+
await runSwitch({ configDir, stream, testConnection: false });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── Done ─────────────────────────────────────────────────────────────────
|
|
484
|
+
const profileWord = addedCount === 1 ? '1 profile' : `${addedCount} profiles`;
|
|
485
|
+
stream.write(`\n ${s.green('✔')} ${profileWord} configured.\n\n`);
|
|
486
|
+
|
|
487
|
+
// Quick-start panel
|
|
488
|
+
const cmds = [
|
|
489
|
+
['ticketlens triage', 'Scan your assigned tickets'],
|
|
490
|
+
['ticketlens <TICKET-KEY>', 'Fetch a specific ticket'],
|
|
491
|
+
['ticketlens switch', 'Switch active profile'],
|
|
492
|
+
['ticketlens --help', 'Full command reference'],
|
|
493
|
+
];
|
|
494
|
+
const cmdWidth = cmds.reduce((max, [c]) => Math.max(max, c.length), 0);
|
|
495
|
+
const cmdRows = cmds.map(([cmd, desc]) =>
|
|
496
|
+
` ${s.bold(s.cyan(cmd.padEnd(cmdWidth)))} ${s.dim(desc)}`
|
|
497
|
+
);
|
|
498
|
+
const QTITLE = ' Quick start ';
|
|
499
|
+
const contentWidth = cmdRows.reduce((max, r) => Math.max(max, visLen(r)), 0);
|
|
500
|
+
const qWidth = Math.max(contentWidth + 2, QTITLE.length + 4);
|
|
501
|
+
const qPad = (r) => ' ' + r + ' '.repeat(Math.max(0, qWidth - visLen(r) - 1));
|
|
502
|
+
const qTitleFill = qWidth - 1 - QTITLE.length;
|
|
503
|
+
stream.write(bc('╭') + bc('─') + s.bold(s.cyan(QTITLE)) + bc('─'.repeat(Math.max(0, qTitleFill))) + bc('╮') + '\n');
|
|
504
|
+
stream.write(bc('│') + qPad('') + bc('│') + '\n');
|
|
505
|
+
for (const r of cmdRows) stream.write(bc('│') + qPad(r) + bc('│') + '\n');
|
|
506
|
+
stream.write(bc('│') + qPad('') + bc('│') + '\n');
|
|
507
|
+
stream.write(bc('╰') + bc('─'.repeat(qWidth)) + bc('╯') + '\n\n');
|
|
508
|
+
}
|