ticketlens 0.1.11 → 0.1.12
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,13 +148,9 @@ 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
155
|
const tokenChanged = token !== existingToken;
|
|
134
156
|
|
|
@@ -144,11 +166,11 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
144
166
|
let startFrom = 'test';
|
|
145
167
|
|
|
146
168
|
setupLoop: while (true) {
|
|
147
|
-
// Re-prompt URL + auth
|
|
169
|
+
// Re-prompt URL + auth (on retry)
|
|
148
170
|
if (startFrom === 'url') {
|
|
149
171
|
stream.write('\n');
|
|
150
172
|
const reTyped = await promptText(
|
|
151
|
-
s.dim(
|
|
173
|
+
s.dim(urlLabel) + s.dim(` [current: ${url}]:`),
|
|
152
174
|
{ stream, defaultValue: url }
|
|
153
175
|
);
|
|
154
176
|
if (reTyped !== url) {
|
|
@@ -157,21 +179,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
157
179
|
: `https://${reTyped.replace(/\/$/, '')}`;
|
|
158
180
|
if (url !== reTyped) stream.write(` ${s.dim('○')} Interpreted as ${url}\n`);
|
|
159
181
|
}
|
|
160
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
182
|
+
|
|
183
|
+
if (isJira) {
|
|
184
|
+
const reCloud = /\.atlassian\.net(\/|$)/i.test(url);
|
|
185
|
+
if (reCloud) {
|
|
186
|
+
auth = 'cloud';
|
|
187
|
+
stream.write(` ${s.green('✔')} Jira Cloud detected — using email + API token\n\n`);
|
|
188
|
+
} else if (auth === 'cloud') {
|
|
189
|
+
stream.write(`\n ${s.dim('Auth type:')}\n\n`);
|
|
190
|
+
const idx = await promptSelect(SERVER_AUTH_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
|
|
191
|
+
if (idx !== null) auth = SERVER_AUTH_TYPES[idx].value;
|
|
192
|
+
}
|
|
168
193
|
}
|
|
169
194
|
startFrom = 'creds';
|
|
170
195
|
}
|
|
171
196
|
|
|
172
197
|
// Re-prompt email + token (pre-populated)
|
|
173
198
|
if (startFrom === 'creds') {
|
|
174
|
-
if (auth === 'cloud' || auth === 'basic') {
|
|
199
|
+
if (isJira && (auth === 'cloud' || auth === 'basic')) {
|
|
175
200
|
const eHint = email ? s.dim(` [current: ${email}]`) : '';
|
|
176
201
|
const eLabel = (auth === 'cloud' ? s.dim('Email') : s.dim('Username')) + eHint + s.dim(':');
|
|
177
202
|
email = await promptText(eLabel, {
|
|
@@ -185,24 +210,30 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
185
210
|
});
|
|
186
211
|
}
|
|
187
212
|
const tHint = token ? s.dim(' [keep existing]') : '';
|
|
188
|
-
const tLabel =
|
|
213
|
+
const tLabel = s.dim(getTokenLabel(trackerType, auth)) + tHint + s.dim(':');
|
|
189
214
|
token = await promptSecret(tLabel, { stream, existingValue: token });
|
|
190
215
|
}
|
|
191
216
|
|
|
192
217
|
// 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 });
|
|
218
|
+
const session = createSession(
|
|
219
|
+
{ baseUrl: url, profileName: target, email: email || undefined, pat: auth === 'pat' ? token : undefined },
|
|
220
|
+
{ stream },
|
|
221
|
+
);
|
|
201
222
|
stream.write('\n');
|
|
202
223
|
session.spin('Testing connection...');
|
|
203
224
|
|
|
204
225
|
try {
|
|
205
|
-
|
|
226
|
+
if (isJira) {
|
|
227
|
+
const testEnv = {
|
|
228
|
+
JIRA_BASE_URL: url,
|
|
229
|
+
JIRA_EMAIL: email,
|
|
230
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
231
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
232
|
+
};
|
|
233
|
+
await fetchCurrentUser({ env: testEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
|
|
234
|
+
} else {
|
|
235
|
+
await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchCurrentUser();
|
|
236
|
+
}
|
|
206
237
|
session.connected();
|
|
207
238
|
connected = true;
|
|
208
239
|
break setupLoop;
|
|
@@ -242,7 +273,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
242
273
|
}
|
|
243
274
|
const ticketPrefixes = [...existing];
|
|
244
275
|
|
|
245
|
-
// Project path
|
|
276
|
+
// Project path
|
|
246
277
|
const home = homedir();
|
|
247
278
|
const cwd = process.cwd();
|
|
248
279
|
const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
|
|
@@ -275,8 +306,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
275
306
|
}
|
|
276
307
|
|
|
277
308
|
// ── 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
309
|
const currentStatuses = profile.triageStatuses?.length
|
|
281
310
|
? profile.triageStatuses
|
|
282
311
|
: ['In Progress', 'Code Review', 'QA'];
|
|
@@ -292,21 +321,24 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
292
321
|
if (statusInput) {
|
|
293
322
|
const newEntries = statusInput.split(',').map(v => v.trim()).filter(Boolean);
|
|
294
323
|
const existingLower = new Set(currentStatuses.map(n => n.toLowerCase()));
|
|
295
|
-
// Only validate statuses that aren't already in the list
|
|
296
324
|
const toValidate = newEntries.filter(n => !existingLower.has(n.toLowerCase()));
|
|
297
325
|
|
|
298
326
|
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
327
|
stream.write(` ${s.dim('Validating new statuses...')}\n`);
|
|
308
328
|
try {
|
|
309
|
-
|
|
329
|
+
let available;
|
|
330
|
+
if (isJira) {
|
|
331
|
+
const validateEnv = {
|
|
332
|
+
JIRA_BASE_URL: url,
|
|
333
|
+
JIRA_EMAIL: email,
|
|
334
|
+
JIRA_API_TOKEN: auth !== 'pat' ? token : '',
|
|
335
|
+
JIRA_PAT: auth === 'pat' ? token : '',
|
|
336
|
+
};
|
|
337
|
+
available = await fetchStatuses({ env: validateEnv, apiVersion: auth === 'cloud' ? 3 : 2 });
|
|
338
|
+
} else {
|
|
339
|
+
available = await resolveAdapter({ baseUrl: url, auth: trackerType, apiToken: token }).fetchStatuses();
|
|
340
|
+
}
|
|
341
|
+
|
|
310
342
|
const lowerMap = new Map(available.map(n => [n.toLowerCase(), n]));
|
|
311
343
|
stream.write('\x1b[A\r\x1b[2K');
|
|
312
344
|
|
|
@@ -316,13 +348,11 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
316
348
|
toAdd.push(name);
|
|
317
349
|
stream.write(` ${s.green('✔')} ${name}\n`);
|
|
318
350
|
} else {
|
|
319
|
-
// Case-insensitive exact match
|
|
320
351
|
const exact = lowerMap.get(name.toLowerCase());
|
|
321
352
|
if (exact) {
|
|
322
353
|
stream.write(` ${s.yellow('~')} ${s.dim(name)} → ${s.cyan(exact)}\n`);
|
|
323
354
|
toAdd.push(exact);
|
|
324
355
|
} else {
|
|
325
|
-
// Partial match: "QA" → "QA Testing"
|
|
326
356
|
const partial = available.find(a =>
|
|
327
357
|
a.toLowerCase().includes(name.toLowerCase()) ||
|
|
328
358
|
name.toLowerCase().startsWith(a.toLowerCase().split(' ')[0])
|
|
@@ -343,9 +373,8 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
343
373
|
}
|
|
344
374
|
} catch {
|
|
345
375
|
stream.write('\x1b[A\r\x1b[2K');
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
triageStatuses = [...currentStatuses, ...toAddRaw];
|
|
376
|
+
// Tracker unreachable — add without validation, deduped
|
|
377
|
+
triageStatuses = [...currentStatuses, ...toValidate.filter(n => !existingLower.has(n.toLowerCase()))];
|
|
349
378
|
}
|
|
350
379
|
}
|
|
351
380
|
}
|
|
@@ -368,12 +397,7 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
368
397
|
}
|
|
369
398
|
|
|
370
399
|
// ── Save ───────────────────────────────────────────────────────────────────
|
|
371
|
-
const updated = {
|
|
372
|
-
...profile,
|
|
373
|
-
baseUrl: url,
|
|
374
|
-
auth,
|
|
375
|
-
triageStatuses,
|
|
376
|
-
};
|
|
400
|
+
const updated = { ...profile, baseUrl: url, auth, triageStatuses };
|
|
377
401
|
if (email) updated.email = email;
|
|
378
402
|
if (ticketPrefixes.length > 0) updated.ticketPrefixes = ticketPrefixes;
|
|
379
403
|
else delete updated.ticketPrefixes;
|
|
@@ -382,7 +406,6 @@ export async function run({ configDir = DEFAULT_CONFIG_DIR, profileName } = {})
|
|
|
382
406
|
if (cacheTtl && cacheTtl !== DEFAULT_BRIEF_TTL) updated.cacheTtl = cacheTtl;
|
|
383
407
|
else delete updated.cacheTtl;
|
|
384
408
|
|
|
385
|
-
// Only write credentials file if the token actually changed
|
|
386
409
|
const credData = (token !== existingToken)
|
|
387
410
|
? (auth === 'pat' ? { pat: token } : { apiToken: token })
|
|
388
411
|
: {};
|