@enrichlayer/el-linear 1.7.0 → 1.10.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 (108) hide show
  1. package/README.md +82 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/attachments.js +2 -1
  13. package/dist/commands/comments.js +17 -28
  14. package/dist/commands/cycles.js +2 -1
  15. package/dist/commands/documents.js +2 -1
  16. package/dist/commands/graphql.js +4 -6
  17. package/dist/commands/init/index.js +2 -0
  18. package/dist/commands/init/oauth.d.ts +6 -0
  19. package/dist/commands/init/oauth.js +8 -2
  20. package/dist/commands/init/token.d.ts +0 -8
  21. package/dist/commands/init/token.js +13 -1
  22. package/dist/commands/issue-id.js +1 -3
  23. package/dist/commands/issues/branch.d.ts +17 -0
  24. package/dist/commands/issues/branch.js +40 -0
  25. package/dist/commands/issues/description.d.ts +89 -0
  26. package/dist/commands/issues/description.js +183 -0
  27. package/dist/commands/issues/link-references.d.ts +21 -0
  28. package/dist/commands/issues/link-references.js +171 -0
  29. package/dist/commands/issues/relations.d.ts +55 -0
  30. package/dist/commands/issues/relations.js +132 -0
  31. package/dist/commands/issues.js +97 -497
  32. package/dist/commands/labels.js +20 -28
  33. package/dist/commands/profile/migrate-legacy.js +10 -33
  34. package/dist/commands/profile.js +1 -10
  35. package/dist/commands/project-milestones.js +13 -20
  36. package/dist/commands/projects.d.ts +5 -1
  37. package/dist/commands/projects.js +202 -102
  38. package/dist/commands/read-shortcut.d.ts +6 -0
  39. package/dist/commands/read-shortcut.js +6 -1
  40. package/dist/commands/refs.js +10 -1
  41. package/dist/commands/releases.js +26 -30
  42. package/dist/commands/search.js +21 -30
  43. package/dist/commands/teams.js +2 -1
  44. package/dist/commands/templates.js +128 -7
  45. package/dist/commands/users.js +3 -1
  46. package/dist/config/config.js +31 -9
  47. package/dist/config/issue-validation.js +1 -1
  48. package/dist/config/paths.d.ts +11 -0
  49. package/dist/config/paths.js +44 -6
  50. package/dist/config/resolver.js +2 -3
  51. package/dist/config/term-enforcer.js +1 -1
  52. package/dist/main.js +37 -2
  53. package/dist/queries/attachments-types.d.ts +30 -0
  54. package/dist/queries/attachments-types.js +5 -0
  55. package/dist/queries/comments-types.d.ts +49 -0
  56. package/dist/queries/comments-types.js +5 -0
  57. package/dist/queries/documents-types.d.ts +61 -0
  58. package/dist/queries/documents-types.js +9 -0
  59. package/dist/queries/introspect-types.d.ts +57 -0
  60. package/dist/queries/introspect-types.js +10 -0
  61. package/dist/queries/issues-types.d.ts +416 -0
  62. package/dist/queries/issues-types.js +23 -0
  63. package/dist/queries/issues.d.ts +2 -0
  64. package/dist/queries/issues.js +22 -0
  65. package/dist/queries/labels-types.d.ts +64 -0
  66. package/dist/queries/labels-types.js +5 -0
  67. package/dist/queries/project-milestones-types.d.ts +91 -0
  68. package/dist/queries/project-milestones-types.js +10 -0
  69. package/dist/queries/projects-types.d.ts +75 -0
  70. package/dist/queries/projects-types.js +5 -0
  71. package/dist/queries/projects.d.ts +2 -0
  72. package/dist/queries/projects.js +22 -0
  73. package/dist/queries/releases-types.d.ts +84 -0
  74. package/dist/queries/releases-types.js +5 -0
  75. package/dist/queries/search-types.d.ts +86 -0
  76. package/dist/queries/search-types.js +6 -0
  77. package/dist/queries/templates-types.d.ts +61 -0
  78. package/dist/queries/templates-types.js +9 -0
  79. package/dist/queries/templates.d.ts +3 -0
  80. package/dist/queries/templates.js +43 -0
  81. package/dist/types/linear.d.ts +8 -2
  82. package/dist/utils/auth.js +7 -9
  83. package/dist/utils/auto-link-references.js +44 -25
  84. package/dist/utils/disk-cache.js +2 -1
  85. package/dist/utils/formatters/summary.d.ts +62 -0
  86. package/dist/utils/formatters/summary.js +755 -0
  87. package/dist/utils/graphql-attachments-service.js +6 -9
  88. package/dist/utils/graphql-documents-service.js +19 -25
  89. package/dist/utils/graphql-issues-service.d.ts +118 -5
  90. package/dist/utils/graphql-issues-service.js +202 -209
  91. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  92. package/dist/utils/issue-reference-extractor.js +16 -9
  93. package/dist/utils/issue-reference-wrapper.js +1 -54
  94. package/dist/utils/linear-service.d.ts +7 -3
  95. package/dist/utils/linear-service.js +27 -5
  96. package/dist/utils/markdown-prosemirror.js +17 -1
  97. package/dist/utils/mention-resolver.js +17 -5
  98. package/dist/utils/output.d.ts +7 -1
  99. package/dist/utils/output.js +38 -1
  100. package/dist/utils/protected-ranges.d.ts +33 -0
  101. package/dist/utils/protected-ranges.js +73 -0
  102. package/dist/utils/table-formatter.d.ts +36 -0
  103. package/dist/utils/table-formatter.js +46 -24
  104. package/dist/utils/validators.d.ts +9 -1
  105. package/dist/utils/validators.js +10 -0
  106. package/dist/utils/workspace-url.d.ts +5 -1
  107. package/dist/utils/workspace-url.js +35 -5
  108. package/package.json +1 -1
