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.
@@ -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
- const data: any = await withRetry(() =>
781
- (client as any).client.rawRequest(DETAIL_QUERY, {
782
- filter,
783
- includeComments: opts.includeComments !== false,
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
- /** Build an IssueCreateInput, resolving every human reference to an id. */
871
- export async function createIssue(
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 unwrapMutation(
910
- withRetry(() => client.createIssue(input as any)),
911
- "issue",
912
- "Issue creation",
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
- export async function updateIssue(client: LinearClient, idArg: string, opts: UpdateOptions) {
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
- return unwrapMutation(
994
- withRetry(() => client.updateIssue(issue.id, input as any)),
995
- "issue",
996
- "Issue update",
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) {