ticketlens 0.1.11 → 0.1.13
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
CHANGED
|
@@ -11,6 +11,7 @@ import { createStyler } from './ansi.mjs';
|
|
|
11
11
|
import { createSession } from './banner.mjs';
|
|
12
12
|
import { classifyError } from './error-classifier.mjs';
|
|
13
13
|
import { fetchCurrentUser, fetchStatuses } from './jira-client.mjs';
|
|
14
|
+
import { resolveAdapter } from './resolve-adapter.mjs';
|
|
14
15
|
import { loadProfiles, loadCredentials, saveProfile } from './profile-resolver.mjs';
|
|
15
16
|
import { promptSelect } from './select-prompt.mjs';
|
|
16
17
|
import { parseAge } from './cache-manager.mjs';
|
|
@@ -26,6 +27,26 @@ const RETRY_OPTIONS = [
|
|
|
26
27
|
{ label: 'Skip', sublabel: 'Abandon connection changes — no changes saved', value: 'skip' },
|
|
27
28
|
];
|
|
28
29
|
|
|
30
|
+
function getTrackerType(profile) {
|
|
31
|
+
if (profile.auth === 'linear') return 'linear';
|
|
32
|
+
if (profile.auth === 'github') return 'github';
|
|
33
|
+
return 'jira';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getUrlLabel(trackerType) {
|
|
37
|
+
if (trackerType === 'linear') return 'Linear workspace URL';
|
|
38
|
+
if (trackerType === 'github') return 'GitHub URL';
|
|
39
|
+
return 'Jira URL';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getTokenLabel(trackerType, auth) {
|
|
43
|
+
if (trackerType === 'linear') return 'Linear API key';
|
|
44
|
+
if (trackerType === 'github') return 'GitHub token';
|
|
45
|
+
if (auth === 'cloud') return 'API token';
|
|
46
|
+
if (auth === 'pat') return 'Personal access token';
|
|
47
|
+
return 'Password';
|
|
48
|
+
}
|
|
49
|
+
|
|
29
50
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
30
51
|
|
|
31
52
|
export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {}) {
|
|
@@ -54,6 +75,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
54
75
|
const profileCreds = creds[target] || {};
|
|
55
76
|
const hostname = (() => { try { return new URL(profile.baseUrl).hostname; } catch { return profile.baseUrl; } })();
|
|
56
77
|
|
|
78
|
+
const trackerType = getTrackerType(profile);
|
|
79
|
+
const isJira = trackerType === 'jira';
|
|
80
|
+
const urlLabel = getUrlLabel(trackerType);
|
|
81
|
+
|
|
57
82
|
// Header box
|
|
58
83
|
const headerLines = [
|
|
59
84
|
`Editing profile ${s.bold(s.cyan(`"${target}"`))}`,
|
|
@@ -80,35 +105,36 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
80
105
|
|
|
81
106
|
// ── URL ───────────────────────────────────────────────────────────────────
|
|
82
107
|
const urlTyped = await promptText(
|
|
83
|
-
s.dim(
|
|
108
|
+
s.dim(urlLabel) + s.dim(` [current: ${profile.baseUrl}]:`),
|
|
84
109
|
{ stream, defaultValue: profile.baseUrl }
|
|
85
110
|
);
|
|
86
111
|
if (urlTyped !== profile.baseUrl) {
|
|
87
|
-
// Auto-prefix https:// when the user omits the protocol
|
|
88
112
|
url = /^https?:\/\//i.test(urlTyped)
|
|
89
113
|
? urlTyped.replace(/\/$/, '')
|
|
90
114
|
: `https://${urlTyped.replace(/\/$/, '')}`;
|
|
91
115
|
if (url !== urlTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
|
|
92
116
|
}
|
|
93
117
|
|
|
94
|
-
// ── Auth type
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
auth
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
118
|
+
// ── Auth type (Jira only) ─────────────────────────────────────────────────
|
|
119
|
+
if (isJira) {
|
|
120
|
+
const isCloud = /\.atlassian\.net(\/|$)/i.test(url);
|
|
121
|
+
if (isCloud && auth !== 'cloud') {
|
|
122
|
+
auth = 'cloud';
|
|
123
|
+
stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n`);
|
|
124
|
+
} else if (!isCloud) {
|
|
125
|
+
const currentIdx = SERVER_AUTH_TYPES.findIndex(a => a.value === auth);
|
|
126
|
+
stream.write(`\n ${s.dim('Auth type:')}\n\n`);
|
|
127
|
+
const authIdx = await promptSelect(SERVER_AUTH_TYPES, {
|
|
128
|
+
stream,
|
|
129
|
+
hint: '↑/↓ select Enter confirm',
|
|
130
|
+
initialIndex: Math.max(0, currentIdx),
|
|
131
|
+
});
|
|
132
|
+
if (authIdx !== null) auth = SERVER_AUTH_TYPES[authIdx].value;
|
|
133
|
+
}
|
|
108
134
|
}
|
|
109
135
|
|
|
110
|
-
// ── Email / username
|
|
111
|
-
if (auth === 'cloud' || auth === 'basic') {
|
|
136
|
+
// ── Email / username (Jira only) ──────────────────────────────────────────
|
|
137
|
+
if (isJira && (auth === 'cloud' || auth === 'basic')) {
|
|
112
138
|
const emailHint = email ? s.dim(` [current: ${email}]`) : '';
|
|
113
139
|
const emailLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + emailHint + s.dim(':');
|
|
114
140
|
email = await promptText(emailLabel, {
|
|
@@ -122,33 +148,21 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
122
148
|
});
|
|
123
149
|
}
|
|
124
150
|
|
|
125
|
-
// ── Token / PAT / password
|
|
151
|
+
// ── Token / PAT / password / API key ─────────────────────────────────────
|
|
126
152
|
const tokenHint = existingToken ? s.dim(' [keep existing]') : '';
|
|
127
|
-
const tokenLabel = (auth
|
|
128
|
-
? s.dim('API token')
|
|
129
|
-
: auth === 'pat'
|
|
130
|
-
? s.dim('Personal access token')
|
|
131
|
-
: s.dim('Password')) + tokenHint + s.dim(':');
|
|
153
|
+
const tokenLabel = s.dim(getTokenLabel(trackerType, auth)) + tokenHint + s.dim(':');
|
|
132
154
|
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
155
|
|
|
141
|
-
|
|
156
|
+
// ── Connection test (always) ──────────────────────────────────────────────
|
|
157
|
+
let connected = false;
|
|
158
|
+
let startFrom = 'test';
|
|
142
159
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
setupLoop: while (true) {
|
|
147
|
-
// Re-prompt URL + auth
|
|
148
|
-
if (startFrom === 'url') {
|
|
160
|
+
setupLoop: while (true) {
|
|
161
|
+
// Re-prompt URL + auth (on retry with 'url' option)
|
|
162
|
+
if (startFrom === 'url') {
|
|
149
163
|
stream.write('\n');
|
|
150
164
|
const reTyped = await promptText(
|
|
151
|
-
s.dim(
|
|
165
|
+
s.dim(urlLabel) + s.dim(` [current: ${url}]:`),
|
|
152
166
|
{ stream, defaultValue: url }
|
|
153
167
|
);
|
|
154
168
|
if (reTyped !== url) {
|
|
@@ -157,21 +171,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
157
171
|
: `https://${reTyped.replace(/\/$/, '')}`;
|
|
158
172
|
if (url !== reTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
|
|
159
173
|
}
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
|
|
175
|
+
if (isJira) {
|
|
176
|
+
const reCloud = /\.atlassian\.net(\/|$)/i.test(url);
|
|
177
|
+
if (reCloud) {
|
|
178
|
+
auth = 'cloud';
|
|
179
|
+
stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n\n`);
|
|
180
|
+
} else if (auth === 'cloud') {
|
|
181
|
+
stream.write(`\n ${s.dim('Auth type:')}\n\n`);
|
|
182
|
+
const idx = await promptSelect(SERVER_AUTH_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
|
|
183
|
+
if (idx !== null) auth = SERVER_AUTH_TYPES[idx].value;
|
|
184
|
+
}
|
|
168
185
|
}
|
|
169
186
|
startFrom = 'creds';
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
// Re-prompt email + token (pre-populated)
|
|
173
190
|
if (startFrom === 'creds') {
|
|
174
|
-
if (auth === 'cloud' || auth === 'basic') {
|
|
191
|
+
if (isJira && (auth === 'cloud' || auth === 'basic')) {
|
|
175
192
|
const eHint = email ? s.dim(` [current: ${email}]`) : '';
|
|
176
193
|
const eLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + eHint + s.dim(':');
|
|
177
194
|
email = await promptText(eLabel, {
|
|
@@ -185,24 +202,30 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
185
202
|
});
|
|
186
203
|
}
|
|
187
204
|
const tHint = token ? s.dim(' [keep existing]') : '';
|
|
188
|
-
const tLabel =
|
|
205
|
+
const tLabel = s.dim(getTokenLabel(trackerType, auth)) + tHint + s.dim(':');
|
|
189
206
|
token = await promptSecret(tLabel, { stream, existingValue: token });
|
|
190
207
|
}
|
|
191
208
|
|
|
192
209
|
// Test connection
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 });
|
|
210
|
+
const session = createSession(
|
|
211
|
+
{ baseUrl: url, profileName: target, email: email || undefined, pat: auth === 'pat' ? token : undefined },
|
|
212
|
+
{ stream },
|
|
213
|
+
);
|
|
201
214
|
stream.write('\n');
|
|
202
215
|
session.spin('Testing connection...');
|
|
203
216
|
|
|
204
217
|
try {
|
|
205
|
-
|
|
218
|
+
if (isJira) {
|
|
219
|
+
const testEnv = {
|
|
220
|
+
JIRA_BASE_URL: url,
|
|
221
|
+
JIRA_EMAIL: email,
|
|
222
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
223
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
224
|
+
};
|
|
225
|
+
await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
|
|
226
|
+
} else {
|
|
227
|
+
await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
|
|
228
|
+
}
|
|
206
229
|
session.connected();
|
|
207
230
|
connected = true;
|
|
208
231
|
break setupLoop;
|
|
@@ -218,11 +241,10 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
218
241
|
startFrom = RETRY_OPTIONS[retryIdx].value;
|
|
219
242
|
}
|
|
220
243
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
244
|
+
if (!connected) {
|
|
245
|
+
stream.write(`\n ${s.yellow('○')} Connection changes discarded. ${s.dim('Run')} ${s.cyan('ticketlens config')} ${s.dim('again to retry.')}\n\n`);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
return;
|
|
226
248
|
}
|
|
227
249
|
|
|
228
250
|
// ── Optional settings ──────────────────────────────────────────────────────
|
|
@@ -242,7 +264,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
242
264
|
}
|
|
243
265
|
const ticketPrefixes = [...existing];
|
|
244
266
|
|
|
245
|
-
// Project path
|
|
267
|
+
// Project path
|
|
246
268
|
const home = homedir();
|
|
247
269
|
const cwd = process.cwd();
|
|
248
270
|
const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
|
|
@@ -275,8 +297,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
275
297
|
}
|
|
276
298
|
|
|
277
299
|
// ── 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
300
|
const currentStatuses = profile.triageStatuses?.length
|
|
281
301
|
? profile.triageStatuses
|
|
282
302
|
: ['In Progress', 'Code Review', 'QA'];
|
|
@@ -292,21 +312,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
292
312
|
if (statusInput) {
|
|
293
313
|
const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
|
|
294
314
|
const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
|
|
295
|
-
// Only validate statuses that aren't already in the list
|
|
296
315
|
const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
|
|
297
316
|
|
|
298
317
|
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
318
|
stream.write(` ${s.dim('Validating new statuses...')}\n`);
|
|
308
319
|
try {
|
|
309
|
-
|
|
320
|
+
let available;
|
|
321
|
+
if (isJira) {
|
|
322
|
+
const validateEnv = {
|
|
323
|
+
JIRA_BASE_URL: url,
|
|
324
|
+
JIRA_EMAIL: email,
|
|
325
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
326
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
327
|
+
};
|
|
328
|
+
available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
|
|
329
|
+
} else {
|
|
330
|
+
available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
|
|
331
|
+
}
|
|
332
|
+
|
|
310
333
|
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
311
334
|
stream.write('\x1b[A\r\x1b[2K');
|
|
312
335
|
|
|
@@ -316,13 +339,11 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
316
339
|
toAdd.push(name);
|
|
317
340
|
stream.write(` ${s.green('✔')} ${name}\n`);
|
|
318
341
|
} else {
|
|
319
|
-
// Case-insensitive exact match
|
|
320
342
|
const exact = lowerMap.get(name.toLowerCase());
|
|
321
343
|
if (exact) {
|
|
322
344
|
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
|
|
323
345
|
toAdd.push(exact);
|
|
324
346
|
} else {
|
|
325
|
-
// Partial match: "QA" → "QA Testing"
|
|
326
347
|
const partial = available.find(a =>
|
|
327
348
|
a.toLowerCase().includes(name.toLowerCase()) ||
|
|
328
349
|
name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
|
|
@@ -343,9 +364,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
343
364
|
}
|
|
344
365
|
} catch {
|
|
345
366
|
stream.write('\x1b[A\r\x1b[2K');
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
triageStatuses = [...currentStatuses, ...toAddRaw];
|
|
367
|
+
// Tracker unreachable — add without validation, deduped
|
|
368
|
+
triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
|
|
349
369
|
}
|
|
350
370
|
}
|
|
351
371
|
}
|
|
@@ -368,12 +388,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
368
388
|
}
|
|
369
389
|
|
|
370
390
|
// ── Save ───────────────────────────────────────────────────────────────────
|
|
371
|
-
const updated = {
|
|
372
|
-
...profile,
|
|
373
|
-
baseUrl: url,
|
|
374
|
-
auth,
|
|
375
|
-
triageStatuses,
|
|
376
|
-
};
|
|
391
|
+
const updated = { ...profile, baseUrl: url, auth, triageStatuses };
|
|
377
392
|
if (email) updated.email = email;
|
|
378
393
|
if (ticketPrefixes.length > 0) updated.ticketPrefixes = ticketPrefixes;
|
|
379
394
|
else delete updated.ticketPrefixes;
|
|
@@ -382,7 +397,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
382
397
|
if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
|
|
383
398
|
else delete updated.cacheTtl;
|
|
384
399
|
|
|
385
|
-
// Only write credentials file if the token actually changed
|
|
386
400
|
const credData = (token !== existingToken)
|
|
387
401
|
? (auth === 'pat' ? { pat: token } : { apiToken: token })
|
|
388
402
|
: {};
|