@@ -1,29 +1,26 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import fs from "node:fs";
3
2
  import { loadConfig } from "../config/config.js";
4
3
  import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
5
4
  import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
6
5
  import { resolveDefaultStatus } from "../config/status-defaults.js";
7
6
  import { enforceTerms } from "../config/term-enforcer.js";
8
- import { LIST_COMMENTS_QUERY } from "../queries/comments.js";
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 { autoLinkReferences, } from "../utils/auto-link-references.js";
11
- import { downloadLinearUploads } from "../utils/download-uploads.js";
7
+ import { GET_ISSUE_RELATIONS_QUERY, GET_ISSUE_STATE_HISTORY_QUERY, } from "../queries/issues.js";
12
8
  import { createFileService } from "../utils/file-service.js";
13
9
  import { applyFooter } from "../utils/footer.js";
14
10
  import { createGraphQLAttachmentsService } from "../utils/graphql-attachments-service.js";
15
- import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
16
- import { createGraphQLService, } from "../utils/graphql-service.js";
17
- import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
18
- import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
19
- import { createLinearService, } from "../utils/linear-service.js";
11
+ import { GraphQLIssuesService, } from "../utils/graphql-issues-service.js";
12
+ import { createGraphQLService } from "../utils/graphql-service.js";
13
+ import { createLinearService } from "../utils/linear-service.js";
20
14
  import { logger } from "../utils/logger.js";
21
15
  import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
22
16
  import { getRootOpts } from "../utils/root-opts.js";
23
17
  import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
24
- import { validateReferences } from "../utils/validate-references.js";
25
- import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
26
- import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
18
+ import { parsePositiveInt, parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
19
+ import { gitCheckoutBranch, toBranchName } from "./issues/branch.js";
20
+ import { maybeAutoLink, prepareAutoLinkedDescription, readDescriptionFile, resolveDescription, } from "./issues/description.js";
21
+ import { handleLinkReferencesIssue } from "./issues/link-references.js";
22
+ import { buildIncomingRelationEntries, buildOutgoingRelationEntries, createRelations, } from "./issues/relations.js";
23
+ import { readIssues } from "./read-shortcut.js";
27
24
  const IMAGE_EXTENSIONS = new Set([
28
25
  ".png",
29
26
  ".jpg",
@@ -33,202 +30,11 @@ const IMAGE_EXTENSIONS = new Set([
33
30
  ".svg",
34
31
  ".ico",
35
32
  ]);
36
- const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
37
33
  const COMMIT_HASH_PREFIX_REGEX = /^[a-f0-9]+ /;
38
- /**
39
- * Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
40
- * "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
41
- */
42
- function toBranchName(linearBranchName, prefix = "feature/") {
43
- // Linear branch names look like "dev-123-some-slug"
44
- // We need to uppercase the team key: "DEV-123-some-slug"
45
- const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
46
- if (!match) {
47
- return `${prefix}${linearBranchName}`;
48
- }
49
- const [, teamKey, number, slug] = match;
50
- return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
51
- }
52
- /**
53
- * Check out a new git branch. Warns and skips if not in a git repo.
54
- * Throws if the branch already exists.
55
- */
56
- function gitCheckoutBranch(branchName) {
57
- try {
58
- execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
59
- stdio: "pipe",
60
- });
61
- }
62
- catch {
63
- outputWarning("Not inside a git repository — skipping branch checkout.");
64
- return;
65
- }
66
- execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
67
- }
68
- /**
69
- * Read description from a file path or stdin ("-").
70
- * Avoids shell escaping issues when descriptions contain special characters.
71
- */
72
- function readDescriptionFile(filePath) {
73
- if (filePath === "-") {
74
- return fs.readFileSync(0, "utf8").trim();
75
- }
76
- if (!fs.existsSync(filePath)) {
77
- throw new Error(`Description file not found: ${filePath}`);
78
- }
79
- return fs.readFileSync(filePath, "utf8").trim();
80
- }
81
- /**
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.
91
- */
92
- function resolveDescription(options) {
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) {
101
- return readDescriptionFile(options.descriptionFile);
102
- }
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;
119
- }
120
34
  function isImageFile(filename) {
121
35
  const ext = filename.lastIndexOf(".");
122
36
  return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
123
37
  }
