@dabble/linear-cli 1.0.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.
- package/README.md +169 -0
- package/bin/linear.mjs +2068 -0
- package/claude/commands/done.md +65 -0
- package/claude/commands/next.md +94 -0
- package/claude/commands/standup.md +63 -0
- package/claude/skills/linear-cli.md +193 -0
- package/claude/skills/product-planning.md +136 -0
- package/package.json +35 -0
- package/postinstall.mjs +54 -0
- package/preuninstall.mjs +52 -0
package/bin/linear.mjs
ADDED
|
@@ -0,0 +1,2068 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, statSync } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join, basename } from 'path';
|
|
6
|
+
import { createInterface } from 'readline';
|
|
7
|
+
import { exec, execSync } from 'child_process';
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// CONFIG
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
const API_URL = 'https://api.linear.app/graphql';
|
|
14
|
+
let CONFIG_FILE = '';
|
|
15
|
+
let LINEAR_API_KEY = '';
|
|
16
|
+
let TEAM_KEY = '';
|
|
17
|
+
|
|
18
|
+
// Colors (ANSI)
|
|
19
|
+
const colors = {
|
|
20
|
+
red: s => `\x1b[31m${s}\x1b[0m`,
|
|
21
|
+
green: s => `\x1b[32m${s}\x1b[0m`,
|
|
22
|
+
yellow: s => `\x1b[33m${s}\x1b[0m`,
|
|
23
|
+
blue: s => `\x1b[34m${s}\x1b[0m`,
|
|
24
|
+
gray: s => `\x1b[90m${s}\x1b[0m`,
|
|
25
|
+
bold: s => `\x1b[1m${s}\x1b[0m`,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// UTILITIES
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
function loadConfig() {
|
|
33
|
+
const localPath = join(process.cwd(), '.linear');
|
|
34
|
+
const globalPath = join(homedir(), '.linear');
|
|
35
|
+
|
|
36
|
+
// Priority: ./.linear > ~/.linear > env vars
|
|
37
|
+
if (existsSync(localPath)) {
|
|
38
|
+
CONFIG_FILE = localPath;
|
|
39
|
+
} else if (existsSync(globalPath)) {
|
|
40
|
+
CONFIG_FILE = globalPath;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Load from config file first (highest priority)
|
|
44
|
+
if (CONFIG_FILE) {
|
|
45
|
+
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
46
|
+
for (const line of content.split('\n')) {
|
|
47
|
+
if (!line || line.startsWith('#')) continue;
|
|
48
|
+
const [key, ...rest] = line.split('=');
|
|
49
|
+
const value = rest.join('=').trim();
|
|
50
|
+
if (key === 'api_key') LINEAR_API_KEY = value;
|
|
51
|
+
if (key === 'team') TEAM_KEY = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fall back to env vars if not set by config file
|
|
56
|
+
if (!LINEAR_API_KEY) LINEAR_API_KEY = process.env.LINEAR_API_KEY || '';
|
|
57
|
+
if (!TEAM_KEY) TEAM_KEY = process.env.LINEAR_TEAM || '';
|
|
58
|
+
|
|
59
|
+
if (!LINEAR_API_KEY || !TEAM_KEY) {
|
|
60
|
+
console.error(colors.red("Error: No config file found or missing API key or team key. Run 'linear login' first."));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkAuth() {
|
|
66
|
+
if (!LINEAR_API_KEY) {
|
|
67
|
+
console.error(colors.red("Error: Not logged in. Run 'linear login' first."));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function gql(query, variables = {}) {
|
|
73
|
+
let response;
|
|
74
|
+
try {
|
|
75
|
+
response = await fetch(API_URL, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Content-Type': 'application/json',
|
|
79
|
+
'Authorization': LINEAR_API_KEY,
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({ query, variables }),
|
|
82
|
+
});
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(colors.red(`Network error: ${err.message}`));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
console.error(colors.red(`HTTP error: ${response.status} ${response.statusText}`));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const json = await response.json();
|
|
94
|
+
|
|
95
|
+
if (json.errors?.length) {
|
|
96
|
+
console.error(colors.red(`API error: ${json.errors[0].message}`));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return json;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function prompt(question) {
|
|
104
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
105
|
+
return new Promise(resolve => {
|
|
106
|
+
rl.question(question, answer => {
|
|
107
|
+
rl.close();
|
|
108
|
+
resolve(answer);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function suggestTeamKey(teamName) {
|
|
114
|
+
// Generate acronym from first letter of each word
|
|
115
|
+
const words = teamName.trim().split(/\s+/);
|
|
116
|
+
let key = words.map(w => w[0] || '').join('').toUpperCase();
|
|
117
|
+
|
|
118
|
+
// If single word or very short, use first 3-4 chars instead
|
|
119
|
+
if (key.length < 2) {
|
|
120
|
+
key = teamName.trim().slice(0, 4).toUpperCase().replace(/\s+/g, '');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Ensure it's at least 2 chars and max 5
|
|
124
|
+
return key.slice(0, 5) || 'TEAM';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function openBrowser(url) {
|
|
128
|
+
let cmd;
|
|
129
|
+
if (process.platform === 'darwin') {
|
|
130
|
+
cmd = `open "${url}"`;
|
|
131
|
+
} else if (process.platform === 'win32') {
|
|
132
|
+
// start requires cmd /c, and first quoted arg is window title (so pass empty)
|
|
133
|
+
cmd = `cmd /c start "" "${url}"`;
|
|
134
|
+
} else {
|
|
135
|
+
cmd = `xdg-open "${url}"`;
|
|
136
|
+
}
|
|
137
|
+
exec(cmd);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatTable(rows) {
|
|
141
|
+
if (rows.length === 0) return '';
|
|
142
|
+
const colWidths = [];
|
|
143
|
+
for (const row of rows) {
|
|
144
|
+
row.forEach((cell, i) => {
|
|
145
|
+
colWidths[i] = Math.max(colWidths[i] || 0, String(cell).length);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return rows.map(row =>
|
|
149
|
+
row.map((cell, i) => String(cell).padEnd(colWidths[i])).join(' ')
|
|
150
|
+
).join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseArgs(args, flags = {}) {
|
|
154
|
+
const result = { _: [] };
|
|
155
|
+
let i = 0;
|
|
156
|
+
while (i < args.length) {
|
|
157
|
+
const arg = args[i];
|
|
158
|
+
if (arg.startsWith('--') || arg.startsWith('-')) {
|
|
159
|
+
const key = arg.replace(/^-+/, '');
|
|
160
|
+
const flagDef = flags[key];
|
|
161
|
+
if (flagDef === 'boolean') {
|
|
162
|
+
result[key] = true;
|
|
163
|
+
} else {
|
|
164
|
+
const value = args[++i];
|
|
165
|
+
if (value === undefined || value.startsWith('-')) {
|
|
166
|
+
console.error(colors.red(`Error: --${key} requires a value`));
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
result[key] = value;
|
|
170
|
+
}
|
|
171
|
+
} else {
|
|
172
|
+
result._.push(arg);
|
|
173
|
+
}
|
|
174
|
+
i++;
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// ISSUES
|
|
181
|
+
// ============================================================================
|
|
182
|
+
|
|
183
|
+
async function cmdIssues(args) {
|
|
184
|
+
const opts = parseArgs(args, {
|
|
185
|
+
unblocked: 'boolean', u: 'boolean',
|
|
186
|
+
all: 'boolean', a: 'boolean',
|
|
187
|
+
open: 'boolean', o: 'boolean',
|
|
188
|
+
mine: 'boolean', m: 'boolean',
|
|
189
|
+
'in-progress': 'boolean',
|
|
190
|
+
project: 'string', p: 'string',
|
|
191
|
+
state: 'string', s: 'string',
|
|
192
|
+
label: 'string', l: 'string',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const inProgress = opts['in-progress'];
|
|
196
|
+
const unblocked = opts.unblocked || opts.u;
|
|
197
|
+
const allStates = opts.all || opts.a;
|
|
198
|
+
const openOnly = opts.open || opts.o;
|
|
199
|
+
const mineOnly = opts.mine || opts.m;
|
|
200
|
+
const stateFilter = inProgress ? 'started' : (opts.state || opts.s || 'backlog');
|
|
201
|
+
const labelFilter = opts.label || opts.l;
|
|
202
|
+
|
|
203
|
+
// Get current user ID for filtering/sorting
|
|
204
|
+
const viewerResult = await gql('{ viewer { id } }');
|
|
205
|
+
const viewerId = viewerResult.data?.viewer?.id;
|
|
206
|
+
|
|
207
|
+
const query = `{
|
|
208
|
+
team(id: "${TEAM_KEY}") {
|
|
209
|
+
issues(first: 100) {
|
|
210
|
+
nodes {
|
|
211
|
+
identifier
|
|
212
|
+
title
|
|
213
|
+
priority
|
|
214
|
+
state { name type }
|
|
215
|
+
project { name }
|
|
216
|
+
assignee { id name }
|
|
217
|
+
labels { nodes { name } }
|
|
218
|
+
relations(first: 20) {
|
|
219
|
+
nodes {
|
|
220
|
+
type
|
|
221
|
+
relatedIssue { identifier state { type } }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}`;
|
|
228
|
+
|
|
229
|
+
const result = await gql(query);
|
|
230
|
+
let issues = result.data?.team?.issues?.nodes || [];
|
|
231
|
+
|
|
232
|
+
// Check if any issues have assignees (to decide whether to show column)
|
|
233
|
+
const hasAssignees = issues.some(i => i.assignee);
|
|
234
|
+
|
|
235
|
+
// Sort: assigned to you first, then by identifier
|
|
236
|
+
issues.sort((a, b) => {
|
|
237
|
+
const aIsMine = a.assignee?.id === viewerId;
|
|
238
|
+
const bIsMine = b.assignee?.id === viewerId;
|
|
239
|
+
if (aIsMine && !bIsMine) return -1;
|
|
240
|
+
if (!aIsMine && bIsMine) return 1;
|
|
241
|
+
return a.identifier.localeCompare(b.identifier);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Helper to format issue row
|
|
245
|
+
const formatRow = (i) => {
|
|
246
|
+
const row = [
|
|
247
|
+
i.identifier,
|
|
248
|
+
i.title,
|
|
249
|
+
i.state.name,
|
|
250
|
+
i.project?.name || '-'
|
|
251
|
+
];
|
|
252
|
+
if (hasAssignees) {
|
|
253
|
+
const assignee = i.assignee?.id === viewerId ? 'you' : (i.assignee?.name || '-');
|
|
254
|
+
row.push(assignee);
|
|
255
|
+
}
|
|
256
|
+
return row;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Helper to apply common filters (mine, label)
|
|
260
|
+
const applyFilters = (list) => {
|
|
261
|
+
let filtered = list;
|
|
262
|
+
if (mineOnly) {
|
|
263
|
+
filtered = filtered.filter(i => i.assignee?.id === viewerId);
|
|
264
|
+
}
|
|
265
|
+
if (labelFilter) {
|
|
266
|
+
filtered = filtered.filter(i =>
|
|
267
|
+
i.labels?.nodes?.some(l => l.name.toLowerCase() === labelFilter.toLowerCase())
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return filtered;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if (unblocked) {
|
|
274
|
+
// Collect all blocked issue IDs
|
|
275
|
+
const blocked = new Set();
|
|
276
|
+
for (const issue of issues) {
|
|
277
|
+
for (const rel of issue.relations?.nodes || []) {
|
|
278
|
+
if (rel.type === 'blocks') {
|
|
279
|
+
blocked.add(rel.relatedIssue.identifier);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Filter to unblocked, non-completed issues
|
|
285
|
+
let filtered = issues.filter(i =>
|
|
286
|
+
!['completed', 'canceled'].includes(i.state.type) &&
|
|
287
|
+
!blocked.has(i.identifier)
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
filtered = applyFilters(filtered);
|
|
291
|
+
|
|
292
|
+
console.log(colors.bold('Unblocked Issues:\n'));
|
|
293
|
+
console.log(formatTable(filtered.map(formatRow)));
|
|
294
|
+
} else if (allStates) {
|
|
295
|
+
let filtered = applyFilters(issues);
|
|
296
|
+
|
|
297
|
+
console.log(colors.bold('All Issues:\n'));
|
|
298
|
+
console.log(formatTable(filtered.map(formatRow)));
|
|
299
|
+
} else if (openOnly) {
|
|
300
|
+
// Open = everything except completed/canceled
|
|
301
|
+
let filtered = issues.filter(i =>
|
|
302
|
+
!['completed', 'canceled'].includes(i.state.type)
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
filtered = applyFilters(filtered);
|
|
306
|
+
|
|
307
|
+
console.log(colors.bold('Open Issues:\n'));
|
|
308
|
+
console.log(formatTable(filtered.map(formatRow)));
|
|
309
|
+
} else {
|
|
310
|
+
let filtered = issues.filter(i =>
|
|
311
|
+
i.state.type === stateFilter || i.state.name.toLowerCase() === stateFilter.toLowerCase()
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
filtered = applyFilters(filtered);
|
|
315
|
+
|
|
316
|
+
console.log(colors.bold(`Issues (${stateFilter}):\n`));
|
|
317
|
+
console.log(formatTable(filtered.map(formatRow)));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function cmdIssueShow(args) {
|
|
322
|
+
const issueId = args[0];
|
|
323
|
+
if (!issueId) {
|
|
324
|
+
console.error(colors.red('Error: Issue ID required'));
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Enhanced query to get parent context with siblings
|
|
329
|
+
const query = `{
|
|
330
|
+
issue(id: "${issueId}") {
|
|
331
|
+
identifier
|
|
332
|
+
title
|
|
333
|
+
description
|
|
334
|
+
state { name }
|
|
335
|
+
priority
|
|
336
|
+
project { name }
|
|
337
|
+
labels { nodes { name } }
|
|
338
|
+
assignee { name }
|
|
339
|
+
parent {
|
|
340
|
+
identifier
|
|
341
|
+
title
|
|
342
|
+
description
|
|
343
|
+
children { nodes { identifier title state { name } } }
|
|
344
|
+
parent {
|
|
345
|
+
identifier
|
|
346
|
+
title
|
|
347
|
+
children { nodes { identifier title state { name } } }
|
|
348
|
+
parent {
|
|
349
|
+
identifier
|
|
350
|
+
title
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
children { nodes { identifier title state { name } } }
|
|
355
|
+
relations(first: 20) {
|
|
356
|
+
nodes {
|
|
357
|
+
type
|
|
358
|
+
relatedIssue { identifier title state { name } }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
comments { nodes { body createdAt user { name } } }
|
|
362
|
+
}
|
|
363
|
+
}`;
|
|
364
|
+
|
|
365
|
+
const result = await gql(query);
|
|
366
|
+
const issue = result.data?.issue;
|
|
367
|
+
|
|
368
|
+
if (!issue) {
|
|
369
|
+
console.error(colors.red(`Issue not found: ${issueId}`));
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
console.log(`# ${issue.identifier}: ${issue.title}\n`);
|
|
374
|
+
console.log(`State: ${issue.state.name}`);
|
|
375
|
+
console.log(`Priority: ${issue.priority || 'None'}`);
|
|
376
|
+
console.log(`Project: ${issue.project?.name || 'None'}`);
|
|
377
|
+
console.log(`Assignee: ${issue.assignee?.name || 'Unassigned'}`);
|
|
378
|
+
console.log(`Labels: ${issue.labels.nodes.map(l => l.name).join(', ') || 'None'}`);
|
|
379
|
+
|
|
380
|
+
// Show parent context with siblings (where you are in the larger work)
|
|
381
|
+
if (issue.parent) {
|
|
382
|
+
console.log('\n## Context\n');
|
|
383
|
+
|
|
384
|
+
// Build parent chain (walk up)
|
|
385
|
+
const parentChain = [];
|
|
386
|
+
let current = issue.parent;
|
|
387
|
+
while (current) {
|
|
388
|
+
parentChain.unshift(current);
|
|
389
|
+
current = current.parent;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Show parent chain
|
|
393
|
+
for (let i = 0; i < parentChain.length; i++) {
|
|
394
|
+
const parent = parentChain[i];
|
|
395
|
+
const indent = ' '.repeat(i);
|
|
396
|
+
console.log(`${indent}${colors.bold(parent.identifier)}: ${parent.title}`);
|
|
397
|
+
|
|
398
|
+
// Show siblings at each level (children of this parent)
|
|
399
|
+
const siblings = parent.children?.nodes || [];
|
|
400
|
+
for (const sibling of siblings) {
|
|
401
|
+
const sibIndent = ' '.repeat(i + 1);
|
|
402
|
+
const isCurrent = sibling.identifier === issue.identifier;
|
|
403
|
+
const isDirectParent = i === parentChain.length - 1;
|
|
404
|
+
|
|
405
|
+
if (isCurrent && isDirectParent) {
|
|
406
|
+
// This is the current issue - highlight it
|
|
407
|
+
console.log(`${sibIndent}${colors.green('→')} [${sibling.state.name}] ${colors.green(sibling.identifier)}: ${sibling.title} ${colors.green('← you are here')}`);
|
|
408
|
+
} else {
|
|
409
|
+
console.log(`${sibIndent}- [${sibling.state.name}] ${sibling.identifier}: ${sibling.title}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Show parent description if available (most immediate parent)
|
|
415
|
+
const immediateParent = parentChain[parentChain.length - 1];
|
|
416
|
+
if (immediateParent.description) {
|
|
417
|
+
console.log(`\n### Parent Description (${immediateParent.identifier})\n`);
|
|
418
|
+
// Show truncated description
|
|
419
|
+
const desc = immediateParent.description;
|
|
420
|
+
const truncated = desc.length > 500 ? desc.slice(0, 500) + '...' : desc;
|
|
421
|
+
console.log(colors.gray(truncated));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (issue.children.nodes.length > 0) {
|
|
426
|
+
console.log('\n## Sub-issues\n');
|
|
427
|
+
for (const child of issue.children.nodes) {
|
|
428
|
+
console.log(` - [${child.state.name}] ${child.identifier}: ${child.title}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const blockedBy = issue.relations.nodes.filter(r => r.type === 'is_blocked_by');
|
|
433
|
+
if (blockedBy.length > 0) {
|
|
434
|
+
console.log('\n## Blocked by\n');
|
|
435
|
+
for (const rel of blockedBy) {
|
|
436
|
+
console.log(` - ${rel.relatedIssue.identifier}: ${rel.relatedIssue.title}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const blocks = issue.relations.nodes.filter(r => r.type === 'blocks');
|
|
441
|
+
if (blocks.length > 0) {
|
|
442
|
+
console.log('\n## Blocks\n');
|
|
443
|
+
for (const rel of blocks) {
|
|
444
|
+
console.log(` - ${rel.relatedIssue.identifier}: ${rel.relatedIssue.title}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
console.log('\n## Description\n');
|
|
449
|
+
console.log(issue.description || 'No description');
|
|
450
|
+
|
|
451
|
+
if (issue.comments.nodes.length > 0) {
|
|
452
|
+
console.log('\n## Comments\n');
|
|
453
|
+
for (const comment of issue.comments.nodes) {
|
|
454
|
+
const date = comment.createdAt.split('T')[0];
|
|
455
|
+
console.log(`**${comment.user.name}** (${date}):`);
|
|
456
|
+
console.log(comment.body);
|
|
457
|
+
console.log('');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// T-shirt size to Linear estimate mapping
|
|
463
|
+
const ESTIMATE_MAP = {
|
|
464
|
+
'xs': 0,
|
|
465
|
+
's': 1,
|
|
466
|
+
'm': 2,
|
|
467
|
+
'l': 3,
|
|
468
|
+
'xl': 5,
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
async function cmdIssueCreate(args) {
|
|
472
|
+
const opts = parseArgs(args, {
|
|
473
|
+
title: 'string', t: 'string',
|
|
474
|
+
description: 'string', d: 'string',
|
|
475
|
+
project: 'string', p: 'string',
|
|
476
|
+
parent: 'string',
|
|
477
|
+
state: 'string', s: 'string',
|
|
478
|
+
assign: 'boolean',
|
|
479
|
+
estimate: 'string', e: 'string',
|
|
480
|
+
label: 'string', l: 'string',
|
|
481
|
+
blocks: 'string',
|
|
482
|
+
'blocked-by': 'string',
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const title = opts.title || opts.t || opts._[0];
|
|
486
|
+
const description = opts.description || opts.d || '';
|
|
487
|
+
const project = opts.project || opts.p;
|
|
488
|
+
const parent = opts.parent;
|
|
489
|
+
const shouldAssign = opts.assign;
|
|
490
|
+
const estimate = (opts.estimate || opts.e || '').toLowerCase();
|
|
491
|
+
const labelName = opts.label || opts.l;
|
|
492
|
+
const blocksIssue = opts.blocks;
|
|
493
|
+
const blockedByIssue = opts['blocked-by'];
|
|
494
|
+
|
|
495
|
+
if (!title) {
|
|
496
|
+
console.error(colors.red('Error: Title is required'));
|
|
497
|
+
console.error('Usage: linear issue create --title "Issue title" [--project "..."] [--parent ISSUE-X] [--estimate M] [--assign] [--label bug] [--blocks ISSUE-X] [--blocked-by ISSUE-X]');
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Validate estimate
|
|
502
|
+
if (estimate && !ESTIMATE_MAP.hasOwnProperty(estimate)) {
|
|
503
|
+
console.error(colors.red(`Error: Invalid estimate "${estimate}". Use: XS, S, M, L, or XL`));
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Get team UUID (required for mutations)
|
|
508
|
+
const teamResult = await gql(`{ team(id: "${TEAM_KEY}") { id } }`);
|
|
509
|
+
const teamId = teamResult.data?.team?.id;
|
|
510
|
+
|
|
511
|
+
if (!teamId) {
|
|
512
|
+
console.error(colors.red(`Error: Team not found: ${TEAM_KEY}`));
|
|
513
|
+
process.exit(1);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Look up project ID
|
|
517
|
+
let projectId = null;
|
|
518
|
+
if (project) {
|
|
519
|
+
const projectsResult = await gql(`{
|
|
520
|
+
team(id: "${TEAM_KEY}") {
|
|
521
|
+
projects(first: 50) { nodes { id name } }
|
|
522
|
+
}
|
|
523
|
+
}`);
|
|
524
|
+
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
525
|
+
const match = projects.find(p => p.name.toLowerCase().includes(project.toLowerCase()));
|
|
526
|
+
if (match) projectId = match.id;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Look up label ID
|
|
530
|
+
let labelIds = [];
|
|
531
|
+
if (labelName) {
|
|
532
|
+
const labelsResult = await gql(`{
|
|
533
|
+
team(id: "${TEAM_KEY}") {
|
|
534
|
+
labels(first: 100) { nodes { id name } }
|
|
535
|
+
}
|
|
536
|
+
}`);
|
|
537
|
+
const labels = labelsResult.data?.team?.labels?.nodes || [];
|
|
538
|
+
const match = labels.find(l => l.name.toLowerCase() === labelName.toLowerCase());
|
|
539
|
+
if (match) {
|
|
540
|
+
labelIds.push(match.id);
|
|
541
|
+
} else {
|
|
542
|
+
console.error(colors.yellow(`Warning: Label "${labelName}" not found. Creating issue without label.`));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Get current user ID if assigning
|
|
547
|
+
let assigneeId = null;
|
|
548
|
+
if (shouldAssign) {
|
|
549
|
+
const viewerResult = await gql('{ viewer { id } }');
|
|
550
|
+
assigneeId = viewerResult.data?.viewer?.id;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const mutation = `
|
|
554
|
+
mutation($input: IssueCreateInput!) {
|
|
555
|
+
issueCreate(input: $input) {
|
|
556
|
+
success
|
|
557
|
+
issue { identifier title url estimate }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
`;
|
|
561
|
+
|
|
562
|
+
const input = { teamId, title, description };
|
|
563
|
+
if (projectId) input.projectId = projectId;
|
|
564
|
+
if (parent) input.parentId = parent;
|
|
565
|
+
if (assigneeId) input.assigneeId = assigneeId;
|
|
566
|
+
if (estimate) input.estimate = ESTIMATE_MAP[estimate];
|
|
567
|
+
if (labelIds.length > 0) input.labelIds = labelIds;
|
|
568
|
+
|
|
569
|
+
const result = await gql(mutation, { input });
|
|
570
|
+
|
|
571
|
+
if (result.data?.issueCreate?.success) {
|
|
572
|
+
const issue = result.data.issueCreate.issue;
|
|
573
|
+
const estLabel = estimate ? ` [${estimate.toUpperCase()}]` : '';
|
|
574
|
+
console.log(colors.green(`Created: ${issue.identifier}${estLabel}`));
|
|
575
|
+
console.log(issue.url);
|
|
576
|
+
|
|
577
|
+
// Create blocking relations if specified
|
|
578
|
+
if (blocksIssue || blockedByIssue) {
|
|
579
|
+
const relationMutation = `
|
|
580
|
+
mutation($input: IssueRelationCreateInput!) {
|
|
581
|
+
issueRelationCreate(input: $input) { success }
|
|
582
|
+
}
|
|
583
|
+
`;
|
|
584
|
+
|
|
585
|
+
if (blocksIssue) {
|
|
586
|
+
// This issue blocks another issue
|
|
587
|
+
await gql(relationMutation, {
|
|
588
|
+
input: { issueId: issue.identifier, relatedIssueId: blocksIssue, type: 'blocks' }
|
|
589
|
+
});
|
|
590
|
+
console.log(colors.gray(` → blocks ${blocksIssue}`));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (blockedByIssue) {
|
|
594
|
+
// This issue is blocked by another issue
|
|
595
|
+
await gql(relationMutation, {
|
|
596
|
+
input: { issueId: blockedByIssue, relatedIssueId: issue.identifier, type: 'blocks' }
|
|
597
|
+
});
|
|
598
|
+
console.log(colors.gray(` → blocked by ${blockedByIssue}`));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
console.error(colors.red('Failed to create issue'));
|
|
603
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
604
|
+
process.exit(1);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function cmdIssueUpdate(args) {
|
|
609
|
+
const issueId = args[0];
|
|
610
|
+
if (!issueId) {
|
|
611
|
+
console.error(colors.red('Error: Issue ID required'));
|
|
612
|
+
process.exit(1);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const opts = parseArgs(args.slice(1), {
|
|
616
|
+
title: 'string', t: 'string',
|
|
617
|
+
description: 'string', d: 'string',
|
|
618
|
+
state: 'string', s: 'string',
|
|
619
|
+
append: 'string', a: 'string',
|
|
620
|
+
blocks: 'string',
|
|
621
|
+
'blocked-by': 'string',
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const blocksIssue = opts.blocks;
|
|
625
|
+
const blockedByIssue = opts['blocked-by'];
|
|
626
|
+
const input = {};
|
|
627
|
+
|
|
628
|
+
if (opts.title || opts.t) input.title = opts.title || opts.t;
|
|
629
|
+
|
|
630
|
+
// Handle append
|
|
631
|
+
if (opts.append || opts.a) {
|
|
632
|
+
const currentResult = await gql(`{ issue(id: "${issueId}") { description } }`);
|
|
633
|
+
const current = currentResult.data?.issue?.description || '';
|
|
634
|
+
input.description = current + '\n\n' + (opts.append || opts.a);
|
|
635
|
+
} else if (opts.description || opts.d) {
|
|
636
|
+
input.description = opts.description || opts.d;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Handle state
|
|
640
|
+
if (opts.state || opts.s) {
|
|
641
|
+
const stateName = opts.state || opts.s;
|
|
642
|
+
const statesResult = await gql(`{
|
|
643
|
+
team(id: "${TEAM_KEY}") {
|
|
644
|
+
states { nodes { id name } }
|
|
645
|
+
}
|
|
646
|
+
}`);
|
|
647
|
+
const states = statesResult.data?.team?.states?.nodes || [];
|
|
648
|
+
const match = states.find(s => s.name.toLowerCase().includes(stateName.toLowerCase()));
|
|
649
|
+
if (match) input.stateId = match.id;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Handle blocking relations (can be set even without other updates)
|
|
653
|
+
const hasRelationUpdates = blocksIssue || blockedByIssue;
|
|
654
|
+
|
|
655
|
+
if (Object.keys(input).length === 0 && !hasRelationUpdates) {
|
|
656
|
+
console.error(colors.red('Error: No updates specified'));
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Update issue fields if any
|
|
661
|
+
if (Object.keys(input).length > 0) {
|
|
662
|
+
const mutation = `
|
|
663
|
+
mutation($id: String!, $input: IssueUpdateInput!) {
|
|
664
|
+
issueUpdate(id: $id, input: $input) {
|
|
665
|
+
success
|
|
666
|
+
issue { identifier title state { name } }
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
`;
|
|
670
|
+
|
|
671
|
+
const result = await gql(mutation, { id: issueId, input });
|
|
672
|
+
|
|
673
|
+
if (result.data?.issueUpdate?.success) {
|
|
674
|
+
const issue = result.data.issueUpdate.issue;
|
|
675
|
+
console.log(colors.green(`Updated: ${issue.identifier}`));
|
|
676
|
+
console.log(`${issue.identifier}: ${issue.title} [${issue.state.name}]`);
|
|
677
|
+
} else {
|
|
678
|
+
console.error(colors.red('Failed to update issue'));
|
|
679
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Create blocking relations if specified
|
|
685
|
+
if (hasRelationUpdates) {
|
|
686
|
+
const relationMutation = `
|
|
687
|
+
mutation($input: IssueRelationCreateInput!) {
|
|
688
|
+
issueRelationCreate(input: $input) { success }
|
|
689
|
+
}
|
|
690
|
+
`;
|
|
691
|
+
|
|
692
|
+
if (blocksIssue) {
|
|
693
|
+
await gql(relationMutation, {
|
|
694
|
+
input: { issueId: issueId, relatedIssueId: blocksIssue, type: 'blocks' }
|
|
695
|
+
});
|
|
696
|
+
console.log(colors.green(`${issueId} now blocks ${blocksIssue}`));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (blockedByIssue) {
|
|
700
|
+
await gql(relationMutation, {
|
|
701
|
+
input: { issueId: blockedByIssue, relatedIssueId: issueId, type: 'blocks' }
|
|
702
|
+
});
|
|
703
|
+
console.log(colors.green(`${issueId} now blocked by ${blockedByIssue}`));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function cmdIssueClose(args) {
|
|
709
|
+
const issueId = args[0];
|
|
710
|
+
if (!issueId) {
|
|
711
|
+
console.error(colors.red('Error: Issue ID required'));
|
|
712
|
+
process.exit(1);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Find completed state
|
|
716
|
+
const statesResult = await gql(`{
|
|
717
|
+
team(id: "${TEAM_KEY}") {
|
|
718
|
+
states { nodes { id name type } }
|
|
719
|
+
}
|
|
720
|
+
}`);
|
|
721
|
+
const states = statesResult.data?.team?.states?.nodes || [];
|
|
722
|
+
const doneState = states.find(s => s.type === 'completed');
|
|
723
|
+
|
|
724
|
+
if (!doneState) {
|
|
725
|
+
console.error(colors.red('Error: Could not find completed state'));
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const mutation = `
|
|
730
|
+
mutation($id: String!, $input: IssueUpdateInput!) {
|
|
731
|
+
issueUpdate(id: $id, input: $input) {
|
|
732
|
+
success
|
|
733
|
+
issue { identifier }
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
`;
|
|
737
|
+
|
|
738
|
+
const result = await gql(mutation, { id: issueId, input: { stateId: doneState.id } });
|
|
739
|
+
|
|
740
|
+
if (result.data?.issueUpdate?.success) {
|
|
741
|
+
console.log(colors.green(`Closed: ${issueId}`));
|
|
742
|
+
} else {
|
|
743
|
+
console.error(colors.red('Failed to close issue'));
|
|
744
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
745
|
+
process.exit(1);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function cmdIssueStart(args) {
|
|
750
|
+
const issueId = args[0];
|
|
751
|
+
if (!issueId) {
|
|
752
|
+
console.error(colors.red('Error: Issue ID required'));
|
|
753
|
+
process.exit(1);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Get current user and "In Progress" state
|
|
757
|
+
const dataResult = await gql(`{
|
|
758
|
+
viewer { id }
|
|
759
|
+
team(id: "${TEAM_KEY}") {
|
|
760
|
+
states { nodes { id name type } }
|
|
761
|
+
}
|
|
762
|
+
}`);
|
|
763
|
+
|
|
764
|
+
const viewerId = dataResult.data?.viewer?.id;
|
|
765
|
+
const states = dataResult.data?.team?.states?.nodes || [];
|
|
766
|
+
const inProgressState = states.find(s => s.type === 'started');
|
|
767
|
+
|
|
768
|
+
if (!inProgressState) {
|
|
769
|
+
console.error(colors.red('Error: Could not find "In Progress" state'));
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const mutation = `
|
|
774
|
+
mutation($id: String!, $input: IssueUpdateInput!) {
|
|
775
|
+
issueUpdate(id: $id, input: $input) {
|
|
776
|
+
success
|
|
777
|
+
issue { identifier title state { name } }
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
`;
|
|
781
|
+
|
|
782
|
+
const result = await gql(mutation, {
|
|
783
|
+
id: issueId,
|
|
784
|
+
input: { stateId: inProgressState.id, assigneeId: viewerId }
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
if (result.data?.issueUpdate?.success) {
|
|
788
|
+
const issue = result.data.issueUpdate.issue;
|
|
789
|
+
console.log(colors.green(`Started: ${issue.identifier}`));
|
|
790
|
+
console.log(`${issue.identifier}: ${issue.title} [${issue.state.name}]`);
|
|
791
|
+
} else {
|
|
792
|
+
console.error(colors.red('Failed to start issue'));
|
|
793
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function cmdIssueComment(args) {
|
|
799
|
+
const issueId = args[0];
|
|
800
|
+
const body = args.slice(1).join(' ');
|
|
801
|
+
|
|
802
|
+
if (!issueId || !body) {
|
|
803
|
+
console.error(colors.red('Error: Issue ID and comment body required'));
|
|
804
|
+
console.error('Usage: linear issue comment ISSUE-1 "Comment text"');
|
|
805
|
+
process.exit(1);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const mutation = `
|
|
809
|
+
mutation($input: CommentCreateInput!) {
|
|
810
|
+
commentCreate(input: $input) {
|
|
811
|
+
success
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
`;
|
|
815
|
+
|
|
816
|
+
const result = await gql(mutation, { input: { issueId, body } });
|
|
817
|
+
|
|
818
|
+
if (result.data?.commentCreate?.success) {
|
|
819
|
+
console.log(colors.green(`Comment added to ${issueId}`));
|
|
820
|
+
} else {
|
|
821
|
+
console.error(colors.red('Failed to add comment'));
|
|
822
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
823
|
+
process.exit(1);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ============================================================================
|
|
828
|
+
// PROJECTS
|
|
829
|
+
// ============================================================================
|
|
830
|
+
|
|
831
|
+
async function cmdProjects(args) {
|
|
832
|
+
const opts = parseArgs(args, { all: 'boolean', a: 'boolean' });
|
|
833
|
+
const showAll = opts.all || opts.a;
|
|
834
|
+
|
|
835
|
+
const query = `{
|
|
836
|
+
team(id: "${TEAM_KEY}") {
|
|
837
|
+
projects(first: 50) {
|
|
838
|
+
nodes { id name description state progress }
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}`;
|
|
842
|
+
|
|
843
|
+
const result = await gql(query);
|
|
844
|
+
let projects = result.data?.team?.projects?.nodes || [];
|
|
845
|
+
|
|
846
|
+
if (!showAll) {
|
|
847
|
+
projects = projects.filter(p => !['completed', 'canceled'].includes(p.state));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
console.log(colors.bold('Projects:\n'));
|
|
851
|
+
const rows = projects.map(p => [
|
|
852
|
+
p.name,
|
|
853
|
+
p.state,
|
|
854
|
+
`${Math.floor(p.progress * 100)}%`
|
|
855
|
+
]);
|
|
856
|
+
console.log(formatTable(rows));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
async function cmdProjectShow(args) {
|
|
860
|
+
const projectName = args[0];
|
|
861
|
+
if (!projectName) {
|
|
862
|
+
console.error(colors.red('Error: Project name required'));
|
|
863
|
+
process.exit(1);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const query = `{
|
|
867
|
+
team(id: "${TEAM_KEY}") {
|
|
868
|
+
projects(first: 50) {
|
|
869
|
+
nodes {
|
|
870
|
+
id name description state progress
|
|
871
|
+
issues { nodes { identifier title state { name } } }
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}`;
|
|
876
|
+
|
|
877
|
+
const result = await gql(query);
|
|
878
|
+
const projects = result.data?.team?.projects?.nodes || [];
|
|
879
|
+
const project = projects.find(p => p.name.includes(projectName));
|
|
880
|
+
|
|
881
|
+
if (!project) {
|
|
882
|
+
console.error(colors.red(`Project not found: ${projectName}`));
|
|
883
|
+
process.exit(1);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
console.log(`# ${project.name}\n`);
|
|
887
|
+
console.log(`State: ${project.state}`);
|
|
888
|
+
console.log(`Progress: ${Math.floor(project.progress * 100)}%`);
|
|
889
|
+
console.log(`\n## Description\n${project.description || 'No description'}`);
|
|
890
|
+
|
|
891
|
+
// Group issues by state
|
|
892
|
+
const byState = {};
|
|
893
|
+
for (const issue of project.issues.nodes) {
|
|
894
|
+
const state = issue.state.name;
|
|
895
|
+
if (!byState[state]) byState[state] = [];
|
|
896
|
+
byState[state].push(issue);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
console.log('\n## Issues\n');
|
|
900
|
+
for (const [state, issues] of Object.entries(byState)) {
|
|
901
|
+
console.log(`### ${state}`);
|
|
902
|
+
for (const issue of issues) {
|
|
903
|
+
console.log(`- ${issue.identifier}: ${issue.title}`);
|
|
904
|
+
}
|
|
905
|
+
console.log('');
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async function cmdProjectCreate(args) {
|
|
910
|
+
const opts = parseArgs(args, {
|
|
911
|
+
name: 'string', n: 'string',
|
|
912
|
+
description: 'string', d: 'string',
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
const name = opts.name || opts.n || opts._[0];
|
|
916
|
+
const description = opts.description || opts.d || '';
|
|
917
|
+
|
|
918
|
+
if (!name) {
|
|
919
|
+
console.error(colors.red('Error: Name is required'));
|
|
920
|
+
console.error('Usage: linear project create "Project name" [--description "..."]');
|
|
921
|
+
process.exit(1);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Get team UUID
|
|
925
|
+
const teamResult = await gql(`{ team(id: "${TEAM_KEY}") { id } }`);
|
|
926
|
+
const teamId = teamResult.data?.team?.id;
|
|
927
|
+
|
|
928
|
+
const mutation = `
|
|
929
|
+
mutation($input: ProjectCreateInput!) {
|
|
930
|
+
projectCreate(input: $input) {
|
|
931
|
+
success
|
|
932
|
+
project { id name url }
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
`;
|
|
936
|
+
|
|
937
|
+
const result = await gql(mutation, {
|
|
938
|
+
input: { name, description, teamIds: [teamId] }
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
if (result.data?.projectCreate?.success) {
|
|
942
|
+
const project = result.data.projectCreate.project;
|
|
943
|
+
console.log(colors.green(`Created project: ${project.name}`));
|
|
944
|
+
console.log(project.url);
|
|
945
|
+
} else {
|
|
946
|
+
console.error(colors.red('Failed to create project'));
|
|
947
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async function cmdProjectComplete(args) {
|
|
953
|
+
const projectName = args[0];
|
|
954
|
+
if (!projectName) {
|
|
955
|
+
console.error(colors.red('Error: Project name required'));
|
|
956
|
+
process.exit(1);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Find project
|
|
960
|
+
const projectsResult = await gql(`{
|
|
961
|
+
team(id: "${TEAM_KEY}") {
|
|
962
|
+
projects(first: 50) { nodes { id name } }
|
|
963
|
+
}
|
|
964
|
+
}`);
|
|
965
|
+
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
966
|
+
const project = projects.find(p => p.name.includes(projectName));
|
|
967
|
+
|
|
968
|
+
if (!project) {
|
|
969
|
+
console.error(colors.red(`Project not found: ${projectName}`));
|
|
970
|
+
process.exit(1);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const mutation = `
|
|
974
|
+
mutation($id: String!, $input: ProjectUpdateInput!) {
|
|
975
|
+
projectUpdate(id: $id, input: $input) {
|
|
976
|
+
success
|
|
977
|
+
project { name state }
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
`;
|
|
981
|
+
|
|
982
|
+
const result = await gql(mutation, { id: project.id, input: { state: 'completed' } });
|
|
983
|
+
|
|
984
|
+
if (result.data?.projectUpdate?.success) {
|
|
985
|
+
console.log(colors.green(`Completed project: ${projectName}`));
|
|
986
|
+
} else {
|
|
987
|
+
console.error(colors.red('Failed to complete project'));
|
|
988
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
989
|
+
process.exit(1);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// ============================================================================
|
|
994
|
+
// LABELS
|
|
995
|
+
// ============================================================================
|
|
996
|
+
|
|
997
|
+
async function cmdLabels() {
|
|
998
|
+
const query = `{
|
|
999
|
+
team(id: "${TEAM_KEY}") {
|
|
1000
|
+
labels(first: 100) {
|
|
1001
|
+
nodes { id name color description }
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}`;
|
|
1005
|
+
|
|
1006
|
+
const result = await gql(query);
|
|
1007
|
+
const labels = result.data?.team?.labels?.nodes || [];
|
|
1008
|
+
|
|
1009
|
+
console.log(colors.bold('Labels:\n'));
|
|
1010
|
+
if (labels.length === 0) {
|
|
1011
|
+
console.log('No labels found. Create one with: linear label create "name"');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const rows = labels.map(l => [
|
|
1016
|
+
l.name,
|
|
1017
|
+
l.description || '-'
|
|
1018
|
+
]);
|
|
1019
|
+
console.log(formatTable(rows));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async function cmdLabelCreate(args) {
|
|
1023
|
+
const opts = parseArgs(args, {
|
|
1024
|
+
name: 'string', n: 'string',
|
|
1025
|
+
description: 'string', d: 'string',
|
|
1026
|
+
color: 'string', c: 'string',
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
const name = opts.name || opts.n || opts._[0];
|
|
1030
|
+
const description = opts.description || opts.d || '';
|
|
1031
|
+
const color = opts.color || opts.c;
|
|
1032
|
+
|
|
1033
|
+
if (!name) {
|
|
1034
|
+
console.error(colors.red('Error: Name is required'));
|
|
1035
|
+
console.error('Usage: linear label create "label name" [--description "..."] [--color "#FF0000"]');
|
|
1036
|
+
process.exit(1);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Get team UUID
|
|
1040
|
+
const teamResult = await gql(`{ team(id: "${TEAM_KEY}") { id } }`);
|
|
1041
|
+
const teamId = teamResult.data?.team?.id;
|
|
1042
|
+
|
|
1043
|
+
const mutation = `
|
|
1044
|
+
mutation($input: IssueLabelCreateInput!) {
|
|
1045
|
+
issueLabelCreate(input: $input) {
|
|
1046
|
+
success
|
|
1047
|
+
issueLabel { id name }
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
`;
|
|
1051
|
+
|
|
1052
|
+
const input = { teamId, name };
|
|
1053
|
+
if (description) input.description = description;
|
|
1054
|
+
if (color) input.color = color;
|
|
1055
|
+
|
|
1056
|
+
const result = await gql(mutation, { input });
|
|
1057
|
+
|
|
1058
|
+
if (result.data?.issueLabelCreate?.success) {
|
|
1059
|
+
console.log(colors.green(`Created label: ${name}`));
|
|
1060
|
+
} else {
|
|
1061
|
+
console.error(colors.red('Failed to create label'));
|
|
1062
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
1063
|
+
process.exit(1);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// ============================================================================
|
|
1068
|
+
// GIT INTEGRATION
|
|
1069
|
+
// ============================================================================
|
|
1070
|
+
|
|
1071
|
+
function slugify(text) {
|
|
1072
|
+
return text
|
|
1073
|
+
.toLowerCase()
|
|
1074
|
+
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with dashes
|
|
1075
|
+
.replace(/^-+|-+$/g, '') // Trim leading/trailing dashes
|
|
1076
|
+
.slice(0, 50); // Limit length
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function detectPackageManager(dir) {
|
|
1080
|
+
if (existsSync(join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
1081
|
+
if (existsSync(join(dir, 'yarn.lock'))) return 'yarn';
|
|
1082
|
+
if (existsSync(join(dir, 'bun.lockb'))) return 'bun';
|
|
1083
|
+
if (existsSync(join(dir, 'package-lock.json'))) return 'npm';
|
|
1084
|
+
if (existsSync(join(dir, 'package.json'))) return 'npm'; // fallback
|
|
1085
|
+
return null;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function copyWorktreeIncludes(repoRoot, worktreePath) {
|
|
1089
|
+
const includeFile = join(repoRoot, '.worktreeinclude');
|
|
1090
|
+
if (!existsSync(includeFile)) return [];
|
|
1091
|
+
|
|
1092
|
+
const patterns = readFileSync(includeFile, 'utf-8')
|
|
1093
|
+
.split('\n')
|
|
1094
|
+
.filter(line => line && !line.startsWith('#'));
|
|
1095
|
+
|
|
1096
|
+
const copied = [];
|
|
1097
|
+
for (const pattern of patterns) {
|
|
1098
|
+
const sourcePath = join(repoRoot, pattern);
|
|
1099
|
+
const destPath = join(worktreePath, pattern);
|
|
1100
|
+
|
|
1101
|
+
if (!existsSync(sourcePath)) continue;
|
|
1102
|
+
|
|
1103
|
+
// Check if the file/dir is gitignored (only copy if it is)
|
|
1104
|
+
try {
|
|
1105
|
+
execSync(`git check-ignore -q "${pattern}"`, { cwd: repoRoot, stdio: 'pipe' });
|
|
1106
|
+
// If we get here, the file IS ignored - copy it
|
|
1107
|
+
const destDir = join(destPath, '..');
|
|
1108
|
+
if (!existsSync(destDir)) {
|
|
1109
|
+
mkdirSync(destDir, { recursive: true });
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Use cp for both files and directories
|
|
1113
|
+
const fileStat = statSync(sourcePath);
|
|
1114
|
+
if (fileStat.isDirectory()) {
|
|
1115
|
+
execSync(`cp -r "${sourcePath}" "${destPath}"`, { stdio: 'pipe' });
|
|
1116
|
+
} else {
|
|
1117
|
+
execSync(`cp "${sourcePath}" "${destPath}"`, { stdio: 'pipe' });
|
|
1118
|
+
}
|
|
1119
|
+
copied.push(pattern);
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
// File is not gitignored or doesn't exist, skip it
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return copied;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function cmdNext(args) {
|
|
1128
|
+
const opts = parseArgs(args, { 'dry-run': 'boolean' });
|
|
1129
|
+
const dryRun = opts['dry-run'];
|
|
1130
|
+
|
|
1131
|
+
// Get repo info
|
|
1132
|
+
let repoRoot;
|
|
1133
|
+
try {
|
|
1134
|
+
repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim();
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
console.error(colors.red('Error: Not in a git repository'));
|
|
1137
|
+
process.exit(1);
|
|
1138
|
+
}
|
|
1139
|
+
const repoName = basename(repoRoot);
|
|
1140
|
+
|
|
1141
|
+
// Get current user ID for sorting
|
|
1142
|
+
const viewerResult = await gql('{ viewer { id } }');
|
|
1143
|
+
const viewerId = viewerResult.data?.viewer?.id;
|
|
1144
|
+
|
|
1145
|
+
// Fetch unblocked issues (reuse logic from cmdIssues --unblocked)
|
|
1146
|
+
const query = `{
|
|
1147
|
+
team(id: "${TEAM_KEY}") {
|
|
1148
|
+
issues(first: 100) {
|
|
1149
|
+
nodes {
|
|
1150
|
+
identifier
|
|
1151
|
+
title
|
|
1152
|
+
priority
|
|
1153
|
+
state { name type }
|
|
1154
|
+
project { name }
|
|
1155
|
+
assignee { id name }
|
|
1156
|
+
relations(first: 20) {
|
|
1157
|
+
nodes {
|
|
1158
|
+
type
|
|
1159
|
+
relatedIssue { identifier state { type } }
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}`;
|
|
1166
|
+
|
|
1167
|
+
const result = await gql(query);
|
|
1168
|
+
let issues = result.data?.team?.issues?.nodes || [];
|
|
1169
|
+
|
|
1170
|
+
// Collect all blocked issue IDs
|
|
1171
|
+
const blocked = new Set();
|
|
1172
|
+
for (const issue of issues) {
|
|
1173
|
+
for (const rel of issue.relations?.nodes || []) {
|
|
1174
|
+
if (rel.type === 'blocks') {
|
|
1175
|
+
blocked.add(rel.relatedIssue.identifier);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Filter to unblocked, non-completed issues
|
|
1181
|
+
issues = issues.filter(i =>
|
|
1182
|
+
!['completed', 'canceled'].includes(i.state.type) &&
|
|
1183
|
+
!blocked.has(i.identifier)
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1186
|
+
// Sort: assigned to you first, then by identifier
|
|
1187
|
+
issues.sort((a, b) => {
|
|
1188
|
+
const aIsMine = a.assignee?.id === viewerId;
|
|
1189
|
+
const bIsMine = b.assignee?.id === viewerId;
|
|
1190
|
+
if (aIsMine && !bIsMine) return -1;
|
|
1191
|
+
if (!aIsMine && bIsMine) return 1;
|
|
1192
|
+
return a.identifier.localeCompare(b.identifier);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// Limit to 10 issues
|
|
1196
|
+
issues = issues.slice(0, 10);
|
|
1197
|
+
|
|
1198
|
+
if (issues.length === 0) {
|
|
1199
|
+
console.error(colors.red('No unblocked issues found'));
|
|
1200
|
+
process.exit(1);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Display issues with numbered selection
|
|
1204
|
+
console.log(colors.bold('Select an issue to work on:\n'));
|
|
1205
|
+
issues.forEach((issue, i) => {
|
|
1206
|
+
const assignee = issue.assignee?.id === viewerId ? colors.green('(you)') : '';
|
|
1207
|
+
const project = issue.project?.name ? colors.gray(`[${issue.project.name}]`) : '';
|
|
1208
|
+
console.log(` ${i + 1}. ${issue.identifier}: ${issue.title} ${assignee} ${project}`);
|
|
1209
|
+
});
|
|
1210
|
+
console.log('');
|
|
1211
|
+
|
|
1212
|
+
// Interactive selection
|
|
1213
|
+
const selection = await prompt('Enter number: ');
|
|
1214
|
+
const idx = parseInt(selection) - 1;
|
|
1215
|
+
|
|
1216
|
+
if (isNaN(idx) || idx < 0 || idx >= issues.length) {
|
|
1217
|
+
console.error(colors.red('Invalid selection'));
|
|
1218
|
+
process.exit(1);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const selectedIssue = issues[idx];
|
|
1222
|
+
const branchName = `${selectedIssue.identifier}-${slugify(selectedIssue.title)}`;
|
|
1223
|
+
const worktreePath = join(homedir(), '.claude-worktrees', repoName, branchName);
|
|
1224
|
+
|
|
1225
|
+
if (dryRun) {
|
|
1226
|
+
console.log(colors.bold('\nDry run - would execute:\n'));
|
|
1227
|
+
console.log(` git worktree add "${worktreePath}" -b "${branchName}"`);
|
|
1228
|
+
console.log(` Copy .worktreeinclude files to worktree`);
|
|
1229
|
+
const pm = detectPackageManager(repoRoot);
|
|
1230
|
+
if (pm) {
|
|
1231
|
+
console.log(` ${pm} install`);
|
|
1232
|
+
}
|
|
1233
|
+
console.log(` cd "${worktreePath}" && claude --plan "/next ${selectedIssue.identifier}"`);
|
|
1234
|
+
process.exit(0);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Create worktree directory parent if needed
|
|
1238
|
+
const worktreeParent = join(homedir(), '.claude-worktrees', repoName);
|
|
1239
|
+
if (!existsSync(worktreeParent)) {
|
|
1240
|
+
mkdirSync(worktreeParent, { recursive: true });
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// Check if worktree already exists
|
|
1244
|
+
if (existsSync(worktreePath)) {
|
|
1245
|
+
console.log(colors.yellow(`\nWorktree already exists: ${worktreePath}`));
|
|
1246
|
+
console.log(`cd "${worktreePath}" && claude --plan "/next ${selectedIssue.identifier}"`);
|
|
1247
|
+
process.exit(0);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Create the worktree
|
|
1251
|
+
console.log(colors.gray(`\nCreating worktree at ${worktreePath}...`));
|
|
1252
|
+
try {
|
|
1253
|
+
execSync(`git worktree add "${worktreePath}" -b "${branchName}"`, {
|
|
1254
|
+
cwd: repoRoot,
|
|
1255
|
+
stdio: 'inherit'
|
|
1256
|
+
});
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
// Branch might already exist, try without -b
|
|
1259
|
+
try {
|
|
1260
|
+
execSync(`git worktree add "${worktreePath}" "${branchName}"`, {
|
|
1261
|
+
cwd: repoRoot,
|
|
1262
|
+
stdio: 'inherit'
|
|
1263
|
+
});
|
|
1264
|
+
} catch (err2) {
|
|
1265
|
+
console.error(colors.red(`Failed to create worktree: ${err2.message}`));
|
|
1266
|
+
process.exit(1);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// Copy .worktreeinclude files
|
|
1271
|
+
const copied = copyWorktreeIncludes(repoRoot, worktreePath);
|
|
1272
|
+
if (copied.length > 0) {
|
|
1273
|
+
console.log(colors.green(`Copied: ${copied.join(', ')}`));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// Detect package manager and install dependencies
|
|
1277
|
+
const pm = detectPackageManager(worktreePath);
|
|
1278
|
+
if (pm) {
|
|
1279
|
+
console.log(colors.gray(`Installing dependencies with ${pm}...`));
|
|
1280
|
+
try {
|
|
1281
|
+
execSync(`${pm} install`, { cwd: worktreePath, stdio: 'inherit' });
|
|
1282
|
+
} catch (err) {
|
|
1283
|
+
console.error(colors.yellow(`Warning: ${pm} install failed, continuing anyway`));
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Output eval-able shell command for the wrapper function
|
|
1288
|
+
console.log(`cd "${worktreePath}" && claude --plan "/next ${selectedIssue.identifier}"`);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
async function cmdBranch(args) {
|
|
1292
|
+
const issueId = args[0];
|
|
1293
|
+
if (!issueId) {
|
|
1294
|
+
console.error(colors.red('Error: Issue ID required'));
|
|
1295
|
+
console.error('Usage: linear branch ISSUE-5');
|
|
1296
|
+
process.exit(1);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Fetch issue title
|
|
1300
|
+
const result = await gql(`{
|
|
1301
|
+
issue(id: "${issueId}") {
|
|
1302
|
+
identifier
|
|
1303
|
+
title
|
|
1304
|
+
}
|
|
1305
|
+
}`);
|
|
1306
|
+
|
|
1307
|
+
const issue = result.data?.issue;
|
|
1308
|
+
if (!issue) {
|
|
1309
|
+
console.error(colors.red(`Issue not found: ${issueId}`));
|
|
1310
|
+
process.exit(1);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Create branch name: ISSUE-5-slugified-title
|
|
1314
|
+
const branchName = `${issue.identifier}-${slugify(issue.title)}`;
|
|
1315
|
+
|
|
1316
|
+
try {
|
|
1317
|
+
// Check for uncommitted changes
|
|
1318
|
+
const status = execSync('git status --porcelain', { encoding: 'utf-8' });
|
|
1319
|
+
if (status.trim()) {
|
|
1320
|
+
console.error(colors.yellow('Warning: You have uncommitted changes'));
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Create and checkout branch
|
|
1324
|
+
execSync(`git checkout -b "${branchName}"`, { stdio: 'inherit' });
|
|
1325
|
+
console.log(colors.green(`\nCreated branch: ${branchName}`));
|
|
1326
|
+
console.log(`\nWorking on: ${issue.identifier} - ${issue.title}`);
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
if (err.message?.includes('not a git repository')) {
|
|
1329
|
+
console.error(colors.red('Error: Not in a git repository'));
|
|
1330
|
+
} else if (err.message?.includes('already exists')) {
|
|
1331
|
+
console.error(colors.red(`Branch '${branchName}' already exists`));
|
|
1332
|
+
console.log(`Try: git checkout ${branchName}`);
|
|
1333
|
+
} else {
|
|
1334
|
+
console.error(colors.red(`Git error: ${err.message}`));
|
|
1335
|
+
}
|
|
1336
|
+
process.exit(1);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// ============================================================================
|
|
1341
|
+
// DONE (Complete work on an issue)
|
|
1342
|
+
// ============================================================================
|
|
1343
|
+
|
|
1344
|
+
function getIssueFromBranch() {
|
|
1345
|
+
try {
|
|
1346
|
+
const branch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
|
|
1347
|
+
// Extract issue ID from branch name (e.g., ISSUE-12-some-title -> ISSUE-12)
|
|
1348
|
+
const match = branch.match(/^([A-Z]+-\d+)/);
|
|
1349
|
+
return match ? match[1] : null;
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function isInWorktree() {
|
|
1356
|
+
try {
|
|
1357
|
+
// In a worktree, git rev-parse --git-dir returns something like /path/to/main/.git/worktrees/branch-name
|
|
1358
|
+
const gitDir = execSync('git rev-parse --git-dir', { encoding: 'utf-8' }).trim();
|
|
1359
|
+
return gitDir.includes('/worktrees/');
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function getMainRepoPath() {
|
|
1366
|
+
try {
|
|
1367
|
+
// Get the path to the main working tree
|
|
1368
|
+
const worktreeList = execSync('git worktree list --porcelain', { encoding: 'utf-8' });
|
|
1369
|
+
const lines = worktreeList.split('\n');
|
|
1370
|
+
// First worktree entry is the main repo
|
|
1371
|
+
for (const line of lines) {
|
|
1372
|
+
if (line.startsWith('worktree ')) {
|
|
1373
|
+
return line.replace('worktree ', '');
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
return null;
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
async function cmdDone(args) {
|
|
1383
|
+
const opts = parseArgs(args, {
|
|
1384
|
+
'no-close': 'boolean',
|
|
1385
|
+
'keep-branch': 'boolean',
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
// Determine issue ID: from argument or from branch name
|
|
1389
|
+
let issueId = opts._[0];
|
|
1390
|
+
if (!issueId) {
|
|
1391
|
+
issueId = getIssueFromBranch();
|
|
1392
|
+
if (!issueId) {
|
|
1393
|
+
console.error(colors.red('Error: Could not detect issue from branch name'));
|
|
1394
|
+
console.error('Usage: linear done [ISSUE-12]');
|
|
1395
|
+
process.exit(1);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const shouldClose = !opts['no-close'];
|
|
1400
|
+
const keepBranch = opts['keep-branch'];
|
|
1401
|
+
const inWorktree = isInWorktree();
|
|
1402
|
+
|
|
1403
|
+
// Verify the issue exists
|
|
1404
|
+
const result = await gql(`{
|
|
1405
|
+
issue(id: "${issueId}") {
|
|
1406
|
+
identifier
|
|
1407
|
+
title
|
|
1408
|
+
state { name type }
|
|
1409
|
+
}
|
|
1410
|
+
}`);
|
|
1411
|
+
|
|
1412
|
+
const issue = result.data?.issue;
|
|
1413
|
+
if (!issue) {
|
|
1414
|
+
console.error(colors.red(`Issue not found: ${issueId}`));
|
|
1415
|
+
process.exit(1);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
console.log(colors.bold(`\nCompleting: ${issue.identifier}: ${issue.title}\n`));
|
|
1419
|
+
|
|
1420
|
+
// Close the issue if not already closed
|
|
1421
|
+
if (shouldClose && issue.state.type !== 'completed') {
|
|
1422
|
+
const statesResult = await gql(`{
|
|
1423
|
+
team(id: "${TEAM_KEY}") {
|
|
1424
|
+
states { nodes { id name type } }
|
|
1425
|
+
}
|
|
1426
|
+
}`);
|
|
1427
|
+
const states = statesResult.data?.team?.states?.nodes || [];
|
|
1428
|
+
const doneState = states.find(s => s.type === 'completed');
|
|
1429
|
+
|
|
1430
|
+
if (doneState) {
|
|
1431
|
+
const closeResult = await gql(`
|
|
1432
|
+
mutation($id: String!, $input: IssueUpdateInput!) {
|
|
1433
|
+
issueUpdate(id: $id, input: $input) { success }
|
|
1434
|
+
}
|
|
1435
|
+
`, { id: issueId, input: { stateId: doneState.id } });
|
|
1436
|
+
|
|
1437
|
+
if (closeResult.data?.issueUpdate?.success) {
|
|
1438
|
+
console.log(colors.green(`✓ Closed ${issueId}`));
|
|
1439
|
+
} else {
|
|
1440
|
+
console.error(colors.yellow(`Warning: Could not close issue`));
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
} else if (issue.state.type === 'completed') {
|
|
1444
|
+
console.log(colors.gray(`Issue already closed`));
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// Handle worktree cleanup
|
|
1448
|
+
if (inWorktree) {
|
|
1449
|
+
const currentDir = process.cwd();
|
|
1450
|
+
const mainRepo = getMainRepoPath();
|
|
1451
|
+
const branchName = execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
|
|
1452
|
+
|
|
1453
|
+
console.log(colors.gray(`\nWorktree detected: ${currentDir}`));
|
|
1454
|
+
|
|
1455
|
+
// Output commands for the shell wrapper to execute
|
|
1456
|
+
// We can't cd from within Node, so we output eval-able commands
|
|
1457
|
+
console.log(colors.bold('\nTo clean up the worktree, run:\n'));
|
|
1458
|
+
console.log(`cd "${mainRepo}"`);
|
|
1459
|
+
console.log(`git worktree remove "${currentDir}"`);
|
|
1460
|
+
if (!keepBranch) {
|
|
1461
|
+
console.log(`git branch -d "${branchName}"`);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Also provide a one-liner
|
|
1465
|
+
console.log(colors.gray('\nOr copy this one-liner:'));
|
|
1466
|
+
const oneLiner = keepBranch
|
|
1467
|
+
? `cd "${mainRepo}" && git worktree remove "${currentDir}"`
|
|
1468
|
+
: `cd "${mainRepo}" && git worktree remove "${currentDir}" && git branch -d "${branchName}"`;
|
|
1469
|
+
console.log(oneLiner);
|
|
1470
|
+
} else {
|
|
1471
|
+
console.log(colors.green('\nDone!'));
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// ============================================================================
|
|
1476
|
+
// STANDUP
|
|
1477
|
+
// ============================================================================
|
|
1478
|
+
|
|
1479
|
+
function getYesterdayDate() {
|
|
1480
|
+
const date = new Date();
|
|
1481
|
+
date.setDate(date.getDate() - 1);
|
|
1482
|
+
return date.toISOString().split('T')[0];
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function getTodayDate() {
|
|
1486
|
+
return new Date().toISOString().split('T')[0];
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
async function cmdStandup(args) {
|
|
1490
|
+
const opts = parseArgs(args, {
|
|
1491
|
+
'no-github': 'boolean',
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
const skipGitHub = opts['no-github'];
|
|
1495
|
+
const yesterday = getYesterdayDate();
|
|
1496
|
+
const today = getTodayDate();
|
|
1497
|
+
|
|
1498
|
+
// Get current user
|
|
1499
|
+
const viewerResult = await gql('{ viewer { id name } }');
|
|
1500
|
+
const viewer = viewerResult.data?.viewer;
|
|
1501
|
+
if (!viewer) {
|
|
1502
|
+
console.error(colors.red('Error: Could not fetch user info'));
|
|
1503
|
+
process.exit(1);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
console.log(colors.bold(`\nStandup for ${viewer.name}\n`));
|
|
1507
|
+
console.log(colors.gray(`─────────────────────────────────────────\n`));
|
|
1508
|
+
|
|
1509
|
+
// Fetch issues with completion info
|
|
1510
|
+
const query = `{
|
|
1511
|
+
team(id: "${TEAM_KEY}") {
|
|
1512
|
+
issues(first: 100) {
|
|
1513
|
+
nodes {
|
|
1514
|
+
identifier
|
|
1515
|
+
title
|
|
1516
|
+
state { name type }
|
|
1517
|
+
assignee { id }
|
|
1518
|
+
completedAt
|
|
1519
|
+
relations(first: 20) {
|
|
1520
|
+
nodes {
|
|
1521
|
+
type
|
|
1522
|
+
relatedIssue { identifier state { type } }
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}`;
|
|
1529
|
+
|
|
1530
|
+
const result = await gql(query);
|
|
1531
|
+
const issues = result.data?.team?.issues?.nodes || [];
|
|
1532
|
+
|
|
1533
|
+
// Issues completed yesterday (by me)
|
|
1534
|
+
const completedYesterday = issues.filter(i => {
|
|
1535
|
+
if (i.assignee?.id !== viewer.id) return false;
|
|
1536
|
+
if (!i.completedAt) return false;
|
|
1537
|
+
const completedDate = i.completedAt.split('T')[0];
|
|
1538
|
+
return completedDate === yesterday;
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
// Issues in progress (assigned to me)
|
|
1542
|
+
const inProgress = issues.filter(i =>
|
|
1543
|
+
i.assignee?.id === viewer.id &&
|
|
1544
|
+
i.state.type === 'started'
|
|
1545
|
+
);
|
|
1546
|
+
|
|
1547
|
+
// Blocked issues (assigned to me)
|
|
1548
|
+
const blockedIds = new Set();
|
|
1549
|
+
for (const issue of issues) {
|
|
1550
|
+
for (const rel of issue.relations?.nodes || []) {
|
|
1551
|
+
if (rel.type === 'blocks' && rel.relatedIssue.state.type !== 'completed') {
|
|
1552
|
+
blockedIds.add(rel.relatedIssue.identifier);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
const blocked = issues.filter(i =>
|
|
1557
|
+
i.assignee?.id === viewer.id &&
|
|
1558
|
+
blockedIds.has(i.identifier)
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
// Display Linear info
|
|
1562
|
+
console.log(colors.bold('Yesterday (completed):'));
|
|
1563
|
+
if (completedYesterday.length === 0) {
|
|
1564
|
+
console.log(colors.gray(' No issues completed'));
|
|
1565
|
+
} else {
|
|
1566
|
+
for (const issue of completedYesterday) {
|
|
1567
|
+
console.log(` ${colors.green('✓')} ${issue.identifier}: ${issue.title}`);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
console.log('');
|
|
1572
|
+
console.log(colors.bold('Today (in progress):'));
|
|
1573
|
+
if (inProgress.length === 0) {
|
|
1574
|
+
console.log(colors.gray(' No issues in progress'));
|
|
1575
|
+
} else {
|
|
1576
|
+
for (const issue of inProgress) {
|
|
1577
|
+
console.log(` → ${issue.identifier}: ${issue.title}`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
if (blocked.length > 0) {
|
|
1582
|
+
console.log('');
|
|
1583
|
+
console.log(colors.bold('Blocked:'));
|
|
1584
|
+
for (const issue of blocked) {
|
|
1585
|
+
console.log(` ${colors.red('⊘')} ${issue.identifier}: ${issue.title}`);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// GitHub activity
|
|
1590
|
+
if (!skipGitHub) {
|
|
1591
|
+
console.log('');
|
|
1592
|
+
console.log(colors.gray(`─────────────────────────────────────────\n`));
|
|
1593
|
+
console.log(colors.bold('GitHub Activity (yesterday):'));
|
|
1594
|
+
|
|
1595
|
+
try {
|
|
1596
|
+
// Get commits from yesterday
|
|
1597
|
+
const sinceDate = `${yesterday}T00:00:00`;
|
|
1598
|
+
const untilDate = `${today}T00:00:00`;
|
|
1599
|
+
|
|
1600
|
+
// Try to get repo info
|
|
1601
|
+
let repoOwner, repoName;
|
|
1602
|
+
try {
|
|
1603
|
+
const remoteUrl = execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
|
|
1604
|
+
const match = remoteUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
|
|
1605
|
+
if (match) {
|
|
1606
|
+
repoOwner = match[1];
|
|
1607
|
+
repoName = match[2];
|
|
1608
|
+
}
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
// Not in a git repo or no origin
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
if (repoOwner && repoName) {
|
|
1614
|
+
// Get git user name for author matching (may differ from Linear display name)
|
|
1615
|
+
let gitUserName;
|
|
1616
|
+
try {
|
|
1617
|
+
gitUserName = execSync('git config user.name', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
gitUserName = viewer.name; // Fall back to Linear name
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// Get commits using git log (more reliable than gh for commits)
|
|
1623
|
+
try {
|
|
1624
|
+
const gitLog = execSync(
|
|
1625
|
+
`git log --since="${sinceDate}" --until="${untilDate}" --author="${gitUserName}" --oneline`,
|
|
1626
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
1627
|
+
).trim();
|
|
1628
|
+
|
|
1629
|
+
if (gitLog) {
|
|
1630
|
+
const commits = gitLog.split('\n').filter(Boolean);
|
|
1631
|
+
console.log(`\n Commits (${commits.length}):`);
|
|
1632
|
+
for (const commit of commits.slice(0, 10)) {
|
|
1633
|
+
console.log(` ${commit}`);
|
|
1634
|
+
}
|
|
1635
|
+
if (commits.length > 10) {
|
|
1636
|
+
console.log(colors.gray(` ... and ${commits.length - 10} more`));
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
// No commits or git error
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Get PRs using gh
|
|
1644
|
+
try {
|
|
1645
|
+
const prsJson = execSync(
|
|
1646
|
+
`gh pr list --author @me --state all --json number,title,state,mergedAt,createdAt --limit 20`,
|
|
1647
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
1648
|
+
);
|
|
1649
|
+
const prs = JSON.parse(prsJson);
|
|
1650
|
+
|
|
1651
|
+
// Filter to PRs created or merged yesterday
|
|
1652
|
+
const relevantPrs = prs.filter(pr => {
|
|
1653
|
+
const createdDate = pr.createdAt?.split('T')[0];
|
|
1654
|
+
const mergedDate = pr.mergedAt?.split('T')[0];
|
|
1655
|
+
return createdDate === yesterday || mergedDate === yesterday;
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1658
|
+
if (relevantPrs.length > 0) {
|
|
1659
|
+
console.log(`\n Pull Requests:`);
|
|
1660
|
+
for (const pr of relevantPrs) {
|
|
1661
|
+
const status = pr.state === 'MERGED' ? colors.green('merged') :
|
|
1662
|
+
pr.state === 'OPEN' ? colors.yellow('open') :
|
|
1663
|
+
colors.gray(pr.state.toLowerCase());
|
|
1664
|
+
console.log(` #${pr.number} ${pr.title} [${status}]`);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
} catch (err) {
|
|
1668
|
+
// gh not available or error
|
|
1669
|
+
console.log(colors.gray(' (gh CLI not available or not authenticated)'));
|
|
1670
|
+
}
|
|
1671
|
+
} else {
|
|
1672
|
+
console.log(colors.gray(' (not in a GitHub repository)'));
|
|
1673
|
+
}
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
console.log(colors.gray(` Error fetching GitHub data: ${err.message}`));
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
console.log('');
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// ============================================================================
|
|
1683
|
+
// AUTH
|
|
1684
|
+
// ============================================================================
|
|
1685
|
+
|
|
1686
|
+
async function cmdLogin(args) {
|
|
1687
|
+
const opts = parseArgs(args, { global: 'boolean', g: 'boolean' });
|
|
1688
|
+
const saveGlobal = opts.global || opts.g;
|
|
1689
|
+
|
|
1690
|
+
console.log(colors.bold('Linear CLI Login\n'));
|
|
1691
|
+
console.log('Opening Linear API settings in your browser...');
|
|
1692
|
+
console.log(colors.gray('(Create a new personal API key if you don\'t have one)\n'));
|
|
1693
|
+
|
|
1694
|
+
openBrowser('https://linear.app/settings/api');
|
|
1695
|
+
|
|
1696
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
1697
|
+
|
|
1698
|
+
const apiKey = await prompt('Paste your API key: ');
|
|
1699
|
+
|
|
1700
|
+
if (!apiKey) {
|
|
1701
|
+
console.error(colors.red('Error: API key is required'));
|
|
1702
|
+
process.exit(1);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
console.log('\nValidating...');
|
|
1706
|
+
LINEAR_API_KEY = apiKey;
|
|
1707
|
+
|
|
1708
|
+
const teamsResult = await gql('{ teams { nodes { id key name } } }');
|
|
1709
|
+
const teams = teamsResult.data?.teams?.nodes;
|
|
1710
|
+
|
|
1711
|
+
if (!teams || teams.length === 0) {
|
|
1712
|
+
console.error(colors.red('Error: Invalid API key or no access to any teams'));
|
|
1713
|
+
if (teamsResult.errors) {
|
|
1714
|
+
console.error(teamsResult.errors[0]?.message);
|
|
1715
|
+
}
|
|
1716
|
+
process.exit(1);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
console.log(colors.green('Valid!\n'));
|
|
1720
|
+
console.log(colors.bold('Select a team:\n'));
|
|
1721
|
+
|
|
1722
|
+
teams.forEach((team, i) => {
|
|
1723
|
+
console.log(` ${i + 1}. ${team.name} (${team.key})`);
|
|
1724
|
+
});
|
|
1725
|
+
console.log(` ${teams.length + 1}. Create a new team...`);
|
|
1726
|
+
console.log('');
|
|
1727
|
+
|
|
1728
|
+
const selection = await prompt('Enter number [1]: ') || '1';
|
|
1729
|
+
let selectedKey = '';
|
|
1730
|
+
|
|
1731
|
+
if (parseInt(selection) === teams.length + 1) {
|
|
1732
|
+
// Create new team
|
|
1733
|
+
console.log('');
|
|
1734
|
+
const teamName = await prompt('Team name: ');
|
|
1735
|
+
|
|
1736
|
+
if (!teamName) {
|
|
1737
|
+
console.error(colors.red('Error: Team name is required'));
|
|
1738
|
+
process.exit(1);
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
const suggestedKey = suggestTeamKey(teamName);
|
|
1742
|
+
let teamKey = await prompt(`Team key [${suggestedKey}]: `);
|
|
1743
|
+
teamKey = (teamKey || suggestedKey).toUpperCase();
|
|
1744
|
+
|
|
1745
|
+
const createResult = await gql(`
|
|
1746
|
+
mutation($input: TeamCreateInput!) {
|
|
1747
|
+
teamCreate(input: $input) {
|
|
1748
|
+
success
|
|
1749
|
+
team { key name }
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
`, { input: { name: teamName, key: teamKey } });
|
|
1753
|
+
|
|
1754
|
+
if (createResult.data?.teamCreate?.success) {
|
|
1755
|
+
selectedKey = teamKey;
|
|
1756
|
+
console.log(colors.green(`Created team: ${teamName} (${teamKey})`));
|
|
1757
|
+
} else {
|
|
1758
|
+
console.error(colors.red('Failed to create team'));
|
|
1759
|
+
console.error(createResult.errors?.[0]?.message || JSON.stringify(createResult));
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
}
|
|
1762
|
+
} else {
|
|
1763
|
+
const idx = parseInt(selection) - 1;
|
|
1764
|
+
if (idx < 0 || idx >= teams.length) {
|
|
1765
|
+
console.error(colors.red('Error: Invalid selection'));
|
|
1766
|
+
process.exit(1);
|
|
1767
|
+
}
|
|
1768
|
+
selectedKey = teams[idx].key;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
// Save config
|
|
1772
|
+
const configPath = saveGlobal ? join(homedir(), '.linear') : join(process.cwd(), '.linear');
|
|
1773
|
+
const configContent = `# Linear CLI configuration
|
|
1774
|
+
api_key=${apiKey}
|
|
1775
|
+
team=${selectedKey}
|
|
1776
|
+
`;
|
|
1777
|
+
|
|
1778
|
+
writeFileSync(configPath, configContent);
|
|
1779
|
+
|
|
1780
|
+
console.log('');
|
|
1781
|
+
console.log(colors.green(`Saved to ${configPath}`));
|
|
1782
|
+
|
|
1783
|
+
// Add .linear to .gitignore if saving locally
|
|
1784
|
+
if (!saveGlobal) {
|
|
1785
|
+
const gitignorePath = join(process.cwd(), '.gitignore');
|
|
1786
|
+
try {
|
|
1787
|
+
let gitignore = '';
|
|
1788
|
+
if (existsSync(gitignorePath)) {
|
|
1789
|
+
gitignore = readFileSync(gitignorePath, 'utf-8');
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// Check if .linear is already in .gitignore
|
|
1793
|
+
const lines = gitignore.split('\n').map(l => l.trim());
|
|
1794
|
+
if (!lines.includes('.linear')) {
|
|
1795
|
+
// Add .linear to .gitignore
|
|
1796
|
+
const newline = gitignore.endsWith('\n') || gitignore === '' ? '' : '\n';
|
|
1797
|
+
const content = gitignore + newline + '.linear\n';
|
|
1798
|
+
writeFileSync(gitignorePath, content);
|
|
1799
|
+
console.log(colors.green(`Added .linear to .gitignore`));
|
|
1800
|
+
}
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
// Silently ignore if we can't update .gitignore
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// Add .linear to .worktreeinclude for worktree support
|
|
1806
|
+
const worktreeIncludePath = join(process.cwd(), '.worktreeinclude');
|
|
1807
|
+
try {
|
|
1808
|
+
let worktreeInclude = '';
|
|
1809
|
+
if (existsSync(worktreeIncludePath)) {
|
|
1810
|
+
worktreeInclude = readFileSync(worktreeIncludePath, 'utf-8');
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
const wtLines = worktreeInclude.split('\n').map(l => l.trim());
|
|
1814
|
+
if (!wtLines.includes('.linear')) {
|
|
1815
|
+
const newline = worktreeInclude.endsWith('\n') || worktreeInclude === '' ? '' : '\n';
|
|
1816
|
+
writeFileSync(worktreeIncludePath, worktreeInclude + newline + '.linear\n');
|
|
1817
|
+
console.log(colors.green(`Added .linear to .worktreeinclude`));
|
|
1818
|
+
}
|
|
1819
|
+
} catch (err) {
|
|
1820
|
+
// Silently ignore if we can't update .worktreeinclude
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
console.log('');
|
|
1825
|
+
console.log("You're ready to go! Try:");
|
|
1826
|
+
console.log(' linear issues --unblocked');
|
|
1827
|
+
console.log(' linear projects');
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
async function cmdLogout() {
|
|
1831
|
+
const localPath = join(process.cwd(), '.linear');
|
|
1832
|
+
const globalPath = join(homedir(), '.linear');
|
|
1833
|
+
|
|
1834
|
+
if (existsSync(localPath)) {
|
|
1835
|
+
unlinkSync(localPath);
|
|
1836
|
+
console.log(colors.green(`Removed ${localPath}`));
|
|
1837
|
+
} else if (existsSync(globalPath)) {
|
|
1838
|
+
unlinkSync(globalPath);
|
|
1839
|
+
console.log(colors.green(`Removed ${globalPath}`));
|
|
1840
|
+
} else {
|
|
1841
|
+
console.log('No config file found.');
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
async function cmdWhoami() {
|
|
1846
|
+
checkAuth();
|
|
1847
|
+
|
|
1848
|
+
const result = await gql('{ viewer { id name email } }');
|
|
1849
|
+
const user = result.data?.viewer;
|
|
1850
|
+
|
|
1851
|
+
if (!user) {
|
|
1852
|
+
console.error(colors.red('Error: Could not fetch user info'));
|
|
1853
|
+
process.exit(1);
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
console.log(`Logged in as: ${user.name} <${user.email}>`);
|
|
1857
|
+
console.log(`Team: ${TEAM_KEY}`);
|
|
1858
|
+
console.log(`Config: ${CONFIG_FILE || '(environment variables)'}`);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// ============================================================================
|
|
1862
|
+
// HELP
|
|
1863
|
+
// ============================================================================
|
|
1864
|
+
|
|
1865
|
+
function showHelp() {
|
|
1866
|
+
console.log(`Linear CLI - A simple wrapper around Linear's GraphQL API
|
|
1867
|
+
|
|
1868
|
+
USAGE:
|
|
1869
|
+
linear <command> [options]
|
|
1870
|
+
|
|
1871
|
+
AUTHENTICATION:
|
|
1872
|
+
login [--global] Login and save credentials to .linear
|
|
1873
|
+
--global, -g Save to ~/.linear instead of ./.linear
|
|
1874
|
+
logout Remove saved credentials
|
|
1875
|
+
whoami Show current user and team
|
|
1876
|
+
|
|
1877
|
+
ISSUES:
|
|
1878
|
+
issues [options] List issues (yours shown first)
|
|
1879
|
+
--unblocked, -u Show only unblocked issues
|
|
1880
|
+
--open, -o Show all non-completed/canceled issues
|
|
1881
|
+
--all, -a Show all states (including completed)
|
|
1882
|
+
--mine, -m Show only issues assigned to you
|
|
1883
|
+
--in-progress Show issues in progress
|
|
1884
|
+
--state, -s <state> Filter by state (default: backlog)
|
|
1885
|
+
--label, -l <name> Filter by label
|
|
1886
|
+
|
|
1887
|
+
issue show <id> Show issue details with parent context
|
|
1888
|
+
issue start <id> Assign to yourself and set to In Progress
|
|
1889
|
+
issue create [options] Create a new issue
|
|
1890
|
+
--title, -t <title> Issue title (required)
|
|
1891
|
+
--description, -d <desc> Issue description
|
|
1892
|
+
--project, -p <name> Add to project
|
|
1893
|
+
--parent <id> Parent issue (for sub-issues)
|
|
1894
|
+
--assign Assign to yourself
|
|
1895
|
+
--estimate, -e <size> Estimate: XS, S, M, L, XL
|
|
1896
|
+
--label, -l <name> Add label
|
|
1897
|
+
--blocks <id> This issue blocks another
|
|
1898
|
+
--blocked-by <id> This issue is blocked by another
|
|
1899
|
+
issue update <id> [opts] Update an issue
|
|
1900
|
+
--title, -t <title> New title
|
|
1901
|
+
--description, -d <desc> New description
|
|
1902
|
+
--state, -s <state> New state
|
|
1903
|
+
--append, -a <text> Append to description
|
|
1904
|
+
--blocks <id> Add blocking relation
|
|
1905
|
+
--blocked-by <id> Add blocked-by relation
|
|
1906
|
+
issue close <id> Mark issue as done
|
|
1907
|
+
issue comment <id> <body> Add a comment
|
|
1908
|
+
|
|
1909
|
+
PROJECTS:
|
|
1910
|
+
projects [options] List projects
|
|
1911
|
+
--all, -a Include completed projects
|
|
1912
|
+
project show <name> Show project details
|
|
1913
|
+
project create [options] Create a new project
|
|
1914
|
+
--name, -n <name> Project name (required)
|
|
1915
|
+
--description, -d <desc> Project description
|
|
1916
|
+
project complete <name> Mark project as completed
|
|
1917
|
+
|
|
1918
|
+
LABELS:
|
|
1919
|
+
labels List all labels
|
|
1920
|
+
label create [options] Create a new label
|
|
1921
|
+
--name, -n <name> Label name (required)
|
|
1922
|
+
--description, -d <desc> Label description
|
|
1923
|
+
--color, -c <hex> Label color (e.g., #FF0000)
|
|
1924
|
+
|
|
1925
|
+
GIT:
|
|
1926
|
+
branch <id> Create git branch from issue (ISSUE-5-issue-title)
|
|
1927
|
+
|
|
1928
|
+
WORKFLOW:
|
|
1929
|
+
next Pick an issue and start in a new worktree
|
|
1930
|
+
--dry-run Show commands without executing
|
|
1931
|
+
done [id] Complete work on an issue
|
|
1932
|
+
--no-close Don't close the issue in Linear
|
|
1933
|
+
--keep-branch Don't suggest deleting the branch
|
|
1934
|
+
standup Show daily standup summary
|
|
1935
|
+
--no-github Skip GitHub activity
|
|
1936
|
+
|
|
1937
|
+
Shell setup (add to ~/.zshrc):
|
|
1938
|
+
lnext() { eval "$(linear next "$@")"; }
|
|
1939
|
+
|
|
1940
|
+
CONFIGURATION:
|
|
1941
|
+
Config is loaded from ./.linear first, then ~/.linear, then env vars.
|
|
1942
|
+
|
|
1943
|
+
File format:
|
|
1944
|
+
api_key=lin_api_xxx
|
|
1945
|
+
team=ISSUE
|
|
1946
|
+
|
|
1947
|
+
EXAMPLES:
|
|
1948
|
+
linear login # First-time setup
|
|
1949
|
+
linear issues --unblocked # Find workable issues
|
|
1950
|
+
linear issues --in-progress # See what you're working on
|
|
1951
|
+
linear issue show ISSUE-1 # View with parent context
|
|
1952
|
+
linear issue start ISSUE-1 # Assign and start working
|
|
1953
|
+
linear issue create --title "Fix bug" --project "Phase 1" --assign --estimate M
|
|
1954
|
+
linear issue create --title "Needs API key" --blocked-by ISSUE-5
|
|
1955
|
+
linear issue update ISSUE-1 --append "Found the root cause..."
|
|
1956
|
+
linear branch ISSUE-5 # Create git branch for issue
|
|
1957
|
+
`);
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
// ============================================================================
|
|
1961
|
+
// MAIN
|
|
1962
|
+
// ============================================================================
|
|
1963
|
+
|
|
1964
|
+
async function main() {
|
|
1965
|
+
loadConfig();
|
|
1966
|
+
|
|
1967
|
+
const args = process.argv.slice(2);
|
|
1968
|
+
const cmd = args[0];
|
|
1969
|
+
|
|
1970
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
1971
|
+
showHelp();
|
|
1972
|
+
process.exit(0);
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
try {
|
|
1976
|
+
switch (cmd) {
|
|
1977
|
+
case 'login':
|
|
1978
|
+
await cmdLogin(args.slice(1));
|
|
1979
|
+
break;
|
|
1980
|
+
case 'logout':
|
|
1981
|
+
await cmdLogout();
|
|
1982
|
+
break;
|
|
1983
|
+
case 'whoami':
|
|
1984
|
+
await cmdWhoami();
|
|
1985
|
+
break;
|
|
1986
|
+
case 'issues':
|
|
1987
|
+
checkAuth();
|
|
1988
|
+
await cmdIssues(args.slice(1));
|
|
1989
|
+
break;
|
|
1990
|
+
case 'issue': {
|
|
1991
|
+
checkAuth();
|
|
1992
|
+
const subcmd = args[1];
|
|
1993
|
+
const subargs = args.slice(2);
|
|
1994
|
+
switch (subcmd) {
|
|
1995
|
+
case 'show': await cmdIssueShow(subargs); break;
|
|
1996
|
+
case 'create': await cmdIssueCreate(subargs); break;
|
|
1997
|
+
case 'update': await cmdIssueUpdate(subargs); break;
|
|
1998
|
+
case 'start': await cmdIssueStart(subargs); break;
|
|
1999
|
+
case 'close': await cmdIssueClose(subargs); break;
|
|
2000
|
+
case 'comment': await cmdIssueComment(subargs); break;
|
|
2001
|
+
default:
|
|
2002
|
+
console.error(`Unknown issue command: ${subcmd}`);
|
|
2003
|
+
process.exit(1);
|
|
2004
|
+
}
|
|
2005
|
+
break;
|
|
2006
|
+
}
|
|
2007
|
+
case 'projects':
|
|
2008
|
+
checkAuth();
|
|
2009
|
+
await cmdProjects(args.slice(1));
|
|
2010
|
+
break;
|
|
2011
|
+
case 'project': {
|
|
2012
|
+
checkAuth();
|
|
2013
|
+
const subcmd = args[1];
|
|
2014
|
+
const subargs = args.slice(2);
|
|
2015
|
+
switch (subcmd) {
|
|
2016
|
+
case 'show': await cmdProjectShow(subargs); break;
|
|
2017
|
+
case 'create': await cmdProjectCreate(subargs); break;
|
|
2018
|
+
case 'complete': await cmdProjectComplete(subargs); break;
|
|
2019
|
+
default:
|
|
2020
|
+
console.error(`Unknown project command: ${subcmd}`);
|
|
2021
|
+
process.exit(1);
|
|
2022
|
+
}
|
|
2023
|
+
break;
|
|
2024
|
+
}
|
|
2025
|
+
case 'labels':
|
|
2026
|
+
checkAuth();
|
|
2027
|
+
await cmdLabels();
|
|
2028
|
+
break;
|
|
2029
|
+
case 'label': {
|
|
2030
|
+
checkAuth();
|
|
2031
|
+
const subcmd = args[1];
|
|
2032
|
+
const subargs = args.slice(2);
|
|
2033
|
+
switch (subcmd) {
|
|
2034
|
+
case 'create': await cmdLabelCreate(subargs); break;
|
|
2035
|
+
default:
|
|
2036
|
+
console.error(`Unknown label command: ${subcmd}`);
|
|
2037
|
+
process.exit(1);
|
|
2038
|
+
}
|
|
2039
|
+
break;
|
|
2040
|
+
}
|
|
2041
|
+
case 'branch':
|
|
2042
|
+
checkAuth();
|
|
2043
|
+
await cmdBranch(args.slice(1));
|
|
2044
|
+
break;
|
|
2045
|
+
case 'next':
|
|
2046
|
+
checkAuth();
|
|
2047
|
+
await cmdNext(args.slice(1));
|
|
2048
|
+
break;
|
|
2049
|
+
case 'done':
|
|
2050
|
+
checkAuth();
|
|
2051
|
+
await cmdDone(args.slice(1));
|
|
2052
|
+
break;
|
|
2053
|
+
case 'standup':
|
|
2054
|
+
checkAuth();
|
|
2055
|
+
await cmdStandup(args.slice(1));
|
|
2056
|
+
break;
|
|
2057
|
+
default:
|
|
2058
|
+
console.error(`Unknown command: ${cmd}`);
|
|
2059
|
+
showHelp();
|
|
2060
|
+
process.exit(1);
|
|
2061
|
+
}
|
|
2062
|
+
} catch (err) {
|
|
2063
|
+
console.error(colors.red(`Error: ${err.message}`));
|
|
2064
|
+
process.exit(1);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
main();
|