linear-sdk-cli 0.1.0 → 0.1.1

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 (71) hide show
  1. package/CHANGELOG.md +46 -46
  2. package/COVERAGE.md +5 -5
  3. package/MIGRATING.md +69 -71
  4. package/README.md +88 -96
  5. package/package.json +3 -4
  6. package/skills/linear-sdk-cli/SKILL.md +3 -13
  7. package/skills/linear-sdk-cli/references/api.md +9 -9
  8. package/skills/linear-sdk-cli/references/auth.md +0 -18
  9. package/skills/linear-sdk-cli/references/commands.md +2 -2
  10. package/skills/linear-sdk-cli/references/config.md +11 -9
  11. package/skills/linear-sdk-cli/references/issue.md +12 -63
  12. package/skills/linear-sdk-cli/references/team.md +0 -16
  13. package/skills/linear-sdk-cli/scripts/generate-docs.ts +2 -6
  14. package/src/bin/linear.ts +6 -16
  15. package/src/cli.ts +1 -3
  16. package/src/commands/agent-session.ts +5 -13
  17. package/src/commands/api.ts +9 -15
  18. package/src/commands/attach.ts +4 -15
  19. package/src/commands/comment.ts +2 -6
  20. package/src/commands/completion.ts +6 -2
  21. package/src/commands/cycle.ts +1 -6
  22. package/src/commands/discover.ts +2 -6
  23. package/src/commands/document.ts +1 -4
  24. package/src/commands/initiative.ts +1 -5
  25. package/src/commands/issue.ts +88 -376
  26. package/src/commands/meta.ts +31 -139
  27. package/src/commands/milestone.ts +1 -4
  28. package/src/commands/notification.ts +6 -15
  29. package/src/commands/project.ts +4 -17
  30. package/src/commands/team.ts +0 -13
  31. package/src/commands/webhook.ts +2 -12
  32. package/src/config.ts +183 -500
  33. package/src/context.ts +4 -13
  34. package/src/git.ts +3 -28
  35. package/src/lib/body.ts +2 -26
  36. package/src/lib/errors.ts +7 -32
  37. package/src/lib/introspect.ts +7 -76
  38. package/src/lib/keyring.ts +9 -51
  39. package/src/lib/options.ts +12 -108
  40. package/src/lib/output-shapes.ts +9 -81
  41. package/src/lib/pagination.ts +17 -50
  42. package/src/lib/resolve.ts +7 -40
  43. package/src/lib/shape.ts +12 -16
  44. package/src/lib/upload.ts +1 -3
  45. package/src/output/format.ts +18 -96
  46. package/src/output/table.ts +7 -7
  47. package/src/services/agent-session.ts +3 -11
  48. package/src/services/attachment.ts +5 -14
  49. package/src/services/comment.ts +4 -12
  50. package/src/services/cycle.ts +4 -11
  51. package/src/services/document.ts +10 -29
  52. package/src/services/favorite.ts +9 -15
  53. package/src/services/initiative-update.ts +15 -16
  54. package/src/services/initiative.ts +10 -15
  55. package/src/services/issue.ts +18 -262
  56. package/src/services/label.ts +2 -12
  57. package/src/services/milestone.ts +5 -8
  58. package/src/services/notification.ts +2 -16
  59. package/src/services/organization.ts +18 -23
  60. package/src/services/project.ts +34 -55
  61. package/src/services/roadmap.ts +6 -7
  62. package/src/services/state.ts +3 -9
  63. package/src/services/team.ts +24 -67
  64. package/src/services/user.ts +2 -2
  65. package/src/services/webhook.ts +17 -28
  66. package/skills/linear-sdk-cli/references/open.md +0 -29
  67. package/src/commands/open.ts +0 -84
  68. package/src/lib/open.ts +0 -32
  69. package/src/output/color.ts +0 -52
  70. package/src/output/markdown.ts +0 -133
  71. package/src/output/pager.ts +0 -131
