@enrichlayer/el-linear 1.10.0 → 1.15.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 (106) hide show
  1. package/README.md +126 -10
  2. package/claude-skills/linear-operations/SKILL.md +41 -1
  3. package/dist/auth/linear-credential.d.ts +27 -0
  4. package/dist/auth/linear-credential.js +1 -0
  5. package/dist/auth/oauth-app-config.d.ts +4 -3
  6. package/dist/auth/oauth-app-config.js +13 -2
  7. package/dist/auth/oauth-callback.d.ts +2 -3
  8. package/dist/auth/oauth-callback.js +2 -2
  9. package/dist/auth/oauth-client.d.ts +8 -2
  10. package/dist/auth/oauth-client.js +26 -0
  11. package/dist/auth/oauth-fs.d.ts +2 -1
  12. package/dist/auth/oauth-headless.d.ts +2 -1
  13. package/dist/auth/oauth-storage.d.ts +5 -1
  14. package/dist/auth/oauth-storage.js +1 -1
  15. package/dist/auth/oauth-token.d.ts +4 -3
  16. package/dist/auth/oauth-token.js +16 -4
  17. package/dist/auth/token-resolver.d.ts +14 -5
  18. package/dist/auth/token-resolver.js +6 -1
  19. package/dist/commands/batch.js +18 -21
  20. package/dist/commands/comments.js +5 -5
  21. package/dist/commands/config.js +178 -5
  22. package/dist/commands/init/aliases.js +1 -1
  23. package/dist/commands/init/defaults.d.ts +2 -1
  24. package/dist/commands/init/index.js +45 -35
  25. package/dist/commands/init/oauth.d.ts +4 -1
  26. package/dist/commands/init/oauth.js +22 -4
  27. package/dist/commands/init/shared.d.ts +24 -2
  28. package/dist/commands/init/shared.js +35 -4
  29. package/dist/commands/init/token.d.ts +3 -3
  30. package/dist/commands/init/token.js +5 -24
  31. package/dist/commands/init/workspace.d.ts +2 -1
  32. package/dist/commands/init/workspace.js +1 -1
  33. package/dist/commands/introspect.d.ts +27 -0
  34. package/dist/commands/introspect.js +178 -0
  35. package/dist/commands/issues/branch.js +9 -1
  36. package/dist/commands/issues/relations.d.ts +3 -14
  37. package/dist/commands/issues/relations.js +3 -3
  38. package/dist/commands/issues.js +222 -43
  39. package/dist/commands/labels.js +2 -1
  40. package/dist/commands/profile.js +1 -0
  41. package/dist/commands/projects.d.ts +2 -0
  42. package/dist/commands/projects.js +91 -7
  43. package/dist/commands/read-shortcut.d.ts +1 -1
  44. package/dist/commands/read-shortcut.js +28 -8
  45. package/dist/commands/refs.js +67 -8
  46. package/dist/commands/search.js +30 -5
  47. package/dist/commands/users.js +4 -2
  48. package/dist/config/config.d.ts +99 -1
  49. package/dist/config/config.js +264 -52
  50. package/dist/config/error-enrichment.d.ts +62 -0
  51. package/dist/config/error-enrichment.js +417 -0
  52. package/dist/config/issue-validation.d.ts +37 -0
  53. package/dist/config/issue-validation.js +63 -1
  54. package/dist/config/paths.d.ts +2 -8
  55. package/dist/config/paths.js +4 -2
  56. package/dist/config/resolver.d.ts +8 -1
  57. package/dist/config/resolver.js +9 -2
  58. package/dist/main.js +13 -1
  59. package/dist/queries/comments-types.d.ts +15 -9
  60. package/dist/queries/common.d.ts +2 -2
  61. package/dist/queries/common.js +8 -0
  62. package/dist/queries/documents-types.d.ts +4 -3
  63. package/dist/queries/introspect-types.d.ts +8 -7
  64. package/dist/queries/issues-types.d.ts +92 -27
  65. package/dist/queries/issues.d.ts +49 -10
  66. package/dist/queries/issues.js +125 -5
  67. package/dist/queries/labels-types.d.ts +7 -6
  68. package/dist/queries/project-milestones-types.d.ts +5 -4
  69. package/dist/queries/project-milestones.d.ts +1 -1
  70. package/dist/queries/projects-types.d.ts +8 -7
  71. package/dist/queries/releases-types.d.ts +5 -4
  72. package/dist/queries/search-types.d.ts +28 -12
  73. package/dist/queries/templates-types.d.ts +3 -2
  74. package/dist/types/linear.d.ts +13 -1
  75. package/dist/utils/auto-link-references.d.ts +3 -3
  76. package/dist/utils/auto-link-references.js +1 -10
  77. package/dist/utils/extract-field.d.ts +19 -0
  78. package/dist/utils/extract-field.js +99 -0
  79. package/dist/utils/file-service.d.ts +6 -13
  80. package/dist/utils/file-service.js +0 -2
  81. package/dist/utils/formatters/summary.js +6 -1
  82. package/dist/utils/graphql-issues-service.d.ts +101 -45
  83. package/dist/utils/graphql-issues-service.js +252 -39
  84. package/dist/utils/graphql-service.d.ts +10 -12
  85. package/dist/utils/graphql-service.js +0 -3
  86. package/dist/utils/issue-reference-extractor.d.ts +7 -0
  87. package/dist/utils/issue-reference-extractor.js +5 -3
  88. package/dist/utils/issues-service-bootstrap.d.ts +28 -0
  89. package/dist/utils/issues-service-bootstrap.js +27 -0
  90. package/dist/utils/linear-service.d.ts +21 -14
  91. package/dist/utils/linear-service.js +73 -11
  92. package/dist/utils/markdown-prosemirror.js +12 -12
  93. package/dist/utils/mention-resolver.js +1 -1
  94. package/dist/utils/output.d.ts +81 -3
  95. package/dist/utils/output.js +61 -6
  96. package/dist/utils/project-slug.d.ts +21 -0
  97. package/dist/utils/project-slug.js +45 -0
  98. package/dist/utils/protected-ranges.d.ts +14 -0
  99. package/dist/utils/protected-ranges.js +88 -2
  100. package/dist/utils/sanitize-for-log.d.ts +24 -0
  101. package/dist/utils/sanitize-for-log.js +38 -0
  102. package/dist/utils/table-formatter.js +24 -0
  103. package/dist/utils/validators.d.ts +7 -2
  104. package/dist/utils/validators.js +6 -0
  105. package/dist/utils/workspace-url.js +20 -4
  106. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  import { resolveUserDisplayName } from "../config/resolver.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";
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_START_CONTEXT_QUERY, GET_ISSUE_TEAM_QUERY, GET_ISSUES_QUERY, SEARCH_ISSUES_QUERY, TEAM_STARTED_STATUSES_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";
@@ -61,34 +61,81 @@ export class GraphQLIssuesService {
61
61
  }
