@enrichlayer/el-linear 1.2.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 (151) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/claude-skills/linear-operations/SKILL.md +315 -0
  4. package/claude-skills/linear-operations/evals/evals.json +46 -0
  5. package/dist/commands/attachments.d.ts +2 -0
  6. package/dist/commands/attachments.js +57 -0
  7. package/dist/commands/batch.d.ts +2 -0
  8. package/dist/commands/batch.js +309 -0
  9. package/dist/commands/comments.d.ts +2 -0
  10. package/dist/commands/comments.js +272 -0
  11. package/dist/commands/config.d.ts +2 -0
  12. package/dist/commands/config.js +15 -0
  13. package/dist/commands/cycles.d.ts +2 -0
  14. package/dist/commands/cycles.js +63 -0
  15. package/dist/commands/documents.d.ts +2 -0
  16. package/dist/commands/documents.js +175 -0
  17. package/dist/commands/embeds.d.ts +2 -0
  18. package/dist/commands/embeds.js +65 -0
  19. package/dist/commands/gdoc.d.ts +2 -0
  20. package/dist/commands/gdoc.js +37 -0
  21. package/dist/commands/graphql.d.ts +2 -0
  22. package/dist/commands/graphql.js +70 -0
  23. package/dist/commands/init/aliases.d.ts +109 -0
  24. package/dist/commands/init/aliases.js +569 -0
  25. package/dist/commands/init/defaults.d.ts +25 -0
  26. package/dist/commands/init/defaults.js +112 -0
  27. package/dist/commands/init/index.d.ts +18 -0
  28. package/dist/commands/init/index.js +182 -0
  29. package/dist/commands/init/shared.d.ts +88 -0
  30. package/dist/commands/init/shared.js +164 -0
  31. package/dist/commands/init/token.d.ts +50 -0
  32. package/dist/commands/init/token.js +141 -0
  33. package/dist/commands/init/workspace.d.ts +20 -0
  34. package/dist/commands/init/workspace.js +80 -0
  35. package/dist/commands/issue-id.d.ts +28 -0
  36. package/dist/commands/issue-id.js +81 -0
  37. package/dist/commands/issues.d.ts +2 -0
  38. package/dist/commands/issues.js +1145 -0
  39. package/dist/commands/labels.d.ts +2 -0
  40. package/dist/commands/labels.js +100 -0
  41. package/dist/commands/project-milestones.d.ts +2 -0
  42. package/dist/commands/project-milestones.js +143 -0
  43. package/dist/commands/projects.d.ts +2 -0
  44. package/dist/commands/projects.js +336 -0
  45. package/dist/commands/read-shortcut.d.ts +6 -0
  46. package/dist/commands/read-shortcut.js +69 -0
  47. package/dist/commands/releases.d.ts +2 -0
  48. package/dist/commands/releases.js +142 -0
  49. package/dist/commands/search.d.ts +2 -0
  50. package/dist/commands/search.js +171 -0
  51. package/dist/commands/teams.d.ts +2 -0
  52. package/dist/commands/teams.js +19 -0
  53. package/dist/commands/templates.d.ts +2 -0
  54. package/dist/commands/templates.js +58 -0
  55. package/dist/commands/users.d.ts +2 -0
  56. package/dist/commands/users.js +17 -0
  57. package/dist/config/config.d.ts +43 -0
  58. package/dist/config/config.js +81 -0
  59. package/dist/config/issue-validation.d.ts +39 -0
  60. package/dist/config/issue-validation.js +264 -0
  61. package/dist/config/paths.d.ts +20 -0
  62. package/dist/config/paths.js +22 -0
  63. package/dist/config/resolver.d.ts +25 -0
  64. package/dist/config/resolver.js +183 -0
  65. package/dist/config/status-defaults.d.ts +13 -0
  66. package/dist/config/status-defaults.js +20 -0
  67. package/dist/config/term-enforcer.d.ts +31 -0
  68. package/dist/config/term-enforcer.js +69 -0
  69. package/dist/main.d.ts +2 -0
  70. package/dist/main.js +76 -0
  71. package/dist/queries/attachments.d.ts +3 -0
  72. package/dist/queries/attachments.js +35 -0
  73. package/dist/queries/comments.d.ts +3 -0
  74. package/dist/queries/comments.js +64 -0
  75. package/dist/queries/common.d.ts +2 -0
  76. package/dist/queries/common.js +109 -0
  77. package/dist/queries/cycles.d.ts +2 -0
  78. package/dist/queries/cycles.js +40 -0
  79. package/dist/queries/documents.d.ts +5 -0
  80. package/dist/queries/documents.js +67 -0
  81. package/dist/queries/introspect.d.ts +2 -0
  82. package/dist/queries/introspect.js +24 -0
  83. package/dist/queries/issues.d.ts +23 -0
  84. package/dist/queries/issues.js +380 -0
  85. package/dist/queries/labels.d.ts +4 -0
  86. package/dist/queries/labels.js +53 -0
  87. package/dist/queries/project-milestones.d.ts +6 -0
  88. package/dist/queries/project-milestones.js +125 -0
  89. package/dist/queries/projects.d.ts +7 -0
  90. package/dist/queries/projects.js +104 -0
  91. package/dist/queries/releases.d.ts +4 -0
  92. package/dist/queries/releases.js +85 -0
  93. package/dist/queries/search.d.ts +1 -0
  94. package/dist/queries/search.js +35 -0
  95. package/dist/queries/templates.d.ts +2 -0
  96. package/dist/queries/templates.js +30 -0
  97. package/dist/types/linear.d.ts +217 -0
  98. package/dist/types/linear.js +6 -0
  99. package/dist/utils/auth.d.ts +4 -0
  100. package/dist/utils/auth.js +23 -0
  101. package/dist/utils/auto-link-references.d.ts +47 -0
  102. package/dist/utils/auto-link-references.js +188 -0
  103. package/dist/utils/date-format.d.ts +4 -0
  104. package/dist/utils/date-format.js +8 -0
  105. package/dist/utils/download-uploads.d.ts +7 -0
  106. package/dist/utils/download-uploads.js +88 -0
  107. package/dist/utils/embed-parser.d.ts +8 -0
  108. package/dist/utils/embed-parser.js +54 -0
  109. package/dist/utils/error-messages.d.ts +4 -0
  110. package/dist/utils/error-messages.js +17 -0
  111. package/dist/utils/file-service.d.ts +13 -0
  112. package/dist/utils/file-service.js +239 -0
  113. package/dist/utils/gdoc-parser.d.ts +45 -0
  114. package/dist/utils/gdoc-parser.js +107 -0
  115. package/dist/utils/graphql-attachments-service.d.ts +12 -0
  116. package/dist/utils/graphql-attachments-service.js +46 -0
  117. package/dist/utils/graphql-documents-service.d.ts +18 -0
  118. package/dist/utils/graphql-documents-service.js +97 -0
  119. package/dist/utils/graphql-issues-service.d.ts +49 -0
  120. package/dist/utils/graphql-issues-service.js +925 -0
  121. package/dist/utils/graphql-service.d.ts +8 -0
  122. package/dist/utils/graphql-service.js +35 -0
  123. package/dist/utils/identifier-parser.d.ts +7 -0
  124. package/dist/utils/identifier-parser.js +20 -0
  125. package/dist/utils/issue-reference-extractor.d.ts +18 -0
  126. package/dist/utils/issue-reference-extractor.js +95 -0
  127. package/dist/utils/issue-reference-wrapper.d.ts +12 -0
  128. package/dist/utils/issue-reference-wrapper.js +91 -0
  129. package/dist/utils/linear-service.d.ts +26 -0
  130. package/dist/utils/linear-service.js +442 -0
  131. package/dist/utils/logger.d.ts +4 -0
  132. package/dist/utils/logger.js +8 -0
  133. package/dist/utils/markdown-prosemirror.d.ts +24 -0
  134. package/dist/utils/markdown-prosemirror.js +325 -0
  135. package/dist/utils/mention-resolver.d.ts +31 -0
  136. package/dist/utils/mention-resolver.js +234 -0
  137. package/dist/utils/output.d.ts +7 -0
  138. package/dist/utils/output.js +125 -0
  139. package/dist/utils/table-formatter.d.ts +4 -0
  140. package/dist/utils/table-formatter.js +249 -0
  141. package/dist/utils/usage.d.ts +2 -0
  142. package/dist/utils/usage.js +24 -0
  143. package/dist/utils/uuid.d.ts +2 -0
  144. package/dist/utils/uuid.js +8 -0
  145. package/dist/utils/validate-references.d.ts +10 -0
  146. package/dist/utils/validate-references.js +33 -0
  147. package/dist/utils/validators.d.ts +7 -0
  148. package/dist/utils/validators.js +71 -0
  149. package/dist/utils/workspace-url.d.ts +4 -0
  150. package/dist/utils/workspace-url.js +44 -0
  151. package/package.json +71 -0
