ticketlens 0.1.7 → 0.1.9

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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 email }
12
+ creator { name email }
13
+ createdAt
14
+ updatedAt
15
+ labels { nodes { name } }
16
+ comments { nodes { body createdAt user { name 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?.name ?? null,
30
+ reporter: 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?.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(Object.keys(variables).length ? { query, variables } : { query }),
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 email } }`,
96
+ {},
97
+ { token, fetcher, signal },
98
+ );
99
+ const v = data.viewer;
100
+ return { 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
+ }
@@ -40,9 +40,14 @@ export function createSession(conn, { stream = process.stderr } = {}) {
40
40
  const profileLabel = conn.profileName || 'default';
41
41
  const userLabel = conn.email || (conn.pat ? 'token auth' : 'unknown');
42
42
 
43
- const jiraLabel = conn.profileName
44
- ? conn.profileName.charAt(0).toUpperCase() + conn.profileName.slice(1) + ' Jira'
45
- : hostname;
43
+ const trackerName = hostname.includes('linear.app') ? 'Linear'
44
+ : hostname.includes('github.com') ? 'GitHub'
45
+ : 'Jira';
46
+ const jiraLabel = (trackerName !== 'Jira')
47
+ ? trackerName
48
+ : conn.profileName
49
+ ? conn.profileName.charAt(0).toUpperCase() + conn.profileName.slice(1) + ' Jira'
50
+ : hostname;
46
51
 
47
52
  // Pre-build the info lines to calculate box width including status line.
48
53
  const infoLines = [
@@ -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
- throw new Error(`Tracker type '${type}' is not yet supported. Supported: jira, github.`);
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
  }