@enrichlayer/el-linear 1.7.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +82 -1
  2. package/claude-skills/linear-operations/SKILL.md +55 -0
  3. package/dist/auth/oauth-callback.js +14 -7
  4. package/dist/auth/oauth-fs.d.ts +22 -0
  5. package/dist/auth/oauth-fs.js +77 -6
  6. package/dist/auth/oauth-headless.d.ts +13 -8
  7. package/dist/auth/oauth-headless.js +18 -11
  8. package/dist/auth/oauth-storage.d.ts +11 -3
  9. package/dist/auth/oauth-storage.js +15 -8
  10. package/dist/auth/token-resolver.d.ts +9 -0
  11. package/dist/auth/token-resolver.js +57 -37
  12. package/dist/commands/attachments.js +2 -1
  13. package/dist/commands/comments.js +17 -28
  14. package/dist/commands/cycles.js +2 -1
  15. package/dist/commands/documents.js +2 -1
  16. package/dist/commands/graphql.js +4 -6
  17. package/dist/commands/init/index.js +2 -0
  18. package/dist/commands/init/oauth.d.ts +6 -0
  19. package/dist/commands/init/oauth.js +8 -2
  20. package/dist/commands/init/token.d.ts +0 -8
  21. package/dist/commands/init/token.js +13 -1
  22. package/dist/commands/issue-id.js +1 -3
  23. package/dist/commands/issues/branch.d.ts +17 -0
  24. package/dist/commands/issues/branch.js +40 -0
  25. package/dist/commands/issues/description.d.ts +89 -0
  26. package/dist/commands/issues/description.js +183 -0
  27. package/dist/commands/issues/link-references.d.ts +21 -0
  28. package/dist/commands/issues/link-references.js +171 -0
  29. package/dist/commands/issues/relations.d.ts +55 -0
  30. package/dist/commands/issues/relations.js +132 -0
  31. package/dist/commands/issues.js +97 -497
  32. package/dist/commands/labels.js +20 -28
  33. package/dist/commands/profile/migrate-legacy.js +10 -33
  34. package/dist/commands/profile.js +1 -10
  35. package/dist/commands/project-milestones.js +13 -20
  36. package/dist/commands/projects.d.ts +5 -1
  37. package/dist/commands/projects.js +202 -102
  38. package/dist/commands/read-shortcut.d.ts +6 -0
  39. package/dist/commands/read-shortcut.js +6 -1
  40. package/dist/commands/refs.js +10 -1
  41. package/dist/commands/releases.js +26 -30
  42. package/dist/commands/search.js +21 -30
  43. package/dist/commands/teams.js +2 -1
  44. package/dist/commands/templates.js +128 -7
  45. package/dist/commands/users.js +3 -1
  46. package/dist/config/config.js +31 -9
  47. package/dist/config/issue-validation.js +1 -1
  48. package/dist/config/paths.d.ts +11 -0
  49. package/dist/config/paths.js +44 -6
  50. package/dist/config/resolver.js +2 -3
  51. package/dist/config/term-enforcer.js +1 -1
  52. package/dist/main.js +37 -2
  53. package/dist/queries/attachments-types.d.ts +30 -0
  54. package/dist/queries/attachments-types.js +5 -0
  55. package/dist/queries/comments-types.d.ts +49 -0
  56. package/dist/queries/comments-types.js +5 -0
  57. package/dist/queries/documents-types.d.ts +61 -0
  58. package/dist/queries/documents-types.js +9 -0
  59. package/dist/queries/introspect-types.d.ts +57 -0
  60. package/dist/queries/introspect-types.js +10 -0
  61. package/dist/queries/issues-types.d.ts +416 -0
  62. package/dist/queries/issues-types.js +23 -0
  63. package/dist/queries/issues.d.ts +2 -0
  64. package/dist/queries/issues.js +22 -0
  65. package/dist/queries/labels-types.d.ts +64 -0
  66. package/dist/queries/labels-types.js +5 -0
  67. package/dist/queries/project-milestones-types.d.ts +91 -0
  68. package/dist/queries/project-milestones-types.js +10 -0
  69. package/dist/queries/projects-types.d.ts +75 -0
  70. package/dist/queries/projects-types.js +5 -0
  71. package/dist/queries/projects.d.ts +2 -0
  72. package/dist/queries/projects.js +22 -0
  73. package/dist/queries/releases-types.d.ts +84 -0
  74. package/dist/queries/releases-types.js +5 -0
  75. package/dist/queries/search-types.d.ts +86 -0
  76. package/dist/queries/search-types.js +6 -0
  77. package/dist/queries/templates-types.d.ts +61 -0
  78. package/dist/queries/templates-types.js +9 -0
  79. package/dist/queries/templates.d.ts +3 -0
  80. package/dist/queries/templates.js +43 -0
  81. package/dist/types/linear.d.ts +8 -2
  82. package/dist/utils/auth.js +7 -9
  83. package/dist/utils/auto-link-references.js +44 -25
  84. package/dist/utils/disk-cache.js +2 -1
  85. package/dist/utils/formatters/summary.d.ts +62 -0
  86. package/dist/utils/formatters/summary.js +755 -0
  87. package/dist/utils/graphql-attachments-service.js +6 -9
  88. package/dist/utils/graphql-documents-service.js +19 -25
  89. package/dist/utils/graphql-issues-service.d.ts +118 -5
  90. package/dist/utils/graphql-issues-service.js +202 -209
  91. package/dist/utils/issue-reference-extractor.d.ts +9 -1
  92. package/dist/utils/issue-reference-extractor.js +16 -9
  93. package/dist/utils/issue-reference-wrapper.js +1 -54
  94. package/dist/utils/linear-service.d.ts +7 -3
  95. package/dist/utils/linear-service.js +27 -5
  96. package/dist/utils/markdown-prosemirror.js +17 -1
  97. package/dist/utils/mention-resolver.js +17 -5
  98. package/dist/utils/output.d.ts +7 -1
  99. package/dist/utils/output.js +38 -1
  100. package/dist/utils/protected-ranges.d.ts +33 -0
  101. package/dist/utils/protected-ranges.js +73 -0
  102. package/dist/utils/table-formatter.d.ts +36 -0
  103. package/dist/utils/table-formatter.js +46 -24
  104. package/dist/utils/validators.d.ts +9 -1
  105. package/dist/utils/validators.js +10 -0
  106. package/dist/utils/workspace-url.d.ts +5 -1
  107. package/dist/utils/workspace-url.js +35 -5
  108. package/package.json +1 -1