@@ -0,0 +1,1145 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { loadConfig } from "../config/config.js";
4
+ import { enforceValidation, validateIssueCreation, } from "../config/issue-validation.js";
5
+ import { resolveAssignee, resolveLabels, resolveMember, resolveTeam, } from "../config/resolver.js";
6
+ import { resolveDefaultStatus } from "../config/status-defaults.js";
7
+ 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 { getApiToken } from "../utils/auth.js";
11
+ import { autoLinkReferences, } from "../utils/auto-link-references.js";
12
+ import { downloadLinearUploads } from "../utils/download-uploads.js";
13
+ import { FileService } from "../utils/file-service.js";
14
+ 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";
20
+ import { logger } from "../utils/logger.js";
21
+ import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
22
+ import { formatCsv, formatMarkdown, formatTable, } from "../utils/table-formatter.js";
23
+ import { validateReferences } from "../utils/validate-references.js";
24
+ import { parsePriorityFilter, splitList, validatePriority, } from "../utils/validators.js";
25
+ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
26
+ const IMAGE_EXTENSIONS = new Set([
27
+ ".png",
28
+ ".jpg",
29
+ ".jpeg",
30
+ ".gif",
31
+ ".webp",
32
+ ".svg",
33
+ ".ico",
34
+ ]);
35
+ const LINEAR_BRANCH_REGEX = /^([a-zA-Z]+)-(\d+)-(.+)$/;
36
+ const COMMIT_HASH_PREFIX_REGEX = /^[a-f0-9]+ /;
37
+ /**
38
+ * Transform Linear's branchName (e.g. "dev-3549-slug") into our convention:
39
+ * "feature/DEV-3549-slug" — uppercase team key with a configurable prefix.
40
+ */
41
+ function toBranchName(linearBranchName, prefix = "feature/") {
42
+ // Linear branch names look like "dev-123-some-slug"
43
+ // We need to uppercase the team key: "DEV-123-some-slug"
44
+ const match = linearBranchName.match(LINEAR_BRANCH_REGEX);
45
+ if (!match) {
46
+ return `${prefix}${linearBranchName}`;
47
+ }
48
+ const [, teamKey, number, slug] = match;
49
+ return `${prefix}${teamKey.toUpperCase()}-${number}-${slug}`;
50
+ }
51
+ /**
52
+ * Check out a new git branch. Warns and skips if not in a git repo.
53
+ * Throws if the branch already exists.
54
+ */
55
+ function gitCheckoutBranch(branchName) {
56
+ try {
57
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
58
+ stdio: "pipe",
59
+ });
60
+ }
61
+ catch {
62
+ outputWarning("Not inside a git repository — skipping branch checkout.");
63
+ return;
64
+ }
65
+ execFileSync("git", ["checkout", "-b", branchName], { stdio: "pipe" });
66
+ }
67
+ /**
68
+ * Read description from a file path or stdin ("-").
69
+ * Avoids shell escaping issues when descriptions contain special characters.
70
+ */
71
+ function readDescriptionFile(filePath) {
72
+ if (filePath === "-") {
73
+ return fs.readFileSync(0, "utf8").trim();
74
+ }
75
+ if (!fs.existsSync(filePath)) {
76
+ throw new Error(`Description file not found: ${filePath}`);
77
+ }
78
+ return fs.readFileSync(filePath, "utf8").trim();
79
+ }
80
+ /**
81
+ * Resolve the description from --description, --description-file, or both.
82
+ * --description-file takes precedence when both are provided.
83
+ */
84
+ function resolveDescription(options) {
85
+ if (options.descriptionFile) {
86
+ return readDescriptionFile(options.descriptionFile);
87
+ }
88
+ return options.description;
89
+ }
90
+ function isImageFile(filename) {
91
+ const ext = filename.lastIndexOf(".");
92
+ return ext !== -1 && IMAGE_EXTENSIONS.has(filename.slice(ext).toLowerCase());
93
+ }
94
+ function transformIssueRelation(rel) {
95
+ const issue = rel.issue;
96
+ const relatedIssue = rel.relatedIssue;
97
+ return {
98
+ id: rel.id,
99
+ type: rel.type,
100
+ issue: {
101
+ id: issue.id,
102
+ identifier: issue.identifier,
103
+ title: issue.title,
104
+ },
105
+ relatedIssue: {
106
+ id: relatedIssue.id,
107
+ identifier: relatedIssue.identifier,
108
+ title: relatedIssue.title,
109
+ },
110
+ };
111
+ }
112
+ async function createRelations(sourceId, options, graphQLService, linearService) {
113
+ const relations = [];
114
+ const relationSpecs = [
115
+ { option: "relatedTo", type: "related" },
116
+ { option: "blocks", type: "blocks" },
117
+ { option: "blockedBy", type: "blocks", reverse: true },
118
+ { option: "duplicateOf", type: "duplicate" },
119
+ ];
120
+ for (const spec of relationSpecs) {
121
+ const value = options[spec.option];
122
+ if (!value) {
123
+ continue;
124
+ }
125
+ for (const id of splitList(value)) {
126
+ const targetId = await linearService.resolveIssueId(id);
127
+ const rel = await graphQLService.rawRequest(ISSUE_RELATION_CREATE_MUTATION, {
128
+ input: {
129
+ issueId: spec.reverse ? targetId : sourceId,
130
+ relatedIssueId: spec.reverse ? sourceId : targetId,
131
+ type: spec.type,
132
+ },
133
+ });
134
+ const create = rel.issueRelationCreate;
135
+ const issueRelation = create?.issueRelation;
136
+ relations.push(transformIssueRelation(issueRelation));
137
+ }
138
+ }
139
+ return relations;
140
+ }
141
+ /**
142
+ * Pre-process a description before sending to Linear:
143
+ * 1. Extract all issue references from the description.
144
+ * 2. Validate them (drop those that don't resolve in this workspace — handles "ISO-1424"-style false positives).
145
+ * 3. Wrap valid identifiers as markdown links — skipping any already inside a link, code block, or backtick span.
146
+ *
147
+ * Returns the rewritten description plus the validated map for downstream auto-linking.
148
+ *
149
+ * No-ops (returns the original description with an empty map) when:
150
+ * - description is empty/undefined
151
+ * - the user passed `--no-auto-link`
152
+ */
153
+ async function prepareAutoLinkedDescription(description, options, selfIdentifier, linearService, graphQLService) {
154
+ if (!description || options.autoLink === false) {
155
+ return { description, preResolved: new Map(), rewritten: false };
156
+ }
157
+ const refs = extractIssueReferences(description, selfIdentifier);
158
+ if (refs.length === 0) {
159
+ return { description, preResolved: new Map(), rewritten: false };
160
+ }
161
+ const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
162
+ if (preResolved.size === 0) {
163
+ return { description, preResolved, rewritten: false };
164
+ }
165
+ const validIds = new Set(preResolved.keys());
166
+ const urlKey = await getWorkspaceUrlKey(graphQLService);
167
+ const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
168
+ return {
169
+ description: wrapped,
170
+ preResolved,
171
+ rewritten: wrapped !== description,
172
+ };
173
+ }
174
+ /**
175
+ * Run auto-linking for issue references found in a description, unless the user opted out
176
+ * with --no-auto-link or no description was provided. Returns undefined when nothing was linked
177
+ * and nothing was skipped/failed (so callers can omit the field from JSON output).
178
+ */
179
+ async function maybeAutoLink(input) {
180
+ const { issueId, identifier, description, options, graphQLService, linearService, preResolved, } = input;
181
+ if (options.autoLink === false) {
182
+ return;
183
+ }
184
+ if (!description) {
185
+ return;
186
+ }
187
+ const result = await autoLinkReferences({
188
+ issueId,
189
+ identifier,
190
+ description,
191
+ graphQLService,
192
+ linearService,
193
+ preResolved,
194
+ });
195
+ if (result.linked.length === 0 &&
196
+ result.skipped.length === 0 &&
197
+ result.failed.length === 0) {
198
+ return;
199
+ }
200
+ return result;
201
+ }
202
+ function validateUpdateOptions(options) {
203
+ if (options.parentTicket && options.clearParentTicket) {
204
+ throw new Error("Cannot use --parent-ticket and --clear-parent-ticket together");
205
+ }
206
+ if (options.projectMilestone && options.clearProjectMilestone) {
207
+ throw new Error("Cannot use --project-milestone and --clear-project-milestone together");
208
+ }
209
+ if (options.cycle && options.clearCycle) {
210
+ throw new Error("Cannot use --cycle and --clear-cycle together");
211
+ }
212
+ if (options.labelBy && !options.labels) {
213
+ throw new Error("--label-by requires --labels to be specified");
214
+ }
215
+ if (options.clearLabels && options.labels) {
216
+ throw new Error("--clear-labels cannot be used with --labels");
217
+ }
218
+ if (options.clearLabels && options.labelBy) {
219
+ throw new Error("--clear-labels cannot be used with --label-by");
220
+ }
221
+ if (options.labelBy && !["adding", "overwriting"].includes(options.labelBy)) {
222
+ throw new Error("--label-by must be either 'adding' or 'overwriting'");
223
+ }
224
+ if (options.description && options.appendDescription) {
225
+ throw new Error("Cannot use --description and --append-description together");
226
+ }
227
+ }
228
+ function buildUpdateArgs(issueId, options, assigneeId) {
229
+ let labelIds;
230
+ if (options.clearLabels) {
231
+ labelIds = [];
232
+ }
233
+ else if (options.labels) {
234
+ labelIds = splitList(options.labels);
235
+ }
236
+ return {
237
+ id: issueId,
238
+ title: options.title,
239
+ description: options.description,
240
+ statusId: options.status,
241
+ priority: options.priority ? validatePriority(options.priority) : undefined,
242
+ assigneeId,
243
+ projectId: options.project,
244
+ labelIds,
245
+ parentId: options.parentTicket || (options.clearParentTicket ? null : undefined),
246
+ milestoneId: options.projectMilestone ||
247
+ (options.clearProjectMilestone ? null : undefined),
248
+ cycleId: options.cycle || (options.clearCycle ? null : undefined),
249
+ dueDate: options.dueDate || (options.clearDueDate ? null : undefined),
250
+ };
251
+ }
252
+ function outputIssues(issues, format, fields, meta) {
253
+ const fieldList = fields ? splitList(fields) : undefined;
254
+ if (format === "table") {
255
+ logger.info(formatTable(issues, fieldList));
256
+ logger.info(`\n${issues.length} issues`);
257
+ }
258
+ else if (format === "md" || format === "markdown") {
259
+ logger.info(formatMarkdown(issues, fieldList));
260
+ }
261
+ else if (format === "csv") {
262
+ logger.info(formatCsv(issues, fieldList));
263
+ }
264
+ else {
265
+ outputSuccess({ data: issues, meta: { count: issues.length, ...meta } });
266
+ }
267
+ }
268
+ function sortIssues(issues, sort) {
269
+ if (!sort) {
270
+ return issues;
271
+ }
272
+ const sorted = [...issues];
273
+ switch (sort) {
274
+ case "priority":
275
+ sorted.sort((a, b) => {
276
+ const pa = a.priority === 0 ? 5 : a.priority;
277
+ const pb = b.priority === 0 ? 5 : b.priority;
278
+ return pa - pb;
279
+ });
280
+ break;
281
+ case "status":
282
+ sorted.sort((a, b) => (a.state?.name ?? "").localeCompare(b.state?.name ?? ""));
283
+ break;
284
+ case "created":
285
+ sorted.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
286
+ break;
287
+ case "updated":
288
+ sorted.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
289
+ break;
290
+ default:
291
+ break;
292
+ }
293
+ return sorted;
294
+ }
295
+ async function handleListIssues(options, command) {
296
+ if (options.label && !options.labels) {
297
+ options.labels = options.label;
298
+ }
299
+ const rootOpts = command.parent.parent.opts();
300
+ const graphQLService = createGraphQLService(rootOpts);
301
+ const linearService = createLinearService(rootOpts);
302
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
303
+ const hasFilters = options.team ||
304
+ options.labels ||
305
+ options.status ||
306
+ options.assignee ||
307
+ options.project ||
308
+ options.project === false ||
309
+ options.priority;
310
+ if (hasFilters) {
311
+ const searchArgs = {
312
+ teamId: options.team ? resolveTeam(options.team) : undefined,
313
+ assigneeId: options.assignee
314
+ ? await resolveAssignee(options.assignee, rootOpts)
315
+ : undefined,
316
+ projectId: options.project || undefined,
317
+ noProject: options.project === false,
318
+ labelNames: options.labels ? splitList(options.labels) : undefined,
319
+ status: options.status ? splitList(options.status) : undefined,
320
+ priority: options.priority
321
+ ? parsePriorityFilter(options.priority)
322
+ : undefined,
323
+ orderBy: options.sort === "created" ? "createdAt" : "updatedAt",
324
+ limit: Number.parseInt(options.limit, 10),
325
+ };
326
+ const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
327
+ outputIssues(result, options.format, options.fields, {
328
+ team: options.team,
329
+ });
330
+ }
331
+ else {
332
+ const result = sortIssues(await issuesService.getIssues(Number.parseInt(options.limit, 10)), options.sort);
333
+ outputIssues(result, options.format, options.fields, {});
334
+ }
335
+ }
336
+ async function handleSearchIssues(query, options, command) {
337
+ if (options.label && !options.labels) {
338
+ options.labels = options.label;
339
+ }
340
+ const rootOpts = command.parent.parent.opts();
341
+ const graphQLService = createGraphQLService(rootOpts);
342
+ const linearService = createLinearService(rootOpts);
343
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
344
+ const searchArgs = {
345
+ query,
346
+ teamId: options.team ? resolveTeam(options.team) : undefined,
347
+ assigneeId: options.assignee
348
+ ? await resolveAssignee(options.assignee, rootOpts)
349
+ : undefined,
350
+ projectId: options.project || undefined,
351
+ noProject: options.project === false,
352
+ status: options.status ? splitList(options.status) : undefined,
353
+ labelNames: options.labels ? splitList(options.labels) : undefined,
354
+ priority: options.priority
355
+ ? parsePriorityFilter(options.priority)
356
+ : undefined,
357
+ limit: Number.parseInt(options.limit, 10),
358
+ };
359
+ const result = sortIssues(await issuesService.searchIssues(searchArgs), options.sort);
360
+ outputIssues(result, options.format, options.fields, { query });
361
+ }
362
+ async function resolveCreateInputs(title, options, rootOpts) {
363
+ const config = loadConfig();
364
+ enforceTerms([title, options.description], { strict: options.strict });
365
+ // --- Validation (labels, description, assignee, project, title) ---
366
+ // Controlled by config.validation.enabled (default: true).
367
+ // Bypassed by --skip-validation flag.
368
+ if (!options.skipValidation) {
369
+ const rawLabels = options.labels ? splitList(options.labels) : null;
370
+ const description = resolveDescription(options);
371
+ const validationResult = validateIssueCreation({
372
+ labels: rawLabels,
373
+ description: description || undefined,
374
+ title,
375
+ assignee: options.assignee,
376
+ project: options.project,
377
+ });
378
+ // Apply normalized labels back so resolution uses the canonical names
379
+ if (validationResult.normalizedLabels) {
380
+ options.labels = validationResult.normalizedLabels.join(",");
381
+ }
382
+ enforceValidation(validationResult);
383
+ }
384
+ if (!options.priority) {
385
+ outputWarning("Creating issue without --priority. Consider specifying it for better triage.", "missing_fields");
386
+ }
387
+ const teamInput = options.team || config.defaultTeam;
388
+ const teamId = resolveTeam(teamInput);
389
+ const assigneeId = options.assignee
390
+ ? await resolveAssignee(options.assignee, rootOpts)
391
+ : undefined;
392
+ let labelIds = [];
393
+ if (options.labels) {
394
+ labelIds = resolveLabels(splitList(options.labels), teamInput);
395
+ }
396
+ if (options.claude) {
397
+ const claudeId = config.labels.workspace.claude;
398
+ if (claudeId && !labelIds.includes(claudeId)) {
399
+ labelIds.push(claudeId);
400
+ }
401
+ }
402
+ const status = resolveDefaultStatus({
403
+ explicitStatus: options.status,
404
+ hasAssignee: !!assigneeId,
405
+ hasProject: !!options.project,
406
+ });
407
+ let subscriberIds;
408
+ if (options.subscriber) {
409
+ subscriberIds = splitList(options.subscriber).map((s) => resolveMember(s));
410
+ }
411
+ return { teamInput, teamId, assigneeId, labelIds, status, subscriberIds };
412
+ }
413
+ async function uploadAttachmentsIfNeeded(options, rootOpts) {
414
+ if (!options.attachment) {
415
+ return [];
416
+ }
417
+ const paths = Array.isArray(options.attachment)
418
+ ? options.attachment
419
+ : [options.attachment];
420
+ const apiToken = getApiToken(rootOpts);
421
+ const fileService = new FileService(apiToken);
422
+ const results = [];
423
+ for (const filePath of paths) {
424
+ const result = await fileService.uploadFile(filePath);
425
+ if (!result.success) {
426
+ throw new Error(`Attachment upload failed for ${filePath}: ${result.error}`);
427
+ }
428
+ results.push(result);
429
+ }
430
+ return results;
431
+ }
432
+ function buildDescriptionWithAttachments(baseDescription, uploadResults) {
433
+ let description = baseDescription;
434
+ for (const uploadResult of uploadResults) {
435
+ if (isImageFile(uploadResult.filename)) {
436
+ const imageMarkdown = `\n\n![${uploadResult.filename}](${uploadResult.assetUrl})`;
437
+ description = description
438
+ ? description + imageMarkdown
439
+ : imageMarkdown.trimStart();
440
+ }
441
+ }
442
+ return description;
443
+ }
444
+ 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);
447
+ const uploadResults = await uploadAttachmentsIfNeeded(options, rootOpts);
448
+ const description = buildDescriptionWithAttachments(resolveDescription(options) || "", uploadResults);
449
+ const graphQLService = createGraphQLService(rootOpts);
450
+ const linearService = createLinearService(rootOpts);
451
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
452
+ // Wrap valid issue identifiers as markdown links before creating, so the description
453
+ // saved on Linear has clickable refs from the start. Self-reference can't apply here
454
+ // because the issue doesn't exist yet — pass undefined.
455
+ const prepared = await prepareAutoLinkedDescription(description || undefined, options, undefined, linearService, graphQLService);
456
+ const result = await issuesService.createIssue({
457
+ title,
458
+ teamId,
459
+ teamInput,
460
+ description: prepared.description,
461
+ assigneeId,
462
+ priority: options.priority ? validatePriority(options.priority) : undefined,
463
+ projectId: options.project,
464
+ statusId: status,
465
+ labelIds: labelIds.length > 0 ? labelIds : undefined,
466
+ parentId: options.parentTicket,
467
+ milestoneId: options.projectMilestone,
468
+ cycleId: options.cycle,
469
+ subscriberIds,
470
+ dueDate: options.dueDate,
471
+ });
472
+ const relations = await createRelations(result.id, options, graphQLService, linearService);
473
+ // Pass the ORIGINAL description (pre-wrap) so the extractor's prose-keyword inference
474
+ // ("blocked by", "duplicates", etc.) sees `keyword DEV-100` instead of `keyword [DEV-100](url)`.
475
+ // After wrapping, the keyword regex's trailing-whitespace anchor fails to match the inserted `[`.
476
+ const autoLinked = await maybeAutoLink({
477
+ issueId: result.id,
478
+ identifier: result.identifier,
479
+ description: description || undefined,
480
+ options,
481
+ preResolved: prepared.preResolved,
482
+ graphQLService,
483
+ linearService,
484
+ });
485
+ // Images are already embedded inline as markdown in the description,
486
+ // so only create separate attachment records for non-image files.
487
+ const attachments = [];
488
+ if (uploadResults.length > 0) {
489
+ const attachmentsService = createGraphQLAttachmentsService(rootOpts);
490
+ for (const uploadResult of uploadResults) {
491
+ if (!isImageFile(uploadResult.filename)) {
492
+ const attachment = await attachmentsService.createAttachment({
493
+ issueId: result.id,
494
+ url: uploadResult.assetUrl,
495
+ title: uploadResult.filename,
496
+ });
497
+ attachments.push(attachment);
498
+ }
499
+ }
500
+ }
501
+ let branch;
502
+ if (options.checkout && result.branchName) {
503
+ branch = toBranchName(result.branchName);
504
+ gitCheckoutBranch(branch);
505
+ }
506
+ const output = {
507
+ ...result,
508
+ ...(branch ? { branch } : {}),
509
+ ...(relations.length > 0 ? { relations } : {}),
510
+ ...(autoLinked ? { autoLinked } : {}),
511
+ ...(attachments.length === 1
512
+ ? { attachment: attachments[0] }
513
+ : attachments.length > 1
514
+ ? { attachments }
515
+ : {}),
516
+ };
517
+ outputSuccess(output);
518
+ }
519
+ async function handleHistoryIssue(issueId, _options, command) {
520
+ const rootOpts = command.parent.parent.opts();
521
+ const graphQLService = createGraphQLService(rootOpts);
522
+ const linearService = createLinearService(rootOpts);
523
+ const resolvedId = await linearService.resolveIssueId(issueId);
524
+ const result = await graphQLService.rawRequest(GET_ISSUE_STATE_HISTORY_QUERY, {
525
+ id: resolvedId,
526
+ });
527
+ if (!result.issue) {
528
+ throw new Error(`Issue "${issueId}" not found`);
529
+ }
530
+ const issue = result.issue;
531
+ const stateHistory = issue.stateHistory;
532
+ const spans = (stateHistory?.nodes ?? []).map((span) => ({
533
+ state: {
534
+ id: span.state.id,
535
+ name: span.state.name,
536
+ type: span.state.type,
537
+ },
538
+ startedAt: span.startedAt,
539
+ endedAt: span.endedAt || undefined,
540
+ }));
541
+ outputSuccess({
542
+ id: issue.id,
543
+ identifier: issue.identifier,
544
+ title: issue.title,
545
+ stateHistory: spans,
546
+ meta: { count: spans.length },
547
+ });
548
+ }
549
+ async function handleRelateIssue(issueId, options, command) {
550
+ const rootOpts = command.parent.parent.opts();
551
+ const graphQLService = createGraphQLService(rootOpts);
552
+ const linearService = createLinearService(rootOpts);
553
+ const sourceId = await linearService.resolveIssueId(issueId);
554
+ const relations = await createRelations(sourceId, options, graphQLService, linearService);
555
+ if (relations.length === 0) {
556
+ throw new Error("Specify at least one of: --related-to, --blocks, --blocked-by, --duplicate-of");
557
+ }
558
+ outputSuccess({
559
+ data: relations,
560
+ meta: { count: relations.length, source: issueId },
561
+ });
562
+ }
563
+ /**
564
+ * Invert relation type for incoming (inverse) relations so the output
565
+ * reads naturally from the perspective of the queried issue.
566
+ * e.g. if DEV-100 "blocks" DEV-200, and we query DEV-200,
567
+ * the inverse relation type is "blocks" but direction is incoming → "blockedBy".
568
+ */
569
+ function normalizeInverseType(type) {
570
+ if (type === "blocks") {
571
+ return "blockedBy";
572
+ }
573
+ return type;
574
+ }
575
+ function buildRelatedIssueSummary(peer) {
576
+ const state = peer.state;
577
+ const assignee = peer.assignee;
578
+ const team = peer.team;
579
+ return {
580
+ id: peer.id,
581
+ identifier: peer.identifier,
582
+ title: peer.title,
583
+ ...(state
584
+ ? { state: { id: state.id, name: state.name } }
585
+ : {}),
586
+ ...(peer.priority == null ? {} : { priority: peer.priority }),
587
+ ...(assignee
588
+ ? {
589
+ assignee: {
590
+ id: assignee.id,
591
+ name: assignee.name,
592
+ },
593
+ }
594
+ : {}),
595
+ ...(team
596
+ ? {
597
+ team: {
598
+ id: team.id,
599
+ key: team.key,
600
+ name: team.name,
601
+ },
602
+ }
603
+ : {}),
604
+ };
605
+ }
606
+ function buildRelationEntries(nodes, peerKey, direction, normalizeType) {
607
+ const entries = [];
608
+ for (const rel of nodes ?? []) {
609
+ const peer = rel[peerKey];
610
+ if (!peer) {
611
+ continue;
612
+ }
613
+ entries.push({
614
+ id: rel.id,
615
+ type: normalizeType(rel.type),
616
+ direction,
617
+ issue: buildRelatedIssueSummary(peer),
618
+ });
619
+ }
620
+ return entries;
621
+ }
622
+ async function handleRelatedIssues(issueId, _options, command) {
623
+ const rootOpts = command.parent.parent.opts();
624
+ const graphQLService = createGraphQLService(rootOpts);
625
+ const linearService = createLinearService(rootOpts);
626
+ const resolvedId = await linearService.resolveIssueId(issueId);
627
+ const result = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
628
+ id: resolvedId,
629
+ });
630
+ if (!result.issue) {
631
+ throw new Error(`Issue "${issueId}" not found`);
632
+ }
633
+ const issue = result.issue;
634
+ const relations = issue.relations;
635
+ const inverseRelations = issue.inverseRelations;
636
+ const entries = [
637
+ ...buildRelationEntries(relations?.nodes, "relatedIssue", "outgoing", (raw) => raw),
638
+ ...buildRelationEntries(inverseRelations?.nodes, "issue", "incoming", normalizeInverseType),
639
+ ];
640
+ outputSuccess({
641
+ id: issue.id,
642
+ identifier: issue.identifier,
643
+ title: issue.title,
644
+ data: entries,
645
+ meta: { count: entries.length },
646
+ });
647
+ }
648
+ async function fetchCommentBodies(issueUuid, graphQLService) {
649
+ const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
650
+ issueId: issueUuid,
651
+ first: 250,
652
+ });
653
+ const issue = result.issue;
654
+ const comments = issue?.comments;
655
+ const nodes = comments?.nodes ?? [];
656
+ return nodes
657
+ .map((c) => c.body)
658
+ .filter((b) => typeof b === "string" && b.length > 0);
659
+ }
660
+ async function linkReferencesForIssue(args) {
661
+ const comments = args.includeComments
662
+ ? await fetchCommentBodies(args.issueUuid, args.graphQLService)
663
+ : undefined;
664
+ return autoLinkReferences({
665
+ issueId: args.issueUuid,
666
+ identifier: args.identifier,
667
+ description: args.description,
668
+ comments,
669
+ dryRun: args.dryRun,
670
+ graphQLService: args.graphQLService,
671
+ linearService: args.linearService,
672
+ preResolved: args.preResolved,
673
+ });
674
+ }
675
+ /**
676
+ * Prepare a description rewrite: validate refs, wrap valid IDs as markdown links,
677
+ * and return the rewritten text (or undefined if nothing would change).
678
+ */
679
+ async function prepareDescriptionRewrite(description, selfIdentifier, linearService, graphQLService) {
680
+ if (!description) {
681
+ return { preResolved: undefined, wrapped: undefined };
682
+ }
683
+ const refs = extractIssueReferences(description, selfIdentifier);
684
+ if (refs.length === 0) {
685
+ return { preResolved: undefined, wrapped: undefined };
686
+ }
687
+ const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
688
+ if (preResolved.size === 0) {
689
+ return { preResolved, wrapped: undefined };
690
+ }
691
+ const validIds = new Set(preResolved.keys());
692
+ const urlKey = await getWorkspaceUrlKey(graphQLService);
693
+ const wrapped = wrapIssueReferencesAsLinks(description, validIds, urlKey);
694
+ return {
695
+ preResolved,
696
+ wrapped: wrapped === description ? undefined : wrapped,
697
+ };
698
+ }
699
+ async function pushDescriptionUpdate(issueUuid, description, graphQLService) {
700
+ const updateResult = await graphQLService.rawRequest(UPDATE_ISSUE_MUTATION, {
701
+ id: issueUuid,
702
+ input: { description },
703
+ });
704
+ const mutation = updateResult.issueUpdate;
705
+ if (!mutation?.success) {
706
+ throw new Error("Failed to rewrite description");
707
+ }
708
+ }
709
+ async function handleLinkReferencesSingle(issueId, options, graphQLService, linearService) {
710
+ const resolvedId = await linearService.resolveIssueId(issueId);
711
+ // GET_ISSUE_RELATIONS_QUERY also returns description, so this single call gives us everything
712
+ // we need for the description-only path. Comments are fetched separately on demand.
713
+ const issueResult = await graphQLService.rawRequest(GET_ISSUE_RELATIONS_QUERY, {
714
+ id: resolvedId,
715
+ });
716
+ if (!issueResult.issue) {
717
+ throw new Error(`Issue "${issueId}" not found`);
718
+ }
719
+ const issue = issueResult.issue;
720
+ const identifier = issue.identifier;
721
+ const description = issue.description ?? "";
722
+ const dryRun = Boolean(options.dryRun);
723
+ const includeComments = Boolean(options.includeComments);
724
+ const rewriteDescription = Boolean(options.rewriteDescription);
725
+ // If rewriting, validate refs once up front so we can both wrap the description and
726
+ // pass the resolved map down to autoLink — avoids a second resolve round trip.
727
+ const rewrite = rewriteDescription
728
+ ? await prepareDescriptionRewrite(description, identifier, linearService, graphQLService)
729
+ : { preResolved: undefined, wrapped: undefined };
730
+ const autoLinked = await linkReferencesForIssue({
731
+ issueUuid: resolvedId,
732
+ identifier,
733
+ description,
734
+ includeComments,
735
+ dryRun,
736
+ graphQLService,
737
+ linearService,
738
+ preResolved: rewrite.preResolved,
739
+ });
740
+ let descriptionRewritten = false;
741
+ if (rewriteDescription && rewrite.wrapped && !dryRun) {
742
+ await pushDescriptionUpdate(resolvedId, rewrite.wrapped, graphQLService);
743
+ descriptionRewritten = true;
744
+ }
745
+ outputSuccess({
746
+ id: resolvedId,
747
+ identifier,
748
+ title: issue.title,
749
+ autoLinked,
750
+ ...(rewriteDescription
751
+ ? {
752
+ descriptionRewritten,
753
+ ...(dryRun && rewrite.wrapped
754
+ ? { descriptionPreview: rewrite.wrapped }
755
+ : {}),
756
+ }
757
+ : {}),
758
+ meta: {
759
+ dryRun,
760
+ includeComments,
761
+ ...(rewriteDescription ? { rewriteDescription: true } : {}),
762
+ },
763
+ });
764
+ }
765
+ async function handleLinkReferencesBatch(teamInput, options, graphQLService, linearService) {
766
+ const teamId = resolveTeam(teamInput);
767
+ const limit = options.limit
768
+ ? Number.parseInt(options.limit, 10)
769
+ : 100;
770
+ if (!Number.isFinite(limit) || limit <= 0) {
771
+ throw new Error(`--limit must be a positive integer, got: ${options.limit}`);
772
+ }
773
+ const dryRun = Boolean(options.dryRun);
774
+ const includeComments = Boolean(options.includeComments);
775
+ const scan = await graphQLService.rawRequest(SCAN_ISSUES_QUERY, {
776
+ filter: { team: { id: { eq: teamId } } },
777
+ first: limit,
778
+ });
779
+ const issues = scan.issues?.nodes;
780
+ const nodes = issues ?? [];
781
+ const entries = [];
782
+ for (const node of nodes) {
783
+ const issueUuid = node.id;
784
+ const identifier = node.identifier;
785
+ const description = node.description ?? "";
786
+ const autoLinked = await linkReferencesForIssue({
787
+ issueUuid,
788
+ identifier,
789
+ description,
790
+ includeComments,
791
+ dryRun,
792
+ graphQLService,
793
+ linearService,
794
+ });
795
+ entries.push({ identifier, autoLinked });
796
+ }
797
+ // Summary counts across the batch
798
+ const totalLinked = entries.reduce((n, e) => n + e.autoLinked.linked.length, 0);
799
+ const totalSkipped = entries.reduce((n, e) => n + e.autoLinked.skipped.length, 0);
800
+ const totalFailed = entries.reduce((n, e) => n + e.autoLinked.failed.length, 0);
801
+ outputSuccess({
802
+ data: entries,
803
+ meta: {
804
+ count: entries.length,
805
+ team: teamInput,
806
+ dryRun,
807
+ includeComments,
808
+ totals: {
809
+ linked: totalLinked,
810
+ skipped: totalSkipped,
811
+ failed: totalFailed,
812
+ },
813
+ },
814
+ });
815
+ }
816
+ function handleLinkReferencesIssue(issueIdOrTeamFlag, options, command) {
817
+ const rootOpts = command.parent.parent.opts();
818
+ const graphQLService = createGraphQLService(rootOpts);
819
+ const linearService = createLinearService(rootOpts);
820
+ if (options.team) {
821
+ if (issueIdOrTeamFlag) {
822
+ throw new Error("Provide either an issueId positional argument OR --team, not both. With --team, omit the issueId.");
823
+ }
824
+ if (options.rewriteDescription) {
825
+ 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.");
826
+ }
827
+ return handleLinkReferencesBatch(options.team, options, graphQLService, linearService);
828
+ }
829
+ if (!issueIdOrTeamFlag) {
830
+ throw new Error("issueId is required (or use --team <key> to scan a whole team)");
831
+ }
832
+ return handleLinkReferencesSingle(issueIdOrTeamFlag, options, graphQLService, linearService);
833
+ }
834
+ async function handleReadIssue(issueIds, _options, command) {
835
+ const rootOpts = command.parent.parent.opts();
836
+ const graphQLService = createGraphQLService(rootOpts);
837
+ const linearService = createLinearService(rootOpts);
838
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
839
+ const apiToken = getApiToken(rootOpts);
840
+ if (issueIds.length === 1) {
841
+ const issue = await issuesService.getIssueById(issueIds[0]);
842
+ const resolved = await downloadLinearUploads(issue, apiToken);
843
+ outputSuccess(resolved);
844
+ }
845
+ else {
846
+ const results = await Promise.all(issueIds.map(async (id) => {
847
+ const issue = await issuesService.getIssueById(id);
848
+ return downloadLinearUploads(issue, apiToken);
849
+ }));
850
+ outputSuccess(results);
851
+ }
852
+ }
853
+ async function handleUpdateIssue(issueId, options, command) {
854
+ // Normalize aliases
855
+ if (options.state && !options.status) {
856
+ options.status = options.state;
857
+ }
858
+ if (options.label && !options.labels) {
859
+ options.labels = options.label;
860
+ }
861
+ // Resolve --description-file before validation
862
+ if (options.descriptionFile) {
863
+ options.description = readDescriptionFile(options.descriptionFile);
864
+ }
865
+ validateUpdateOptions(options);
866
+ const rootOpts = command.parent.parent.opts();
867
+ const graphQLService = createGraphQLService(rootOpts);
868
+ const linearService = createLinearService(rootOpts);
869
+ if (options.appendDescription) {
870
+ const resolved = await linearService.resolveIssueId(issueId);
871
+ const current = await graphQLService.rawRequest("query($id: String!) { issue(id: $id) { description } }", { id: resolved });
872
+ const issue = current.issue;
873
+ const existing = issue?.description ?? "";
874
+ options.description = `${existing}\n${options.appendDescription}`;
875
+ }
876
+ if (options.title || options.description) {
877
+ enforceTerms([options.title, options.description], {
878
+ strict: options.strict,
879
+ });
880
+ }
881
+ // Save the original (pre-wrap) description so we can pass it to maybeAutoLink later.
882
+ // The wrapped form breaks prose-keyword inference because the inserted `[` defeats the
883
+ // trailing-whitespace anchor in patterns like /\bblocked by\s*$/.
884
+ const originalDescription = options.description;
885
+ // Wrap valid refs as markdown links before sending the update. Idempotent against
886
+ // already-wrapped refs (the wrapper skips text inside existing markdown link syntax).
887
+ const prepared = options.description
888
+ ? await prepareAutoLinkedDescription(options.description, options, undefined, linearService, graphQLService)
889
+ : {
890
+ description: undefined,
891
+ preResolved: new Map(),
892
+ rewritten: false,
893
+ };
894
+ if (prepared.description !== undefined) {
895
+ options.description = prepared.description;
896
+ }
897
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
898
+ const assigneeId = options.assignee
899
+ ? await resolveAssignee(options.assignee, rootOpts)
900
+ : undefined;
901
+ const updateArgs = buildUpdateArgs(issueId, options, assigneeId);
902
+ const result = await issuesService.updateIssue(updateArgs, options.labelBy || "adding");
903
+ const autoLinked = originalDescription
904
+ ? await maybeAutoLink({
905
+ issueId: result.id,
906
+ identifier: result.identifier,
907
+ description: originalDescription,
908
+ options,
909
+ preResolved: prepared.preResolved,
910
+ graphQLService,
911
+ linearService,
912
+ })
913
+ : undefined;
914
+ outputSuccess(autoLinked ? { ...result, autoLinked } : result);
915
+ }
916
+ async function handleRetrolink(options, command) {
917
+ // 1. Read current branch
918
+ let currentBranch;
919
+ try {
920
+ currentBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
921
+ stdio: "pipe",
922
+ })
923
+ .toString()
924
+ .trim();
925
+ }
926
+ catch {
927
+ throw new Error("Not inside a git repository.");
928
+ }
929
+ if (currentBranch === "main" || currentBranch === "master") {
930
+ throw new Error(`Cannot retrolink the '${currentBranch}' branch.`);
931
+ }
932
+ // 2. Read commit messages on this branch vs base
933
+ const base = options.base || "main";
934
+ let commitLog;
935
+ try {
936
+ commitLog = execFileSync("git", ["log", `${base}..HEAD`, "--oneline"], {
937
+ stdio: "pipe",
938
+ })
939
+ .toString()
940
+ .trim();
941
+ }
942
+ catch {
943
+ commitLog = "";
944
+ }
945
+ const commits = commitLog
946
+ ? commitLog
947
+ .split("\n")
948
+ .map((line) => line.replace(COMMIT_HASH_PREFIX_REGEX, ""))
949
+ : [];
950
+ // 3. Build title and description
951
+ const title = options.title || (commits.length > 0 ? commits[0] : currentBranch);
952
+ const description = commits.length > 0
953
+ ? commits.map((msg) => `- ${msg}`).join("\n")
954
+ : `Retrolinked from branch: ${currentBranch}`;
955
+ const rootOpts = command.parent.parent.opts();
956
+ const graphQLService = createGraphQLService(rootOpts);
957
+ const linearService = createLinearService(rootOpts);
958
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
959
+ let result;
960
+ if (options.issue) {
961
+ // Link to existing issue — fetch it and rename branch
962
+ const issueId = options.issue;
963
+ result = await issuesService.getIssueById(issueId);
964
+ }
965
+ else {
966
+ // Create new issue
967
+ const config = loadConfig();
968
+ const teamInput = options.team || config.defaultTeam;
969
+ if (!teamInput) {
970
+ throw new Error("Team is required. Use --team or set a defaultTeam in config.");
971
+ }
972
+ const teamId = resolveTeam(teamInput);
973
+ result = await issuesService.createIssue({
974
+ title,
975
+ teamId,
976
+ description,
977
+ });
978
+ }
979
+ // Rename branch to match issue
980
+ const oldBranch = currentBranch;
981
+ let newBranch;
982
+ if (result.branchName) {
983
+ newBranch = toBranchName(result.branchName);
984
+ execFileSync("git", ["branch", "-m", oldBranch, newBranch], {
985
+ stdio: "pipe",
986
+ });
987
+ }
988
+ outputSuccess({
989
+ ...result,
990
+ oldBranch,
991
+ ...(newBranch ? { branch: newBranch } : {}),
992
+ });
993
+ }
994
+ export function setupIssuesCommands(program) {
995
+ const issues = program
996
+ .command("issues")
997
+ .alias("issue")
998
+ .description("Issue operations");
999
+ issues.action(() => issues.help());
1000
+ issues
1001
+ .command("list")
1002
+ .description("List issues.")
1003
+ .option("-l, --limit <number>", "limit results", "25")
1004
+ .option("--team <team>", "filter by team key (EL: resolves names)")
1005
+ .option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
1006
+ .option("--project <project>", "filter by project name or ID")
1007
+ .option("--no-project", "filter issues with no project assigned")
1008
+ .option("--labels <labels>", "filter by labels (comma-separated names)")
1009
+ .option("--label <labels>", "alias for --labels")
1010
+ .option("--status <status>", "filter by status (comma-separated, e.g. Todo,Backlog)")
1011
+ .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
1012
+ .option("--sort <field>", "sort results (priority, status, created, updated)")
1013
+ .option("--format <format>", "output format (json, table, md, csv)", "json")
1014
+ .option("--fields <fields>", "columns for table/csv (comma-separated: identifier,title,status,priority,assignee,project,team,labels,updated)")
1015
+ .action(handleAsyncCommand(handleListIssues));
1016
+ issues
1017
+ .command("search <query>")
1018
+ .description("Search issues.")
1019
+ .option("--team <team>", "filter by team key, name, or ID")
1020
+ .option("--assignee <assignee>", "filter by assignee (name, alias, or ID)")
1021
+ .option("--project <project>", "filter by project name or ID")
1022
+ .option("--no-project", "filter issues with no project assigned")
1023
+ .option("--status <status>", "filter by status (comma-separated)")
1024
+ .option("--labels <labels>", "filter by labels (comma-separated names)")
1025
+ .option("--label <labels>", "alias for --labels")
1026
+ .option("--priority <priority>", "filter by priority (comma-separated: urgent,high,medium,low,none or 0-4)")
1027
+ .option("--sort <field>", "sort results (priority, status, created, updated)")
1028
+ .option("--format <format>", "output format (json, table, md, csv)", "json")
1029
+ .option("--fields <fields>", "columns for table/csv output")
1030
+ .option("-l, --limit <number>", "limit results", "10")
1031
+ .action(handleAsyncCommand(handleSearchIssues));
1032
+ issues
1033
+ .command("create [title]")
1034
+ .description("Create new issue (EL-enhanced: name/alias resolution, brand validation, status defaults).")
1035
+ .option("-t, --title <title>", "issue title (alternative to positional argument)")
1036
+ .option("-d, --description <desc>", "issue description")
1037
+ .option("--description-file <path>", "read description from file (use - for stdin)")
1038
+ .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)")
1040
+ .option("--project <project>", "add to project (name or ID)")
1041
+ .option("--team <team>", "team key or name (default: from config)")
1042
+ .option("--labels <labels>", "labels (comma-separated names, auto-resolved per team)")
1043
+ .option("--label <labels>", "alias for --labels")
1044
+ .option("--claude", "add the workspace-level 'claude' label")
1045
+ .option("--project-milestone <milestone>", "project milestone name or ID")
1046
+ .option("--cycle <cycle>", "cycle name or ID")
1047
+ .option("--status <status>", "status name or ID")
1048
+ .option("--parent-ticket <parentId>", "parent issue ID or identifier")
1049
+ .option("--subscriber <subscribers>", "subscribers (comma-separated names, aliases, or UUIDs)")
1050
+ .option("--strict", "strict brand validation (error instead of warning)")
1051
+ .option("--related-to <issues>", "related issues (comma-separated identifiers)")
1052
+ .option("--blocks <issues>", "issues this blocks (comma-separated identifiers)")
1053
+ .option("--blocked-by <issues>", "issues blocking this (comma-separated identifiers)")
1054
+ .option("--attachment <path>", "attach a file (image, PDF, etc.) to the created issue (repeatable)", (value, prev) => prev ? [...prev, value] : [value])
1055
+ .option("--due-date <date>", "due date (YYYY-MM-DD)")
1056
+ .option("--checkout", "create and checkout a git branch named after the issue")
1057
+ .option("--skip-validation", "skip all validation (labels, description, assignee, project)")
1058
+ .option("--no-auto-link", "skip auto-linking issue references found in the description")
1059
+ .action(handleAsyncCommand((titleArg, options, command) => {
1060
+ // Normalize --label alias to --labels
1061
+ if (options.label && !options.labels) {
1062
+ options.labels = options.label;
1063
+ }
1064
+ const title = options.title || titleArg;
1065
+ if (!title) {
1066
+ throw new Error("Title is required. Provide it as a positional argument or with --title.");
1067
+ }
1068
+ return handleCreateIssue(title, options, command);
1069
+ }));
1070
+ issues
1071
+ .command("read <issueId...>")
1072
+ .alias("view")
1073
+ .alias("get")
1074
+ .alias("show")
1075
+ .description("Get issue details. Accepts multiple IDs for batch retrieval.")
1076
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nMultiple IDs: el-linear issue get DEV-123 DEV-456 DEV-789")
1077
+ .action(handleAsyncCommand(handleReadIssue));
1078
+ issues
1079
+ .command("update <issueId>")
1080
+ .alias("edit")
1081
+ .alias("set")
1082
+ .description("Update an issue (EL-enhanced: name/alias resolution).")
1083
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
1084
+ .option("-t, --title <title>", "new title")
1085
+ .option("-d, --description <desc>", "new description")
1086
+ .option("--description-file <path>", "read description from file (use - for stdin)")
1087
+ .option("--append-description <text>", "append text to the existing description")
1088
+ .option("-s, --status <status>", "new status name or ID")
1089
+ .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)")
1091
+ .option("--assignee <assignee>", "new assignee (name, alias, or UUID)")
1092
+ .option("--project <project>", "new project (name or ID)")
1093
+ .option("--labels <labels>", "labels (comma-separated names or IDs)")
1094
+ .option("--label <labels>", "alias for --labels")
1095
+ .option("--label-by <mode>", "how to apply labels: 'adding' (default) or 'overwriting'")
1096
+ .option("--clear-labels", "remove all labels from issue")
1097
+ .option("--parent-ticket <parentId>", "set parent issue")
1098
+ .option("--clear-parent-ticket", "clear parent relationship")
1099
+ .option("--project-milestone <milestone>", "set project milestone")
1100
+ .option("--clear-project-milestone", "clear project milestone")
1101
+ .option("--cycle <cycle>", "set cycle")
1102
+ .option("--clear-cycle", "clear cycle")
1103
+ .option("--due-date <date>", "due date (YYYY-MM-DD)")
1104
+ .option("--clear-due-date", "clear due date")
1105
+ .option("--strict", "strict brand validation")
1106
+ .option("--no-auto-link", "skip auto-linking issue references found in the description")
1107
+ .action(handleAsyncCommand(handleUpdateIssue));
1108
+ issues
1109
+ .command("history <issueId>")
1110
+ .description("Show issue state transition history (time in each status).")
1111
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
1112
+ .action(handleAsyncCommand(handleHistoryIssue));
1113
+ issues
1114
+ .command("relate <issueId>")
1115
+ .description("Create issue relations.")
1116
+ .option("--related-to <issues>", "related issues (comma-separated)")
1117
+ .option("--blocks <issues>", "issues this blocks (comma-separated)")
1118
+ .option("--blocked-by <issues>", "issues blocking this (comma-separated)")
1119
+ .option("--duplicate-of <issue>", "mark as duplicate of another issue")
1120
+ .action(handleAsyncCommand(handleRelateIssue));
1121
+ issues
1122
+ .command("related <issueId>")
1123
+ .alias("relations")
1124
+ .description("List all relations for an issue (related, blocks, blockedBy, duplicate).")
1125
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
1126
+ .action(handleAsyncCommand(handleRelatedIssues));
1127
+ issues
1128
+ .command("link-references [issueId]")
1129
+ .description("Scan an issue's description (and optionally comments) for issue identifiers and auto-create relations for any not already linked. Default relation is 'related'; prose keywords like 'blocked by' or 'duplicates' upgrade the type.")
1130
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nSkips references that already have any relation (related, blocks, blockedBy, duplicate) to the source issue.\nKeyword detection: 'blocked by'/'depends on' → blocks, 'blocks' → blocks, 'duplicates'/'duplicate of' → duplicate, 'duplicated by' → duplicate (reversed).\nUse --team <key> to backfill an entire team instead of a single issue.\nUse --rewrite-description to also wrap bare identifiers as markdown links inside the description (single-issue only).")
1131
+ .option("--dry-run", "show what would be linked without creating relations")
1132
+ .option("--include-comments", "also scan comment bodies for references")
1133
+ .option("--team <team>", "batch mode: scan all issues in a team (omit issueId)")
1134
+ .option("--limit <number>", "max issues to scan in --team mode", "100")
1135
+ .option("--rewrite-description", "also rewrite the issue description to wrap bare identifiers as markdown links (single-issue only; not allowed with --team)")
1136
+ .action(handleAsyncCommand(handleLinkReferencesIssue));
1137
+ issues
1138
+ .command("retrolink")
1139
+ .description("Link current branch to a Linear issue. Creates a new issue by default, or links to an existing one with --issue.")
1140
+ .option("--issue <issueId>", "link to an existing issue instead of creating one (e.g. INF-459)")
1141
+ .option("--team <team>", "team key or name (required if creating, not needed with --issue)")
1142
+ .option("--title <title>", "override auto-generated title (default: first commit message)")
1143
+ .option("--base <branch>", "base branch for log comparison", "main")
1144
+ .action(handleAsyncCommand(handleRetrolink));
1145
+ }