linear-sdk-cli 0.4.0 → 0.5.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.
@@ -41,8 +41,9 @@ import { openUrl } from "../lib/open.js";
41
41
  import type { Context } from "../context.js";
42
42
  import * as svc from "../services/issue.js";
43
43
  import * as commentSvc from "../services/comment.js";
44
- import { isSelf, normalizeIssueReference, resolveIssue } from "../lib/resolve.js";
44
+ import { isSelf, isUuid, normalizeIssueReference, resolveIssue } from "../lib/resolve.js";
45
45
  import type { Column } from "../output/table.js";
46
+ import { lifecycleSuffix } from "../output/lifecycle.js";
46
47
 
47
48
  /** Resolve the target issue id from an argument/current branch, expanding `42` via the default team. */
48
49
  function requireId(idArg: string | undefined, defaultTeam?: string): string {
@@ -60,7 +61,7 @@ const ROW_COLUMNS: Column<svc.IssueRow>[] = [
60
61
  {
61
62
  key: "state",
62
63
  header: "State",
63
- value: (r) => `${r.state?.name ?? ""}${lifecycleMark(r)}`,
64
+ value: (r) => `${r.state?.name ?? ""}${lifecycleSuffix(r)}`,
64
65
  max: 26,
65
66
  },
66
67
  { key: "priority", header: "Pri", value: (r) => shortPriority(r.priority) },
@@ -92,11 +93,6 @@ function shortPriority(p: number): string {
92
93
  return ["—", "Urgent", "High", "Med", "Low"][p] ?? String(p);
93
94
  }
94
95
 
95
- /** ` (trashed)` / ` (archived)` for a row that is not live; empty otherwise. */
96
- function lifecycleMark(r: Pick<svc.IssueRow, "trashed" | "archivedAt">): string {
97
- return r.trashed ? " (trashed)" : r.archivedAt ? " (archived)" : "";
98
- }
99
-
100
96
  /** A removed reference flag whose meaning is already this CLI's query default. */
101
97
  function addNoopAllAssignees(command: Command): void {
102
98
  command.addOption(
@@ -362,6 +358,10 @@ export function registerIssue(program: Command): void {
362
358
  .option("--description-file <path>", "read description from a file ('-' = stdin)")
363
359
  .option("--editor", "compose the description in $EDITOR")
364
360
  .option("-a, --assignee <who>", "assignee (me|email|name|id)")
361
+ .option("--delegate <agent>", "delegate to an eligible agent app user (name or id)")
362
+ .option("--clear-delegate", "explicitly create with no agent delegate")
363
+ .option("--dry-run", "resolve and show the delegation mutation input without writing")
364
+ .option("--full-result", "read back and verify the resulting delegate")
365
365
  .option("-s, --state <name>", "workflow state name or type")
366
366
  .option("-P, --priority <0-4>", "priority", parsePriority)
367
367
  .option("-l, --label <name>", "label (repeatable / comma-separated)", parseList)
@@ -386,6 +386,8 @@ export function registerIssue(program: Command): void {
386
386
  "",
387
387
  "Examples:",
388
388
  " linear issue create --title 'Fix login' --team TES --assignee me",
389
+ " linear issue create --title 'Fix login' --assignee me --delegate Codex",
390
+ " linear issue create --title 'Fix login' --delegate Codex --dry-run --json",
389
391
  " linear issue create --title 'Bug' -l bug -l urgent --priority 1",
390
392
  " linear issue create --title 'Sprint task' --cycle current --state 'In Progress'",
391
393
  " linear issue create --title 'Sub-task' --parent TES-42 # inherits TES-42's project",
@@ -396,6 +398,13 @@ export function registerIssue(program: Command): void {
396
398
  )
397
399
  .action(
398
400
  action(async (ctx: Context, opts) => {
401
+ const delegationChanged = opts.delegate !== undefined || opts.clearDelegate === true;
402
+ const mode = delegationMutationMode(opts, delegationChanged);
403
+ if (opts.start && mode.dryRun) {
404
+ throw usageError(
405
+ "--dry-run cannot be combined with --start, which also changes git and state.",
406
+ );
407
+ }
399
408
  // `--start` means *you* are starting on it, so it assigns to you (as the
400
409
  // reference CLI does); naming somebody else at the same time is a contradiction.
401
410
  if (opts.start && opts.assignee && !isSelf(opts.assignee)) {
@@ -411,13 +420,15 @@ export function registerIssue(program: Command): void {
411
420
  interactive: !!opts.editor && ctx.isTTY,
412
421
  editorRequested: !!opts.editor,
413
422
  });
414
- const created = await svc.createIssue(
423
+ const plan = await svc.prepareIssueCreate(
415
424
  ctx.client,
416
425
  {
417
426
  title,
418
427
  description,
419
428
  team: opts.team ?? ctx.defaultTeam,
420
429
  assignee: opts.start ? (opts.assignee ?? "me") : opts.assignee,
430
+ delegate: opts.delegate,
431
+ clearDelegate: opts.clearDelegate,
421
432
  state: opts.state,
422
433
  priority: opts.priority,
423
434
  label: opts.label,
@@ -433,7 +444,16 @@ export function registerIssue(program: Command): void {
433
444
  },
434
445
  ctx.defaultTeam,
435
446
  );
447
+ if (mode.dryRun) {
448
+ emitMutationPreview(ctx, plan);
449
+ return;
450
+ }
451
+ const created = await svc.executeIssueCreate(ctx.client, plan);
436
452
  if (!opts.start) {
453
+ if (delegationChanged) {
454
+ await emitDelegationResult(ctx, created, plan, mode.fullResult, "Created");
455
+ return;
456
+ }
437
457
  ctx.output.emit(
438
458
  { id: created.id, identifier: created.identifier, url: created.url },
439
459
  () => ctx.output.success(`Created ${created.identifier}: ${created.url}`),
@@ -448,6 +468,16 @@ export function registerIssue(program: Command): void {
448
468
  // (dirty tree, invalid ref, …), the issue remains in its existing state.
449
469
  const branchResult = isGitRepo() ? checkoutBranch(created.branchName) : undefined;
450
470
  await svc.moveIssueState(ctx.client, created, { move: moved });
471
+ if (delegationChanged) {
472
+ await emitDelegationResult(ctx, created, plan, mode.fullResult, "Created");
473
+ if (branchResult)
474
+ ctx.output.success(
475
+ `${branchResult.created ? "Created and checked out" : "Checked out"} ${branchResult.branch}`,
476
+ );
477
+ else ctx.output.info(`Branch name: ${created.branchName}`);
478
+ if (moved) ctx.output.success(`Moved ${created.identifier} → started`);
479
+ return;
480
+ }
451
481
  ctx.output.emit(
452
482
  {
453
483
  id: created.id,
@@ -471,6 +501,7 @@ export function registerIssue(program: Command): void {
471
501
  );
472
502
  addAliasOption(create, "--due-date <date>", "--due");
473
503
  addAliasOption(create, "--no-use-default-template", "--no-default-template");
504
+ addAliasOption(create, "--read-back", "--full-result");
474
505
 
475
506
  // update ------------------------------------------------------------------
476
507
  const update = issue
@@ -485,6 +516,7 @@ export function registerIssue(program: Command): void {
485
516
  // lookup. (addGlobalOptions leaves a locally-declared global alone.)
486
517
  .option("-t, --team <key>", "move the issue to another team (changes its identifier)")
487
518
  .option("-a, --assignee <who>", "assignee (me|email|name|id)")
519
+ .option("--delegate <agent>", "delegate to an eligible agent app user (name or id)")
488
520
  .option("-s, --state <name>", "workflow state name or type")
489
521
  .option("-P, --priority <0-4>", "priority", parsePriority)
490
522
  .option("-p, --project <name>", "project name or id")
@@ -497,13 +529,18 @@ export function registerIssue(program: Command): void {
497
529
  .option("--add-label <name>", "add a label (repeatable)", parseList)
498
530
  .option("--remove-label <name>", "remove a label (repeatable)", parseList)
499
531
  .option("--unassign", "clear the assignee")
532
+ .option("--clear-delegate", "remove the agent delegate")
500
533
  .option("--clear-cycle", "remove the issue from its cycle")
534
+ .option("--dry-run", "resolve and show the delegation mutation input without writing")
535
+ .option("--full-result", "read back and verify the resulting delegate")
501
536
  .addHelpText(
502
537
  "after",
503
538
  [
504
539
  "",
505
540
  "Examples:",
506
541
  " linear issue update TES-42 --state 'In Progress' --assignee me",
542
+ " linear issue update TES-42 --delegate Codex",
543
+ " linear issue update TES-42 --clear-delegate",
507
544
  " linear issue update --priority 1 --add-label regression # id from branch",
508
545
  " linear issue update TES-42 --cycle current --json",
509
546
  " linear issue update TES-42 --team ENG # move to another team (new identifier)",
@@ -511,18 +548,21 @@ export function registerIssue(program: Command): void {
511
548
  )
512
549
  .action(
513
550
  action(async (ctx: Context, opts, idArg?: string) => {
551
+ const delegationChanged = opts.delegate !== undefined || opts.clearDelegate === true;
552
+ const mode = delegationMutationMode(opts, delegationChanged);
514
553
  const description = resolveBody({
515
554
  arg: opts.description,
516
555
  file: opts.descriptionFile,
517
556
  interactive: false,
518
557
  });
519
- const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
558
+ const plan = await svc.prepareIssueUpdate(ctx.client, requireId(idArg, ctx.defaultTeam), {
520
559
  title: opts.title,
521
560
  description,
522
561
  // Only the explicit flag moves an issue — never `ctx.defaultTeam`, or
523
562
  // every update in a repo with a configured team would be a team move.
524
563
  team: opts.team,
525
564
  assignee: opts.assignee,
565
+ delegate: opts.delegate,
526
566
  state: opts.state,
527
567
  priority: opts.priority,
528
568
  project: opts.project,
@@ -535,8 +575,18 @@ export function registerIssue(program: Command): void {
535
575
  addLabel: opts.addLabel,
536
576
  removeLabel: opts.removeLabel,
537
577
  unassign: opts.unassign,
578
+ clearDelegate: opts.clearDelegate,
538
579
  clearCycle: opts.clearCycle,
539
580
  });
581
+ if (mode.dryRun) {
582
+ emitMutationPreview(ctx, plan);
583
+ return;
584
+ }
585
+ const updated = await svc.executeIssueUpdate(ctx.client, plan);
586
+ if (delegationChanged) {
587
+ await emitDelegationResult(ctx, updated, plan, mode.fullResult, "Updated");
588
+ return;
589
+ }
540
590
  ctx.output.emit(
541
591
  { id: updated.id, identifier: updated.identifier, url: updated.url },
542
592
  () => {
@@ -555,6 +605,7 @@ export function registerIssue(program: Command): void {
555
605
  }),
556
606
  );
557
607
  addAliasOption(update, "--due-date <date>", "--due");
608
+ addAliasOption(update, "--read-back", "--full-result");
558
609
 
559
610
  // assign / state ----------------------------------------------------------
560
611
  issue
@@ -572,6 +623,61 @@ export function registerIssue(program: Command): void {
572
623
  }),
573
624
  );
574
625
 
626
+ const delegate = issue
627
+ .command("delegate [idOrAgent] [agent]")
628
+ .description(
629
+ "Delegate an issue to an eligible agent, or --clear it. Issue defaults to the branch.",
630
+ )
631
+ .option("--clear", "remove the agent delegate")
632
+ .option("--dry-run", "resolve and show the exact delegateId without writing")
633
+ .option("--full-result", "read back the full issue and verify the resulting delegate")
634
+ .addHelpText(
635
+ "after",
636
+ [
637
+ "",
638
+ "Examples:",
639
+ " linear issue delegate TES-42 Codex",
640
+ " linear issue delegate Codex # issue from branch",
641
+ " linear issue delegate <agent-user-uuid> # issue from branch",
642
+ " linear issue delegate TES-42 --clear",
643
+ " linear issue delegate --clear # issue from branch",
644
+ "",
645
+ "Delegating may trigger an Agent Session/webhook. Clearing the delegate does not",
646
+ "claim to cancel a session that is already running.",
647
+ ].join("\n"),
648
+ )
649
+ .action(
650
+ action(async (ctx: Context, opts, a?: string, b?: string) => {
651
+ const mode = delegationMutationMode(opts, true);
652
+ let idArg: string | undefined;
653
+ let change: svc.UpdateOptions;
654
+ if (opts.clear) {
655
+ if (b !== undefined || (a !== undefined && !ISSUE_ID_RE.test(a))) {
656
+ throw usageError("Pass either an agent or --clear, not both.");
657
+ }
658
+ idArg = a;
659
+ change = { clearDelegate: true };
660
+ } else {
661
+ const parsed = delegateOperands(a, b);
662
+ idArg = parsed.idArg;
663
+ change = { delegate: parsed.value };
664
+ }
665
+ const plan = await svc.prepareIssueUpdate(
666
+ ctx.client,
667
+ requireId(idArg, ctx.defaultTeam),
668
+ change,
669
+ "issue.delegate",
670
+ );
671
+ if (mode.dryRun) {
672
+ emitMutationPreview(ctx, plan);
673
+ return;
674
+ }
675
+ const updated = await svc.executeIssueUpdate(ctx.client, plan);
676
+ await emitDelegationResult(ctx, updated, plan, mode.fullResult, "Delegated");
677
+ }),
678
+ );
679
+ addAliasOption(delegate, "--read-back", "--full-result");
680
+
575
681
  issue
576
682
  .command("state [idOrState] [state]")
577
683
  .description("Move an issue to a workflow state. Issue defaults to the branch.")
@@ -588,14 +694,111 @@ export function registerIssue(program: Command): void {
588
694
  );
589
695
 
590
696
  // label -------------------------------------------------------------------
591
- issue
697
+ const labelCommand = issue
592
698
  .command("label [id]")
593
- .description("Add/remove labels on an issue")
699
+ .description("Add, remove, or replace one member of a label group on an issue")
594
700
  .option("--add <name>", "add a label (repeatable)", parseList)
595
701
  .option("--remove <name>", "remove a label (repeatable)", parseList)
702
+ .option(
703
+ "--set-group <group=label>",
704
+ "replace a group's direct member (repeatable; exact name or UUID)",
705
+ collectArray,
706
+ )
707
+ .option("--dry-run", "resolve and show the exact relative-label mutation without writing")
708
+ .option("--full-result", "read back and verify the resulting group labels")
709
+ .addHelpText(
710
+ "after",
711
+ [
712
+ "",
713
+ "Examples:",
714
+ " linear issue label TES-42 --add regression",
715
+ " linear issue label TES-42 --set-group 'Team=QA'",
716
+ " linear issue label TES-42 --set-group 'Team=QA' --set-group 'Issue Type=Bug' --json",
717
+ "",
718
+ "--set-group splits on the first '='. Quote the whole value when either name has spaces",
719
+ "or shell metacharacters. '=' needs no escaping in the label name; use the group UUID",
720
+ "when the group name itself contains '='. Group and label names match exactly.",
721
+ "",
722
+ "Concurrency: the command reads current labels, then sends one addedLabelIds/removedLabelIds",
723
+ "update. Unrelated concurrent label changes are preserved, but another change to the same",
724
+ "group can race between that read and update; --full-result verifies the state afterward.",
725
+ ].join("\n"),
726
+ )
596
727
  .action(
597
728
  action(async (ctx: Context, opts, idArg?: string) => {
598
- if (!opts.add && !opts.remove) throw usageError("Pass --add and/or --remove.");
729
+ const setGroup: string[] | undefined = opts.setGroup;
730
+ const dryRun = opts.dryRun === true;
731
+ const fullResult = readAlias<boolean>(opts, "--full-result", "--read-back") === true;
732
+ if (dryRun && fullResult) {
733
+ throw usageError("Pass either --dry-run or --full-result, not both.");
734
+ }
735
+ if (setGroup?.length) {
736
+ if (opts.add || opts.remove) {
737
+ throw usageError("Pass either --set-group or --add/--remove, not both.");
738
+ }
739
+ const plan = await svc.prepareIssueLabelGroupUpdate(
740
+ ctx.client,
741
+ requireId(idArg, ctx.defaultTeam),
742
+ setGroup,
743
+ );
744
+ if (dryRun) {
745
+ emitIssueLabelGroupPreview(ctx, plan);
746
+ return;
747
+ }
748
+ const mutationSent = await svc.executeIssueLabelGroupUpdate(ctx.client, plan);
749
+ const receipt = svc.issueLabelGroupReceipt(plan, mutationSent);
750
+ if (fullResult) {
751
+ let detail: svc.IssueDetail;
752
+ let actualLabels: svc.IssueLabelRef[];
753
+ try {
754
+ detail = await svc.getIssueDetail(ctx.client, plan.target.id, {
755
+ includeComments: false,
756
+ });
757
+ actualLabels = await svc.getIssueLabelRefs(ctx.client, plan.target.id);
758
+ } catch (err) {
759
+ const normalized = normalizeError(err);
760
+ throw new CliError(
761
+ mutationSent
762
+ ? `Issue mutation succeeded, but label-group read-back failed: ${normalized.message}. The write may have committed.`
763
+ : `Label-group read-back failed after the no-op: ${normalized.message}. No mutation was sent.`,
764
+ normalized.code,
765
+ normalized.detail,
766
+ "Re-read the issue before retrying the mutation.",
767
+ );
768
+ }
769
+ svc.verifyIssueLabelGroups(
770
+ plan,
771
+ actualLabels.map((label) => label.id),
772
+ mutationSent,
773
+ );
774
+ ctx.output.emit(
775
+ { receipt, resource: detail, priorState: plan.priorState, verified: true },
776
+ () => {
777
+ ctx.output.success(
778
+ mutationSent
779
+ ? `Updated label groups on ${plan.target.identifier}`
780
+ : `Label groups already satisfied on ${plan.target.identifier}; no mutation sent`,
781
+ );
782
+ ctx.output.success("Verified label groups by authoritative read-back");
783
+ },
784
+ );
785
+ return;
786
+ }
787
+ ctx.output.emit(receipt, () => {
788
+ ctx.output.success(
789
+ mutationSent
790
+ ? `Updated label groups on ${plan.target.identifier}`
791
+ : `Label groups already satisfied on ${plan.target.identifier}; no mutation sent`,
792
+ );
793
+ });
794
+ return;
795
+ }
796
+ if (dryRun || fullResult) {
797
+ throw usageError("--dry-run/--full-result currently require --set-group.");
798
+ }
799
+ if (!opts.add && !opts.remove) {
800
+ throw usageError("Pass --add, --remove, or --set-group GROUP=LABEL.");
801
+ }
599
802
  const updated = await svc.updateIssue(ctx.client, requireId(idArg, ctx.defaultTeam), {
600
803
  addLabel: opts.add,
601
804
  removeLabel: opts.remove,
@@ -605,6 +808,7 @@ export function registerIssue(program: Command): void {
605
808
  );
606
809
  }),
607
810
  );
811
+ addAliasOption(labelCommand, "--read-back", "--full-result");
608
812
 
609
813
  // comment / comments ------------------------------------------------------
610
814
  const comment = issue
@@ -1062,6 +1266,89 @@ async function runBulkIssueMutation(
1062
1266
  if (failures > 0) process.exitCode = 1;
1063
1267
  }
1064
1268
 
1269
+ function delegationMutationMode(
1270
+ opts: Record<string, any>,
1271
+ delegationChanged: boolean,
1272
+ ): { dryRun: boolean; fullResult: boolean } {
1273
+ const dryRun = opts.dryRun === true;
1274
+ const fullResult = readAlias<boolean>(opts, "--full-result", "--read-back") === true;
1275
+ if (dryRun && fullResult) {
1276
+ throw usageError("Pass either --dry-run or --full-result, not both.");
1277
+ }
1278
+ // #42 owns general mutation preview/read-back. This bounded slice exposes
1279
+ // the modes only when #45's delegateId is actually part of the mutation.
1280
+ if ((dryRun || fullResult) && !delegationChanged) {
1281
+ throw usageError("--dry-run/--full-result currently require --delegate or --clear-delegate.");
1282
+ }
1283
+ return { dryRun, fullResult };
1284
+ }
1285
+
1286
+ function emitMutationPreview(ctx: Context, plan: svc.IssueMutationPlan): void {
1287
+ const value = {
1288
+ operation: plan.operation,
1289
+ dryRun: true,
1290
+ target: plan.target,
1291
+ input: plan.input,
1292
+ };
1293
+ ctx.output.detail(value, [
1294
+ ["Operation", plan.operation],
1295
+ ["Dry run", "yes (no mutation sent)"],
1296
+ ["Target", plan.target?.identifier ?? null],
1297
+ ["Input", `\n${JSON.stringify(plan.input, null, 2)}`],
1298
+ ]);
1299
+ }
1300
+
1301
+ function emitIssueLabelGroupPreview(ctx: Context, plan: svc.IssueLabelGroupPlan): void {
1302
+ const value = {
1303
+ operation: plan.operation,
1304
+ dryRun: true,
1305
+ target: plan.target,
1306
+ input: plan.input,
1307
+ changed: plan.changed,
1308
+ mutationSent: false,
1309
+ groups: plan.groups,
1310
+ };
1311
+ ctx.output.detail(value, [
1312
+ ["Operation", plan.operation],
1313
+ ["Dry run", "yes (no mutation sent)"],
1314
+ ["Target", plan.target.identifier],
1315
+ ["Changed", plan.changed ? "yes" : "no"],
1316
+ ["Input", `\n${JSON.stringify(plan.input, null, 2)}`],
1317
+ ]);
1318
+ }
1319
+
1320
+ async function emitDelegationResult(
1321
+ ctx: Context,
1322
+ issue: { id: string; identifier: string },
1323
+ plan: svc.IssueMutationPlan,
1324
+ fullResult: boolean,
1325
+ verb: string,
1326
+ ): Promise<void> {
1327
+ // Read through the same canonical mapper as `issue view`; this is both the
1328
+ // relationship receipt source and the full-result verification boundary.
1329
+ let detail: svc.IssueDetail;
1330
+ try {
1331
+ detail = await svc.getIssueDetail(ctx.client, issue.id, { includeComments: false });
1332
+ } catch (err) {
1333
+ const normalized = normalizeError(err);
1334
+ throw new CliError(
1335
+ `Issue mutation succeeded, but delegation read-back failed: ${normalized.message}. The write may have committed.`,
1336
+ normalized.code,
1337
+ normalized.detail,
1338
+ "Re-read the issue before retrying the mutation.",
1339
+ );
1340
+ }
1341
+ svc.verifyIssueDelegate(detail, plan.input);
1342
+ const receipt = svc.issueDelegationReceipt(detail);
1343
+ const value = fullResult ? { receipt, resource: detail, verified: true } : receipt;
1344
+ ctx.output.emit(value, () => {
1345
+ ctx.output.success(`${verb} ${detail.identifier}`);
1346
+ ctx.output.info(`Assignee: ${detail.assignee?.displayName ?? "none"}`);
1347
+ ctx.output.info(`Delegate: ${detail.delegate?.displayName ?? "none"}`);
1348
+ if (fullResult) ctx.output.success("Verified delegate by authoritative read-back");
1349
+ });
1350
+ }
1351
+
1065
1352
  /**
1066
1353
  * Render a single issue's detail block. Shared by `issue view` and the bare
1067
1354
  * `linear` command so `linear --json` === `issue view <id> --json`.
@@ -1088,6 +1375,7 @@ export async function renderIssueDetail(
1088
1375
  ["State", detail.state?.name ?? null],
1089
1376
  ["Priority", detail.priorityLabel],
1090
1377
  ["Assignee", detail.assignee?.displayName ?? null],
1378
+ ["Delegate", detail.delegate?.displayName ?? null],
1091
1379
  ["Team", team ? `${team.key} ${team.name}` : null],
1092
1380
  ["Project", detail.project?.name ?? null],
1093
1381
  ["Milestone", detail.milestone?.name ?? null],
@@ -1240,6 +1528,19 @@ function oneOrTwo(
1240
1528
  return { value: a };
1241
1529
  }
1242
1530
 
1531
+ /**
1532
+ * A lone UUID on `issue delegate` is the agent UUID, because a target issue
1533
+ * without an agent is never a complete mutation. The issue therefore comes
1534
+ * from the branch; two operands remain the explicit issue + agent form.
1535
+ */
1536
+ export function delegateOperands(
1537
+ a: string | undefined,
1538
+ b: string | undefined,
1539
+ ): { idArg?: string; value: string } {
1540
+ if (a !== undefined && b === undefined && isUuid(a)) return { value: a };
1541
+ return oneOrTwo(a, b, "agent");
1542
+ }
1543
+
1243
1544
  /**
1244
1545
  * `oneOrTwo`'s sibling for `issue comment [id] [body]`, where the value may
1245
1546
  * legitimately be absent (it can come from --body-file or $EDITOR): a lone
@@ -12,7 +12,7 @@ import {
12
12
  writeCredential,
13
13
  probeKeyringCredential,
14
14
  adoptKeyringCredential,
15
- removeCredential,
15
+ removeCredentialWithMetadata,
16
16
  removeOAuthCredential,
17
17
  setDefaultWorkspace,
18
18
  listCredentials,
@@ -24,6 +24,7 @@ import {
24
24
  defaultProjectConfigPath,
25
25
  assertSettableKey,
26
26
  setConfigKey,
27
+ setWorkspaceTeam,
27
28
  initProjectConfig,
28
29
  SETTABLE_KEYS,
29
30
  type SettableKey,
@@ -33,6 +34,7 @@ import { readStdinSync } from "../lib/body.js";
33
34
  import { authError, normalizeError, usageError } from "../lib/errors.js";
34
35
  import { confirmDestructive, promptSelect } from "../lib/prompt.js";
35
36
  import { listTeams } from "../services/team.js";
37
+ import { resolveTeam } from "../lib/resolve.js";
36
38
  import { ISSUE_SORTS } from "../services/issue.js";
37
39
  import { firstTeam, type Context } from "../context.js";
38
40
  import { openUrl } from "../lib/open.js";
@@ -105,6 +107,31 @@ export function setAuthValidationClientFactoryForTests(
105
107
  authValidationClientFactory = factory ?? defaultAuthValidationClientFactory;
106
108
  }
107
109
 
110
+ type WorkspaceTeamValidator = (ctx: Context, workspace: string, team: string) => Promise<string>;
111
+
112
+ /** Validate that an effective credential and team both belong to the requested workspace. */
113
+ export const validateWorkspaceTeam: WorkspaceTeamValidator = async (ctx, workspace, team) => {
114
+ const [organization, resolved] = await Promise.all([
115
+ withRetry(() => ctx.client.organization),
116
+ resolveTeam(ctx.client, team, undefined),
117
+ ]);
118
+ if (organization.urlKey !== workspace) {
119
+ throw usageError(
120
+ `--workspace '${workspace}' does not match the credential's workspace '${organization.urlKey}'.`,
121
+ );
122
+ }
123
+ return resolved.key;
124
+ };
125
+
126
+ let workspaceTeamValidator = validateWorkspaceTeam;
127
+
128
+ /** Test seam for profile-team validation; production always checks Linear. */
129
+ export function setWorkspaceTeamValidatorForTests(
130
+ validator: WorkspaceTeamValidator | undefined,
131
+ ): void {
132
+ workspaceTeamValidator = validator ?? validateWorkspaceTeam;
133
+ }
134
+
108
135
  /** Merge the local and global login spellings without silently choosing one. */
109
136
  export function selectLoginKey(
110
137
  localKey: string | undefined,
@@ -529,7 +556,7 @@ export function registerMeta(program: Command): void {
529
556
  const local =
530
557
  entry?.credentialType === "oauth-user"
531
558
  ? removeOAuthCredential(slug)
532
- : { removed: removeCredential(slug), fallbackCredentialType: null };
559
+ : { ...removeCredentialWithMetadata(slug), fallbackCredentialType: null };
533
560
  ctx.output.emit(
534
561
  {
535
562
  success: true,
@@ -537,11 +564,12 @@ export function registerMeta(program: Command): void {
537
564
  removed: local.removed,
538
565
  revocation,
539
566
  fallbackCredentialType: local.fallbackCredentialType,
567
+ teamMetadataRemoved: local.teamMetadataRemoved,
540
568
  },
541
569
  () =>
542
570
  local.removed
543
571
  ? ctx.output.success(
544
- `Removed workspace '${slug}'.${revocation === "revoked" ? " OAuth access was revoked." : revocation === "already-revoked" ? " OAuth access was already revoked." : ""}${local.fallbackCredentialType ? " The existing personal API-key profile is active again." : ""}`,
572
+ `Removed workspace '${slug}'.${revocation === "revoked" ? " OAuth access was revoked." : revocation === "already-revoked" ? " OAuth access was already revoked." : ""}${local.fallbackCredentialType ? " The existing personal API-key profile is active again." : ""}${local.teamMetadataRemoved ? " Its default team metadata was removed." : ""}`,
545
573
  )
546
574
  : ctx.output.info(`No credential for workspace '${slug}' to remove.`),
547
575
  );
@@ -589,6 +617,7 @@ export function registerMeta(program: Command): void {
589
617
  accessToken: redactKey(c.accessToken),
590
618
  accessTokenSource: c.accessTokenSource,
591
619
  credentialWorkspace: c.credentialWorkspace ?? null,
620
+ workspaceProfile: c.workspaceProfile ?? null,
592
621
  team: c.team ?? null,
593
622
  workspace: c.workspace ?? null,
594
623
  sort: c.sort,
@@ -601,6 +630,7 @@ export function registerMeta(program: Command): void {
601
630
  ["API key", `${redactKey(c.apiKey)} (${c.apiKeySource})`],
602
631
  ["OAuth access token", `${redactKey(c.accessToken)} (${c.accessTokenSource})`],
603
632
  ["Credential workspace", c.credentialWorkspace ?? "(none)"],
633
+ ["Workspace profile", c.workspaceProfile ?? "(none)"],
604
634
  ["Team", show("team", c.team)],
605
635
  ["Workspace", show("workspace", c.workspace)],
606
636
  ["Sort", show("sort", c.sort)],
@@ -656,8 +686,8 @@ export function registerMeta(program: Command): void {
656
686
  // set ---------------------------------------------------------------------
657
687
  config
658
688
  .command("set <key> <value>")
659
- .description(`Set one project setting (${SETTABLE_KEYS.join(", ")}) in the project config`)
660
- .option("--user", "write the user config (~/.config/linear/config.toml) instead")
689
+ .description(`Set one project or user setting (${SETTABLE_KEYS.join(", ")})`)
690
+ .option("--user", "write the user config (team + --workspace targets that workspace profile)")
661
691
  .option("--path <file>", "write this file instead of the project config in effect")
662
692
  .action(
663
693
  action(async (ctx: Context, opts, key: string, value: string) => {
@@ -676,10 +706,26 @@ export function registerMeta(program: Command): void {
676
706
  // here — or a fresh .linear.toml at the git root if there is none.
677
707
  path = ctx.config.projectConfigPath ?? defaultProjectConfigPath();
678
708
  }
679
- const spelling = setConfigKey(path, key, clean, { mode });
680
- ctx.output.emit({ success: true, path, key: spelling, value: clean }, () =>
681
- ctx.output.success(`Set ${spelling} = ${JSON.stringify(clean)} in ${path}`),
682
- );
709
+ const profileWorkspace = opts.user && key === "team" ? ctx.options.workspace : undefined;
710
+ if (profileWorkspace) {
711
+ let team = clean;
712
+ if (ctx.config.apiKey || ctx.config.accessToken) {
713
+ team = await workspaceTeamValidator(ctx, profileWorkspace, clean);
714
+ }
715
+ path = setWorkspaceTeam(profileWorkspace, team);
716
+ ctx.output.emit(
717
+ { success: true, path, key: "team", value: team, workspace: profileWorkspace },
718
+ () =>
719
+ ctx.output.success(
720
+ `Set team = ${JSON.stringify(team)} for workspace '${profileWorkspace}' in ${path}`,
721
+ ),
722
+ );
723
+ } else {
724
+ const spelling = setConfigKey(path, key, clean, { mode });
725
+ ctx.output.emit({ success: true, path, key: spelling, value: clean }, () =>
726
+ ctx.output.success(`Set ${spelling} = ${JSON.stringify(clean)} in ${path}`),
727
+ );
728
+ }
683
729
  }),
684
730
  );
685
731
  }
@@ -9,9 +9,11 @@ import type { Context } from "../context.js";
9
9
  import * as svc from "../services/notification.js";
10
10
  import type { Column } from "../output/table.js";
11
11
  import { ExitCode } from "../lib/errors.js";
12
+ import { addIncludeArchivedOption } from "../lib/options.js";
13
+ import { lifecycleSuffix } from "../output/lifecycle.js";
12
14
 
13
15
  const ROW_COLUMNS: Column<svc.NotificationRow>[] = [
14
- { key: "type", header: "Type", value: (r) => r.type, max: 24 },
16
+ { key: "type", header: "Type", value: (r) => `${r.type}${lifecycleSuffix(r)}`, max: 36 },
15
17
  { key: "subject", header: "Subject", value: (r) => r.subject ?? "—", max: 50 },
16
18
  { key: "read", header: "Read", value: (r) => (r.read ? "✓" : "•") },
17
19
  { key: "createdAt", header: "Created", value: (r) => r.createdAt.slice(0, 10) },
@@ -24,17 +26,17 @@ export function registerNotification(program: Command): void {
24
26
  .description("Work with your notifications");
25
27
 
26
28
  // list --------------------------------------------------------------------
27
- notification
29
+ const list = notification
28
30
  .command("list")
29
31
  .alias("ls")
30
32
  .description("List your notifications")
31
- .option("--include-archived", "include archived notifications")
32
33
  .action(
33
34
  action(async (ctx: Context, opts) => {
34
35
  const rows = await svc.listNotifications(ctx.client, ctx.limit, !!opts.includeArchived);
35
36
  ctx.output.list(rows, ROW_COLUMNS, rows);
36
37
  }),
37
38
  );
39
+ addIncludeArchivedOption(list);
38
40
 
39
41
  // read --------------------------------------------------------------------
40
42
  notification