@enrichlayer/el-linear 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +32 -4
  2. package/dist/auth/oauth-app-config.d.ts +21 -0
  3. package/dist/auth/oauth-app-config.js +82 -0
  4. package/dist/commands/attachments.js +11 -12
  5. package/dist/commands/batch.js +15 -14
  6. package/dist/commands/comments.js +22 -10
  7. package/dist/commands/cycles.js +5 -4
  8. package/dist/commands/documents.js +18 -15
  9. package/dist/commands/embeds.js +4 -6
  10. package/dist/commands/graphql.js +5 -4
  11. package/dist/commands/init/aliases.js +0 -14
  12. package/dist/commands/init/defaults.d.ts +18 -1
  13. package/dist/commands/init/defaults.js +83 -8
  14. package/dist/commands/init/index.js +7 -13
  15. package/dist/commands/init/oauth.d.ts +4 -4
  16. package/dist/commands/init/oauth.js +24 -6
  17. package/dist/commands/init/shared.js +0 -1
  18. package/dist/commands/init/token.js +0 -7
  19. package/dist/commands/init/workspace.js +0 -2
  20. package/dist/commands/issue-id.js +3 -1
  21. package/dist/commands/issues.js +130 -54
  22. package/dist/commands/labels.js +23 -9
  23. package/dist/commands/profile/migrate-legacy.js +0 -1
  24. package/dist/commands/project-milestones.js +13 -12
  25. package/dist/commands/projects.js +28 -15
  26. package/dist/commands/read-shortcut.js +8 -7
  27. package/dist/commands/refs.js +4 -3
  28. package/dist/commands/releases.js +9 -8
  29. package/dist/commands/search.js +3 -2
  30. package/dist/commands/teams.js +14 -3
  31. package/dist/commands/templates.js +5 -4
  32. package/dist/commands/users.js +3 -2
  33. package/dist/config/config.d.ts +37 -0
  34. package/dist/config/issue-validation.js +1 -1
  35. package/dist/config/paths.d.ts +1 -0
  36. package/dist/config/paths.js +1 -0
  37. package/dist/config/resolver.js +1 -1
  38. package/dist/main.js +3 -2
  39. package/dist/utils/disk-cache.d.ts +51 -0
  40. package/dist/utils/disk-cache.js +178 -0
  41. package/dist/utils/download-uploads.d.ts +2 -1
  42. package/dist/utils/download-uploads.js +2 -4
  43. package/dist/utils/file-service.d.ts +26 -2
  44. package/dist/utils/file-service.js +27 -5
  45. package/dist/utils/footer.d.ts +19 -0
  46. package/dist/utils/footer.js +27 -0
  47. package/dist/utils/gdoc-parser.js +1 -1
  48. package/dist/utils/graphql-attachments-service.d.ts +1 -1
  49. package/dist/utils/graphql-attachments-service.js +2 -2
  50. package/dist/utils/graphql-documents-service.d.ts +1 -1
  51. package/dist/utils/graphql-documents-service.js +2 -2
  52. package/dist/utils/graphql-service.d.ts +2 -2
  53. package/dist/utils/graphql-service.js +7 -4
  54. package/dist/utils/linear-service.d.ts +18 -3
  55. package/dist/utils/linear-service.js +21 -6
  56. package/dist/utils/markdown-prosemirror.js +16 -9
  57. package/dist/utils/root-opts.d.ts +11 -0
  58. package/dist/utils/root-opts.js +13 -0
  59. package/dist/utils/validators.d.ts +7 -0
  60. package/dist/utils/validators.js +14 -14
  61. package/package.json +1 -1
@@ -7,10 +7,10 @@ import { resolveDefaultStatus } from "../config/status-defaults.js";
7
7
  import { enforceTerms } from "../config/term-enforcer.js";
8
8
  import { LIST_COMMENTS_QUERY } from "../queries/comments.js";
9
9
  import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, ISSUE_RELATION_CREATE_MUTATION, SCAN_ISSUES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
10
- import { getApiToken } from "../utils/auth.js";
11
10
  import { autoLinkReferences, } from "../utils/auto-link-references.js";
12
11
  import { downloadLinearUploads } from "../utils/download-uploads.js";
13
- import { FileService } from "../utils/file-service.js";
12
+ import { createFileService } from "../utils/file-service.js";
13
+ import { applyFooter } from "../utils/footer.js";
14
14
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
15
15
  import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
16
16
  import { createGraphQLService, } from "../utils/graphql-service.js";
@@ -19,6 +19,7 @@ import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js"
19
19
  import { createLinearService, } from "../utils/linear-service.js";
20
20
  import { logger } from "../utils/logger.js";
21
21
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
22
+ import { getRootOpts } from "../utils/root-opts.js";
22
23
  import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
23
24
  import { validateReferences } from "../utils/validate-references.js";
24
25
  import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
@@ -78,14 +79,43 @@ function readDescriptionFile(filePath) {
78
79
  return fs.readFileSync(filePath, "utf8").trim();
79
80
  }
