linear-sdk-cli 0.1.3 → 0.2.3

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 (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +96 -58
  4. package/COVERAGE.md +5 -5
  5. package/MIGRATING.md +71 -69
  6. package/README.md +115 -90
  7. package/package.json +6 -7
  8. package/skills/README.md +15 -0
  9. package/skills/linear-sdk-cli/SKILL.md +27 -3
  10. package/skills/linear-sdk-cli/references/api.md +9 -9
  11. package/skills/linear-sdk-cli/references/auth.md +18 -0
  12. package/skills/linear-sdk-cli/references/commands.md +2 -2
  13. package/skills/linear-sdk-cli/references/comment.md +9 -6
  14. package/skills/linear-sdk-cli/references/config.md +9 -11
  15. package/skills/linear-sdk-cli/references/issue.md +72 -18
  16. package/skills/linear-sdk-cli/references/open.md +29 -0
  17. package/skills/linear-sdk-cli/references/team.md +16 -0
  18. package/skills/linear-sdk-cli/scripts/generate-docs.ts +6 -2
  19. package/skills/linear-sdk-cli-maintenance/SKILL.md +97 -0
  20. package/src/bin/linear.ts +16 -6
  21. package/src/cli.ts +3 -1
  22. package/src/commands/agent-session.ts +13 -5
  23. package/src/commands/api.ts +15 -9
  24. package/src/commands/attach.ts +15 -4
  25. package/src/commands/comment.ts +50 -11
  26. package/src/commands/completion.ts +2 -6
  27. package/src/commands/cycle.ts +6 -1
  28. package/src/commands/discover.ts +6 -2
  29. package/src/commands/document.ts +4 -1
  30. package/src/commands/initiative.ts +5 -1
  31. package/src/commands/issue.ts +392 -91
  32. package/src/commands/meta.ts +139 -31
  33. package/src/commands/milestone.ts +4 -1
  34. package/src/commands/notification.ts +15 -6
  35. package/src/commands/open.ts +84 -0
  36. package/src/commands/project.ts +17 -4
  37. package/src/commands/team.ts +13 -0
  38. package/src/commands/user.ts +2 -2
  39. package/src/commands/webhook.ts +12 -2
  40. package/src/config.ts +501 -184
  41. package/src/context.ts +13 -4
  42. package/src/git.ts +28 -3
  43. package/src/lib/body.ts +26 -2
  44. package/src/lib/errors.ts +33 -8
  45. package/src/lib/introspect.ts +76 -7
  46. package/src/lib/keyring.ts +51 -9
  47. package/src/lib/mentions.ts +49 -0
  48. package/src/lib/open.ts +32 -0
  49. package/src/lib/options.ts +108 -12
  50. package/src/lib/output-shapes.ts +81 -9
  51. package/src/lib/pagination.ts +50 -17
  52. package/src/lib/resolve.ts +40 -7
  53. package/src/lib/shape.ts +16 -12
  54. package/src/lib/upload.ts +3 -1
  55. package/src/output/color.ts +52 -0
  56. package/src/output/format.ts +96 -18
  57. package/src/output/markdown.ts +133 -0
  58. package/src/output/pager.ts +131 -0
  59. package/src/output/sanitize.ts +1 -1
  60. package/src/output/table.ts +7 -7
  61. package/src/services/agent-session.ts +11 -3
  62. package/src/services/attachment.ts +14 -5
  63. package/src/services/comment.ts +37 -9
  64. package/src/services/cycle.ts +11 -4
  65. package/src/services/document.ts +29 -10
  66. package/src/services/favorite.ts +15 -9
  67. package/src/services/initiative-update.ts +16 -15
  68. package/src/services/initiative.ts +15 -10
  69. package/src/services/issue.ts +263 -19
  70. package/src/services/label.ts +12 -2
  71. package/src/services/milestone.ts +8 -5
  72. package/src/services/notification.ts +16 -2
  73. package/src/services/organization.ts +23 -18
  74. package/src/services/project.ts +55 -34
  75. package/src/services/roadmap.ts +7 -6
  76. package/src/services/state.ts +9 -3
  77. package/src/services/team.ts +67 -24
  78. package/src/services/user.ts +2 -2
  79. package/src/services/webhook.ts +28 -17
@@ -6,12 +6,24 @@
6
6
  import { Command, Option } from "commander";
7
7
  import { usageError, type CliError } from "./errors.js";
8
8
 
9
- /** Repeatable `--var k=v` collector → { k: v }. */
10
- export function collectKeyVal(value: string, previous: Record<string, string> = {}): Record<string, string> {
9
+ /**
10
+ * Repeatable `--var k=v` collector. JSON literals become their real types
11
+ * (`2`, `true`, `null`, arrays/objects); ordinary text stays a string.
12
+ */
13
+ export function collectKeyVal(
14
+ value: string,
15
+ previous: Record<string, unknown> = {},
16
+ ): Record<string, unknown> {
11
17
  const eq = value.indexOf("=");
12
18
  if (eq === -1) throw usageError(`Expected key=value, got '${value}'`);
13
19
  const key = value.slice(0, eq);
14
- previous[key] = value.slice(eq + 1);
20
+ if (!key) throw usageError(`Expected a non-empty key in key=value, got '${value}'`);
21
+ const raw = value.slice(eq + 1);
22
+ try {
23
+ previous[key] = JSON.parse(raw);
24
+ } catch {
25
+ previous[key] = raw;
26
+ }
15
27
  return previous;
16
28
  }
17
29
 
@@ -376,6 +388,7 @@ export const FIELDS_COMMANDS: ReadonlySet<string> = new Set([
376
388
  "state view",
377
389
  "team list",
378
390
  "team view",
391
+ "team id",
379
392
  "team members",
380
393
  "team states",
381
394
  "team labels",
@@ -423,6 +436,65 @@ export const LIMIT_COMMANDS: ReadonlySet<string> = new Set([
423
436
  "webhook list",
424
437
  ]);
425
438
 
439
+ /** Commands where the global team changes resolution, filtering, or a mutation. */
440
+ export const TEAM_COMMANDS: ReadonlySet<string> = new Set([
441
+ "config show",
442
+ "config init",
443
+ "issue view",
444
+ "issue list",
445
+ "issue mine",
446
+ "issue search",
447
+ "issue create",
448
+ "issue update",
449
+ "team view",
450
+ "team members",
451
+ "team states",
452
+ "team labels",
453
+ "team cycles",
454
+ "team update",
455
+ "project list",
456
+ "project create",
457
+ // Registered locally by project update so it can reject the dangerous
458
+ // singular spelling with a tailored `--teams` explanation.
459
+ "project update",
460
+ "cycle list",
461
+ "cycle view",
462
+ "cycle current",
463
+ "cycle create",
464
+ "cycle update",
465
+ "state list",
466
+ "state view",
467
+ "label list",
468
+ "label create",
469
+ "document list",
470
+ "document create",
471
+ "document update",
472
+ "webhook create",
473
+ ]);
474
+
475
+ /** Commands whose confirmation prompt is bypassed by `--yes`. */
476
+ export const YES_COMMANDS: ReadonlySet<string> = new Set([
477
+ "attachment delete",
478
+ "auth logout",
479
+ "comment delete",
480
+ "issue comment delete",
481
+ "document delete",
482
+ "favorite remove",
483
+ "initiative archive",
484
+ "initiative remove-project",
485
+ "initiative delete",
486
+ "issue archive",
487
+ "issue delete",
488
+ "label delete",
489
+ "milestone delete",
490
+ "notification archive",
491
+ "project archive",
492
+ "project delete",
493
+ "roadmap delete",
494
+ "team delete",
495
+ "webhook delete",
496
+ ]);
497
+
426
498
  /** `project list` for the `list` command under `project` — the path without the program name. */
427
499
  export function subcommandPath(cmd: Command): string {
428
500
  const names: string[] = [];
@@ -461,7 +533,9 @@ export function assertGlobalsApply(cmd: Command): void {
461
533
  const files = fileOptionsOf(cmd);
462
534
  throw usageError(
463
535
  `--fields does not apply to \`linear ${path}\`: it prints a receipt, not a table or detail block to select fields from.` +
464
- (files.length ? ` To read from a file, use ${files.join(" or ")} (-f is --fields here, not a file).` : ""),
536
+ (files.length
537
+ ? ` To read from a file, use ${files.join(" or ")} (-f is --fields here, not a file).`
538
+ : ""),
465
539
  );
466
540
  }
467
541
  if (!LIMIT_COMMANDS.has(path)) {
@@ -477,6 +551,12 @@ export function assertGlobalsApply(cmd: Command): void {
477
551
  throw usageError(`--all does not apply to \`linear ${path}\`: it is not a paged query.`);
478
552
  }
479
553
  }
554
+ if (merged.team !== undefined && !TEAM_COMMANDS.has(path)) {
555
+ throw usageError(`--team does not apply to \`linear ${path}\`.`);
556
+ }
557
+ if (merged.yes === true && !YES_COMMANDS.has(path)) {
558
+ throw usageError(`--yes does not apply to \`linear ${path}\`: it has no confirmation prompt.`);
559
+ }
480
560
  }
481
561
 
482
562
  /** Opt-outs for the shared filter sets; `issue mine` is fixed to the viewer. */
@@ -502,9 +582,10 @@ export interface FilterOptionSet {
502
582
  */
503
583
  export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): Command {
504
584
  cmd.addOption(
505
- new Option("-t, --team <key>", "filter by team key (repeatable; default: configured team)").argParser(
506
- collectArray,
507
- ),
585
+ new Option(
586
+ "-t, --team <key>",
587
+ "filter by team key (repeatable; default: configured team)",
588
+ ).argParser(collectArray),
508
589
  );
509
590
  cmd.addOption(
510
591
  new Option("-s, --state <name>", "filter by workflow state name/type (repeatable)").argParser(
@@ -521,11 +602,19 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
521
602
  new Option("--project-label <name>", "filter by the project's label (excludes --project)"),
522
603
  )
523
604
  .addOption(new Option("--milestone <name>", "filter by project milestone"))
524
- .addOption(new Option("-l, --label <name>", "filter by label (repeat to narrow)").argParser(parseList))
525
- .addOption(new Option("-P, --priority <0-4>", "filter by priority").argParser(parsePriorityFilter))
605
+ .addOption(
606
+ new Option("-l, --label <name>", "filter by label (repeat to narrow)").argParser(parseList),
607
+ )
608
+ .addOption(
609
+ new Option("-P, --priority <0-4>", "filter by priority").argParser(parsePriorityFilter),
610
+ )
526
611
  .addOption(new Option(CYCLE_FLAG, CYCLE_DESC))
527
- .addOption(new Option("--created-after <date>", "only issues created at/after a date (YYYY-MM-DD)"))
528
- .addOption(new Option("--updated-after <date>", "only issues updated at/after a date (YYYY-MM-DD)"))
612
+ .addOption(
613
+ new Option("--created-after <date>", "only issues created at/after a date (YYYY-MM-DD)"),
614
+ )
615
+ .addOption(
616
+ new Option("--updated-after <date>", "only issues updated at/after a date (YYYY-MM-DD)"),
617
+ )
529
618
  .addOption(new Option("--all-teams", "search every team, ignoring the default team"))
530
619
  .addOption(new Option("--include-archived", "include archived issues"));
531
620
  }
@@ -538,7 +627,14 @@ export function addCoreFilterOptions(cmd: Command, set: FilterOptionSet = {}): C
538
627
  export function addFilterOptions(cmd: Command, set: FilterOptionSet = {}): Command {
539
628
  addCoreFilterOptions(cmd, set)
540
629
  .addOption(new Option("--query <text>", "full-text search"))
541
- .addOption(new Option("--sort <field>", "sort order").choices(["priority", "updated", "created"]));
630
+ .addOption(
631
+ new Option("--sort <field>", "sort order").choices([
632
+ "priority",
633
+ "manual",
634
+ "updated",
635
+ "created",
636
+ ]),
637
+ );
542
638
  // `--search` is the reference CLI's spelling of the same filter.
543
639
  return addAliasOption(cmd, "--search <text>", "--query");
544
640
  }
@@ -130,7 +130,10 @@ export const UPDATE_ROW_SHAPE = shape<UpdateRow>({
130
130
  body: "string",
131
131
  health: "string|null",
132
132
  });
133
- const UPDATE_RECEIPT_SHAPE = shape<UpdateRow & { url: string }>({ ...UPDATE_ROW_SHAPE, url: "string" });
133
+ const UPDATE_RECEIPT_SHAPE = shape<UpdateRow & { url: string }>({
134
+ ...UPDATE_ROW_SHAPE,
135
+ url: "string",
136
+ });
134
137
 
135
138
  /** `whoami` / `auth whoami`. */
136
139
  const WHOAMI_SHAPE: ObjectFields = {
@@ -142,12 +145,15 @@ const WHOAMI_SHAPE: ObjectFields = {
142
145
  organization: { id: "string", name: "string", urlKey: "string" },
143
146
  };
144
147
 
145
- const SETTING_ORIGIN_SHAPE = shape<SettingOrigin>({ source: "string", "path?": "string" });
148
+ const SETTING_ORIGIN_SHAPE = shape<SettingOrigin>({
149
+ source: "string",
150
+ "path?": "string",
151
+ "key?": "string",
152
+ });
146
153
  const SETTING_ORIGINS_SHAPE = shape<SettingOrigins>({
147
154
  team: SETTING_ORIGIN_SHAPE,
148
155
  workspace: SETTING_ORIGIN_SHAPE,
149
156
  sort: SETTING_ORIGIN_SHAPE,
150
- vcs: SETTING_ORIGIN_SHAPE,
151
157
  });
152
158
 
153
159
  /** `config show`: every resolved setting, redacted, with its provenance. */
@@ -158,7 +164,6 @@ const CONFIG_SHOW_SHAPE: ObjectFields = {
158
164
  team: "string|null",
159
165
  workspace: "string|null",
160
166
  sort: "string",
161
- vcs: "string",
162
167
  origins: SETTING_ORIGINS_SHAPE,
163
168
  userConfigPath: "string",
164
169
  projectConfigPath: "string|null",
@@ -171,7 +176,20 @@ export const COMMAND_NODE_SHAPE = shape<CommandNode>({
171
176
  description: "string",
172
177
  aliases: ["string"],
173
178
  arguments: [{ name: "string", required: "boolean", variadic: "boolean" }],
174
- options: [{ flags: "string", description: "string" }],
179
+ options: [
180
+ {
181
+ flags: "string",
182
+ description: "string",
183
+ attribute: "string",
184
+ valueRequired: "boolean",
185
+ valueOptional: "boolean",
186
+ variadic: "boolean",
187
+ "choices?": ["string"],
188
+ "defaultValue?": "unknown",
189
+ global: "boolean",
190
+ applicable: "boolean",
191
+ },
192
+ ],
175
193
  "output?": {
176
194
  kind: "string",
177
195
  "fields?": "object",
@@ -210,8 +228,8 @@ const RELATION_ROW: ObjectFields = { type: "string", issue: "string", title: "st
210
228
 
211
229
  const ISSUE_VIEW = object(ISSUE_DETAIL_SHAPE, {
212
230
  variants: {
213
- "--comments": object({ ...ISSUE_DETAIL_SHAPE, comments: [COMMENT_ROW_SHAPE] }),
214
231
  "--web": receipt({ ...ISSUE_URL_RECEIPT, opened: "boolean" }),
232
+ "--app": receipt({ ...ISSUE_URL_RECEIPT, opened: "boolean" }),
215
233
  },
216
234
  });
217
235
  const ISSUE_LIST = list(ISSUE_ROW_SHAPE);
@@ -236,6 +254,13 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
236
254
  "attachment list": list(ATTACHMENT_ROW_SHAPE),
237
255
 
238
256
  auth: null,
257
+ "auth adopt": receipt({
258
+ success: "boolean",
259
+ workspace: "string",
260
+ user: { id: "string", name: "string", email: "string" },
261
+ storage: "string",
262
+ path: "string",
263
+ }),
239
264
  "auth default": receipt({ success: "boolean", default_workspace: "string", path: "string" }),
240
265
  "auth list": list({ slug: "string", isDefault: "boolean", storage: "string" }),
241
266
  "auth login": receipt({
@@ -316,7 +341,28 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
316
341
  "issue agent-session": null,
317
342
  "issue agent-session list": list(AGENT_SESSION_ROW_SHAPE),
318
343
  "issue agent-session view": object(AGENT_SESSION_DETAIL_SHAPE),
319
- "issue archive": receipt({ ...ISSUE_REF, archived: "boolean" }),
344
+ "issue archive": receipt(
345
+ { ...ISSUE_REF, archived: "boolean" },
346
+ {
347
+ variants: {
348
+ "--bulk": receipt({
349
+ action: "string",
350
+ results: [
351
+ {
352
+ input: "string",
353
+ "id?": "string",
354
+ "identifier?": "string",
355
+ "archived?": "boolean",
356
+ "deleted?": "boolean",
357
+ "error?": { message: "string", code: "string" },
358
+ },
359
+ ],
360
+ succeeded: "number",
361
+ failed: "number",
362
+ }),
363
+ },
364
+ },
365
+ ),
320
366
  "issue assign": receipt(ISSUE_REF),
321
367
  "issue attach": list(
322
368
  {
@@ -350,10 +396,33 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
350
396
  }),
351
397
  },
352
398
  }),
353
- "issue delete": receipt({ ...ISSUE_REF, deleted: "boolean" }),
399
+ "issue delete": receipt(
400
+ { ...ISSUE_REF, deleted: "boolean" },
401
+ {
402
+ variants: {
403
+ "--bulk": receipt({
404
+ action: "string",
405
+ results: [
406
+ {
407
+ input: "string",
408
+ "id?": "string",
409
+ "identifier?": "string",
410
+ "archived?": "boolean",
411
+ "deleted?": "boolean",
412
+ "error?": { message: "string", code: "string" },
413
+ },
414
+ ],
415
+ succeeded: "number",
416
+ failed: "number",
417
+ }),
418
+ },
419
+ },
420
+ ),
354
421
  "issue describe": receipt(
355
422
  { identifier: "string", title: "string", url: "string", trailer: "string", message: "string" },
356
- { note: "`trailer` is the magic-word phrase (Fixes TES-1); `message` the full commit text as printed" },
423
+ {
424
+ note: "`trailer` is the magic-word phrase (Fixes TES-1); `message` the full commit text as printed",
425
+ },
357
426
  ),
358
427
  "issue id": receipt({ id: "string" }, { note: "`id` is the identifier (TES-123), not the UUID" }),
359
428
  "issue label": receipt(ISSUE_REF),
@@ -416,6 +485,8 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
416
485
  "notification snooze": receipt({ id: "string", snoozedUntilAt: "string" }),
417
486
  "notification unread": receipt({ id: "string", read: "boolean" }),
418
487
 
488
+ open: receipt({ target: "string", url: "string", label: "string", opened: "boolean" }),
489
+
419
490
  organization: runs("organization view", object(ORGANIZATION_DETAIL_SHAPE)),
420
491
  "organization invites": list(INVITE_ROW_SHAPE),
421
492
  "organization members": list(ORGANIZATION_MEMBER_ROW_SHAPE),
@@ -459,6 +530,7 @@ export const OUTPUT_SHAPES: Record<string, OutputShape | null> = {
459
530
  movedIssues: "number",
460
531
  movedTo: { nullable: TEAM_ROW_SHAPE },
461
532
  }),
533
+ "team id": receipt(TEAM_ROW_SHAPE),
462
534
  "team labels": list(TEAM_LABEL_ROW_SHAPE),
463
535
  "team list": list(TEAM_ROW_SHAPE),
464
536
  "team members": list(TEAM_MEMBER_ROW_SHAPE),
@@ -5,7 +5,10 @@
5
5
  * and `.fetchNext()` mutates the connection to append the next page's nodes.
6
6
  */
7
7
 
8
- import { withRetry } from "../client.js";
8
+ import { fetchNextWithRetry, withRetry } from "../client.js";
9
+
10
+ /** Linear accepts at most 250 nodes per connection page. */
11
+ export const MAX_PAGE_SIZE = 250;
9
12
 
10
13
  export interface Connection<T> {
11
14
  nodes: T[];
@@ -13,22 +16,49 @@ export interface Connection<T> {
13
16
  fetchNext: () => Promise<Connection<T>>;
14
17
  }
15
18
 
16
- export async function collect<T>(
17
- first: Connection<T>,
18
- limit: number,
19
- ): Promise<T[]> {
19
+ /** Non-enumerable metadata carried by list arrays without changing their JSON contract. */
20
+ const HAS_MORE = Symbol.for("linear-sdk-cli.pagination.has-more");
21
+
22
+ type PagedArray<T> = T[] & { [HAS_MORE]?: boolean };
23
+
24
+ function withMoreMetadata<T>(items: T[], hasMore: boolean): T[] {
25
+ Object.defineProperty(items, HAS_MORE, { value: hasMore, enumerable: false });
26
+ return items;
27
+ }
28
+
29
+ /** Attach an explicit pagination result to an array without changing its JSON value. */
30
+ export function setPaginationMetadata<T>(items: T[], hasMore: boolean): T[] {
31
+ return withMoreMetadata(items, hasMore);
32
+ }
33
+
34
+ /** Whether a service list stopped at its caller-supplied limit. */
35
+ export function hasMoreResults(items: readonly unknown[]): boolean {
36
+ return (items as PagedArray<unknown>)[HAS_MORE] === true;
37
+ }
38
+
39
+ /** Carry pagination metadata through a service's map/filter/sort result. */
40
+ export function inheritPaginationMetadata<T>(items: T[], source: readonly unknown[]): T[] {
41
+ return withMoreMetadata(items, hasMoreResults(source));
42
+ }
43
+
44
+ export async function collect<T>(first: Connection<T>, limit: number): Promise<T[]> {
20
45
  let conn = first;
21
46
  // Each fetchNext() appends to conn.nodes in place.
22
47
  while (conn.nodes.length < limit && conn.pageInfo.hasNextPage) {
23
- conn = await conn.fetchNext();
48
+ // Page two and later are independent requests and can be rate-limited just
49
+ // like page one. Keep them behind the same bounded retry policy.
50
+ conn = await fetchNextWithRetry(conn);
24
51
  }
25
- return limit === Infinity ? conn.nodes : conn.nodes.slice(0, limit);
52
+ if (limit === Infinity) return withMoreMetadata(conn.nodes, false);
53
+ const hasMore =
54
+ conn.nodes.length > limit || (conn.nodes.length >= limit && conn.pageInfo.hasNextPage);
55
+ return withMoreMetadata(conn.nodes.slice(0, limit), hasMore);
26
56
  }
27
57
 
28
58
  /** How many to request per page given a desired total limit. */
29
59
  export function pageSize(limit: number): number {
30
- if (limit === Infinity) return 100;
31
- return Math.min(Math.max(limit, 1), 100);
60
+ if (limit === Infinity) return MAX_PAGE_SIZE;
61
+ return Math.min(Math.max(limit, 1), MAX_PAGE_SIZE);
32
62
  }
33
63
 
34
64
  /**
@@ -76,7 +106,7 @@ interface RawClient {
76
106
  * `withRetry`; pagination stops at `limit` or when `hasNextPage` is false.
77
107
  *
78
108
  * Mirrors the hand-rolled loops in the list services exactly: page size is
79
- * `limit === Infinity ? 100 : Math.min(limit, 100)`, and rows are capped at
109
+ * `limit === Infinity ? 250 : Math.min(limit, 250)`, and rows are capped at
80
110
  * `limit` mid-page.
81
111
  */
82
112
  export async function collectRawQuery<T>(
@@ -87,26 +117,29 @@ export async function collectRawQuery<T>(
87
117
  limit: number,
88
118
  transform: (node: any) => T,
89
119
  ): Promise<T[]> {
90
- const pageLimit = limit === Infinity ? 100 : Math.min(limit, 100);
120
+ const pageLimit = pageSize(limit);
91
121
  const rows: T[] = [];
92
122
  let after: string | undefined;
123
+ let hasMore = false;
93
124
 
94
125
  for (;;) {
95
126
  const data: any = await withRetry(() =>
96
127
  client.client.rawRequest(query, { ...variables, first: pageLimit, after }),
97
128
  );
98
- const conn = connectionPath
99
- .split(".")
100
- .reduce((acc: any, key) => acc?.[key], data.data) as RawConnection | null | undefined;
129
+ const conn = connectionPath.split(".").reduce((acc: any, key) => acc?.[key], data.data) as
130
+ RawConnection | null | undefined;
101
131
  // A nested path (`issue.comments`) can land on null when the parent entity
102
132
  // is gone; that is an empty list, not a crash.
103
133
  if (!conn) break;
104
- for (const n of conn.nodes) {
134
+ for (const [index, n] of conn.nodes.entries()) {
105
135
  rows.push(transform(n));
106
- if (rows.length >= limit) break;
136
+ if (rows.length >= limit) {
137
+ hasMore = index < conn.nodes.length - 1 || conn.pageInfo.hasNextPage;
138
+ break;
139
+ }
107
140
  }
108
141
  if (rows.length >= limit || !conn.pageInfo.hasNextPage) break;
109
142
  after = conn.pageInfo.endCursor;
110
143
  }
111
- return rows;
144
+ return withMoreMetadata(rows, hasMore);
112
145
  }
@@ -15,6 +15,27 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
15
15
  export const isUuid = (v: string): boolean => UUID_RE.test(v);
16
16
 
17
17
  const IDENTIFIER_RE = /^([a-zA-Z][a-zA-Z0-9]*)-(\d+)$/;
18
+ const ISSUE_NUMBER_RE = /^\d+$/;
19
+
20
+ /**
21
+ * Expand a bare issue number with the configured team (`42` + `TES` →
22
+ * `TES-42`). This is deliberately pure: commands can normalize every place an
23
+ * issue reference appears before handing it to the existing resolvers.
24
+ */
25
+ export function normalizeIssueReference(input: string, defaultTeamKey?: string): string {
26
+ if (!ISSUE_NUMBER_RE.test(input)) return input;
27
+ if (!defaultTeamKey) {
28
+ throw usageError(
29
+ `Bare issue number '${input}' needs a default team. Pass --team <KEY> or set one in config.`,
30
+ );
31
+ }
32
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(defaultTeamKey)) {
33
+ throw usageError(
34
+ `Cannot expand issue number '${input}' with default team '${defaultTeamKey}'; configure a team key such as TES.`,
35
+ );
36
+ }
37
+ return `${defaultTeamKey.toUpperCase()}-${Number.parseInt(input, 10)}`;
38
+ }
18
39
 
19
40
  /** Known workflow-state types, used to decide name-vs-type filtering. */
20
41
  export const STATE_TYPES = ["triage", "backlog", "unstarted", "started", "completed", "canceled"];
@@ -101,13 +122,15 @@ export async function resolveTeam(
101
122
  "linear team list",
102
123
  )}`,
103
124
  );
104
- if (matches.length > 1) throw ambiguous(`Multiple teams match '${value}': ${matches.map((t) => t.key).join(", ")}`);
125
+ if (matches.length > 1)
126
+ throw ambiguous(`Multiple teams match '${value}': ${matches.map((t) => t.key).join(", ")}`);
105
127
  const team = matches[0]!;
106
128
  return { id: team.id, key: team.key, name: team.name };
107
129
  }
108
130
 
109
131
  /** The spellings that mean "the authenticated user": ours (`me`, `@me`) and the reference CLI's (`self`). */
110
- export const isSelf = (input: string): boolean => input === "me" || input === "@me" || input === "self";
132
+ export const isSelf = (input: string): boolean =>
133
+ input === "me" || input === "@me" || input === "self";
111
134
 
112
135
  /**
113
136
  * Resolve an assignee reference (`me`, email, name, or id) to a user id.
@@ -138,7 +161,9 @@ export async function resolveUserId(client: LinearClient, input: string): Promis
138
161
  if (nodes.length === 0)
139
162
  throw notFound(`No user matching '${input}'. Run 'linear user list' to see workspace members.`);
140
163
  if (nodes.length > 1)
141
- throw ambiguous(`Multiple users match '${input}': ${nodes.map((u: any) => u.email).join(", ")}`);
164
+ throw ambiguous(
165
+ `Multiple users match '${input}': ${nodes.map((u: any) => u.email).join(", ")}`,
166
+ );
142
167
  return nodes[0]!.id;
143
168
  }
144
169
 
@@ -203,7 +228,8 @@ export async function resolveLabelIds(
203
228
  // Narrow to this team's labels + workspace-level labels when a team is known.
204
229
  // If a team is known and nothing is in scope, that's not-found (do NOT fall
205
230
  // back to an out-of-scope label from another team).
206
- let candidates: any[] = nodes;
231
+ // Label groups are containers, not labels that can be applied to an issue.
232
+ let candidates: any[] = nodes.filter((label: any) => !label.isGroup);
207
233
  if (teamId) {
208
234
  const scoped = await Promise.all(
209
235
  nodes.map(async (l: any) => ({ label: l, team: await l.team })),
@@ -334,7 +360,9 @@ export async function resolveProjectId(client: LinearClient, input: string): Pro
334
360
  if (nodes.length === 0)
335
361
  throw notFound(`No project matching '${input}'. Run 'linear project list' to see the options.`);
336
362
  if (nodes.length > 1)
337
- throw ambiguous(`Multiple projects match '${input}': ${nodes.map((p: any) => p.name).join(", ")}`);
363
+ throw ambiguous(
364
+ `Multiple projects match '${input}': ${nodes.map((p: any) => p.name).join(", ")}`,
365
+ );
338
366
  return nodes[0]!.id;
339
367
  }
340
368
 
@@ -485,7 +513,11 @@ export async function resolveReleaseId(client: LinearClient, input: string): Pro
485
513
  first: RESOLVE_PAGE,
486
514
  }),
487
515
  );
488
- const nodes = await scanAll<any>(conn as any, "releases", "linear api '{ releases { nodes { id name version } } }'");
516
+ const nodes = await scanAll<any>(
517
+ conn as any,
518
+ "releases",
519
+ "linear api '{ releases { nodes { id name version } } }'",
520
+ );
489
521
  if (nodes.length === 0) throw notFound(`No release named or versioned '${input}'.`);
490
522
  if (nodes.length > 1)
491
523
  throw ambiguous(
@@ -547,7 +579,8 @@ export async function resolveTemplateId(
547
579
  export async function resolveIssue(client: LinearClient, input: string): Promise<Issue> {
548
580
  if (isUuid(input)) return withRetry(() => client.issue(input));
549
581
  const match = input.match(IDENTIFIER_RE);
550
- if (!match) throw usageError(`'${input}' is not a valid issue id (expected e.g. TES-123 or a UUID).`);
582
+ if (!match)
583
+ throw usageError(`'${input}' is not a valid issue id (expected e.g. TES-123 or a UUID).`);
551
584
  const key = match[1]!.toUpperCase();
552
585
  const number = Number.parseInt(match[2]!, 10);
553
586
  // includeArchived so archive/unarchive/delete can operate on archived issues.
package/src/lib/shape.ts CHANGED
@@ -66,11 +66,13 @@ type IsAny<T> = 0 extends 1 & T ? true : false;
66
66
  type ObjectShapeOf<T> = string extends keyof T
67
67
  ? "object"
68
68
  : {
69
- [K in keyof T as IsAny<T[K]> extends true
70
- ? K & string
71
- : undefined extends T[K]
72
- ? `${K & string}?`
73
- : K & string]-?: ShapeOf<Exclude<T[K], undefined>>;
69
+ [
70
+ K in keyof T as IsAny<T[K]> extends true
71
+ ? K & string
72
+ : undefined extends T[K]
73
+ ? `${K & string}?`
74
+ : K & string
75
+ ]-?: ShapeOf<Exclude<T[K], undefined>>;
74
76
  };
75
77
 
76
78
  export type ShapeOf<T> =
@@ -119,10 +121,7 @@ export type ObjectFields = Record<string, FieldShape>;
119
121
 
120
122
  function isNullableWrapper(s: FieldShape): s is { readonly nullable: FieldShape } {
121
123
  return (
122
- typeof s === "object" &&
123
- !Array.isArray(s) &&
124
- Object.keys(s).length === 1 &&
125
- "nullable" in s
124
+ typeof s === "object" && !Array.isArray(s) && Object.keys(s).length === 1 && "nullable" in s
126
125
  );
127
126
  }
128
127
 
@@ -160,7 +159,9 @@ export function matchesShape(
160
159
  }
161
160
  if (Array.isArray(shape)) {
162
161
  if (!Array.isArray(value)) return [`${path}: expected an array, got ${describe(value)}`];
163
- return value.flatMap((item, i) => matchesShape(item, shape[0], `${path}[${i}]`, strictNullable));
162
+ return value.flatMap((item, i) =>
163
+ matchesShape(item, shape[0], `${path}[${i}]`, strictNullable),
164
+ );
164
165
  }
165
166
  if (isNullableWrapper(shape)) {
166
167
  if (value === undefined) return [`${path}: expected an object or null, got undefined`];
@@ -184,7 +185,8 @@ export function matchesShape(
184
185
  problems.push(...matchesShape(record[key], sub, `${path}.${key}`, strictNullable));
185
186
  }
186
187
  for (const key of Object.keys(record)) {
187
- if (!declared.has(key) && record[key] !== undefined) problems.push(`${path}.${key}: not in the declared shape`);
188
+ if (!declared.has(key) && record[key] !== undefined)
189
+ problems.push(`${path}.${key}: not in the declared shape`);
188
190
  }
189
191
  return problems;
190
192
  }
@@ -203,7 +205,9 @@ export function renderShape(shape: FieldShape): string {
203
205
  if (typeof shape === "string") return shape.replace("|null", " | null");
204
206
  if (Array.isArray(shape)) {
205
207
  const inner = renderShape(shape[0]);
206
- return typeof shape[0] === "string" && !shape[0].includes("|") ? `${inner}[]` : `Array<${inner}>`;
208
+ return typeof shape[0] === "string" && !shape[0].includes("|")
209
+ ? `${inner}[]`
210
+ : `Array<${inner}>`;
207
211
  }
208
212
  if (isNullableWrapper(shape)) return `${renderShape(shape.nullable)} | null`;
209
213
  const parts = Object.entries(shape).map(([k, v]) => `${k}: ${renderShape(v)}`);
package/src/lib/upload.ts CHANGED
@@ -294,7 +294,9 @@ function redact(text: string, uploadUrl: string): string {
294
294
  }
295
295
 
296
296
  /** Markdown for a comment body: images embed inline, anything else links. */
297
- export function formatEmbed(r: Pick<UploadResult, "filename" | "assetUrl" | "contentType">): string {
297
+ export function formatEmbed(
298
+ r: Pick<UploadResult, "filename" | "assetUrl" | "contentType">,
299
+ ): string {
298
300
  return `${isImage(r.contentType) ? "!" : ""}[${r.filename}](${r.assetUrl})`;
299
301
  }
300
302