@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,309 @@
1
+ import { resolveAssignee, resolveLabels, resolveTeam, } from "../config/resolver.js";
2
+ import { GraphQLIssuesService } from "../utils/graphql-issues-service.js";
3
+ import { createGraphQLService } from "../utils/graphql-service.js";
4
+ import { createLinearService } from "../utils/linear-service.js";
5
+ import { logger } from "../utils/logger.js";
6
+ import { handleAsyncCommand, outputSuccess, outputWarning, } from "../utils/output.js";
7
+ import { splitList } from "../utils/validators.js";
8
+ /**
9
+ * Parse the --filter string into structured search arguments.
10
+ * Format: "status:Backlog team:DEV label:Bug assignee:Alice project:Sprint12"
11
+ */
12
+ function parseFilterString(filter) {
13
+ const result = {};
14
+ const parts = filter.match(/(\w+):("[^"]+"|[^\s]+)/g);
15
+ if (!parts) {
16
+ throw new Error(`Invalid filter format: "${filter}". ` +
17
+ 'Expected key:value pairs, e.g. "status:Backlog team:DEV label:Bug"');
18
+ }
19
+ for (const part of parts) {
20
+ const colonIndex = part.indexOf(":");
21
+ const key = part.slice(0, colonIndex);
22
+ const value = part.slice(colonIndex + 1).replace(/^"|"$/g, "");
23
+ result[key] = value;
24
+ }
25
+ return result;
26
+ }
27
+ /**
28
+ * Resolve issue identifiers from --issues or --filter.
29
+ * Returns LinearIssue[] so we can show a preview.
30
+ */
31
+ async function resolveTargetIssues(options, rootOpts) {
32
+ const graphQLService = createGraphQLService(rootOpts);
33
+ const linearService = createLinearService(rootOpts);
34
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
35
+ if (options.issues) {
36
+ const ids = splitList(options.issues);
37
+ return Promise.all(ids.map((id) => issuesService.getIssueById(id)));
38
+ }
39
+ if (options.filter) {
40
+ const filters = parseFilterString(options.filter);
41
+ const searchArgs = {
42
+ teamId: filters.team ? resolveTeam(filters.team) : undefined,
43
+ assigneeId: filters.assignee
44
+ ? await resolveAssignee(filters.assignee, rootOpts)
45
+ : undefined,
46
+ projectId: filters.project || undefined,
47
+ labelNames: filters.label ? splitList(filters.label) : undefined,
48
+ status: filters.status ? splitList(filters.status) : undefined,
49
+ limit: 50,
50
+ };
51
+ return issuesService.searchIssues(searchArgs);
52
+ }
53
+ throw new Error("Specify --issues (comma-separated IDs) or --filter to select issues.");
54
+ }
55
+ /**
56
+ * Print a preview table of issues that will be affected.
57
+ * Returns false if no issues were found.
58
+ */
59
+ function printPreview(issues, action) {
60
+ if (issues.length === 0) {
61
+ logger.error("No issues matched the given criteria.");
62
+ return false;
63
+ }
64
+ logger.error(`\n${action}\n`);
65
+ logger.error(` ${"Identifier".padEnd(14)} ${"Status".padEnd(16)} Title`);
66
+ logger.error(` ${"─".repeat(14)} ${"─".repeat(16)} ${"─".repeat(40)}`);
67
+ for (const issue of issues) {
68
+ const id = issue.identifier.padEnd(14);
69
+ const status = (issue.state?.name ?? "—").padEnd(16);
70
+ const title = issue.title.length > 50 ? `${issue.title.slice(0, 47)}...` : issue.title;
71
+ logger.error(` ${id} ${status} ${title}`);
72
+ }
73
+ logger.error(`\n ${issues.length} issue${issues.length === 1 ? "" : "s"} will be affected.\n`);
74
+ return true;
75
+ }
76
+ /**
77
+ * Execute a batch operation on each issue, collecting results.
78
+ */
79
+ async function executeBatch(issues, operation) {
80
+ const results = [];
81
+ for (const issue of issues) {
82
+ try {
83
+ await operation(issue);
84
+ results.push({ identifier: issue.identifier, success: true });
85
+ }
86
+ catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ results.push({
89
+ identifier: issue.identifier,
90
+ success: false,
91
+ error: message,
92
+ });
93
+ }
94
+ }
95
+ return { results };
96
+ }
97
+ async function handleBatchAssign(options, command) {
98
+ const rootOpts = command.parent.parent.opts();
99
+ if (!options.assignee) {
100
+ throw new Error("--assignee is required.");
101
+ }
102
+ const issues = await resolveTargetIssues(options, rootOpts);
103
+ const assigneeId = await resolveAssignee(options.assignee, rootOpts);
104
+ if (!options.yes) {
105
+ const hasPreview = printPreview(issues, `Assign ${issues.length} issues to "${options.assignee}":`);
106
+ if (!hasPreview) {
107
+ return;
108
+ }
109
+ outputWarning("Dry run — pass --yes to execute.");
110
+ outputSuccess({
111
+ dryRun: true,
112
+ action: "assign",
113
+ assignee: options.assignee,
114
+ issues: issues.map((i) => i.identifier),
115
+ count: issues.length,
116
+ });
117
+ return;
118
+ }
119
+ const graphQLService = createGraphQLService(rootOpts);
120
+ const linearService = createLinearService(rootOpts);
121
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
122
+ const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, assigneeId }, "adding"));
123
+ outputSuccess({
124
+ action: "assign",
125
+ assignee: options.assignee,
126
+ results,
127
+ meta: {
128
+ total: results.length,
129
+ succeeded: results.filter((r) => r.success).length,
130
+ failed: results.filter((r) => !r.success).length,
131
+ },
132
+ });
133
+ }
134
+ async function handleBatchLabel(options, command) {
135
+ const rootOpts = command.parent.parent.opts();
136
+ if (!(options.add || options.remove)) {
137
+ throw new Error("Specify --add and/or --remove for labels.");
138
+ }
139
+ const issues = await resolveTargetIssues(options, rootOpts);
140
+ const addLabels = options.add ? splitList(options.add) : [];
141
+ const removeLabels = options.remove ? splitList(options.remove) : [];
142
+ const actionParts = [];
143
+ if (addLabels.length > 0) {
144
+ actionParts.push(`add [${addLabels.join(", ")}]`);
145
+ }
146
+ if (removeLabels.length > 0) {
147
+ actionParts.push(`remove [${removeLabels.join(", ")}]`);
148
+ }
149
+ const actionDesc = `Labels: ${actionParts.join(", ")} on ${issues.length} issues:`;
150
+ if (!options.yes) {
151
+ const hasPreview = printPreview(issues, actionDesc);
152
+ if (!hasPreview) {
153
+ return;
154
+ }
155
+ outputWarning("Dry run — pass --yes to execute.");
156
+ outputSuccess({
157
+ dryRun: true,
158
+ action: "label",
159
+ add: addLabels,
160
+ remove: removeLabels,
161
+ issues: issues.map((i) => i.identifier),
162
+ count: issues.length,
163
+ });
164
+ return;
165
+ }
166
+ const graphQLService = createGraphQLService(rootOpts);
167
+ const linearService = createLinearService(rootOpts);
168
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
169
+ const removeLower = removeLabels.map((l) => l.toLowerCase());
170
+ const { results } = await executeBatch(issues, (issue) => {
171
+ // Filter out labels to remove (by name, case-insensitive)
172
+ const keepIds = issue.labels
173
+ .filter((l) => !removeLower.includes(l.name.toLowerCase()))
174
+ .map((l) => l.id);
175
+ // Resolve labels to add (may be names that need resolution)
176
+ const teamKey = issue.team?.key;
177
+ const addIds = teamKey ? resolveLabels(addLabels, teamKey) : addLabels;
178
+ // Merge: keep existing (minus removed) + add new (avoiding duplicates)
179
+ const finalLabelIds = [...keepIds];
180
+ for (const id of addIds) {
181
+ if (!finalLabelIds.includes(id)) {
182
+ finalLabelIds.push(id);
183
+ }
184
+ }
185
+ return issuesService.updateIssue({ id: issue.identifier, labelIds: finalLabelIds }, "overwriting");
186
+ });
187
+ outputSuccess({
188
+ action: "label",
189
+ add: addLabels,
190
+ remove: removeLabels,
191
+ results,
192
+ meta: {
193
+ total: results.length,
194
+ succeeded: results.filter((r) => r.success).length,
195
+ failed: results.filter((r) => !r.success).length,
196
+ },
197
+ });
198
+ }
199
+ async function handleBatchMove(options, command) {
200
+ const rootOpts = command.parent.parent.opts();
201
+ if (!options.project) {
202
+ throw new Error("--project is required.");
203
+ }
204
+ const issues = await resolveTargetIssues(options, rootOpts);
205
+ if (!options.yes) {
206
+ const hasPreview = printPreview(issues, `Move ${issues.length} issues to project "${options.project}":`);
207
+ if (!hasPreview) {
208
+ return;
209
+ }
210
+ outputWarning("Dry run — pass --yes to execute.");
211
+ outputSuccess({
212
+ dryRun: true,
213
+ action: "move",
214
+ project: options.project,
215
+ issues: issues.map((i) => i.identifier),
216
+ count: issues.length,
217
+ });
218
+ return;
219
+ }
220
+ const graphQLService = createGraphQLService(rootOpts);
221
+ const linearService = createLinearService(rootOpts);
222
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
223
+ const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, projectId: options.project }, "adding"));
224
+ outputSuccess({
225
+ action: "move",
226
+ project: options.project,
227
+ results,
228
+ meta: {
229
+ total: results.length,
230
+ succeeded: results.filter((r) => r.success).length,
231
+ failed: results.filter((r) => !r.success).length,
232
+ },
233
+ });
234
+ }
235
+ async function handleBatchStatus(options, command) {
236
+ const rootOpts = command.parent.parent.opts();
237
+ if (!options.status) {
238
+ throw new Error("--status is required.");
239
+ }
240
+ const issues = await resolveTargetIssues(options, rootOpts);
241
+ if (!options.yes) {
242
+ const hasPreview = printPreview(issues, `Change status to "${options.status}" on ${issues.length} issues:`);
243
+ if (!hasPreview) {
244
+ return;
245
+ }
246
+ outputWarning("Dry run — pass --yes to execute.");
247
+ outputSuccess({
248
+ dryRun: true,
249
+ action: "status",
250
+ status: options.status,
251
+ issues: issues.map((i) => i.identifier),
252
+ count: issues.length,
253
+ });
254
+ return;
255
+ }
256
+ const graphQLService = createGraphQLService(rootOpts);
257
+ const linearService = createLinearService(rootOpts);
258
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
259
+ const { results } = await executeBatch(issues, (issue) => issuesService.updateIssue({ id: issue.identifier, statusId: options.status }, "adding"));
260
+ outputSuccess({
261
+ action: "status",
262
+ status: options.status,
263
+ results,
264
+ meta: {
265
+ total: results.length,
266
+ succeeded: results.filter((r) => r.success).length,
267
+ failed: results.filter((r) => !r.success).length,
268
+ },
269
+ });
270
+ }
271
+ export function setupBatchCommands(program) {
272
+ const batch = program
273
+ .command("batch")
274
+ .description("Batch operations on multiple issues");
275
+ batch.action(() => batch.help());
276
+ batch
277
+ .command("assign")
278
+ .description("Assign multiple issues to a user")
279
+ .option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
280
+ .option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
281
+ .requiredOption("--assignee <assignee>", "assignee (name, alias, or UUID)")
282
+ .option("--yes", "skip confirmation preview and execute immediately")
283
+ .action(handleAsyncCommand(handleBatchAssign));
284
+ batch
285
+ .command("label")
286
+ .description("Add or remove labels on multiple issues")
287
+ .option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
288
+ .option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
289
+ .option("--add <labels>", "labels to add (comma-separated names)")
290
+ .option("--remove <labels>", "labels to remove (comma-separated names)")
291
+ .option("--yes", "skip confirmation preview and execute immediately")
292
+ .action(handleAsyncCommand(handleBatchLabel));
293
+ batch
294
+ .command("move")
295
+ .description("Move multiple issues to a project")
296
+ .option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
297
+ .option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
298
+ .requiredOption("--project <project>", "project name or ID")
299
+ .option("--yes", "skip confirmation preview and execute immediately")
300
+ .action(handleAsyncCommand(handleBatchMove));
301
+ batch
302
+ .command("status")
303
+ .description("Change status on multiple issues")
304
+ .option("--issues <issues>", "comma-separated issue IDs (e.g. DEV-100,DEV-101)")
305
+ .option("--filter <filter>", 'query filter (e.g. "status:Backlog team:DEV label:Bug")')
306
+ .requiredOption("-s, --status <status>", "new status name or ID")
307
+ .option("--yes", "skip confirmation preview and execute immediately")
308
+ .action(handleAsyncCommand(handleBatchStatus));
309
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function setupCommentsCommands(program: Command): void;
@@ -0,0 +1,272 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolveUserDisplayName } from "../config/resolver.js";
3
+ import { CREATE_COMMENT_MUTATION, LIST_COMMENTS_QUERY, UPDATE_COMMENT_MUTATION, } from "../queries/comments.js";
4
+ import { autoLinkReferences, } from "../utils/auto-link-references.js";
5
+ import { createGraphQLService, } from "../utils/graphql-service.js";
6
+ import { extractIssueReferences } from "../utils/issue-reference-extractor.js";
7
+ import { wrapIssueReferencesAsLinks } from "../utils/issue-reference-wrapper.js";
8
+ import { createLinearService, } from "../utils/linear-service.js";
9
+ import { resolveMentions } from "../utils/mention-resolver.js";
10
+ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
11
+ import { validateReferences } from "../utils/validate-references.js";
12
+ import { getWorkspaceUrlKey } from "../utils/workspace-url.js";
13
+ // Match Linear's bodyData validation error in multiple phrasings so a wording
14
+ // tweak on their side doesn't silently regress the fallback path.
15
+ const BODY_DATA_ERROR_RE = /prosemirror|bodydata|invalid.*body/i;
16
+ const ISSUE_IDENTIFIER_REGEX = /^[A-Z][A-Z0-9]*-\d+$/;
17
+ function readBody(options) {
18
+ if (options.file) {
19
+ return readFileSync(options.file, "utf-8");
20
+ }
21
+ if (options.body) {
22
+ return options.body;
23
+ }
24
+ throw new Error("Either --body or --file is required");
25
+ }
26
+ async function fetchSelfUserId(graphQLService) {
27
+ try {
28
+ const result = await graphQLService.rawRequest("{ viewer { id } }");
29
+ const viewer = result.viewer;
30
+ return viewer?.id;
31
+ }
32
+ catch {
33
+ return;
34
+ }
35
+ }
36
+ function transformComment(comment) {
37
+ const user = comment.user;
38
+ return {
39
+ id: comment.id,
40
+ body: comment.body,
41
+ user: {
42
+ id: user.id,
43
+ name: resolveUserDisplayName(user.id, user.name),
44
+ url: user.url || undefined,
45
+ },
46
+ createdAt: comment.createdAt,
47
+ updatedAt: comment.updatedAt,
48
+ };
49
+ }
50
+ /**
51
+ * Wrap valid issue references in a comment body as markdown links — same logic as for
52
+ * issue descriptions. Returns the rewritten body plus the validated identifier→UUID map
53
+ * so the caller can reuse it for sidebar relation creation without re-resolving.
54
+ *
55
+ * No-op (returns the original body with an empty map) when:
56
+ * - body has no candidate refs
57
+ * - the user passed `--no-auto-link`
58
+ */
59
+ async function prepareCommentBodyWithLinks(body, options, linearService, graphQLService) {
60
+ if (options.autoLink === false || !body) {
61
+ return { body, preResolved: new Map() };
62
+ }
63
+ const refs = extractIssueReferences(body);
64
+ if (refs.length === 0) {
65
+ return { body, preResolved: new Map() };
66
+ }
67
+ const preResolved = await validateReferences(refs.map((r) => r.identifier), linearService);
68
+ if (preResolved.size === 0) {
69
+ return { body, preResolved };
70
+ }
71
+ const validIds = new Set(preResolved.keys());
72
+ const urlKey = await getWorkspaceUrlKey(graphQLService);
73
+ const wrapped = wrapIssueReferencesAsLinks(body, validIds, urlKey);
74
+ return { body: wrapped, preResolved };
75
+ }
76
+ /**
77
+ * After a comment is saved, create sidebar relations on the parent issue for the
78
+ * referenced issues — same de-dup and prose-keyword inference logic as descriptions.
79
+ * Returns undefined when nothing was linked, skipped, or failed.
80
+ */
81
+ async function autoLinkCommentReferences(args) {
82
+ if (args.options.autoLink === false) {
83
+ return;
84
+ }
85
+ const result = await autoLinkReferences({
86
+ issueId: args.parentIssueUuid,
87
+ identifier: args.parentIssueIdentifier,
88
+ description: args.body,
89
+ preResolved: args.preResolved,
90
+ graphQLService: args.graphQLService,
91
+ linearService: args.linearService,
92
+ });
93
+ if (result.linked.length === 0 &&
94
+ result.skipped.length === 0 &&
95
+ result.failed.length === 0) {
96
+ return;
97
+ }
98
+ return result;
99
+ }
100
+ async function handleCreateComment(issueId, options, command) {
101
+ const rootOpts = command.parent.parent.opts();
102
+ const graphQLService = createGraphQLService(rootOpts);
103
+ const linearService = createLinearService(rootOpts);
104
+ const rawBody = readBody(options);
105
+ const resolvedIssueId = await linearService.resolveIssueId(issueId);
106
+ // Wrap valid refs as markdown links before mention resolution. Idempotent against
107
+ // already-wrapped refs.
108
+ const { body, preResolved } = await prepareCommentBodyWithLinks(rawBody, options, linearService, graphQLService);
109
+ const autoMention = options.autoMention !== false;
110
+ const selfUserId = autoMention
111
+ ? await fetchSelfUserId(graphQLService)
112
+ : undefined;
113
+ const mentionResult = await resolveMentions(body, linearService, {
114
+ autoMention,
115
+ selfUserId,
116
+ });
117
+ const input = { issueId: resolvedIssueId };
118
+ if (mentionResult) {
119
+ input.bodyData = mentionResult.bodyData;
120
+ }
121
+ else {
122
+ input.body = body;
123
+ }
124
+ let result;
125
+ try {
126
+ result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
127
+ input,
128
+ });
129
+ }
130
+ catch (err) {
131
+ // If the bodyData ProseMirror document is rejected (invalid shape,
132
+ // unsupported node, schema change), fall back to raw body text and let
133
+ // Linear handle the conversion server-side.
134
+ const msg = err instanceof Error ? err.message : String(err);
135
+ if (input.bodyData && BODY_DATA_ERROR_RE.test(msg)) {
136
+ const fallbackInput = {
137
+ issueId: resolvedIssueId,
138
+ body,
139
+ };
140
+ result = await graphQLService.rawRequest(CREATE_COMMENT_MUTATION, {
141
+ input: fallbackInput,
142
+ });
143
+ }
144
+ else {
145
+ throw err;
146
+ }
147
+ }
148
+ const mutation = result.commentCreate;
149
+ if (!mutation.success) {
150
+ throw new Error("Failed to create comment");
151
+ }
152
+ // We don't have the parent issue's identifier from the create mutation response,
153
+ // but we don't need it for self-exclusion in comments — wrapping a self-ref in a
154
+ // comment body is harmless. Use the user-provided issueId if it looks like one.
155
+ const parentIdentifier = ISSUE_IDENTIFIER_REGEX.test(issueId)
156
+ ? issueId.toUpperCase()
157
+ : "";
158
+ // Pass rawBody (pre-wrap) to autoLink so prose-keyword inference ("blocked by", etc.)
159
+ // sees `keyword DEV-100` rather than `keyword [DEV-100](url)`. After wrapping the inserted
160
+ // `[` defeats the trailing-whitespace anchor and inference falls back to "related".
161
+ const autoLinked = await autoLinkCommentReferences({
162
+ parentIssueUuid: resolvedIssueId,
163
+ parentIssueIdentifier: parentIdentifier,
164
+ body: rawBody,
165
+ preResolved,
166
+ options,
167
+ graphQLService,
168
+ linearService,
169
+ });
170
+ const output = transformComment(mutation.comment);
171
+ outputSuccess(autoLinked ? { ...output, autoLinked } : output);
172
+ }
173
+ async function handleUpdateComment(commentId, options, command) {
174
+ const rootOpts = command.parent.parent.opts();
175
+ const graphQLService = createGraphQLService(rootOpts);
176
+ const linearService = createLinearService(rootOpts);
177
+ const rawBody = readBody(options);
178
+ const { body, preResolved } = await prepareCommentBodyWithLinks(rawBody, options, linearService, graphQLService);
179
+ const autoMention = options.autoMention !== false;
180
+ const selfUserId = autoMention
181
+ ? await fetchSelfUserId(graphQLService)
182
+ : undefined;
183
+ const mentionResult = await resolveMentions(body, linearService, {
184
+ autoMention,
185
+ selfUserId,
186
+ });
187
+ const input = {};
188
+ if (mentionResult) {
189
+ input.bodyData = mentionResult.bodyData;
190
+ }
191
+ else {
192
+ input.body = body;
193
+ }
194
+ const result = await graphQLService.rawRequest(UPDATE_COMMENT_MUTATION, {
195
+ id: commentId,
196
+ input,
197
+ });
198
+ const mutation = result.commentUpdate;
199
+ if (!mutation.success) {
200
+ throw new Error("Failed to update comment");
201
+ }
202
+ const comment = mutation.comment;
203
+ const issue = comment.issue;
204
+ let autoLinked;
205
+ if (issue) {
206
+ // rawBody (pre-wrap) — see note in handleCreateComment about keyword inference
207
+ autoLinked = await autoLinkCommentReferences({
208
+ parentIssueUuid: issue.id,
209
+ parentIssueIdentifier: issue.identifier ?? "",
210
+ body: rawBody,
211
+ preResolved,
212
+ options,
213
+ graphQLService,
214
+ linearService,
215
+ });
216
+ }
217
+ const output = transformComment(comment);
218
+ outputSuccess(autoLinked ? { ...output, autoLinked } : output);
219
+ }
220
+ async function handleListComments(issueId, options, command) {
221
+ const rootOpts = command.parent.parent.opts();
222
+ const graphQLService = createGraphQLService(rootOpts);
223
+ const linearService = createLinearService(rootOpts);
224
+ const resolvedId = await linearService.resolveIssueId(issueId);
225
+ const result = await graphQLService.rawRequest(LIST_COMMENTS_QUERY, {
226
+ issueId: resolvedId,
227
+ first: Number.parseInt(options.limit, 10),
228
+ });
229
+ const issue = result.issue;
230
+ if (!issue) {
231
+ throw new Error(`Issue "${issueId}" not found`);
232
+ }
233
+ const commentsData = issue.comments;
234
+ const nodes = commentsData.nodes.map(transformComment);
235
+ outputSuccess({
236
+ data: nodes,
237
+ meta: {
238
+ count: nodes.length,
239
+ issue: issue.identifier,
240
+ },
241
+ });
242
+ }
243
+ export function setupCommentsCommands(program) {
244
+ const comments = program
245
+ .command("comments")
246
+ .alias("comment")
247
+ .description("Comment operations");
248
+ comments.action(() => comments.help());
249
+ comments
250
+ .command("create <issueId>")
251
+ .description("Create new comment on issue.")
252
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.\nBare references to known team members (e.g. 'Dima') are auto-converted to @mentions — pass --no-auto-mention to disable.\nIssue identifiers (e.g. DEV-123) are wrapped as markdown links and added as 'related' relations on the parent issue — pass --no-auto-link to disable.")
253
+ .option("--body <body>", "comment body (inline)")
254
+ .option("--file <path>", "read comment body from file")
255
+ .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
256
+ .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
257
+ .action(handleAsyncCommand(handleCreateComment));
258
+ comments
259
+ .command("update <commentId>")
260
+ .description("Update an existing comment.")
261
+ .option("--body <body>", "new comment body (inline)")
262
+ .option("--file <path>", "read new comment body from file")
263
+ .option("--no-auto-mention", "do not auto-convert bare team-member names to @mentions")
264
+ .option("--no-auto-link", "skip wrapping issue refs as markdown links and creating sidebar relations")
265
+ .action(handleAsyncCommand(handleUpdateComment));
266
+ comments
267
+ .command("list <issueId>")
268
+ .description("List comments on an issue.")
269
+ .addHelpText("after", "\nBoth UUID and identifiers like ABC-123 are supported.")
270
+ .option("-l, --limit <number>", "limit results", "25")
271
+ .action(handleAsyncCommand(handleListComments));
272
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function setupConfigCommands(program: Command): void;
@@ -0,0 +1,15 @@
1
+ import { loadConfig } from "../config/config.js";
2
+ import { outputSuccess } from "../utils/output.js";
3
+ export function setupConfigCommands(program) {
4
+ const config = program
5
+ .command("config")
6
+ .description("Configuration introspection");
7
+ config.action(() => config.help());
8
+ config
9
+ .command("show")
10
+ .description("Show resolved configuration")
11
+ .addHelpText("after", "\nDumps the merged config from ~/.config/el-linear/config.json.\n\nExamples:\n el-linear config show")
12
+ .action(() => {
13
+ outputSuccess({ data: loadConfig() });
14
+ });
15
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function setupCyclesCommands(program: Command): void;
@@ -0,0 +1,63 @@
1
+ import { resolveTeam } from "../config/resolver.js";
2
+ import { invalidParameterError, notFoundError, requiresParameterError, } from "../utils/error-messages.js";
3
+ import { createLinearService } from "../utils/linear-service.js";
4
+ import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
5
+ export function setupCyclesCommands(program) {
6
+ const cycles = program
7
+ .command("cycles")
8
+ .alias("cycle")
9
+ .description("Cycle operations");
10
+ cycles.action(() => cycles.help());
11
+ cycles
12
+ .command("list")
13
+ .description("List cycles")
14
+ .option("--team <team>", "team key, name, or ID")
15
+ .option("--active", "only active cycles")
16
+ .option("--around-active <n>", "return active +/- n cycles (requires --team)")
17
+ .option("-l, --limit <number>", "limit results", "50")
18
+ .action(handleAsyncCommand(async (options, command) => {
19
+ if (options.aroundActive && !options.team) {
20
+ throw requiresParameterError("--around-active", "--team");
21
+ }
22
+ const rootOpts = command.parent.parent.opts();
23
+ const teamFilter = options.team ? resolveTeam(options.team) : undefined;
24
+ const linearService = createLinearService(rootOpts);
25
+ const allCycles = await linearService.getCycles(teamFilter, options.active || undefined, Number.parseInt(options.limit, 10));
26
+ if (options.aroundActive) {
27
+ const n = Number.parseInt(options.aroundActive, 10);
28
+ if (Number.isNaN(n) || n < 0) {
29
+ throw invalidParameterError("--around-active", "requires a non-negative integer");
30
+ }
31
+ const activeCycle = allCycles.find((c) => c.isActive);
32
+ if (!activeCycle) {
33
+ throw notFoundError("Active cycle", options.team, "for team");
34
+ }
35
+ const activeNumber = Number(activeCycle.number || 0);
36
+ const min = activeNumber - n;
37
+ const max = activeNumber + n;
38
+ const filtered = allCycles
39
+ .filter((c) => typeof c.number === "number" &&
40
+ c.number >= min &&
41
+ c.number <= max)
42
+ .sort((a, b) => a.number - b.number);
43
+ outputSuccess({ data: filtered, meta: { count: filtered.length } });
44
+ return;
45
+ }
46
+ outputSuccess({ data: allCycles, meta: { count: allCycles.length } });
47
+ }));
48
+ cycles
49
+ .command("read <cycleIdOrName>")
50
+ .description("Get cycle details including issues.")
51
+ .option("--team <team>", "team key, name, or ID to scope name lookup")
52
+ .option("--issues-first <n>", "how many issues to fetch (default 50)", "50")
53
+ .action(handleAsyncCommand(async (cycleIdOrName, options, command) => {
54
+ const rootOpts = command.parent.parent.opts();
55
+ const teamFilter = options.team
56
+ ? resolveTeam(options.team)
57
+ : undefined;
58
+ const linearService = createLinearService(rootOpts);
59
+ const cycleId = await linearService.resolveCycleId(cycleIdOrName, teamFilter);
60
+ const cycle = await linearService.getCycleById(cycleId, Number.parseInt(options.issuesFirst || "50", 10));
61
+ outputSuccess(cycle);
62
+ }));
63
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function setupDocumentsCommands(program: Command): void;