linear-sdk-cli 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/COVERAGE.md +8 -3
- package/MIGRATING.md +6 -0
- package/README.md +193 -37
- package/package.json +8 -7
- package/skills/linear-sdk-cli/SKILL.md +11 -0
- package/skills/linear-sdk-cli/references/auth.md +4 -1
- package/skills/linear-sdk-cli/references/comment.md +2 -0
- package/skills/linear-sdk-cli/references/config.md +10 -7
- package/skills/linear-sdk-cli/references/initiative.md +10 -5
- package/skills/linear-sdk-cli/references/issue.md +224 -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 +1 -0
- package/src/commands/comment.ts +9 -7
- package/src/commands/initiative.ts +23 -6
- package/src/commands/issue.ts +313 -12
- package/src/commands/meta.ts +85 -13
- package/src/commands/notification.ts +5 -3
- package/src/commands/open.ts +2 -1
- package/src/commands/project.ts +21 -3
- package/src/config.ts +124 -39
- package/src/context.ts +21 -1
- package/src/lib/action.ts +8 -0
- package/src/lib/delegation.ts +240 -0
- package/src/lib/options.ts +17 -3
- package/src/lib/output-shapes.ts +78 -3
- package/src/lib/resolve.ts +53 -39
- package/src/output/lifecycle.ts +10 -0
- package/src/services/initiative.ts +12 -3
- package/src/services/issue.ts +498 -21
- package/src/services/project.ts +14 -3
package/src/services/issue.ts
CHANGED
|
@@ -10,8 +10,14 @@ import type { ResolvedConfig } from "../config.js";
|
|
|
10
10
|
import { withRetry } from "../client.js";
|
|
11
11
|
import { shape } from "../lib/shape.js";
|
|
12
12
|
import { collect, collectRawQuery } from "../lib/pagination.js";
|
|
13
|
-
import { usageError, notFound } from "../lib/errors.js";
|
|
13
|
+
import { CliError, usageError, notFound, ambiguous } from "../lib/errors.js";
|
|
14
14
|
import { assertMutation, unwrapMutation } from "../lib/mutation.js";
|
|
15
|
+
import {
|
|
16
|
+
delegationFeatureError,
|
|
17
|
+
isDelegationReadUnavailable,
|
|
18
|
+
resolveDelegate,
|
|
19
|
+
type DelegateRef,
|
|
20
|
+
} from "../lib/delegation.js";
|
|
15
21
|
import {
|
|
16
22
|
resolveUserId,
|
|
17
23
|
resolveProjectId,
|
|
@@ -564,6 +570,7 @@ export interface IssueDetail {
|
|
|
564
570
|
canceledAt: string | null;
|
|
565
571
|
state: { id: string; name: string; type: string } | null;
|
|
566
572
|
assignee: { id: string; displayName: string; email: string } | null;
|
|
573
|
+
delegate: DelegateRef | null;
|
|
567
574
|
team: { id: string; key: string; name: string } | null;
|
|
568
575
|
project: { id: string; name: string } | null;
|
|
569
576
|
milestone: { id: string; name: string } | null;
|
|
@@ -600,6 +607,7 @@ export const ISSUE_DETAIL_SHAPE = shape<IssueDetail>({
|
|
|
600
607
|
canceledAt: "string|null",
|
|
601
608
|
state: { nullable: { id: "string", name: "string", type: "string" } },
|
|
602
609
|
assignee: { nullable: { id: "string", displayName: "string", email: "string" } },
|
|
610
|
+
delegate: { nullable: { id: "string", displayName: "string", name: "string" } },
|
|
603
611
|
team: { nullable: { id: "string", key: "string", name: "string" } },
|
|
604
612
|
project: { nullable: { id: "string", name: "string" } },
|
|
605
613
|
milestone: { nullable: { id: "string", name: "string" } },
|
|
@@ -714,6 +722,7 @@ query CliIssueDetail($filter: IssueFilter!, $includeComments: Boolean!) {
|
|
|
714
722
|
createdAt updatedAt ${LIFECYCLE_FIELDS}
|
|
715
723
|
state { id name type }
|
|
716
724
|
assignee { id displayName email }
|
|
725
|
+
delegate { id displayName name }
|
|
717
726
|
team { id key name }
|
|
718
727
|
project { id name }
|
|
719
728
|
projectMilestone { id name }
|
|
@@ -755,6 +764,12 @@ query CliIssueDetail($filter: IssueFilter!, $includeComments: Boolean!) {
|
|
|
755
764
|
}
|
|
756
765
|
}`;
|
|
757
766
|
|
|
767
|
+
/** Ordinary issue reads remain usable against schemas that predate delegation. */
|
|
768
|
+
const DETAIL_QUERY_WITHOUT_DELEGATE = DETAIL_QUERY.replace(
|
|
769
|
+
" delegate { id displayName name }\n",
|
|
770
|
+
"",
|
|
771
|
+
);
|
|
772
|
+
|
|
758
773
|
const IDENTIFIER_RE = /^([a-zA-Z][a-zA-Z0-9]*)-(\d+)$/;
|
|
759
774
|
|
|
760
775
|
/** The IssueFilter that names exactly one issue, by UUID or by `TES-123`. */
|
|
@@ -777,12 +792,27 @@ export async function getIssueDetail(
|
|
|
777
792
|
opts: { includeComments?: boolean } = {},
|
|
778
793
|
): Promise<IssueDetail> {
|
|
779
794
|
const { filter, label } = detailFilter(idArg);
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
795
|
+
let data: any;
|
|
796
|
+
try {
|
|
797
|
+
data = await withRetry(() =>
|
|
798
|
+
(client as any).client.rawRequest(DETAIL_QUERY, {
|
|
799
|
+
filter,
|
|
800
|
+
includeComments: opts.includeComments !== false,
|
|
801
|
+
}),
|
|
802
|
+
);
|
|
803
|
+
} catch (err) {
|
|
804
|
+
if (!isDelegationReadUnavailable(err)) throw err;
|
|
805
|
+
// `delegate` belongs to Linear for Agents (Developer Preview). An older
|
|
806
|
+
// schema must not break every ordinary `issue view`; retry the established
|
|
807
|
+
// detail query and represent the unavailable relationship as null. Writes
|
|
808
|
+
// and delegate resolution do not use this fallback and still fail closed.
|
|
809
|
+
data = await withRetry(() =>
|
|
810
|
+
(client as any).client.rawRequest(DETAIL_QUERY_WITHOUT_DELEGATE, {
|
|
811
|
+
filter,
|
|
812
|
+
includeComments: opts.includeComments !== false,
|
|
813
|
+
}),
|
|
814
|
+
);
|
|
815
|
+
}
|
|
786
816
|
const n = data.data?.issues?.nodes?.[0];
|
|
787
817
|
if (!n) throw notFound(`No issue ${label}.`);
|
|
788
818
|
return {
|
|
@@ -801,6 +831,7 @@ export async function getIssueDetail(
|
|
|
801
831
|
...lifecycle(n),
|
|
802
832
|
state: n.state ?? null,
|
|
803
833
|
assignee: n.assignee ?? null,
|
|
834
|
+
delegate: n.delegate ?? null,
|
|
804
835
|
team: n.team ?? null,
|
|
805
836
|
project: n.project ?? null,
|
|
806
837
|
milestone: n.projectMilestone ?? null,
|
|
@@ -824,6 +855,45 @@ export async function getIssueDetail(
|
|
|
824
855
|
};
|
|
825
856
|
}
|
|
826
857
|
|
|
858
|
+
export interface IssueDelegationReceipt {
|
|
859
|
+
id: string;
|
|
860
|
+
identifier: string;
|
|
861
|
+
url: string;
|
|
862
|
+
assignee: IssueDetail["assignee"];
|
|
863
|
+
delegate: IssueDetail["delegate"];
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** Stable relationship receipt used whenever a command changes delegation. */
|
|
867
|
+
export function issueDelegationReceipt(detail: IssueDetail): IssueDelegationReceipt {
|
|
868
|
+
return {
|
|
869
|
+
id: detail.id,
|
|
870
|
+
identifier: detail.identifier,
|
|
871
|
+
url: detail.url,
|
|
872
|
+
assignee: detail.assignee,
|
|
873
|
+
delegate: detail.delegate,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Confirm the authoritative delegate matches the exact nullable mutation input.
|
|
879
|
+
* A mismatch happens after a successful write, so the error says the mutation
|
|
880
|
+
* may have committed rather than pretending it was rejected.
|
|
881
|
+
*/
|
|
882
|
+
export function verifyIssueDelegate(detail: IssueDetail, input: Record<string, unknown>): void {
|
|
883
|
+
if (!("delegateId" in input)) return;
|
|
884
|
+
const expected = input.delegateId ?? null;
|
|
885
|
+
const actual = detail.delegate?.id ?? null;
|
|
886
|
+
if (actual === expected) return;
|
|
887
|
+
throw new CliError(
|
|
888
|
+
`Issue mutation succeeded, but delegation read-back did not match (expected ${
|
|
889
|
+
expected ?? "no delegate"
|
|
890
|
+
}, got ${actual ?? "no delegate"}). The write may have committed.`,
|
|
891
|
+
"api",
|
|
892
|
+
{ expectedDelegateId: expected, actualDelegateId: actual },
|
|
893
|
+
"Re-read the issue before retrying the mutation.",
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
827
897
|
/** Normalize nullable comment relations while preserving thread metadata. */
|
|
828
898
|
function toIssueCommentDetail(c: any): IssueCommentDetail {
|
|
829
899
|
return {
|
|
@@ -846,6 +916,9 @@ export interface CreateOptions {
|
|
|
846
916
|
description?: string;
|
|
847
917
|
team?: string;
|
|
848
918
|
assignee?: string;
|
|
919
|
+
delegate?: string;
|
|
920
|
+
/** Explicitly create without a delegate (`delegateId: null`), distinct from omission. */
|
|
921
|
+
clearDelegate?: boolean;
|
|
849
922
|
state?: string;
|
|
850
923
|
priority?: number;
|
|
851
924
|
label?: string[];
|
|
@@ -867,12 +940,358 @@ export interface CreateOptions {
|
|
|
867
940
|
useDefaultTemplate?: boolean;
|
|
868
941
|
}
|
|
869
942
|
|
|
870
|
-
|
|
871
|
-
|
|
943
|
+
export interface IssueMutationTarget {
|
|
944
|
+
id: string;
|
|
945
|
+
identifier: string;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export interface IssueMutationPlan {
|
|
949
|
+
operation: "issue.create" | "issue.update" | "issue.delegate" | "issue.label.set-group";
|
|
950
|
+
target: IssueMutationTarget | null;
|
|
951
|
+
input: Record<string, any>;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
export interface IssueLabelRef {
|
|
955
|
+
id: string;
|
|
956
|
+
name: string;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export interface IssueLabelGroupResolution {
|
|
960
|
+
group: IssueLabelRef;
|
|
961
|
+
label: IssueLabelRef;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export interface IssueLabelGroupPriorState {
|
|
965
|
+
group: IssueLabelRef;
|
|
966
|
+
labels: IssueLabelRef[];
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
export interface IssueLabelGroupReceipt {
|
|
970
|
+
id: string;
|
|
971
|
+
identifier: string;
|
|
972
|
+
changed: boolean;
|
|
973
|
+
mutationSent: boolean;
|
|
974
|
+
groups: IssueLabelGroupResolution[];
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export interface IssueLabelGroupPlan extends IssueMutationPlan {
|
|
978
|
+
operation: "issue.label.set-group";
|
|
979
|
+
target: IssueMutationTarget;
|
|
980
|
+
changed: boolean;
|
|
981
|
+
groups: IssueLabelGroupResolution[];
|
|
982
|
+
priorState: IssueLabelGroupPriorState[];
|
|
983
|
+
currentLabelIds: string[];
|
|
984
|
+
groupMemberIds: Record<string, string[]>;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
export interface IssueLabelMetadata extends IssueLabelRef {
|
|
988
|
+
isGroup: boolean;
|
|
989
|
+
archivedAt: string | null;
|
|
990
|
+
team: { id: string; key: string; name: string } | null;
|
|
991
|
+
parent: IssueLabelRef | null;
|
|
992
|
+
inheritedFrom: IssueLabelRef | null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
interface GroupAssignment {
|
|
996
|
+
group: string;
|
|
997
|
+
label: string;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const ISSUE_LABEL_METADATA_QUERY = `
|
|
1001
|
+
query CliIssueLabelGroups($first: Int!, $after: String) {
|
|
1002
|
+
issueLabels(first: $first, after: $after, includeArchived: true) {
|
|
1003
|
+
nodes {
|
|
1004
|
+
id name isGroup archivedAt
|
|
1005
|
+
team { id key name }
|
|
1006
|
+
parent { id name }
|
|
1007
|
+
inheritedFrom { id name }
|
|
1008
|
+
}
|
|
1009
|
+
pageInfo { hasNextPage endCursor }
|
|
1010
|
+
}
|
|
1011
|
+
}`;
|
|
1012
|
+
|
|
1013
|
+
/** Parse `GROUP=LABEL`, using only the first equals sign as the separator. */
|
|
1014
|
+
export function parseIssueLabelGroupAssignment(value: string): GroupAssignment {
|
|
1015
|
+
const eq = value.indexOf("=");
|
|
1016
|
+
const group = eq < 0 ? "" : value.slice(0, eq).trim();
|
|
1017
|
+
const label = eq < 0 ? "" : value.slice(eq + 1).trim();
|
|
1018
|
+
if (!group || !label) {
|
|
1019
|
+
throw usageError(
|
|
1020
|
+
`Expected GROUP=LABEL for --set-group, got '${value}'. Quote the whole assignment when it contains spaces.`,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
return { group, label };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function labelMatches(label: IssueLabelMetadata, input: string): boolean {
|
|
1027
|
+
return isUuid(input) ? label.id === input : label.name === input;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function isUsableLabel(label: IssueLabelMetadata, teamId: string): boolean {
|
|
1031
|
+
return label.team === null || label.team.id === teamId;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function validationError(message: string): CliError {
|
|
1035
|
+
return new CliError(message, "validation");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function resolveGroup(
|
|
1039
|
+
labels: IssueLabelMetadata[],
|
|
1040
|
+
input: string,
|
|
1041
|
+
teamId: string,
|
|
1042
|
+
): IssueLabelMetadata {
|
|
1043
|
+
const matches = labels.filter((label) => labelMatches(label, input));
|
|
1044
|
+
if (matches.length === 0) throw notFound(`No issue label group exactly matching '${input}'.`);
|
|
1045
|
+
const inScope = matches.filter((label) => isUsableLabel(label, teamId));
|
|
1046
|
+
if (inScope.length === 0) {
|
|
1047
|
+
throw validationError(`Issue label group '${input}' is not usable by this issue's team.`);
|
|
1048
|
+
}
|
|
1049
|
+
const groups = inScope.filter((label) => label.isGroup);
|
|
1050
|
+
if (groups.length === 0) {
|
|
1051
|
+
throw validationError(`Issue label '${input}' is not a group container.`);
|
|
1052
|
+
}
|
|
1053
|
+
if (groups.length > 1) {
|
|
1054
|
+
throw ambiguous(`Multiple issue label groups are named '${input}'; pass the group id instead.`);
|
|
1055
|
+
}
|
|
1056
|
+
const group = groups[0]!;
|
|
1057
|
+
if (group.archivedAt) throw validationError(`Issue label group '${group.name}' is archived.`);
|
|
1058
|
+
return group;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function resolveGroupMember(
|
|
1062
|
+
labels: IssueLabelMetadata[],
|
|
1063
|
+
input: string,
|
|
1064
|
+
group: IssueLabelMetadata,
|
|
1065
|
+
teamId: string,
|
|
1066
|
+
): IssueLabelMetadata {
|
|
1067
|
+
const direct = labels.filter(
|
|
1068
|
+
(label) => label.parent?.id === group.id && labelMatches(label, input),
|
|
1069
|
+
);
|
|
1070
|
+
if (direct.length > 1) {
|
|
1071
|
+
throw ambiguous(
|
|
1072
|
+
`Multiple direct members of issue label group '${group.name}' are named '${input}'; pass the label id instead.`,
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
if (direct.length === 1) {
|
|
1076
|
+
const member = direct[0]!;
|
|
1077
|
+
if (!isUsableLabel(member, teamId)) {
|
|
1078
|
+
throw validationError(`Issue label '${member.name}' is not usable by this issue's team.`);
|
|
1079
|
+
}
|
|
1080
|
+
if (member.isGroup) {
|
|
1081
|
+
throw validationError(
|
|
1082
|
+
`Issue label '${member.name}' is a group container, not an applicable label.`,
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
if (member.archivedAt) throw validationError(`Issue label '${member.name}' is archived.`);
|
|
1086
|
+
return member;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const matches = labels.filter((label) => labelMatches(label, input));
|
|
1090
|
+
if (matches.length === 0) throw notFound(`No issue label exactly matching '${input}'.`);
|
|
1091
|
+
const inScope = matches.filter((label) => isUsableLabel(label, teamId));
|
|
1092
|
+
if (inScope.length === 0) {
|
|
1093
|
+
throw validationError(`Issue label '${input}' is not usable by this issue's team.`);
|
|
1094
|
+
}
|
|
1095
|
+
if (inScope.some((label) => label.isGroup)) {
|
|
1096
|
+
throw validationError(`Issue label '${input}' is a group container, not an applicable label.`);
|
|
1097
|
+
}
|
|
1098
|
+
const parents = [
|
|
1099
|
+
...new Set(inScope.map((label) => label.parent?.name).filter((name): name is string => !!name)),
|
|
1100
|
+
];
|
|
1101
|
+
const where = parents.length === 1 ? `; it belongs to '${parents[0]}'` : "";
|
|
1102
|
+
throw validationError(
|
|
1103
|
+
`Issue label '${input}' is not a direct member of group '${group.name}'${where}.`,
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Resolve every requested group replacement and calculate one relative label mutation.
|
|
1109
|
+
* Exported so the validation/planning boundary can be tested without an API.
|
|
1110
|
+
*/
|
|
1111
|
+
export function planIssueLabelGroupUpdate(
|
|
1112
|
+
issue: {
|
|
1113
|
+
id: string;
|
|
1114
|
+
identifier: string;
|
|
1115
|
+
teamId: string;
|
|
1116
|
+
currentLabels: IssueLabelRef[];
|
|
1117
|
+
},
|
|
1118
|
+
labels: IssueLabelMetadata[],
|
|
1119
|
+
assignments: string[],
|
|
1120
|
+
): IssueLabelGroupPlan {
|
|
1121
|
+
if (assignments.length === 0) throw usageError("Pass at least one --set-group GROUP=LABEL.");
|
|
1122
|
+
const parsed = assignments.map(parseIssueLabelGroupAssignment);
|
|
1123
|
+
const requested = new Map<string, IssueLabelGroupResolution>();
|
|
1124
|
+
|
|
1125
|
+
for (const assignment of parsed) {
|
|
1126
|
+
const group = resolveGroup(labels, assignment.group, issue.teamId);
|
|
1127
|
+
const member = resolveGroupMember(labels, assignment.label, group, issue.teamId);
|
|
1128
|
+
const previous = requested.get(group.id);
|
|
1129
|
+
if (previous) {
|
|
1130
|
+
if (previous.label.id === member.id) continue;
|
|
1131
|
+
throw usageError(
|
|
1132
|
+
`Issue label group '${group.name}' was assigned both '${previous.label.name}' and '${member.name}'; pass one target per group.`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
requested.set(group.id, {
|
|
1136
|
+
group: { id: group.id, name: group.name },
|
|
1137
|
+
label: { id: member.id, name: member.name },
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const currentLabelIds = issue.currentLabels.map((label) => label.id);
|
|
1142
|
+
const current = new Set(currentLabelIds);
|
|
1143
|
+
const addedLabelIds: string[] = [];
|
|
1144
|
+
const removedLabelIds: string[] = [];
|
|
1145
|
+
const groups = [...requested.values()];
|
|
1146
|
+
const priorState: IssueLabelGroupPriorState[] = [];
|
|
1147
|
+
const groupMemberIds: Record<string, string[]> = {};
|
|
1148
|
+
|
|
1149
|
+
for (const resolution of groups) {
|
|
1150
|
+
const members = labels.filter(
|
|
1151
|
+
(label) => label.parent?.id === resolution.group.id && !label.isGroup,
|
|
1152
|
+
);
|
|
1153
|
+
groupMemberIds[resolution.group.id] = members.map((label) => label.id);
|
|
1154
|
+
const applied = members.filter((label) => current.has(label.id));
|
|
1155
|
+
priorState.push({
|
|
1156
|
+
group: resolution.group,
|
|
1157
|
+
labels: applied.map((label) => ({ id: label.id, name: label.name })),
|
|
1158
|
+
});
|
|
1159
|
+
if (!current.has(resolution.label.id)) addedLabelIds.push(resolution.label.id);
|
|
1160
|
+
for (const label of applied) {
|
|
1161
|
+
if (label.id !== resolution.label.id) removedLabelIds.push(label.id);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const input = {
|
|
1166
|
+
addedLabelIds: [...new Set(addedLabelIds)],
|
|
1167
|
+
removedLabelIds: [...new Set(removedLabelIds)],
|
|
1168
|
+
};
|
|
1169
|
+
return {
|
|
1170
|
+
operation: "issue.label.set-group",
|
|
1171
|
+
target: { id: issue.id, identifier: issue.identifier },
|
|
1172
|
+
input,
|
|
1173
|
+
changed: input.addedLabelIds.length > 0 || input.removedLabelIds.length > 0,
|
|
1174
|
+
groups,
|
|
1175
|
+
priorState,
|
|
1176
|
+
currentLabelIds,
|
|
1177
|
+
groupMemberIds,
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Resolve an issue, its current labels, and every visible label before writing. */
|
|
1182
|
+
export async function prepareIssueLabelGroupUpdate(
|
|
1183
|
+
client: LinearClient,
|
|
1184
|
+
idArg: string,
|
|
1185
|
+
assignments: string[],
|
|
1186
|
+
): Promise<IssueLabelGroupPlan> {
|
|
1187
|
+
// Malformed syntax fails before even resolving the issue.
|
|
1188
|
+
assignments.map(parseIssueLabelGroupAssignment);
|
|
1189
|
+
const issue = await resolveIssue(client, idArg);
|
|
1190
|
+
const team = await issue.team;
|
|
1191
|
+
if (!team) throw usageError("Cannot resolve label groups without an issue team.");
|
|
1192
|
+
const currentLabels = await collectIssueLabelRefs(issue);
|
|
1193
|
+
const labels = await collectRawQuery<IssueLabelMetadata>(
|
|
1194
|
+
client as any,
|
|
1195
|
+
ISSUE_LABEL_METADATA_QUERY,
|
|
1196
|
+
{},
|
|
1197
|
+
"issueLabels",
|
|
1198
|
+
Infinity,
|
|
1199
|
+
(label) => ({
|
|
1200
|
+
id: label.id,
|
|
1201
|
+
name: label.name,
|
|
1202
|
+
isGroup: !!label.isGroup,
|
|
1203
|
+
archivedAt: label.archivedAt ?? null,
|
|
1204
|
+
team: label.team ?? null,
|
|
1205
|
+
parent: label.parent ?? null,
|
|
1206
|
+
inheritedFrom: label.inheritedFrom ?? null,
|
|
1207
|
+
}),
|
|
1208
|
+
);
|
|
1209
|
+
return planIssueLabelGroupUpdate(
|
|
1210
|
+
{
|
|
1211
|
+
id: issue.id,
|
|
1212
|
+
identifier: issue.identifier,
|
|
1213
|
+
teamId: team.id,
|
|
1214
|
+
currentLabels,
|
|
1215
|
+
},
|
|
1216
|
+
labels,
|
|
1217
|
+
assignments,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async function collectIssueLabelRefs(issue: Issue): Promise<IssueLabelRef[]> {
|
|
1222
|
+
const labels = await collect(
|
|
1223
|
+
(await withRetry(() => issue.labels({ first: 250, includeArchived: true }))) as any,
|
|
1224
|
+
Infinity,
|
|
1225
|
+
);
|
|
1226
|
+
return labels.map((label: any) => ({ id: label.id, name: label.name }));
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** Read every current label for post-mutation verification, without a fixed row cap. */
|
|
1230
|
+
export async function getIssueLabelRefs(
|
|
1231
|
+
client: LinearClient,
|
|
1232
|
+
idArg: string,
|
|
1233
|
+
): Promise<IssueLabelRef[]> {
|
|
1234
|
+
return collectIssueLabelRefs(await resolveIssue(client, idArg));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** Execute the prepared relative-label mutation, or make an explicit no-op. */
|
|
1238
|
+
export async function executeIssueLabelGroupUpdate(
|
|
1239
|
+
client: LinearClient,
|
|
1240
|
+
plan: IssueLabelGroupPlan,
|
|
1241
|
+
): Promise<boolean> {
|
|
1242
|
+
if (!plan.changed) return false;
|
|
1243
|
+
await executeIssueUpdate(client, plan);
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
export function issueLabelGroupReceipt(
|
|
1248
|
+
plan: IssueLabelGroupPlan,
|
|
1249
|
+
mutationSent: boolean,
|
|
1250
|
+
): IssueLabelGroupReceipt {
|
|
1251
|
+
return {
|
|
1252
|
+
id: plan.target.id,
|
|
1253
|
+
identifier: plan.target.identifier,
|
|
1254
|
+
changed: plan.changed,
|
|
1255
|
+
mutationSent,
|
|
1256
|
+
groups: plan.groups,
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** Verify every requested member is present and every sibling is absent. */
|
|
1261
|
+
export function verifyIssueLabelGroups(
|
|
1262
|
+
plan: IssueLabelGroupPlan,
|
|
1263
|
+
actualLabelIds: string[],
|
|
1264
|
+
mutationSent = plan.changed,
|
|
1265
|
+
): void {
|
|
1266
|
+
const actual = new Set(actualLabelIds);
|
|
1267
|
+
for (const resolution of plan.groups) {
|
|
1268
|
+
const siblings = plan.groupMemberIds[resolution.group.id] ?? [];
|
|
1269
|
+
const unexpected = siblings.filter((id) => id !== resolution.label.id && actual.has(id));
|
|
1270
|
+
if (actual.has(resolution.label.id) && unexpected.length === 0) continue;
|
|
1271
|
+
throw new CliError(
|
|
1272
|
+
`Issue label-group read-back did not match for '${resolution.group.name}'.${
|
|
1273
|
+
mutationSent ? " The write may have committed." : " No mutation was sent."
|
|
1274
|
+
}`,
|
|
1275
|
+
"api",
|
|
1276
|
+
{
|
|
1277
|
+
expectedLabelId: resolution.label.id,
|
|
1278
|
+
unexpectedLabelIds: unexpected,
|
|
1279
|
+
actualLabelIds,
|
|
1280
|
+
},
|
|
1281
|
+
"Re-read the issue before retrying the mutation.",
|
|
1282
|
+
);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/** Build the exact IssueCreateInput without sending a mutation. */
|
|
1287
|
+
export async function prepareIssueCreate(
|
|
872
1288
|
client: LinearClient,
|
|
873
1289
|
opts: CreateOptions,
|
|
874
1290
|
defaultTeamKey: string | undefined,
|
|
875
|
-
) {
|
|
1291
|
+
): Promise<IssueMutationPlan> {
|
|
1292
|
+
if (opts.delegate !== undefined && opts.clearDelegate) {
|
|
1293
|
+
throw usageError("Pass either --delegate or --clear-delegate, not both.");
|
|
1294
|
+
}
|
|
876
1295
|
const team = await resolveTeam(client, opts.team, defaultTeamKey);
|
|
877
1296
|
const input: Record<string, any> = { teamId: team.id, title: opts.title };
|
|
878
1297
|
if (opts.description !== undefined) input.description = opts.description;
|
|
@@ -880,6 +1299,10 @@ export async function createIssue(
|
|
|
880
1299
|
if (opts.estimate !== undefined) input.estimate = opts.estimate;
|
|
881
1300
|
if (opts.dueDate) input.dueDate = opts.dueDate;
|
|
882
1301
|
if (opts.assignee) input.assigneeId = await resolveUserId(client, opts.assignee);
|
|
1302
|
+
if (opts.delegate !== undefined) {
|
|
1303
|
+
input.delegateId = (await resolveDelegate(client, opts.delegate, team.id)).id;
|
|
1304
|
+
}
|
|
1305
|
+
if (opts.clearDelegate) input.delegateId = null;
|
|
883
1306
|
if (opts.state) input.stateId = await resolveStateId(client, team.id, opts.state);
|
|
884
1307
|
if (opts.label?.length) input.labelIds = await resolveLabelIds(client, opts.label, team.id);
|
|
885
1308
|
if (opts.project) input.projectId = await resolveProjectId(client, opts.project);
|
|
@@ -906,11 +1329,30 @@ export async function createIssue(
|
|
|
906
1329
|
if (opts.template) input.templateId = await resolveTemplateId(client, team.id, opts.template);
|
|
907
1330
|
else if (opts.useDefaultTemplate !== false) input.useDefaultTemplate = true;
|
|
908
1331
|
|
|
909
|
-
return
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1332
|
+
return { operation: "issue.create", target: null, input };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Execute an already-resolved issue create plan. */
|
|
1336
|
+
export async function executeIssueCreate(client: LinearClient, plan: IssueMutationPlan) {
|
|
1337
|
+
try {
|
|
1338
|
+
return await unwrapMutation(
|
|
1339
|
+
withRetry(() => client.createIssue(plan.input as any)),
|
|
1340
|
+
"issue",
|
|
1341
|
+
"Issue creation",
|
|
1342
|
+
);
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
if ("delegateId" in plan.input) throw delegationFeatureError(err);
|
|
1345
|
+
throw err;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/** Build and execute an issue create, preserving the existing service contract. */
|
|
1350
|
+
export async function createIssue(
|
|
1351
|
+
client: LinearClient,
|
|
1352
|
+
opts: CreateOptions,
|
|
1353
|
+
defaultTeamKey: string | undefined,
|
|
1354
|
+
) {
|
|
1355
|
+
return executeIssueCreate(client, await prepareIssueCreate(client, opts, defaultTeamKey));
|
|
914
1356
|
}
|
|
915
1357
|
|
|
916
1358
|
export interface UpdateOptions {
|
|
@@ -919,6 +1361,7 @@ export interface UpdateOptions {
|
|
|
919
1361
|
/** Move the issue to another team (key, name, or id). Changes its identifier. */
|
|
920
1362
|
team?: string;
|
|
921
1363
|
assignee?: string;
|
|
1364
|
+
delegate?: string;
|
|
922
1365
|
state?: string;
|
|
923
1366
|
priority?: number;
|
|
924
1367
|
project?: string;
|
|
@@ -933,16 +1376,26 @@ export interface UpdateOptions {
|
|
|
933
1376
|
removeLabel?: string[];
|
|
934
1377
|
/** Clear the assignee. Mutually exclusive with `assignee`. */
|
|
935
1378
|
unassign?: boolean;
|
|
1379
|
+
/** Clear the delegate. Mutually exclusive with `delegate`. */
|
|
1380
|
+
clearDelegate?: boolean;
|
|
936
1381
|
/** Remove the issue from its cycle. Mutually exclusive with `cycle`. */
|
|
937
1382
|
clearCycle?: boolean;
|
|
938
1383
|
}
|
|
939
1384
|
|
|
940
|
-
|
|
1385
|
+
/** Build the exact IssueUpdateInput without sending a mutation. */
|
|
1386
|
+
export async function prepareIssueUpdate(
|
|
1387
|
+
client: LinearClient,
|
|
1388
|
+
idArg: string,
|
|
1389
|
+
opts: UpdateOptions,
|
|
1390
|
+
operation: IssueMutationPlan["operation"] = "issue.update",
|
|
1391
|
+
): Promise<IssueMutationPlan> {
|
|
941
1392
|
// Contradictory pairs are a usage error rather than a silent last-one-wins.
|
|
942
1393
|
if (opts.unassign && opts.assignee)
|
|
943
1394
|
throw usageError("Pass either --assignee or --unassign, not both.");
|
|
944
1395
|
if (opts.clearCycle && opts.cycle)
|
|
945
1396
|
throw usageError("Pass either --cycle or --clear-cycle, not both.");
|
|
1397
|
+
if (opts.clearDelegate && opts.delegate !== undefined)
|
|
1398
|
+
throw usageError("Pass either --delegate or --clear-delegate, not both.");
|
|
946
1399
|
if (opts.label !== undefined && (opts.addLabel !== undefined || opts.removeLabel !== undefined))
|
|
947
1400
|
throw usageError("Pass either --label (replace all) or --add-label/--remove-label, not both.");
|
|
948
1401
|
|
|
@@ -965,6 +1418,11 @@ export async function updateIssue(client: LinearClient, idArg: string, opts: Upd
|
|
|
965
1418
|
if (opts.assignee) input.assigneeId = await resolveUserId(client, opts.assignee);
|
|
966
1419
|
// null (not undefined) is what clears a relation in Linear's update inputs.
|
|
967
1420
|
if (opts.unassign) input.assigneeId = null;
|
|
1421
|
+
if (opts.delegate !== undefined) {
|
|
1422
|
+
if (!teamId) throw usageError("Cannot resolve a delegate without an issue team.");
|
|
1423
|
+
input.delegateId = (await resolveDelegate(client, opts.delegate, teamId)).id;
|
|
1424
|
+
}
|
|
1425
|
+
if (opts.clearDelegate) input.delegateId = null;
|
|
968
1426
|
if (opts.clearCycle) input.cycleId = null;
|
|
969
1427
|
if (opts.state) {
|
|
970
1428
|
if (!teamId) throw usageError("Cannot resolve state without a team.");
|
|
@@ -987,14 +1445,33 @@ export async function updateIssue(client: LinearClient, idArg: string, opts: Upd
|
|
|
987
1445
|
|
|
988
1446
|
if (Object.keys(input).length === 0)
|
|
989
1447
|
throw usageError("Nothing to update; pass at least one field.");
|
|
1448
|
+
return {
|
|
1449
|
+
operation,
|
|
1450
|
+
target: { id: issue.id, identifier: issue.identifier },
|
|
1451
|
+
input,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/** Execute an already-resolved issue update plan. */
|
|
1456
|
+
export async function executeIssueUpdate(client: LinearClient, plan: IssueMutationPlan) {
|
|
1457
|
+
if (!plan.target) throw usageError("Issue update plan is missing its target.");
|
|
990
1458
|
// The updated issue comes from the payload, never from `issue` — falling back
|
|
991
1459
|
// to the pre-mutation entity would print "Updated TES-1" for a write the API
|
|
992
1460
|
// refused.
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1461
|
+
try {
|
|
1462
|
+
return await unwrapMutation(
|
|
1463
|
+
withRetry(() => client.updateIssue(plan.target!.id, plan.input as any)),
|
|
1464
|
+
"issue",
|
|
1465
|
+
"Issue update",
|
|
1466
|
+
);
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
if ("delegateId" in plan.input) throw delegationFeatureError(err);
|
|
1469
|
+
throw err;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
export async function updateIssue(client: LinearClient, idArg: string, opts: UpdateOptions) {
|
|
1474
|
+
return executeIssueUpdate(client, await prepareIssueUpdate(client, idArg, opts));
|
|
998
1475
|
}
|
|
999
1476
|
|
|
1000
1477
|
export async function archiveIssue(client: LinearClient, idArg: string, unarchive: boolean) {
|