@dabble/linear-cli 1.0.0 → 1.0.2
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 +1 -2
- package/bin/linear.mjs +982 -49
- package/claude/skills/linear-cli.md +48 -7
- package/claude/skills/product-planning.md +67 -98
- package/package.json +1 -1
package/bin/linear.mjs
CHANGED
|
@@ -55,11 +55,6 @@ function loadConfig() {
|
|
|
55
55
|
// Fall back to env vars if not set by config file
|
|
56
56
|
if (!LINEAR_API_KEY) LINEAR_API_KEY = process.env.LINEAR_API_KEY || '';
|
|
57
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
58
|
}
|
|
64
59
|
|
|
65
60
|
function checkAuth() {
|
|
@@ -185,9 +180,11 @@ async function cmdIssues(args) {
|
|
|
185
180
|
unblocked: 'boolean', u: 'boolean',
|
|
186
181
|
all: 'boolean', a: 'boolean',
|
|
187
182
|
open: 'boolean', o: 'boolean',
|
|
183
|
+
backlog: 'boolean', b: 'boolean',
|
|
188
184
|
mine: 'boolean', m: 'boolean',
|
|
189
185
|
'in-progress': 'boolean',
|
|
190
186
|
project: 'string', p: 'string',
|
|
187
|
+
milestone: 'string',
|
|
191
188
|
state: 'string', s: 'string',
|
|
192
189
|
label: 'string', l: 'string',
|
|
193
190
|
});
|
|
@@ -196,8 +193,11 @@ async function cmdIssues(args) {
|
|
|
196
193
|
const unblocked = opts.unblocked || opts.u;
|
|
197
194
|
const allStates = opts.all || opts.a;
|
|
198
195
|
const openOnly = opts.open || opts.o;
|
|
196
|
+
const backlogOnly = opts.backlog || opts.b;
|
|
199
197
|
const mineOnly = opts.mine || opts.m;
|
|
200
|
-
const
|
|
198
|
+
const projectFilter = opts.project || opts.p;
|
|
199
|
+
const milestoneFilter = opts.milestone;
|
|
200
|
+
const stateFilter = opts.state || opts.s;
|
|
201
201
|
const labelFilter = opts.label || opts.l;
|
|
202
202
|
|
|
203
203
|
// Get current user ID for filtering/sorting
|
|
@@ -211,8 +211,10 @@ async function cmdIssues(args) {
|
|
|
211
211
|
identifier
|
|
212
212
|
title
|
|
213
213
|
priority
|
|
214
|
+
sortOrder
|
|
214
215
|
state { name type }
|
|
215
216
|
project { name }
|
|
217
|
+
projectMilestone { name }
|
|
216
218
|
assignee { id name }
|
|
217
219
|
labels { nodes { name } }
|
|
218
220
|
relations(first: 20) {
|
|
@@ -232,13 +234,16 @@ async function cmdIssues(args) {
|
|
|
232
234
|
// Check if any issues have assignees (to decide whether to show column)
|
|
233
235
|
const hasAssignees = issues.some(i => i.assignee);
|
|
234
236
|
|
|
235
|
-
// Sort: assigned to you first, then by
|
|
237
|
+
// Sort: assigned to you first, then by priority, then by sortOrder
|
|
236
238
|
issues.sort((a, b) => {
|
|
237
239
|
const aIsMine = a.assignee?.id === viewerId;
|
|
238
240
|
const bIsMine = b.assignee?.id === viewerId;
|
|
239
241
|
if (aIsMine && !bIsMine) return -1;
|
|
240
242
|
if (!aIsMine && bIsMine) return 1;
|
|
241
|
-
|
|
243
|
+
// Then by priority (higher = more urgent)
|
|
244
|
+
if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
|
|
245
|
+
// Then by sortOrder
|
|
246
|
+
return (b.sortOrder || 0) - (a.sortOrder || 0);
|
|
242
247
|
});
|
|
243
248
|
|
|
244
249
|
// Helper to format issue row
|
|
@@ -256,7 +261,7 @@ async function cmdIssues(args) {
|
|
|
256
261
|
return row;
|
|
257
262
|
};
|
|
258
263
|
|
|
259
|
-
// Helper to apply common filters (mine, label)
|
|
264
|
+
// Helper to apply common filters (mine, label, project, milestone)
|
|
260
265
|
const applyFilters = (list) => {
|
|
261
266
|
let filtered = list;
|
|
262
267
|
if (mineOnly) {
|
|
@@ -267,6 +272,16 @@ async function cmdIssues(args) {
|
|
|
267
272
|
i.labels?.nodes?.some(l => l.name.toLowerCase() === labelFilter.toLowerCase())
|
|
268
273
|
);
|
|
269
274
|
}
|
|
275
|
+
if (projectFilter) {
|
|
276
|
+
filtered = filtered.filter(i =>
|
|
277
|
+
i.project?.name?.toLowerCase().includes(projectFilter.toLowerCase())
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (milestoneFilter) {
|
|
281
|
+
filtered = filtered.filter(i =>
|
|
282
|
+
i.projectMilestone?.name?.toLowerCase().includes(milestoneFilter.toLowerCase())
|
|
283
|
+
);
|
|
284
|
+
}
|
|
270
285
|
return filtered;
|
|
271
286
|
};
|
|
272
287
|
|
|
@@ -306,14 +321,28 @@ async function cmdIssues(args) {
|
|
|
306
321
|
|
|
307
322
|
console.log(colors.bold('Open Issues:\n'));
|
|
308
323
|
console.log(formatTable(filtered.map(formatRow)));
|
|
309
|
-
} else {
|
|
324
|
+
} else if (inProgress) {
|
|
325
|
+
let filtered = issues.filter(i => i.state.type === 'started');
|
|
326
|
+
filtered = applyFilters(filtered);
|
|
327
|
+
|
|
328
|
+
console.log(colors.bold('In Progress:\n'));
|
|
329
|
+
console.log(formatTable(filtered.map(formatRow)));
|
|
330
|
+
} else if (backlogOnly || stateFilter) {
|
|
331
|
+
const targetState = stateFilter || 'backlog';
|
|
310
332
|
let filtered = issues.filter(i =>
|
|
311
|
-
i.state.type ===
|
|
333
|
+
i.state.type === targetState || i.state.name.toLowerCase() === targetState.toLowerCase()
|
|
312
334
|
);
|
|
313
335
|
|
|
314
336
|
filtered = applyFilters(filtered);
|
|
315
337
|
|
|
316
|
-
console.log(colors.bold(`Issues (${
|
|
338
|
+
console.log(colors.bold(`Issues (${targetState}):\n`));
|
|
339
|
+
console.log(formatTable(filtered.map(formatRow)));
|
|
340
|
+
} else {
|
|
341
|
+
// Default: show backlog
|
|
342
|
+
let filtered = issues.filter(i => i.state.type === 'backlog');
|
|
343
|
+
filtered = applyFilters(filtered);
|
|
344
|
+
|
|
345
|
+
console.log(colors.bold('Issues (backlog):\n'));
|
|
317
346
|
console.log(formatTable(filtered.map(formatRow)));
|
|
318
347
|
}
|
|
319
348
|
}
|
|
@@ -473,6 +502,7 @@ async function cmdIssueCreate(args) {
|
|
|
473
502
|
title: 'string', t: 'string',
|
|
474
503
|
description: 'string', d: 'string',
|
|
475
504
|
project: 'string', p: 'string',
|
|
505
|
+
milestone: 'string',
|
|
476
506
|
parent: 'string',
|
|
477
507
|
state: 'string', s: 'string',
|
|
478
508
|
assign: 'boolean',
|
|
@@ -485,6 +515,7 @@ async function cmdIssueCreate(args) {
|
|
|
485
515
|
const title = opts.title || opts.t || opts._[0];
|
|
486
516
|
const description = opts.description || opts.d || '';
|
|
487
517
|
const project = opts.project || opts.p;
|
|
518
|
+
const milestone = opts.milestone;
|
|
488
519
|
const parent = opts.parent;
|
|
489
520
|
const shouldAssign = opts.assign;
|
|
490
521
|
const estimate = (opts.estimate || opts.e || '').toLowerCase();
|
|
@@ -494,7 +525,7 @@ async function cmdIssueCreate(args) {
|
|
|
494
525
|
|
|
495
526
|
if (!title) {
|
|
496
527
|
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]');
|
|
528
|
+
console.error('Usage: linear issue create --title "Issue title" [--project "..."] [--milestone "..."] [--parent ISSUE-X] [--estimate M] [--assign] [--label bug] [--blocks ISSUE-X] [--blocked-by ISSUE-X]');
|
|
498
529
|
process.exit(1);
|
|
499
530
|
}
|
|
500
531
|
|
|
@@ -513,17 +544,47 @@ async function cmdIssueCreate(args) {
|
|
|
513
544
|
process.exit(1);
|
|
514
545
|
}
|
|
515
546
|
|
|
516
|
-
// Look up project
|
|
547
|
+
// Look up project and milestone IDs
|
|
517
548
|
let projectId = null;
|
|
518
|
-
|
|
549
|
+
let milestoneId = null;
|
|
550
|
+
if (project || milestone) {
|
|
519
551
|
const projectsResult = await gql(`{
|
|
520
552
|
team(id: "${TEAM_KEY}") {
|
|
521
|
-
projects(first: 50) {
|
|
553
|
+
projects(first: 50) {
|
|
554
|
+
nodes {
|
|
555
|
+
id name
|
|
556
|
+
projectMilestones { nodes { id name } }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
522
559
|
}
|
|
523
560
|
}`);
|
|
524
561
|
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
525
|
-
|
|
526
|
-
if (
|
|
562
|
+
|
|
563
|
+
if (project) {
|
|
564
|
+
const projectMatch = projects.find(p => p.name.toLowerCase().includes(project.toLowerCase()));
|
|
565
|
+
if (projectMatch) {
|
|
566
|
+
projectId = projectMatch.id;
|
|
567
|
+
// Look for milestone within this project
|
|
568
|
+
if (milestone) {
|
|
569
|
+
const milestoneMatch = projectMatch.projectMilestones?.nodes?.find(m =>
|
|
570
|
+
m.name.toLowerCase().includes(milestone.toLowerCase())
|
|
571
|
+
);
|
|
572
|
+
if (milestoneMatch) milestoneId = milestoneMatch.id;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
} else if (milestone) {
|
|
576
|
+
// Search all projects for the milestone
|
|
577
|
+
for (const p of projects) {
|
|
578
|
+
const milestoneMatch = p.projectMilestones?.nodes?.find(m =>
|
|
579
|
+
m.name.toLowerCase().includes(milestone.toLowerCase())
|
|
580
|
+
);
|
|
581
|
+
if (milestoneMatch) {
|
|
582
|
+
projectId = p.id; // Auto-set project from milestone
|
|
583
|
+
milestoneId = milestoneMatch.id;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
527
588
|
}
|
|
528
589
|
|
|
529
590
|
// Look up label ID
|
|
@@ -561,6 +622,7 @@ async function cmdIssueCreate(args) {
|
|
|
561
622
|
|
|
562
623
|
const input = { teamId, title, description };
|
|
563
624
|
if (projectId) input.projectId = projectId;
|
|
625
|
+
if (milestoneId) input.projectMilestoneId = milestoneId;
|
|
564
626
|
if (parent) input.parentId = parent;
|
|
565
627
|
if (assigneeId) input.assigneeId = assigneeId;
|
|
566
628
|
if (estimate) input.estimate = ESTIMATE_MAP[estimate];
|
|
@@ -616,6 +678,8 @@ async function cmdIssueUpdate(args) {
|
|
|
616
678
|
title: 'string', t: 'string',
|
|
617
679
|
description: 'string', d: 'string',
|
|
618
680
|
state: 'string', s: 'string',
|
|
681
|
+
project: 'string', p: 'string',
|
|
682
|
+
milestone: 'string',
|
|
619
683
|
append: 'string', a: 'string',
|
|
620
684
|
blocks: 'string',
|
|
621
685
|
'blocked-by': 'string',
|
|
@@ -623,6 +687,8 @@ async function cmdIssueUpdate(args) {
|
|
|
623
687
|
|
|
624
688
|
const blocksIssue = opts.blocks;
|
|
625
689
|
const blockedByIssue = opts['blocked-by'];
|
|
690
|
+
const projectName = opts.project || opts.p;
|
|
691
|
+
const milestoneName = opts.milestone;
|
|
626
692
|
const input = {};
|
|
627
693
|
|
|
628
694
|
if (opts.title || opts.t) input.title = opts.title || opts.t;
|
|
@@ -649,6 +715,46 @@ async function cmdIssueUpdate(args) {
|
|
|
649
715
|
if (match) input.stateId = match.id;
|
|
650
716
|
}
|
|
651
717
|
|
|
718
|
+
// Handle project and milestone
|
|
719
|
+
if (projectName || milestoneName) {
|
|
720
|
+
const projectsResult = await gql(`{
|
|
721
|
+
team(id: "${TEAM_KEY}") {
|
|
722
|
+
projects(first: 50) {
|
|
723
|
+
nodes {
|
|
724
|
+
id name
|
|
725
|
+
projectMilestones { nodes { id name } }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}`);
|
|
730
|
+
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
731
|
+
|
|
732
|
+
if (projectName) {
|
|
733
|
+
const projectMatch = projects.find(p => p.name.toLowerCase().includes(projectName.toLowerCase()));
|
|
734
|
+
if (projectMatch) {
|
|
735
|
+
input.projectId = projectMatch.id;
|
|
736
|
+
if (milestoneName) {
|
|
737
|
+
const milestoneMatch = projectMatch.projectMilestones?.nodes?.find(m =>
|
|
738
|
+
m.name.toLowerCase().includes(milestoneName.toLowerCase())
|
|
739
|
+
);
|
|
740
|
+
if (milestoneMatch) input.projectMilestoneId = milestoneMatch.id;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
} else if (milestoneName) {
|
|
744
|
+
// Search all projects for the milestone
|
|
745
|
+
for (const p of projects) {
|
|
746
|
+
const milestoneMatch = p.projectMilestones?.nodes?.find(m =>
|
|
747
|
+
m.name.toLowerCase().includes(milestoneName.toLowerCase())
|
|
748
|
+
);
|
|
749
|
+
if (milestoneMatch) {
|
|
750
|
+
input.projectId = p.id;
|
|
751
|
+
input.projectMilestoneId = milestoneMatch.id;
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
652
758
|
// Handle blocking relations (can be set even without other updates)
|
|
653
759
|
const hasRelationUpdates = blocksIssue || blockedByIssue;
|
|
654
760
|
|
|
@@ -990,6 +1096,746 @@ async function cmdProjectComplete(args) {
|
|
|
990
1096
|
}
|
|
991
1097
|
}
|
|
992
1098
|
|
|
1099
|
+
// ============================================================================
|
|
1100
|
+
// MILESTONES
|
|
1101
|
+
// ============================================================================
|
|
1102
|
+
|
|
1103
|
+
async function cmdMilestones(args) {
|
|
1104
|
+
const opts = parseArgs(args, {
|
|
1105
|
+
project: 'string', p: 'string',
|
|
1106
|
+
all: 'boolean', a: 'boolean',
|
|
1107
|
+
});
|
|
1108
|
+
const projectFilter = opts.project || opts.p;
|
|
1109
|
+
const showAll = opts.all || opts.a;
|
|
1110
|
+
|
|
1111
|
+
const query = `{
|
|
1112
|
+
team(id: "${TEAM_KEY}") {
|
|
1113
|
+
projects(first: 50) {
|
|
1114
|
+
nodes {
|
|
1115
|
+
id name state
|
|
1116
|
+
projectMilestones {
|
|
1117
|
+
nodes { id name targetDate sortOrder status }
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}`;
|
|
1123
|
+
|
|
1124
|
+
const result = await gql(query);
|
|
1125
|
+
let projects = result.data?.team?.projects?.nodes || [];
|
|
1126
|
+
|
|
1127
|
+
// Filter to active projects unless --all
|
|
1128
|
+
if (!showAll) {
|
|
1129
|
+
projects = projects.filter(p => !['completed', 'canceled'].includes(p.state));
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Filter by project name if specified
|
|
1133
|
+
if (projectFilter) {
|
|
1134
|
+
projects = projects.filter(p => p.name.toLowerCase().includes(projectFilter.toLowerCase()));
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
console.log(colors.bold('Milestones:\n'));
|
|
1138
|
+
for (const project of projects) {
|
|
1139
|
+
const milestones = project.projectMilestones?.nodes || [];
|
|
1140
|
+
if (milestones.length === 0) continue;
|
|
1141
|
+
|
|
1142
|
+
console.log(colors.bold(project.name));
|
|
1143
|
+
for (const m of milestones) {
|
|
1144
|
+
const date = m.targetDate ? ` (${m.targetDate})` : '';
|
|
1145
|
+
const status = m.status !== 'planned' ? ` [${m.status}]` : '';
|
|
1146
|
+
console.log(` ${m.name}${date}${status}`);
|
|
1147
|
+
}
|
|
1148
|
+
console.log('');
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function cmdMilestoneShow(args) {
|
|
1153
|
+
const milestoneName = args[0];
|
|
1154
|
+
if (!milestoneName) {
|
|
1155
|
+
console.error(colors.red('Error: Milestone name required'));
|
|
1156
|
+
process.exit(1);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const query = `{
|
|
1160
|
+
team(id: "${TEAM_KEY}") {
|
|
1161
|
+
projects(first: 50) {
|
|
1162
|
+
nodes {
|
|
1163
|
+
name
|
|
1164
|
+
projectMilestones {
|
|
1165
|
+
nodes {
|
|
1166
|
+
id name description targetDate status sortOrder
|
|
1167
|
+
issues { nodes { identifier title state { name type } } }
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}`;
|
|
1174
|
+
|
|
1175
|
+
const result = await gql(query);
|
|
1176
|
+
const projects = result.data?.team?.projects?.nodes || [];
|
|
1177
|
+
|
|
1178
|
+
let milestone = null;
|
|
1179
|
+
let projectName = '';
|
|
1180
|
+
for (const p of projects) {
|
|
1181
|
+
const m = p.projectMilestones?.nodes?.find(m =>
|
|
1182
|
+
m.name.toLowerCase().includes(milestoneName.toLowerCase())
|
|
1183
|
+
);
|
|
1184
|
+
if (m) {
|
|
1185
|
+
milestone = m;
|
|
1186
|
+
projectName = p.name;
|
|
1187
|
+
break;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
if (!milestone) {
|
|
1192
|
+
console.error(colors.red(`Milestone not found: ${milestoneName}`));
|
|
1193
|
+
process.exit(1);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
console.log(`# ${milestone.name}\n`);
|
|
1197
|
+
console.log(`Project: ${projectName}`);
|
|
1198
|
+
console.log(`Status: ${milestone.status}`);
|
|
1199
|
+
if (milestone.targetDate) console.log(`Target: ${milestone.targetDate}`);
|
|
1200
|
+
if (milestone.description) console.log(`\n## Description\n${milestone.description}`);
|
|
1201
|
+
|
|
1202
|
+
const issues = milestone.issues?.nodes || [];
|
|
1203
|
+
if (issues.length > 0) {
|
|
1204
|
+
// Group by state type
|
|
1205
|
+
const done = issues.filter(i => i.state.type === 'completed');
|
|
1206
|
+
const inProgress = issues.filter(i => i.state.type === 'started');
|
|
1207
|
+
const backlog = issues.filter(i => !['completed', 'started', 'canceled'].includes(i.state.type));
|
|
1208
|
+
|
|
1209
|
+
console.log('\n## Issues\n');
|
|
1210
|
+
if (inProgress.length > 0) {
|
|
1211
|
+
console.log('### In Progress');
|
|
1212
|
+
inProgress.forEach(i => console.log(`- ${i.identifier}: ${i.title}`));
|
|
1213
|
+
console.log('');
|
|
1214
|
+
}
|
|
1215
|
+
if (backlog.length > 0) {
|
|
1216
|
+
console.log('### Backlog');
|
|
1217
|
+
backlog.forEach(i => console.log(`- ${i.identifier}: ${i.title}`));
|
|
1218
|
+
console.log('');
|
|
1219
|
+
}
|
|
1220
|
+
if (done.length > 0) {
|
|
1221
|
+
console.log('### Done');
|
|
1222
|
+
done.forEach(i => console.log(`- ${i.identifier}: ${i.title}`));
|
|
1223
|
+
console.log('');
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async function cmdMilestoneCreate(args) {
|
|
1229
|
+
const opts = parseArgs(args, {
|
|
1230
|
+
name: 'string', n: 'string',
|
|
1231
|
+
project: 'string', p: 'string',
|
|
1232
|
+
description: 'string', d: 'string',
|
|
1233
|
+
'target-date': 'string',
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
const name = opts.name || opts.n || opts._[0];
|
|
1237
|
+
const projectName = opts.project || opts.p;
|
|
1238
|
+
const description = opts.description || opts.d;
|
|
1239
|
+
const targetDate = opts['target-date'];
|
|
1240
|
+
|
|
1241
|
+
if (!name) {
|
|
1242
|
+
console.error(colors.red('Error: Milestone name required'));
|
|
1243
|
+
console.error('Usage: linear milestone create "Name" --project "Project" [--target-date 2024-03-01]');
|
|
1244
|
+
process.exit(1);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!projectName) {
|
|
1248
|
+
console.error(colors.red('Error: Project required (--project)'));
|
|
1249
|
+
process.exit(1);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Find project
|
|
1253
|
+
const projectsResult = await gql(`{
|
|
1254
|
+
team(id: "${TEAM_KEY}") {
|
|
1255
|
+
projects(first: 50) { nodes { id name } }
|
|
1256
|
+
}
|
|
1257
|
+
}`);
|
|
1258
|
+
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
1259
|
+
const project = projects.find(p => p.name.toLowerCase().includes(projectName.toLowerCase()));
|
|
1260
|
+
|
|
1261
|
+
if (!project) {
|
|
1262
|
+
console.error(colors.red(`Project not found: ${projectName}`));
|
|
1263
|
+
process.exit(1);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const mutation = `
|
|
1267
|
+
mutation($input: ProjectMilestoneCreateInput!) {
|
|
1268
|
+
projectMilestoneCreate(input: $input) {
|
|
1269
|
+
success
|
|
1270
|
+
projectMilestone { id name }
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
`;
|
|
1274
|
+
|
|
1275
|
+
const input = { projectId: project.id, name };
|
|
1276
|
+
if (description) input.description = description;
|
|
1277
|
+
if (targetDate) input.targetDate = targetDate;
|
|
1278
|
+
|
|
1279
|
+
const result = await gql(mutation, { input });
|
|
1280
|
+
|
|
1281
|
+
if (result.data?.projectMilestoneCreate?.success) {
|
|
1282
|
+
console.log(colors.green(`Created milestone: ${name}`));
|
|
1283
|
+
console.log(`Project: ${project.name}`);
|
|
1284
|
+
} else {
|
|
1285
|
+
console.error(colors.red('Failed to create milestone'));
|
|
1286
|
+
console.error(result.errors?.[0]?.message || JSON.stringify(result));
|
|
1287
|
+
process.exit(1);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ============================================================================
|
|
1292
|
+
// ROADMAP
|
|
1293
|
+
// ============================================================================
|
|
1294
|
+
|
|
1295
|
+
async function cmdRoadmap(args) {
|
|
1296
|
+
const opts = parseArgs(args, { all: 'boolean', a: 'boolean' });
|
|
1297
|
+
const showAll = opts.all || opts.a;
|
|
1298
|
+
|
|
1299
|
+
// Fetch projects and milestones
|
|
1300
|
+
const projectsQuery = `{
|
|
1301
|
+
team(id: "${TEAM_KEY}") {
|
|
1302
|
+
projects(first: 50) {
|
|
1303
|
+
nodes {
|
|
1304
|
+
id name state priority sortOrder targetDate startDate
|
|
1305
|
+
projectMilestones {
|
|
1306
|
+
nodes {
|
|
1307
|
+
id name targetDate status sortOrder
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}`;
|
|
1314
|
+
|
|
1315
|
+
// Fetch issues separately to avoid complexity limits
|
|
1316
|
+
const issuesQuery = `{
|
|
1317
|
+
team(id: "${TEAM_KEY}") {
|
|
1318
|
+
issues(first: 200) {
|
|
1319
|
+
nodes {
|
|
1320
|
+
id identifier title state { name type } sortOrder priority
|
|
1321
|
+
project { id }
|
|
1322
|
+
projectMilestone { id }
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}`;
|
|
1327
|
+
|
|
1328
|
+
const [projectsResult, issuesResult] = await Promise.all([
|
|
1329
|
+
gql(projectsQuery),
|
|
1330
|
+
gql(issuesQuery)
|
|
1331
|
+
]);
|
|
1332
|
+
|
|
1333
|
+
let projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
1334
|
+
const allIssues = issuesResult.data?.team?.issues?.nodes || [];
|
|
1335
|
+
|
|
1336
|
+
// Sort by sortOrder descending (higher = first)
|
|
1337
|
+
projects.sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
|
|
1338
|
+
|
|
1339
|
+
if (!showAll) {
|
|
1340
|
+
projects = projects.filter(p => !['completed', 'canceled'].includes(p.state));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
console.log(colors.bold('Roadmap\n'));
|
|
1344
|
+
|
|
1345
|
+
for (const project of projects) {
|
|
1346
|
+
const issues = allIssues.filter(i => i.project?.id === project.id);
|
|
1347
|
+
const milestones = project.projectMilestones?.nodes || [];
|
|
1348
|
+
|
|
1349
|
+
// Count issues by state
|
|
1350
|
+
const done = issues.filter(i => i.state.type === 'completed').length;
|
|
1351
|
+
const inProgress = issues.filter(i => i.state.type === 'started').length;
|
|
1352
|
+
const backlog = issues.filter(i => !['completed', 'started', 'canceled'].includes(i.state.type)).length;
|
|
1353
|
+
|
|
1354
|
+
// Project header
|
|
1355
|
+
const dates = [];
|
|
1356
|
+
if (project.startDate) dates.push(`start: ${project.startDate}`);
|
|
1357
|
+
if (project.targetDate) dates.push(`target: ${project.targetDate}`);
|
|
1358
|
+
const dateStr = dates.length > 0 ? ` (${dates.join(', ')})` : '';
|
|
1359
|
+
const priorityStr = project.priority > 0 ? ` [P${project.priority}]` : '';
|
|
1360
|
+
|
|
1361
|
+
console.log(colors.bold(`${project.name}${priorityStr}${dateStr}`));
|
|
1362
|
+
console.log(` ${colors.green(`✓ ${done}`)} done | ${colors.yellow(`→ ${inProgress}`)} in progress | ${colors.gray(`○ ${backlog}`)} backlog`);
|
|
1363
|
+
|
|
1364
|
+
// Show milestones with their issues
|
|
1365
|
+
if (milestones.length > 0) {
|
|
1366
|
+
// Sort milestones by sortOrder descending
|
|
1367
|
+
milestones.sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
|
|
1368
|
+
|
|
1369
|
+
for (const m of milestones) {
|
|
1370
|
+
// Count issues in this milestone from project issues
|
|
1371
|
+
const mIssues = issues.filter(i => i.projectMilestone?.id === m.id);
|
|
1372
|
+
const mDone = mIssues.filter(i => i.state.type === 'completed').length;
|
|
1373
|
+
const mTotal = mIssues.length;
|
|
1374
|
+
const statusIcon = m.status === 'completed' ? colors.green('✓') :
|
|
1375
|
+
m.status === 'inProgress' ? colors.yellow('→') : '○';
|
|
1376
|
+
const targetStr = m.targetDate ? ` (${m.targetDate})` : '';
|
|
1377
|
+
|
|
1378
|
+
console.log(` ${statusIcon} ${m.name}${targetStr}: ${mDone}/${mTotal} done`);
|
|
1379
|
+
|
|
1380
|
+
// Show non-completed issues in this milestone
|
|
1381
|
+
const issuesInMilestone = mIssues.filter(i =>
|
|
1382
|
+
!['completed', 'canceled'].includes(i.state.type)
|
|
1383
|
+
);
|
|
1384
|
+
|
|
1385
|
+
// Sort by priority then sortOrder
|
|
1386
|
+
issuesInMilestone.sort((a, b) => {
|
|
1387
|
+
if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
|
|
1388
|
+
return (b.sortOrder || 0) - (a.sortOrder || 0);
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
for (const i of issuesInMilestone.slice(0, 5)) {
|
|
1392
|
+
const stateIcon = i.state.type === 'started' ? colors.yellow('→') : '○';
|
|
1393
|
+
console.log(` ${stateIcon} ${i.identifier}: ${i.title}`);
|
|
1394
|
+
}
|
|
1395
|
+
if (issuesInMilestone.length > 5) {
|
|
1396
|
+
console.log(colors.gray(` ... and ${issuesInMilestone.length - 5} more`));
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Show issues not in any milestone
|
|
1402
|
+
const unmilestonedIssues = issues.filter(i =>
|
|
1403
|
+
!i.projectMilestone &&
|
|
1404
|
+
!['completed', 'canceled'].includes(i.state.type)
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
if (unmilestonedIssues.length > 0 && milestones.length > 0) {
|
|
1408
|
+
console.log(colors.gray(` (${unmilestonedIssues.length} issues not in milestones)`));
|
|
1409
|
+
} else if (unmilestonedIssues.length > 0) {
|
|
1410
|
+
// Sort by priority then sortOrder
|
|
1411
|
+
unmilestonedIssues.sort((a, b) => {
|
|
1412
|
+
if ((b.priority || 0) !== (a.priority || 0)) return (b.priority || 0) - (a.priority || 0);
|
|
1413
|
+
return (b.sortOrder || 0) - (a.sortOrder || 0);
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
for (const i of unmilestonedIssues.slice(0, 5)) {
|
|
1417
|
+
const stateIcon = i.state.type === 'started' ? colors.yellow('→') : '○';
|
|
1418
|
+
console.log(` ${stateIcon} ${i.identifier}: ${i.title}`);
|
|
1419
|
+
}
|
|
1420
|
+
if (unmilestonedIssues.length > 5) {
|
|
1421
|
+
console.log(colors.gray(` ... and ${unmilestonedIssues.length - 5} more`));
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
console.log('');
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// ============================================================================
|
|
1430
|
+
// REORDERING
|
|
1431
|
+
// ============================================================================
|
|
1432
|
+
|
|
1433
|
+
async function cmdProjectsReorder(args) {
|
|
1434
|
+
if (args.length < 2) {
|
|
1435
|
+
console.error(colors.red('Error: At least 2 project names required'));
|
|
1436
|
+
console.error('Usage: linear projects reorder "Project A" "Project B" "Project C"');
|
|
1437
|
+
process.exit(1);
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Get all projects
|
|
1441
|
+
const projectsResult = await gql(`{
|
|
1442
|
+
team(id: "${TEAM_KEY}") {
|
|
1443
|
+
projects(first: 50) { nodes { id name sortOrder } }
|
|
1444
|
+
}
|
|
1445
|
+
}`);
|
|
1446
|
+
const allProjects = projectsResult.data?.team?.projects?.nodes || [];
|
|
1447
|
+
|
|
1448
|
+
// Match provided names to projects
|
|
1449
|
+
const orderedProjects = [];
|
|
1450
|
+
for (const name of args) {
|
|
1451
|
+
const match = allProjects.find(p => p.name.toLowerCase().includes(name.toLowerCase()));
|
|
1452
|
+
if (!match) {
|
|
1453
|
+
console.error(colors.red(`Project not found: ${name}`));
|
|
1454
|
+
process.exit(1);
|
|
1455
|
+
}
|
|
1456
|
+
orderedProjects.push(match);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// Assign new sortOrders (highest first)
|
|
1460
|
+
const baseSort = Math.max(...allProjects.map(p => p.sortOrder || 0)) + 1000;
|
|
1461
|
+
const mutations = [];
|
|
1462
|
+
|
|
1463
|
+
for (let i = 0; i < orderedProjects.length; i++) {
|
|
1464
|
+
const newSortOrder = baseSort - (i * 1000);
|
|
1465
|
+
mutations.push(gql(`
|
|
1466
|
+
mutation {
|
|
1467
|
+
projectUpdate(id: "${orderedProjects[i].id}", input: { sortOrder: ${newSortOrder} }) {
|
|
1468
|
+
success
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
`));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
await Promise.all(mutations);
|
|
1475
|
+
console.log(colors.green('Reordered projects:'));
|
|
1476
|
+
orderedProjects.forEach((p, i) => console.log(` ${i + 1}. ${p.name}`));
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async function cmdProjectMove(args) {
|
|
1480
|
+
const opts = parseArgs(args, {
|
|
1481
|
+
before: 'string',
|
|
1482
|
+
after: 'string',
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
const projectName = opts._[0];
|
|
1486
|
+
const beforeName = opts.before;
|
|
1487
|
+
const afterName = opts.after;
|
|
1488
|
+
|
|
1489
|
+
if (!projectName) {
|
|
1490
|
+
console.error(colors.red('Error: Project name required'));
|
|
1491
|
+
console.error('Usage: linear project move "Project" --before "Other" or --after "Other"');
|
|
1492
|
+
process.exit(1);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if (!beforeName && !afterName) {
|
|
1496
|
+
console.error(colors.red('Error: --before or --after required'));
|
|
1497
|
+
process.exit(1);
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
// Get all projects
|
|
1501
|
+
const projectsResult = await gql(`{
|
|
1502
|
+
team(id: "${TEAM_KEY}") {
|
|
1503
|
+
projects(first: 50) { nodes { id name sortOrder } }
|
|
1504
|
+
}
|
|
1505
|
+
}`);
|
|
1506
|
+
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
1507
|
+
projects.sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
|
|
1508
|
+
|
|
1509
|
+
const project = projects.find(p => p.name.toLowerCase().includes(projectName.toLowerCase()));
|
|
1510
|
+
const target = projects.find(p => p.name.toLowerCase().includes((beforeName || afterName).toLowerCase()));
|
|
1511
|
+
|
|
1512
|
+
if (!project) {
|
|
1513
|
+
console.error(colors.red(`Project not found: ${projectName}`));
|
|
1514
|
+
process.exit(1);
|
|
1515
|
+
}
|
|
1516
|
+
if (!target) {
|
|
1517
|
+
console.error(colors.red(`Target project not found: ${beforeName || afterName}`));
|
|
1518
|
+
process.exit(1);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const targetIdx = projects.findIndex(p => p.id === target.id);
|
|
1522
|
+
let newSortOrder;
|
|
1523
|
+
|
|
1524
|
+
if (beforeName) {
|
|
1525
|
+
// Insert before target (higher sortOrder)
|
|
1526
|
+
const prevProject = projects[targetIdx - 1];
|
|
1527
|
+
if (prevProject) {
|
|
1528
|
+
newSortOrder = (target.sortOrder + prevProject.sortOrder) / 2;
|
|
1529
|
+
} else {
|
|
1530
|
+
newSortOrder = target.sortOrder + 1000;
|
|
1531
|
+
}
|
|
1532
|
+
} else {
|
|
1533
|
+
// Insert after target (lower sortOrder)
|
|
1534
|
+
const nextProject = projects[targetIdx + 1];
|
|
1535
|
+
if (nextProject) {
|
|
1536
|
+
newSortOrder = (target.sortOrder + nextProject.sortOrder) / 2;
|
|
1537
|
+
} else {
|
|
1538
|
+
newSortOrder = target.sortOrder - 1000;
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
await gql(`
|
|
1543
|
+
mutation {
|
|
1544
|
+
projectUpdate(id: "${project.id}", input: { sortOrder: ${newSortOrder} }) {
|
|
1545
|
+
success
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
`);
|
|
1549
|
+
|
|
1550
|
+
console.log(colors.green(`Moved "${project.name}" ${beforeName ? 'before' : 'after'} "${target.name}"`));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
async function cmdMilestonesReorder(args) {
|
|
1554
|
+
const opts = parseArgs(args, { project: 'string', p: 'string' });
|
|
1555
|
+
const projectName = opts.project || opts.p;
|
|
1556
|
+
const milestoneNames = opts._;
|
|
1557
|
+
|
|
1558
|
+
if (!projectName) {
|
|
1559
|
+
console.error(colors.red('Error: --project required'));
|
|
1560
|
+
console.error('Usage: linear milestones reorder "M1" "M2" "M3" --project "Project"');
|
|
1561
|
+
process.exit(1);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
if (milestoneNames.length < 2) {
|
|
1565
|
+
console.error(colors.red('Error: At least 2 milestone names required'));
|
|
1566
|
+
process.exit(1);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// Get project with milestones
|
|
1570
|
+
const projectsResult = await gql(`{
|
|
1571
|
+
team(id: "${TEAM_KEY}") {
|
|
1572
|
+
projects(first: 50) {
|
|
1573
|
+
nodes {
|
|
1574
|
+
id name
|
|
1575
|
+
projectMilestones { nodes { id name sortOrder } }
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}`);
|
|
1580
|
+
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
1581
|
+
const project = projects.find(p => p.name.toLowerCase().includes(projectName.toLowerCase()));
|
|
1582
|
+
|
|
1583
|
+
if (!project) {
|
|
1584
|
+
console.error(colors.red(`Project not found: ${projectName}`));
|
|
1585
|
+
process.exit(1);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const allMilestones = project.projectMilestones?.nodes || [];
|
|
1589
|
+
|
|
1590
|
+
// Match provided names to milestones
|
|
1591
|
+
const orderedMilestones = [];
|
|
1592
|
+
for (const name of milestoneNames) {
|
|
1593
|
+
const match = allMilestones.find(m => m.name.toLowerCase().includes(name.toLowerCase()));
|
|
1594
|
+
if (!match) {
|
|
1595
|
+
console.error(colors.red(`Milestone not found: ${name}`));
|
|
1596
|
+
process.exit(1);
|
|
1597
|
+
}
|
|
1598
|
+
orderedMilestones.push(match);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Assign new sortOrders
|
|
1602
|
+
const baseSort = Math.max(...allMilestones.map(m => m.sortOrder || 0)) + 1000;
|
|
1603
|
+
const mutations = [];
|
|
1604
|
+
|
|
1605
|
+
for (let i = 0; i < orderedMilestones.length; i++) {
|
|
1606
|
+
const newSortOrder = baseSort - (i * 1000);
|
|
1607
|
+
mutations.push(gql(`
|
|
1608
|
+
mutation {
|
|
1609
|
+
projectMilestoneUpdate(id: "${orderedMilestones[i].id}", input: { sortOrder: ${newSortOrder} }) {
|
|
1610
|
+
success
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
`));
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
await Promise.all(mutations);
|
|
1617
|
+
console.log(colors.green(`Reordered milestones in ${project.name}:`));
|
|
1618
|
+
orderedMilestones.forEach((m, i) => console.log(` ${i + 1}. ${m.name}`));
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
async function cmdMilestoneMove(args) {
|
|
1622
|
+
const opts = parseArgs(args, {
|
|
1623
|
+
before: 'string',
|
|
1624
|
+
after: 'string',
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
const milestoneName = opts._[0];
|
|
1628
|
+
const beforeName = opts.before;
|
|
1629
|
+
const afterName = opts.after;
|
|
1630
|
+
|
|
1631
|
+
if (!milestoneName) {
|
|
1632
|
+
console.error(colors.red('Error: Milestone name required'));
|
|
1633
|
+
process.exit(1);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
if (!beforeName && !afterName) {
|
|
1637
|
+
console.error(colors.red('Error: --before or --after required'));
|
|
1638
|
+
process.exit(1);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// Get all projects with milestones
|
|
1642
|
+
const projectsResult = await gql(`{
|
|
1643
|
+
team(id: "${TEAM_KEY}") {
|
|
1644
|
+
projects(first: 50) {
|
|
1645
|
+
nodes {
|
|
1646
|
+
id name
|
|
1647
|
+
projectMilestones { nodes { id name sortOrder } }
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
}`);
|
|
1652
|
+
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
1653
|
+
|
|
1654
|
+
// Find milestone and its project
|
|
1655
|
+
let milestone = null;
|
|
1656
|
+
let projectMilestones = [];
|
|
1657
|
+
for (const p of projects) {
|
|
1658
|
+
const m = p.projectMilestones?.nodes?.find(m =>
|
|
1659
|
+
m.name.toLowerCase().includes(milestoneName.toLowerCase())
|
|
1660
|
+
);
|
|
1661
|
+
if (m) {
|
|
1662
|
+
milestone = m;
|
|
1663
|
+
projectMilestones = p.projectMilestones.nodes;
|
|
1664
|
+
projectMilestones.sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
|
|
1665
|
+
break;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
if (!milestone) {
|
|
1670
|
+
console.error(colors.red(`Milestone not found: ${milestoneName}`));
|
|
1671
|
+
process.exit(1);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
const target = projectMilestones.find(m =>
|
|
1675
|
+
m.name.toLowerCase().includes((beforeName || afterName).toLowerCase())
|
|
1676
|
+
);
|
|
1677
|
+
|
|
1678
|
+
if (!target) {
|
|
1679
|
+
console.error(colors.red(`Target milestone not found: ${beforeName || afterName}`));
|
|
1680
|
+
process.exit(1);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const targetIdx = projectMilestones.findIndex(m => m.id === target.id);
|
|
1684
|
+
let newSortOrder;
|
|
1685
|
+
|
|
1686
|
+
if (beforeName) {
|
|
1687
|
+
const prevMilestone = projectMilestones[targetIdx - 1];
|
|
1688
|
+
if (prevMilestone) {
|
|
1689
|
+
newSortOrder = (target.sortOrder + prevMilestone.sortOrder) / 2;
|
|
1690
|
+
} else {
|
|
1691
|
+
newSortOrder = target.sortOrder + 1000;
|
|
1692
|
+
}
|
|
1693
|
+
} else {
|
|
1694
|
+
const nextMilestone = projectMilestones[targetIdx + 1];
|
|
1695
|
+
if (nextMilestone) {
|
|
1696
|
+
newSortOrder = (target.sortOrder + nextMilestone.sortOrder) / 2;
|
|
1697
|
+
} else {
|
|
1698
|
+
newSortOrder = target.sortOrder - 1000;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
await gql(`
|
|
1703
|
+
mutation {
|
|
1704
|
+
projectMilestoneUpdate(id: "${milestone.id}", input: { sortOrder: ${newSortOrder} }) {
|
|
1705
|
+
success
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
`);
|
|
1709
|
+
|
|
1710
|
+
console.log(colors.green(`Moved "${milestone.name}" ${beforeName ? 'before' : 'after'} "${target.name}"`));
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
async function cmdIssuesReorder(args) {
|
|
1714
|
+
if (args.length < 2) {
|
|
1715
|
+
console.error(colors.red('Error: At least 2 issue IDs required'));
|
|
1716
|
+
console.error('Usage: linear issues reorder ISSUE-1 ISSUE-2 ISSUE-3');
|
|
1717
|
+
process.exit(1);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// Get issues to verify they exist
|
|
1721
|
+
const query = `{
|
|
1722
|
+
team(id: "${TEAM_KEY}") {
|
|
1723
|
+
issues(first: 100) {
|
|
1724
|
+
nodes { id identifier sortOrder }
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
}`;
|
|
1728
|
+
|
|
1729
|
+
const result = await gql(query);
|
|
1730
|
+
const allIssues = result.data?.team?.issues?.nodes || [];
|
|
1731
|
+
|
|
1732
|
+
// Match provided IDs to issues
|
|
1733
|
+
const orderedIssues = [];
|
|
1734
|
+
for (const id of args) {
|
|
1735
|
+
const match = allIssues.find(i => i.identifier === id.toUpperCase());
|
|
1736
|
+
if (!match) {
|
|
1737
|
+
console.error(colors.red(`Issue not found: ${id}`));
|
|
1738
|
+
process.exit(1);
|
|
1739
|
+
}
|
|
1740
|
+
orderedIssues.push(match);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// Assign new sortOrders
|
|
1744
|
+
const baseSort = Math.max(...allIssues.map(i => i.sortOrder || 0)) + 1000;
|
|
1745
|
+
const mutations = [];
|
|
1746
|
+
|
|
1747
|
+
for (let i = 0; i < orderedIssues.length; i++) {
|
|
1748
|
+
const newSortOrder = baseSort - (i * 1000);
|
|
1749
|
+
mutations.push(gql(`
|
|
1750
|
+
mutation {
|
|
1751
|
+
issueUpdate(id: "${orderedIssues[i].identifier}", input: { sortOrder: ${newSortOrder} }) {
|
|
1752
|
+
success
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
`));
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
await Promise.all(mutations);
|
|
1759
|
+
console.log(colors.green('Reordered issues:'));
|
|
1760
|
+
orderedIssues.forEach((i, idx) => console.log(` ${idx + 1}. ${i.identifier}`));
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
async function cmdIssueMove(args) {
|
|
1764
|
+
const opts = parseArgs(args, {
|
|
1765
|
+
before: 'string',
|
|
1766
|
+
after: 'string',
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
const issueId = opts._[0];
|
|
1770
|
+
const beforeId = opts.before;
|
|
1771
|
+
const afterId = opts.after;
|
|
1772
|
+
|
|
1773
|
+
if (!issueId) {
|
|
1774
|
+
console.error(colors.red('Error: Issue ID required'));
|
|
1775
|
+
console.error('Usage: linear issue move ISSUE-1 --before ISSUE-2 or --after ISSUE-2');
|
|
1776
|
+
process.exit(1);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
if (!beforeId && !afterId) {
|
|
1780
|
+
console.error(colors.red('Error: --before or --after required'));
|
|
1781
|
+
process.exit(1);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
// Get issues
|
|
1785
|
+
const query = `{
|
|
1786
|
+
team(id: "${TEAM_KEY}") {
|
|
1787
|
+
issues(first: 100) {
|
|
1788
|
+
nodes { id identifier sortOrder }
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}`;
|
|
1792
|
+
|
|
1793
|
+
const result = await gql(query);
|
|
1794
|
+
const issues = result.data?.team?.issues?.nodes || [];
|
|
1795
|
+
issues.sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
|
|
1796
|
+
|
|
1797
|
+
const issue = issues.find(i => i.identifier === issueId.toUpperCase());
|
|
1798
|
+
const target = issues.find(i => i.identifier === (beforeId || afterId).toUpperCase());
|
|
1799
|
+
|
|
1800
|
+
if (!issue) {
|
|
1801
|
+
console.error(colors.red(`Issue not found: ${issueId}`));
|
|
1802
|
+
process.exit(1);
|
|
1803
|
+
}
|
|
1804
|
+
if (!target) {
|
|
1805
|
+
console.error(colors.red(`Target issue not found: ${beforeId || afterId}`));
|
|
1806
|
+
process.exit(1);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
const targetIdx = issues.findIndex(i => i.identifier === target.identifier);
|
|
1810
|
+
let newSortOrder;
|
|
1811
|
+
|
|
1812
|
+
if (beforeId) {
|
|
1813
|
+
const prevIssue = issues[targetIdx - 1];
|
|
1814
|
+
if (prevIssue) {
|
|
1815
|
+
newSortOrder = (target.sortOrder + prevIssue.sortOrder) / 2;
|
|
1816
|
+
} else {
|
|
1817
|
+
newSortOrder = target.sortOrder + 1000;
|
|
1818
|
+
}
|
|
1819
|
+
} else {
|
|
1820
|
+
const nextIssue = issues[targetIdx + 1];
|
|
1821
|
+
if (nextIssue) {
|
|
1822
|
+
newSortOrder = (target.sortOrder + nextIssue.sortOrder) / 2;
|
|
1823
|
+
} else {
|
|
1824
|
+
newSortOrder = target.sortOrder - 1000;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
await gql(`
|
|
1829
|
+
mutation {
|
|
1830
|
+
issueUpdate(id: "${issue.identifier}", input: { sortOrder: ${newSortOrder} }) {
|
|
1831
|
+
success
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
`);
|
|
1835
|
+
|
|
1836
|
+
console.log(colors.green(`Moved ${issue.identifier} ${beforeId ? 'before' : 'after'} ${target.identifier}`));
|
|
1837
|
+
}
|
|
1838
|
+
|
|
993
1839
|
// ============================================================================
|
|
994
1840
|
// LABELS
|
|
995
1841
|
// ============================================================================
|
|
@@ -1684,17 +2530,30 @@ async function cmdStandup(args) {
|
|
|
1684
2530
|
// ============================================================================
|
|
1685
2531
|
|
|
1686
2532
|
async function cmdLogin(args) {
|
|
1687
|
-
const opts = parseArgs(args, { global: 'boolean', g: 'boolean' });
|
|
1688
|
-
const saveGlobal = opts.global || opts.g;
|
|
1689
|
-
|
|
1690
2533
|
console.log(colors.bold('Linear CLI Login\n'));
|
|
1691
|
-
|
|
2534
|
+
|
|
2535
|
+
// Ask where to save credentials
|
|
2536
|
+
console.log('Where would you like to save your credentials?\n');
|
|
2537
|
+
console.log(' 1. This project only (./.linear)');
|
|
2538
|
+
console.log(' 2. Global, for all projects (~/.linear)');
|
|
2539
|
+
console.log('');
|
|
2540
|
+
|
|
2541
|
+
const locationChoice = await prompt('Enter number: ');
|
|
2542
|
+
if (locationChoice !== '1' && locationChoice !== '2') {
|
|
2543
|
+
console.error(colors.red('Error: Please enter 1 or 2'));
|
|
2544
|
+
process.exit(1);
|
|
2545
|
+
}
|
|
2546
|
+
const saveGlobal = locationChoice === '2';
|
|
2547
|
+
console.log('');
|
|
2548
|
+
|
|
2549
|
+
// Explain and prompt before opening browser
|
|
2550
|
+
console.log('To authenticate, you\'ll need a Linear API key.');
|
|
1692
2551
|
console.log(colors.gray('(Create a new personal API key if you don\'t have one)\n'));
|
|
2552
|
+
await prompt('Press Enter to open Linear\'s API settings in your browser...');
|
|
1693
2553
|
|
|
1694
2554
|
openBrowser('https://linear.app/settings/api');
|
|
1695
2555
|
|
|
1696
|
-
|
|
1697
|
-
|
|
2556
|
+
console.log('');
|
|
1698
2557
|
const apiKey = await prompt('Paste your API key: ');
|
|
1699
2558
|
|
|
1700
2559
|
if (!apiKey) {
|
|
@@ -1725,10 +2584,15 @@ async function cmdLogin(args) {
|
|
|
1725
2584
|
console.log(` ${teams.length + 1}. Create a new team...`);
|
|
1726
2585
|
console.log('');
|
|
1727
2586
|
|
|
1728
|
-
const selection = await prompt('Enter number
|
|
2587
|
+
const selection = await prompt('Enter number: ');
|
|
2588
|
+
const selectionNum = parseInt(selection);
|
|
2589
|
+
if (!selection || isNaN(selectionNum) || selectionNum < 1 || selectionNum > teams.length + 1) {
|
|
2590
|
+
console.error(colors.red('Error: Invalid selection'));
|
|
2591
|
+
process.exit(1);
|
|
2592
|
+
}
|
|
1729
2593
|
let selectedKey = '';
|
|
1730
2594
|
|
|
1731
|
-
if (
|
|
2595
|
+
if (selectionNum === teams.length + 1) {
|
|
1732
2596
|
// Create new team
|
|
1733
2597
|
console.log('');
|
|
1734
2598
|
const teamName = await prompt('Team name: ');
|
|
@@ -1760,12 +2624,7 @@ async function cmdLogin(args) {
|
|
|
1760
2624
|
process.exit(1);
|
|
1761
2625
|
}
|
|
1762
2626
|
} else {
|
|
1763
|
-
|
|
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;
|
|
2627
|
+
selectedKey = teams[selectionNum - 1].key;
|
|
1769
2628
|
}
|
|
1770
2629
|
|
|
1771
2630
|
// Save config
|
|
@@ -1869,20 +2728,27 @@ USAGE:
|
|
|
1869
2728
|
linear <command> [options]
|
|
1870
2729
|
|
|
1871
2730
|
AUTHENTICATION:
|
|
1872
|
-
login
|
|
1873
|
-
--global, -g Save to ~/.linear instead of ./.linear
|
|
2731
|
+
login Login and save credentials to .linear
|
|
1874
2732
|
logout Remove saved credentials
|
|
1875
2733
|
whoami Show current user and team
|
|
1876
2734
|
|
|
2735
|
+
PLANNING:
|
|
2736
|
+
roadmap [options] Overview of projects, milestones, and issues
|
|
2737
|
+
--all, -a Include completed projects
|
|
2738
|
+
|
|
1877
2739
|
ISSUES:
|
|
1878
|
-
issues [options] List issues (yours
|
|
2740
|
+
issues [options] List issues (default: backlog, yours first)
|
|
1879
2741
|
--unblocked, -u Show only unblocked issues
|
|
1880
2742
|
--open, -o Show all non-completed/canceled issues
|
|
2743
|
+
--backlog, -b Show only backlog issues
|
|
1881
2744
|
--all, -a Show all states (including completed)
|
|
1882
2745
|
--mine, -m Show only issues assigned to you
|
|
1883
2746
|
--in-progress Show issues in progress
|
|
1884
|
-
--
|
|
2747
|
+
--project, -p <name> Filter by project
|
|
2748
|
+
--milestone <name> Filter by milestone
|
|
2749
|
+
--state, -s <state> Filter by state
|
|
1885
2750
|
--label, -l <name> Filter by label
|
|
2751
|
+
issues reorder <ids...> Reorder issues by listing IDs in order
|
|
1886
2752
|
|
|
1887
2753
|
issue show <id> Show issue details with parent context
|
|
1888
2754
|
issue start <id> Assign to yourself and set to In Progress
|
|
@@ -1890,6 +2756,7 @@ ISSUES:
|
|
|
1890
2756
|
--title, -t <title> Issue title (required)
|
|
1891
2757
|
--description, -d <desc> Issue description
|
|
1892
2758
|
--project, -p <name> Add to project
|
|
2759
|
+
--milestone <name> Add to milestone
|
|
1893
2760
|
--parent <id> Parent issue (for sub-issues)
|
|
1894
2761
|
--assign Assign to yourself
|
|
1895
2762
|
--estimate, -e <size> Estimate: XS, S, M, L, XL
|
|
@@ -1900,20 +2767,47 @@ ISSUES:
|
|
|
1900
2767
|
--title, -t <title> New title
|
|
1901
2768
|
--description, -d <desc> New description
|
|
1902
2769
|
--state, -s <state> New state
|
|
2770
|
+
--project, -p <name> Move to project
|
|
2771
|
+
--milestone <name> Move to milestone
|
|
1903
2772
|
--append, -a <text> Append to description
|
|
1904
2773
|
--blocks <id> Add blocking relation
|
|
1905
2774
|
--blocked-by <id> Add blocked-by relation
|
|
1906
2775
|
issue close <id> Mark issue as done
|
|
1907
2776
|
issue comment <id> <body> Add a comment
|
|
2777
|
+
issue move <id> Move issue in sort order
|
|
2778
|
+
--before <id> Move before this issue
|
|
2779
|
+
--after <id> Move after this issue
|
|
1908
2780
|
|
|
1909
2781
|
PROJECTS:
|
|
1910
2782
|
projects [options] List projects
|
|
1911
2783
|
--all, -a Include completed projects
|
|
1912
|
-
|
|
2784
|
+
projects reorder <names..> Reorder projects by listing names in order
|
|
2785
|
+
|
|
2786
|
+
project show <name> Show project details with issues
|
|
1913
2787
|
project create [options] Create a new project
|
|
1914
2788
|
--name, -n <name> Project name (required)
|
|
1915
2789
|
--description, -d <desc> Project description
|
|
1916
2790
|
project complete <name> Mark project as completed
|
|
2791
|
+
project move <name> Move project in sort order
|
|
2792
|
+
--before <name> Move before this project
|
|
2793
|
+
--after <name> Move after this project
|
|
2794
|
+
|
|
2795
|
+
MILESTONES:
|
|
2796
|
+
milestones [options] List milestones by project
|
|
2797
|
+
--project, -p <name> Filter by project
|
|
2798
|
+
--all, -a Include completed projects
|
|
2799
|
+
milestones reorder <names> Reorder milestones (requires --project)
|
|
2800
|
+
--project, -p <name> Project containing milestones
|
|
2801
|
+
|
|
2802
|
+
milestone show <name> Show milestone with issues
|
|
2803
|
+
milestone create [options] Create a new milestone
|
|
2804
|
+
--name, -n <name> Milestone name (required)
|
|
2805
|
+
--project, -p <name> Project (required)
|
|
2806
|
+
--description, -d <desc> Milestone description
|
|
2807
|
+
--target-date <date> Target date (YYYY-MM-DD)
|
|
2808
|
+
milestone move <name> Move milestone in sort order
|
|
2809
|
+
--before <name> Move before this milestone
|
|
2810
|
+
--after <name> Move after this milestone
|
|
1917
2811
|
|
|
1918
2812
|
LABELS:
|
|
1919
2813
|
labels List all labels
|
|
@@ -1945,15 +2839,12 @@ CONFIGURATION:
|
|
|
1945
2839
|
team=ISSUE
|
|
1946
2840
|
|
|
1947
2841
|
EXAMPLES:
|
|
1948
|
-
linear
|
|
2842
|
+
linear roadmap # See all projects and milestones
|
|
1949
2843
|
linear issues --unblocked # Find workable issues
|
|
1950
|
-
linear issues --
|
|
1951
|
-
linear issue
|
|
1952
|
-
linear
|
|
1953
|
-
linear
|
|
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
|
|
2844
|
+
linear issues --project "Phase 1" # Issues in a project
|
|
2845
|
+
linear issue create --title "Fix bug" --milestone "Beta" --estimate M
|
|
2846
|
+
linear projects reorder "Phase 1" "Phase 2" "Phase 3"
|
|
2847
|
+
linear project move "Phase 3" --before "Phase 1"
|
|
1957
2848
|
`);
|
|
1958
2849
|
}
|
|
1959
2850
|
|
|
@@ -1983,10 +2874,16 @@ async function main() {
|
|
|
1983
2874
|
case 'whoami':
|
|
1984
2875
|
await cmdWhoami();
|
|
1985
2876
|
break;
|
|
1986
|
-
case 'issues':
|
|
2877
|
+
case 'issues': {
|
|
1987
2878
|
checkAuth();
|
|
1988
|
-
|
|
2879
|
+
// Check for "issues reorder" subcommand
|
|
2880
|
+
if (args[1] === 'reorder') {
|
|
2881
|
+
await cmdIssuesReorder(args.slice(2));
|
|
2882
|
+
} else {
|
|
2883
|
+
await cmdIssues(args.slice(1));
|
|
2884
|
+
}
|
|
1989
2885
|
break;
|
|
2886
|
+
}
|
|
1990
2887
|
case 'issue': {
|
|
1991
2888
|
checkAuth();
|
|
1992
2889
|
const subcmd = args[1];
|
|
@@ -1998,16 +2895,23 @@ async function main() {
|
|
|
1998
2895
|
case 'start': await cmdIssueStart(subargs); break;
|
|
1999
2896
|
case 'close': await cmdIssueClose(subargs); break;
|
|
2000
2897
|
case 'comment': await cmdIssueComment(subargs); break;
|
|
2898
|
+
case 'move': await cmdIssueMove(subargs); break;
|
|
2001
2899
|
default:
|
|
2002
2900
|
console.error(`Unknown issue command: ${subcmd}`);
|
|
2003
2901
|
process.exit(1);
|
|
2004
2902
|
}
|
|
2005
2903
|
break;
|
|
2006
2904
|
}
|
|
2007
|
-
case 'projects':
|
|
2905
|
+
case 'projects': {
|
|
2008
2906
|
checkAuth();
|
|
2009
|
-
|
|
2907
|
+
// Check for "projects reorder" subcommand
|
|
2908
|
+
if (args[1] === 'reorder') {
|
|
2909
|
+
await cmdProjectsReorder(args.slice(2));
|
|
2910
|
+
} else {
|
|
2911
|
+
await cmdProjects(args.slice(1));
|
|
2912
|
+
}
|
|
2010
2913
|
break;
|
|
2914
|
+
}
|
|
2011
2915
|
case 'project': {
|
|
2012
2916
|
checkAuth();
|
|
2013
2917
|
const subcmd = args[1];
|
|
@@ -2016,12 +2920,41 @@ async function main() {
|
|
|
2016
2920
|
case 'show': await cmdProjectShow(subargs); break;
|
|
2017
2921
|
case 'create': await cmdProjectCreate(subargs); break;
|
|
2018
2922
|
case 'complete': await cmdProjectComplete(subargs); break;
|
|
2923
|
+
case 'move': await cmdProjectMove(subargs); break;
|
|
2019
2924
|
default:
|
|
2020
2925
|
console.error(`Unknown project command: ${subcmd}`);
|
|
2021
2926
|
process.exit(1);
|
|
2022
2927
|
}
|
|
2023
2928
|
break;
|
|
2024
2929
|
}
|
|
2930
|
+
case 'milestones': {
|
|
2931
|
+
checkAuth();
|
|
2932
|
+
// Check for "milestones reorder" subcommand
|
|
2933
|
+
if (args[1] === 'reorder') {
|
|
2934
|
+
await cmdMilestonesReorder(args.slice(2));
|
|
2935
|
+
} else {
|
|
2936
|
+
await cmdMilestones(args.slice(1));
|
|
2937
|
+
}
|
|
2938
|
+
break;
|
|
2939
|
+
}
|
|
2940
|
+
case 'milestone': {
|
|
2941
|
+
checkAuth();
|
|
2942
|
+
const subcmd = args[1];
|
|
2943
|
+
const subargs = args.slice(2);
|
|
2944
|
+
switch (subcmd) {
|
|
2945
|
+
case 'show': await cmdMilestoneShow(subargs); break;
|
|
2946
|
+
case 'create': await cmdMilestoneCreate(subargs); break;
|
|
2947
|
+
case 'move': await cmdMilestoneMove(subargs); break;
|
|
2948
|
+
default:
|
|
2949
|
+
console.error(`Unknown milestone command: ${subcmd}`);
|
|
2950
|
+
process.exit(1);
|
|
2951
|
+
}
|
|
2952
|
+
break;
|
|
2953
|
+
}
|
|
2954
|
+
case 'roadmap':
|
|
2955
|
+
checkAuth();
|
|
2956
|
+
await cmdRoadmap(args.slice(1));
|
|
2957
|
+
break;
|
|
2025
2958
|
case 'labels':
|
|
2026
2959
|
checkAuth();
|
|
2027
2960
|
await cmdLabels();
|