@dabble/linear-cli 1.0.1 → 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/bin/linear.mjs +967 -37
- package/claude/skills/linear-cli.md +43 -0
- package/claude/skills/product-planning.md +67 -98
- package/package.json +1 -1
package/bin/linear.mjs
CHANGED
|
@@ -180,9 +180,11 @@ async function cmdIssues(args) {
|
|
|
180
180
|
unblocked: 'boolean', u: 'boolean',
|
|
181
181
|
all: 'boolean', a: 'boolean',
|
|
182
182
|
open: 'boolean', o: 'boolean',
|
|
183
|
+
backlog: 'boolean', b: 'boolean',
|
|
183
184
|
mine: 'boolean', m: 'boolean',
|
|
184
185
|
'in-progress': 'boolean',
|
|
185
186
|
project: 'string', p: 'string',
|
|
187
|
+
milestone: 'string',
|
|
186
188
|
state: 'string', s: 'string',
|
|
187
189
|
label: 'string', l: 'string',
|
|
188
190
|
});
|
|
@@ -191,8 +193,11 @@ async function cmdIssues(args) {
|
|
|
191
193
|
const unblocked = opts.unblocked || opts.u;
|
|
192
194
|
const allStates = opts.all || opts.a;
|
|
193
195
|
const openOnly = opts.open || opts.o;
|
|
196
|
+
const backlogOnly = opts.backlog || opts.b;
|
|
194
197
|
const mineOnly = opts.mine || opts.m;
|
|
195
|
-
const
|
|
198
|
+
const projectFilter = opts.project || opts.p;
|
|
199
|
+
const milestoneFilter = opts.milestone;
|
|
200
|
+
const stateFilter = opts.state || opts.s;
|
|
196
201
|
const labelFilter = opts.label || opts.l;
|
|
197
202
|
|
|
198
203
|
// Get current user ID for filtering/sorting
|
|
@@ -206,8 +211,10 @@ async function cmdIssues(args) {
|
|
|
206
211
|
identifier
|
|
207
212
|
title
|
|
208
213
|
priority
|
|
214
|
+
sortOrder
|
|
209
215
|
state { name type }
|
|
210
216
|
project { name }
|
|
217
|
+
projectMilestone { name }
|
|
211
218
|
assignee { id name }
|
|
212
219
|
labels { nodes { name } }
|
|
213
220
|
relations(first: 20) {
|
|
@@ -227,13 +234,16 @@ async function cmdIssues(args) {
|
|
|
227
234
|
// Check if any issues have assignees (to decide whether to show column)
|
|
228
235
|
const hasAssignees = issues.some(i => i.assignee);
|
|
229
236
|
|
|
230
|
-
// Sort: assigned to you first, then by
|
|
237
|
+
// Sort: assigned to you first, then by priority, then by sortOrder
|
|
231
238
|
issues.sort((a, b) => {
|
|
232
239
|
const aIsMine = a.assignee?.id === viewerId;
|
|
233
240
|
const bIsMine = b.assignee?.id === viewerId;
|
|
234
241
|
if (aIsMine && !bIsMine) return -1;
|
|
235
242
|
if (!aIsMine && bIsMine) return 1;
|
|
236
|
-
|
|
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);
|
|
237
247
|
});
|
|
238
248
|
|
|
239
249
|
// Helper to format issue row
|
|
@@ -251,7 +261,7 @@ async function cmdIssues(args) {
|
|
|
251
261
|
return row;
|
|
252
262
|
};
|
|
253
263
|
|
|
254
|
-
// Helper to apply common filters (mine, label)
|
|
264
|
+
// Helper to apply common filters (mine, label, project, milestone)
|
|
255
265
|
const applyFilters = (list) => {
|
|
256
266
|
let filtered = list;
|
|
257
267
|
if (mineOnly) {
|
|
@@ -262,6 +272,16 @@ async function cmdIssues(args) {
|
|
|
262
272
|
i.labels?.nodes?.some(l => l.name.toLowerCase() === labelFilter.toLowerCase())
|
|
263
273
|
);
|
|
264
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
|
+
}
|
|
265
285
|
return filtered;
|
|
266
286
|
};
|
|
267
287
|
|
|
@@ -301,14 +321,28 @@ async function cmdIssues(args) {
|
|
|
301
321
|
|
|
302
322
|
console.log(colors.bold('Open Issues:\n'));
|
|
303
323
|
console.log(formatTable(filtered.map(formatRow)));
|
|
304
|
-
} 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';
|
|
305
332
|
let filtered = issues.filter(i =>
|
|
306
|
-
i.state.type ===
|
|
333
|
+
i.state.type === targetState || i.state.name.toLowerCase() === targetState.toLowerCase()
|
|
307
334
|
);
|
|
308
335
|
|
|
309
336
|
filtered = applyFilters(filtered);
|
|
310
337
|
|
|
311
|
-
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'));
|
|
312
346
|
console.log(formatTable(filtered.map(formatRow)));
|
|
313
347
|
}
|
|
314
348
|
}
|
|
@@ -468,6 +502,7 @@ async function cmdIssueCreate(args) {
|
|
|
468
502
|
title: 'string', t: 'string',
|
|
469
503
|
description: 'string', d: 'string',
|
|
470
504
|
project: 'string', p: 'string',
|
|
505
|
+
milestone: 'string',
|
|
471
506
|
parent: 'string',
|
|
472
507
|
state: 'string', s: 'string',
|
|
473
508
|
assign: 'boolean',
|
|
@@ -480,6 +515,7 @@ async function cmdIssueCreate(args) {
|
|
|
480
515
|
const title = opts.title || opts.t || opts._[0];
|
|
481
516
|
const description = opts.description || opts.d || '';
|
|
482
517
|
const project = opts.project || opts.p;
|
|
518
|
+
const milestone = opts.milestone;
|
|
483
519
|
const parent = opts.parent;
|
|
484
520
|
const shouldAssign = opts.assign;
|
|
485
521
|
const estimate = (opts.estimate || opts.e || '').toLowerCase();
|
|
@@ -489,7 +525,7 @@ async function cmdIssueCreate(args) {
|
|
|
489
525
|
|
|
490
526
|
if (!title) {
|
|
491
527
|
console.error(colors.red('Error: Title is required'));
|
|
492
|
-
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]');
|
|
493
529
|
process.exit(1);
|
|
494
530
|
}
|
|
495
531
|
|
|
@@ -508,17 +544,47 @@ async function cmdIssueCreate(args) {
|
|
|
508
544
|
process.exit(1);
|
|
509
545
|
}
|
|
510
546
|
|
|
511
|
-
// Look up project
|
|
547
|
+
// Look up project and milestone IDs
|
|
512
548
|
let projectId = null;
|
|
513
|
-
|
|
549
|
+
let milestoneId = null;
|
|
550
|
+
if (project || milestone) {
|
|
514
551
|
const projectsResult = await gql(`{
|
|
515
552
|
team(id: "${TEAM_KEY}") {
|
|
516
|
-
projects(first: 50) {
|
|
553
|
+
projects(first: 50) {
|
|
554
|
+
nodes {
|
|
555
|
+
id name
|
|
556
|
+
projectMilestones { nodes { id name } }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
517
559
|
}
|
|
518
560
|
}`);
|
|
519
561
|
const projects = projectsResult.data?.team?.projects?.nodes || [];
|
|
520
|
-
|
|
521
|
-
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
|
+
}
|
|
522
588
|
}
|
|
523
589
|
|
|
524
590
|
// Look up label ID
|
|
@@ -556,6 +622,7 @@ async function cmdIssueCreate(args) {
|
|
|
556
622
|
|
|
557
623
|
const input = { teamId, title, description };
|
|
558
624
|
if (projectId) input.projectId = projectId;
|
|
625
|
+
if (milestoneId) input.projectMilestoneId = milestoneId;
|
|
559
626
|
if (parent) input.parentId = parent;
|
|
560
627
|
if (assigneeId) input.assigneeId = assigneeId;
|
|
561
628
|
if (estimate) input.estimate = ESTIMATE_MAP[estimate];
|
|
@@ -611,6 +678,8 @@ async function cmdIssueUpdate(args) {
|
|
|
611
678
|
title: 'string', t: 'string',
|
|
612
679
|
description: 'string', d: 'string',
|
|
613
680
|
state: 'string', s: 'string',
|
|
681
|
+
project: 'string', p: 'string',
|
|
682
|
+
milestone: 'string',
|
|
614
683
|
append: 'string', a: 'string',
|
|
615
684
|
blocks: 'string',
|
|
616
685
|
'blocked-by': 'string',
|
|
@@ -618,6 +687,8 @@ async function cmdIssueUpdate(args) {
|
|
|
618
687
|
|
|
619
688
|
const blocksIssue = opts.blocks;
|
|
620
689
|
const blockedByIssue = opts['blocked-by'];
|
|
690
|
+
const projectName = opts.project || opts.p;
|
|
691
|
+
const milestoneName = opts.milestone;
|
|
621
692
|
const input = {};
|
|
622
693
|
|
|
623
694
|
if (opts.title || opts.t) input.title = opts.title || opts.t;
|
|
@@ -644,6 +715,46 @@ async function cmdIssueUpdate(args) {
|
|
|
644
715
|
if (match) input.stateId = match.id;
|
|
645
716
|
}
|
|
646
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
|
+
|
|
647
758
|
// Handle blocking relations (can be set even without other updates)
|
|
648
759
|
const hasRelationUpdates = blocksIssue || blockedByIssue;
|
|
649
760
|
|
|
@@ -985,6 +1096,746 @@ async function cmdProjectComplete(args) {
|
|
|
985
1096
|
}
|
|
986
1097
|
}
|
|
987
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
|
+
|
|
988
1839
|
// ============================================================================
|
|
989
1840
|
// LABELS
|
|
990
1841
|
// ============================================================================
|
|
@@ -1687,7 +2538,11 @@ async function cmdLogin(args) {
|
|
|
1687
2538
|
console.log(' 2. Global, for all projects (~/.linear)');
|
|
1688
2539
|
console.log('');
|
|
1689
2540
|
|
|
1690
|
-
const locationChoice = await prompt('Enter number
|
|
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
|
+
}
|
|
1691
2546
|
const saveGlobal = locationChoice === '2';
|
|
1692
2547
|
console.log('');
|
|
1693
2548
|
|
|
@@ -1729,10 +2584,15 @@ async function cmdLogin(args) {
|
|
|
1729
2584
|
console.log(` ${teams.length + 1}. Create a new team...`);
|
|
1730
2585
|
console.log('');
|
|
1731
2586
|
|
|
1732
|
-
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
|
+
}
|
|
1733
2593
|
let selectedKey = '';
|
|
1734
2594
|
|
|
1735
|
-
if (
|
|
2595
|
+
if (selectionNum === teams.length + 1) {
|
|
1736
2596
|
// Create new team
|
|
1737
2597
|
console.log('');
|
|
1738
2598
|
const teamName = await prompt('Team name: ');
|
|
@@ -1764,12 +2624,7 @@ async function cmdLogin(args) {
|
|
|
1764
2624
|
process.exit(1);
|
|
1765
2625
|
}
|
|
1766
2626
|
} else {
|
|
1767
|
-
|
|
1768
|
-
if (idx < 0 || idx >= teams.length) {
|
|
1769
|
-
console.error(colors.red('Error: Invalid selection'));
|
|
1770
|
-
process.exit(1);
|
|
1771
|
-
}
|
|
1772
|
-
selectedKey = teams[idx].key;
|
|
2627
|
+
selectedKey = teams[selectionNum - 1].key;
|
|
1773
2628
|
}
|
|
1774
2629
|
|
|
1775
2630
|
// Save config
|
|
@@ -1877,15 +2732,23 @@ AUTHENTICATION:
|
|
|
1877
2732
|
logout Remove saved credentials
|
|
1878
2733
|
whoami Show current user and team
|
|
1879
2734
|
|
|
2735
|
+
PLANNING:
|
|
2736
|
+
roadmap [options] Overview of projects, milestones, and issues
|
|
2737
|
+
--all, -a Include completed projects
|
|
2738
|
+
|
|
1880
2739
|
ISSUES:
|
|
1881
|
-
issues [options] List issues (yours
|
|
2740
|
+
issues [options] List issues (default: backlog, yours first)
|
|
1882
2741
|
--unblocked, -u Show only unblocked issues
|
|
1883
2742
|
--open, -o Show all non-completed/canceled issues
|
|
2743
|
+
--backlog, -b Show only backlog issues
|
|
1884
2744
|
--all, -a Show all states (including completed)
|
|
1885
2745
|
--mine, -m Show only issues assigned to you
|
|
1886
2746
|
--in-progress Show issues in progress
|
|
1887
|
-
--
|
|
2747
|
+
--project, -p <name> Filter by project
|
|
2748
|
+
--milestone <name> Filter by milestone
|
|
2749
|
+
--state, -s <state> Filter by state
|
|
1888
2750
|
--label, -l <name> Filter by label
|
|
2751
|
+
issues reorder <ids...> Reorder issues by listing IDs in order
|
|
1889
2752
|
|
|
1890
2753
|
issue show <id> Show issue details with parent context
|
|
1891
2754
|
issue start <id> Assign to yourself and set to In Progress
|
|
@@ -1893,6 +2756,7 @@ ISSUES:
|
|
|
1893
2756
|
--title, -t <title> Issue title (required)
|
|
1894
2757
|
--description, -d <desc> Issue description
|
|
1895
2758
|
--project, -p <name> Add to project
|
|
2759
|
+
--milestone <name> Add to milestone
|
|
1896
2760
|
--parent <id> Parent issue (for sub-issues)
|
|
1897
2761
|
--assign Assign to yourself
|
|
1898
2762
|
--estimate, -e <size> Estimate: XS, S, M, L, XL
|
|
@@ -1903,20 +2767,47 @@ ISSUES:
|
|
|
1903
2767
|
--title, -t <title> New title
|
|
1904
2768
|
--description, -d <desc> New description
|
|
1905
2769
|
--state, -s <state> New state
|
|
2770
|
+
--project, -p <name> Move to project
|
|
2771
|
+
--milestone <name> Move to milestone
|
|
1906
2772
|
--append, -a <text> Append to description
|
|
1907
2773
|
--blocks <id> Add blocking relation
|
|
1908
2774
|
--blocked-by <id> Add blocked-by relation
|
|
1909
2775
|
issue close <id> Mark issue as done
|
|
1910
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
|
|
1911
2780
|
|
|
1912
2781
|
PROJECTS:
|
|
1913
2782
|
projects [options] List projects
|
|
1914
2783
|
--all, -a Include completed projects
|
|
1915
|
-
|
|
2784
|
+
projects reorder <names..> Reorder projects by listing names in order
|
|
2785
|
+
|
|
2786
|
+
project show <name> Show project details with issues
|
|
1916
2787
|
project create [options] Create a new project
|
|
1917
2788
|
--name, -n <name> Project name (required)
|
|
1918
2789
|
--description, -d <desc> Project description
|
|
1919
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
|
|
1920
2811
|
|
|
1921
2812
|
LABELS:
|
|
1922
2813
|
labels List all labels
|
|
@@ -1948,15 +2839,12 @@ CONFIGURATION:
|
|
|
1948
2839
|
team=ISSUE
|
|
1949
2840
|
|
|
1950
2841
|
EXAMPLES:
|
|
1951
|
-
linear
|
|
2842
|
+
linear roadmap # See all projects and milestones
|
|
1952
2843
|
linear issues --unblocked # Find workable issues
|
|
1953
|
-
linear issues --
|
|
1954
|
-
linear issue
|
|
1955
|
-
linear
|
|
1956
|
-
linear
|
|
1957
|
-
linear issue create --title "Needs API key" --blocked-by ISSUE-5
|
|
1958
|
-
linear issue update ISSUE-1 --append "Found the root cause..."
|
|
1959
|
-
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"
|
|
1960
2848
|
`);
|
|
1961
2849
|
}
|
|
1962
2850
|
|
|
@@ -1986,10 +2874,16 @@ async function main() {
|
|
|
1986
2874
|
case 'whoami':
|
|
1987
2875
|
await cmdWhoami();
|
|
1988
2876
|
break;
|
|
1989
|
-
case 'issues':
|
|
2877
|
+
case 'issues': {
|
|
1990
2878
|
checkAuth();
|
|
1991
|
-
|
|
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
|
+
}
|
|
1992
2885
|
break;
|
|
2886
|
+
}
|
|
1993
2887
|
case 'issue': {
|
|
1994
2888
|
checkAuth();
|
|
1995
2889
|
const subcmd = args[1];
|
|
@@ -2001,16 +2895,23 @@ async function main() {
|
|
|
2001
2895
|
case 'start': await cmdIssueStart(subargs); break;
|
|
2002
2896
|
case 'close': await cmdIssueClose(subargs); break;
|
|
2003
2897
|
case 'comment': await cmdIssueComment(subargs); break;
|
|
2898
|
+
case 'move': await cmdIssueMove(subargs); break;
|
|
2004
2899
|
default:
|
|
2005
2900
|
console.error(`Unknown issue command: ${subcmd}`);
|
|
2006
2901
|
process.exit(1);
|
|
2007
2902
|
}
|
|
2008
2903
|
break;
|
|
2009
2904
|
}
|
|
2010
|
-
case 'projects':
|
|
2905
|
+
case 'projects': {
|
|
2011
2906
|
checkAuth();
|
|
2012
|
-
|
|
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
|
+
}
|
|
2013
2913
|
break;
|
|
2914
|
+
}
|
|
2014
2915
|
case 'project': {
|
|
2015
2916
|
checkAuth();
|
|
2016
2917
|
const subcmd = args[1];
|
|
@@ -2019,12 +2920,41 @@ async function main() {
|
|
|
2019
2920
|
case 'show': await cmdProjectShow(subargs); break;
|
|
2020
2921
|
case 'create': await cmdProjectCreate(subargs); break;
|
|
2021
2922
|
case 'complete': await cmdProjectComplete(subargs); break;
|
|
2923
|
+
case 'move': await cmdProjectMove(subargs); break;
|
|
2022
2924
|
default:
|
|
2023
2925
|
console.error(`Unknown project command: ${subcmd}`);
|
|
2024
2926
|
process.exit(1);
|
|
2025
2927
|
}
|
|
2026
2928
|
break;
|
|
2027
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;
|
|
2028
2958
|
case 'labels':
|
|
2029
2959
|
checkAuth();
|
|
2030
2960
|
await cmdLabels();
|