linear-sdk-cli 0.1.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 (111) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/CHANGELOG.md +807 -0
  4. package/COVERAGE.md +495 -0
  5. package/LICENSE +21 -0
  6. package/MIGRATING.md +220 -0
  7. package/README.md +524 -0
  8. package/package.json +87 -0
  9. package/skills/linear-sdk-cli/SKILL.md +534 -0
  10. package/skills/linear-sdk-cli/references/api.md +27 -0
  11. package/skills/linear-sdk-cli/references/attachment.md +82 -0
  12. package/skills/linear-sdk-cli/references/auth.md +174 -0
  13. package/skills/linear-sdk-cli/references/commands.md +37 -0
  14. package/skills/linear-sdk-cli/references/comment.md +154 -0
  15. package/skills/linear-sdk-cli/references/completion.md +17 -0
  16. package/skills/linear-sdk-cli/references/config.md +99 -0
  17. package/skills/linear-sdk-cli/references/cycle.md +131 -0
  18. package/skills/linear-sdk-cli/references/document.md +180 -0
  19. package/skills/linear-sdk-cli/references/favorite.md +78 -0
  20. package/skills/linear-sdk-cli/references/initiative-update.md +67 -0
  21. package/skills/linear-sdk-cli/references/initiative.md +256 -0
  22. package/skills/linear-sdk-cli/references/issue.md +928 -0
  23. package/skills/linear-sdk-cli/references/label.md +112 -0
  24. package/skills/linear-sdk-cli/references/milestone.md +139 -0
  25. package/skills/linear-sdk-cli/references/notification.md +123 -0
  26. package/skills/linear-sdk-cli/references/open.md +29 -0
  27. package/skills/linear-sdk-cli/references/organization.md +99 -0
  28. package/skills/linear-sdk-cli/references/project-update.md +67 -0
  29. package/skills/linear-sdk-cli/references/project.md +234 -0
  30. package/skills/linear-sdk-cli/references/roadmap.md +150 -0
  31. package/skills/linear-sdk-cli/references/schema.md +21 -0
  32. package/skills/linear-sdk-cli/references/state.md +61 -0
  33. package/skills/linear-sdk-cli/references/team.md +227 -0
  34. package/skills/linear-sdk-cli/references/user.md +103 -0
  35. package/skills/linear-sdk-cli/references/webhook.md +151 -0
  36. package/skills/linear-sdk-cli/references/whoami.md +26 -0
  37. package/skills/linear-sdk-cli/scripts/generate-docs.ts +339 -0
  38. package/src/bin/linear.ts +97 -0
  39. package/src/cli.ts +232 -0
  40. package/src/client.ts +172 -0
  41. package/src/commands/agent-session.ts +121 -0
  42. package/src/commands/api.ts +317 -0
  43. package/src/commands/attach.ts +90 -0
  44. package/src/commands/attachment.ts +74 -0
  45. package/src/commands/comment.ts +225 -0
  46. package/src/commands/completion.ts +69 -0
  47. package/src/commands/cycle.ts +125 -0
  48. package/src/commands/discover.ts +170 -0
  49. package/src/commands/document.ts +211 -0
  50. package/src/commands/favorite.ts +72 -0
  51. package/src/commands/initiative-update.ts +47 -0
  52. package/src/commands/initiative.ts +261 -0
  53. package/src/commands/issue.ts +1263 -0
  54. package/src/commands/label.ts +119 -0
  55. package/src/commands/meta.ts +534 -0
  56. package/src/commands/milestone.ts +163 -0
  57. package/src/commands/notification.ts +110 -0
  58. package/src/commands/open.ts +84 -0
  59. package/src/commands/organization.ts +79 -0
  60. package/src/commands/project-update.ts +47 -0
  61. package/src/commands/project.ts +339 -0
  62. package/src/commands/roadmap.ts +131 -0
  63. package/src/commands/state.ts +58 -0
  64. package/src/commands/team.ts +243 -0
  65. package/src/commands/user.ts +107 -0
  66. package/src/commands/webhook.ts +156 -0
  67. package/src/config.ts +1236 -0
  68. package/src/context.ts +117 -0
  69. package/src/git.ts +174 -0
  70. package/src/index.ts +10 -0
  71. package/src/lib/action.ts +26 -0
  72. package/src/lib/body.ts +172 -0
  73. package/src/lib/errors.ts +195 -0
  74. package/src/lib/introspect.ts +150 -0
  75. package/src/lib/keyring.ts +270 -0
  76. package/src/lib/mutation.ts +53 -0
  77. package/src/lib/open.ts +32 -0
  78. package/src/lib/options.ts +640 -0
  79. package/src/lib/output-shapes.ts +562 -0
  80. package/src/lib/pagination.ts +145 -0
  81. package/src/lib/prompt.ts +91 -0
  82. package/src/lib/resolve.ts +596 -0
  83. package/src/lib/shape.ts +273 -0
  84. package/src/lib/status-update.ts +91 -0
  85. package/src/lib/upload.ts +329 -0
  86. package/src/output/color.ts +52 -0
  87. package/src/output/format.ts +238 -0
  88. package/src/output/markdown.ts +133 -0
  89. package/src/output/pager.ts +131 -0
  90. package/src/output/sanitize.ts +76 -0
  91. package/src/output/table.ts +198 -0
  92. package/src/services/agent-session.ts +289 -0
  93. package/src/services/attachment.ts +198 -0
  94. package/src/services/comment.ts +262 -0
  95. package/src/services/cycle.ts +224 -0
  96. package/src/services/document.ts +357 -0
  97. package/src/services/favorite.ts +147 -0
  98. package/src/services/initiative-update.ts +59 -0
  99. package/src/services/initiative.ts +449 -0
  100. package/src/services/issue.ts +1153 -0
  101. package/src/services/label.ts +193 -0
  102. package/src/services/milestone.ts +262 -0
  103. package/src/services/notification.ts +190 -0
  104. package/src/services/organization.ts +144 -0
  105. package/src/services/project-update.ts +36 -0
  106. package/src/services/project.ts +494 -0
  107. package/src/services/roadmap.ts +177 -0
  108. package/src/services/state.ts +127 -0
  109. package/src/services/team.ts +386 -0
  110. package/src/services/user.ts +137 -0
  111. package/src/services/webhook.ts +203 -0