124
- function transformIssueRelation(rel) {
125
- const issue = rel.issue;
126
- const relatedIssue = rel.relatedIssue;
127
- return {
128
- id: rel.id,
129
- type: rel.type,
130
- issue: {
131
- id: issue.id,
132
- identifier: issue.identifier,
133
- title: issue.title,
134
- },
135
- relatedIssue: {
136
- id: relatedIssue.id,
137
- identifier: relatedIssue.identifier,
138
- title: relatedIssue.title,
139
- },
140
- };
141
- }
142
- async function createRelations(sourceId, options, graphQLService, linearService) {
143
- const relations = [];
144
- const relationSpecs = [
145
- { option: "relatedTo", type: "related" },
146
- { option: "blocks", type: "blocks" },
147
- { option: "blockedBy", type: "blocks", reverse: true },
148
- { option: "duplicateOf", type: "duplicate" },
149
- ];
150
- for (const spec of relationSpecs) {
151
- const value = options[spec.option];
152
- if (!value) {
153
- continue;
154
- }
155
- for (const id of splitList(value)) {
156
- const targetId = await linearService.resolveIssueId(id);
157
- const rel = await graphQLService.rawRequest(ISSUE_RELATION_CREATE_MUTATION, {
158
- input: {
159
- issueId: spec.reverse ? targetId : sourceId,
160
- relatedIssueId: spec.reverse ? sourceId : targetId,
161
- type: spec.type,
162
- },
163
- });
164
- const create = rel.issueRelationCreate;
165
- const issueRelation = create?.issueRelation;
166
- relations.push(transformIssueRelation(issueRelation));
167
- }
168
- }
169
- return relations;
170
- }
171
- /**
172
- * Pre-process a description before sending to Linear:
173
- * 1. Extract all issue references from the description.
174
- * 2. Validate them (drop those that don't resolve in this workspace — handles "ISO-1424"-style false positives).
175
- * 3. Wrap valid identifiers as markdown links — skipping any already inside a link, code block, or backtick span.
176
- *
177
- * Returns the rewritten description plus the validated map for downstream auto-linking.
178
- *
179
- * No-ops (returns the original description with an empty map) when:
180
- * - description is empty/undefined
181
- * - the user passed `--no-auto-link`
182
- */
183
- async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
184
- if (!description || options.autoLink === false) {
185
- return { description, preResolved: new Map(), rewritten: false };
186
- }
187
- const refs = extractIssueReferences(description, selfIdentifier);
188
- if (refs.length === 0) {
189
- return { description, preResolved: new Map(), rewritten: false };
190
- }
191
- const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
192
- if (preResolved.size === 0) {
193
- return { description, preResolved, rewritten: false };
194
- }
195
- const validIds = new Set(preResolved.keys());
196
- const urlKey = await getWorkspaceUrlKey(graphQLService);
197
- const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
198
- return {
199
- description: wrapped,
200
- preResolved,
201
- rewritten: wrapped !== description,
202
- };
203
- }
204
- /**
205
- * Run auto-linking for issue references found in a description, unless the user opted out
206
- * with --no-auto-link or no description was provided. Returns undefined when nothing was linked
207
- * and nothing was skipped/failed (so callers can omit the field from JSON output).
208
- */
209
- async function maybeAutoLink(input) {
210
- const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
211
- if (options.autoLink === false) {
212
- return;
213
- }
214
- if (!description) {
215
- return;
216
- }
217
- const result = await autoLinkReferences({
218
- issueId,
219
- identifier,
220
- description,
221
- graphQLService,
222
- linearService,
223
- preResolved,
224
- });
225
- if (result.linked.length === 0 &&
226
- result.skipped.length === 0 &&
227
- result.failed.length === 0) {
228
- return;
229
- }
230
- return result;
231
- }
232
38
  function validateUpdateOptions(options) {
233
39
  if (options.parentTicket && options.clearParentTicket) {
234
40
  throw new Error("Cannot use --parent-ticket and --clear-parent-ticket together");
@@ -359,7 +165,7 @@ async function handleListIssues(options, command) {
359
165
  ? parsePriorityFilter(options.priority)
360
166
  : undefined,
361
167
  orderBy: options.sort === "created" ? "createdAt" : "updatedAt",
362
- limit: Number.parseInt(options.limit, 10),
168
+ limit: parsePositiveInt(options.limit, "--limit"),
363
169
  };
364
170
  const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
365
171
  outputIssues(result, options.format, options.fields, {
@@ -367,7 +173,7 @@ async function handleListIssues(options, command) {
367
173
  });
368
174
  }
369
175
  else {
370
- const result = sortIssues(await issuesService.getIssues(Number.parseInt(options.limit, 10)), options.sort);
176
+ const result = sortIssues(await issuesService.getIssues(parsePositiveInt(options.limit, "--limit")), options.sort);
371
177
  outputIssues(result, options.format, options.fields, {});
372
178
  }
373
179
  }
@@ -392,7 +198,7 @@ async function handleSearchIssues(query, options, command) {
392
198
  priority: options.priority
393
199
  ? parsePriorityFilter(options.priority)
394
200
  : undefined,
395
- limit: Number.parseInt(options.limit, 10),
201
+ limit: parsePositiveInt(options.limit, "--limit"),
396
202
  };
397
203
  const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
398
204
  outputIssues(result, options.format, options.fields, { query });
@@ -417,8 +223,14 @@ async function resolveCreateInputs(title, options, rootOpts) {
417
223
  : config.defaultPriority;
418
224
  // --- Validation (labels, description, assignee, project, title) ---
419
225
  // Controlled by config.validation.enabled (default: true).
420
- // Bypassed by --skip-validation flag.
421
- if (!options.skipValidation) {
226
+ // Bypassed by --skip-validation flag, OR by --from-template which
227
+ // defers field provision to Linear's server-side template
228
+ // instantiation. With --from-template + no overrides, the
229
+ // resulting issue inherits title/description/labels/etc. from the
230
+ // template — running the local validator against undefined fields
231
+ // would produce a false-negative "Missing --labels" error.
232
+ const hasFromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
233
+ if (!options.skipValidation && !hasFromTemplate) {
422
234
  const rawLabels = options.labels ? splitList(options.labels) : null;
423
235
  const description = resolveDescription(options);
424
236
  const validationResult = validateIssueCreation({
@@ -435,7 +247,7 @@ async function resolveCreateInputs(title, options, rootOpts) {
435
247
  enforceValidation(validationResult);
436
248
  }
437
249
  if (!effectivePriorityInput) {
438
- outputWarning("Creating issue without --priority. Consider specifying it for better triage.", "missing_fields");
250
+ outputWarning("Creating issue without --priority. Consider specifying it for better triage.");
439
251
  }
440
252
  const teamInput = options.team || config.defaultTeam;
441
253
  const teamId = resolveTeam(teamInput);
@@ -506,7 +318,7 @@ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
506
318
  }
507
319
  async function handleCreateIssue(title, options, command) {
508
320
  const rootOpts = getRootOpts(command);
509
- const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title, options, rootOpts);
321
+ const { teamInput, teamId, assigneeId, labelIds, status, subscriberIds, priority, } = await resolveCreateInputs(title ?? "", options, rootOpts);
510
322
  const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
511
323
  const descriptionWithAttachments = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
512
324
  // Append messageFooter (config or --footer flag) so auto-link picks up any
@@ -526,7 +338,9 @@ async function handleCreateIssue(title, options, command) {
526
338
  // because the issue doesn't exist yet — pass undefined.
527
339
  const prepared = await prepareAutoLinkedDescription(description || undefined, options, undefined, linearService, graphQLService);
528
340
  const result = await issuesService.createIssue({
529
- title,
341
+ // title is omitted when --from-template is set without an override,
342
+ // so Linear copies the template's title server-side.
343
+ ...(title ? { title } : {}),
530
344
  teamId,
531
345
  teamInput,
532
346
  description: prepared.description,
@@ -540,6 +354,12 @@ async function handleCreateIssue(title, options, command) {
540
354
  cycleId: options.cycle,
541
355
  subscriberIds,
542
356
  dueDate: options.dueDate,
357
+ // Linear server-side template instantiation. When set,
358
+ // Linear copies the template's title/description/labels/priority
359
+ // onto the new issue; any explicit field above wins by override.
360
+ ...(typeof options.fromTemplate === "string" && options.fromTemplate
361
+ ? { templateId: options.fromTemplate }
362
+ : {}),
543
363
  });
544
364
  const relations = await createRelations(result.id, options, graphQLService, linearService);
545
365
  // Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
@@ -593,22 +413,19 @@ async function handleHistoryIssue(issueId, _options, command) {
593
413
  const graphQLService = await createGraphQLService(rootOpts);
594
414
  const linearService = await createLinearService(rootOpts);
595
415
  const resolvedId = await linearService.resolveIssueId(issueId);
596
- const result = await graphQLService.rawRequest(GET_ISSUE_STATE_HISTORY_QUERY, {
597
- id: resolvedId,
598
- });
416
+ const result = await graphQLService.rawRequest(GET_ISSUE_STATE_HISTORY_QUERY, { id: resolvedId });
599
417
  if (!result.issue) {
600
418
  throw new Error(`Issue "${issueId}" not found`);
601
419
  }
602
420
  const issue = result.issue;
603
- const stateHistory = issue.stateHistory;
604
- const spans = (stateHistory?.nodes ?? []).map((span) => ({
421
+ const spans = issue.stateHistory.nodes.map((span) => ({
605
422
  state: {
606
423
  id: span.state.id,
607
424
  name: span.state.name,
608
425
  type: span.state.type,
609
426
  },
610
427
  startedAt: span.startedAt,
611
- endedAt: span.endedAt || undefined,
428
+ endedAt: span.endedAt ?? undefined,
612
429
  }));
613
430
  outputSuccess({
614
431
  id: issue.id,
@@ -632,82 +449,19 @@ async function handleRelateIssue(issueId, options, command) {
632
449
  meta: { count: relations.length, source: issueId },
633
450
  });
634
451
  }
635
- /**
636
- * Invert relation type for incoming (inverse) relations so the output
637
- * reads naturally from the perspective of the queried issue.
638
- * e.g. if DEV-100 "blocks" DEV-200, and we query DEV-200,
639
- * the inverse relation type is "blocks" but direction is incoming → "blockedBy".
640
- */
641
- function normalizeInverseType(type) {
642
- if (type === "blocks") {
643
- return "blockedBy";
644
- }
645
- return type;
646
- }
647
- function buildRelatedIssueSummary(peer) {
648
- const state = peer.state;
649
- const assignee = peer.assignee;
650
- const team = peer.team;
651
- return {
652
- id: peer.id,
653
- identifier: peer.identifier,
654
- title: peer.title,
655
- ...(state
656
- ? { state: { id: state.id, name: state.name } }
657
- : {}),
658
- ...(peer.priority == null ? {} : { priority: peer.priority }),
659
- ...(assignee
660
- ? {
661
- assignee: {
662
- id: assignee.id,
663
- name: assignee.name,
664
- },
665
- }
666
- : {}),
667
- ...(team
668
- ? {
669
- team: {
670
- id: team.id,
671
- key: team.key,
672
- name: team.name,
673
- },
674
- }
675
- : {}),
676
- };
677
- }
678
- function buildRelationEntries(nodes, peerKey, direction, normalizeType) {
679
- const entries = [];
680
- for (const rel of nodes ?? []) {
681
- const peer = rel[peerKey];
682
- if (!peer) {
683
- continue;
684
- }
685
- entries.push({
686
- id: rel.id,
687
- type: normalizeType(rel.type),
688
- direction,
689
- issue: buildRelatedIssueSummary(peer),
690
- });
691
- }
692
- return entries;
693
- }
694
452
  async function handleRelatedIssues(issueId, _options, command) {
695
453
  const rootOpts = getRootOpts(command);
696
454
  const graphQLService = await createGraphQLService(rootOpts);
697
455
  const linearService = await createLinearService(rootOpts);
698
456
  const resolvedId = await linearService.resolveIssueId(issueId);
699
- const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
700
- id: resolvedId,
701
- });
457
+ const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, { id: resolvedId });
702
458
  if (!result.issue) {
703
459
  throw new Error(`Issue "${issueId}" not found`);
704
460
  }
705
461
  const issue = result.issue;
706
- const relations = issue.relations;
707
- const inverseRelations = issue.inverseRelations;
708
462
  const entries = [
709
- ...buildRelationEntries(relations?.nodes, "relatedIssue", "outgoing", (raw) => raw),
710
- ...buildRelationEntries(inverseRelations?.nodes, "issue", "incoming", normalizeInverseType),
463
+ ...buildOutgoingRelationEntries(issue.relations.nodes),
464
+ ...buildIncomingRelationEntries(issue.inverseRelations.nodes),
711
465
  ];
712
466
  outputSuccess({
713
467
  id: issue.id,
@@ -717,211 +471,6 @@ async function handleRelatedIssues(issueId, _options, command) {
717
471
  meta: { count: entries.length },
718
472
  });
719
473
  }
720
- async function fetchCommentBodies(issueUuid, graphQLService) {
721
- const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
722
- issueId: issueUuid,
723
- first: 250,
724
- });
725
- const issue = result.issue;
726
- const comments = issue?.comments;
727
- const nodes = comments?.nodes ?? [];
728
- return nodes
729
- .map((c) => c.body)
730
- .filter((b) => typeof b === "string" && b.length > 0);
731
- }
732
- async function linkReferencesForIssue(args) {
733
- const comments = args.includeComments
734
- ? await fetchCommentBodies(args.issueUuid, args.graphQLService)
735
- : undefined;
736
- return autoLinkReferences({
737
- issueId: args.issueUuid,
738
- identifier: args.identifier,
739
- description: args.description,
740
- comments,
741
- dryRun: args.dryRun,
742
- graphQLService: args.graphQLService,
743
- linearService: args.linearService,
744
- preResolved: args.preResolved,
745
- });
746
- }
747
- /**
748
- * Prepare a description rewrite: validate refs, wrap valid IDs as markdown links,
749
- * and return the rewritten text (or undefined if nothing would change).
750
- */
751
- async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
752
- if (!description) {
753
- return { preResolved: undefined, wrapped: undefined };
754
- }
755
- const refs = extractIssueReferences(description, selfIdentifier);
756
- if (refs.length === 0) {
757
- return { preResolved: undefined, wrapped: undefined };
758
- }
759
- const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
760
- if (preResolved.size === 0) {
761
- return { preResolved, wrapped: undefined };
762
- }
763
- const validIds = new Set(preResolved.keys());
764
- const urlKey = await getWorkspaceUrlKey(graphQLService);
765
- const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
766
- return {
767
- preResolved,
768
- wrapped: wrapped === description ? undefined : wrapped,
769
- };
770
- }
771
- async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
772
- const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
773
- id: issueUuid,
774
- input: { description },
775
- });
776
- const mutation = updateResult.issueUpdate;
777
- if (!mutation?.success) {
778
- throw new Error("Failed to rewrite description");
779
- }
780
- }
781
- async function handleLinkReferencesSingle(issueId, options, graphQLService, linearService) {
782
- const resolvedId = await linearService.resolveIssueId(issueId);
783
- // GET_ISSUE_RELATIONS_QUERY also returns description, so this single call gives us everything
784
- // we need for the description-only path. Comments are fetched separately on demand.
785
- const issueResult = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
786
- id: resolvedId,
787
- });
788
- if (!issueResult.issue) {
789
- throw new Error(`Issue "${issueId}" not found`);
790
- }
791
- const issue = issueResult.issue;
792
- const identifier = issue.identifier;
793
- const description = issue.description ?? "";
794
- const dryRun = Boolean(options.dryRun);
795
- const includeComments = Boolean(options.includeComments);
796
- const rewriteDescription = Boolean(options.rewriteDescription);
797
- // If rewriting, validate refs once up front so we can both wrap the description and
798
- // pass the resolved map down to autoLink — avoids a second resolve round trip.
799
- const rewrite = rewriteDescription
800
- ? await prepareDescriptionRewrite(description, identifier, linearService, graphQLService)
801
- : { preResolved: undefined, wrapped: undefined };
802
- const autoLinked = await linkReferencesForIssue({
803
- issueUuid: resolvedId,
804
- identifier,
805
- description,
806
- includeComments,
807
- dryRun,
808
- graphQLService,
809
- linearService,
810
- preResolved: rewrite.preResolved,
811
- });
812
- let descriptionRewritten = false;
813
- if (rewriteDescription && rewrite.wrapped && !dryRun) {
814
- await pushDescriptionUpdate(resolvedId, rewrite.wrapped, graphQLService);
815
- descriptionRewritten = true;
816
- }
817
- outputSuccess({
818
- id: resolvedId,
819
- identifier,
820
- title: issue.title,
821
- autoLinked,
822
- ...(rewriteDescription
823
- ? {
824
- descriptionRewritten,
825
- ...(dryRun && rewrite.wrapped
826
- ? { descriptionPreview: rewrite.wrapped }
827
- : {}),
828
- }
829
- : {}),
830
- meta: {
831
- dryRun,
832
- includeComments,
833
- ...(rewriteDescription ? { rewriteDescription: true } : {}),
834
- },
835
- });
836
- }
837
- async function handleLinkReferencesBatch(teamInput, options, graphQLService, linearService) {
838
- const teamId = resolveTeam(teamInput);
839
- const limit = options.limit
840
- ? Number.parseInt(options.limit, 10)
841
- : 100;
842
- if (!Number.isFinite(limit) || limit <= 0) {
843
- throw new Error(`--limit must be a positive integer, got: ${options.limit}`);
844
- }
845
- const dryRun = Boolean(options.dryRun);
846
- const includeComments = Boolean(options.includeComments);
847
- const scan = await graphQLService.rawRequest(SCAN_ISSUES_QUERY, {
848
- filter: { team: { id: { eq: teamId } } },
849
- first: limit,
850
- });
851
- const issues = scan.issues?.nodes;
852
- const nodes = issues ?? [];
853
- const entries = [];
854
- for (const node of nodes) {
855
- const issueUuid = node.id;
856
- const identifier = node.identifier;
857
- const description = node.description ?? "";
858
- const autoLinked = await linkReferencesForIssue({
859
- issueUuid,
860
- identifier,
861
- description,
862
- includeComments,
863
- dryRun,
864
- graphQLService,
865
- linearService,
866
- });
867
- entries.push({ identifier, autoLinked });
868
- }
869
- // Summary counts across the batch
870
- const totalLinked = entries.reduce((n, e) => n + e.autoLinked.linked.length, 0);
871
- const totalSkipped = entries.reduce((n, e) => n + e.autoLinked.skipped.length, 0);
872
- const totalFailed = entries.reduce((n, e) => n + e.autoLinked.failed.length, 0);
873
- outputSuccess({
874
- data: entries,
875
- meta: {
876
- count: entries.length,
877
- team: teamInput,
878
- dryRun,
879
- includeComments,
880
- totals: {
881
- linked: totalLinked,
882
- skipped: totalSkipped,
883
- failed: totalFailed,
884
- },
885
- },
886
- });
887
- }
888
- async function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
889
- const rootOpts = getRootOpts(command);
890
- const graphQLService = await createGraphQLService(rootOpts);
891
- const linearService = await createLinearService(rootOpts);
892
- if (options.team) {
893
- if (issueIdOrTeamFlag) {
894
- throw new Error("Provide either an issueId positional argument OR --team, not both. With --team, omit the issueId.");
895
- }
896
- if (options.rewriteDescription) {
897
- throw new Error("--rewrite-description is single-issue only. Use it case by case (one issueId per invocation) — bulk description rewrites bot-author the whole team's history.");
898
- }
899
- return handleLinkReferencesBatch(options.team, options, graphQLService, linearService);
900
- }
901
- if (!issueIdOrTeamFlag) {
902
- throw new Error("issueId is required (or use --team <key> to scan a whole team)");
903
- }
904
- return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
905
- }
906
- async function handleReadIssue(issueIds, _options, command) {
907
- const rootOpts = getRootOpts(command);
908
- const graphQLService = await createGraphQLService(rootOpts);
909
- const linearService = await createLinearService(rootOpts);
910
- const issuesService = new GraphQLIssuesService(graphQLService, linearService);
911
- const fileService = await createFileService(rootOpts);
912
- if (issueIds.length === 1) {
913
- const issue = await issuesService.getIssueById(issueIds[0]);
914
- const resolved = await downloadLinearUploads(issue, fileService);
915
- outputSuccess(resolved);
916
- }
917
- else {
918
- const results = await Promise.all(issueIds.map(async (id) => {
919
- const issue = await issuesService.getIssueById(id);
920
- return downloadLinearUploads(issue, fileService);
921
- }));
922
- outputSuccess(results);
923
- }
924
- }
925
474
  async function handleUpdateIssue(issueId, options, command) {
926
475
  // Normalize aliases
927
476
  if (options.state && !options.status) {
@@ -940,9 +489,10 @@ async function handleUpdateIssue(issueId, options, command) {
940
489
  const linearService = await createLinearService(rootOpts);
941
490
  if (options.appendDescription) {
942
491
  const resolved = await linearService.resolveIssueId(issueId);
943
- const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { description } }", { id: resolved });
944
- const issue = current.issue;
945
- const existing = issue?.description ?? "";
492
+ const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { description } }", {
493
+ id: resolved,
494
+ });
495
+ const existing = current.issue?.description ?? "";
946
496
  options.description = `${existing}\n${options.appendDescription}`;