@@ -1,10 +1,11 @@
1
1
  import { resolveUserDisplayName } from "../config/resolver.js";
2
- import { BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
2
+ import { ARCHIVE_ISSUE_MUTATION, BATCH_RESOLVE_FOR_CREATE_QUERY, BATCH_RESOLVE_FOR_SEARCH_QUERY, BATCH_RESOLVE_FOR_UPDATE_QUERY, buildResolveLabelsByNameQuery, CREATE_ISSUE_MUTATION, DELETE_ISSUE_MUTATION, FILTERED_SEARCH_ISSUES_QUERY, GET_ISSUE_BY_ID_QUERY, GET_ISSUE_BY_IDENTIFIER_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, UPDATE_ISSUE_MUTATION, } from "../queries/issues.js";
3
3
  import { CREATE_LABEL_MUTATION } from "../queries/labels.js";
4
4
  import { toISOStringOrNow } from "./date-format.js";
5
5
  import { extractEmbeds } from "./embed-parser.js";
6
6
  import { notFoundError } from "./error-messages.js";
7
7
  import { parseIssueIdentifier, tryParseIssueIdentifier, } from "./identifier-parser.js";
8
+ import { logger } from "./logger.js";
8
9
  import { isUuid } from "./uuid.js";
9
10
  const TEAM_KEY_REGEX = /^[A-Z0-9]+$/i;
10
11
  function extractSummaryText(summary) {
@@ -13,7 +14,7 @@ function extractSummaryText(summary) {
13
14
  }
14
15
  const parts = [];
15
16
  const walk = (node) => {
16
- if (node.text) {
17
+ if (typeof node.text === "string") {
17
18
  parts.push(node.text);
18
19
  }
19
20
  if (Array.isArray(node.content)) {
@@ -33,15 +34,12 @@ export class GraphQLIssuesService {
33
34
  this.linearService = linearService;
34
35
  }
35
36
  async getIssues(limit = 25) {
36
- const result = await this.graphQLService.rawRequest(GET_ISSUES_QUERY, {
37
- first: limit,
38
- orderBy: "updatedAt",
39
- });
40
- const issues = result.issues;
41
- if (!issues?.nodes) {
37
+ const result = await this.graphQLService.rawRequest(GET_ISSUES_QUERY, { first: limit, orderBy: "updatedAt" });
38
+ const nodes = result.issues?.nodes;
39
+ if (!nodes?.length) {
42
40
  return [];
43
41
  }
44
- return issues.nodes.map((issue) => this.transformIssueData(issue));
42
+ return nodes.map((issue) => this.transformIssueData(issue));
45
43
  }
46
44
  async getIssueById(issueId) {
47
45
  let issueData;
@@ -54,12 +52,8 @@ export class GraphQLIssuesService {
54
52
  }
55
53
  else {
56
54
  const { teamKey, issueNumber } = parseIssueIdentifier(issueId);
57
- const result = await this.graphQLService.rawRequest(GET_ISSUE_BY_IDENTIFIER_QUERY, {
58
- teamKey,
59
- number: issueNumber,
60
- });
61
- const issues = result.issues;
62
- const nodes = issues?.nodes;
55
+ const result = await this.graphQLService.rawRequest(GET_ISSUE_BY_IDENTIFIER_QUERY, { teamKey, number: issueNumber });
56
+ const nodes = result.issues?.nodes;
63
57
  if (!nodes?.length) {
64
58
  throw notFoundError("Issue", issueId);
65
59
  }
@@ -76,15 +70,18 @@ export class GraphQLIssuesService {
76
70
  const finalLabelIds = await this.resolveLabelsWithMode(args.labelIds, resolveResult, labelMode, currentIssueLabels, teamIdForLabels, args.teamId);
77
71
  const finalProjectId = args.projectId
78
72
  ? this.resolveProjectId(args.projectId, resolveResult)
79
- : args.projectId;
73
+ : undefined;
80
74
  const { projectMilestoneNodes, issueProjectMilestoneNodes } = this.extractMilestoneNodes(args, resolveResult);
81
75
  const finalMilestoneId = this.resolveMilestoneId(args.milestoneId, resolveResult, projectMilestoneNodes, issueProjectMilestoneNodes);
82
76
  const finalCycleId = await this.resolveCycleIdForUpdate(args, resolveResult, resolvedIssueId);
83
77
  const resolvedStatusId = await this.resolveStatusIdForUpdate(args, resolvedIssueId);
84
- if (args.assigneeId && !isUuid(args.assigneeId)) {
85
- args.assigneeId = await this.linearService.resolveUserId(args.assigneeId);
78
+ // Resolve assignee in place — `buildUpdateInput` reads it
79
+ // directly. Match the createIssue pattern.
80
+ let assigneeId = args.assigneeId;
81
+ if (assigneeId && !isUuid(assigneeId)) {
82
+ assigneeId = await this.linearService.resolveUserId(assigneeId);
86
83
  }
87
- const updateInput = this.buildUpdateInput(args, {
84
+ const updateInput = this.buildUpdateInput({ ...args, assigneeId }, {
88
85
  statusId: resolvedStatusId,
89
86
  projectId: finalProjectId,
90
87
  labelIds: finalLabelIds,
@@ -93,15 +90,40 @@ export class GraphQLIssuesService {
93
90
  });
94
91
  return this.executeUpdateMutation(resolvedIssueId, args.id, updateInput);
95
92
  }
93
+ async archiveIssue(issueId) {
94
+ const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
95
+ const result = await this.graphQLService.rawRequest(ARCHIVE_ISSUE_MUTATION, { id: resolvedIssueId });
96
+ const payload = result.issueArchive;
97
+ if (!payload.success) {
98
+ throw new Error(`Failed to archive issue ${issueId}`);
99
+ }
100
+ return {
101
+ id: resolvedIssueId,
102
+ entity: payload.entity ?? undefined,
103
+ lastSyncId: payload.lastSyncId,
104
+ };
105
+ }
106
+ async deleteIssue(issueId, options = {}) {
107
+ const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
108
+ const result = await this.graphQLService.rawRequest(DELETE_ISSUE_MUTATION, {
109
+ id: resolvedIssueId,
110
+ permanentlyDelete: Boolean(options.permanentlyDelete),
111
+ });
112
+ const payload = result.issueDelete;
113
+ if (!payload.success) {
114
+ throw new Error(`Failed to delete issue ${issueId}`);
115
+ }
116
+ return {
117
+ id: resolvedIssueId,
118
+ entity: payload.entity ?? undefined,
119
+ lastSyncId: payload.lastSyncId,
120
+ };
121
+ }
96
122
  extractMilestoneNodes(args, resolveResult) {
97
- const projectNodes = resolveResult.projects?.nodes;
98
123
  const projectMilestoneNodes = args.projectId
99
- ? projectNodes?.[0]?.projectMilestones?.nodes
124
+ ? resolveResult.projects?.nodes?.[0]?.projectMilestones?.nodes
100
125
  : undefined;
101
- const issueNodes = resolveResult.issues
102
- ?.nodes;
103
- const issueProjectMilestoneNodes = issueNodes?.[0]?.project
104
- ?.projectMilestones?.nodes;
126
+ const issueProjectMilestoneNodes = resolveResult.issues?.nodes?.[0]?.project?.projectMilestones.nodes;
105
127
  return { projectMilestoneNodes, issueProjectMilestoneNodes };
106
128
  }
107
129
  async executeUpdateMutation(resolvedIssueId, originalId, updateInput) {
@@ -136,49 +158,51 @@ export class GraphQLIssuesService {
136
158
  let resolveResult = {};
137
159
  if (Object.keys(resolveVariables).length > 0) {
138
160
  const { __labelNames, ...queryVars } = resolveVariables;
139
- const requests = [
140
- this.graphQLService.rawRequest(BATCH_RESOLVE_FOR_CREATE_QUERY, queryVars),
141
- ];
161
+ const batchPromise = this.graphQLService.rawRequest(BATCH_RESOLVE_FOR_CREATE_QUERY, queryVars);
142
162
  if (__labelNames) {
143
163
  const labelQuery = buildResolveLabelsByNameQuery(__labelNames);
144
- requests.push(this.graphQLService.rawRequest(labelQuery.query, labelQuery.variables));
164
+ const [batch, labels] = await Promise.all([
165
+ batchPromise,
166
+ this.graphQLService.rawRequest(labelQuery.query, labelQuery.variables),
167
+ ]);
168
+ resolveResult = { ...batch, labels: labels.labels };
145
169
  }
146
- const results = await Promise.all(requests);
147
- resolveResult = results[0];
148
- if (results[1]) {
149
- resolveResult.labels = results[1].labels;
170
+ else {
171
+ resolveResult = await batchPromise;
150
172
  }
151
173
  }
152
174
  const resolved = await this.resolveCreateFields(args, resolveResult);
153
- if (args.assigneeId && !isUuid(args.assigneeId)) {
154
- args.assigneeId = await this.linearService.resolveUserId(args.assigneeId);
155
- }
156
- const createInput = this.buildCreateInput(args, resolved);
175
+ // Mutate-the-input feels gross but matches the existing pattern
176
+ // — `resolved` doesn't carry assigneeId, so the buildCreateInput
177
+ // step reads `args.assigneeId` directly. Resolve in place.
178
+ let assigneeId = args.assigneeId;
179
+ if (assigneeId && !isUuid(assigneeId)) {
180
+ assigneeId = await this.linearService.resolveUserId(assigneeId);
181
+ }
182
+ const createInput = this.buildCreateInput({ ...args, assigneeId }, resolved);
157
183
  return this.executeCreateMutation(createInput);
158
184
  }
159
185
  async resolveCreateFields(args, resolveResult) {
160
186
  let teamId = args.teamId
161
187
  ? await this.resolveTeamId(args.teamId, resolveResult)
162
- : args.teamId;
188
+ : undefined;
163
189
  const projectId = args.projectId
164
190
  ? this.resolveProjectId(args.projectId, resolveResult)
165
- : args.projectId;
191
+ : undefined;
166
192
  // Validate team-project compatibility and auto-correct when possible
167
- if (projectId && teamId) {
193
+ if (projectId && teamId && args.projectId) {
168
194
  teamId = this.validateProjectTeam(teamId, args.projectId, resolveResult);
169
195
  }
170
196
  const labelIds = await this.resolveLabels(args.labelIds, resolveResult, teamId, args.teamInput);
171
197
  const parentId = this.resolveParentId(args.parentId, resolveResult);
172
198
  const milestoneId = this.resolveMilestoneIdForCreate(args.milestoneId, resolveResult, projectId);
173
199
  let cycleId = args.cycleId;
174
- if (args.cycleId &&
175
- typeof args.cycleId === "string" &&
176
- !isUuid(args.cycleId)) {
177
- cycleId = await this.linearService.resolveCycleId(args.cycleId, teamId);
200
+ if (cycleId && !isUuid(cycleId)) {
201
+ cycleId = await this.linearService.resolveCycleId(cycleId, teamId);
178
202
  }
179
203
  let statusId = args.statusId;
180
- if (args.statusId && !isUuid(args.statusId)) {
181
- statusId = await this.linearService.resolveStatusId(args.statusId, teamId);
204
+ if (statusId && !isUuid(statusId)) {
205
+ statusId = await this.linearService.resolveStatusId(statusId, teamId);
182
206
  }
183
207
  return {
184
208
  teamId,
@@ -225,25 +249,27 @@ export class GraphQLIssuesService {
225
249
  }
226
250
  let resolveResult = {};
227
251
  if (Object.keys(resolveVariables).length > 0) {
228
- resolveResult = await this.graphQLService.rawRequest(BATCH_RESOLVE_FOR_SEARCH_QUERY, resolveVariables);
252
+ resolveResult =
253
+ await this.graphQLService.rawRequest(BATCH_RESOLVE_FOR_SEARCH_QUERY, resolveVariables);
229
254
  }
230
255
  const finalTeamId = args.teamId
231
256
  ? await this.resolveTeamId(args.teamId, resolveResult)
232
- : args.teamId;
257
+ : undefined;
233
258
  const finalProjectId = args.projectId
234
259
  ? this.resolveProjectId(args.projectId, resolveResult)
235
- : args.projectId;
260
+ : undefined;
236
261
  const finalAssigneeId = this.resolveAssigneeId(args.assigneeId, resolveResult);
262
+ const limit = args.limit ?? 10;
237
263
  if (args.query) {
238
264
  const searchResult = await this.graphQLService.rawRequest(SEARCH_ISSUES_QUERY, {
239
265
  term: args.query,
240
- first: args.limit || 10,
266
+ first: limit,
241
267
  });
242
- const searchIssues = searchResult.searchIssues;
243
- if (!searchIssues?.nodes) {
268
+ const nodes = searchResult.searchIssues?.nodes;
269
+ if (!nodes?.length) {
244
270
  return [];
245
271
  }
246
- const results = searchIssues.nodes.map((issue) => this.transformIssueData(issue));
272
+ const results = nodes.map((issue) => this.transformIssueData(issue));
247
273
  return this.applySearchFilters(results, {
248
274
  teamId: finalTeamId,
249
275
  assigneeId: finalAssigneeId,
@@ -263,9 +289,9 @@ export class GraphQLIssuesService {
263
289
  priority: args.priority,
264
290
  });
265
291
  const searchResult = await this.graphQLService.rawRequest(FILTERED_SEARCH_ISSUES_QUERY, {
266
- first: args.limit || 10,
292
+ first: limit,
267
293
  filter: Object.keys(filter).length > 0 ? filter : undefined,
268
- orderBy: args.orderBy || "updatedAt",
294
+ orderBy: args.orderBy ?? "updatedAt",
269
295
  });
270
296
  const filteredIssues = searchResult.issues;
271
297
  if (!filteredIssues?.nodes) {
@@ -278,8 +304,7 @@ export class GraphQLIssuesService {
278
304
  if (isUuid(teamId)) {
279
305
  return teamId;
280
306
  }
281
- const teams = resolveResult.teams;
282
- const teamNodes = teams?.nodes;
307
+ const teamNodes = resolveResult.teams?.nodes;
283
308
  const resolvedTeam = teamNodes?.[0];
284
309
  if (resolvedTeam &&
285
310
  (resolvedTeam.key.toUpperCase() === teamId.toUpperCase() ||
@@ -293,8 +318,7 @@ export class GraphQLIssuesService {
293
318
  if (isUuid(projectId)) {
294
319
  return projectId;
295
320
  }
296
- const projects = resolveResult.projects;
297
- const projectNodes = projects?.nodes;
321
+ const projectNodes = resolveResult.projects?.nodes;
298
322
  if (!projectNodes?.length) {
299
323
  throw notFoundError("Project", projectId);
300
324
  }
@@ -305,13 +329,11 @@ export class GraphQLIssuesService {
305
329
  * If not, auto-switch when the project belongs to exactly one team.
306
330
  */
307
331
  validateProjectTeam(teamId, projectInput, resolveResult) {
308
- const projects = resolveResult.projects;
309
- const projectNode = projects?.nodes?.[0];
332
+ const projectNode = resolveResult.projects?.nodes?.[0];
310
333
  if (!projectNode) {
311
334
  return teamId;
312
335
  }
313
- const teamsConn = projectNode.teams;
314
- const teamNodes = teamsConn?.nodes;
336
+ const teamNodes = projectNode.teams?.nodes;
315
337
  if (!teamNodes?.length) {
316
338
  return teamId;
317
339
  }
@@ -324,7 +346,7 @@ export class GraphQLIssuesService {
324
346
  if (teamNodes.length === 1) {
325
347
  const correctTeamId = teamNodes[0].id;
326
348
  const correctKey = teamKeys[0];
327
- process.stderr.write(`Auto-switched team to ${correctKey} (the only team associated with project "${projectName}").\n`);
349
+ logger.error(`Auto-switched team to ${correctKey} (the only team associated with project "${projectName}").`);
328
350
  return correctTeamId;
329
351
  }
330
352
  throw new Error(`Project "${projectName}" is not associated with the specified team.\n` +
@@ -339,8 +361,7 @@ export class GraphQLIssuesService {
339
361
  return labelIds;
340
362
  }
341
363
  const resolvedLabels = [];
342
- const labels = resolveResult.labels;
343
- const labelNodes = labels?.nodes;
364
+ const labelNodes = resolveResult.labels?.nodes;
344
365
  for (const labelIdOrName of labelIds) {
345
366
  if (isUuid(labelIdOrName)) {
346
367
  resolvedLabels.push(labelIdOrName);
@@ -350,10 +371,7 @@ export class GraphQLIssuesService {
350
371
  const candidates = labelNodes?.filter((l) => l.name.toLowerCase() === lowerName);
351
372
  let label;
352
373
  if (candidates && candidates.length > 1 && teamId) {
353
- label = candidates.find((l) => {
354
- const team = l.team;
355
- return team?.id === teamId;
356
- });
374
+ label = candidates.find((l) => l.team?.id === teamId);
357
375
  }
358
376
  if (!label) {
359
377
  label = candidates?.[0];
@@ -395,12 +413,10 @@ export class GraphQLIssuesService {
395
413
  }
396
414
  const result = await this.graphQLService.rawRequest(CREATE_LABEL_MUTATION, { input });
397
415
  const created = result.issueLabelCreate;
398
- if (created?.success && created.issueLabel) {
399
- const labelData = created.issueLabel;
400
- const teamInfo = labelData.team;
401
- const teamKey = teamInfo?.key ?? "";
402
- process.stderr.write(`Auto-created label "${name}" on team ${teamKey}\n`);
403
- return labelData.id;
416
+ if (created.success && created.issueLabel) {
417
+ const teamKey = created.issueLabel.team?.key ?? "";
418
+ logger.error(`Auto-created label "${name}" on team ${teamKey}`);
419
+ return created.issueLabel.id;
404
420
  }
405
421
  return null;
406
422
  }
@@ -428,37 +444,27 @@ export class GraphQLIssuesService {
428
444
  // Only fall back to first team if there's exactly one (unambiguous).
429
445
  // With multiple teams, returning an arbitrary one produces misleading error hints.
430
446
  const topLevelNodes = resolveResult.teams?.nodes;
431
- return topLevelNodes?.length === 1
432
- ? topLevelNodes[0].key
433
- : undefined;
447
+ return topLevelNodes?.length === 1 ? topLevelNodes[0].key : undefined;
434
448
  }
435
449
  collectTeamNodes(resolveResult) {
436
450
  const nodes = [];
437
451
  const seen = new Set();
438
- const topTeams = resolveResult.teams;
439
- const topNodes = topTeams?.nodes;
452
+ const topNodes = resolveResult.teams?.nodes;
440
453
  if (topNodes) {
441
454
  for (const t of topNodes) {
442
- const id = t.id;
443
- if (!seen.has(id)) {
444
- seen.add(id);
455
+ if (!seen.has(t.id)) {
456
+ seen.add(t.id);
445
457
  nodes.push(t);
446
458
  }
447
459
  }
448
460
  }
449
461
  // Also check project teams (which are always fetched when --project is used)
450
- const projects = resolveResult.projects;
451
- const projectNode = projects?.nodes?.[0];
452
- if (projectNode) {
453
- const projectTeams = projectNode.teams;
454
- const projectTeamNodes = projectTeams?.nodes;
455
- if (projectTeamNodes) {
456
- for (const t of projectTeamNodes) {
457
- const id = t.id;
458
- if (!seen.has(id)) {
459
- seen.add(id);
460
- nodes.push(t);
461
- }
462
+ const projectTeamNodes = resolveResult.projects?.nodes?.[0]?.teams?.nodes;
463
+ if (projectTeamNodes) {
464
+ for (const t of projectTeamNodes) {
465
+ if (!seen.has(t.id)) {
466
+ seen.add(t.id);
467
+ nodes.push(t);
462
468
  }
463
469
  }
464
470
  }
@@ -481,8 +487,7 @@ export class GraphQLIssuesService {
481
487
  resolved = issueMilestone.id;
482
488
  }
483
489
  }
484
- const milestones = resolveResult.milestones;
485
- const milestoneNodes = milestones?.nodes;
490
+ const milestoneNodes = resolveResult.milestones?.nodes;
486
491
  if (!isUuid(resolved) && milestoneNodes?.length) {
487
492
  resolved = milestoneNodes[0].id;
488
493
  }
@@ -496,19 +501,14 @@ export class GraphQLIssuesService {
496
501
  return milestoneId;
497
502
  }
498
503
  let resolved;
499
- const createProjects = resolveResult.projects;
500
- const createProjectNodes = createProjects?.nodes;
501
- const createProjectMilestones = createProjectNodes?.[0]
502
- ?.projectMilestones;
503
- const createProjectMilestoneNodes = createProjectMilestones?.nodes;
504
+ const createProjectMilestoneNodes = resolveResult.projects?.nodes?.[0]?.projectMilestones?.nodes;
504
505
  if (createProjectMilestoneNodes) {
505
506
  const projectMilestone = createProjectMilestoneNodes.find((m) => m.name === milestoneId);
506
507
  if (projectMilestone) {
507
508
  resolved = projectMilestone.id;
508
509
  }
509
510
  }
510
- const createMilestones = resolveResult.milestones;
511
- const createMilestoneNodes = createMilestones?.nodes;
511
+ const createMilestoneNodes = resolveResult.milestones?.nodes;
512
512
  if (!resolved && createMilestoneNodes?.length) {
513
513
  resolved = createMilestoneNodes[0].id;
514
514
  }
@@ -521,15 +521,18 @@ export class GraphQLIssuesService {
521
521
  return resolved;
522
522
  }
523
523
  async fetchIssueTeamId(issueId) {
524
- const result = await this.graphQLService.rawRequest(GET_ISSUE_TEAM_QUERY, {
525
- issueId,
526
- });
527
- const fetchedIssue = result.issue;
528
- const fetchedTeam = fetchedIssue?.team;
529
- return fetchedTeam?.id;
524
+ const result = await this.graphQLService.rawRequest(GET_ISSUE_TEAM_QUERY, { issueId });
525
+ return result.issue?.team?.id;
530
526
  }
531
527
  buildCreateInput(args, resolved) {
532
- const input = { title: args.title };
528
+ const input = {};
529
+ // Title is required by Linear's API unless --from-template is set,
530
+ // in which case Linear copies the template's title. We only include
531
+ // the field when explicitly set so a missing title with templateId
532
+ // works as the API documents.
533
+ if (args.title) {
534
+ input.title = args.title;
535
+ }
533
536
  if (resolved.teamId) {
534
537
  input.teamId = resolved.teamId;
535
538
  }
@@ -548,9 +551,7 @@ export class GraphQLIssuesService {
548
551
  if (resolved.statusId) {
549
552
  input.stateId = resolved.statusId;
550
553
  }
551
- if (resolved.labelIds &&
552
- Array.isArray(resolved.labelIds) &&
553
- resolved.labelIds.length > 0) {
554
+ if (resolved.labelIds && resolved.labelIds.length > 0) {
554
555
  input.labelIds = resolved.labelIds;
555
556
  }
556
557
  if (args.estimate !== undefined) {
@@ -565,14 +566,18 @@ export class GraphQLIssuesService {
565
566
  if (resolved.cycleId) {
566
567
  input.cycleId = resolved.cycleId;
567
568
  }
568
- if (args.subscriberIds &&
569
- Array.isArray(args.subscriberIds) &&
570
- args.subscriberIds.length > 0) {
569
+ if (args.subscriberIds && args.subscriberIds.length > 0) {
571
570
  input.subscriberIds = args.subscriberIds;
572
571
  }
573
572
  if (args.dueDate !== undefined) {
574
573
  input.dueDate = args.dueDate;
575
574
  }
575
+ if (args.templateId !== undefined && args.templateId !== null) {
576
+ // Server-side template instantiation: Linear copies the
577
+ // template's title/description/labels/priority onto the
578
+ // new issue. Any explicit field above wins by override.
579
+ input.templateId = args.templateId;
580
+ }
576
581
  return input;
577
582
  }
578
583
  buildUpdateInput(args, resolved) {
@@ -688,8 +693,7 @@ export class GraphQLIssuesService {
688
693
  if (!assigneeId || isUuid(assigneeId) || !assigneeId.includes("@")) {
689
694
  return assigneeId;
690
695
  }
691
- const users = resolveResult.users;
692
- const userNodes = users?.nodes;
696
+ const userNodes = resolveResult.users?.nodes;
693
697
  if (!userNodes?.length) {
694
698
  throw notFoundError("User", assigneeId);
695
699
  }
@@ -705,7 +709,7 @@ export class GraphQLIssuesService {
705
709
  resolveVariables.teamKey = teamKey;
706
710
  resolveVariables.issueNumber = issueNumber;
707
711
  }
708
- if (args.labelIds && Array.isArray(args.labelIds)) {
712
+ if (args.labelIds && args.labelIds.length > 0) {
709
713
  const nonUuidLabels = args.labelIds.filter((id) => !isUuid(id));
710
714
  if (nonUuidLabels.length > 0) {
711
715
  resolveVariables.__labelNames = nonUuidLabels;
@@ -714,35 +718,31 @@ export class GraphQLIssuesService {
714
718
  if (args.projectId && !isUuid(args.projectId)) {
715
719
  resolveVariables.projectName = args.projectId;
716
720
  }
717
- if (args.milestoneId &&
718
- typeof args.milestoneId === "string" &&
719
- !isUuid(args.milestoneId)) {
721
+ if (args.milestoneId && !isUuid(args.milestoneId)) {
720
722
  resolveVariables.milestoneName = args.milestoneId;
721
723
  }
722
724
  const { __labelNames, ...updateQueryVars } = resolveVariables;
723
- const requests = [
724
- this.graphQLService.rawRequest(BATCH_RESOLVE_FOR_UPDATE_QUERY, updateQueryVars),
725
- ];
725
+ const batchPromise = this.graphQLService.rawRequest(BATCH_RESOLVE_FOR_UPDATE_QUERY, updateQueryVars);
726
+ let resolveResult;
726
727
  if (__labelNames) {
727
728
  const labelQuery = buildResolveLabelsByNameQuery(__labelNames);
728
- requests.push(this.graphQLService.rawRequest(labelQuery.query, labelQuery.variables));
729
+ const [batch, labels] = await Promise.all([
730
+ batchPromise,
731
+ this.graphQLService.rawRequest(labelQuery.query, labelQuery.variables),
732
+ ]);
733
+ resolveResult = { ...batch, labels: labels.labels };
729
734
  }
730
- const results = await Promise.all(requests);
731
- const resolveResult = results[0];
732
- if (results[1]) {
733
- resolveResult.labels = results[1].labels;
735
+ else {
736
+ resolveResult = await batchPromise;
734
737
  }
735
738
  if (!isUuid(args.id)) {
736
- const resolvedIssues = resolveResult.issues;
737
- const resolvedIssueNodes = resolvedIssues.nodes;
739
+ const resolvedIssueNodes = resolveResult.issues?.nodes ?? [];
738
740
  if (!resolvedIssueNodes.length) {
739
741
  throw notFoundError("Issue", args.id);
740
742
  }
741
743
  resolvedIssueId = resolvedIssueNodes[0].id;
742
- const team = resolvedIssueNodes[0].team;
743
- issueTeamId = team?.id;
744
- const issueLabels = resolvedIssueNodes[0].labels;
745
- currentIssueLabels = issueLabels.nodes.map((l) => l.id);
744
+ issueTeamId = resolvedIssueNodes[0].team?.id;
745
+ currentIssueLabels = resolvedIssueNodes[0].labels.nodes.map((l) => l.id);
746
746
  }
747
747
  return { resolvedIssueId, issueTeamId, currentIssueLabels, resolveResult };
748
748
  }
@@ -750,7 +750,7 @@ export class GraphQLIssuesService {
750
750
  if (args.cycleId === undefined || args.cycleId === null) {
751
751
  return args.cycleId;
752
752
  }
753
- if (typeof args.cycleId !== "string" || isUuid(args.cycleId)) {
753
+ if (isUuid(args.cycleId)) {
754
754
  return args.cycleId;
755
755
  }
756
756
  let teamIdForCycle = resolveResult.issues?.nodes?.[0]?.team?.id;
@@ -780,7 +780,7 @@ export class GraphQLIssuesService {
780
780
  if (args.milestoneId && !isUuid(args.milestoneId)) {
781
781
  resolveVariables.milestoneName = args.milestoneId;
782
782
  }
783
- if (args.labelIds && Array.isArray(args.labelIds)) {
783
+ if (args.labelIds && args.labelIds.length > 0) {
784
784
  const nonUuidLabels = args.labelIds.filter((id) => !isUuid(id));
785
785
  if (nonUuidLabels.length > 0) {
786
786
  resolveVariables.__labelNames = nonUuidLabels;
@@ -799,30 +799,32 @@ export class GraphQLIssuesService {
799
799
  if (!parentId || isUuid(parentId)) {
800
800
  return parentId;
801
801
  }
802
- const parentIssues = resolveResult.parentIssues;
803
- const parentNodes = parentIssues?.nodes;
802
+ const parentNodes = resolveResult.parentIssues?.nodes;
804
803
  if (!parentNodes?.length) {
805
804
  throw notFoundError("Parent issue", parentId);
806
805
  }
807
806
  return parentNodes[0].id;
808
807
  }
809
808
  transformIssueData(issue) {
810
- const labels = issue.labels;
809
+ // `labels`/`children`/`comments` are connection fields that the
810
+ // fragment always selects, so production responses populate them.
811
+ // Test mocks pass partial objects, so read defensively to keep
812
+ // those test fixtures small.
811
813
  return {
812
814
  ...this.transformIssueCoreFields(issue),
813
815
  ...this.transformIssueRelations(issue),
814
- summary: issue.summary
815
- ? extractSummaryText(issue.summary)
816
- : undefined,
816
+ summary: issue.summary ? extractSummaryText(issue.summary) : undefined,
817
817
  priority: issue.priority,
818
- estimate: issue.estimate || undefined,
819
- dueDate: issue.dueDate || undefined,
820
- labels: labels.nodes.map((label) => ({
818
+ estimate: issue.estimate ?? undefined,
819
+ dueDate: issue.dueDate ?? undefined,
820
+ labels: (issue.labels?.nodes ?? []).map((label) => ({
821
821
  id: label.id,
822
822
  name: label.name,
823
823
  })),
824
824
  ...this.transformIssueHierarchy(issue),
825
- comments: this.transformIssueComments(issue),
825
+ comments: "comments" in issue && issue.comments
826
+ ? this.transformIssueComments(issue)
827
+ : [],
826
828
  createdAt: toISOStringOrNow(issue.createdAt),
827
829
  updatedAt: toISOStringOrNow(issue.updatedAt),
828
830
  };
@@ -833,93 +835,84 @@ export class GraphQLIssuesService {
833
835
  identifier: issue.identifier,
834
836
  url: issue.url,
835
837
  title: issue.title,
836
- description: issue.description || undefined,
838
+ description: issue.description ?? undefined,
837
839
  branchName: issue.branchName || undefined,
838
- embeds: issue.description
839
- ? extractEmbeds(issue.description)
840
- : undefined,
840
+ embeds: issue.description ? extractEmbeds(issue.description) : undefined,
841
841
  };
842
842
  }
843
843
  transformIssueRelations(issue) {
844
- const state = issue.state;
845
- const assignee = issue.assignee;
846
- const team = issue.team;
847
- const project = issue.project;
848
- const cycle = issue.cycle;
849
- const milestone = issue.projectMilestone;
850
844
  return {
851
- state: state
852
- ? { id: state.id, name: state.name }
845
+ state: issue.state
846
+ ? { id: issue.state.id, name: issue.state.name }
853
847
  : undefined,
854
- assignee: assignee
848
+ assignee: issue.assignee
855
849
  ? {
856
- id: assignee.id,
857
- name: resolveUserDisplayName(assignee.id, assignee.name),
858
- url: assignee.url || undefined,
850
+ id: issue.assignee.id,
851
+ name: resolveUserDisplayName(issue.assignee.id, issue.assignee.name),
852
+ url: issue.assignee.url ?? undefined,
859
853
  }
860
854
  : undefined,
861
- team: team
855
+ team: issue.team
862
856
  ? {
863
- id: team.id,
864
- key: team.key,
865
- name: team.name,
857
+ id: issue.team.id,
858
+ key: issue.team.key,
859
+ name: issue.team.name,
866
860
  }
867
861
  : undefined,
868
- project: project
869
- ? { id: project.id, name: project.name }
862
+ project: issue.project
863
+ ? { id: issue.project.id, name: issue.project.name }
870
864
  : undefined,
871
- cycle: cycle
865
+ cycle: issue.cycle
872
866
  ? {
873
- id: cycle.id,
874
- name: cycle.name,
875
- number: cycle.number,
867
+ id: issue.cycle.id,
868
+ name: issue.cycle.name ?? "",
869
+ number: issue.cycle.number,
876
870
  }
877
871
  : undefined,
878
- projectMilestone: milestone
872
+ projectMilestone: issue.projectMilestone
879
873
  ? {
880
- id: milestone.id,
881
- name: milestone.name,
882
- targetDate: milestone.targetDate || undefined,
874
+ id: issue.projectMilestone.id,
875
+ name: issue.projectMilestone.name,
876
+ targetDate: issue.projectMilestone.targetDate ?? undefined,
883
877
  }
884
878
  : undefined,
885
879
  };
886
880
  }
887
881
  transformIssueHierarchy(issue) {
888
- const parent = issue.parent;
889
- const children = issue.children;
882
+ // children is a connection field — always present in production
883
+ // responses. Defensive read keeps test mocks small.
884
+ const childNodes = issue.children?.nodes ?? [];
890
885
  return {
891
- parentIssue: parent
886
+ parentIssue: issue.parent
892
887
  ? {
893
- id: parent.id,
894
- identifier: parent.identifier,
895
- title: parent.title,
888
+ id: issue.parent.id,
889
+ identifier: issue.parent.identifier,
890
+ title: issue.parent.title,
896
891
  }
897
892
  : undefined,
898
- subIssues: children?.nodes?.map((child) => ({
899
- id: child.id,
900
- identifier: child.identifier,
901
- title: child.title,
902
- })) || undefined,
893
+ subIssues: childNodes.length > 0
894
+ ? childNodes.map((child) => ({
895
+ id: child.id,
896
+ identifier: child.identifier,
897
+ title: child.title,
898
+ }))
899
+ : undefined,
903
900
  };
904
901
  }
905
902
  transformIssueComments(issue) {
906
- const comments = issue.comments;
907
- return (comments?.nodes?.map((comment) => {
908
- const commentUser = comment.user;
909
- return {
910
- id: comment.id,
911
- body: comment.body,
912
- embeds: extractEmbeds(comment.body),
913
- user: commentUser
914
- ? {
915
- id: commentUser.id,
916
- name: resolveUserDisplayName(commentUser.id, commentUser.name),
917
- url: commentUser.url || undefined,
918
- }
919
- : undefined,
920
- createdAt: toISOStringOrNow(comment.createdAt),
921
- updatedAt: toISOStringOrNow(comment.updatedAt),
922
- };
923
- }) || []);
903
+ return issue.comments.nodes.map((comment) => ({
904
+ id: comment.id,
905
+ body: comment.body,
906
+ embeds: extractEmbeds(comment.body),
907
+ user: comment.user
908
+ ? {
909
+ id: comment.user.id,
910
+ name: resolveUserDisplayName(comment.user.id, comment.user.name),
911
+ url: comment.user.url ?? undefined,
912
+ }
913
+ : undefined,
914
+ createdAt: toISOStringOrNow(comment.createdAt),
915
+ updatedAt: toISOStringOrNow(comment.updatedAt),
916
+ }));
924
917
  }
925
918
  }