@@ -14,7 +14,6 @@ import {
14
14
  readAlias,
15
15
  parseList,
16
16
  parseIntOption,
17
- parsePriority,
18
17
  CYCLE_FLAG,
19
18
  CYCLE_DESC,
20
19
  suggestSubcommand,
@@ -34,34 +33,27 @@ import {
34
33
  buildPrArgs,
35
34
  } from "../git.js";
36
35
  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";
36
+ import { CliError } from "../lib/errors.js";
40
37
  import type { Context } from "../context.js";
41
38
  import * as svc from "../services/issue.js";
42
39
  import * as commentSvc from "../services/comment.js";
43
- import { isSelf, normalizeIssueReference, resolveIssue } from "../lib/resolve.js";
40
+ import { isSelf } from "../lib/resolve.js";
44
41
  import type { Column } from "../output/table.js";
45
42
 
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 {
43
+ /** Resolve the target issue id from an argument or the current git branch. */
44
+ function requireId(idArg: string | undefined): string {
48
45
  const id = idArg ?? currentIssueId();
49
46
  if (!id) {
50
47
  throw usageError("No issue id given and none could be inferred from the current branch.");
51
48
  }
52
- return normalizeIssueReference(id, defaultTeam);
49
+ return id;
53
50
  }
54
51
 
55
52
  const ROW_COLUMNS: Column<svc.IssueRow>[] = [
56
53
  { key: "id", header: "ID", value: (r) => r.identifier },
57
54
  // `--include-archived` mixes live, archived and trashed rows; mark the latter
58
55
  // 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
- },
56
+ { key: "state", header: "State", value: (r) => `${r.state?.name ?? ""}${lifecycleMark(r)}`, max: 26 },
65
57
  { key: "priority", header: "Pri", value: (r) => shortPriority(r.priority) },
66
58
  { key: "assignee", header: "Assignee", value: (r) => r.assignee?.displayName ?? "—", max: 16 },
67
59
  { key: "title", header: "Title", value: (r) => r.title, max: 60 },
@@ -96,16 +88,6 @@ function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string
96
88
  return r.trashed ? " (trashed)" : r.archivedAt ? " (archived)" : "";
97
89
  }
98
90
 
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
91
  /**
110
92
  * schpet/linear-cli `issue` subcommands that a migrating user may type and that
111
93
  * do not exist under that name here. Since `view` is the default subcommand,
@@ -115,8 +97,7 @@ function addNoopAllAssignees(command: Command): void {
115
97
  */
116
98
  const SCHPET_ISSUE_SUBCOMMANDS: Record<string, string> = {
117
99
  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>'.",
100
+ commits: "'issue commits' is not available here (jj/git log integration is not adopted). Use 'git log --grep <ID>'.",
120
101
  "agent-session": "Use 'linear issue agent-session list|view <issue>'.",
121
102
  };
122
103
 
@@ -128,16 +109,7 @@ export function registerIssue(program: Command): void {
128
109
  .command("view [id]", { isDefault: true })
129
110
  .description("Show an issue (defaults to the current branch's issue)")
130
111
  .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")
112
+ .option("--comments", "include recent comments")
141
113
  .action(
142
114
  action(async (ctx: Context, opts, idArg?: string) => {
143
115
  // `view` is the default subcommand, so `linear issue lst` lands here
@@ -149,28 +121,21 @@ export function registerIssue(program: Command): void {
149
121
  const ported = SCHPET_ISSUE_SUBCOMMANDS[idArg.toLowerCase()];
150
122
  const guess = ported ? undefined : suggestSubcommand(issue, idArg);
151
123
  throw usageError(
152
- `'${idArg}' is not a valid issue id (expected e.g. TES-123, 123 with a default team, or a UUID).${
124
+ `'${idArg}' is not a valid issue id (expected e.g. TES-123 or a UUID).${
153
125
  ported ? ` ${ported}` : guess ? ` Did you mean 'linear issue ${guess}'?` : ""
154
126
  }`,
155
127
  );
156
128
  }
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 });
129
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
130
+ if (opts.web) {
131
+ await openUrl(detail.url);
164
132
  ctx.output.emit(
165
133
  { 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
- ),
134
+ () => ctx.output.success(`Opened ${detail.identifier}`),
170
135
  );
171
136
  return;
172
137
  }
173
- await renderIssueDetail(ctx, detail, includeComments, opts.showResolvedThreads === true);
138
+ await renderIssueDetail(ctx, detail, !!opts.comments);
174
139
  }),
175
140
  );
176
141
 
@@ -231,12 +196,8 @@ export function registerIssue(program: Command): void {
231
196
  addFilterOptions(list).addOption(
232
197
  // Not an alias of anything here — `list` already spans every state — but
233
198
  // 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(),
199
+ new Option("--all-states", "accepted for compatibility (list is all-states already)").hideHelp(),
238
200
  );
239
- addNoopAllAssignees(list);
240
201
 
241
202
  // mine --------------------------------------------------------------------
242
203
  // `list` stays general; `mine` is the opinionated "what's on my plate" view
@@ -299,7 +260,6 @@ export function registerIssue(program: Command): void {
299
260
  addFilterOptions(mine, { assignee: false }).addOption(
300
261
  new Option("--all-states", "include every workflow state, not just unstarted"),
301
262
  );
302
- addNoopAllAssignees(mine);
303
263
 
304
264
  // search ------------------------------------------------------------------
305
265
  const search = issue
@@ -349,7 +309,6 @@ export function registerIssue(program: Command): void {
349
309
  "--search-comments",
350
310
  "match comment bodies as well as titles and descriptions",
351
311
  );
352
- addNoopAllAssignees(search);
353
312
 
354
313
  // create ------------------------------------------------------------------
355
314
  const create = issue
@@ -362,16 +321,13 @@ export function registerIssue(program: Command): void {
362
321
  .option("--editor", "compose the description in $EDITOR")
363
322
  .option("-a, --assignee <who>", "assignee (me|email|name|id)")
364
323
  .option("-s, --state <name>", "workflow state name or type")
365
- .option("-P, --priority <0-4>", "priority", parsePriority)
324
+ .option("-P, --priority <0-4>", "priority", parseIntOption)
366
325
  .option("-l, --label <name>", "label (repeatable / comma-separated)", parseList)
367
326
  .option("-p, --project <name>", "project name or id")
368
327
  .option("--milestone <name>", "project milestone (requires --project)")
369
328
  .option(CYCLE_FLAG, CYCLE_DESC)
370
329
  .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
- )
330
+ .option("--parent <id>", "parent issue id (the sub-issue joins the parent's project unless --project says otherwise)")
375
331
  .option("--due <date>", "due date (YYYY-MM-DD)")
376
332
  .option("--template <name|id>", "create from an issue template (the team's or a shared one)")
377
333
  .option("--no-default-template", "do not apply the team's default issue template")
@@ -424,7 +380,7 @@ export function registerIssue(program: Command): void {
424
380
  milestone: opts.milestone,
425
381
  cycle: opts.cycle,
426
382
  estimate: opts.estimate,
427
- parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
383
+ parent: opts.parent,
428
384
  dueDate: readAlias(opts, "--due", "--due-date"),
429
385
  template: opts.template,
430
386
  // Both spellings: ours, and the reference CLI's `--no-use-default-template`.
@@ -433,9 +389,8 @@ export function registerIssue(program: Command): void {
433
389
  ctx.defaultTeam,
434
390
  );
435
391
  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}`),
392
+ ctx.output.emit({ id: created.id, identifier: created.identifier, url: created.url }, () =>
393
+ ctx.output.success(`Created ${created.identifier}: ${created.url}`),
439
394
  );
440
395
  return;
441
396
  }
@@ -443,10 +398,8 @@ export function registerIssue(program: Command): void {
443
398
  // just created. An explicit --state already put it where it belongs, so
444
399
  // only the default case moves it (to the team's first `started` state).
445
400
  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
401
  await svc.moveIssueState(ctx.client, created, { move: moved });
402
+ const branchResult = isGitRepo() ? checkoutBranch(created.branchName) : undefined;
450
403
  ctx.output.emit(
451
404
  {
452
405
  id: created.id,
@@ -485,14 +438,13 @@ export function registerIssue(program: Command): void {
485
438
  .option("-t, --team <key>", "move the issue to another team (changes its identifier)")
486
439
  .option("-a, --assignee <who>", "assignee (me|email|name|id)")
487
440
  .option("-s, --state <name>", "workflow state name or type")
488
- .option("-P, --priority <0-4>", "priority", parsePriority)
441
+ .option("-P, --priority <0-4>", "priority", parseIntOption)
489
442
  .option("-p, --project <name>", "project name or id")
490
443
  .option("--milestone <name>", "project milestone")
491
444
  .option(CYCLE_FLAG, CYCLE_DESC)
492
445
  .option("--estimate <n>", "estimate points", parseIntOption)
493
446
  .option("--parent <id>", "parent issue id")
494
447
  .option("--due <date>", "due date (YYYY-MM-DD)")
495
- .option("-l, --label <name>", "replace all labels (repeatable / comma-separated)", parseList)
496
448
  .option("--add-label <name>", "add a label (repeatable)", parseList)
497
449
  .option("--remove-label <name>", "remove a label (repeatable)", parseList)
498
450
  .option("--unassign", "clear the assignee")
@@ -515,7 +467,7 @@ export function registerIssue(program: Command): void {
515
467
  file: opts.descriptionFile,
516
468
  interactive: false,
517
469
  });
518
- const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
470
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
519
471
  title: opts.title,
520
472
  description,
521
473
  // Only the explicit flag moves an issue — never `ctx.defaultTeam`, or
@@ -528,29 +480,25 @@ export function registerIssue(program: Command): void {
528
480
  milestone: opts.milestone,
529
481
  cycle: opts.cycle,
530
482
  estimate: opts.estimate,
531
- parent: opts.parent ? normalizeIssueReference(opts.parent, ctx.defaultTeam) : undefined,
483
+ parent: opts.parent,
532
484
  dueDate: readAlias(opts, "--due", "--due-date"),
533
- label: opts.label,
534
485
  addLabel: opts.addLabel,
535
486
  removeLabel: opts.removeLabel,
536
487
  unassign: opts.unassign,
537
488
  clearCycle: opts.clearCycle,
538
489
  });
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
- );
490
+ ctx.output.emit({ id: updated.id, identifier: updated.identifier, url: updated.url }, () => {
491
+ ctx.output.success(`Updated ${updated.identifier}`);
492
+ // A move renumbers the issue and Linear drops what the destination
493
+ // team cannot hold. Say so once, rather than letting a script's next
494
+ // `TES-42` fail with "no such issue". Verified live — see CHANGELOG.
495
+ if (opts.team) {
496
+ ctx.output.info(
497
+ `Moved to team ${updated.identifier.split("-")[0]}: the issue is now ${updated.identifier}. ` +
498
+ `Its cycle, team-scoped labels, and any project the new team is not part of do not carry over.`,
499
+ );
500
+ }
501
+ });
554
502
  }),
555
503
  );
556
504
  addAliasOption(update, "--due-date <date>", "--due");
@@ -562,9 +510,7 @@ export function registerIssue(program: Command): void {
562
510
  .action(
563
511
  action(async (ctx: Context, _opts, a?: string, b?: string) => {
564
512
  const { idArg, value } = oneOrTwo(a, b, "assignee");
565
- const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
566
- assignee: value,
567
- });
513
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg), { assignee: value });
568
514
  ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
569
515
  ctx.output.success(`Assigned ${updated.identifier}`),
570
516
  );
@@ -577,9 +523,7 @@ export function registerIssue(program: Command): void {
577
523
  .action(
578
524
  action(async (ctx: Context, _opts, a?: string, b?: string) => {
579
525
  const { idArg, value } = oneOrTwo(a, b, "state");
580
- const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
581
- state: value,
582
- });
526
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg), { state: value });
583
527
  ctx.output.emit({ id: updated.id, identifier: updated.identifier }, () =>
584
528
  ctx.output.success(`Moved ${updated.identifier} → ${value}`),
585
529
  );
@@ -595,7 +539,7 @@ export function registerIssue(program: Command): void {
595
539
  .action(
596
540
  action(async (ctx: Context, opts, idArg?: string) => {
597
541
  if (!opts.add && !opts.remove) throw usageError("Pass --add and/or --remove.");
598
- const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
542
+ const updated = await svc.updateIssue(ctx.client, requireId(idArg), {
599
543
  addLabel: opts.add,
600
544
  removeLabel: opts.remove,
601
545
  });
@@ -609,7 +553,7 @@ export function registerIssue(program: Command): void {
609
553
  const comment = issue
610
554
  .command("comment [id] [body]")
611
555
  .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)',
556
+ "Add a comment to an issue; on a matching branch, `issue comment \"<body>\"` is enough (or use the add/list/update/delete subcommands)",
613
557
  )
614
558
  .option("--body-file <path>", "read comment body from a file ('-' = stdin)")
615
559
  .action(
@@ -621,7 +565,7 @@ export function registerIssue(program: Command): void {
621
565
  const { idArg, bodyArg } = idAndBody(a, b);
622
566
  // Settle the id BEFORE the editor can open, so nobody writes a comment
623
567
  // only to be told there was nowhere to put it.
624
- const id = requireId(idArg, ctx.defaultTeam);
568
+ const id = requireId(idArg);
625
569
  const body = resolveBody({ arg: bodyArg, file: opts.bodyFile, interactive: ctx.isTTY });
626
570
  if (!body) throw usageError("No comment body provided.");
627
571
  const { issue: iss, comment } = await commentSvc.addComment(ctx.client, id, body);
@@ -643,11 +587,7 @@ export function registerIssue(program: Command): void {
643
587
  action(async (ctx: Context, _opts, idArg?: string) => {
644
588
  // Same implementation and row shape as `comment list` — TES-629: this
645
589
  // 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
- );
590
+ const comments = await commentSvc.listComments(ctx.client, requireId(idArg), ctx.limit);
651
591
  ctx.output.list(
652
592
  comments,
653
593
  [
@@ -672,9 +612,7 @@ export function registerIssue(program: Command): void {
672
612
  // is a surprise.
673
613
  issue
674
614
  .command("start [id]")
675
- .description(
676
- "Start work on an issue: check out its branch and move it to the first 'started' state",
677
- )
615
+ .description("Start work on an issue: check out its branch and move it to the first 'started' state")
678
616
  .option("--state <name>", "move to this state instead of the first 'started' one")
679
617
  .option("--no-move", "do not change the state; only check out the branch")
680
618
  .addOption(new Option("--move", "accepted for compatibility: moving is the default").hideHelp())
@@ -697,17 +635,14 @@ export function registerIssue(program: Command): void {
697
635
  throw usageError("Pass either --state or --no-move, not both.");
698
636
  }
699
637
  const moved = opts.state !== undefined || opts.move !== false;
700
- const issueModel = await resolveIssue(ctx.client, requireId(idArg, ctx.defaultTeam));
638
+ const issueModel = await svc.startIssue(ctx.client, requireId(idArg), {
639
+ stateInput: opts.state,
640
+ move: moved,
641
+ });
701
642
  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
643
  if (opts.checkout !== false && isGitRepo()) {
705
644
  branchResult = checkoutBranch(issueModel.branchName);
706
645
  }
707
- await svc.moveIssueState(ctx.client, issueModel, {
708
- stateInput: opts.state,
709
- move: moved,
710
- });
711
646
  ctx.output.emit(
712
647
  {
713
648
  id: issueModel.id,
@@ -721,8 +656,7 @@ export function registerIssue(program: Command): void {
721
656
  ctx.output.success(
722
657
  `${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
723
658
  );
724
- if (moved)
725
- ctx.output.success(`Moved ${issueModel.identifier} → ${opts.state ?? "started"}`);
659
+ if (moved) ctx.output.success(`Moved ${issueModel.identifier} → ${opts.state ?? "started"}`);
726
660
  if (!branchResult && !moved) ctx.output.info(`Branch name: ${issueModel.branchName}`);
727
661
  },
728
662
  );
@@ -741,14 +675,10 @@ export function registerIssue(program: Command): void {
741
675
  .option("-r, --references", "link without closing: 'References <ID>' instead of 'Fixes <ID>'")
742
676
  .action(
743
677
  action(async (ctx: Context, opts, idArg?: string) => {
744
- const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
745
- includeComments: false,
746
- });
678
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
747
679
  const references = opts.references === true;
748
680
  const trailer = buildTrailer(detail.identifier, { references });
749
- const message = buildDescription(detail.identifier, detail.title, detail.url, {
750
- references,
751
- });
681
+ const message = buildDescription(detail.identifier, detail.title, detail.url, { references });
752
682
  ctx.output.emit(
753
683
  {
754
684
  identifier: detail.identifier,
@@ -779,9 +709,7 @@ export function registerIssue(program: Command): void {
779
709
  if (!isGitRepo()) {
780
710
  throw usageError("`issue pr` must be run inside a git repository.");
781
711
  }
782
- const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
783
- includeComments: false,
784
- });
712
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
785
713
  // Title `ID Title` (a custom --title is prefixed the same way), body the
786
714
  // two Linear-issue trailers — schpet/linear-cli's PR, plus the magic word
787
715
  // (`buildPrContent`). The issue's *description* is no longer copied in
@@ -822,9 +750,8 @@ export function registerIssue(program: Command): void {
822
750
  .option("--duplicate", "relation type: duplicate")
823
751
  .action(
824
752
  action(async (ctx: Context, opts, id: string, op: string, other?: string) => {
825
- const issueId = normalizeIssueReference(id, ctx.defaultTeam);
826
753
  if (op === "list") {
827
- const rels = await svc.listRelations(ctx.client, issueId);
754
+ const rels = await svc.listRelations(ctx.client, id);
828
755
  ctx.output.list(
829
756
  rels,
830
757
  [
@@ -845,13 +772,7 @@ export function registerIssue(program: Command): void {
845
772
  : opts.duplicate
846
773
  ? "duplicate"
847
774
  : "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
- );
775
+ const { issue: a, other: b } = await svc.addRemoveRelation(ctx.client, id, op, type as any, other);
855
776
  ctx.output.emit(
856
777
  {
857
778
  issueId: a.id,
@@ -875,7 +796,7 @@ export function registerIssue(program: Command): void {
875
796
  .description("Subscribe to an issue")
876
797
  .action(
877
798
  action(async (ctx: Context, _opts, idArg?: string) => {
878
- const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), true);
799
+ const iss = await svc.setSubscription(ctx.client, requireId(idArg), true);
879
800
  ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: true }, () =>
880
801
  ctx.output.success(`Subscribed to ${iss.identifier}`),
881
802
  );
@@ -886,7 +807,7 @@ export function registerIssue(program: Command): void {
886
807
  .description("Unsubscribe from an issue")
887
808
  .action(
888
809
  action(async (ctx: Context, _opts, idArg?: string) => {
889
- const iss = await svc.setSubscription(ctx.client, requireId(idArg, ctx.defaultTeam), false);
810
+ const iss = await svc.setSubscription(ctx.client, requireId(idArg), false);
890
811
  ctx.output.emit({ id: iss.id, identifier: iss.identifier, subscribed: false }, () =>
891
812
  ctx.output.success(`Unsubscribed from ${iss.identifier}`),
892
813
  );
@@ -897,13 +818,13 @@ export function registerIssue(program: Command): void {
897
818
  issue
898
819
  .command("archive [id]")
899
820
  .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
821
  .action(
904
- action(async (ctx: Context, opts, idArg?: string) => {
905
- await runBulkIssueMutation(ctx, opts, idArg, "archive", (id) =>
906
- svc.archiveIssue(ctx.client, id, false),
822
+ action(async (ctx: Context, _opts, idArg?: string) => {
823
+ const id = requireId(idArg);
824
+ if (!(await confirmDestructive(ctx, `Archive issue ${id}?`))) return;
825
+ const iss = await svc.archiveIssue(ctx.client, id, false);
826
+ ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: true }, () =>
827
+ ctx.output.success(`Archived ${iss.identifier}`),
907
828
  );
908
829
  }),
909
830
  );
@@ -912,7 +833,7 @@ export function registerIssue(program: Command): void {
912
833
  .description("Unarchive an issue")
913
834
  .action(
914
835
  action(async (ctx: Context, _opts, idArg?: string) => {
915
- const iss = await svc.archiveIssue(ctx.client, requireId(idArg, ctx.defaultTeam), true);
836
+ const iss = await svc.archiveIssue(ctx.client, requireId(idArg), true);
916
837
  ctx.output.emit({ id: iss.id, identifier: iss.identifier, archived: false }, () =>
917
838
  ctx.output.success(`Unarchived ${iss.identifier}`),
918
839
  );
@@ -922,13 +843,13 @@ export function registerIssue(program: Command): void {
922
843
  .command("delete [id]")
923
844
  .alias("rm")
924
845
  .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
846
  .action(
929
- action(async (ctx: Context, opts, idArg?: string) => {
930
- await runBulkIssueMutation(ctx, opts, idArg, "delete", (id) =>
931
- svc.deleteIssue(ctx.client, id),
847
+ action(async (ctx: Context, _opts, idArg?: string) => {
848
+ const id = requireId(idArg);
849
+ if (!(await confirmDestructive(ctx, `Delete issue ${id}?`))) return;
850
+ const iss = await svc.deleteIssue(ctx.client, id);
851
+ ctx.output.emit({ id: iss.id, identifier: iss.identifier, deleted: true }, () =>
852
+ ctx.output.success(`Deleted ${iss.identifier}`),
932
853
  );
933
854
  }),
934
855
  );
@@ -940,115 +861,6 @@ export function registerIssue(program: Command): void {
940
861
  registerScalar(issue, "branch", "Print the suggested git branch name", (d) => d.branchName);
941
862
  }
942
863
 
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
864
  /**
1053
865
  * Render a single issue's detail block. Shared by `issue view` and the bare
1054
866
  * `linear` command so `linear --json` === `issue view <id> --json`.
@@ -1057,20 +869,14 @@ export async function renderIssueDetail(
1057
869
  ctx: Context,
1058
870
  detail: svc.IssueDetail,
1059
871
  includeComments: boolean,
1060
- showResolvedThreads = false,
1061
872
  ): Promise<void> {
873
+ const comments = includeComments ? await commentSvc.listComments(ctx.client, detail.id, 10) : [];
1062
874
  const { cycle, team } = detail;
1063
- const commentPairs = includeComments
1064
- ? issueCommentPairs(detail.comments, showResolvedThreads)
1065
- : [];
1066
- ctx.output.detail(detail, [
875
+ ctx.output.detail({ ...detail, comments: includeComments ? comments : undefined }, [
1067
876
  ["Issue", `${detail.identifier} ${detail.title}`],
1068
877
  // A deleted issue used to view exactly like a live one. Say so first, and
1069
878
  // 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
- ],
879
+ ["Trashed", detail.trashed ? `YES (deleted ${detail.archivedAt ?? "at an unknown time"})` : null],
1074
880
  ["Archived", !detail.trashed && detail.archivedAt ? `YES (${detail.archivedAt})` : null],
1075
881
  ["State", detail.state?.name ?? null],
1076
882
  ["Priority", detail.priorityLabel],
@@ -1079,109 +885,21 @@ export async function renderIssueDetail(
1079
885
  ["Project", detail.project?.name ?? null],
1080
886
  ["Milestone", detail.milestone?.name ?? null],
1081
887
  ["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
- ],
888
+ ["Parent", detail.parent?.identifier ?? null],
1087
889
  ["Estimate", detail.estimate],
1088
890
  ["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
891
  ["Due", detail.dueDate],
1101
892
  ["URL", detail.url],
1102
893
  ["Updated", detail.updatedAt],
1103
894
  ["Description", detail.description ? `\n${detail.description}` : null],
1104
- ...commentPairs,
895
+ ...(includeComments
896
+ ? comments.map(
897
+ (c) => [c.createdAt.slice(0, 10) + " " + (c.user?.displayName ?? "—"), c.body] as [string, unknown],
898
+ )
899
+ : []),
1105
900
  ]);
1106
901
  }
1107
902
 
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
903
  function registerScalar(
1186
904
  issue: Command,
1187
905
  name: string,
@@ -1193,36 +911,27 @@ function registerScalar(
1193
911
  .description(description)
1194
912
  .action(
1195
913
  action(async (ctx: Context, _opts, idArg?: string) => {
1196
- const detail = await svc.getIssueDetail(ctx.client, requireId(idArg, ctx.defaultTeam), {
1197
- includeComments: false,
1198
- });
914
+ const detail = await svc.getIssueDetail(ctx.client, requireId(idArg));
1199
915
  ctx.output.emit({ [name]: pick(detail) }, () => ctx.output.line(pick(detail)));
1200
916
  }),
1201
917
  );
1202
918
  }
1203
919
 
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;
920
+ const ISSUE_ID_RE = /^([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
921
 
1207
922
  /**
1208
923
  * Disambiguate `[id] [value]` where both are optional so the issue id can be
1209
924
  * inferred from the branch. Two args → (id, value). One arg → it's the value
1210
925
  * unless it looks like an issue id (then the value is missing → usage error).
1211
926
  */
1212
- function oneOrTwo(
1213
- a: string | undefined,
1214
- b: string | undefined,
1215
- valueName: string,
1216
- ): {
927
+ function oneOrTwo(a: string | undefined, b: string | undefined, valueName: string): {
1217
928
  idArg?: string;
1218
929
  value: string;
1219
930
  } {
1220
931
  if (a !== undefined && b !== undefined) return { idArg: a, value: b };
1221
932
  if (a === undefined) throw usageError(`Missing ${valueName}.`);
1222
933
  if (ISSUE_ID_RE.test(a)) {
1223
- throw usageError(
1224
- `Missing ${valueName}. Usage: <id> <${valueName}> (or just <${valueName}> on a matching branch)`,
1225
- );
934
+ throw usageError(`Missing ${valueName}. Usage: <id> <${valueName}> (or just <${valueName}> on a matching branch)`);
1226
935
  }
1227
936
  return { value: a };
1228
937
  }
@@ -1232,10 +941,7 @@ function oneOrTwo(
1232
941
  * legitimately be absent (it can come from --body-file or $EDITOR): a lone
1233
942
  * operand that looks like an issue id IS the id; anything else is the body.
1234
943
  */
1235
- function idAndBody(
1236
- a: string | undefined,
1237
- b: string | undefined,
1238
- ): { idArg?: string; bodyArg?: string } {
944
+ function idAndBody(a: string | undefined, b: string | undefined): { idArg?: string; bodyArg?: string } {
1239
945
  if (a !== undefined && b !== undefined) return { idArg: a, bodyArg: b };
1240
946
  if (a === undefined) return {};
1241
947
  return ISSUE_ID_RE.test(a) ? { idArg: a } : { bodyArg: a };
@@ -1261,3 +967,9 @@ function runGh(args: string[]): string {
1261
967
  throw new CliError(stderr || `gh exited with code ${e.status ?? 1}.`, "runtime");
1262
968
  }
1263
969
  }
970
+
971
+ async function openUrl(url: string): Promise<void> {
972
+ const { execFile } = await import("node:child_process");
973
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
974
+ await new Promise<void>((resolve) => execFile(cmd, [url], () => resolve()));
975
+ }