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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +457 -0
  3. package/bin/ticketlens.mjs +376 -0
  4. package/package.json +37 -0
  5. package/skills/jtb/scripts/fetch-my-tickets.mjs +377 -0
  6. package/skills/jtb/scripts/fetch-ticket.mjs +682 -0
  7. package/skills/jtb/scripts/lib/adapters/github-adapter.mjs +112 -0
  8. package/skills/jtb/scripts/lib/adapters/jira-adapter.mjs +19 -0
  9. package/skills/jtb/scripts/lib/adf-converter.mjs +67 -0
  10. package/skills/jtb/scripts/lib/ansi.mjs +87 -0
  11. package/skills/jtb/scripts/lib/arg-validator.mjs +178 -0
  12. package/skills/jtb/scripts/lib/attachment-downloader.mjs +123 -0
  13. package/skills/jtb/scripts/lib/attention-scorer.mjs +152 -0
  14. package/skills/jtb/scripts/lib/banner.mjs +201 -0
  15. package/skills/jtb/scripts/lib/brief-assembler.mjs +137 -0
  16. package/skills/jtb/scripts/lib/brief-cache.mjs +137 -0
  17. package/skills/jtb/scripts/lib/budget-pruner.mjs +242 -0
  18. package/skills/jtb/scripts/lib/cache-manager.mjs +499 -0
  19. package/skills/jtb/scripts/lib/cli-auth.mjs +40 -0
  20. package/skills/jtb/scripts/lib/cli.mjs +87 -0
  21. package/skills/jtb/scripts/lib/code-ref-parser.mjs +113 -0
  22. package/skills/jtb/scripts/lib/commit-linker.mjs +42 -0
  23. package/skills/jtb/scripts/lib/compliance-checker.mjs +92 -0
  24. package/skills/jtb/scripts/lib/config-wizard.mjs +392 -0
  25. package/skills/jtb/scripts/lib/config.mjs +63 -0
  26. package/skills/jtb/scripts/lib/diff-analyzer.mjs +66 -0
  27. package/skills/jtb/scripts/lib/drift-tracker.mjs +120 -0
  28. package/skills/jtb/scripts/lib/error-classifier.mjs +119 -0
  29. package/skills/jtb/scripts/lib/help.mjs +253 -0
  30. package/skills/jtb/scripts/lib/hook-installer.mjs +81 -0
  31. package/skills/jtb/scripts/lib/init-wizard.mjs +508 -0
  32. package/skills/jtb/scripts/lib/interactive-list.mjs +257 -0
  33. package/skills/jtb/scripts/lib/jira-client.mjs +169 -0
  34. package/skills/jtb/scripts/lib/ledger.mjs +96 -0
  35. package/skills/jtb/scripts/lib/license.mjs +195 -0
  36. package/skills/jtb/scripts/lib/pr-assembler.mjs +186 -0
  37. package/skills/jtb/scripts/lib/profile-picker.mjs +216 -0
  38. package/skills/jtb/scripts/lib/profile-resolver.mjs +236 -0
  39. package/skills/jtb/scripts/lib/profile-switcher.mjs +147 -0
  40. package/skills/jtb/scripts/lib/prompt-helpers.mjs +122 -0
  41. package/skills/jtb/scripts/lib/requirement-extractor.mjs +52 -0
  42. package/skills/jtb/scripts/lib/resolve-adapter.mjs +28 -0
  43. package/skills/jtb/scripts/lib/schedule-wizard.mjs +153 -0
  44. package/skills/jtb/scripts/lib/select-prompt.mjs +106 -0
  45. package/skills/jtb/scripts/lib/spinner.mjs +44 -0
  46. package/skills/jtb/scripts/lib/styled-assembler.mjs +183 -0
  47. package/skills/jtb/scripts/lib/summarizer.mjs +109 -0
  48. package/skills/jtb/scripts/lib/sync.mjs +119 -0
  49. package/skills/jtb/scripts/lib/table-formatter.mjs +48 -0
  50. package/skills/jtb/scripts/lib/triage-exporter.mjs +93 -0
  51. package/skills/jtb/scripts/lib/triage-history.mjs +166 -0
  52. package/skills/jtb/scripts/lib/triage-push.mjs +98 -0
  53. package/skills/jtb/scripts/lib/usage-tracker.mjs +54 -0
  54. package/skills/jtb/scripts/lib/vcs-detector.mjs +12 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Resolves Jira connection config from profiles or env vars.
