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,1263 @@
1
+ /**
2
+ * `linear issue` (alias `i`) — the core, git-branch-aware command group.
3
+ *
4
+ * When an issue id is omitted, it is inferred from the current git branch
5
+ * (`tes-123-foo` → `TES-123`).
6
+ */
7
+
8
+ import { Command, Option } from "commander";
9
+ import { action } from "../lib/action.js";
10
+ import {
11
+ addFilterOptions,
12
+ addCoreFilterOptions,
13
+ addAliasOption,
14
+ readAlias,
15
+ parseList,
16
+ parseIntOption,
17
+ parsePriority,
18
+ CYCLE_FLAG,
19
+ CYCLE_DESC,
20
+ suggestSubcommand,
21
+ } from "../lib/options.js";
22
+ import { registerIssueCommentGroup } from "./comment.js";
23
+ import { noteWorkspaceWide } from "./project.js";
24
+ import { resolveBody } from "../lib/body.js";
25
+ import { confirmDestructive, promptInput } from "../lib/prompt.js";
26
+ import { usageError } from "../lib/errors.js";
27
+ import {
28
+ currentIssueId,
29
+ checkoutBranch,
30
+ isGitRepo,
31
+ buildTrailer,
32
+ buildDescription,
33
+ buildPrContent,
34
+ buildPrArgs,
35
+ } from "../git.js";
36
+ import { execFileSync } from "node:child_process";
37
+ import { readFileSync } from "node:fs";
38
+ import { CliError, normalizeError } from "../lib/errors.js";
39
+ import { openUrl } from "../lib/open.js";
40
+ import type { Context } from "../context.js";
41
+ import * as svc from "../services/issue.js";
42
+ import * as commentSvc from "../services/comment.js";
43
+ import { isSelf, normalizeIssueReference, resolveIssue } from "../lib/resolve.js";
44
+ import type { Column } from "../output/table.js";
45
+
46
+ /** Resolve the target issue id from an argument/current branch, expanding `42` via the default team. */
47
+ function requireId(idArg: string | undefined, defaultTeam?: string): string {
48
+ const id = idArg ?? currentIssueId();
49
+ if (!id) {
50
+ throw usageError("No issue id given and none could be inferred from the current branch.");
51
+ }
52
+ return normalizeIssueReference(id, defaultTeam);
53
+ }
54
+
55
+ const ROW_COLUMNS: Column<svc.IssueRow>[] = [
56
+ { key: "id", header: "ID", value: (r) => r.identifier },
57
+ // `--include-archived` mixes live, archived and trashed rows; mark the latter
58
+ // two so they cannot pass for live. The state name alone stays under 14.
59
+ {
60
+ key: "state",
61
+ header: "State",
62
+ value: (r) => `${r.state?.name ?? ""}${lifecycleMark(r)}`,
63
+ max: 26,
64
+ },
65
+ { key: "priority", header: "Pri", value: (r) => shortPriority(r.priority) },
66
+ { key: "assignee", header: "Assignee", value: (r) => r.assignee?.displayName ?? "—", max: 16 },
67
+ { key: "title", header: "Title", value: (r) => r.title, max: 60 },
68
+ ];
69
+
70
+ /**
71
+ * Columns the default table leaves out (it is wide already) but `--fields` can
72
+ * ask for by name: `--fields id,milestone,title`. A cycle shows its name, or
73
+ * `#n` when it has none — the generic row-key fallback would print its id.
74
+ */
75
+ const EXTRA_COLUMNS: Column<svc.IssueRow>[] = [
76
+ { key: "milestone", header: "Milestone", value: (r) => r.milestone?.name ?? "—", max: 30 },
77
+ { key: "cycle", header: "Cycle", value: (r) => cycleLabel(r.cycle) },
78
+ ];
79
+
80
+ /** The list table's columns: the defaults, plus the optional ones once `--fields` is selecting. */
81
+ function listColumns(ctx: Context): Column<svc.IssueRow>[] {
82
+ return ctx.options.fields?.length ? [...ROW_COLUMNS, ...EXTRA_COLUMNS] : ROW_COLUMNS;
83
+ }
84
+
85
+ /** `Sprint 3`, or `#3` for an unnamed cycle; `—` for none. */
86
+ function cycleLabel(c: svc.IssueRow["cycle"]): string {
87
+ return c ? (c.name ?? `#${c.number}`) : "—";
88
+ }
89
+
90
+ function shortPriority(p: number): string {
91
+ return ["—", "Urgent", "High", "Med", "Low"][p] ?? String(p);
92
+ }
93
+
94
+ /** ` (trashed)` / ` (archived)` for a row that is not live; empty otherwise. */
95
+ function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string {
96
+ return r.trashed ? " (trashed)" : r.archivedAt ? " (archived)" : "";
97
+ }
98
+
99
+ /** A removed reference flag whose meaning is already this CLI's query default. */
100
+ function addNoopAllAssignees(command: Command): void {
101
+ command.addOption(
102
+ new Option(
103
+ "--all-assignees",
104
+ "accepted for compatibility (does not change the query)",
105
+ ).hideHelp(),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * schpet/linear-cli `issue` subcommands that a migrating user may type and that
111
+ * do not exist under that name here. Since `view` is the default subcommand,
112
+ * `linear issue link x` lands in `view` with "link" as the id; without this
113
+ * the error would only say it is not an issue id. Keep in step with MIGRATING.md.
114
+ * (`issue attach` is a real subcommand now — TES-602 — so it never lands here.)
115
+ */
116
+ const SCHPET_ISSUE_SUBCOMMANDS: Record<string, string> = {
117
+ link: "Use 'linear attachment create <issue> --url <url>'.",
118
+ commits:
119
+ "'issue commits' is not available here (jj/git log integration is not adopted). Use 'git log --grep <ID>'.",
120
+ "agent-session": "Use 'linear issue agent-session list|view <issue>'.",
121
+ };
122
+
123
+ export function registerIssue(program: Command): void {
124
+ const issue = program.command("issue").alias("i").description("Work with issues");
125
+
126
+ // view --------------------------------------------------------------------
127
+ issue
128
+ .command("view [id]", { isDefault: true })
129
+ .description("Show an issue (defaults to the current branch's issue)")
130
+ .option("-w, --web", "open the issue in the browser instead of printing")
131
+ .option("--app", "open the issue in Linear.app instead of printing")
132
+ .option("--no-comments", "exclude comments from the output")
133
+ // The old opt-in spelling remains accepted; comments are now on by default.
134
+ .addOption(
135
+ new Option(
136
+ "--comments",
137
+ "accepted for compatibility: comments are included by default",
138
+ ).hideHelp(),
139
+ )
140
+ .option("--show-resolved-threads", "include resolved comment threads")
141
+ .action(
142
+ action(async (ctx: Context, opts, idArg?: string) => {
143
+ // `view` is the default subcommand, so `linear issue lst` lands here
144
+ // with "lst" as the id. Say what it probably was, not just what it is not.
145
+ if (idArg !== undefined && !ISSUE_ID_RE.test(idArg)) {
146
+ // A schpet/linear-cli subcommand that lives elsewhere here (or not at
147
+ // all) is the likeliest thing a migrating user types; name the
148
+ // equivalent before falling back to a spelling guess.
149
+ const ported = SCHPET_ISSUE_SUBCOMMANDS[idArg.toLowerCase()];
150
+ const guess = ported ? undefined : suggestSubcommand(issue, idArg);
151
+ throw usageError(
152
+ `'${idArg}' is not a valid issue id (expected e.g. TES-123, 123 with a default team, or a UUID).${
153
+ ported ? ` ${ported}` : guess ? ` Did you mean 'linear issue ${guess}'?` : ""
154
+ }`,
155
+ );
156
+ }
157
+ if (opts.web && opts.app) throw usageError("Pass either --web or --app, not both.");
158
+ const includeComments = opts.comments !== false && !opts.web && !opts.app;
159
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
160
+ includeComments,
161
+ });
162
+ if (opts.web || opts.app) {
163
+ await openUrl(detail.url, { app: opts.app === true });
164
+ ctx.output.emit(
165
+ { id: detail.id, identifier: detail.identifier, url: detail.url, opened: true },
166
+ () =>
167
+ ctx.output.success(
168
+ `Opened ${detail.identifier} in ${opts.app ? "Linear.app" : "the browser"}`,
169
+ ),
170
+ );
171
+ return;
172
+ }
173
+ await renderIssueDetail(ctx, detail, includeComments, opts.showResolvedThreads === true);
174
+ }),
175
+ );
176
+
177
+ // list --------------------------------------------------------------------
178
+ const list = issue
179
+ .command("list")
180
+ .alias("ls")
181
+ // `query` is the reference CLI's name for this command; same code path.
182
+ .alias("query")
183
+ .description("List issues with filters")
184
+ .addHelpText(
185
+ "after",
186
+ [
187
+ "",
188
+ "Examples:",
189
+ " linear issue list --assignee me --state 'In Progress'",
190
+ " linear issue list --team TES --team ENG --state started --state 'In Review'",
191
+ " linear issue list --unassigned --created-after 2026-01-01",
192
+ " linear issue list --project-label mobile --updated-after 2026-08-01",
193
+ " linear issue list --cycle current --json | jq -r '.[].identifier'",
194
+ ].join("\n"),
195
+ )
196
+ .action(
197
+ action(async (ctx: Context, opts) => {
198
+ // `--all-states` is already how `list` behaves; accepting it keeps
199
+ // transplanted commands working, but pairing it with an explicit
200
+ // --state is a contradiction, so say so rather than pick one.
201
+ if (opts.allStates && opts.state?.length) {
202
+ throw usageError("Pass either --state or --all-states, not both.");
203
+ }
204
+ noteWorkspaceWide(ctx, opts);
205
+ const rows = await svc.listIssues(
206
+ ctx.client,
207
+ {
208
+ team: opts.team ?? ctx.defaultTeam,
209
+ allTeams: opts.allTeams,
210
+ assignee: opts.assignee,
211
+ unassigned: opts.unassigned,
212
+ state: opts.state,
213
+ project: opts.project,
214
+ projectLabel: opts.projectLabel,
215
+ milestone: opts.milestone,
216
+ label: opts.label,
217
+ priority: opts.priority,
218
+ cycle: opts.cycle,
219
+ createdAfter: opts.createdAfter,
220
+ updatedAfter: opts.updatedAfter,
221
+ query: readAlias(opts, "--query", "--search"),
222
+ sort: svc.resolveIssueSort(opts.sort, ctx.config),
223
+ includeArchived: opts.includeArchived,
224
+ },
225
+ ctx.limit,
226
+ ctx.defaultTeam,
227
+ );
228
+ ctx.output.list(rows, listColumns(ctx), rows);
229
+ }),
230
+ );
231
+ addFilterOptions(list).addOption(
232
+ // Not an alias of anything here — `list` already spans every state — but
233
+ // the reference ships it, so accept it as a no-op instead of erroring.
234
+ new Option(
235
+ "--all-states",
236
+ "accepted for compatibility (list is all-states already)",
237
+ ).hideHelp(),
238
+ );
239
+ addNoopAllAssignees(list);
240
+
241
+ // mine --------------------------------------------------------------------
242
+ // `list` stays general; `mine` is the opinionated "what's on my plate" view
243
+ // the reference CLI ships as its default listing — same defaults (you, and
244
+ // unstarted work only), so a transplanted `linear issue mine` behaves.
245
+ const mine = issue
246
+ .command("mine")
247
+ // Deliberately NO `l` alias, which is what the reference uses: our `list` is
248
+ // `ls`, so `l` and `ls` would sit one keystroke apart and return completely
249
+ // different sets. `linear issue l` failing loudly is the better outcome.
250
+ .description("List your unstarted issues (--all-states for every state)")
251
+ .addHelpText(
252
+ "after",
253
+ [
254
+ "",
255
+ "Examples:",
256
+ " linear issue mine # your unstarted issues",
257
+ " linear issue mine --all-states # every state, still yours",
258
+ " linear issue mine --state started # one specific state instead",
259
+ ].join("\n"),
260
+ )
261
+ .action(
262
+ action(async (ctx: Context, opts) => {
263
+ if (opts.allStates && opts.state?.length) {
264
+ throw usageError("Pass either --state or --all-states, not both.");
265
+ }
266
+ noteWorkspaceWide(ctx, opts);
267
+ const rows = await svc.listIssues(
268
+ ctx.client,
269
+ {
270
+ team: opts.team ?? ctx.defaultTeam,
271
+ allTeams: opts.allTeams,
272
+ // The whole point of the command: never overridable.
273
+ assignee: "me",
274
+ // An explicit --state (repeatable) replaces the default set rather
275
+ // than intersecting it; --all-states drops the restriction entirely.
276
+ state: opts.state?.length
277
+ ? opts.state
278
+ : opts.allStates
279
+ ? undefined
280
+ : svc.MINE_STATE_TYPES,
281
+ project: opts.project,
282
+ projectLabel: opts.projectLabel,
283
+ milestone: opts.milestone,
284
+ label: opts.label,
285
+ priority: opts.priority,
286
+ cycle: opts.cycle,
287
+ createdAfter: opts.createdAfter,
288
+ updatedAfter: opts.updatedAfter,
289
+ query: readAlias(opts, "--query", "--search"),
290
+ sort: svc.resolveIssueSort(opts.sort, ctx.config),
291
+ includeArchived: opts.includeArchived,
292
+ },
293
+ ctx.limit,
294
+ ctx.defaultTeam,
295
+ );
296
+ ctx.output.list(rows, listColumns(ctx), rows);
297
+ }),
298
+ );
299
+ addFilterOptions(mine, { assignee: false }).addOption(
300
+ new Option("--all-states", "include every workflow state, not just unstarted"),
301
+ );
302
+ addNoopAllAssignees(mine);
303
+
304
+ // search ------------------------------------------------------------------
305
+ const search = issue
306
+ .command("search <text>")
307
+ .description("Full-text search across issues (scoped to the default team; --all-teams widens)")
308
+ .addHelpText(
309
+ "after",
310
+ [
311
+ "",
312
+ "Examples:",
313
+ " linear issue search 'login crash' --state started",
314
+ " linear issue search 'login crash' --all-teams --assignee me",
315
+ ].join("\n"),
316
+ )
317
+ .action(
318
+ action(async (ctx: Context, opts, text: string) => {
319
+ noteWorkspaceWide(ctx, opts);
320
+ const rows = await svc.searchIssues(
321
+ ctx.client,
322
+ text,
323
+ {
324
+ team: opts.team ?? ctx.defaultTeam,
325
+ allTeams: opts.allTeams,
326
+ assignee: opts.assignee,
327
+ unassigned: opts.unassigned,
328
+ state: opts.state,
329
+ project: opts.project,
330
+ projectLabel: opts.projectLabel,
331
+ milestone: opts.milestone,
332
+ label: opts.label,
333
+ priority: opts.priority,
334
+ cycle: opts.cycle,
335
+ createdAfter: opts.createdAfter,
336
+ updatedAfter: opts.updatedAfter,
337
+ includeArchived: opts.includeArchived,
338
+ searchComments: opts.searchComments,
339
+ },
340
+ ctx.limit,
341
+ ctx.defaultTeam,
342
+ );
343
+ ctx.output.list(rows, listColumns(ctx), rows);
344
+ }),
345
+ );
346
+ // Search-only: the plain `issues` query has nowhere to put it, so this does
347
+ // not belong in the shared filter options.
348
+ addCoreFilterOptions(search).option(
349
+ "--search-comments",
350
+ "match comment bodies as well as titles and descriptions",
351
+ );
352
+ addNoopAllAssignees(search);
353
+
354
+ // create ------------------------------------------------------------------
355
+ const create = issue
356
+ .command("create")
357
+ .alias("new")
358
+ .description("Create a new issue")
359
+ .option("--title <title>", "issue title")
360
+ .option("-d, --description <text>", "issue description (body)")
361
+ .option("--description-file <path>", "read description from a file ('-' = stdin)")
362
+ .option("--editor", "compose the description in $EDITOR")
363
+ .option("-a, --assignee <who>", "assignee (me|email|name|id)")
364
+ .option("-s, --state <name>", "workflow state name or type")
365
+ .option("-P, --priority <0-4>", "priority", parsePriority)
366
+ .option("-l, --label <name>", "label (repeatable / comma-separated)", parseList)
367
+ .option("-p, --project <name>", "project name or id")
368
+ .option("--milestone <name>", "project milestone (requires --project)")
369
+ .option(CYCLE_FLAG, CYCLE_DESC)
370
+ .option("--estimate <n>", "estimate points", parseIntOption)
371
+ .option(
372
+ "--parent <id>",
373
+ "parent issue id (the sub-issue joins the parent's project unless --project says otherwise)",
374
+ )
375
+ .option("--due <date>", "due date (YYYY-MM-DD)")
376
+ .option("--template <name|id>", "create from an issue template (the team's or a shared one)")
377
+ .option("--no-default-template", "do not apply the team's default issue template")
378
+ .option(
379
+ "--start",
380
+ "then start work: check out the branch, move to the first 'started' state (or --state), assign to you",
381
+ )
382
+ .addHelpText(
383
+ "after",
384
+ [
385
+ "",
386
+ "Examples:",
387
+ " linear issue create --title 'Fix login' --team TES --assignee me",
388
+ " linear issue create --title 'Bug' -l bug -l urgent --priority 1",
389
+ " linear issue create --title 'Sprint task' --cycle current --state 'In Progress'",
390
+ " linear issue create --title 'Sub-task' --parent TES-42 # inherits TES-42's project",
391
+ " linear issue create --title 'Bug report' --template 'Bug' # from a template",
392
+ " linear issue create --title 'Hotfix' --start # create, branch, In Progress",
393
+ " linear issue create --title 'API' --team TES --json | jq -r '.identifier'",
394
+ ].join("\n"),
395
+ )
396
+ .action(
397
+ action(async (ctx: Context, opts) => {
398
+ // `--start` means *you* are starting on it, so it assigns to you (as the
399
+ // reference CLI does); naming somebody else at the same time is a contradiction.
400
+ if (opts.start && opts.assignee && !isSelf(opts.assignee)) {
401
+ throw usageError(
402
+ "--start assigns the issue to you; pass either --start or --assignee <someone else>, not both.",
403
+ );
404
+ }
405
+ let title: string | undefined = opts.title;
406
+ if (!title) title = await promptInput(ctx, "Title:", { required: true });
407
+ const description = resolveBody({
408
+ arg: opts.description,
409
+ file: opts.descriptionFile,
410
+ interactive: !!opts.editor && ctx.isTTY,
411
+ editorRequested: !!opts.editor,
412
+ });
413
+ const created = await svc.createIssue(
414
+ ctx.client,
415
+ {
416
+ title,
417
+ description,
418
+ team: opts.team ?? ctx.defaultTeam,
419
+ assignee: opts.start ? (opts.assignee ?? "me") : opts.assignee,
420
+ state: opts.state,
421
+ priority: opts.priority,
422
+ label: opts.label,
423
+ project: opts.project,
424
+ milestone: opts.milestone,
425
+ cycle: opts.cycle,
426
+ estimate: opts.estimate,
427
+ parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
428
+ dueDate: readAlias(opts, "--due", "--due-date"),
429
+ template: opts.template,
430
+ // Both spellings: ours, and the reference CLI's `--no-use-default-template`.
431
+ useDefaultTemplate: opts.defaultTemplate !== false && opts.useDefaultTemplate !== false,
432
+ },
433
+ ctx.defaultTeam,
434
+ );
435
+ if (!opts.start) {
436
+ ctx.output.emit(
437
+ { id: created.id, identifier: created.identifier, url: created.url },
438
+ () => ctx.output.success(`Created ${created.identifier}: ${created.url}`),
439
+ );
440
+ return;
441
+ }
442
+ // --start: the same two steps `issue start --move` takes, on the issue
443
+ // just created. An explicit --state already put it where it belongs, so
444
+ // only the default case moves it (to the team's first `started` state).
445
+ const moved = !opts.state;
446
+ // Local checkout is the preflight for starting work. If git refuses
447
+ // (dirty tree, invalid ref, …), the issue remains in its existing state.
448
+ const branchResult = isGitRepo() ? checkoutBranch(created.branchName) : undefined;
449
+ await svc.moveIssueState(ctx.client, created, { move: moved });
450
+ ctx.output.emit(
451
+ {
452
+ id: created.id,
453
+ identifier: created.identifier,
454
+ url: created.url,
455
+ branch: branchResult?.branch ?? created.branchName,
456
+ checkedOut: !!branchResult,
457
+ stateChanged: moved,
458
+ },
459
+ () => {
460
+ ctx.output.success(`Created ${created.identifier}: ${created.url}`);
461
+ if (branchResult)
462
+ ctx.output.success(
463
+ `${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
464
+ );
465
+ else ctx.output.info(`Branch name: ${created.branchName}`);
466
+ if (moved) ctx.output.success(`Moved ${created.identifier} → started`);
467
+ },
468
+ );
469
+ }),
470
+ );
471
+ addAliasOption(create, "--due-date <date>", "--due");
472
+ addAliasOption(create, "--no-use-default-template", "--no-default-template");
473
+
474
+ // update ------------------------------------------------------------------
475
+ const update = issue
476
+ .command("update [id]")
477
+ .alias("edit")
478
+ .description("Update an issue")
479
+ .option("--title <title>", "new title")
480
+ .option("-d, --description <text>", "new description")
481
+ .option("--description-file <path>", "read description from a file ('-' = stdin)")
482
+ // Declared locally so help says what `--team` *does here* — this is the one
483
+ // command where the global team flag moves the issue instead of scoping a
484
+ // lookup. (addGlobalOptions leaves a locally-declared global alone.)
485
+ .option("-t, --team <key>", "move the issue to another team (changes its identifier)")
486
+ .option("-a, --assignee <who>", "assignee (me|email|name|id)")
487
+ .option("-s, --state <name>", "workflow state name or type")
488
+ .option("-P, --priority <0-4>", "priority", parsePriority)
489
+ .option("-p, --project <name>", "project name or id")
490
+ .option("--milestone <name>", "project milestone")
491
+ .option(CYCLE_FLAG, CYCLE_DESC)
492
+ .option("--estimate <n>", "estimate points", parseIntOption)
493
+ .option("--parent <id>", "parent issue id")
494
+ .option("--due <date>", "due date (YYYY-MM-DD)")
495
+ .option("-l, --label <name>", "replace all labels (repeatable / comma-separated)", parseList)
496
+ .option("--add-label <name>", "add a label (repeatable)", parseList)
497
+ .option("--remove-label <name>", "remove a label (repeatable)", parseList)
498
+ .option("--unassign", "clear the assignee")
499
+ .option("--clear-cycle", "remove the issue from its cycle")
500
+ .addHelpText(
501
+ "after",
502
+ [
503
+ "",
504
+ "Examples:",
505
+ " linear issue update TES-42 --state 'In Progress' --assignee me",
506
+ " linear issue update --priority 1 --add-label regression # id from branch",
507
+ " linear issue update TES-42 --cycle current --json",
508
+ " linear issue update TES-42 --team ENG # move to another team (new identifier)",
509
+ ].join("\n"),
510
+ )
511
+ .action(
512
+ action(async (ctx: Context, opts, idArg?: string) => {
513
+ const description = resolveBody({
514
+ arg: opts.description,
515
+ file: opts.descriptionFile,
516
+ interactive: false,
517
+ });
518
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
519
+ title: opts.title,
520
+ description,
521
+ // Only the explicit flag moves an issue — never `ctx.defaultTeam`, or
522
+ // every update in a repo with a configured team would be a team move.
523
+ team: opts.team,
524
+ assignee: opts.assignee,
525
+ state: opts.state,
526
+ priority: opts.priority,
527
+ project: opts.project,
528
+ milestone: opts.milestone,
529
+ cycle: opts.cycle,
530
+ estimate: opts.estimate,
531
+ parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
532
+ dueDate: readAlias(opts, "--due", "--due-date"),
533
+ label: opts.label,
534
+ addLabel: opts.addLabel,
535
+ removeLabel: opts.removeLabel,
536
+ unassign: opts.unassign,
537
+ clearCycle: opts.clearCycle,
538
+ });
539
+ ctx.output.emit(
540
+ { id: updated.id, identifier: updated.identifier, url: updated.url },
541
+ () => {
542
+ ctx.output.success(`Updated ${updated.identifier}`);
543
+ // A move renumbers the issue and Linear drops what the destination
544
+ // team cannot hold. Say so once, rather than letting a script's next
545
+ // `TES-42` fail with "no such issue". Verified live — see CHANGELOG.
546
+ if (opts.team) {
547
+ ctx.output.info(
548
+ `Moved to team ${updated.identifier.split("-")[0]}: the issue is now ${updated.identifier}. ` +
549
+ `Its cycle, team-scoped labels, and any project the new team is not part of do not carry over.`,
550
+ );
551
+ }
552
+ },
553
+ );
554
+ }),
555
+ );
556
+ addAliasOption(update, "--due-date <date>", "--due");
557
+
558
+ // assign / state ----------------------------------------------------------
559
+ issue
560
+ .command("assign [idOrAssignee] [assignee]")
561
+ .description("Assign an issue (use 'me', email, name, or id). Issue defaults to the branch.")
562
+ .action(
563
+ action(async (ctx: Context, _opts, a?: string, b?: string) => {
564
+ const { idArg, value } = oneOrTwo(a, b, "assignee");
565
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
566
+ assignee: value,
567
+ });
568
+ ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
569
+ ctx.output.success(`Assigned ${updated.identifier}`),
570
+ );
571
+ }),
572
+ );
573
+
574
+ issue
575
+ .command("state [idOrState] [state]")
576
+ .description("Move an issue to a workflow state. Issue defaults to the branch.")
577
+ .action(
578
+ action(async (ctx: Context, _opts, a?: string, b?: string) => {
579
+ const { idArg, value } = oneOrTwo(a, b, "state");
580
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
581
+ state: value,
582
+ });
583
+ ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
584
+ ctx.output.success(`Moved ${updated.identifier} → ${value}`),
585
+ );
586
+ }),
587
+ );
588
+
589
+ // label -------------------------------------------------------------------
590
+ issue
591
+ .command("label [id]")
592
+ .description("Add/remove labels on an issue")
593
+ .option("--add <name>", "add a label (repeatable)", parseList)
594
+ .option("--remove <name>", "remove a label (repeatable)", parseList)
595
+ .action(
596
+ action(async (ctx: Context, opts, idArg?: string) => {
597
+ if (!opts.add && !opts.remove) throw usageError("Pass --add and/or --remove.");
598
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
599
+ addLabel: opts.add,
600
+ removeLabel: opts.remove,
601
+ });
602
+ ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
603
+ ctx.output.success(`Updated labels on ${updated.identifier}`),
604
+ );
605
+ }),
606
+ );
607
+
608
+ // comment / comments ------------------------------------------------------
609
+ const comment = issue
610
+ .command("comment [id] [body]")
611
+ .description(
612
+ 'Add a comment to an issue; on a matching branch, `issue comment "<body>"` is enough (or use the add/list/update/delete subcommands)',
613
+ )
614
+ .option("--body-file <path>", "read comment body from a file ('-' = stdin)")
615
+ .action(
616
+ action(async (ctx: Context, opts, a?: string, b?: string) => {
617
+ // Both operands are optional, so a lone one is ambiguous: `TES-42` is
618
+ // an id (body from --body-file or $EDITOR), anything else is the body
619
+ // with the id inferred from the branch — the README's headline
620
+ // `linear issue comment "shipped"`. Same rule as `assign`/`state`.
621
+ const { idArg, bodyArg } = idAndBody(a, b);
622
+ // Settle the id BEFORE the editor can open, so nobody writes a comment
623
+ // only to be told there was nowhere to put it.
624
+ const id = requireId(idArg, ctx.defaultTeam);
625
+ const body = resolveBody({ arg: bodyArg, file: opts.bodyFile, interactive: ctx.isTTY });
626
+ if (!body) throw usageError("No comment body provided.");
627
+ const { issue: iss, comment } = await commentSvc.addComment(ctx.client, id, body);
628
+ ctx.output.emit({ id: comment?.id, issue: iss.identifier }, () =>
629
+ ctx.output.success(`Commented on ${iss.identifier}`),
630
+ );
631
+ }),
632
+ );
633
+ // The reference CLI's `issue comment {add,list,update,delete}` layout, mounted
634
+ // on the same handlers as the top-level `comment` group. Commander dispatches
635
+ // to a subcommand only when the first operand matches one of these four names,
636
+ // so `linear issue comment TES-1 'body'` is untouched.
637
+ registerIssueCommentGroup(comment);
638
+
639
+ issue
640
+ .command("comments [id]")
641
+ .description("List comments on an issue")
642
+ .action(
643
+ action(async (ctx: Context, _opts, idArg?: string) => {
644
+ // Same implementation and row shape as `comment list` — TES-629: this
645
+ // used to be a second, narrower lister with its own JSON.
646
+ const comments = await commentSvc.listComments(
647
+ ctx.client,
648
+ requireId(idArg, ctx.defaultTeam),
649
+ ctx.limit,
650
+ );
651
+ ctx.output.list(
652
+ comments,
653
+ [
654
+ { key: "createdAt", header: "Date", value: (c) => c.createdAt.slice(0, 10) },
655
+ { key: "user", header: "Author", value: (c) => c.user?.displayName ?? "—", max: 18 },
656
+ { key: "body", header: "Comment", value: (c) => c.body.replace(/\n/g, " "), max: 70 },
657
+ ],
658
+ comments,
659
+ );
660
+ }),
661
+ );
662
+
663
+ // start -------------------------------------------------------------------
664
+ // "Start" moves the issue: to the team's first `started` state, or to
665
+ // `--state`. That is what the word means, what schpet/linear-cli does
666
+ // unconditionally (T `src/utils/actions.ts`), and what an agent that just
667
+ // said "start" expects to have happened. It used to be opt-in (`--move`), so
668
+ // a transplanted `linear issue start TES-1` checked the branch out and left
669
+ // the issue in Backlog without a word (TES-637 item 4). `--no-move` is the
670
+ // opt-out; `--move` is still accepted (hidden) so an existing script keeps
671
+ // working. Both the state change and the checkout are reported, so neither
672
+ // is a surprise.
673
+ issue
674
+ .command("start [id]")
675
+ .description(
676
+ "Start work on an issue: check out its branch and move it to the first 'started' state",
677
+ )
678
+ .option("--state <name>", "move to this state instead of the first 'started' one")
679
+ .option("--no-move", "do not change the state; only check out the branch")
680
+ .addOption(new Option("--move", "accepted for compatibility: moving is the default").hideHelp())
681
+ .option("--no-checkout", "do not touch git; only update state")
682
+ .addHelpText(
683
+ "after",
684
+ [
685
+ "",
686
+ "Examples:",
687
+ " linear issue start TES-42 # branch + first 'started' state",
688
+ " linear issue start TES-42 --no-move # branch only",
689
+ " linear issue start TES-42 --state 'In Review' --no-checkout",
690
+ " linear issue start --json | jq -r '.branch' # id from branch",
691
+ ].join("\n"),
692
+ )
693
+ .action(
694
+ action(async (ctx: Context, opts, idArg?: string) => {
695
+ // `--state` is a move; `--no-move` says don't. Not a coin flip.
696
+ if (opts.state !== undefined && opts.move === false) {
697
+ throw usageError("Pass either --state or --no-move, not both.");
698
+ }
699
+ const moved = opts.state !== undefined || opts.move !== false;
700
+ const issueModel = await resolveIssue(ctx.client, requireId(idArg, ctx.defaultTeam));
701
+ let branchResult: { branch: string; created: boolean } | undefined;
702
+ // Branch first, Linear second. A checkout failure is recoverable local
703
+ // feedback and must never leave the remote issue claiming work started.
704
+ if (opts.checkout !== false && isGitRepo()) {
705
+ branchResult = checkoutBranch(issueModel.branchName);
706
+ }
707
+ await svc.moveIssueState(ctx.client, issueModel, {
708
+ stateInput: opts.state,
709
+ move: moved,
710
+ });
711
+ ctx.output.emit(
712
+ {
713
+ id: issueModel.id,
714
+ identifier: issueModel.identifier,
715
+ branch: branchResult?.branch ?? issueModel.branchName,
716
+ checkedOut: !!branchResult,
717
+ stateChanged: moved,
718
+ },
719
+ () => {
720
+ if (branchResult)
721
+ ctx.output.success(
722
+ `${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
723
+ );
724
+ if (moved)
725
+ ctx.output.success(`Moved ${issueModel.identifier} → ${opts.state ?? "started"}`);
726
+ if (!branchResult && !moved) ctx.output.info(`Branch name: ${issueModel.branchName}`);
727
+ },
728
+ );
729
+ }),
730
+ );
731
+
732
+ // describe ----------------------------------------------------------------
733
+ // The output is a whole commit message — `git commit -m "$(linear issue
734
+ // describe)"` — in schpet/linear-cli's exact shape (`ID Title`, blank line,
735
+ // `Linear-issue:` / `Linear-issue-url:` trailers; see `buildDescription` in
736
+ // git.ts). It used to be `Title` + a bare `Fixes ID` line, so the same
737
+ // pipeline produced a different commit (TES-637 item 5).
738
+ issue
739
+ .command("describe [id]")
740
+ .description("Print a commit message for the issue: 'ID Title' plus Linear-issue trailers")
741
+ .option("-r, --references", "link without closing: 'References <ID>' instead of 'Fixes <ID>'")
742
+ .action(
743
+ action(async (ctx: Context, opts, idArg?: string) => {
744
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
745
+ includeComments: false,
746
+ });
747
+ const references = opts.references === true;
748
+ const trailer = buildTrailer(detail.identifier, { references });
749
+ const message = buildDescription(detail.identifier, detail.title, detail.url, {
750
+ references,
751
+ });
752
+ ctx.output.emit(
753
+ {
754
+ identifier: detail.identifier,
755
+ title: detail.title,
756
+ url: detail.url,
757
+ /** The magic-word phrase (`Fixes TES-1`); the trailers are `Linear-issue: <trailer>` + `Linear-issue-url: <url>`. */
758
+ trailer,
759
+ /** The full message, exactly as the human output prints it. */
760
+ message,
761
+ },
762
+ () => ctx.output.line(message),
763
+ );
764
+ }),
765
+ );
766
+
767
+ // pull-request ------------------------------------------------------------
768
+ issue
769
+ .command("pull-request [id]")
770
+ .alias("pr")
771
+ .description("Create a GitHub PR for the issue via the gh CLI")
772
+ .option("--base <branch>", "base branch for the PR")
773
+ .option("--head <branch>", "head branch for the PR")
774
+ .option("--draft", "create the PR as a draft")
775
+ .option("--title <title>", "PR title after the issue id (default: the issue title)")
776
+ .option("-w, --web", "open the PR creation page in the browser")
777
+ .action(
778
+ action(async (ctx: Context, opts, idArg?: string) => {
779
+ if (!isGitRepo()) {
780
+ throw usageError("`issue pr` must be run inside a git repository.");
781
+ }
782
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
783
+ includeComments: false,
784
+ });
785
+ // Title `ID Title` (a custom --title is prefixed the same way), body the
786
+ // two Linear-issue trailers — schpet/linear-cli's PR, plus the magic word
787
+ // (`buildPrContent`). The issue's *description* is no longer copied in
788
+ // (TES-637 item 5): a GitHub PR body is a wider audience than a Linear
789
+ // issue, and Linear links the PR from the trailer, not from the prose.
790
+ const { title, body } = buildPrContent(detail, opts.title);
791
+ const args = buildPrArgs({
792
+ title,
793
+ body,
794
+ base: opts.base,
795
+ head: opts.head,
796
+ draft: opts.draft,
797
+ web: opts.web,
798
+ });
799
+
800
+ if (opts.web) {
801
+ runGh(args);
802
+ ctx.output.emit({ web: true, identifier: detail.identifier }, () =>
803
+ ctx.output.info(`Opening a PR for ${detail.identifier} in the browser…`),
804
+ );
805
+ return;
806
+ }
807
+
808
+ const url = runGh(args).trim();
809
+ ctx.output.emit({ url, identifier: detail.identifier, title }, () =>
810
+ ctx.output.success(`Created PR for ${detail.identifier}: ${url}`),
811
+ );
812
+ }),
813
+ );
814
+
815
+ // relation ----------------------------------------------------------------
816
+ issue
817
+ .command("relation <id> <op> [other]")
818
+ .description("Manage issue relations: op = add|remove|list")
819
+ .option("--blocks", "relation type: blocks")
820
+ .option("--blocked-by", "relation type: blocked by")
821
+ .option("--related", "relation type: related (default)")
822
+ .option("--duplicate", "relation type: duplicate")
823
+ .action(
824
+ action(async (ctx: Context, opts, id: string, op: string, other?: string) => {
825
+ const issueId = normalizeIssueReference(id, ctx.defaultTeam);
826
+ if (op === "list") {
827
+ const rels = await svc.listRelations(ctx.client, issueId);
828
+ ctx.output.list(
829
+ rels,
830
+ [
831
+ { key: "type", header: "Type", value: (r) => r.type },
832
+ { key: "issue", header: "Issue", value: (r) => r.issue },
833
+ { key: "title", header: "Title", value: (r) => r.title, max: 60 },
834
+ ],
835
+ rels,
836
+ );
837
+ return;
838
+ }
839
+ if (op !== "add" && op !== "remove") throw usageError("op must be add, remove, or list.");
840
+ if (!other) throw usageError("Specify the other issue id.");
841
+ const type = opts.blocks
842
+ ? "blocks"
843
+ : opts.blockedBy
844
+ ? "blocked_by"
845
+ : opts.duplicate
846
+ ? "duplicate"
847
+ : "related";
848
+ const { issue: a, other: b } = await svc.addRemoveRelation(
849
+ ctx.client,
850
+ issueId,
851
+ op,
852
+ type as any,
853
+ normalizeIssueReference(other, ctx.defaultTeam),
854
+ );
855
+ ctx.output.emit(
856
+ {
857
+ issueId: a.id,
858
+ issueIdentifier: a.identifier,
859
+ otherId: b.id,
860
+ otherIdentifier: b.identifier,
861
+ type,
862
+ op,
863
+ },
864
+ () =>
865
+ ctx.output.success(
866
+ `${op === "add" ? "Added" : "Removed"} ${type} relation ${a.identifier} ↔ ${b.identifier}`,
867
+ ),
868
+ );
869
+ }),
870
+ );
871
+
872
+ // subscribe / unsubscribe -------------------------------------------------
873
+ issue
874
+ .command("subscribe [id]")
875
+ .description("Subscribe to an issue")
876
+ .action(
877
+ action(async (ctx: Context, _opts, idArg?: string) => {
878
+ const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), true);
879
+ ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: true }, () =>
880
+ ctx.output.success(`Subscribed to ${iss.identifier}`),
881
+ );
882
+ }),
883
+ );
884
+ issue
885
+ .command("unsubscribe [id]")
886
+ .description("Unsubscribe from an issue")
887
+ .action(
888
+ action(async (ctx: Context, _opts, idArg?: string) => {
889
+ const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), false);
890
+ ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: false }, () =>
891
+ ctx.output.success(`Unsubscribed from ${iss.identifier}`),
892
+ );
893
+ }),
894
+ );
895
+
896
+ // archive / unarchive / delete -------------------------------------------
897
+ issue
898
+ .command("archive [id]")
899
+ .description("Archive an issue")
900
+ .option("--bulk <ids>", "archive comma-separated issue ids (repeatable)", parseList)
901
+ .option("--bulk-file <path>", "archive issue ids from a file (one per line)")
902
+ .option("--bulk-stdin", "archive issue ids read from stdin (one per line)")
903
+ .action(
904
+ action(async (ctx: Context, opts, idArg?: string) => {
905
+ await runBulkIssueMutation(ctx, opts, idArg, "archive", (id) =>
906
+ svc.archiveIssue(ctx.client, id, false),
907
+ );
908
+ }),
909
+ );
910
+ issue
911
+ .command("unarchive [id]")
912
+ .description("Unarchive an issue")
913
+ .action(
914
+ action(async (ctx: Context, _opts, idArg?: string) => {
915
+ const iss = await svc.archiveIssue(ctx.client, requireId(idArg, ctx.defaultTeam), true);
916
+ ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: false }, () =>
917
+ ctx.output.success(`Unarchived ${iss.identifier}`),
918
+ );
919
+ }),
920
+ );
921
+ issue
922
+ .command("delete [id]")
923
+ .alias("rm")
924
+ .description("Delete (trash) an issue")
925
+ .option("--bulk <ids>", "delete comma-separated issue ids (repeatable)", parseList)
926
+ .option("--bulk-file <path>", "delete issue ids from a file (one per line)")
927
+ .option("--bulk-stdin", "delete issue ids read from stdin (one per line)")
928
+ .action(
929
+ action(async (ctx: Context, opts, idArg?: string) => {
930
+ await runBulkIssueMutation(ctx, opts, idArg, "delete", (id) =>
931
+ svc.deleteIssue(ctx.client, id),
932
+ );
933
+ }),
934
+ );
935
+
936
+ // scalar getters: id / title / url / branch ------------------------------
937
+ registerScalar(issue, "id", "Print the current issue's identifier", (d) => d.identifier);
938
+ registerScalar(issue, "title", "Print the issue title", (d) => d.title);
939
+ registerScalar(issue, "url", "Print the issue URL", (d) => d.url);
940
+ registerScalar(issue, "branch", "Print the suggested git branch name", (d) => d.branchName);
941
+ }
942
+
943
+ type BulkAction = "archive" | "delete";
944
+ type BulkIssueResult = {
945
+ input: string;
946
+ id?: string;
947
+ identifier?: string;
948
+ archived?: boolean;
949
+ deleted?: boolean;
950
+ error?: { message: string; code: string };
951
+ };
952
+
953
+ /**
954
+ * Collect issue ids from one explicit source. A positional id remains the
955
+ * ergonomic path for one issue (and can still be inferred from the branch),
956
+ * while bulk sources intentionally never consult the branch: an empty input
957
+ * must not accidentally mutate the current issue.
958
+ */
959
+ function bulkIssueIds(
960
+ opts: Record<string, unknown>,
961
+ idArg: string | undefined,
962
+ ): string[] | undefined {
963
+ const sources = [
964
+ Array.isArray(opts.bulk) && opts.bulk.length > 0 ? "--bulk" : undefined,
965
+ typeof opts.bulkFile === "string" ? "--bulk-file" : undefined,
966
+ opts.bulkStdin === true ? "--bulk-stdin" : undefined,
967
+ ].filter((v): v is string => !!v);
968
+ if (sources.length === 0) return undefined;
969
+ if (sources.length > 1 || idArg !== undefined) {
970
+ throw usageError(
971
+ "Pass either one issue id or exactly one of --bulk, --bulk-file, and --bulk-stdin.",
972
+ );
973
+ }
974
+ let values: string[];
975
+ if (sources[0] === "--bulk") values = opts.bulk as string[];
976
+ else {
977
+ const path = sources[0] === "--bulk-stdin" ? 0 : (opts.bulkFile as string);
978
+ let text: string;
979
+ try {
980
+ text = readFileSync(path, "utf8");
981
+ } catch (err) {
982
+ throw new CliError(
983
+ `Cannot read ${sources[0] === "--bulk-stdin" ? "stdin" : path}: ${(err as Error).message}`,
984
+ "runtime",
985
+ );
986
+ }
987
+ values = text.split(/[\s,]+/).filter(Boolean);
988
+ }
989
+ const ids = [...new Set(values.map((value) => value.trim()).filter(Boolean))];
990
+ if (ids.length === 0) throw usageError(`${sources[0]} did not contain any issue ids.`);
991
+ return ids;
992
+ }
993
+
994
+ async function runBulkIssueMutation(
995
+ ctx: Context,
996
+ opts: Record<string, unknown>,
997
+ idArg: string | undefined,
998
+ actionName: BulkAction,
999
+ mutate: (id: string) => Promise<{ id: string; identifier: string }>,
1000
+ ): Promise<void> {
1001
+ const bulk = bulkIssueIds(opts, idArg);
1002
+ const ids = bulk ?? [requireId(idArg, ctx.defaultTeam)];
1003
+ const single = bulk === undefined;
1004
+ const noun = actionName === "archive" ? "Archive" : "Delete";
1005
+ const message = single ? `${noun} issue ${ids[0]}?` : `${noun} ${ids.length} issues?`;
1006
+ if (!(await confirmDestructive(ctx, message))) return;
1007
+
1008
+ const results: BulkIssueResult[] = [];
1009
+ for (const input of ids) {
1010
+ const id = normalizeIssueReference(input, ctx.defaultTeam);
1011
+ try {
1012
+ const issue = await mutate(id);
1013
+ results.push({
1014
+ input,
1015
+ id: issue.id,
1016
+ identifier: issue.identifier,
1017
+ [actionName === "archive" ? "archived" : "deleted"]: true,
1018
+ });
1019
+ } catch (err) {
1020
+ const normalized = normalizeError(err);
1021
+ results.push({ input, error: { message: normalized.message, code: normalized.code } });
1022
+ }
1023
+ }
1024
+
1025
+ if (single) {
1026
+ const result = results[0]!;
1027
+ if (result.error)
1028
+ throw new CliError(result.error.message, result.error.code as CliError["code"]);
1029
+ ctx.output.emit(
1030
+ actionName === "archive"
1031
+ ? { id: result.id!, identifier: result.identifier!, archived: true }
1032
+ : { id: result.id!, identifier: result.identifier!, deleted: true },
1033
+ () => ctx.output.success(`${noun}d ${result.identifier}`),
1034
+ );
1035
+ return;
1036
+ }
1037
+
1038
+ const failures = results.filter((result) => result.error).length;
1039
+ ctx.output.emit(
1040
+ { action: actionName, results, succeeded: results.length - failures, failed: failures },
1041
+ () => {
1042
+ for (const result of results) {
1043
+ if (result.error) ctx.output.warn(`${result.input}: ${result.error.message}`);
1044
+ else ctx.output.success(`${noun}d ${result.identifier}`);
1045
+ }
1046
+ ctx.output.info(`${results.length - failures}/${results.length} issues ${actionName}d.`);
1047
+ },
1048
+ );
1049
+ if (failures > 0) process.exitCode = 1;
1050
+ }
1051
+
1052
+ /**
1053
+ * Render a single issue's detail block. Shared by `issue view` and the bare
1054
+ * `linear` command so `linear --json` === `issue view <id> --json`.
1055
+ */
1056
+ export async function renderIssueDetail(
1057
+ ctx: Context,
1058
+ detail: svc.IssueDetail,
1059
+ includeComments: boolean,
1060
+ showResolvedThreads = false,
1061
+ ): Promise<void> {
1062
+ const { cycle, team } = detail;
1063
+ const commentPairs = includeComments
1064
+ ? issueCommentPairs(detail.comments, showResolvedThreads)
1065
+ : [];
1066
+ ctx.output.detail(detail, [
1067
+ ["Issue", `${detail.identifier} ${detail.title}`],
1068
+ // A deleted issue used to view exactly like a live one. Say so first, and
1069
+ // in capitals: an agent that deletes and re-reads must see the change.
1070
+ [
1071
+ "Trashed",
1072
+ detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null,
1073
+ ],
1074
+ ["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
1075
+ ["State", detail.state?.name ?? null],
1076
+ ["Priority", detail.priorityLabel],
1077
+ ["Assignee", detail.assignee?.displayName ?? null],
1078
+ ["Team", team ? `${team.key} ${team.name}` : null],
1079
+ ["Project", detail.project?.name ?? null],
1080
+ ["Milestone", detail.milestone?.name ?? null],
1081
+ ["Cycle", cycle ? `#${cycle.number}${cycle.name ? ` ${cycle.name}` : ""}` : null],
1082
+ ["Parent", detail.parent ? issueRefLabel(detail.parent) : null],
1083
+ [
1084
+ "Sub-issues",
1085
+ detail.children.length ? `\n${detail.children.map(issueRefLabel).join("\n")}` : null,
1086
+ ],
1087
+ ["Estimate", detail.estimate],
1088
+ ["Labels", detail.labels.length ? detail.labels.map((l) => l.name).join(", ") : null],
1089
+ [
1090
+ "Attachments",
1091
+ detail.attachments.length ? `\n${detail.attachments.map(attachmentLabel).join("\n")}` : null,
1092
+ ],
1093
+ [
1094
+ "Documents",
1095
+ detail.documents.length
1096
+ ? `\n${detail.documents.map((d) => `${d.title}: ${d.url}`).join("\n")}`
1097
+ : null,
1098
+ ],
1099
+ ["Relations", relationLabels(detail)],
1100
+ ["Due", detail.dueDate],
1101
+ ["URL", detail.url],
1102
+ ["Updated", detail.updatedAt],
1103
+ ["Description", detail.description ? `\n${detail.description}` : null],
1104
+ ...commentPairs,
1105
+ ]);
1106
+ }
1107
+
1108
+ function issueRefLabel(issue: svc.IssueDetailRef): string {
1109
+ return `${issue.identifier} ${issue.title}${issue.state ? ` [${issue.state.name}]` : ""}`;
1110
+ }
1111
+
1112
+ function attachmentLabel(attachment: svc.IssueAttachmentDetail): string {
1113
+ const source = attachment.sourceType ? ` [${attachment.sourceType}]` : "";
1114
+ const subtitle = attachment.subtitle ? ` — ${attachment.subtitle}` : "";
1115
+ return `${attachment.title}: ${attachment.url}${source}${subtitle}`;
1116
+ }
1117
+
1118
+ function relationLabels(detail: svc.IssueDetail): string | null {
1119
+ const lines = [
1120
+ ...detail.relations.map(
1121
+ (r) => `${relationVerb(r.type, false)} ${issueRefLabel(r.relatedIssue)}`,
1122
+ ),
1123
+ ...detail.inverseRelations.map(
1124
+ (r) => `${relationVerb(r.type, true)} ${issueRefLabel(r.issue)}`,
1125
+ ),
1126
+ ];
1127
+ return lines.length ? `\n${lines.join("\n")}` : null;
1128
+ }
1129
+
1130
+ function relationVerb(type: string, inverse: boolean): string {
1131
+ if (type === "blocks") return inverse ? "Blocked by" : "Blocks";
1132
+ if (type === "duplicate") return inverse ? "Duplicated by" : "Duplicates";
1133
+ return type === "related" ? "Related to" : `${inverse ? "Inverse" : "Related"} (${type})`;
1134
+ }
1135
+
1136
+ /** Human comment rows grouped as threads, hiding resolved roots unless requested. */
1137
+ function issueCommentPairs(
1138
+ comments: svc.IssueCommentDetail[],
1139
+ showResolvedThreads: boolean,
1140
+ ): Array<[string, unknown]> {
1141
+ const byId = new Map(comments.map((comment) => [comment.id, comment]));
1142
+ const rootId = (comment: svc.IssueCommentDetail): string => {
1143
+ const seen = new Set<string>();
1144
+ let current = comment;
1145
+ while (current.parent && !seen.has(current.id)) {
1146
+ seen.add(current.id);
1147
+ const parent = byId.get(current.parent.id);
1148
+ if (!parent) break;
1149
+ current = parent;
1150
+ }
1151
+ return current.id;
1152
+ };
1153
+ const roots = comments.filter((comment) => !comment.parent);
1154
+ const visibleRoots = roots.filter((comment) => showResolvedThreads || !comment.resolvedAt);
1155
+ const visibleIds = new Set(visibleRoots.map((comment) => comment.id));
1156
+ const pairs: Array<[string, unknown]> = [];
1157
+
1158
+ for (const root of visibleRoots) {
1159
+ pairs.push(["Comment", commentLabel(root, false)]);
1160
+ for (const reply of comments) {
1161
+ if (reply.parent && rootId(reply) === root.id && visibleIds.has(root.id)) {
1162
+ pairs.push(["Reply", commentLabel(reply, true)]);
1163
+ }
1164
+ }
1165
+ }
1166
+ const hidden = roots.length - visibleRoots.length;
1167
+ if (hidden > 0) {
1168
+ pairs.push([
1169
+ "Comments",
1170
+ `${hidden} resolved ${hidden === 1 ? "thread" : "threads"} hidden; use --show-resolved-threads to show ${hidden === 1 ? "it" : "them"}.`,
1171
+ ]);
1172
+ }
1173
+ return pairs;
1174
+ }
1175
+
1176
+ function commentLabel(comment: svc.IssueCommentDetail, reply: boolean): string {
1177
+ const author = comment.user?.displayName ?? comment.externalUser?.displayName ?? "Unknown";
1178
+ const thread = reply ? "" : ` [thread: ${comment.id}]`;
1179
+ const resolved = comment.resolvedAt
1180
+ ? ` [resolved${comment.resolvingUser ? ` by ${comment.resolvingUser.displayName}` : ""}]`
1181
+ : "";
1182
+ return `@${author} ${comment.createdAt.slice(0, 10)}${thread}${resolved}\n${comment.body}`;
1183
+ }
1184
+
1185
+ function registerScalar(
1186
+ issue: Command,
1187
+ name: string,
1188
+ description: string,
1189
+ pick: (d: svc.IssueDetail) => string,
1190
+ ): void {
1191
+ issue
1192
+ .command(`${name} [id]`)
1193
+ .description(description)
1194
+ .action(
1195
+ action(async (ctx: Context, _opts, idArg?: string) => {
1196
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
1197
+ includeComments: false,
1198
+ });
1199
+ ctx.output.emit({ [name]: pick(detail) }, () => ctx.output.line(pick(detail)));
1200
+ }),
1201
+ );
1202
+ }
1203
+
1204
+ const ISSUE_ID_RE =
1205
+ /^(\d+|[a-zA-Z][a-zA-Z0-9]*-\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
1206
+
1207
+ /**
1208
+ * Disambiguate `[id] [value]` where both are optional so the issue id can be
1209
+ * inferred from the branch. Two args → (id, value). One arg → it's the value
1210
+ * unless it looks like an issue id (then the value is missing → usage error).
1211
+ */
1212
+ function oneOrTwo(
1213
+ a: string | undefined,
1214
+ b: string | undefined,
1215
+ valueName: string,
1216
+ ): {
1217
+ idArg?: string;
1218
+ value: string;
1219
+ } {
1220
+ if (a !== undefined && b !== undefined) return { idArg: a, value: b };
1221
+ if (a === undefined) throw usageError(`Missing ${valueName}.`);
1222
+ if (ISSUE_ID_RE.test(a)) {
1223
+ throw usageError(
1224
+ `Missing ${valueName}. Usage: <id> <${valueName}> (or just <${valueName}> on a matching branch)`,
1225
+ );
1226
+ }
1227
+ return { value: a };
1228
+ }
1229
+
1230
+ /**
1231
+ * `oneOrTwo`'s sibling for `issue comment [id] [body]`, where the value may
1232
+ * legitimately be absent (it can come from --body-file or $EDITOR): a lone
1233
+ * operand that looks like an issue id IS the id; anything else is the body.
1234
+ */
1235
+ function idAndBody(
1236
+ a: string | undefined,
1237
+ b: string | undefined,
1238
+ ): { idArg?: string; bodyArg?: string } {
1239
+ if (a !== undefined && b !== undefined) return { idArg: a, bodyArg: b };
1240
+ if (a === undefined) return {};
1241
+ return ISSUE_ID_RE.test(a) ? { idArg: a } : { bodyArg: a };
1242
+ }
1243
+
1244
+ /**
1245
+ * Invoke `gh` with the given argv, returning its stdout. `gh`'s stderr is
1246
+ * captured (so it never pollutes our stdout / the `--json` contract) and only
1247
+ * surfaced when gh fails — as the message of a clear CliError. Failures map to
1248
+ * helpful errors: a missing `gh` becomes a usage error.
1249
+ */
1250
+ function runGh(args: string[]): string {
1251
+ try {
1252
+ return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
1253
+ } catch (err) {
1254
+ const e = err as NodeJS.ErrnoException & { status?: number; stderr?: Buffer | string };
1255
+ if (e.code === "ENOENT") {
1256
+ throw usageError(
1257
+ "GitHub CLI (gh) is required for `issue pr`. Install it from https://cli.github.com.",
1258
+ );
1259
+ }
1260
+ const stderr = e.stderr ? e.stderr.toString().trim() : "";
1261
+ throw new CliError(stderr || `gh exited with code ${e.status ?? 1}.`, "runtime");
1262
+ }
1263
+ }