@@ -0,0 +1,1153 @@
1
+ /**
2
+ * Issue service: all SDK access for issues lives here so commands stay thin.
3
+ *
4
+ * Lists use a tailored GraphQL query (one round-trip, no N+1 on state/assignee);
5
+ * single `view` and all mutations use the typed SDK models.
6
+ */
7
+
8
+ import type { LinearClient, Issue } from "@linear/sdk";
9
+ import type { ResolvedConfig } from "../config.js";
10
+ import { withRetry } from "../client.js";
11
+ import { shape } from "../lib/shape.js";
12
+ import { collect, collectRawQuery } from "../lib/pagination.js";
13
+ import { usageError, notFound } from "../lib/errors.js";
14
+ import { assertMutation, unwrapMutation } from "../lib/mutation.js";
15
+ import {
16
+ resolveUserId,
17
+ resolveProjectId,
18
+ resolveCycleId,
19
+ resolveStateId,
20
+ resolveLabelIds,
21
+ resolveMilestoneId,
22
+ resolveTeam,
23
+ resolveIssue,
24
+ resolveTemplateId,
25
+ firstStateOfType,
26
+ isUuid,
27
+ STATE_TYPES,
28
+ } from "../lib/resolve.js";
29
+
30
+ export interface IssueRow {
31
+ id: string;
32
+ identifier: string;
33
+ title: string;
34
+ priority: number;
35
+ priorityLabel: string;
36
+ estimate: number | null;
37
+ url: string;
38
+ updatedAt: string;
39
+ state: { name: string; type: string } | null;
40
+ assignee: { displayName: string } | null;
41
+ project: { name: string } | null;
42
+ /**
43
+ * The same object shapes `IssueDetail` uses, so `.milestone.name` reads the
44
+ * same on a list row and on `view`. They were missing here (TES-652): a
45
+ * `--json | jq 'group_by(.milestone.name)'` put every issue in the null bucket.
46
+ */
47
+ milestone: { id: string; name: string } | null;
48
+ cycle: { id: string; number: number; name: string | null } | null;
49
+ labels: string[];
50
+ /**
51
+ * Lifecycle. `--include-archived` used to list archived and trashed issues
52
+ * indistinguishably from live ones, and a deleted issue read back as alive.
53
+ */
54
+ archivedAt: string | null;
55
+ trashed: boolean;
56
+ startedAt: string | null;
57
+ completedAt: string | null;
58
+ canceledAt: string | null;
59
+ }
60
+
61
+ /**
62
+ * The row's shape as `linear commands` advertises it (TES-610). `shape<IssueRow>`
63
+ * is checked against the interface above, so the two cannot disagree.
64
+ */
65
+ export const ISSUE_ROW_SHAPE = shape<IssueRow>({
66
+ id: "string",
67
+ identifier: "string",
68
+ title: "string",
69
+ priority: "number",
70
+ priorityLabel: "string",
71
+ estimate: "number|null",
72
+ url: "string",
73
+ updatedAt: "string",
74
+ state: { nullable: { name: "string", type: "string" } },
75
+ assignee: { nullable: { displayName: "string" } },
76
+ project: { nullable: { name: "string" } },
77
+ milestone: { nullable: { id: "string", name: "string" } },
78
+ cycle: { nullable: { id: "string", number: "number", name: "string|null" } },
79
+ labels: ["string"],
80
+ archivedAt: "string|null",
81
+ trashed: "boolean",
82
+ startedAt: "string|null",
83
+ completedAt: "string|null",
84
+ canceledAt: "string|null",
85
+ });
86
+
87
+ /** The lifecycle fields every issue query selects, so row and detail agree. */
88
+ const LIFECYCLE_FIELDS = "archivedAt trashed startedAt completedAt canceledAt";
89
+
90
+ /**
91
+ * The relations a list/search row selects — one string, so `LIST_QUERY` and
92
+ * `SEARCH_QUERY` cannot drift apart (they did once: search hardcoded `labels: []`).
93
+ */
94
+ const ROW_RELATIONS = `state { name type }
95
+ assignee { displayName }
96
+ project { name }
97
+ projectMilestone { id name }
98
+ cycle { id number name }
99
+ labels(first: 20) { nodes { name } }`;
100
+
101
+ export interface ListFilters {
102
+ /** One team key, or several (`--team` is repeatable on the issue queries). */
103
+ team?: string | string[];
104
+ /** Ignore the team flag and the configured default; search the whole workspace. */
105
+ allTeams?: boolean;
106
+ assignee?: string;
107
+ /** Only issues with no assignee. Mutually exclusive with `assignee`. */
108
+ unassigned?: boolean;
109
+ /**
110
+ * Workflow states, each a state *name* or a state *type*. Several broaden
111
+ * (an issue is in exactly one state, so narrowing could never match). This is
112
+ * also how `issue mine` applies its default: it passes `MINE_STATE_TYPES`
113
+ * here when `--state` is absent, so there is one state path, not two.
114
+ */
115
+ state?: string[];
116
+ project?: string;
117
+ /** Issues whose *project* carries this label. Mutually exclusive with `project`. */
118
+ projectLabel?: string;
119
+ /** Project milestone by name or id; scoped to `project` when one is given. */
120
+ milestone?: string;
121
+ label?: string[];
122
+ priority?: string;
123
+ cycle?: string;
124
+ /** Lower bound (inclusive) on createdAt — `YYYY-MM-DD` or full ISO 8601. */
125
+ createdAfter?: string;
126
+ /** Lower bound (inclusive) on updatedAt — `YYYY-MM-DD` or full ISO 8601. */
127
+ updatedAfter?: string;
128
+ query?: string;
129
+ sort?: IssueSort;
130
+ includeArchived?: boolean;
131
+ /**
132
+ * Search comment bodies too (`issue search` only — `searchIssues` takes this,
133
+ * the plain `issues` query has nowhere to put it).
134
+ */
135
+ searchComments?: boolean;
136
+ }
137
+
138
+ /**
139
+ * The workflow state types `issue mine` shows unless `--all-states` widens it.
140
+ * Matches the reference CLI, whose `mine` defaults to unstarted work only — the
141
+ * point of the command is "what should I pick up next", not "everything of mine".
142
+ */
143
+ export const MINE_STATE_TYPES = ["unstarted"];
144
+
145
+ /** The sort orders `issue list` accepts, in `--sort`, env, and config alike. */
146
+ export const ISSUE_SORTS = ["priority", "manual", "updated", "created"] as const;
147
+ export type IssueSort = (typeof ISSUE_SORTS)[number];
148
+
149
+ type SortConfig = Pick<
150
+ ResolvedConfig,
151
+ "sort" | "sortSource" | "projectConfigPath" | "userConfigPath"
152
+ >;
153
+
154
+ /** Where a configured sort value came from, for the error message. */
155
+ function sortOrigin(config: SortConfig): string {
156
+ switch (config.sortSource) {
157
+ case "env":
158
+ return "LINEAR_ISSUE_SORT";
159
+ case "project":
160
+ return `\`sort\` in ${config.projectConfigPath}`;
161
+ case "user":
162
+ return `\`sort\` in ${config.userConfigPath}`;
163
+ default:
164
+ return "config";
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The single sort-resolution path: `--sort` > env/config > priority.
170
+ *
171
+ * `--sort` is validated by commander's choices, but the configured value is
172
+ * not — an unrecognized `issue_sort`/`LINEAR_ISSUE_SORT` used to fall through
173
+ * `sortSpec`'s default and silently sort by updatedAt instead of the documented
174
+ * priority default. It is an error now, and it names where the value came from.
175
+ */
176
+ export function resolveIssueSort(explicit: string | undefined, config: SortConfig): IssueSort {
177
+ const value = explicit ?? config.sort;
178
+ if (value === undefined) return "priority";
179
+ if (!(ISSUE_SORTS as readonly string[]).includes(value)) {
180
+ const where = explicit !== undefined ? "--sort" : sortOrigin(config);
181
+ throw usageError(
182
+ `Invalid sort '${value}' (${where}). Valid values: ${ISSUE_SORTS.join(", ")}.`,
183
+ );
184
+ }
185
+ return value as IssueSort;
186
+ }
187
+
188
+ const LIST_QUERY = `
189
+ query CliIssues($filter: IssueFilter, $first: Int!, $after: String, $sort: [IssueSortInput!], $includeArchived: Boolean) {
190
+ issues(filter: $filter, first: $first, after: $after, sort: $sort, includeArchived: $includeArchived) {
191
+ nodes {
192
+ id identifier title priority priorityLabel estimate url updatedAt ${LIFECYCLE_FIELDS}
193
+ ${ROW_RELATIONS}
194
+ }
195
+ pageInfo { hasNextPage endCursor }
196
+ }
197
+ }`;
198
+
199
+ /**
200
+ * Validate a date bound and normalize it to an ISO instant.
201
+ *
202
+ * `new Date()` accepts far too much ("1", "March 2024", "yesterday"), and a
203
+ * garbage bound sent to the API comes back as an empty list rather than an
204
+ * error — a filter that silently matches nothing. So the shape is checked here
205
+ * first, and the flag that carried it is named in the message. Exported for tests.
206
+ */
207
+ export function parseDateBound(value: string, flag: string): string {
208
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.]+(Z|[+-]\d{2}:?\d{2})?)?$/;
209
+ const parsed = ISO_DATE_RE.test(value) ? new Date(value) : new Date(NaN);
210
+ if (Number.isNaN(parsed.getTime())) {
211
+ throw usageError(
212
+ `Invalid date for ${flag}: '${value}'. Use YYYY-MM-DD or ISO 8601 (e.g. 2026-01-15T09:00:00Z).`,
213
+ );
214
+ }
215
+ return parsed.toISOString();
216
+ }
217
+
218
+ /** One `--state` value → its filter clause: a known type matches by type, anything else by name. */
219
+ function stateClause(value: string): Record<string, unknown> {
220
+ const lower = value.toLowerCase();
221
+ return STATE_TYPES.includes(lower) ? { type: { eq: lower } } : { name: { eqIgnoreCase: value } };
222
+ }
223
+
224
+ /** Normalize a single-or-repeated option to a deduplicated list. */
225
+ function asList(value: string | string[] | undefined): string[] {
226
+ if (value === undefined) return [];
227
+ return [...new Set((Array.isArray(value) ? value : [value]).filter((v) => v.trim() !== ""))];
228
+ }
229
+
230
+ /** Build an IssueFilter from human options, resolving names to ids. Exported for tests. */
231
+ export async function buildFilter(
232
+ client: LinearClient,
233
+ f: ListFilters,
234
+ defaultTeamKey: string | undefined,
235
+ ): Promise<Record<string, unknown>> {
236
+ const filter: Record<string, any> = {};
237
+ // --all-teams wins over both the flag and the configured default.
238
+ // Uppercase first, then dedupe: `--team tes --team TES` is one team.
239
+ const teamKeys = [
240
+ ...new Set(
241
+ asList(f.allTeams ? undefined : (f.team ?? defaultTeamKey)).map((k) => k.toUpperCase()),
242
+ ),
243
+ ];
244
+ // One team keeps the exact `eq` shape it has always sent; several use `in`
245
+ // (the reference CLI spells the same thing as `or: [{ key: { eq } }, …]`).
246
+ if (teamKeys.length === 1) filter.team = { key: { eq: teamKeys[0] } };
247
+ else if (teamKeys.length > 1) filter.team = { key: { in: teamKeys } };
248
+
249
+ if (f.unassigned && f.assignee) {
250
+ throw usageError("Pass either --assignee or --unassigned, not both.");
251
+ }
252
+ if (f.unassigned) {
253
+ filter.assignee = { null: true };
254
+ } else if (f.assignee) {
255
+ const userId = await resolveUserId(client, f.assignee);
256
+ filter.assignee = { id: { eq: userId } };
257
+ }
258
+ const states = asList(f.state);
259
+ if (states.length === 1) {
260
+ filter.state = stateClause(states[0]!);
261
+ } else if (states.length > 1) {
262
+ // Several states OR together. Types collapse into one `in` (a fixed enum,
263
+ // so no case-sensitivity trap); names stay separate `eqIgnoreCase` clauses,
264
+ // because `in` is exact-case and `--state 'in progress'` must still match.
265
+ const types = states.filter((s) => STATE_TYPES.includes(s.toLowerCase()));
266
+ const names = states.filter((s) => !STATE_TYPES.includes(s.toLowerCase()));
267
+ const clauses: Record<string, unknown>[] = [
268
+ ...(types.length ? [{ type: { in: types.map((t) => t.toLowerCase()) } }] : []),
269
+ ...names.map((name) => ({ name: { eqIgnoreCase: name } })),
270
+ ];
271
+ filter.state = clauses.length === 1 ? clauses[0]! : { or: clauses };
272
+ }
273
+ if (f.project && f.projectLabel) {
274
+ throw usageError(
275
+ "Pass either --project (one project) or --project-label (every project with that label), not both.",
276
+ );
277
+ }
278
+ let projectId: string | undefined;
279
+ if (f.project) {
280
+ projectId = await resolveProjectId(client, f.project);
281
+ filter.project = { id: { eq: projectId } };
282
+ } else if (f.projectLabel) {
283
+ // Project labels are workspace-level and matched by name, like `--label`:
284
+ // `in`/`eq` are exact-case, so `--project-label mobile` would silently miss
285
+ // a label stored as "Mobile".
286
+ filter.project = { labels: { some: { name: { eqIgnoreCase: f.projectLabel } } } };
287
+ }
288
+ if (f.milestone) {
289
+ // Milestone *names* are only unique within a project, so with --project we
290
+ // resolve to an id. Without one, IssueFilter can still match by name across
291
+ // projects — the SDK does not require the scoping the reference CLI demands,
292
+ // so we accept the wider query instead of rejecting it.
293
+ filter.projectMilestone = isUuid(f.milestone)
294
+ ? { id: { eq: f.milestone } }
295
+ : projectId
296
+ ? { id: { eq: await resolveMilestoneId(client, projectId, f.milestone) } }
297
+ : { name: { eqIgnoreCase: f.milestone } };
298
+ }
299
+ if (f.label && f.label.length) {
300
+ // Case-insensitive: `in` is exact-case, so `--label bug` silently matched
301
+ // nothing when the label is stored as "Bug" — an empty list, no error.
302
+ //
303
+ // Repeating the flag NARROWS: the issue must carry every label named. It
304
+ // used to broaden (`some: { or: [...] }`), which both contradicted the
305
+ // repeated-filter convention every other flag here follows and silently
306
+ // returned a superset to scripts ported from the reference CLI. Each label
307
+ // needs its own `some` — a single `some` with an `and` would demand one
308
+ // label row match every name at once, which no row ever can.
309
+ filter.labels =
310
+ f.label.length === 1
311
+ ? { some: { name: { eqIgnoreCase: f.label[0] } } }
312
+ : { and: f.label.map((name) => ({ some: { name: { eqIgnoreCase: name } } })) };
313
+ }
314
+ if (f.priority !== undefined) {
315
+ filter.priority = { eq: Number.parseInt(f.priority, 10) };
316
+ }
317
+ if (f.cycle) {
318
+ // A UUID can filter directly; a number/name needs exactly one team to
319
+ // resolve against — cycle numbers restart per team, so "#3" across two
320
+ // teams names two different cycles.
321
+ if (isUuid(f.cycle)) {
322
+ filter.cycle = { id: { eq: f.cycle } };
323
+ } else if (teamKeys.length === 1) {
324
+ const teamForCycle = (await resolveTeam(client, teamKeys[0]!, undefined)).id;
325
+ filter.cycle = { id: { eq: await resolveCycleId(client, teamForCycle, f.cycle) } };
326
+ } else {
327
+ throw usageError(
328
+ "Filtering by a cycle number/name requires exactly one --team (or pass a cycle id).",
329
+ );
330
+ }
331
+ }
332
+ // Inclusive lower bounds, matching the reference CLI's `--created-after` /
333
+ // `--updated-after` (both are `gte`, so a bare YYYY-MM-DD includes that day).
334
+ if (f.createdAfter) {
335
+ filter.createdAt = { gte: parseDateBound(f.createdAfter, "--created-after") };
336
+ }
337
+ if (f.updatedAfter) {
338
+ filter.updatedAt = { gte: parseDateBound(f.updatedAfter, "--updated-after") };
339
+ }
340
+ if (f.query) {
341
+ filter.searchableContent = { contains: f.query };
342
+ }
343
+ return filter;
344
+ }
345
+
346
+ /**
347
+ * Server-side sort spec (correct under pagination — no client-side resort).
348
+ * Takes an already-validated `IssueSort` so an unknown value can never fall
349
+ * through to a silent default; use `resolveIssueSort` to get one. Exported for tests.
350
+ */
351
+ export function sortSpec(sort: IssueSort = "priority"): Array<Record<string, unknown>> {
352
+ switch (sort) {
353
+ case "priority":
354
+ // Workflow state first, then priority, then manual: active work groups
355
+ // above the backlog, with urgency as the tiebreak inside each state.
356
+ // Sorting purely by priority interleaved states, floating a backlog item
357
+ // above work in progress.
358
+ //
359
+ // `workflowState: Ascending` is what puts started work on top — verified
360
+ // against the API, where Descending returns Backlog BEFORE In Progress.
361
+ // The reference CLI hardcodes Descending, so a Low-priority backlog issue
362
+ // sorts above an Urgent in-progress one there; we deliberately diverge
363
+ // rather than copy that. See ALIGNMENT.md.
364
+ //
365
+ // Descending priority is urgency order (Urgent…Low); `nulls: "last"` keeps
366
+ // "No priority" at the bottom, which is what `noPriorityFirst: false` did.
367
+ return [
368
+ { workflowState: { order: "Ascending" } },
369
+ { priority: { nulls: "last", order: "Descending" } },
370
+ { manual: { nulls: "last", order: "Ascending" } },
371
+ ];
372
+ case "created":
373
+ return [{ createdAt: { order: "Descending" } }];
374
+ case "manual":
375
+ // Linear's board order, without injecting workflow-state or priority
376
+ // grouping. This is the reference CLI's `issue_sort = "manual"` and is
377
+ // useful when a carefully curated backlog should read exactly as arranged.
378
+ return [{ manual: { nulls: "last", order: "Ascending" } }];
379
+ case "updated":
380
+ return [{ updatedAt: { order: "Descending" } }];
381
+ }
382
+ }
383
+
384
+ export async function listIssues(
385
+ client: LinearClient,
386
+ filters: ListFilters,
387
+ limit: number,
388
+ defaultTeamKey: string | undefined,
389
+ ): Promise<IssueRow[]> {
390
+ const filter = await buildFilter(client, filters, defaultTeamKey);
391
+ // Sorting is done server-side (see sortSpec), so it is correct across pages.
392
+ return collectRawQuery<IssueRow>(
393
+ client as any,
394
+ LIST_QUERY,
395
+ { filter, sort: sortSpec(filters.sort), includeArchived: !!filters.includeArchived },
396
+ "issues",
397
+ limit,
398
+ toIssueRow,
399
+ );
400
+ }
401
+
402
+ const SEARCH_QUERY = `
403
+ query CliSearchIssues($term: String!, $filter: IssueFilter, $first: Int!, $after: String, $includeArchived: Boolean, $includeComments: Boolean) {
404
+ searchIssues(term: $term, filter: $filter, first: $first, after: $after, includeArchived: $includeArchived, includeComments: $includeComments) {
405
+ nodes {
406
+ id identifier title priority priorityLabel estimate url updatedAt ${LIFECYCLE_FIELDS}
407
+ ${ROW_RELATIONS}
408
+ }
409
+ pageInfo { hasNextPage endCursor }
410
+ }
411
+ }`;
412
+
413
+ /**
414
+ * Free-text search via the dedicated searchIssues connection, narrowed by the
415
+ * same filters as `listIssues` (searchIssues takes an IssueFilter).
416
+ *
417
+ * Uses the same tailored-query shape as `listIssues` so both paths return an
418
+ * identical `IssueRow`. The previous SDK-model version resolved state/assignee/
419
+ * project one issue at a time and hardcoded `labels: []`, so `issue search --json`
420
+ * reported no labels while `issue list --json` reported the real ones.
421
+ *
422
+ * Results are relevance-ordered by the API, so there is no sort argument here.
423
+ */
424
+ export async function searchIssues(
425
+ client: LinearClient,
426
+ term: string,
427
+ filters: ListFilters,
428
+ limit: number,
429
+ defaultTeamKey: string | undefined,
430
+ ): Promise<IssueRow[]> {
431
+ const filter = await buildFilter(client, filters, defaultTeamKey);
432
+ return collectRawQuery<IssueRow>(
433
+ client as any,
434
+ SEARCH_QUERY,
435
+ {
436
+ term,
437
+ // An empty filter object is not the same as null to the API; omit it.
438
+ filter: Object.keys(filter).length ? filter : undefined,
439
+ includeArchived: !!filters.includeArchived,
440
+ // Off by default: comment bodies widen a title/description search a lot,
441
+ // and the reference makes it opt-in for the same reason.
442
+ includeComments: !!filters.searchComments,
443
+ },
444
+ "searchIssues",
445
+ limit,
446
+ toIssueRow,
447
+ );
448
+ }
449
+
450
+ /** Map a tailored-query issue node to a display row (shared by list and search). */
451
+ function toIssueRow(n: any): IssueRow {
452
+ return {
453
+ id: n.id,
454
+ identifier: n.identifier,
455
+ title: n.title,
456
+ priority: n.priority,
457
+ priorityLabel: n.priorityLabel,
458
+ estimate: n.estimate ?? null,
459
+ url: n.url,
460
+ updatedAt: n.updatedAt,
461
+ state: n.state ?? null,
462
+ assignee: n.assignee ?? null,
463
+ project: n.project ?? null,
464
+ milestone: n.projectMilestone ?? null,
465
+ cycle: cycleRef(n.cycle),
466
+ labels: (n.labels?.nodes ?? []).map((l: any) => l.name),
467
+ ...lifecycle(n),
468
+ };
469
+ }
470
+
471
+ /** A `cycle { id number name }` selection as the row/detail object; a cycle's name is optional upstream. */
472
+ function cycleRef(c: any): IssueRow["cycle"] {
473
+ return c ? { id: c.id, number: c.number, name: c.name ?? null } : null;
474
+ }
475
+
476
+ /** The lifecycle fields off a raw issue node. `trashed` is nullable upstream; null means no. */
477
+ function lifecycle(n: any) {
478
+ return {
479
+ archivedAt: n.archivedAt ?? null,
480
+ trashed: n.trashed === true,
481
+ startedAt: n.startedAt ?? null,
482
+ completedAt: n.completedAt ?? null,
483
+ canceledAt: n.canceledAt ?? null,
484
+ };
485
+ }
486
+
487
+ /**
488
+ * A single issue with every relation a `view` shows.
489
+ *
490
+ * Relations are objects, not display strings — the same shape a list row uses
491
+ * for `state`/`assignee`/`project`, so `.state.name` reads the same on both,
492
+ * plus the ids a script needs to act on what it sees. (They used to be
493
+ * flattened to strings here: `team: "TES Test-workspace-bla"` is not even
494
+ * parseable, since a team name can contain spaces.)
495
+ */
496
+ export interface IssueDetailRef {
497
+ id: string;
498
+ identifier: string;
499
+ title: string;
500
+ state: { id: string; name: string; type: string } | null;
501
+ }
502
+
503
+ export interface IssueAttachmentDetail {
504
+ id: string;
505
+ title: string;
506
+ url: string;
507
+ subtitle: string | null;
508
+ sourceType: string | null;
509
+ createdAt: string;
510
+ }
511
+
512
+ export interface IssueDocumentDetail {
513
+ id: string;
514
+ title: string;
515
+ slugId: string;
516
+ url: string;
517
+ createdAt: string;
518
+ updatedAt: string;
519
+ }
520
+
521
+ export interface IssueRelationDetail {
522
+ id: string;
523
+ type: string;
524
+ issue: IssueDetailRef;
525
+ relatedIssue: IssueDetailRef;
526
+ }
527
+
528
+ /**
529
+ * A comment embedded in `IssueDetail`. It is a superset of the shared comment
530
+ * row: external authors and resolution metadata are retained so a caller can
531
+ * reconstruct threads without another request.
532
+ */
533
+ export interface IssueCommentDetail {
534
+ id: string;
535
+ body: string;
536
+ url: string;
537
+ createdAt: string;
538
+ editedAt: string | null;
539
+ resolvedAt: string | null;
540
+ parent: { id: string } | null;
541
+ user: { id: string; displayName: string } | null;
542
+ externalUser: { id: string; displayName: string } | null;
543
+ resolvingCommentId: string | null;
544
+ resolvingUser: { id: string; displayName: string } | null;
545
+ }
546
+
547
+ export interface IssueDetail {
548
+ id: string;
549
+ identifier: string;
550
+ title: string;
551
+ description: string | null;
552
+ priority: number;
553
+ priorityLabel: string;
554
+ estimate: number | null;
555
+ url: string;
556
+ branchName: string;
557
+ dueDate: string | null;
558
+ createdAt: string;
559
+ updatedAt: string;
560
+ archivedAt: string | null;
561
+ trashed: boolean;
562
+ startedAt: string | null;
563
+ completedAt: string | null;
564
+ canceledAt: string | null;
565
+ state: { id: string; name: string; type: string } | null;
566
+ assignee: { id: string; displayName: string; email: string } | null;
567
+ team: { id: string; key: string; name: string } | null;
568
+ project: { id: string; name: string } | null;
569
+ milestone: { id: string; name: string } | null;
570
+ cycle: { id: string; number: number; name: string | null } | null;
571
+ parent: IssueDetailRef | null;
572
+ children: IssueDetailRef[];
573
+ labels: Array<{ id: string; name: string }>;
574
+ subscribers: Array<{ id: string; displayName: string }>;
575
+ attachments: IssueAttachmentDetail[];
576
+ documents: IssueDocumentDetail[];
577
+ relations: IssueRelationDetail[];
578
+ inverseRelations: IssueRelationDetail[];
579
+ comments: IssueCommentDetail[];
580
+ }
581
+
582
+ /** The detail's shape as `linear commands` advertises it; checked against `IssueDetail`. */
583
+ export const ISSUE_DETAIL_SHAPE = shape<IssueDetail>({
584
+ id: "string",
585
+ identifier: "string",
586
+ title: "string",
587
+ description: "string|null",
588
+ priority: "number",
589
+ priorityLabel: "string",
590
+ estimate: "number|null",
591
+ url: "string",
592
+ branchName: "string",
593
+ dueDate: "string|null",
594
+ createdAt: "string",
595
+ updatedAt: "string",
596
+ archivedAt: "string|null",
597
+ trashed: "boolean",
598
+ startedAt: "string|null",
599
+ completedAt: "string|null",
600
+ canceledAt: "string|null",
601
+ state: { nullable: { id: "string", name: "string", type: "string" } },
602
+ assignee: { nullable: { id: "string", displayName: "string", email: "string" } },
603
+ team: { nullable: { id: "string", key: "string", name: "string" } },
604
+ project: { nullable: { id: "string", name: "string" } },
605
+ milestone: { nullable: { id: "string", name: "string" } },
606
+ cycle: { nullable: { id: "string", number: "number", name: "string|null" } },
607
+ parent: {
608
+ nullable: {
609
+ id: "string",
610
+ identifier: "string",
611
+ title: "string",
612
+ state: { nullable: { id: "string", name: "string", type: "string" } },
613
+ },
614
+ },
615
+ children: [
616
+ {
617
+ id: "string",
618
+ identifier: "string",
619
+ title: "string",
620
+ state: { nullable: { id: "string", name: "string", type: "string" } },
621
+ },
622
+ ],
623
+ labels: [{ id: "string", name: "string" }],
624
+ subscribers: [{ id: "string", displayName: "string" }],
625
+ attachments: [
626
+ {
627
+ id: "string",
628
+ title: "string",
629
+ url: "string",
630
+ subtitle: "string|null",
631
+ sourceType: "string|null",
632
+ createdAt: "string",
633
+ },
634
+ ],
635
+ documents: [
636
+ {
637
+ id: "string",
638
+ title: "string",
639
+ slugId: "string",
640
+ url: "string",
641
+ createdAt: "string",
642
+ updatedAt: "string",
643
+ },
644
+ ],
645
+ relations: [
646
+ {
647
+ id: "string",
648
+ type: "string",
649
+ issue: {
650
+ id: "string",
651
+ identifier: "string",
652
+ title: "string",
653
+ state: { nullable: { id: "string", name: "string", type: "string" } },
654
+ },
655
+ relatedIssue: {
656
+ id: "string",
657
+ identifier: "string",
658
+ title: "string",
659
+ state: { nullable: { id: "string", name: "string", type: "string" } },
660
+ },
661
+ },
662
+ ],
663
+ inverseRelations: [
664
+ {
665
+ id: "string",
666
+ type: "string",
667
+ issue: {
668
+ id: "string",
669
+ identifier: "string",
670
+ title: "string",
671
+ state: { nullable: { id: "string", name: "string", type: "string" } },
672
+ },
673
+ relatedIssue: {
674
+ id: "string",
675
+ identifier: "string",
676
+ title: "string",
677
+ state: { nullable: { id: "string", name: "string", type: "string" } },
678
+ },
679
+ },
680
+ ],
681
+ comments: [
682
+ {
683
+ id: "string",
684
+ body: "string",
685
+ url: "string",
686
+ createdAt: "string",
687
+ editedAt: "string|null",
688
+ resolvedAt: "string|null",
689
+ parent: { nullable: { id: "string" } },
690
+ user: { nullable: { id: "string", displayName: "string" } },
691
+ externalUser: { nullable: { id: "string", displayName: "string" } },
692
+ resolvingCommentId: "string|null",
693
+ resolvingUser: { nullable: { id: "string", displayName: "string" } },
694
+ },
695
+ ],
696
+ });
697
+
698
+ /**
699
+ * Everything `issue view` shows, in one round-trip. Selecting the relations in
700
+ * the query is what makes it one: the SDK model's `issue.state`, `.assignee`,
701
+ * `.team`, … are each a lazy request of their own, and the previous version
702
+ * awaited nine of them (8 requests for TES-601, measured).
703
+ *
704
+ * `includeArchived: true` so archived and trashed issues are viewable — they
705
+ * carry `archivedAt`/`trashed` so the caller can say so — and the identifier
706
+ * route filters by team key + number, as `resolveIssue` does, so a lookup never
707
+ * depends on the API accepting human identifiers in `issue(id:)`.
708
+ */
709
+ const DETAIL_QUERY = `
710
+ query CliIssueDetail($filter: IssueFilter!, $includeComments: Boolean!) {
711
+ issues(filter: $filter, first: 1, includeArchived: true) {
712
+ nodes {
713
+ id identifier title description priority priorityLabel estimate url branchName dueDate
714
+ createdAt updatedAt ${LIFECYCLE_FIELDS}
715
+ state { id name type }
716
+ assignee { id displayName email }
717
+ team { id key name }
718
+ project { id name }
719
+ projectMilestone { id name }
720
+ cycle { id number name }
721
+ parent { id identifier title state { id name type } }
722
+ children(first: 250) { nodes { id identifier title state { id name type } } }
723
+ labels(first: 50) { nodes { id name } }
724
+ subscribers(first: 50) { nodes { id displayName } }
725
+ attachments(first: 50) {
726
+ nodes { id title url subtitle sourceType createdAt }
727
+ }
728
+ documents(first: 50) {
729
+ nodes { id title slugId url createdAt updatedAt }
730
+ }
731
+ relations(first: 50) {
732
+ nodes {
733
+ id type
734
+ issue { id identifier title state { id name type } }
735
+ relatedIssue { id identifier title state { id name type } }
736
+ }
737
+ }
738
+ inverseRelations(first: 50) {
739
+ nodes {
740
+ id type
741
+ issue { id identifier title state { id name type } }
742
+ relatedIssue { id identifier title state { id name type } }
743
+ }
744
+ }
745
+ comments(first: 50, orderBy: createdAt) @include(if: $includeComments) {
746
+ nodes {
747
+ id body url createdAt editedAt resolvedAt resolvingCommentId
748
+ parent { id }
749
+ user { id displayName }
750
+ externalUser { id displayName }
751
+ resolvingUser { id displayName }
752
+ }
753
+ }
754
+ }
755
+ }
756
+ }`;
757
+
758
+ const IDENTIFIER_RE = /^([a-zA-Z][a-zA-Z0-9]*)-(\d+)$/;
759
+
760
+ /** The IssueFilter that names exactly one issue, by UUID or by `TES-123`. */
761
+ function detailFilter(input: string): { filter: Record<string, unknown>; label: string } {
762
+ if (isUuid(input)) return { filter: { id: { eq: input } }, label: input };
763
+ const match = input.match(IDENTIFIER_RE);
764
+ if (!match)
765
+ throw usageError(`'${input}' is not a valid issue id (expected e.g. TES-123 or a UUID).`);
766
+ const key = match[1]!.toUpperCase();
767
+ const number = Number.parseInt(match[2]!, 10);
768
+ return {
769
+ filter: { team: { key: { eq: key } }, number: { eq: number } },
770
+ label: `${key}-${number}`,
771
+ };
772
+ }
773
+
774
+ export async function getIssueDetail(
775
+ client: LinearClient,
776
+ idArg: string,
777
+ opts: { includeComments?: boolean } = {},
778
+ ): Promise<IssueDetail> {
779
+ const { filter, label } = detailFilter(idArg);
780
+ const data: any = await withRetry(() =>
781
+ (client as any).client.rawRequest(DETAIL_QUERY, {
782
+ filter,
783
+ includeComments: opts.includeComments !== false,
784
+ }),
785
+ );
786
+ const n = data.data?.issues?.nodes?.[0];
787
+ if (!n) throw notFound(`No issue ${label}.`);
788
+ return {
789
+ id: n.id,
790
+ identifier: n.identifier,
791
+ title: n.title,
792
+ description: n.description ?? null,
793
+ priority: n.priority,
794
+ priorityLabel: n.priorityLabel,
795
+ estimate: n.estimate ?? null,
796
+ url: n.url,
797
+ branchName: n.branchName,
798
+ dueDate: n.dueDate ?? null,
799
+ createdAt: n.createdAt,
800
+ updatedAt: n.updatedAt,
801
+ ...lifecycle(n),
802
+ state: n.state ?? null,
803
+ assignee: n.assignee ?? null,
804
+ team: n.team ?? null,
805
+ project: n.project ?? null,
806
+ milestone: n.projectMilestone ?? null,
807
+ cycle: cycleRef(n.cycle),
808
+ parent: n.parent ?? null,
809
+ children: n.children?.nodes ?? [],
810
+ labels: n.labels?.nodes ?? [],
811
+ subscribers: n.subscribers?.nodes ?? [],
812
+ attachments: (n.attachments?.nodes ?? []).map((a: any) => ({
813
+ id: a.id,
814
+ title: a.title,
815
+ url: a.url,
816
+ subtitle: a.subtitle ?? null,
817
+ sourceType: a.sourceType ?? null,
818
+ createdAt: a.createdAt,
819
+ })),
820
+ documents: n.documents?.nodes ?? [],
821
+ relations: n.relations?.nodes ?? [],
822
+ inverseRelations: n.inverseRelations?.nodes ?? [],
823
+ comments: (n.comments?.nodes ?? []).map(toIssueCommentDetail),
824
+ };
825
+ }
826
+
827
+ /** Normalize nullable comment relations while preserving thread metadata. */
828
+ function toIssueCommentDetail(c: any): IssueCommentDetail {
829
+ return {
830
+ id: c.id,
831
+ body: c.body,
832
+ url: c.url,
833
+ createdAt: c.createdAt,
834
+ editedAt: c.editedAt ?? null,
835
+ resolvedAt: c.resolvedAt ?? null,
836
+ parent: c.parent ?? null,
837
+ user: c.user ?? null,
838
+ externalUser: c.externalUser ?? null,
839
+ resolvingCommentId: c.resolvingCommentId ?? null,
840
+ resolvingUser: c.resolvingUser ?? null,
841
+ };
842
+ }
843
+
844
+ export interface CreateOptions {
845
+ title: string;
846
+ description?: string;
847
+ team?: string;
848
+ assignee?: string;
849
+ state?: string;
850
+ priority?: number;
851
+ label?: string[];
852
+ project?: string;
853
+ milestone?: string;
854
+ cycle?: string;
855
+ estimate?: number;
856
+ parent?: string;
857
+ dueDate?: string;
858
+ /** An issue template, by name or id (`templateId`). Wins over the team default. */
859
+ template?: string;
860
+ /**
861
+ * Apply the team's default issue template (`useDefaultTemplate`). On by
862
+ * default: the API applies a team's default template ONLY when asked —
863
+ * verified live, a plain `issueCreate` on a team with a default template
864
+ * comes back with no description — and both Linear's own new-issue form and
865
+ * the reference CLI ask. `false` is `--no-default-template`.
866
+ */
867
+ useDefaultTemplate?: boolean;
868
+ }
869
+
870
+ /** Build an IssueCreateInput, resolving every human reference to an id. */
871
+ export async function createIssue(
872
+ client: LinearClient,
873
+ opts: CreateOptions,
874
+ defaultTeamKey: string | undefined,
875
+ ) {
876
+ const team = await resolveTeam(client, opts.team, defaultTeamKey);
877
+ const input: Record<string, any> = { teamId: team.id, title: opts.title };
878
+ if (opts.description !== undefined) input.description = opts.description;
879
+ if (opts.priority !== undefined) input.priority = opts.priority;
880
+ if (opts.estimate !== undefined) input.estimate = opts.estimate;
881
+ if (opts.dueDate) input.dueDate = opts.dueDate;
882
+ if (opts.assignee) input.assigneeId = await resolveUserId(client, opts.assignee);
883
+ if (opts.state) input.stateId = await resolveStateId(client, team.id, opts.state);
884
+ if (opts.label?.length) input.labelIds = await resolveLabelIds(client, opts.label, team.id);
885
+ if (opts.project) input.projectId = await resolveProjectId(client, opts.project);
886
+ if (opts.parent) {
887
+ const parent = await resolveIssue(client, opts.parent);
888
+ input.parentId = parent.id;
889
+ // A sub-issue lands in its parent's project unless told otherwise — what
890
+ // Linear's UI does, and what the reference CLI does. Without this, every
891
+ // `--parent` child sat outside the project its parent belongs to.
892
+ if (!input.projectId) {
893
+ const parentProject = await parent.project;
894
+ if (parentProject) input.projectId = parentProject.id;
895
+ }
896
+ }
897
+ if (opts.milestone) {
898
+ // Resolved after the parent so a milestone can name one in the inherited project.
899
+ if (!input.projectId)
900
+ throw usageError("A milestone requires --project (or a --parent in a project).");
901
+ input.projectMilestoneId = await resolveMilestoneId(client, input.projectId, opts.milestone);
902
+ }
903
+ if (opts.cycle) input.cycleId = await resolveCycleId(client, team.id, opts.cycle);
904
+ // An explicit template overrides the default one, so the two are never sent
905
+ // together. Every other value in the input overrides what the template fills.
906
+ if (opts.template) input.templateId = await resolveTemplateId(client, team.id, opts.template);
907
+ else if (opts.useDefaultTemplate !== false) input.useDefaultTemplate = true;
908
+
909
+ return unwrapMutation(
910
+ withRetry(() => client.createIssue(input as any)),
911
+ "issue",
912
+ "Issue creation",
913
+ );
914
+ }
915
+
916
+ export interface UpdateOptions {
917
+ title?: string;
918
+ description?: string;
919
+ /** Move the issue to another team (key, name, or id). Changes its identifier. */
920
+ team?: string;
921
+ assignee?: string;
922
+ state?: string;
923
+ priority?: number;
924
+ project?: string;
925
+ milestone?: string;
926
+ cycle?: string;
927
+ estimate?: number;
928
+ parent?: string;
929
+ dueDate?: string;
930
+ /** Replace the complete label set (including `[]` to clear it). */
931
+ label?: string[];
932
+ addLabel?: string[];
933
+ removeLabel?: string[];
934
+ /** Clear the assignee. Mutually exclusive with `assignee`. */
935
+ unassign?: boolean;
936
+ /** Remove the issue from its cycle. Mutually exclusive with `cycle`. */
937
+ clearCycle?: boolean;
938
+ }
939
+
940
+ export async function updateIssue(client: LinearClient, idArg: string, opts: UpdateOptions) {
941
+ // Contradictory pairs are a usage error rather than a silent last-one-wins.
942
+ if (opts.unassign && opts.assignee)
943
+ throw usageError("Pass either --assignee or --unassign, not both.");
944
+ if (opts.clearCycle && opts.cycle)
945
+ throw usageError("Pass either --cycle or --clear-cycle, not both.");
946
+ if (opts.label !== undefined && (opts.addLabel !== undefined || opts.removeLabel !== undefined))
947
+ throw usageError("Pass either --label (replace all) or --add-label/--remove-label, not both.");
948
+
949
+ const issue = await resolveIssue(client, idArg);
950
+ const currentTeamId = (await issue.team)?.id;
951
+ const input: Record<string, any> = {};
952
+ // A team move changes what every team-scoped reference in this same command
953
+ // means, so it is resolved first and everything below resolves against the
954
+ // DESTINATION team: `--team ENG --state 'In Review'` has to mean ENG's "In
955
+ // Review", not the state id of a team the issue is about to leave (which the
956
+ // API would reject). Linear itself remaps the workflow state to the
957
+ // equivalent state in the new team and drops the cycle; see the CHANGELOG.
958
+ if (opts.team) input.teamId = (await resolveTeam(client, opts.team, undefined)).id;
959
+ const teamId = input.teamId ?? currentTeamId;
960
+ if (opts.title !== undefined) input.title = opts.title;
961
+ if (opts.description !== undefined) input.description = opts.description;
962
+ if (opts.priority !== undefined) input.priority = opts.priority;
963
+ if (opts.estimate !== undefined) input.estimate = opts.estimate;
964
+ if (opts.dueDate !== undefined) input.dueDate = opts.dueDate;
965
+ if (opts.assignee) input.assigneeId = await resolveUserId(client, opts.assignee);
966
+ // null (not undefined) is what clears a relation in Linear's update inputs.
967
+ if (opts.unassign) input.assigneeId = null;
968
+ if (opts.clearCycle) input.cycleId = null;
969
+ if (opts.state) {
970
+ if (!teamId) throw usageError("Cannot resolve state without a team.");
971
+ input.stateId = await resolveStateId(client, teamId, opts.state);
972
+ }
973
+ if (opts.project) input.projectId = await resolveProjectId(client, opts.project);
974
+ if (opts.cycle && teamId) input.cycleId = await resolveCycleId(client, teamId, opts.cycle);
975
+ if (opts.parent) input.parentId = (await resolveIssue(client, opts.parent)).id;
976
+ if (opts.label !== undefined) input.labelIds = await resolveLabelIds(client, opts.label, teamId);
977
+ if (opts.addLabel?.length)
978
+ input.addedLabelIds = await resolveLabelIds(client, opts.addLabel, teamId);
979
+ if (opts.removeLabel?.length)
980
+ input.removedLabelIds = await resolveLabelIds(client, opts.removeLabel, teamId);
981
+ if (opts.milestone) {
982
+ const projectId = input.projectId ?? (await issue.project)?.id;
983
+ if (!projectId)
984
+ throw usageError("A milestone requires the issue to be in a project (or pass --project).");
985
+ input.projectMilestoneId = await resolveMilestoneId(client, projectId, opts.milestone);
986
+ }
987
+
988
+ if (Object.keys(input).length === 0)
989
+ throw usageError("Nothing to update; pass at least one field.");
990
+ // The updated issue comes from the payload, never from `issue` — falling back
991
+ // to the pre-mutation entity would print "Updated TES-1" for a write the API
992
+ // refused.
993
+ return unwrapMutation(
994
+ withRetry(() => client.updateIssue(issue.id, input as any)),
995
+ "issue",
996
+ "Issue update",
997
+ );
998
+ }
999
+
1000
+ export async function archiveIssue(client: LinearClient, idArg: string, unarchive: boolean) {
1001
+ const issue = await resolveIssue(client, idArg);
1002
+ await assertMutation(
1003
+ withRetry(() => (unarchive ? client.unarchiveIssue(issue.id) : client.archiveIssue(issue.id))),
1004
+ unarchive ? "Issue unarchive" : "Issue archive",
1005
+ );
1006
+ return issue;
1007
+ }
1008
+
1009
+ export async function deleteIssue(client: LinearClient, idArg: string) {
1010
+ const issue = await resolveIssue(client, idArg);
1011
+ await assertMutation(
1012
+ withRetry(() => client.deleteIssue(issue.id)),
1013
+ "Issue deletion",
1014
+ );
1015
+ return issue;
1016
+ }
1017
+
1018
+ export async function setSubscription(client: LinearClient, idArg: string, subscribe: boolean) {
1019
+ const issue = await resolveIssue(client, idArg);
1020
+ await assertMutation(
1021
+ withRetry(() =>
1022
+ subscribe ? client.issueSubscribe(issue.id) : client.issueUnsubscribe(issue.id),
1023
+ ),
1024
+ subscribe ? "Issue subscribe" : "Issue unsubscribe",
1025
+ );
1026
+ return issue;
1027
+ }
1028
+
1029
+ /** The state change `issue start` makes; see `moveIssueState`. */
1030
+ export interface StartMove {
1031
+ /** Move to this state (name or type) instead of the first `started` one. */
1032
+ stateInput?: string;
1033
+ /** Move to the team's first `started`-type state. */
1034
+ move?: boolean;
1035
+ }
1036
+
1037
+ /**
1038
+ * Move an issue's state for `start` (and `create --start`). `move` selects the
1039
+ * team's first `started`-type state; an explicit `stateInput` is resolved by
1040
+ * name/type. Neither → no request. Takes an already-resolved issue so a freshly
1041
+ * created one is not looked up a second time.
1042
+ */
1043
+ export async function moveIssueState(client: LinearClient, issue: Issue, opts: StartMove) {
1044
+ if (!opts.stateInput && !opts.move) return;
1045
+ const teamId = (await issue.team)?.id;
1046
+ if (!teamId) throw usageError("Cannot resolve team for state change.");
1047
+ const stateId = opts.stateInput
1048
+ ? await resolveStateId(client, teamId, opts.stateInput)
1049
+ : await firstStateOfType(client, teamId, "started");
1050
+ await assertMutation(
1051
+ withRetry(() => client.updateIssue(issue.id, { stateId })),
1052
+ "Issue update",
1053
+ );
1054
+ }
1055
+
1056
+ export async function startIssue(client: LinearClient, idArg: string, opts: StartMove) {
1057
+ const issue = await resolveIssue(client, idArg);
1058
+ await moveIssueState(client, issue, opts);
1059
+ return issue;
1060
+ }
1061
+
1062
+ export async function addRemoveRelation(
1063
+ client: LinearClient,
1064
+ idArg: string,
1065
+ op: "add" | "remove",
1066
+ type: "blocks" | "blocked_by" | "related" | "duplicate",
1067
+ otherArg: string,
1068
+ ) {
1069
+ const issue = await resolveIssue(client, idArg);
1070
+ const other = await resolveIssue(client, otherArg);
1071
+ // "blocked_by" is modeled as the inverse "blocks" from the other issue.
1072
+ const apiType = type === "blocked_by" ? "blocks" : type;
1073
+ const [from, to] = type === "blocked_by" ? [other, issue] : [issue, other];
1074
+ if (op === "add") {
1075
+ await assertMutation(
1076
+ withRetry(() =>
1077
+ client.createIssueRelation({
1078
+ issueId: from.id,
1079
+ relatedIssueId: to.id,
1080
+ type: apiType as any,
1081
+ }),
1082
+ ),
1083
+ "Relation creation",
1084
+ );
1085
+ } else {
1086
+ // The single relation record may live on either issue (direction matters for
1087
+ // blocks/blocked_by). Search `from`'s direct + inverse relations for a record
1088
+ // of `apiType` whose source=from and target=to.
1089
+ const [direct, inverse] = await Promise.all([
1090
+ collect((await withRetry(() => from.relations())) as any, Infinity),
1091
+ collect((await withRetry(() => from.inverseRelations())) as any, Infinity),
1092
+ ]);
1093
+ const match = await findRelation([...direct, ...inverse] as any[], from.id, to.id, apiType);
1094
+ if (!match)
1095
+ throw notFound(`No ${type} relation between ${issue.identifier} and ${other.identifier}.`);
1096
+ await assertMutation(
1097
+ withRetry(() => client.deleteIssueRelation(match.id)),
1098
+ "Relation removal",
1099
+ );
1100
+ }
1101
+ return { issue, other };
1102
+ }
1103
+
1104
+ /** Find the relation record of `type` whose endpoints are exactly {sourceId, targetId}. */
1105
+ async function findRelation(nodes: any[], sourceId: string, targetId: string, type: string) {
1106
+ for (const r of nodes) {
1107
+ if (r.type !== type) continue;
1108
+ const [src, tgt] = await Promise.all([r.issue, r.relatedIssue]);
1109
+ const a = src?.id;
1110
+ const b = tgt?.id;
1111
+ // For directional `blocks`, require source→target order; symmetric types
1112
+ // (related/duplicate) match either orientation.
1113
+ if (type === "blocks") {
1114
+ if (a === sourceId && b === targetId) return r;
1115
+ } else if ((a === sourceId && b === targetId) || (a === targetId && b === sourceId)) {
1116
+ return r;
1117
+ }
1118
+ }
1119
+ return undefined;
1120
+ }
1121
+
1122
+ const INVERSE_TYPE: Record<string, string> = {
1123
+ blocks: "blocked_by",
1124
+ related: "related",
1125
+ duplicate: "duplicate",
1126
+ similar: "similar",
1127
+ };
1128
+
1129
+ export async function listRelations(client: LinearClient, idArg: string) {
1130
+ const issue = await resolveIssue(client, idArg);
1131
+ // Direct relations (this issue is the source) + inverse relations (this issue
1132
+ // is the target — e.g. a "blocks" on another issue means we're "blocked_by").
1133
+ // Both are paginated to completion.
1134
+ const [direct, inverse] = await Promise.all([
1135
+ collect((await withRetry(() => issue.relations())) as any, Infinity),
1136
+ collect((await withRetry(() => issue.inverseRelations())) as any, Infinity),
1137
+ ]);
1138
+ const out = await Promise.all([
1139
+ ...direct.map(async (r: any) => {
1140
+ const related = await r.relatedIssue;
1141
+ return { type: r.type, issue: related?.identifier ?? "?", title: related?.title ?? "" };
1142
+ }),
1143
+ ...inverse.map(async (r: any) => {
1144
+ const source = await r.issue;
1145
+ return {
1146
+ type: INVERSE_TYPE[r.type] ?? r.type,
1147
+ issue: source?.identifier ?? "?",
1148
+ title: source?.title ?? "",
1149
+ };
1150
+ }),
1151
+ ]);
1152
+ return out;
1153
+ }