ticketlens 0.1.6 → 0.1.8
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/README.md +17 -0
- package/bin/ticketlens.mjs +6 -3
- package/package.json +1 -1
- package/skills/jtb/scripts/lib/adapters/linear-adapter.mjs +132 -0
- package/skills/jtb/scripts/lib/browser-login.mjs +13 -0
- package/skills/jtb/scripts/lib/init-wizard.mjs +91 -0
- package/skills/jtb/scripts/lib/resolve-adapter.mjs +3 -1
package/README.md
CHANGED
|
@@ -190,6 +190,19 @@ Stores the schedule as a cron entry. Delivers your triage digest at the configur
|
|
|
190
190
|
|
|
191
191
|
---
|
|
192
192
|
|
|
193
|
+
### Login
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
ticketlens login # Open browser → authorize in Console → token saved automatically
|
|
197
|
+
ticketlens login --manual # Paste a token instead (CI/headless environments)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`ticketlens login` opens the TicketLens Console in your default browser. Click **Authorize**, and the CLI receives your token via a one-shot localhost callback — no copy-pasting. Cancelling in the browser exits the CLI cleanly.
|
|
201
|
+
|
|
202
|
+
Use `--manual` when there is no GUI (CI runners, SSH sessions, containers).
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
193
206
|
### License
|
|
194
207
|
|
|
195
208
|
```bash
|
|
@@ -308,6 +321,10 @@ ticketlens schedule # Interactive wizard — set time,
|
|
|
308
321
|
ticketlens schedule --stop # Cancel the scheduled digest [Pro]
|
|
309
322
|
ticketlens schedule --status # Show current schedule [Pro]
|
|
310
323
|
|
|
324
|
+
# ── Login ─────────────────────────────────────────────────────────────────────
|
|
325
|
+
ticketlens login # Browser flow — opens Console, token saved automatically
|
|
326
|
+
ticketlens login --manual # Paste flow — for CI/headless environments
|
|
327
|
+
|
|
311
328
|
# ── License and account ────────────────────────────────────────────────────────
|
|
312
329
|
ticketlens license # Show license tier and status
|
|
313
330
|
ticketlens activate <LICENSE-KEY> # Activate a license key
|
package/bin/ticketlens.mjs
CHANGED
|
@@ -309,9 +309,12 @@ switch (command) {
|
|
|
309
309
|
try {
|
|
310
310
|
token = await browserLogin();
|
|
311
311
|
} catch (err) {
|
|
312
|
-
|
|
313
|
-
process.stderr.write(`\
|
|
314
|
-
|
|
312
|
+
const cancelled = err.message === 'Authorization cancelled';
|
|
313
|
+
process.stderr.write(`\x1b[A\r\x1b[2K ${s.red('✖')} ${cancelled ? 'Login cancelled.' : err.message}\n`);
|
|
314
|
+
if (!cancelled) {
|
|
315
|
+
process.stderr.write(`\n ${s.dim(`Try ${s.cyan('ticketlens login --manual')} to paste a token instead.`)}\n\n`);
|
|
316
|
+
}
|
|
317
|
+
process.exitCode = cancelled ? 0 : 1;
|
|
315
318
|
return;
|
|
316
319
|
}
|
|
317
320
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const LINEAR_API = 'https://api.linear.app/graphql';
|
|
2
|
+
|
|
3
|
+
const PRIORITY_LABELS = { 1: 'Urgent', 2: 'High', 3: 'Medium', 4: 'Low' };
|
|
4
|
+
|
|
5
|
+
const ISSUE_FIELDS = `
|
|
6
|
+
identifier
|
|
7
|
+
title
|
|
8
|
+
description
|
|
9
|
+
state { name }
|
|
10
|
+
priority
|
|
11
|
+
assignee { name displayName email }
|
|
12
|
+
creator { name displayName email }
|
|
13
|
+
createdAt
|
|
14
|
+
updatedAt
|
|
15
|
+
labels { nodes { name } }
|
|
16
|
+
comments { nodes { body createdAt user { name displayName email } } }
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Maps a raw Linear GraphQL issue node to the normalized ticket shape.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeLinearIssue(raw) {
|
|
23
|
+
return {
|
|
24
|
+
key: raw.identifier,
|
|
25
|
+
summary: raw.title,
|
|
26
|
+
type: 'Issue',
|
|
27
|
+
status: raw.state?.name ?? null,
|
|
28
|
+
priority: PRIORITY_LABELS[raw.priority] ?? null,
|
|
29
|
+
assignee: raw.assignee?.displayName ?? raw.assignee?.name ?? null,
|
|
30
|
+
reporter: raw.creator?.displayName ?? raw.creator?.name ?? null,
|
|
31
|
+
description: raw.description ?? null,
|
|
32
|
+
created: raw.createdAt ?? null,
|
|
33
|
+
updated: raw.updatedAt ?? null,
|
|
34
|
+
labels: (raw.labels?.nodes ?? []).map(l => l.name),
|
|
35
|
+
components: [],
|
|
36
|
+
comments: (raw.comments?.nodes ?? []).map(c => ({
|
|
37
|
+
author: c.user?.displayName ?? c.user?.name ?? null,
|
|
38
|
+
authorAccountId: null,
|
|
39
|
+
authorName: c.user?.name ?? null,
|
|
40
|
+
body: c.body ?? '',
|
|
41
|
+
created: c.createdAt ?? null,
|
|
42
|
+
})),
|
|
43
|
+
linkedIssues: [],
|
|
44
|
+
attachments: [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function gql(query, variables, { token, fetcher, signal }) {
|
|
49
|
+
const res = await fetcher(LINEAR_API, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${token}`,
|
|
53
|
+
'Content-Type': 'application/json',
|
|
54
|
+
},
|
|
55
|
+
body: JSON.stringify({ query, variables }),
|
|
56
|
+
signal,
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
throw new Error(`Linear API error ${res.status} (${res.statusText})`);
|
|
60
|
+
}
|
|
61
|
+
const { data, errors } = await res.json();
|
|
62
|
+
if (errors?.length) throw new Error(`Linear GraphQL error: ${errors[0].message}`);
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Returns a tracker adapter backed by the Linear GraphQL API.
|
|
68
|
+
* Profile baseUrl must contain linear.app. Auth token stored as apiToken in credentials.json.
|
|
69
|
+
*/
|
|
70
|
+
export function createLinearAdapter(conn, { fetcher = globalThis.fetch } = {}) {
|
|
71
|
+
const token = conn.apiToken || conn.pat;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
type: 'linear',
|
|
75
|
+
|
|
76
|
+
async fetchTicket(key, opts = {}) {
|
|
77
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
78
|
+
const data = await gql(
|
|
79
|
+
`query IssueByIdentifier($id: String!) {
|
|
80
|
+
issues(filter: { identifier: { eq: $id } }, first: 1) {
|
|
81
|
+
nodes { ${ISSUE_FIELDS} }
|
|
82
|
+
}
|
|
83
|
+
}`,
|
|
84
|
+
{ id: key },
|
|
85
|
+
{ token, fetcher, signal },
|
|
86
|
+
);
|
|
87
|
+
const node = data.issues?.nodes?.[0];
|
|
88
|
+
if (!node) throw new Error(`Linear issue not found: ${key}`);
|
|
89
|
+
return normalizeLinearIssue(node);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async fetchCurrentUser(opts = {}) {
|
|
93
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
94
|
+
const data = await gql(
|
|
95
|
+
`query Me { viewer { name displayName email } }`,
|
|
96
|
+
{},
|
|
97
|
+
{ token, fetcher, signal },
|
|
98
|
+
);
|
|
99
|
+
const v = data.viewer;
|
|
100
|
+
return { displayName: v.displayName ?? v.name, email: v.email ?? null };
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async searchTickets(_query, opts = {}) {
|
|
104
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
105
|
+
const data = await gql(
|
|
106
|
+
`query MyIssues {
|
|
107
|
+
viewer {
|
|
108
|
+
assignedIssues(
|
|
109
|
+
filter: { state: { type: { nin: ["completed", "cancelled"] } } }
|
|
110
|
+
first: 50
|
|
111
|
+
) {
|
|
112
|
+
nodes { ${ISSUE_FIELDS} }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}`,
|
|
116
|
+
{},
|
|
117
|
+
{ token, fetcher, signal },
|
|
118
|
+
);
|
|
119
|
+
return (data.viewer?.assignedIssues?.nodes ?? []).map(normalizeLinearIssue);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async fetchStatuses(opts = {}) {
|
|
123
|
+
const signal = AbortSignal.timeout(opts.timeoutMs ?? 10_000);
|
|
124
|
+
const data = await gql(
|
|
125
|
+
`query WorkflowStates { workflowStates(first: 50) { nodes { name } } }`,
|
|
126
|
+
{},
|
|
127
|
+
{ token, fetcher, signal },
|
|
128
|
+
);
|
|
129
|
+
return (data.workflowStates?.nodes ?? []).map(s => s.name);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -49,6 +49,7 @@ export function startLocalServer(port, expectedState, timeoutMs = TIMEOUT_MS) {
|
|
|
49
49
|
|
|
50
50
|
const token = url.searchParams.get('token') ?? '';
|
|
51
51
|
const state = url.searchParams.get('state') ?? '';
|
|
52
|
+
const error = url.searchParams.get('error') ?? '';
|
|
52
53
|
|
|
53
54
|
if (state !== expectedState) {
|
|
54
55
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
@@ -57,6 +58,18 @@ export function startLocalServer(port, expectedState, timeoutMs = TIMEOUT_MS) {
|
|
|
57
58
|
return;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
if (error) {
|
|
62
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
63
|
+
res.end(
|
|
64
|
+
'<html><body style="font-family:sans-serif;text-align:center;padding:60px;background:#0d1117;color:#cdd9e5">'
|
|
65
|
+
+ '<h2 style="color:#8b949e">Authorization cancelled</h2>'
|
|
66
|
+
+ '<p style="color:#8b949e">You can close this tab.</p>'
|
|
67
|
+
+ '</body></html>',
|
|
68
|
+
);
|
|
69
|
+
settle(reject, new Error('Authorization cancelled'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
if (!token.startsWith('tl_')) {
|
|
61
74
|
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
62
75
|
res.end('Invalid token received.');
|
|
@@ -109,6 +109,7 @@ async function _run({ configDir, stream, s }) {
|
|
|
109
109
|
const TRACKER_TYPES = [
|
|
110
110
|
{ label: 'Jira', sublabel: 'Jira Cloud, Server, or Data Center', value: 'jira' },
|
|
111
111
|
{ label: 'GitHub', sublabel: 'GitHub Issues (github.com)', value: 'github' },
|
|
112
|
+
{ label: 'Linear', sublabel: 'Linear (linear.app)', value: 'linear' },
|
|
112
113
|
];
|
|
113
114
|
stream.write(`\n ${s.dim('Tracker type:')}\n\n`);
|
|
114
115
|
const trackerIndex = await promptSelect(TRACKER_TYPES, { stream, hint: '↑/↓ select Enter confirm' });
|
|
@@ -230,6 +231,96 @@ async function _run({ configDir, stream, s }) {
|
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
if (trackerType === 'linear') {
|
|
235
|
+
let linToken = '';
|
|
236
|
+
|
|
237
|
+
linearLoop: while (true) {
|
|
238
|
+
const tokenHint = linToken ? s.dim(' [keep existing]') : '';
|
|
239
|
+
linToken = await promptSecret(
|
|
240
|
+
s.dim('Linear API key') + tokenHint + s.dim(':'),
|
|
241
|
+
{ stream, existingValue: linToken }
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const linConn = { baseUrl: 'https://linear.app', apiToken: linToken };
|
|
245
|
+
const linSession = createSession({ baseUrl: 'https://linear.app', profileName }, { stream });
|
|
246
|
+
stream.write('\n');
|
|
247
|
+
linSession.spin('Testing connection...');
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const linAdapter = resolveAdapter(linConn);
|
|
251
|
+
await linAdapter.fetchCurrentUser();
|
|
252
|
+
linSession.connected();
|
|
253
|
+
connected = true;
|
|
254
|
+
break linearLoop;
|
|
255
|
+
} catch (err) {
|
|
256
|
+
linSession.failed();
|
|
257
|
+
const classified = classifyError(err, { baseUrl: 'https://linear.app', profileName });
|
|
258
|
+
linSession.footer(classified.message, 'error', classified.hint);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const LIN_RETRY = [
|
|
262
|
+
{ label: 'Retry', sublabel: 'Try again — same key', value: 'retry' },
|
|
263
|
+
{ label: 'Edit key', sublabel: 'Change API key', value: 'creds' },
|
|
264
|
+
{ label: 'Skip', sublabel: 'Abandon — move to next step', value: 'skip' },
|
|
265
|
+
];
|
|
266
|
+
stream.write(`\n ${s.dim('What would you like to do?')}\n\n`);
|
|
267
|
+
const linRetryIndex = await promptSelect(LIN_RETRY, { stream, hint: '↑/↓ select Enter confirm' });
|
|
268
|
+
if (linRetryIndex === null || LIN_RETRY[linRetryIndex].value === 'skip') break linearLoop;
|
|
269
|
+
const lHint = linToken ? s.dim(' [keep existing]') : '';
|
|
270
|
+
linToken = await promptSecret(
|
|
271
|
+
s.dim('Linear API key') + lHint + s.dim(':'),
|
|
272
|
+
{ stream, existingValue: linToken }
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (connected) {
|
|
277
|
+
stream.write(`\n ${s.dim('──── Optional (press Enter to skip) ────')}\n\n`);
|
|
278
|
+
|
|
279
|
+
const prefixRaw = await promptText(s.dim('Team identifier') + s.dim(' (e.g. ENG):'), { stream });
|
|
280
|
+
const ticketPrefixes = prefixRaw
|
|
281
|
+
? prefixRaw.split(',').map(v => v.trim().toUpperCase()).filter(Boolean)
|
|
282
|
+
: [];
|
|
283
|
+
|
|
284
|
+
const home = homedir();
|
|
285
|
+
const cwd = process.cwd();
|
|
286
|
+
const cwdDisplay = cwd.startsWith(home) ? '~' + cwd.slice(home.length) : cwd;
|
|
287
|
+
const pathInput = await promptText(
|
|
288
|
+
s.dim('Project path') + s.dim(` [${cwdDisplay}]:`), { stream }
|
|
289
|
+
);
|
|
290
|
+
const rawPath = (pathInput.trim() || cwdDisplay).replace(/\/+$/, '');
|
|
291
|
+
const projectPaths = [];
|
|
292
|
+
if (rawPath) {
|
|
293
|
+
const expanded = rawPath.startsWith('~') ? join(home, rawPath.slice(1)) : rawPath;
|
|
294
|
+
if (existsSync(expanded)) {
|
|
295
|
+
projectPaths.push(rawPath);
|
|
296
|
+
stream.write(` ${s.green('✔')} ${rawPath}\n`);
|
|
297
|
+
} else {
|
|
298
|
+
stream.write(` ${s.yellow('○')} ${s.dim(rawPath)} — directory not found\n`);
|
|
299
|
+
const doCreate = await promptYN(`Create ${rawPath}?`, { stream });
|
|
300
|
+
if (doCreate) {
|
|
301
|
+
try {
|
|
302
|
+
mkdirSync(expanded, { recursive: true });
|
|
303
|
+
projectPaths.push(rawPath);
|
|
304
|
+
stream.write(` ${s.green('✔')} Created\n`);
|
|
305
|
+
} catch (mkErr) {
|
|
306
|
+
stream.write(` ${s.red('✖')} Could not create: ${mkErr.message}\n`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const profileData = {
|
|
313
|
+
baseUrl: 'https://linear.app',
|
|
314
|
+
auth: 'linear',
|
|
315
|
+
...(ticketPrefixes.length > 0 ? { ticketPrefixes } : {}),
|
|
316
|
+
...(projectPaths.length > 0 ? { projectPaths } : {}),
|
|
317
|
+
};
|
|
318
|
+
saveProfile(profileName, profileData, { apiToken: linToken }, configDir);
|
|
319
|
+
addedCount++;
|
|
320
|
+
stream.write(`\n ${s.green('✔')} Profile ${s.bold(s.cyan(`"${profileName}"`))} saved.\n`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
233
324
|
if (trackerType === 'jira') {
|
|
234
325
|
// ── Setup loop — URL → auth → credentials → test → retry ─────────────────
|
|
235
326
|
//
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createJiraAdapter } from './adapters/jira-adapter.mjs';
|
|
2
2
|
import { createGitHubAdapter } from './adapters/github-adapter.mjs';
|
|
3
|
+
import { createLinearAdapter } from './adapters/linear-adapter.mjs';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Detects the tracker type from a baseUrl string.
|
|
@@ -24,5 +25,6 @@ export function resolveAdapter(conn, opts = {}) {
|
|
|
24
25
|
const type = detectTrackerType(conn?.baseUrl);
|
|
25
26
|
if (type === 'jira') return createJiraAdapter(conn, opts);
|
|
26
27
|
if (type === 'github') return createGitHubAdapter(conn, opts);
|
|
27
|
-
|
|
28
|
+
if (type === 'linear') return createLinearAdapter(conn, opts);
|
|
29
|
+
throw new Error(`Tracker type '${type}' is not yet supported. Supported: jira, github, linear.`);
|
|
28
30
|
}
|