62
62
  return this.transformIssueData(issueData);
63
63
  }
64
+ async startIssue(issueId) {
65
+ const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
66
+ const context = await this.graphQLService.rawRequest(GET_ISSUE_START_CONTEXT_QUERY, { id: resolvedIssueId });
67
+ const issue = context.issue;
68
+ if (!issue) {
69
+ throw notFoundError("Issue", issueId);
70
+ }
71
+ const previousState = issue.state
72
+ ? {
73
+ id: issue.state.id,
74
+ name: issue.state.name,
75
+ type: issue.state.type,
76
+ }
77
+ : undefined;
78
+ if (previousState &&
79
+ ["started", "completed", "canceled"].includes(previousState.type)) {
80
+ return {
81
+ issue: await this.getIssueById(resolvedIssueId),
82
+ previousState,
83
+ started: false,
84
+ };
85
+ }
86
+ if (!issue.team?.id) {
87
+ throw new Error(`Issue ${issue.identifier} has no team; cannot start it.`);
88
+ }
89
+ const statuses = await this.graphQLService.rawRequest(TEAM_STARTED_STATUSES_QUERY, { teamId: issue.team.id });
90
+ const startedStatus = statuses.team?.states.nodes
91
+ .slice()
92
+ .sort((a, b) => a.position - b.position)[0];
93
+ if (!startedStatus) {
94
+ throw new Error(`Team ${issue.team.key} has no workflow status of type "started".`);
95
+ }
96
+ const updated = await this.updateIssue({ id: resolvedIssueId, statusId: startedStatus.id }, "adding");
97
+ return {
98
+ issue: updated,
99
+ previousState,
100
+ started: true,
101
+ targetState: { id: startedStatus.id, name: startedStatus.name },
102
+ };
103
+ }
64
104
  async updateIssue(args, labelMode = "overwriting") {
65
- const { resolvedIssueId, issueTeamId, currentIssueLabels, resolveResult } = await this.resolveUpdateContext(args);
105
+ // Normalize URL/slug-id --project inputs to UUIDs before the batch
106
+ // resolver runs; see comment on `withNormalizedProjectId`.
107
+ const normalizedArgs = await this.withNormalizedProjectId(args);
108
+ const { resolvedIssueId, issueTeamId, currentIssueLabels, resolveResult } = await this.resolveUpdateContext(normalizedArgs);
66
109
  let teamIdForLabels = issueTeamId;
67
- if (!teamIdForLabels && args.labelIds && resolvedIssueId) {
110
+ if (!teamIdForLabels && normalizedArgs.labelIds && resolvedIssueId) {
68
111
  teamIdForLabels = await this.fetchIssueTeamId(resolvedIssueId);
69
112
  }
70
- const finalLabelIds = await this.resolveLabelsWithMode(args.labelIds, resolveResult, labelMode, currentIssueLabels, teamIdForLabels, args.teamId);
71
- const finalProjectId = args.projectId
72
- ? this.resolveProjectId(args.projectId, resolveResult)
113
+ const finalLabelIds = await this.resolveLabelsWithMode(normalizedArgs.labelIds, resolveResult, labelMode, currentIssueLabels, teamIdForLabels, normalizedArgs.teamId);
114
+ const finalProjectId = normalizedArgs.projectId
115
+ ? this.resolveProjectId(normalizedArgs.projectId, resolveResult)
73
116
  : undefined;
74
- const { projectMilestoneNodes, issueProjectMilestoneNodes } = this.extractMilestoneNodes(args, resolveResult);
75
- const finalMilestoneId = this.resolveMilestoneId(args.milestoneId, resolveResult, projectMilestoneNodes, issueProjectMilestoneNodes);
76
- const finalCycleId = await this.resolveCycleIdForUpdate(args, resolveResult, resolvedIssueId);
77
- const resolvedStatusId = await this.resolveStatusIdForUpdate(args, resolvedIssueId);
117
+ const { projectMilestoneNodes, issueProjectMilestoneNodes } = this.extractMilestoneNodes(normalizedArgs, resolveResult);
118
+ const finalMilestoneId = this.resolveMilestoneId(normalizedArgs.milestoneId, resolveResult, projectMilestoneNodes, issueProjectMilestoneNodes);
119
+ const finalCycleId = await this.resolveCycleIdForUpdate(normalizedArgs, resolveResult, resolvedIssueId);
120
+ const resolvedStatusId = await this.resolveStatusIdForUpdate(normalizedArgs, resolvedIssueId);
78
121
  // Resolve assignee in place — `buildUpdateInput` reads it
79
122
  // directly. Match the createIssue pattern.
80
- let assigneeId = args.assigneeId;
123
+ let assigneeId = normalizedArgs.assigneeId;
81
124
  if (assigneeId && !isUuid(assigneeId)) {
82
125
  assigneeId = await this.linearService.resolveUserId(assigneeId);
83
126
  }
84
- const updateInput = this.buildUpdateInput({ ...args, assigneeId }, {
127
+ let delegateId = normalizedArgs.delegateId;
128
+ if (delegateId && !isUuid(delegateId)) {
129
+ delegateId = await this.linearService.resolveUserId(delegateId);
130
+ }
131
+ const updateInput = this.buildUpdateInput({ ...normalizedArgs, assigneeId, delegateId }, {
85
132
  statusId: resolvedStatusId,
86
133
  projectId: finalProjectId,
87
134
  labelIds: finalLabelIds,
88
135
  milestoneId: finalMilestoneId,
89
136
  cycleId: finalCycleId,
90
137
  });
91
- return this.executeUpdateMutation(resolvedIssueId, args.id, updateInput);
138
+ return this.executeUpdateMutation(resolvedIssueId, normalizedArgs.id, updateInput);
92
139
  }
93
140
  async archiveIssue(issueId) {
94
141
  const resolvedIssueId = await this.linearService.resolveIssueId(issueId);
@@ -154,7 +201,12 @@ export class GraphQLIssuesService {
154
201
  return this.transformIssueData(issueUpdate.issue);
155
202
  }
156
203
  async createIssue(args) {
157
- const resolveVariables = this.buildCreateResolveVariables(args);
204
+ // Pre-resolve URL/slug-id forms of --project to a UUID so the batch
205
+ // resolver below (which uses a `name eqIgnoreCase` filter) can skip
206
+ // the project lookup entirely. Plain name inputs flow through unchanged
207
+ // and get resolved by the batch query in a single round-trip.
208
+ const normalizedArgs = await this.withNormalizedProjectId(args);
209
+ const resolveVariables = this.buildCreateResolveVariables(normalizedArgs);
158
210
  let resolveResult = {};
159
211
  if (Object.keys(resolveVariables).length > 0) {
160
212
  const { __labelNames, ...queryVars } = resolveVariables;
@@ -165,23 +217,56 @@ export class GraphQLIssuesService {
165
217
  batchPromise,
166
218
  this.graphQLService.rawRequest(labelQuery.query, labelQuery.variables),
167
219
  ]);
168
- resolveResult = { ...batch, labels: labels.labels };
220
+ resolveResult = {
221
+ ...this.foldCreateBatchResult(batch),
222
+ labels: labels.labels,
223
+ };
169
224
  }
170
225
  else {
171
- resolveResult = await batchPromise;
226
+ resolveResult = this.foldCreateBatchResult(await batchPromise);
172
227
  }
173
228
  }
174
- const resolved = await this.resolveCreateFields(args, resolveResult);
229
+ const resolved = await this.resolveCreateFields(normalizedArgs, resolveResult);
175
230
  // Mutate-the-input feels gross but matches the existing pattern
176
231
  // — `resolved` doesn't carry assigneeId, so the buildCreateInput
177
232
  // step reads `args.assigneeId` directly. Resolve in place.
178
- let assigneeId = args.assigneeId;
233
+ let assigneeId = normalizedArgs.assigneeId;
179
234
  if (assigneeId && !isUuid(assigneeId)) {
180
235
  assigneeId = await this.linearService.resolveUserId(assigneeId);
181
236
  }
182
- const createInput = this.buildCreateInput({ ...args, assigneeId }, resolved);
237
+ let delegateId = normalizedArgs.delegateId;
238
+ if (delegateId && !isUuid(delegateId)) {
239
+ delegateId = await this.linearService.resolveUserId(delegateId);
240
+ }
241
+ const createInput = this.buildCreateInput({ ...normalizedArgs, assigneeId, delegateId }, resolved);
183
242
  return this.executeCreateMutation(createInput);
184
243
  }
244
+ /**
245
+ * Folds the `@include`-gated `projectsByName` / `projectsById` aliases
246
+ * from a create batch response into the single `projects` field the
247
+ * field-resolution helpers expect. At most one project alias is ever
248
+ * present, since a `--project` input is either a name or a UUID.
249
+ */
250
+ foldCreateBatchResult(batch) {
251
+ return {
252
+ teams: batch.teams,
253
+ projects: batch.projectsByName ?? batch.projectsById,
254
+ milestones: batch.milestones,
255
+ parentIssues: batch.parentIssues,
256
+ };
257
+ }
258
+ /**
259
+ * Folds the `@include`-gated `projectsByName` / `projectsById` aliases
260
+ * from an update batch response into the single `projects` field the
261
+ * field-resolution helpers expect. See `foldCreateBatchResult`.
262
+ */
263
+ foldUpdateBatchResult(batch) {
264
+ return {
265
+ projects: batch.projectsByName ?? batch.projectsById,
266
+ milestones: batch.milestones,
267
+ issues: batch.issues,
268
+ };
269
+ }
185
270
  async resolveCreateFields(args, resolveResult) {
186
271
  let teamId = args.teamId
187
272
  ? await this.resolveTeamId(args.teamId, resolveResult)
@@ -239,14 +324,23 @@ export class GraphQLIssuesService {
239
324
  if (args.teamId && !isUuid(args.teamId)) {
240
325
  Object.assign(resolveVariables, this.buildResolveVariablesForTeam(args.teamId));
241
326
  }
242
- if (args.projectId && !isUuid(args.projectId)) {
243
- resolveVariables.projectName = args.projectId;
327
+ // Only the `id` arm carries a name/UUID for resolution; `none` and
328
+ // `undefined` skip the resolve.
329
+ const projectIdArg = args.project?.kind === "id" ? args.project.id : undefined;
330
+ if (projectIdArg && !isUuid(projectIdArg)) {
331
+ resolveVariables.projectName = projectIdArg;
332
+ resolveVariables.hasProjectName = true;
244
333
  }
245
334
  if (args.assigneeId &&
246
335
  !isUuid(args.assigneeId) &&
247
336
  args.assigneeId.includes("@")) {
248
337
  resolveVariables.assigneeEmail = args.assigneeId;
249
338
  }
339
+ if (args.delegateId &&
340
+ !isUuid(args.delegateId) &&
341
+ args.delegateId.includes("@")) {
342
+ resolveVariables.delegateEmail = args.delegateId;
343
+ }
250
344
  let resolveResult = {};
251
345
  if (Object.keys(resolveVariables).length > 0) {
252
346
  resolveResult =
@@ -255,10 +349,24 @@ export class GraphQLIssuesService {
255
349
  const finalTeamId = args.teamId
256
350
  ? await this.resolveTeamId(args.teamId, resolveResult)
257
351
  : undefined;
258
- const finalProjectId = args.projectId
259
- ? this.resolveProjectId(args.projectId, resolveResult)
352
+ const finalProjectId = projectIdArg
353
+ ? this.resolveProjectId(projectIdArg, resolveResult)
260
354
  : undefined;
261
355
  const finalAssigneeId = this.resolveAssigneeId(args.assigneeId, resolveResult);
356
+ const finalDelegateId = await this.resolveDelegateId(args.delegateId, resolveResult);
357
+ // Map the public discriminant back to the internal filter shape:
358
+ // `{ kind: "id", id }` → `{ kind: "id", id: <resolvedUuid> }`
359
+ // `{ kind: "none" }` → pass through
360
+ // undefined → undefined (filter off)
361
+ // Reused by both the full-text and structured search paths so
362
+ // `--no-project` (`{ kind: "none" }`) participates in both — pre-fix,
363
+ // the full-text branch silently dropped `noProject` because only
364
+ // the resolved `projectId` was forwarded.
365
+ const projectFilter = args.project?.kind === "id" && finalProjectId
366
+ ? { kind: "id", id: finalProjectId }
367
+ : args.project?.kind === "none"
368
+ ? { kind: "none" }
369
+ : undefined;
262
370
  const limit = args.limit ?? 10;
263
371
  if (args.query) {
264
372
  const searchResult = await this.graphQLService.rawRequest(SEARCH_ISSUES_QUERY, {
@@ -273,7 +381,8 @@ export class GraphQLIssuesService {
273
381
  return this.applySearchFilters(results, {
274
382
  teamId: finalTeamId,
275
383
  assigneeId: finalAssigneeId,
276
- projectId: finalProjectId,
384
+ delegateId: finalDelegateId,
385
+ project: projectFilter,
277
386
  status: args.status,
278
387
  labelNames: args.labelNames,
279
388
  priority: args.priority,
@@ -282,8 +391,8 @@ export class GraphQLIssuesService {
282
391
  const filter = this.buildSearchFilter({
283
392
  teamId: finalTeamId,
284
393
  assigneeId: finalAssigneeId,
285
- projectId: finalProjectId,
286
- noProject: args.noProject,
394
+ delegateId: finalDelegateId,
395
+ project: projectFilter,
287
396
  status: args.status,
288
397
  labelNames: args.labelNames,
289
398
  priority: args.priority,
@@ -316,6 +425,18 @@ export class GraphQLIssuesService {
316
425
  }
317
426
  resolveProjectId(projectId, resolveResult) {
318
427
  if (isUuid(projectId)) {
428
+ // The create/update batch queries fetch a `projectsById` block for a
429
+ // UUID `--project` (folded into `resolveResult.projects`). An empty
430
+ // `nodes` array means the block ran but matched nothing — i.e. the
431
+ // UUID doesn't exist. Fail clearly here instead of passing a dead
432
+ // UUID to the mutation, which would surface a vague server error.
433
+ // When no block was fetched (`projects` undefined — e.g. the search
434
+ // path has no `projectsById` arm) there's nothing to validate, so
435
+ // the UUID passes through unchanged.
436
+ const fetched = resolveResult.projects?.nodes;
437
+ if (fetched && fetched.length === 0) {
438
+ throw notFoundError("Project", projectId);
439
+ }
319
440
  return projectId;
320
441
  }
321
442
  const projectNodes = resolveResult.projects?.nodes;
@@ -542,6 +663,9 @@ export class GraphQLIssuesService {
542
663
  if (args.assigneeId) {
543
664
  input.assigneeId = args.assigneeId;
544
665
  }
666
+ if (args.delegateId) {
667
+ input.delegateId = args.delegateId;
668
+ }
545
669
  if (args.priority !== undefined) {
546
670
  input.priority = args.priority;
547
671
  }
@@ -597,6 +721,9 @@ export class GraphQLIssuesService {
597
721
  if (args.assigneeId !== undefined) {
598
722
  input.assigneeId = args.assigneeId;
599
723
  }
724
+ if (args.delegateId !== undefined) {
725
+ input.delegateId = args.delegateId;
726
+ }
600
727
  if (resolved.projectId !== undefined) {
601
728
  input.projectId = resolved.projectId;
602
729
  }
@@ -635,8 +762,22 @@ export class GraphQLIssuesService {
635
762
  if (filters.assigneeId) {
636
763
  filtered = filtered.filter((issue) => issue.assignee?.id === filters.assigneeId);
637
764
  }
638
- if (filters.projectId) {
639
- filtered = filtered.filter((issue) => issue.project?.id === filters.projectId);
765
+ if (filters.delegateId) {
766
+ filtered = filtered.filter((issue) => issue.delegate?.id === filters.delegateId);
767
+ }
768
+ if (filters.project) {
769
+ switch (filters.project.kind) {
770
+ case "id": {
771
+ const id = filters.project.id;
772
+ filtered = filtered.filter((issue) => issue.project?.id === id);
773
+ break;
774
+ }
775
+ case "none":
776
+ filtered = filtered.filter((issue) => !issue.project);
777
+ break;
778
+ default:
779
+ return filters.project;
780
+ }
640
781
  }
641
782
  if (filters.status && filters.status.length > 0) {
642
783
  filtered = filtered.filter((issue) => filters.status.includes(issue.state?.name ?? ""));
@@ -661,11 +802,20 @@ export class GraphQLIssuesService {
661
802
  if (filters.assigneeId) {
662
803
  filter.assignee = { id: { eq: filters.assigneeId } };
663
804
  }
664
- if (filters.noProject) {
665
- filter.project = { null: true };
666
- }
667
- else if (filters.projectId) {
668
- filter.project = { id: { eq: filters.projectId } };
805
+ if (filters.delegateId) {
806
+ filter.delegate = { id: { eq: filters.delegateId } };
807
+ }
808
+ if (filters.project) {
809
+ switch (filters.project.kind) {
810
+ case "id":
811
+ filter.project = { id: { eq: filters.project.id } };
812
+ break;
813
+ case "none":
814
+ filter.project = { null: true };
815
+ break;
816
+ default:
817
+ return filters.project;
818
+ }
669
819
  }
670
820
  if (filters.status && filters.status.length > 0) {
671
821
  filter.state = { name: { in: filters.status } };
@@ -699,6 +849,19 @@ export class GraphQLIssuesService {
699
849
  }
700
850
  return userNodes[0].id;
701
851
  }
852
+ async resolveDelegateId(delegateId, resolveResult) {
853
+ if (!delegateId || isUuid(delegateId)) {
854
+ return delegateId;
855
+ }
856
+ if (!delegateId.includes("@")) {
857
+ return this.linearService.resolveUserId(delegateId);
858
+ }
859
+ const userNodes = resolveResult.delegates?.nodes;
860
+ if (!userNodes?.length) {
861
+ throw notFoundError("Delegate", delegateId);
862
+ }
863
+ return userNodes[0].id;
864
+ }
702
865
  async resolveUpdateContext(args) {
703
866
  let resolvedIssueId = args.id;
704
867
  let issueTeamId;
@@ -715,11 +878,23 @@ export class GraphQLIssuesService {
715
878
  resolveVariables.__labelNames = nonUuidLabels;
716
879
  }
717
880
  }
718
- if (args.projectId && !isUuid(args.projectId)) {
719
- resolveVariables.projectName = args.projectId;
881
+ if (args.projectId) {
882
+ // A UUID resolves by `id` so the project's milestones are still
883
+ // fetched for `--project-milestone` name resolution; a name
884
+ // resolves by `name`. The `has*` flags gate the `@include`d
885
+ // blocks so an unset input never triggers a null no-op filter.
886
+ if (isUuid(args.projectId)) {
887
+ resolveVariables.projectId = args.projectId;
888
+ resolveVariables.hasProjectId = true;
889
+ }
890
+ else {
891
+ resolveVariables.projectName = args.projectId;
892
+ resolveVariables.hasProjectName = true;
893
+ }
720
894
  }
721
895
  if (args.milestoneId && !isUuid(args.milestoneId)) {
722
896
  resolveVariables.milestoneName = args.milestoneId;
897
+ resolveVariables.hasMilestoneName = true;
723
898
  }
724
899
  const { __labelNames, ...updateQueryVars } = resolveVariables;
725
900
  const batchPromise = this.graphQLService.rawRequest(BATCH_RESOLVE_FOR_UPDATE_QUERY, updateQueryVars);
@@ -730,10 +905,13 @@ export class GraphQLIssuesService {
730
905
  batchPromise,
731
906
  this.graphQLService.rawRequest(labelQuery.query, labelQuery.variables),
732
907
  ]);
733
- resolveResult = { ...batch, labels: labels.labels };
908
+ resolveResult = {
909
+ ...this.foldUpdateBatchResult(batch),
910
+ labels: labels.labels,
911
+ };
734
912
  }
735
913
  else {
736
- resolveResult = await batchPromise;
914
+ resolveResult = this.foldUpdateBatchResult(await batchPromise);
737
915
  }
738
916
  if (!isUuid(args.id)) {
739
917
  const resolvedIssueNodes = resolveResult.issues?.nodes ?? [];
@@ -769,16 +947,44 @@ export class GraphQLIssuesService {
769
947
  }
770
948
  return this.linearService.resolveStatusId(args.statusId, teamId);
771
949
  }
950
+ /**
951
+ * Pre-resolve URL/slug-id project inputs to UUIDs before the batch
952
+ * resolver runs. Plain names and UUIDs pass through unchanged; only
953
+ * URL/slug-id forms incur a separate slug→UUID round-trip (the batch
954
+ * query's `name eqIgnoreCase` filter can't match a URL string).
955
+ */
956
+ async withNormalizedProjectId(args) {
957
+ if (!args.projectId) {
958
+ return args;
959
+ }
960
+ const normalized = await this.linearService.normalizeProjectInput(args.projectId);
961
+ if (normalized === args.projectId) {
962
+ return args;
963
+ }
964
+ return { ...args, projectId: normalized };
965
+ }
772
966
  buildCreateResolveVariables(args) {
773
967
  const resolveVariables = {};
774
968
  if (args.teamId && !isUuid(args.teamId)) {
775
969
  Object.assign(resolveVariables, this.buildResolveVariablesForTeam(args.teamId));
776
970
  }
777
- if (args.projectId && !isUuid(args.projectId)) {
778
- resolveVariables.projectName = args.projectId;
971
+ if (args.projectId) {
972
+ // A UUID is resolved by `id` so the project's team associations
973
+ // are still fetched for team-project validation; a name is
974
+ // resolved by `name`. The `has*` flags gate the `@include`d
975
+ // blocks so an unset input never triggers a null no-op filter.
976
+ if (isUuid(args.projectId)) {
977
+ resolveVariables.projectId = args.projectId;
978
+ resolveVariables.hasProjectId = true;
979
+ }
980
+ else {
981
+ resolveVariables.projectName = args.projectId;
982
+ resolveVariables.hasProjectName = true;
983
+ }
779
984
  }
780
985
  if (args.milestoneId && !isUuid(args.milestoneId)) {
781
986
  resolveVariables.milestoneName = args.milestoneId;
987
+ resolveVariables.hasMilestoneName = true;
782
988
  }
783
989
  if (args.labelIds && args.labelIds.length > 0) {
784
990
  const nonUuidLabels = args.labelIds.filter((id) => !isUuid(id));
@@ -852,6 +1058,13 @@ export class GraphQLIssuesService {
852
1058
  url: issue.assignee.url ?? undefined,
853
1059
  }
854
1060
  : undefined,
1061
+ delegate: issue.delegate
1062
+ ? {
1063
+ id: issue.delegate.id,
1064
+ name: resolveUserDisplayName(issue.delegate.id, issue.delegate.name),
1065
+ url: issue.delegate.url ?? undefined,
1066
+ }
1067
+ : undefined,
855
1068
  team: issue.team
856
1069
  ? {
857
1070
  id: issue.team.id,
@@ -1,20 +1,18 @@
1
+ import type { LinearCredential } from "../auth/linear-credential.js";
1
2
  import type { GraphQLResponseData, GraphQLVariables } from "../types/linear.js";
2
3
  import type { AuthOptions } from "./auth.js";
3
4
  /**
4
- * Constructor arg shapes for `GraphQLService`. Three variants:
5
- * - `string` → personal API token (legacy; sent without `Bearer` prefix).
6
- * - `{apiKey: string}` → personal API token (explicit).
7
- * - `{oauthToken: string}` → OAuth access token (sent as
8
- * `Authorization: Bearer <token>` via the SDK's accessToken option).
5
+ * Constructor arg for `GraphQLService`. Re-exported alias of the shared
6
+ * `LinearCredential` union (`{ apiKey } | { oauthToken }`). Kept as a
7
+ * named local export so call sites that already import
8
+ * `GraphQLServiceAuth` keep compiling — the alias collapses through to
9
+ * the shared shape.
9
10
  *
10
- * The string variant exists because hundreds of call sites and tests pass
11
- * a plain string. We continue to support it indefinitely.
11
+ * The bare-string legacy arm was dropped in DEV-4068 T7. Tests now
12
+ * construct with `{ apiKey: "test-token" }` (mechanical rewrite, no
13
+ * semantic change — same `Authorization: <token>` header is emitted).
12
14
  */
13
- export type GraphQLServiceAuth = string | {
14
- apiKey: string;
15
- } | {
16
- oauthToken: string;
17
- };
15
+ export type GraphQLServiceAuth = LinearCredential;
18
16
  export declare class GraphQLService {
19
17
  private readonly graphQLClient;
20
18
  constructor(auth: GraphQLServiceAuth);
@@ -2,9 +2,6 @@ import { LinearClient } from "@linear/sdk";
2
2
  import { getActiveAuth } from "../auth/token-resolver.js";
3
3
  function buildLinearClient(auth) {
4
4
  const baseHeaders = { "public-file-urls-expire-in": "3600" };
5
- if (typeof auth === "string") {
6
- return new LinearClient({ apiKey: auth, headers: baseHeaders });
7
- }
8
5
  if ("oauthToken" in auth) {
9
6
  // Linear's SDK natively supports OAuth via the `accessToken` option,
10
7
  // which causes the underlying graphql-request client to send
@@ -5,6 +5,13 @@ export interface IssueReference {
5
5
  reverse: boolean;
6
6
  type: IssueRelationType;
7
7
  }
8
+ /**
9
+ * Specificity ranking — used when the same identifier appears more than once
10
+ * in the text with different qualifiers. The strongest non-default inference
11
+ * wins. Exported because `auto-link-references` does the same merge over its
12
+ * own description+comments fan-in (DEV-4070 deduplication).
13
+ */
14
+ export declare function specificity(ref: IssueReference): number;
8
15
  /**
9
16
  * Extract issue identifiers from text along with the inferred relation type.
10
17
  *
@@ -45,10 +45,12 @@ function inferRelation(textBefore) {
45
45
  return { type: "related", reverse: false };
46
46
  }
47
47
  /**
48
- * Specificity ranking — used when the same identifier appears more than once in the text
49
- * with different qualifiers. The strongest non-default inference wins.
48
+ * Specificity ranking — used when the same identifier appears more than once
49
+ * in the text with different qualifiers. The strongest non-default inference
50
+ * wins. Exported because `auto-link-references` does the same merge over its
51
+ * own description+comments fan-in (DEV-4070 deduplication).
50
52
  */
51
- function specificity(ref) {
53
+ export function specificity(ref) {
52
54
  if (ref.type === "duplicate") {
53
55
  return 3;
54
56
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Service-trio bootstrap helper.
3
+ *
4
+ * The 3-line incantation
5
+ *
6
+ * ```ts
7
+ * const graphQLService = await createGraphQLService(rootOpts);
8
+ * const linearService = await createLinearService(rootOpts);
9
+ * const issuesService = new GraphQLIssuesService(graphQLService, linearService);
10
+ * ```
11
+ *
12
+ * repeats verbatim across ~13 issue/batch command handlers. This helper
13
+ * collapses it to a single line; callers destructure only what they use.
14
+ *
15
+ * Sites that need just `graphQLService` + `linearService` (no `issuesService`)
16
+ * can keep their direct calls — there's no benefit to routing through a
17
+ * helper that constructs an unused third service.
18
+ */
19
+ import type { AuthOptions } from "./auth.js";
20
+ import { GraphQLIssuesService } from "./graphql-issues-service.js";
21
+ import { type GraphQLService } from "./graphql-service.js";
22
+ import { type LinearService } from "./linear-service.js";
23
+ export interface IssuesServiceTrio {
24
+ graphQLService: GraphQLService;
25
+ linearService: LinearService;
26
+ issuesService: GraphQLIssuesService;
27
+ }
28
+ export declare function createIssuesService(options: AuthOptions): Promise<IssuesServiceTrio>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Service-trio bootstrap helper.
3
+ *
4
+ * The 3-line incantation
5
+ *
6
+ * ```ts
7
+ * const graphQLService = await createGraphQLService(rootOpts);
8
+ * const linearService = await createLinearService(rootOpts);
9
+ * const issuesService = new GraphQLIssuesService(graphQLService, linearService);
10
+ * ```
11
+ *
12
+ * repeats verbatim across ~13 issue/batch command handlers. This helper
13
+ * collapses it to a single line; callers destructure only what they use.
14
+ *
15
+ * Sites that need just `graphQLService` + `linearService` (no `issuesService`)
16
+ * can keep their direct calls — there's no benefit to routing through a
17
+ * helper that constructs an unused third service.
18
+ */
19
+ import { GraphQLIssuesService } from "./graphql-issues-service.js";
20
+ import { createGraphQLService, } from "./graphql-service.js";
21
+ import { createLinearService } from "./linear-service.js";
22
+ export async function createIssuesService(options) {
23
+ const graphQLService = await createGraphQLService(options);
24
+ const linearService = await createLinearService(options);
25
+ const issuesService = new GraphQLIssuesService(graphQLService, linearService);
26
+ return { graphQLService, linearService, issuesService };
27
+ }
@@ -1,20 +1,13 @@
1
+ import type { LinearCredential } from "../auth/linear-credential.js";
1
2
  import type { LinearComment, LinearCycleDetail, LinearCycleSummary, LinearLabel, LinearProject, LinearTeam, LinearUser } from "../types/linear.js";
2
3
  import type { AuthOptions } from "./auth.js";
3
4
  /**
4
- * Constructor arg shapes for `LinearService`. Three variants:
5
- * - `string` → personal API token (legacy; sent without `Bearer` prefix).
6
- * - `{apiKey: string}` → personal API token (explicit).
7
- * - `{oauthToken: string}` → OAuth access token (sent as
8
- * `Authorization: Bearer <token>` via the SDK's accessToken option).
9
- *
10
- * The string variant exists because hundreds of call sites and tests pass
11
- * a plain string. We continue to support it indefinitely.
5
+ * Constructor arg for `LinearService`. Re-exported alias of the shared
6
+ * `LinearCredential` union (`{ apiKey } | { oauthToken }`). See
7
+ * `src/auth/linear-credential.ts` for the contract. The bare-string
8
+ * legacy arm was dropped in DEV-4068 T7.
12
9
  */
13
- export type LinearServiceAuth = string | {
14
- apiKey: string;
15
- } | {
16
- oauthToken: string;
17
- };
10
+ export type LinearServiceAuth = LinearCredential;
18
11
  export declare class LinearService {
19
12
  private readonly client;
20
13
  constructor(auth: LinearServiceAuth);
@@ -26,6 +19,8 @@ export declare class LinearService {
26
19
  nameFilter?: string;
27
20
  states?: string[];
28
21
  excludeStates?: string[];
22
+ /** Resolved team UUID — applied as a server-side filter before pagination. */
23
+ teamId?: string;
29
24
  }): Promise<LinearProject[]>;
30
25
  resolveTeamId(teamKeyOrNameOrId: string): Promise<string>;
31
26
  resolveStatusId(statusName: string, teamId?: string): Promise<string>;
@@ -40,6 +35,18 @@ export declare class LinearService {
40
35
  getCycles(teamFilter?: string, activeOnly?: boolean, limit?: number): Promise<LinearCycleSummary[]>;
41
36
  getCycleById(cycleId: string, issuesLimit?: number): Promise<LinearCycleDetail>;
42
37
  resolveCycleId(cycleNameOrId: string, teamFilter?: string): Promise<string>;
43
- resolveProjectId(projectNameOrId: string): Promise<string>;
38
+ resolveProjectId(projectInput: string): Promise<string>;
39
+ /**
40
+ * Normalize a user-supplied project input to a UUID when the input is
41
+ * a URL or slug-id form. Pass-through for UUIDs and plain names — the
42
+ * latter stays a name so downstream batch-resolve queries can fold the
43
+ * lookup into their single round-trip.
44
+ *
45
+ * Used by callers that route project resolution through a separate
46
+ * batch-resolve step (e.g. `GraphqlIssuesService.createIssue`) — they
47
+ * can pre-normalize URL/slug inputs to UUIDs so the batch query's
48
+ * `name eqIgnoreCase` filter doesn't have to learn the URL/slug shape.
49
+ */
50
+ normalizeProjectInput(projectInput: string): Promise<string>;
44
51
  }
45
52
  export declare function createLinearService(options: AuthOptions): Promise<LinearService>;