80
81
  /**
81
- * Resolve the description from --description, --description-file, or both.
82
- * --description-file takes precedence when both are provided.
82
+ * Resolve the description from --description, --description-file, or
83
+ * --template (looked up in `config.descriptionTemplates`).
84
+ *
85
+ * Precedence:
86
+ * --description-file > --description > --template
87
+ *
88
+ * Passing --template alongside --description or --description-file is a
89
+ * usage error — the explicit body and a template both producing content
90
+ * would be silently dropping one. We throw so the user picks one.
83
91
  */
84
92
  function resolveDescription(options) {
85
- if (options.descriptionFile) {
93
+ const hasInline = typeof options.description === "string" && options.description.length > 0;
94
+ const hasFile = Boolean(options.descriptionFile);
95
+ const hasTemplate = typeof options.template === "string" && options.template;
96
+ if (hasTemplate && (hasInline || hasFile)) {
97
+ throw new Error("--template is mutually exclusive with --description / --description-file. " +
98
+ "Pick one.");
99
+ }
100
+ if (hasFile) {
86
101
  return readDescriptionFile(options.descriptionFile);
87
102
  }
88
- return options.description;
103
+ if (hasInline) {
104
+ return options.description;
105
+ }
106
+ if (hasTemplate) {
107
+ const templates = loadConfig().descriptionTemplates ?? {};
108
+ const body = templates[options.template];
109
+ if (!body) {
110
+ const available = Object.keys(templates).sort();
111
+ const hint = available.length
112
+ ? `Available templates: ${available.join(", ")}`
113
+ : "No templates configured. Add one under `descriptionTemplates` in your config.";
114
+ throw new Error(`Template "${options.template}" not found. ${hint}`);
115
+ }
116
+ return body;
117
+ }
118
+ return undefined;
89
119
  }
90
120
  function isImageFile(filename) {
91
121
  const ext = filename.lastIndexOf(".");
@@ -233,12 +263,20 @@ function buildUpdateArgs(issueId, options, assigneeId) {
233
263
  else if (options.labels) {
234
264
  labelIds = splitList(options.labels);
235
265
  }
266
+ // On update, fall back to config.defaultPriority only when --priority
267
+ // wasn't passed. This keeps every update setting a priority for users who
268
+ // want a workspace-wide baseline (e.g. all unassigned triage tickets bump
269
+ // to "medium"). To leave priority untouched, omit defaultPriority from
270
+ // config — the field is opt-in.
271
+ const priorityInput = typeof options.priority === "string"
272
+ ? options.priority
273
+ : loadConfig().defaultPriority;
236
274
  return {
237
275
  id: issueId,
238
276
  title: options.title,
239
277
  description: options.description,
240
278
  statusId: options.status,
241
- priority: options.priority ? validatePriority(options.priority) : undefined,
279
+ priority: priorityInput ? validatePriority(priorityInput) : undefined,
242
280
  assigneeId,
243
281
  projectId: options.project,
244
282
  labelIds,
@@ -296,9 +334,9 @@ async function handleListIssues(options, command) {
296
334
  if (options.label && !options.labels) {
297
335
  options.labels = options.label;
298
336
  }
299
- const rootOpts = command.parent.parent.opts();
300
- const graphQLService = createGraphQLService(rootOpts);
301
- const linearService = createLinearService(rootOpts);
337
+ const rootOpts = getRootOpts(command);
338
+ const graphQLService = await createGraphQLService(rootOpts);
339
+ const linearService = await createLinearService(rootOpts);
302
340
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
303
341
  const hasFilters = options.team ||
304
342
  options.labels ||
@@ -337,9 +375,9 @@ async function handleSearchIssues(query, options, command) {
337
375
  if (options.label && !options.labels) {
338
376
  options.labels = options.label;
339
377
  }
340
- const rootOpts = command.parent.parent.opts();
341
- const graphQLService = createGraphQLService(rootOpts);
342
- const linearService = createLinearService(rootOpts);
378
+ const rootOpts = getRootOpts(command);
379
+ const graphQLService = await createGraphQLService(rootOpts);
380
+ const linearService = await createLinearService(rootOpts);
343
381
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
344
382
  const searchArgs = {
345
383
  query,
@@ -362,6 +400,21 @@ async function handleSearchIssues(query, options, command) {
362
400
  async function resolveCreateInputs(title, options, rootOpts) {
363
401
  const config = loadConfig();
364
402
  enforceTerms([title, options.description], { strict: options.strict });
403
+ // Effective assignee: explicit --assignee wins; --no-assignee (commander
404
+ // parses as `assignee === false`) skips both flag and config; otherwise
405
+ // fall back to config.defaultAssignee. Computed BEFORE validation so the
406
+ // "assignee required" rule sees the resolved value, not undefined.
407
+ const noAssignee = options.assignee === false;
408
+ const explicitAssignee = typeof options.assignee === "string" ? options.assignee : undefined;
409
+ const effectiveAssignee = noAssignee
410
+ ? undefined
411
+ : (explicitAssignee ?? config.defaultAssignee);
412
+ // Effective priority: explicit --priority wins; otherwise fall back to
413
+ // config.defaultPriority. Both go through validatePriority so a bad config
414
+ // value fails fast with a useful error.
415
+ const effectivePriorityInput = typeof options.priority === "string"
416
+ ? options.priority
417
+ : config.defaultPriority;
365
418
  // --- Validation (labels, description, assignee, project, title) ---
366
419
  // Controlled by config.validation.enabled (default: true).
367
420
  // Bypassed by --skip-validation flag.
@@ -372,7 +425,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
372
425
  labels: rawLabels,
373
426
  description: description || undefined,
374
427
  title,
375
- assignee: options.assignee,
428
+ assignee: effectiveAssignee,
376
429
  project: options.project,
377
430
  });
378
431
  // Apply normalized labels back so resolution uses the canonical names
@@ -381,13 +434,13 @@ async function resolveCreateInputs(title, options, rootOpts) {
381
434
  }
382
435
  enforceValidation(validationResult);
383
436
  }
384
- if (!options.priority) {
437
+ if (!effectivePriorityInput) {
385
438
  outputWarning("Creating issue without --priority. Consider specifying it for better triage.", "missing_fields");
386
439
  }
387
440
  const teamInput = options.team || config.defaultTeam;
388
441
  const teamId = resolveTeam(teamInput);
389
- const assigneeId = options.assignee
390
- ? await resolveAssignee(options.assignee, rootOpts)
442
+ const assigneeId = effectiveAssignee
443
+ ? await resolveAssignee(effectiveAssignee, rootOpts)
391
444
  : undefined;
392
445
  let labelIds = [];
393
446
  if (options.labels) {
@@ -408,7 +461,18 @@ async function resolveCreateInputs(title, options, rootOpts) {
408
461
  if (options.subscriber) {
409
462
  subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
410
463
  }
411
- return { teamInput, teamId, assigneeId, labelIds, status, subscriberIds };
464
+ const priority = effectivePriorityInput
465
+ ? validatePriority(effectivePriorityInput)
466
+ : undefined;
467
+ return {
468
+ teamInput,
469
+ teamId,
470
+ assigneeId,
471
+ labelIds,
472
+ status,
473
+ subscriberIds,
474
+ priority,
475
+ };
412
476
  }
413
477
  async function uploadAttachmentsIfNeeded(options, rootOpts) {
414
478
  if (!options.attachment) {
@@ -417,8 +481,7 @@ async function uploadAttachmentsIfNeeded(options, rootOpts) {
417
481
  const paths = Array.isArray(options.attachment)
418
482
  ? options.attachment
419
483
  : [options.attachment];
420
- const apiToken = getApiToken(rootOpts);
421
- const fileService = new FileService(apiToken);
484
+ const fileService = await createFileService(rootOpts);
422
485
  const results = [];
423
486
  for (const filePath of paths) {
424
487
  const result = await fileService.uploadFile(filePath);
@@ -442,12 +505,21 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
442
505
  return description;
443
506
  }
444
507
  async function handleCreateIssue(title, options, command) {
445
- const rootOpts = command.parent.parent.opts();
446
- const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds } = await resolveCreateInputs(title, options, rootOpts);
508
+ const rootOpts = getRootOpts(command);
509
+ const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title, options, rootOpts);
447
510
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
448
- const description = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
449
- const graphQLService = createGraphQLService(rootOpts);
450
- const linearService = createLinearService(rootOpts);
511
+ const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
512
+ // Append messageFooter (config or --footer flag) so auto-link picks up any
513
+ // issue refs in the footer too. --no-footer skips both flag and config.
514
+ // Commander parses --no-footer as `options.footer === false`.
515
+ const noFooter = options.footer === false;
516
+ const explicitFooter = typeof options.footer === "string" ? options.footer : undefined;
517
+ const description = applyFooter(descriptionWithAttachments, {
518
+ footer: explicitFooter,
519
+ noFooter,
520
+ }) ?? "";
521
+ const graphQLService = await createGraphQLService(rootOpts);
522
+ const linearService = await createLinearService(rootOpts);
451
523
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
452
524
  // Wrap valid issue identifiers as markdown links before creating, so the description
453
525
  // saved on Linear has clickable refs from the start. Self-reference can't apply here
@@ -459,7 +531,7 @@ async function handleCreateIssue(title, options, command) {
459
531
  teamInput,
460
532
  description: prepared.description,
461
533
  assigneeId,
462
- priority: options.priority ? validatePriority(options.priority) : undefined,
534
+ priority,
463
535
  projectId: options.project,
464
536
  statusId: status,
465
537
  labelIds: labelIds.length > 0 ? labelIds : undefined,
@@ -486,7 +558,7 @@ async function handleCreateIssue(title, options, command) {
486
558
  // so only create separate attachment records for non-image files.
487
559
  const attachments = [];
488
560
  if (uploadResults.length > 0) {
489
- const attachmentsService = createGraphQLAttachmentsService(rootOpts);
561
+ const attachmentsService = await createGraphQLAttachmentsService(rootOpts);
490
562
  for (const uploadResult of uploadResults) {
491
563
  if (!isImageFile(uploadResult.filename)) {
492
564
  const attachment = await attachmentsService.createAttachment({
@@ -517,9 +589,9 @@ async function handleCreateIssue(title, options, command) {
517
589
  outputSuccess(output);
518
590
  }
519
591
  async function handleHistoryIssue(issueId, _options, command) {
520
- const rootOpts = command.parent.parent.opts();
521
- const graphQLService = createGraphQLService(rootOpts);
522
- const linearService = createLinearService(rootOpts);
592
+ const rootOpts = getRootOpts(command);
593
+ const graphQLService = await createGraphQLService(rootOpts);
594
+ const linearService = await createLinearService(rootOpts);
523
595
  const resolvedId = await linearService.resolveIssueId(issueId);
524
596
  const result = await graphQLService.rawRequest(GET_ISSUE_STATE_HISTORY_QUERY, {
525
597
  id: resolvedId,
@@ -547,9 +619,9 @@ async function handleHistoryIssue(issueId, _options, command) {
547
619
  });
548
620
  }
549
621
  async function handleRelateIssue(issueId, options, command) {
550
- const rootOpts = command.parent.parent.opts();
551
- const graphQLService = createGraphQLService(rootOpts);
552
- const linearService = createLinearService(rootOpts);
622
+ const rootOpts = getRootOpts(command);
623
+ const graphQLService = await createGraphQLService(rootOpts);
624
+ const linearService = await createLinearService(rootOpts);
553
625
  const sourceId = await linearService.resolveIssueId(issueId);
554
626
  const relations = await createRelations(sourceId, options, graphQLService, linearService);
555
627
  if (relations.length === 0) {
@@ -620,9 +692,9 @@ function buildRelationEntries(nodes, peerKey, direction, normalizeType) {
620
692
  return entries;
621
693
  }
622
694
  async function handleRelatedIssues(issueId, _options, command) {
623
- const rootOpts = command.parent.parent.opts();
624
- const graphQLService = createGraphQLService(rootOpts);
625
- const linearService = createLinearService(rootOpts);
695
+ const rootOpts = getRootOpts(command);
696
+ const graphQLService = await createGraphQLService(rootOpts);
697
+ const linearService = await createLinearService(rootOpts);
626
698
  const resolvedId = await linearService.resolveIssueId(issueId);
627
699
  const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
628
700
  id: resolvedId,
@@ -813,10 +885,10 @@ async function handleLinkReferencesBatch(teamInput, options, graphQLService, lin
813
885
  },
814
886
  });
815
887
  }
816
- function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
817
- const rootOpts = command.parent.parent.opts();
818
- const graphQLService = createGraphQLService(rootOpts);
819
- const linearService = createLinearService(rootOpts);
888
+ async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
889
+ const rootOpts = getRootOpts(command);
890
+ const graphQLService = await createGraphQLService(rootOpts);
891
+ const linearService = await createLinearService(rootOpts);
820
892
  if (options.team) {
821
893
  if (issueIdOrTeamFlag) {
822
894
  throw new Error("Provide either an issueId positional argument OR --team, not both. With --team, omit the issueId.");
@@ -832,20 +904,20 @@ function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
832
904
  return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
833
905
  }
834
906
  async function handleReadIssue(issueIds, _options, command) {
835
- const rootOpts = command.parent.parent.opts();
836
- const graphQLService = createGraphQLService(rootOpts);
837
- const linearService = createLinearService(rootOpts);
907
+ const rootOpts = getRootOpts(command);
908
+ const graphQLService = await createGraphQLService(rootOpts);
909
+ const linearService = await createLinearService(rootOpts);
838
910
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
839
- const apiToken = getApiToken(rootOpts);
911
+ const fileService = await createFileService(rootOpts);
840
912
  if (issueIds.length === 1) {
841
913
  const issue = await issuesService.getIssueById(issueIds[0]);
842
- const resolved = await downloadLinearUploads(issue, apiToken);
914
+ const resolved = await downloadLinearUploads(issue, fileService);
843
915
  outputSuccess(resolved);
844
916
  }
845
917
  else {
846
918
  const results = await Promise.all(issueIds.map(async (id) => {
847
919
  const issue = await issuesService.getIssueById(id);
848
- return downloadLinearUploads(issue, apiToken);
920
+ return downloadLinearUploads(issue, fileService);
849
921
  }));
850
922
  outputSuccess(results);
851
923
  }
@@ -863,9 +935,9 @@ async function handleUpdateIssue(issueId, options, command) {
863
935
  options.description = readDescriptionFile(options.descriptionFile);
864
936
  }
865
937
  validateUpdateOptions(options);
866
- const rootOpts = command.parent.parent.opts();
867
- const graphQLService = createGraphQLService(rootOpts);
868
- const linearService = createLinearService(rootOpts);
938
+ const rootOpts = getRootOpts(command);
939
+ const graphQLService = await createGraphQLService(rootOpts);
940
+ const linearService = await createLinearService(rootOpts);
869
941
  if (options.appendDescription) {
870
942
  const resolved = await linearService.resolveIssueId(issueId);
871
943
  const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { description } }", { id: resolved });
@@ -952,9 +1024,9 @@ async function handleRetrolink(options, command) {
952
1024
  const description = commits.length > 0
953
1025
  ? commits.map((msg) => `- ${msg}`).join("\n")
954
1026
  : `Retrolinked from branch: ${currentBranch}`;
955
- const rootOpts = command.parent.parent.opts();
956
- const graphQLService = createGraphQLService(rootOpts);
957
- const linearService = createLinearService(rootOpts);
1027
+ const rootOpts = getRootOpts(command);
1028
+ const graphQLService = await createGraphQLService(rootOpts);
1029
+ const linearService = await createLinearService(rootOpts);
958
1030
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
959
1031
  let result;
960
1032
  if (options.issue) {
@@ -1035,8 +1107,10 @@ export function setupIssuesCommands(program) {
1035
1107
  .option("-t, --title <title>", "issue title (alternative to positional argument)")
1036
1108
  .option("-d, --description <desc>", "issue description")
1037
1109
  .option("--description-file <path>", "read description from file (use - for stdin)")
1110
+ .option("--template <name>", "use a named description template from config.descriptionTemplates")
1038
1111
  .option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
1039
- .option("-p, --priority <priority>", "priority: name (urgent/high/medium/low) or number (1-4)")
1112
+ .option("--no-assignee", "create unassigned even when config.defaultAssignee is set")
1113
+ .option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
1040
1114
  .option("--project <project>", "add to project (name or ID)")
1041
1115
  .option("--team <team>", "team key or name (default: from config)")
1042
1116
  .option("--labels <labels>", "labels (comma-separated names, auto-resolved per team)")
@@ -1056,6 +1130,8 @@ export function setupIssuesCommands(program) {
1056
1130
  .option("--checkout", "create and checkout a git branch named after the issue")
1057
1131
  .option("--skip-validation", "skip all validation (labels, description, assignee, project)")
1058
1132
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1133
+ .option("--footer <text>", "text appended to the description (overrides config.messageFooter)")
1134
+ .option("--no-footer", "skip the configured messageFooter for this issue")
1059
1135
  .action(handleAsyncCommand((titleArg, options, command) => {
1060
1136
  // Normalize --label alias to --labels
1061
1137
  if (options.label && !options.labels) {
@@ -1087,7 +1163,7 @@ export function setupIssuesCommands(program) {
1087
1163
  .option("--append-description <text>", "append text to the existing description")
1088
1164
  .option("-s, --status <status>", "new status name or ID")
1089
1165
  .option("--state <status>", "alias for --status (new status name or ID)")
1090
- .option("-p, --priority <priority>", "new priority: name (urgent/high/medium/low) or number (1-4)")
1166
+ .option("-p, --priority <priority>", "new priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
1091
1167
  .option("--assignee <assignee>", "new assignee (name, alias, or UUID)")
1092
1168
  .option("--project <project>", "new project (name or ID)")
1093
1169
  .option("--labels <labels>", "labels (comma-separated names or IDs)")
@@ -1,13 +1,16 @@
1
+ import { loadConfig } from "../config/config.js";
1
2
  import { resolveTeam } from "../config/resolver.js";
2
3
  import { CREATE_LABEL_MUTATION, FIND_PARENT_LABEL_QUERY, RESTORE_LABEL_MUTATION, RETIRE_LABEL_MUTATION, } from "../queries/labels.js";
4
+ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
3
5
  import { createGraphQLService } from "../utils/graphql-service.js";
4
6
  import { createLinearService } from "../utils/linear-service.js";
5
7
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
8
+ import { getRootOpts } from "../utils/root-opts.js";
6
9
  import { validateHexColor } from "../utils/validators.js";
7
10
  async function handleCreateLabel(name, options, command) {
8
- const rootOpts = command.parent.parent.opts();
11
+ const rootOpts = getRootOpts(command);
9
12
  const teamId = resolveTeam(options.team);
10
- const graphQLService = createGraphQLService(rootOpts);
13
+ const graphQLService = await createGraphQLService(rootOpts);
11
14
  const input = { name, teamId };
12
15
  if (options.color) {
13
16
  input.color = validateHexColor(options.color);
@@ -37,8 +40,8 @@ async function handleCreateLabel(name, options, command) {
37
40
  outputSuccess(createResult.issueLabel);
38
41
  }
39
42
  async function handleRetireLabel(labelId, _options, command) {
40
- const rootOpts = command.parent.parent.opts();
41
- const graphQLService = createGraphQLService(rootOpts);
43
+ const rootOpts = getRootOpts(command);
44
+ const graphQLService = await createGraphQLService(rootOpts);
42
45
  const result = await graphQLService.rawRequest(RETIRE_LABEL_MUTATION, {
43
46
  id: labelId,
44
47
  });
@@ -49,8 +52,8 @@ async function handleRetireLabel(labelId, _options, command) {
49
52
  outputSuccess(retireResult.issueLabel);
50
53
  }
51
54
  async function handleRestoreLabel(labelId, _options, command) {
52
- const rootOpts = command.parent.parent.opts();
53
- const graphQLService = createGraphQLService(rootOpts);
55
+ const rootOpts = getRootOpts(command);
56
+ const graphQLService = await createGraphQLService(rootOpts);
54
57
  const result = await graphQLService.rawRequest(RESTORE_LABEL_MUTATION, {
55
58
  id: labelId,
56
59
  });
@@ -72,10 +75,21 @@ export function setupLabelsCommands(program) {
72
75
  .option("--team <team>", "filter by team key, name, or ID")
73
76
  .option("-l, --limit <number>", "limit results", "100")
74
77
  .action(handleAsyncCommand(async (options, command) => {
75
- const rootOpts = command.parent.parent.opts();
78
+ const rootOpts = getRootOpts(command);
76
79
  const teamFilter = options.team ? resolveTeam(options.team) : undefined;
77
- const service = createLinearService(rootOpts);
78
- const result = await service.getLabels(teamFilter, Number.parseInt(options.limit, 10));
80
+ const limit = Number.parseInt(options.limit, 10);
81
+ const ttl = resolveCacheTTL({
82
+ configTTL: loadConfig().cacheTTLSeconds,
83
+ noCacheFlag: rootOpts.cache === false,
84
+ });
85
+ // Cache key includes the team filter so list-with-team and list-
86
+ // without-team don't collide. Same `limit` participates because
87
+ // a smaller list isn't a valid cached answer for a larger ask.
88
+ const cacheKey = `labels-list-team:${teamFilter ?? "_all"}-limit:${limit}`;
89
+ const result = await cached(cacheKey, ttl, async () => {
90
+ const service = await createLinearService(rootOpts);
91
+ return service.getLabels(teamFilter, limit);
92
+ });
79
93
  outputSuccess({
80
94
  data: result.labels,
81
95
  meta: { count: result.labels.length },
@@ -157,7 +157,6 @@ async function resolveAndValidateToken(options, prompts, validate) {
157
157
  }
158
158
  catch (err) {
159
159
  const raw = err instanceof Error ? err.message : String(err);
160
- // biome-ignore lint/suspicious/noConsole: interactive prompt feedback
161
160
  console.log(` ✗ ${sanitizeForLog(raw)}`);
162
161
  }
163
162
  }
@@ -3,6 +3,7 @@ import { multipleMatchesError, notFoundError, } from "../utils/error-messages.js
3
3
  import { createGraphQLService, } from "../utils/graphql-service.js";
4
4
  import { createLinearService, } from "../utils/linear-service.js";
5
5
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
6
+ import { getRootOpts } from "../utils/root-opts.js";
6
7
  import { isUuid } from "../utils/uuid.js";
7
8
  import { validateIsoDate } from "../utils/validators.js";
8
9
  async function resolveMilestoneId(milestoneNameOrId, graphQLService, linearService, projectNameOrId) {
@@ -37,9 +38,9 @@ async function resolveMilestoneId(milestoneNameOrId, graphQLService, linearServi
37
38
  return nodes[0].id;
38
39
  }
39
40
  async function handleListMilestones(options, command) {
40
- const rootOpts = command.parent.parent.opts();
41
- const graphQLService = createGraphQLService(rootOpts);
42
- const linearService = createLinearService(rootOpts);
41
+ const rootOpts = getRootOpts(command);
42
+ const graphQLService = await createGraphQLService(rootOpts);
43
+ const linearService = await createLinearService(rootOpts);
43
44
  const projectId = await linearService.resolveProjectId(options.project);
44
45
  const result = await graphQLService.rawRequest(LIST_PROJECT_MILESTONES_QUERY, {
45
46
  projectId,
@@ -51,9 +52,9 @@ async function handleListMilestones(options, command) {
51
52
  outputSuccess({ data: nodes, meta: { count: nodes.length } });
52
53
  }
53
54
  async function handleReadMilestone(milestoneIdOrName, options, command) {
54
- const rootOpts = command.parent.parent.opts();
55
- const graphQLService = createGraphQLService(rootOpts);
56
- const linearService = createLinearService(rootOpts);
55
+ const rootOpts = getRootOpts(command);
56
+ const graphQLService = await createGraphQLService(rootOpts);
57
+ const linearService = await createLinearService(rootOpts);
57
58
  const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
58
59
  const result = await graphQLService.rawRequest(GET_PROJECT_MILESTONE_BY_ID_QUERY, {
59
60
  id: milestoneId,
@@ -62,9 +63,9 @@ async function handleReadMilestone(milestoneIdOrName, options, command) {
62
63
  outputSuccess(result.projectMilestone);
63
64
  }
64
65
  async function handleCreateMilestone(name, options, command) {
65
- const rootOpts = command.parent.parent.opts();
66
- const graphQLService = createGraphQLService(rootOpts);
67
- const linearService = createLinearService(rootOpts);
66
+ const rootOpts = getRootOpts(command);
67
+ const graphQLService = await createGraphQLService(rootOpts);
68
+ const linearService = await createLinearService(rootOpts);
68
69
  const projectId = await linearService.resolveProjectId(options.project);
69
70
  if (options.targetDate) {
70
71
  validateIsoDate(options.targetDate);
@@ -82,9 +83,9 @@ async function handleCreateMilestone(name, options, command) {
82
83
  outputSuccess(createData.projectMilestone);
83
84
  }
84
85
  async function handleUpdateMilestone(milestoneIdOrName, options, command) {
85
- const rootOpts = command.parent.parent.opts();
86
- const graphQLService = createGraphQLService(rootOpts);
87
- const linearService = createLinearService(rootOpts);
86
+ const rootOpts = getRootOpts(command);
87
+ const graphQLService = await createGraphQLService(rootOpts);
88
+ const linearService = await createLinearService(rootOpts);
88
89
  const milestoneId = await resolveMilestoneId(milestoneIdOrName, graphQLService, linearService, options.project);
89
90
  const updateVars = { id: milestoneId };
90
91
  if (options.name !== undefined) {
@@ -1,9 +1,12 @@
1
+ import { loadConfig } from "../config/config.js";
1
2
  import { resolveTeam } from "../config/resolver.js";
2
3
  import { CREATE_PROJECT_MUTATION, GET_PROJECT_QUERY, GET_PROJECT_TEAM_ISSUES_QUERY, PROJECT_BY_ID_QUERY, SEARCH_PROJECTS_BY_NAME_QUERY, UPDATE_PROJECT_MUTATION, } from "../queries/projects.js";
4
+ import { cached, resolveCacheTTL } from "../utils/disk-cache.js";
3
5
  import { createGraphQLService } from "../utils/graphql-service.js";
4
6
  import { createLinearService } from "../utils/linear-service.js";
5
7
  import { logger } from "../utils/logger.js";
6
8
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
9
+ import { getRootOpts } from "../utils/root-opts.js";
7
10
  import { isUuid } from "../utils/uuid.js";
8
11
  import { splitList } from "../utils/validators.js";
9
12
  function formatProjectsOutput(projects, format, fieldNames) {
@@ -126,9 +129,10 @@ function formatTeamsOutput(projectUpdate) {
126
129
  };
127
130
  }
128
131
  async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
129
- const rootOpts = command.parent.parent.opts();
130
- const graphQLService = createGraphQLService(rootOpts);
131
- const finalTeamId = await createLinearService(rootOpts).resolveTeamId(resolveTeam(teamInput));
132
+ const rootOpts = getRootOpts(command);
133
+ const graphQLService = await createGraphQLService(rootOpts);
134
+ const linearService = await createLinearService(rootOpts);
135
+ const finalTeamId = await linearService.resolveTeamId(resolveTeam(teamInput));
132
136
  const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
133
137
  const currentTeamIds = currentTeams.map((t) => t.id);
134
138
  if (currentTeamIds.includes(finalTeamId)) {
@@ -149,9 +153,10 @@ async function handleAddTeam(projectNameOrId, teamInput, _options, command) {
149
153
  outputSuccess(formatTeamsOutput(projectUpdate));
150
154
  }
151
155
  async function handleAddTeams(projectNameOrId, teamInputs, _options, command) {
152
- const rootOpts = command.parent.parent.opts();
153
- const graphQLService = createGraphQLService(rootOpts);
154
- const resolvedTeamIds = await Promise.all(teamInputs.map((t) => createLinearService(rootOpts).resolveTeamId(resolveTeam(t))));
156
+ const rootOpts = getRootOpts(command);
157
+ const graphQLService = await createGraphQLService(rootOpts);
158
+ const linearService = await createLinearService(rootOpts);
159
+ const resolvedTeamIds = await Promise.all(teamInputs.map((t) => linearService.resolveTeamId(resolveTeam(t))));
155
160
  const { projectId, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
156
161
  const currentTeamIds = currentTeams.map((t) => t.id);
157
162
  const newTeamIds = resolvedTeamIds.filter((id) => !currentTeamIds.includes(id));
@@ -181,9 +186,10 @@ async function handleAddTeams(projectNameOrId, teamInputs, _options, command) {
181
186
  });
182
187
  }
183
188
  async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
184
- const rootOpts = command.parent.parent.opts();
185
- const graphQLService = createGraphQLService(rootOpts);
186
- const finalTeamId = await createLinearService(rootOpts).resolveTeamId(resolveTeam(teamInput));
189
+ const rootOpts = getRootOpts(command);
190
+ const graphQLService = await createGraphQLService(rootOpts);
191
+ const linearService = await createLinearService(rootOpts);
192
+ const finalTeamId = await linearService.resolveTeamId(resolveTeam(teamInput));
187
193
  const { projectId, projectName, currentTeams } = await resolveProjectWithTeams(graphQLService, projectNameOrId);
188
194
  const currentTeamIds = currentTeams.map((t) => t.id);
189
195
  if (!currentTeamIds.includes(finalTeamId)) {
@@ -220,8 +226,8 @@ async function handleRemoveTeam(projectNameOrId, teamInput, options, command) {
220
226
  });
221
227
  }
222
228
  async function handleCreateProject(name, options, command) {
223
- const rootOpts = command.parent.parent.opts();
224
- const graphQLService = createGraphQLService(rootOpts);
229
+ const rootOpts = getRootOpts(command);
230
+ const graphQLService = await createGraphQLService(rootOpts);
225
231
  // Step 1: Check for duplicate projects (case-insensitive)
226
232
  const searchResult = await graphQLService.rawRequest(SEARCH_PROJECTS_BY_NAME_QUERY, { name });
227
233
  const existing = (searchResult.projects
@@ -236,7 +242,7 @@ async function handleCreateProject(name, options, command) {
236
242
  `Use --force to create anyway, or use the existing project.`);
237
243
  }
238
244
  // Step 2: Resolve team IDs
239
- const service = createLinearService(rootOpts);
245
+ const service = await createLinearService(rootOpts);
240
246
  const teamKeys = options.team ? splitList(options.team) : [];
241
247
  const teamIds = [];
242
248
  for (const key of teamKeys) {
@@ -300,9 +306,16 @@ export function setupProjectsCommands(program) {
300
306
  .option("--format <format>", "output format (json, table, md, csv)", "json")
301
307
  .option("--fields <fields>", "columns for table/csv (comma-separated: name,state,progress,teams,lead,targetDate)")
302
308
  .action(handleAsyncCommand(async (options, command) => {
303
- const rootOpts = command.parent.parent.opts();
304
- const service = createLinearService(rootOpts);
305
- const result = await service.getProjects(Number.parseInt(options.limit, 10));
309
+ const rootOpts = getRootOpts(command);
310
+ const limit = Number.parseInt(options.limit, 10);
311
+ const ttl = resolveCacheTTL({
312
+ configTTL: loadConfig().cacheTTLSeconds,
313
+ noCacheFlag: rootOpts.cache === false,
314
+ });
315
+ const result = await cached(`projects-list-limit:${limit}`, ttl, async () => {
316
+ const service = await createLinearService(rootOpts);
317
+ return service.getProjects(limit);
318
+ });
306
319
  const format = options.format;
307
320
  if (format === "table" ||
308
321
  format === "md" ||
@@ -1,9 +1,10 @@
1
- import { getApiToken } from "../utils/auth.js";
2
1
  import { downloadLinearUploads } from "../utils/download-uploads.js";
2
+ import { createFileService } from "../utils/file-service.js";
3
3
  import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
4
4
  import { createGraphQLService } from "../utils/graphql-service.js";
5
5
  import { createLinearService } from "../utils/linear-service.js";
6
6
  import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
7
+ import { getRootOpts } from "../utils/root-opts.js";
7
8
  /**
8
9
  * Issue ID pattern: 1-5 uppercase letters, dash, 1+ digits (e.g. ADM-652, DEV-12).
9
10
  */
@@ -49,20 +50,20 @@ export function setupReadShortcut(program) {
49
50
  };
50
51
  }
51
52
  async function readIssues(issueIds, _options, command) {
52
- const rootOpts = command.parent.opts();
53
- const graphQLService = createGraphQLService(rootOpts);
54
- const linearService = createLinearService(rootOpts);
53
+ const rootOpts = getRootOpts(command);
54
+ const graphQLService = await createGraphQLService(rootOpts);
55
+ const linearService = await createLinearService(rootOpts);
55
56
  const issuesService = new GraphQLIssuesService(graphQLService, linearService);
56
- const apiToken = getApiToken(rootOpts);
57
+ const fileService = await createFileService(rootOpts);
57
58
  if (issueIds.length === 1) {
58
59
  const issue = await issuesService.getIssueById(issueIds[0]);
59
- const resolved = await downloadLinearUploads(issue, apiToken);
60
+ const resolved = await downloadLinearUploads(issue, fileService);
60
61
  outputSuccess(resolved);
61
62
  }
62
63
  else {
63
64
  const results = await Promise.all(issueIds.map(async (id) => {
64
65
  const issue = await issuesService.getIssueById(id);
65
- return downloadLinearUploads(issue, apiToken);
66
+ return downloadLinearUploads(issue, fileService);
66
67
  }));
67
68
  outputSuccess(results);
68
69
  }