947
497
  }
948
498
  if (options.title || options.description) {
@@ -985,6 +535,34 @@ async function handleUpdateIssue(issueId, options, command) {
985
535
  : undefined;
986
536
  outputSuccess(autoLinked ? { ...result, autoLinked } : result);
987
537
  }
538
+ async function handleArchiveIssue(issueId, _options, command) {
539
+ const rootOpts = getRootOpts(command);
540
+ const graphQLService = await createGraphQLService(rootOpts);
541
+ const linearService = await createLinearService(rootOpts);
542
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
543
+ const result = await issuesService.archiveIssue(issueId);
544
+ outputSuccess({
545
+ success: true,
546
+ archived: true,
547
+ ...result,
548
+ });
549
+ }
550
+ async function handleDeleteIssue(issueId, options, command) {
551
+ const rootOpts = getRootOpts(command);
552
+ const graphQLService = await createGraphQLService(rootOpts);
553
+ const linearService = await createLinearService(rootOpts);
554
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
555
+ const permanentlyDelete = Boolean(options.permanentlyDelete || options.hard);
556
+ const result = await issuesService.deleteIssue(issueId, {
557
+ permanentlyDelete,
558
+ });
559
+ outputSuccess({
560
+ success: true,
561
+ deleted: true,
562
+ permanentlyDeleted: permanentlyDelete,
563
+ ...result,
564
+ });
565
+ }
988
566
  async function handleRetrolink(options, command) {
989
567
  // 1. Read current branch
990
568
  let currentBranch;
@@ -1082,7 +660,7 @@ export function setupIssuesCommands(program) {
1082
660
  .option("--status <status>", "filter by status (comma-separated, e.g. Todo,Backlog)")
1083
661
  .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
1084
662
  .option("--sort <field>", "sort results (priority, status, created, updated)")
1085
- .option("--format <format>", "output format (json, table, md, csv)", "json")
663
+ .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
1086
664
  .option("--fields <fields>", "columns for table/csv (comma-separated: identifier,title,status,priority,assignee,project,team,labels,updated)")
1087
665
  .action(handleAsyncCommand(handleListIssues));
1088
666
  issues
@@ -1097,7 +675,7 @@ export function setupIssuesCommands(program) {
1097
675
  .option("--label <labels>", "alias for --labels")
1098
676
  .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
1099
677
  .option("--sort <field>", "sort results (priority, status, created, updated)")
1100
- .option("--format <format>", "output format (json, table, md, csv)", "json")
678
+ .option("--format <format>", "output format (json, summary, table, md, csv)", "json")
1101
679
  .option("--fields <fields>", "columns for table/csv output")
1102
680
  .option("-l, --limit <number>", "limit results", "10")
1103
681
  .action(handleAsyncCommand(handleSearchIssues));
@@ -1108,6 +686,7 @@ export function setupIssuesCommands(program) {
1108
686
  .option("-d, --description <desc>", "issue description")
1109
687
  .option("--description-file <path>", "read description from file (use - for stdin)")
1110
688
  .option("--template <name>", "use a named description template from config.descriptionTemplates")
689
+ .option("--from-template <id>", "instantiate the issue from a Linear server-side template (UUID from `el-linear templates list`). Sets templateId on the underlying issueCreate mutation; Linear copies the template's title, description, labels, priority, etc. as the new issue's defaults. Override any field with the matching --title / --description / --labels flag.")
1111
690
  .option("-a, --assignee <assignee>", "assign to user (name, alias, or UUID)")
1112
691
  .option("--no-assignee", "create unassigned even when config.defaultAssignee is set")
1113
692
  .option("-p, --priority <priority>", "priority: name (none/urgent/high/medium/normal/low) or number (0-4)")
@@ -1138,8 +717,12 @@ export function setupIssuesCommands(program) {
1138
717
  options.labels = options.label;
1139
718
  }
1140
719
  const title = options.title || titleArg;
1141
- if (!title) {
1142
- throw new Error("Title is required. Provide it as a positional argument or with --title.");
720
+ // --from-template lets Linear copy the template's title, so a
721
+ // local title is optional in that path. Without --from-template,
722
+ // the create mutation requires a title up front.
723
+ const fromTemplate = typeof options.fromTemplate === "string" && options.fromTemplate;
724
+ if (!title && !fromTemplate) {
725
+ throw new Error("Title is required. Provide it as a positional argument, with --title, or use --from-template to copy the template's title.");
1143
726
  }
1144
727
  return handleCreateIssue(title, options, command);
1145
728
  }));
@@ -1150,7 +733,7 @@ export function setupIssuesCommands(program) {
1150
733
  .alias("show")
1151
734
  .description("Get issue details. Accepts multiple IDs for batch retrieval.")
1152
735
  .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789")
1153
- .action(handleAsyncCommand(handleReadIssue));
736
+ .action(handleAsyncCommand(readIssues));
1154
737
  issues
1155
738
  .command("update <issueId>")
1156
739
  .alias("edit")
@@ -1181,6 +764,23 @@ export function setupIssuesCommands(program) {
1181
764
  .option("--strict", "strict brand validation")
1182
765
  .option("--no-auto-link", "skip auto-linking issue references found in the description")
1183
766
  .action(handleAsyncCommand(handleUpdateIssue));
767
+ issues
768
+ .command("archive <issueId>")
769
+ .description("Archive an issue.")
770
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
771
+ .action(handleAsyncCommand(handleArchiveIssue));
772
+ issues
773
+ .command("delete <issueId>")
774
+ .description("Delete (trash) an issue.")
775
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported. Use --permanently-delete only when you intend to skip Linear's grace period; Linear requires admin permissions.")
776
+ .option("--permanently-delete", "skip Linear's 30-day grace period (admin only)")
777
+ .option("--hard", "alias for --permanently-delete")
778
+ .action(handleAsyncCommand(handleDeleteIssue));
779
+ issues
780
+ .command("hard-delete <issueId>")
781
+ .description("Permanently delete an issue immediately (admin only).")
782
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
783
+ .action(handleAsyncCommand((issueId, options, command) => handleDeleteIssue(issueId, { ...options, permanentlyDelete: true }, command)));
1184
784
  issues
1185
785
  .command("history <issueId>")
1186
786
  .description("Show issue state transition history (time in each status).")