3
+ * Resolution order: --profile flag → ticket prefix match → default profile → env vars
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync, chmodSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
10
+
11
+ /** Simple Levenshtein distance for "did you mean" suggestions. */
12
+ function levenshtein(a, b) {
13
+ const m = a.length, n = b.length;
14
+ const dp = Array.from({ length: m + 1 }, (_, i) => i);
15
+ for (let j = 1; j <= n; j++) {
16
+ let prev = dp[0];
17
+ dp[0] = j;
18
+ for (let i = 1; i <= m; i++) {
19
+ const tmp = dp[i];
20
+ dp[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[i], dp[i - 1]);
21
+ prev = tmp;
22
+ }
23
+ }
24
+ return dp[m];
25
+ }
26
+
27
+ function findClosest(input, candidates) {
28
+ if (candidates.length === 0) return null;
29
+ const lower = input.toLowerCase();
30
+ let best = null;
31
+ let bestDist = Infinity;
32
+ for (const c of candidates) {
33
+ const d = levenshtein(lower, c.toLowerCase());
34
+ if (d < bestDist) { bestDist = d; best = c; }
35
+ }
36
+ // Only suggest if reasonably close (within half the input length + 2)
37
+ return bestDist <= Math.floor(input.length / 2) + 2 ? best : null;
38
+ }
39
+
40
+ // Module-level cache keyed by configDir — avoids redundant readFileSync+JSON.parse per run.
41
+ // Invalidated automatically by every write function.
42
+ const _profilesCache = new Map();
43
+ const _credentialsCache = new Map();
44
+
45
+ export function invalidateProfilesCache(configDir = DEFAULT_CONFIG_DIR) {
46
+ _profilesCache.delete(configDir);
47
+ _credentialsCache.delete(configDir);
48
+ }
49
+
50
+ export function saveDefault(name, configDir = DEFAULT_CONFIG_DIR) {
51
+ const profilesPath = join(configDir, 'profiles.json');
52
+ const config = loadProfiles(configDir) || { profiles: {} };
53
+ config.default = name;
54
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
55
+ chmodSync(profilesPath, 0o600);
56
+ invalidateProfilesCache(configDir);
57
+ }
58
+
59
+ export function saveProfile(name, profileData, credData, configDir = DEFAULT_CONFIG_DIR) {
60
+ mkdirSync(configDir, { recursive: true });
61
+ const profilesPath = join(configDir, 'profiles.json');
62
+ const config = loadProfiles(configDir) || { profiles: {} };
63
+ config.profiles[name] = profileData;
64
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
65
+ chmodSync(profilesPath, 0o600);
66
+ if (credData && Object.keys(credData).length > 0) {
67
+ const credPath = join(configDir, 'credentials.json');
68
+ const creds = loadCredentials(configDir);
69
+ creds[name] = credData;
70
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
71
+ chmodSync(credPath, 0o600);
72
+ }
73
+ invalidateProfilesCache(configDir);
74
+ }
75
+
76
+ export function deleteProfile(name, configDir = DEFAULT_CONFIG_DIR) {
77
+ const profilesPath = join(configDir, 'profiles.json');
78
+ const config = loadProfiles(configDir);
79
+ if (!config?.profiles[name]) return { deleted: false, reason: 'not-found' };
80
+
81
+ delete config.profiles[name];
82
+ if (config.default === name) delete config.default;
83
+
84
+ writeFileSync(profilesPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
85
+ chmodSync(profilesPath, 0o600);
86
+
87
+ // Remove credentials entry
88
+ const credPath = join(configDir, 'credentials.json');
89
+ if (existsSync(credPath)) {
90
+ const creds = loadCredentials(configDir);
91
+ if (creds[name]) {
92
+ delete creds[name];
93
+ writeFileSync(credPath, JSON.stringify(creds, null, 2) + '\n', 'utf8');
94
+ chmodSync(credPath, 0o600);
95
+ }
96
+ }
97
+
98
+ invalidateProfilesCache(configDir);
99
+ return { deleted: true };
100
+ }
101
+
102
+ export function loadProfiles(configDir = DEFAULT_CONFIG_DIR) {
103
+ if (_profilesCache.has(configDir)) return _profilesCache.get(configDir);
104
+ const profilesPath = join(configDir, 'profiles.json');
105
+ if (!existsSync(profilesPath)) return null;
106
+ const data = JSON.parse(readFileSync(profilesPath, 'utf8'));
107
+ _profilesCache.set(configDir, data);
108
+ return data;
109
+ }
110
+
111
+ export function loadCredentials(configDir = DEFAULT_CONFIG_DIR) {
112
+ if (_credentialsCache.has(configDir)) return _credentialsCache.get(configDir);
113
+ const credPath = join(configDir, 'credentials.json');
114
+ if (!existsSync(credPath)) {
115
+ _credentialsCache.set(configDir, {});
116
+ return {};
117
+ }
118
+ const data = JSON.parse(readFileSync(credPath, 'utf8'));
119
+ _credentialsCache.set(configDir, data);
120
+ return data;
121
+ }
122
+
123
+ export function expandTilde(p) {
124
+ if (p.startsWith('~/')) return join(homedir(), p.slice(2));
125
+ if (p === '~') return homedir();
126
+ return p;
127
+ }
128
+
129
+ export function resolveProfileByPath(cwd, configDir = DEFAULT_CONFIG_DIR) {
130
+ const config = loadProfiles(configDir);
131
+ if (!config) return null;
132
+
133
+ let bestMatch = null;
134
+ let bestLen = 0;
135
+
136
+ for (const [name, profile] of Object.entries(config.profiles)) {
137
+ if (!profile.projectPaths) continue;
138
+ for (const p of profile.projectPaths) {
139
+ const expanded = expandTilde(p);
140
+ if (cwd.startsWith(expanded) && expanded.length > bestLen) {
141
+ bestMatch = { name, ...profile };
142
+ bestLen = expanded.length;
143
+ }
144
+ }
145
+ }
146
+
147
+ return bestMatch;
148
+ }
149
+
150
+ export function resolveProfile(ticketKey, opts = {}) {
151
+ const { profileName, configDir = DEFAULT_CONFIG_DIR, cwd } = opts;
152
+ const config = loadProfiles(configDir);
153
+ if (!config) return null;
154
+
155
+ // 1. Explicit --profile flag
156
+ if (profileName && config.profiles[profileName]) {
157
+ return { name: profileName, ...config.profiles[profileName] };
158
+ }
159
+
160
+ // 1b. Profile name given but not found — suggest closest match
161
+ if (profileName) {
162
+ const available = Object.keys(config.profiles);
163
+ const suggestion = findClosest(profileName, available);
164
+ if (opts.onProfileNotFound) {
165
+ opts.onProfileNotFound({ profileName, suggestion, available });
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // 2. Match ticket prefix (skip if no ticket key)
171
+ if (ticketKey) {
172
+ const prefix = ticketKey.split('-')[0];
173
+ const matches = [];
174
+ for (const [name, profile] of Object.entries(config.profiles)) {
175
+ if (profile.ticketPrefixes?.includes(prefix)) {
176
+ matches.push({ name, ...profile });
177
+ }
178
+ }
179
+ if (matches.length === 1) return matches[0];
180
+ if (matches.length > 1) {
181
+ const warning = `Warning: Prefix "${prefix}" matches multiple profiles: ${matches.map(m => m.name).join(', ')}. Using ${matches[0].name}. Use --profile=NAME to override.`;
182
+ if (opts.onWarning) opts.onWarning(warning);
183
+ return matches[0];
184
+ }
185
+ }
186
+
187
+ // 3. Project path match
188
+ if (cwd) {
189
+ const pathMatch = resolveProfileByPath(cwd, configDir);
190
+ if (pathMatch) return pathMatch;
191
+ }
192
+
193
+ // 4. Default profile
194
+ if (config.default && config.profiles[config.default]) {
195
+ return { name: config.default, ...config.profiles[config.default] };
196
+ }
197
+
198
+ return null;
199
+ }
200
+
201
+ export function resolveConnection(ticketKey, opts = {}) {
202
+ const { env = process.env, configDir = DEFAULT_CONFIG_DIR, profileName, onWarning, onProfileNotFound, cwd } = opts;
203
+
204
+ const profile = resolveProfile(ticketKey, { profileName, configDir, onWarning, onProfileNotFound, cwd });
205
+
206
+ if (profile) {
207
+ const creds = loadCredentials(configDir);
208
+ const profileCreds = creds[profile.name] || {};
209
+ return {
210
+ baseUrl: profile.baseUrl,
211
+ auth: profile.auth || null,
212
+ email: profile.email || null,
213
+ apiToken: profileCreds.apiToken || null,
214
+ pat: profileCreds.pat || null,
215
+ triageStatuses: profile.triageStatuses || null,
216
+ ticketPrefixes: profile.ticketPrefixes || null,
217
+ source: 'profile',
218
+ profileName: profile.name,
219
+ };
220
+ }
221
+
222
+ // Explicit --profile was given but not found — don't fall back to env vars
223
+ if (profileName) {
224
+ return { baseUrl: null, source: 'profile-not-found', profileName };
225
+ }
226
+
227
+ // Fall back to env vars
228
+ return {
229
+ baseUrl: env.JIRA_BASE_URL || null,
230
+ auth: null,
231
+ email: env.JIRA_EMAIL || null,
232
+ apiToken: env.JIRA_API_TOKEN || null,
233
+ pat: env.JIRA_PAT || null,
234
+ source: 'env',
235
+ };
236
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * ticketlens switch — Titled panel profile switcher (Option B).
3
+ * Used by `ticketlens switch` subcommand and as the final step in `ticketlens init`.
4
+ */
5
+
6
+ import { createStyler } from './ansi.mjs';
7
+ import { fetchCurrentUser } from './jira-client.mjs';
8
+ import { classifyError } from './error-classifier.mjs';
9
+ import { loadProfiles, loadCredentials, saveDefault } from './profile-resolver.mjs';
10
+ import { runRawSelect } from './select-prompt.mjs';
11
+ import { DEFAULT_CONFIG_DIR } from './config.mjs';
12
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
13
+ const visLen = (str) => str.replace(ANSI_RE, '').length;
14
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
15
+
16
+ /**
17
+ * Show the titled profile switcher panel and optionally test the connection on switch.
18
+ *
19
+ * @param {object} [opts]
20
+ * @param {string} [opts.configDir]
21
+ * @param {NodeJS.WriteStream} [opts.stream=process.stderr]
22
+ * @param {boolean} [opts.testConnection=true] - false skips connection test (e.g. in init wizard)
23
+ * @returns {Promise<string|null>} chosen profile name, or null if cancelled/failed
24
+ */
25
+ export async function runSwitch({ configDir = DEFAULT_CONFIG_DIR, stream = process.stderr, testConnection = true } = {}) {
26
+ const s = createStyler({ isTTY: stream.isTTY });
27
+ const config = loadProfiles(configDir);
28
+
29
+ if (!config || Object.keys(config.profiles).length === 0) {
30
+ stream.write(` ${s.red('✖')} No profiles configured. Run ${s.cyan('ticketlens init')} first.\n`);
31
+ return null;
32
+ }
33
+
34
+ const names = Object.keys(config.profiles);
35
+ const creds = loadCredentials(configDir);
36
+ const currentDefault = config.default || names[0];
37
+ const initialIndex = Math.max(0, names.indexOf(currentDefault));
38
+
39
+ if (names.length === 1) {
40
+ await saveDefault(names[0], configDir);
41
+ stream.write(` ${s.dim('Only one profile:')} ${s.bold(s.cyan(names[0]))}\n`);
42
+ return names[0];
43
+ }
44
+
45
+ // Build rows: name + hostname + active badge state
46
+ const rows = names.map(name => {
47
+ const profile = config.profiles[name];
48
+ let hostname = '';
49
+ try { hostname = new URL(profile.baseUrl).hostname; } catch {}
50
+ return { name, hostname, isActive: name === currentDefault };
51
+ });
52
+
53
+ // Compute box inner width to fit all rows cleanly
54
+ const TITLE = ' Profile ';
55
+ const contentWidth = rows.reduce((max, r) => {
56
+ const nameRow = ` ❯ ${r.name}${r.isActive ? ' ● active' : ''}`.length + 2;
57
+ const subRow = ` ${r.hostname}`.length + 2;
58
+ return Math.max(max, nameRow, subRow);
59
+ }, TITLE.length + 4);
60
+ const innerWidth = Math.max(contentWidth, TITLE.length + 4);
61
+
62
+ const bc = s.cyan;
63
+
64
+ function padInner(line) {
65
+ const pad = innerWidth - visLen(line) - 1;
66
+ return ' ' + line + ' '.repeat(Math.max(0, pad));
67
+ }
68
+
69
+ // Full panel in renderFn so runRawSelect erases it cleanly on selection
70
+ const titleFill = innerWidth - 1 - TITLE.length; // -1 for leading ─
71
+ const topBorder = bc('╭') + bc('─') + s.bold(s.cyan(TITLE)) + bc('─'.repeat(Math.max(0, titleFill))) + bc('╮');
72
+
73
+ function renderFn(selected) {
74
+ const lines = [];
75
+ lines.push(topBorder);
76
+ lines.push(bc('│') + padInner('') + bc('│'));
77
+ for (let i = 0; i < rows.length; i++) {
78
+ const row = rows[i];
79
+ const isSelected = i === selected;
80
+ const marker = isSelected ? s.cyan('❯') : ' ';
81
+ const name = isSelected ? s.bold(s.cyan(row.name)) : row.name;
82
+ const badge = row.isActive ? ` ${s.green('● active')}` : '';
83
+ lines.push(bc('│') + padInner(` ${marker} ${name}${badge}`) + bc('│'));
84
+ lines.push(bc('│') + padInner(s.dim(` ${row.hostname}`)) + bc('│'));
85
+ lines.push(bc('│') + padInner('') + bc('│'));
86
+ }
87
+ lines.push(bc('╰') + bc('─'.repeat(innerWidth)) + bc('╯'));
88
+ lines.push('');
89
+ lines.push(` ${s.dim('↑/↓ select Enter switch Esc back')}`);
90
+ stream.write(lines.join('\n') + '\n');
91
+ return lines.length;
92
+ }
93
+
94
+ const selectedIndex = await runRawSelect({ count: rows.length, initialIndex, renderFn, stream });
95
+ if (selectedIndex === null) return null;
96
+
97
+ const chosen = rows[selectedIndex];
98
+
99
+ // No-op if the user selected the already-active profile
100
+ if (chosen.name === currentDefault) {
101
+ stream.write(` ${s.dim('Already on')} ${s.bold(s.cyan(chosen.name))}.\n`);
102
+ return chosen.name;
103
+ }
104
+
105
+ if (!testConnection) {
106
+ await saveDefault(chosen.name, configDir);
107
+ stream.write(` ${s.green('✔')} Active profile set to ${s.bold(s.cyan(chosen.name))}\n`);
108
+ return chosen.name;
109
+ }
110
+
111
+ // Test connection with inline spinner
112
+ const profile = config.profiles[chosen.name];
113
+ const profileCreds = creds[chosen.name] || {};
114
+ const env = {
115
+ JIRA_BASE_URL: profile.baseUrl,
116
+ JIRA_EMAIL: profile.email || '',
117
+ JIRA_API_TOKEN: profileCreds.apiToken || '',
118
+ JIRA_PAT: profileCreds.pat || '',
119
+ };
120
+ const apiVersion = profile.auth === 'cloud' ? 3 : 2;
121
+
122
+ let frame = 0;
123
+ const spinLine = () => ` ${s.cyan(SPINNER_FRAMES[frame])} Connecting to ${s.bold(chosen.name)}...`;
124
+ stream.write(spinLine() + '\n');
125
+ const timer = setInterval(() => {
126
+ frame = (frame + 1) % SPINNER_FRAMES.length;
127
+ stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
128
+ stream.write(spinLine() + '\n');
129
+ }, 80);
130
+
131
+ try {
132
+ await fetchCurrentUser({ env, apiVersion });
133
+ clearInterval(timer);
134
+ stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
135
+ await saveDefault(chosen.name, configDir);
136
+ stream.write(` ${s.green('✔')} Switched to ${s.bold(s.cyan(chosen.name))}\n`);
137
+ return chosen.name;
138
+ } catch (err) {
139
+ clearInterval(timer);
140
+ stream.write('\r\x1b[2K\x1b[A\r\x1b[2K');
141
+ const classified = classifyError(err, { baseUrl: profile.baseUrl, profileName: chosen.name });
142
+ stream.write(` ${s.red('●')} Connection to ${chosen.name} failed.\n`);
143
+ stream.write(` ${s.dim(classified.message)}\n`);
144
+ if (classified.hint) stream.write(` ${s.dim(classified.hint)}\n`);
145
+ return null;
146
+ }
147
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Shared interactive prompt helpers for wizard flows.
3
+ * Used by init-wizard.mjs and config-wizard.mjs.
4
+ */
5
+
6
+ import { createInterface } from 'node:readline';
7
+ import { createStyler } from './ansi.mjs';
8
+
9
+ export const ANSI_RE = /\x1b\[[0-9;]*m/g;
10
+ export const visLen = (str) => str.replace(ANSI_RE, '').length;
11
+
12
+ export const SERVER_AUTH_TYPES = [
13
+ { label: 'PAT (personal access token)', sublabel: 'Jira Server/DC 8.14+', value: 'pat' },
14
+ { label: 'Basic (username + password)', sublabel: 'Jira Server/DC older versions', value: 'basic' },
15
+ ];
16
+
17
+ /**
18
+ * Text prompt. If `defaultValue` is provided, empty input keeps it (shown as [current: …]).
19
+ */
20
+ export async function promptText(label, { validate, defaultValue = '', stream = process.stderr } = {}) {
21
+ const s = createStyler({ isTTY: stream.isTTY });
22
+ while (true) {
23
+ const raw = await new Promise(res => {
24
+ const rl = createInterface({ input: process.stdin, output: stream });
25
+ rl.question(` ${label} `, (a) => { rl.close(); res(a.trim()); });
26
+ });
27
+ const answer = raw || defaultValue;
28
+ if (validate) {
29
+ const err = validate(answer);
30
+ if (err) { stream.write(` ${s.red('✖')} ${err}\n`); continue; }
31
+ }
32
+ return answer;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Masked password prompt. If `existingValue` is provided, Enter with no input keeps it.
38
+ */
39
+ export async function promptSecret(label, { stream = process.stderr, existingValue = '' } = {}) {
40
+ const s = createStyler({ isTTY: stream.isTTY });
41
+ while (true) {
42
+ stream.write(` ${label} `);
43
+ const value = await new Promise(res => {
44
+ let buf = '';
45
+ const stdin = process.stdin;
46
+ stdin.setRawMode(true);
47
+ stdin.resume();
48
+ stdin.setEncoding('utf8');
49
+ function onData(char) {
50
+ if (char === '\r' || char === '\n') {
51
+ stdin.setRawMode(false);
52
+ stdin.pause();
53
+ stdin.removeListener('data', onData);
54
+ stream.write('\n');
55
+ res(buf);
56
+ } else if (char === '\x7f' || char === '\x08') {
57
+ if (buf.length > 0) { buf = buf.slice(0, -1); stream.write('\b \b'); }
58
+ } else if (char === '\x03') {
59
+ process.exit(0);
60
+ } else {
61
+ buf += char;
62
+ stream.write('*');
63
+ }
64
+ }
65
+ stdin.on('data', onData);
66
+ });
67
+ if (!value) {
68
+ if (existingValue) return existingValue; // Enter = keep existing
69
+ stream.write(` ${s.red('✖')} Cannot be empty.\n`);
70
+ continue;
71
+ }
72
+ return value;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Gather schedule wizard answers interactively.
78
+ * Returns { time, email, timezone }.
79
+ */
80
+ export async function promptScheduleAnswers(args = [], { stream = process.stderr } = {}) {
81
+ // Allow pre-filling via flags: --time=07:00 --email=dev@example.com --timezone=UTC
82
+ const timeArg = args.find(a => a.startsWith('--time='))?.split('=')[1];
83
+ const emailArg = args.find(a => a.startsWith('--email='))?.split('=')[1];
84
+ const tzArg = args.find(a => a.startsWith('--timezone='))?.split('=')[1];
85
+
86
+ const time = timeArg ?? await promptText('Delivery time (HH:MM, 24h):', {
87
+ stream,
88
+ validate: (v) => /^\d{1,2}:\d{2}$/.test(v) ? null : 'Enter time as HH:MM (e.g. 07:00)',
89
+ });
90
+
91
+ const email = emailArg ?? await promptText('Delivery email:', {
92
+ stream,
93
+ validate: (v) => v.includes('@') ? null : 'Enter a valid email address',
94
+ });
95
+
96
+ const timezone = tzArg ?? await promptText('Timezone (e.g. America/New_York):', {
97
+ stream,
98
+ defaultValue: Intl.DateTimeFormat().resolvedOptions().timeZone,
99
+ });
100
+
101
+ return { time, email, timezone };
102
+ }
103
+
104
+ export function promptYN(question, { stream = process.stderr } = {}) {
105
+ const s = createStyler({ isTTY: stream.isTTY });
106
+ stream.write(`\n ${question} ${s.dim('y/N')} `);
107
+ return new Promise(res => {
108
+ const stdin = process.stdin;
109
+ stdin.setRawMode(true);
110
+ stdin.resume();
111
+ stdin.setEncoding('utf8');
112
+ function onData(char) {
113
+ stdin.setRawMode(false);
114
+ stdin.pause();
115
+ stdin.removeListener('data', onData);
116
+ stream.write('\n');
117
+ if (char === '\x03') process.exit(0);
118
+ res(char === 'y' || char === 'Y');
119
+ }
120
+ stdin.on('data', onData);
121
+ });
122
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Extracts acceptance criteria / requirements from Jira ticket text.
3
+ * Recognises: Given/When/Then, must/should/shall/ensure/verify bullets,
4
+ * Acceptance Criteria sections, and numbered imperative items.
5
+ */
6
+
7
+ const RE_GWT = /^\s*(given|when|then)\s+(.+)/i;
8
+ const RE_MUST_ITEM = /^\s*[-*•]\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
9
+ const RE_NUM_MUST = /^\s*\d+\.\s+((?:must|should|shall|ensure|verify)\b.+|.+(?:must|should|shall|ensure|verify).+)/i;
10
+ const RE_AC_HEADER = /^\s*#+\s*acceptance criteria\s*$/i;
11
+ const RE_BULLET = /^\s*[-*•]\s+(.+)/;
12
+ const RE_NUM_ITEM = /^\s*\d+\.\s+(.+)/;
13
+
14
+ export function extractRequirements(text) {
15
+ if (!text) return [];
16
+
17
+ const lines = text.split('\n');
18
+ const results = [];
19
+ let inAcSection = false;
20
+
21
+ for (const line of lines) {
22
+ // Given/When/Then
23
+ const gwt = RE_GWT.exec(line);
24
+ if (gwt) { results.push(line.trim()); continue; }
25
+
26
+ // Acceptance Criteria section header
27
+ if (RE_AC_HEADER.test(line)) { inAcSection = true; continue; }
28
+
29
+ // Exit AC section on next heading
30
+ if (inAcSection && /^\s*#+\s/.test(line) && !RE_AC_HEADER.test(line)) {
31
+ inAcSection = false;
32
+ }
33
+
34
+ // must/should/shall in bullet
35
+ const mustItem = RE_MUST_ITEM.exec(line);
36
+ if (mustItem) { results.push(mustItem[1].trim()); continue; }
37
+
38
+ // must/should/shall in numbered item
39
+ const numMust = RE_NUM_MUST.exec(line);
40
+ if (numMust) { results.push(numMust[1].trim()); continue; }
41
+
42
+ // Inside AC section: capture all bullet and numbered items
43
+ if (inAcSection) {
44
+ const bullet = RE_BULLET.exec(line);
45
+ if (bullet) { results.push(bullet[1].trim()); continue; }
46
+ const numItem = RE_NUM_ITEM.exec(line);
47
+ if (numItem) { results.push(numItem[1].trim()); continue; }
48
+ }
49
+ }
50
+
51
+ return [...new Set(results)];
52
+ }
@@ -0,0 +1,28 @@
1
+ import { createJiraAdapter } from './adapters/jira-adapter.mjs';
2
+ import { createGitHubAdapter } from './adapters/github-adapter.mjs';
3
+
4
+ /**
5
+ * Detects the tracker type from a baseUrl string.
6
+ * @param {string|null|undefined} baseUrl
7
+ * @returns {'jira'|'github'|'linear'}
8
+ */
9
+ export function detectTrackerType(baseUrl) {
10
+ if (!baseUrl) return 'jira';
11
+ const lower = baseUrl.toLowerCase();
12
+ if (lower.includes('github.com')) return 'github';
13
+ if (lower.includes('linear.app')) return 'linear';
14
+ return 'jira';
15
+ }
16
+
17
+ /**
18
+ * Instantiates the correct tracker adapter for a resolved connection.
19
+ * @param {{ baseUrl: string, auth?: string, email?: string, apiToken?: string, pat?: string }} conn
20
+ * @param {{ fetcher?: Function }} [opts]
21
+ * @returns {{ type: string, fetchTicket: Function, fetchCurrentUser: Function, searchTickets: Function, fetchStatuses: Function }}
22
+ */
23
+ export function resolveAdapter(conn, opts = {}) {
24
+ const type = detectTrackerType(conn?.baseUrl);
25
+ if (type === 'jira') return createJiraAdapter(conn, opts);
26
+ if (type === 'github') return createGitHubAdapter(conn, opts);
27
+ throw new Error(`Tracker type '${type}' is not yet supported. Supported: jira, github.`);
28
+ }