linear-sdk-cli 0.3.3 → 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.
- package/CHANGELOG.md +15 -0
- package/COVERAGE.md +13 -9
- package/MIGRATING.md +6 -0
- package/README.md +280 -86
- package/package.json +5 -5
- package/skills/linear-sdk-cli/SKILL.md +10 -0
- package/skills/linear-sdk-cli/references/auth.md +1 -0
- package/skills/linear-sdk-cli/references/config.md +10 -7
- package/skills/linear-sdk-cli/references/custom-view.md +205 -0
- package/skills/linear-sdk-cli/references/initiative.md +10 -5
- package/skills/linear-sdk-cli/references/issue.md +222 -77
- package/skills/linear-sdk-cli/references/notification.md +3 -3
- package/skills/linear-sdk-cli/references/project.md +9 -4
- package/src/cli.ts +2 -0
- package/src/commands/custom-view.ts +279 -0
- package/src/commands/initiative.ts +23 -6
- package/src/commands/issue.ts +313 -12
- package/src/commands/meta.ts +55 -9
- package/src/commands/notification.ts +5 -3
- package/src/commands/project.ts +21 -3
- package/src/config.ts +111 -10
- package/src/lib/delegation.ts +240 -0
- package/src/lib/json-input.ts +46 -0
- package/src/lib/options.ts +23 -3
- package/src/lib/output-shapes.ts +102 -3
- package/src/output/lifecycle.ts +10 -0
- package/src/services/custom-view.ts +376 -0
- package/src/services/initiative.ts +12 -3
- package/src/services/issue.ts +498 -21
- package/src/services/project.ts +14 -3
package/src/commands/issue.ts
CHANGED
|
@@ -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 ?? ""}${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
package/src/commands/meta.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
writeCredential,
|
|
13
13
|
probeKeyringCredential,
|
|
14
14
|
adoptKeyringCredential,
|
|
15
|
-
|
|
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
|
-
: {
|
|
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(", ")})
|
|
660
|
-
.option("--user", "write the user config (
|
|
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
|
|
680
|
-
|
|
681
|
-
|
|
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
|
|
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
|