@allocator-one/harness-cli 1.5.2 → 1.7.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/README.md CHANGED
@@ -53,7 +53,9 @@ the browser login attributes writes to the real user.
53
53
 
54
54
  `harness mcp` runs a stdio MCP server exposing every operation as a typed
55
55
  tool — issue_list, issue_show, issue_create, issue_update, issue_claim,
56
- issue_close, issue_comment, relation_add/rm, link_list/add/rm,
56
+ issue_close, issue_comment, issue_comment_edit, issue_comment_delete,
57
+ relation_list/create/update/delete, relation_add/rm (blocking shorthand),
58
+ link_list/add/update/rm,
57
59
  project_list/show/create/update, milestone_list/create,
58
60
  document_list/show/create/update, label_list/create, team_list,
59
61
  team_create, workflow_states, user_list, stats, upload. Read-only tools carry
@@ -69,6 +71,45 @@ Example tool call: `issue_list` with `{"ready": true, "team": "ALL"}`
69
71
  returns the ready queue as JSON; `issue_claim` with `{"id": "IO-123"}`
70
72
  claims atomically (a conflict means someone else holds it).
71
73
 
74
+ Typed issue relations are available without losing the concise blocking
75
+ workflow. `harness dep add|rm` and the `relation_add`/`relation_rm` MCP tools
76
+ remain the blocker-first shorthand; the full surface is:
77
+
78
+ ```sh
79
+ harness relation list IO-123
80
+ harness relation add IO-123 IO-456 --type related
81
+ harness relation update IO-123 IO-456 --type blocks
82
+ harness relation rm IO-123 IO-456
83
+ ```
84
+
85
+ Relation update and removal accept either the relation id from `relation list`
86
+ or a unique related issue identifier. If the same pair has more than one
87
+ relation, the CLI refuses to guess and requires the relation id. Link updates
88
+ use the same safe lookup rule by id or exact URL; the existing `link rm`
89
+ command keeps its first-match behavior for backward compatibility:
90
+
91
+ ```sh
92
+ harness link update IO-123 https://example.com/old --url https://example.com/new --title "Runbook"
93
+ ```
94
+
95
+ Issue creation resolves `--label` names within the target team first, then at
96
+ workspace scope. Missing names are created at workspace scope before the issue
97
+ is filed, and the final label ids travel in the single issue-create request, so
98
+ a preparation or permission failure cannot leave an unlabeled issue behind.
99
+ Creating a missing label requires Harness administrator standing.
100
+
101
+ Label repair is additive and repeatable; it never implicitly replaces the
102
+ whole set:
103
+
104
+ ```sh
105
+ harness update IO-123 --add-label security --add-label needs-review
106
+ harness update IO-123 --remove-label security
107
+ ```
108
+
109
+ Both update flags accept a label name or id. Additions allow only labels from
110
+ the issue's team or workspace. Removal can target an attached legacy foreign
111
+ label; when attached normalized names are ambiguous, use the label id.
112
+
72
113
  Auth resolution is identical to the CLI. Without a credential, tools return
73
114
  an error telling the agent to run `harness login` in a terminal — the
74
115
  server never opens a browser itself.
@@ -538,6 +538,10 @@ var STATUS_STATE_TYPES = {
538
538
  };
539
539
  var READY_CLAIM_ATTEMPTS = 10;
540
540
  var IDENTIFIER_FORMAT = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
541
+ var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
542
+ function isUuid(value) {
543
+ return UUID_FORMAT.test(value);
544
+ }
541
545
  function requireIdentifiers(identifiers) {
542
546
  const bad = identifiers.filter((identifier) => !IDENTIFIER_FORMAT.test(identifier));
543
547
  if (bad.length > 0) {
@@ -619,7 +623,6 @@ async function version(config) {
619
623
  ` };
620
624
  }
621
625
  async function create(ctx, opts) {
622
- const labels = wantedLabels(opts);
623
626
  if (opts.title === void 0) {
624
627
  throw new UsageError("usage: harness create --title <title> [--team <key>] \u2026");
625
628
  }
@@ -627,6 +630,8 @@ async function create(ctx, opts) {
627
630
  const team = requiredTeam(ctx, opts.team);
628
631
  const priority = optionalPriority(opts.priority);
629
632
  const dueDate = optionalDueDate(opts.dueDate);
633
+ const labels = requestedLabelNames(wantedLabels(opts));
634
+ const labelIds = await prepareCreateLabelIds(ctx, team, labels);
630
635
  let body = { title: opts.title };
631
636
  body = putOpt(body, "description", opts.description);
632
637
  body = putOpt(body, "design", opts.design);
@@ -636,23 +641,14 @@ async function create(ctx, opts) {
636
641
  body = putPresent(body, "priority", priority);
637
642
  body = putDueDate(body, dueDate);
638
643
  body = putAssignee(body, opts.assignee);
644
+ if (labelIds.length > 0) body["label_ids"] = labelIds;
639
645
  const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body);
640
646
  const issue = created.data;
641
- try {
642
- const labeled = await applyLabels(ctx, issue, labels);
643
- return {
644
- json: labeled,
645
- human: `Created ${str2(labeled["identifier"])}: ${clean(labeled["title"])}
647
+ return {
648
+ json: issue,
649
+ human: `Created ${str2(issue["identifier"])}: ${clean(issue["title"])}
646
650
  `
647
- };
648
- } catch (error) {
649
- if (error instanceof HttpError || error instanceof TransportError) {
650
- throw new CommandError(
651
- `created ${str2(issue["identifier"])}, but applying labels failed: ${errorText(error)}`
652
- );
653
- }
654
- throw error;
655
- }
651
+ };
656
652
  }
657
653
  async function update(ctx, identifier, opts) {
658
654
  requireIdentifiers([identifier]);
@@ -666,10 +662,15 @@ async function update(ctx, identifier, opts) {
666
662
  }
667
663
  const priority = optionalPriority(opts.priority);
668
664
  const dueDate = optionalDueDate(opts.dueDate);
669
- let issue = null;
665
+ const addLabels = requestedLabelInputs(opts.addLabel ?? []);
666
+ const removeLabels = requestedLabelInputs(opts.removeLabel ?? []);
667
+ const labelPlan = addLabels.length > 0 || removeLabels.length > 0 ? await prepareLabelUpdate(ctx, identifier, addLabels, removeLabels) : null;
668
+ let issue = labelPlan?.issue ?? null;
669
+ let wrote = false;
670
670
  if (opts.claim === true) {
671
671
  const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
672
672
  issue = body.data;
673
+ wrote = true;
673
674
  }
674
675
  let patch = {};
675
676
  patch = putOpt(patch, "title", opts.title);
@@ -692,15 +693,26 @@ ${opts.appendNotes}`;
692
693
  if (Object.keys(patch).length > 0) {
693
694
  const body = await ctx.client.patch(`/api/v1/issues/${identifier}`, patch);
694
695
  issue = body.data;
696
+ wrote = true;
695
697
  }
696
698
  if (opts.state !== void 0) {
697
699
  issue = await setState(ctx, identifier, opts.state);
700
+ wrote = true;
701
+ }
702
+ if (labelPlan?.changed === true) {
703
+ const body = await ctx.client.put(`/api/v1/issues/${identifier}/labels`, {
704
+ label_ids: labelPlan.ids
705
+ });
706
+ issue = body.data;
707
+ wrote = true;
698
708
  }
699
709
  if (issue === null) {
700
710
  throw new UsageError("usage: harness update <id> --claim | --title \u2026 | --state <name> | \u2026");
701
711
  }
702
- return { json: issue, human: `Updated ${str2(issue["identifier"])}
703
- ` };
712
+ const human = wrote ? `Updated ${str2(issue["identifier"])}
713
+ ` : `Labels already up to date for ${str2(issue["identifier"])}
714
+ `;
715
+ return { json: issue, human };
704
716
  }
705
717
  async function close(ctx, identifiers, opts) {
706
718
  if (identifiers.length === 0) {
@@ -778,8 +790,9 @@ async function comments(ctx, identifier) {
778
790
  if (body.data.length === 0) return { json: body.data, human: "No comments.\n" };
779
791
  const human = body.data.map((comment2) => {
780
792
  const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
793
+ const id = clean(comment2["id"] ?? "unknown id");
781
794
  const text = cleanMultiline(str2(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
782
- return `[${str2(comment2["inserted_at"])}] ${author}:
795
+ return `[${str2(comment2["inserted_at"])}] ${author} (${id}):
783
796
  ${text}
784
797
  `;
785
798
  }).join("\n");
@@ -807,6 +820,76 @@ async function dep(ctx, verb, issue, blocker) {
807
820
  }
808
821
  throw new UsageError("usage: harness dep add|rm <issue> <blocker> (the blocker blocks the issue)");
809
822
  }
823
+ var RELATION_CREATE_TYPES = [
824
+ "blocks",
825
+ "blocked_by",
826
+ "related",
827
+ "duplicate",
828
+ "duplicate_of",
829
+ "duplicated_by"
830
+ ];
831
+ var RELATION_UPDATE_TYPES = ["blocks", "blocked_by", "related", "duplicate_of"];
832
+ function requireRelationType(type, allowed, operation) {
833
+ if (!allowed.includes(type)) {
834
+ throw new UsageError(`relation ${operation} type must be one of: ${allowed.join(", ")}`);
835
+ }
836
+ }
837
+ async function findRelation(ctx, issue, idOrIssue) {
838
+ const response = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
839
+ const idMatch = response.data.find((candidate) => candidate["id"] === idOrIssue);
840
+ if (idMatch !== void 0) return idMatch;
841
+ const issueMatches = response.data.filter((candidate) => candidate["other_issue"] === idOrIssue);
842
+ if (issueMatches.length > 1) {
843
+ throw new CommandError(`multiple relations on ${issue} match ${idOrIssue}; use a relation id`);
844
+ }
845
+ if (issueMatches.length === 0) {
846
+ throw new CommandError(`no relation on ${issue} matches ${idOrIssue} (by id or related issue)`);
847
+ }
848
+ return issueMatches[0];
849
+ }
850
+ async function resolveRelationId(ctx, issue, idOrIssue) {
851
+ if (isUuid(idOrIssue)) return idOrIssue.toLowerCase();
852
+ const relation = await findRelation(ctx, issue, idOrIssue);
853
+ return String(relation["id"]);
854
+ }
855
+ async function relationList(ctx, issue) {
856
+ requireIdentifiers([issue]);
857
+ const response = await ctx.client.get(`/api/v1/issues/${issue}/relations`);
858
+ const human = response.data.length === 0 ? "No relations.\n" : table(
859
+ ["ID", "DIRECTION", "ISSUE"],
860
+ response.data.map((relation) => [
861
+ String(relation["id"] ?? ""),
862
+ String(relation["direction"] ?? ""),
863
+ String(relation["other_issue"] ?? "")
864
+ ])
865
+ );
866
+ return { json: response.data, human };
867
+ }
868
+ async function relationAdd(ctx, issue, relatedIssue, type) {
869
+ requireIdentifiers([issue, relatedIssue]);
870
+ requireRelationType(type, RELATION_CREATE_TYPES, "add");
871
+ const response = await ctx.client.post(`/api/v1/issues/${issue}/relations`, {
872
+ type,
873
+ related_issue: relatedIssue
874
+ });
875
+ return { json: response.data, human: `Related ${issue} to ${relatedIssue} as ${type}
876
+ ` };
877
+ }
878
+ async function relationUpdate(ctx, issue, idOrIssue, type) {
879
+ requireIdentifiers([issue]);
880
+ requireRelationType(type, RELATION_UPDATE_TYPES, "update");
881
+ const id = await resolveRelationId(ctx, issue, idOrIssue);
882
+ const response = await ctx.client.patch(`/api/v1/issues/${issue}/relations/${id}`, { type });
883
+ return { json: response.data, human: `Updated relation ${id} on ${issue} to ${type}
884
+ ` };
885
+ }
886
+ async function relationRm(ctx, issue, idOrIssue) {
887
+ requireIdentifiers([issue]);
888
+ const id = await resolveRelationId(ctx, issue, idOrIssue);
889
+ await ctx.client.delete(`/api/v1/issues/${issue}/relations/${id}`);
890
+ return { json: { deleted: id }, human: `Removed relation ${id} from ${issue}
891
+ ` };
892
+ }
810
893
  async function comment(ctx, identifier, textWords, mentions) {
811
894
  requireIdentifiers([identifier]);
812
895
  if (textWords.length === 0) {
@@ -818,6 +901,40 @@ async function comment(ctx, identifier, textWords, mentions) {
818
901
  return { json: response.data, human: `Commented on ${identifier}
819
902
  ` };
820
903
  }
904
+ async function editComment(ctx, identifier, commentId, textWords) {
905
+ requireIdentifiers([identifier]);
906
+ requireCommentId(commentId);
907
+ const body = textWords.join(" ");
908
+ if (body.trim() === "") {
909
+ throw new UsageError("the comment text is empty \u2014 nothing to edit");
910
+ }
911
+ const response = await ctx.client.patch(
912
+ `/api/v1/issues/${identifier}/comments/${commentId}`,
913
+ { body }
914
+ );
915
+ return {
916
+ json: response.data,
917
+ human: `Edited comment ${commentId} on ${identifier}
918
+ `
919
+ };
920
+ }
921
+ async function deleteComment(ctx, identifier, commentId) {
922
+ requireIdentifiers([identifier]);
923
+ requireCommentId(commentId);
924
+ const response = await ctx.client.delete(
925
+ `/api/v1/issues/${identifier}/comments/${commentId}`
926
+ );
927
+ return {
928
+ json: response.data,
929
+ human: `Deleted comment ${commentId} from ${identifier}
930
+ `
931
+ };
932
+ }
933
+ function requireCommentId(commentId) {
934
+ if (!UUID_FORMAT.test(commentId)) {
935
+ throw new UsageError(`not a comment id: ${commentId} (expected a UUID)`);
936
+ }
937
+ }
821
938
  var MAX_ATTACHMENT_BYTES2 = 25e6;
822
939
  async function attachImage(ctx, path2) {
823
940
  attachableFile(path2);
@@ -902,27 +1019,136 @@ function validateType(type) {
902
1019
  throw new UsageError(`not an issue type: ${type} (expected ${ISSUE_TYPES.join(", ")})`);
903
1020
  }
904
1021
  }
905
- async function applyLabels(ctx, issue, names) {
906
- if (names.length === 0) return issue;
907
- const labelsBody = await ctx.client.get("/api/v1/labels");
908
- const byName = new Map(labelsBody.data.map((label) => [str2(label["name"]), label["id"]]));
1022
+ function requestedLabelNames(names) {
1023
+ const unique = /* @__PURE__ */ new Map();
1024
+ for (const name of requestedLabelInputs(names)) {
1025
+ const normalized = normalizeLabelName(name);
1026
+ if (!unique.has(normalized)) unique.set(normalized, name);
1027
+ }
1028
+ return [...unique.values()];
1029
+ }
1030
+ function requestedLabelInputs(values) {
1031
+ return values.map((value) => {
1032
+ const trimmed = value.trim();
1033
+ if (trimmed === "") throw new UsageError("label names and ids cannot be empty");
1034
+ return trimmed;
1035
+ });
1036
+ }
1037
+ function normalizeLabelName(value) {
1038
+ return str2(value).trim().toLowerCase();
1039
+ }
1040
+ async function prepareCreateLabelIds(ctx, teamKey, names) {
1041
+ if (names.length === 0) return [];
1042
+ const teams = await ctx.client.get("/api/v1/teams");
1043
+ const target = teams.data.find(
1044
+ (team) => str2(team["key"]).toLowerCase() === teamKey.toLowerCase()
1045
+ );
1046
+ if (target === void 0) throw new CommandError(`No team matches key "${teamKey}".`);
1047
+ const teamId = str2(target["id"]);
1048
+ if (teamId === "") throw new CommandError(`team ${teamKey} carried no id`);
1049
+ return prepareApplicableLabelIds(ctx, teamId, names);
1050
+ }
1051
+ async function prepareApplicableLabelIds(ctx, teamId, names) {
1052
+ let labels = (await ctx.client.get("/api/v1/labels")).data;
909
1053
  const ids = [];
910
1054
  for (const name of names) {
911
- const existing = byName.get(name);
912
- if (existing !== void 0) {
913
- ids.push(existing);
914
- } else {
915
- const created = await ctx.client.post("/api/v1/labels", {
916
- name,
917
- color: "gray"
918
- });
919
- ids.push(created.data["id"]);
1055
+ const resolved = await resolveOrCreateApplicableLabel(ctx, labels, name, teamId);
1056
+ labels = resolved.catalog;
1057
+ ids.push(labelId(resolved.label));
1058
+ }
1059
+ return [...new Set(ids)];
1060
+ }
1061
+ function applicableLabel(labels, input, teamId) {
1062
+ const normalized = normalizeLabelName(input);
1063
+ const named = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
1064
+ const teamMatches = named.filter((label) => label["team_id"] === teamId);
1065
+ if (teamMatches.length > 1) {
1066
+ throw new CommandError(`more than one team label matches "${input}"; use a label id`);
1067
+ }
1068
+ if (teamMatches.length === 1) return teamMatches[0];
1069
+ const workspaceMatches = named.filter(
1070
+ (label) => label["team_id"] === null || label["team_id"] === void 0
1071
+ );
1072
+ if (workspaceMatches.length > 1) {
1073
+ throw new CommandError(`more than one workspace label matches "${input}"; use a label id`);
1074
+ }
1075
+ return workspaceMatches[0];
1076
+ }
1077
+ async function resolveOrCreateApplicableLabel(ctx, catalog, name, teamId) {
1078
+ const existing = applicableLabel(catalog, name, teamId);
1079
+ if (existing !== void 0) return { label: existing, catalog };
1080
+ try {
1081
+ const created = await ctx.client.post("/api/v1/labels", {
1082
+ name,
1083
+ color: "gray"
1084
+ });
1085
+ return { label: created.data, catalog: [...catalog, created.data] };
1086
+ } catch (error) {
1087
+ if (!(error instanceof HttpError) || error.status !== 422) throw error;
1088
+ const refreshed = (await ctx.client.get("/api/v1/labels")).data;
1089
+ const winner = applicableLabel(refreshed, name, teamId);
1090
+ if (winner === void 0) throw error;
1091
+ return { label: winner, catalog: refreshed };
1092
+ }
1093
+ }
1094
+ function labelId(label) {
1095
+ const id = str2(label["id"]);
1096
+ if (id === "") throw new CommandError("the label response carried no id");
1097
+ return id;
1098
+ }
1099
+ async function prepareLabelUpdate(ctx, identifier, addInputs, removeInputs) {
1100
+ const issue = (await ctx.client.get(`/api/v1/issues/${identifier}`)).data;
1101
+ const attached = Array.isArray(issue["labels"]) ? issue["labels"] : [];
1102
+ const originalIds = attached.map(labelId);
1103
+ const removed = /* @__PURE__ */ new Set();
1104
+ for (const input of removeInputs) {
1105
+ const idInput = UUID_FORMAT.test(input);
1106
+ const exact = attached.find((label) => {
1107
+ const id = labelId(label);
1108
+ return idInput ? id.toLowerCase() === input.toLowerCase() : id === input;
1109
+ });
1110
+ if (exact !== void 0) {
1111
+ removed.add(labelId(exact));
1112
+ continue;
1113
+ }
1114
+ const normalized = normalizeLabelName(input);
1115
+ const matches = attached.filter((label) => normalizeLabelName(label["name"]) === normalized);
1116
+ if (matches.length > 1) {
1117
+ throw new CommandError(`more than one attached label matches "${input}"; remove by label id`);
1118
+ }
1119
+ if (matches.length === 1) removed.add(labelId(matches[0]));
1120
+ }
1121
+ const ids = originalIds.filter((id) => !removed.has(id));
1122
+ if (addInputs.length > 0) {
1123
+ const team = issue["team"] ?? {};
1124
+ const teamId = str2(team["id"]);
1125
+ if (teamId === "") throw new CommandError(`issue ${identifier} carried no team id`);
1126
+ let catalog = (await ctx.client.get("/api/v1/labels")).data;
1127
+ for (const input of addInputs) {
1128
+ const idInput = UUID_FORMAT.test(input);
1129
+ let chosen = idInput ? catalog.find((label) => labelId(label).toLowerCase() === input.toLowerCase()) : void 0;
1130
+ if (chosen !== void 0) {
1131
+ const owner = chosen["team_id"];
1132
+ if (owner !== null && owner !== void 0 && owner !== teamId) {
1133
+ throw new CommandError(
1134
+ `label ${input} belongs to another team and cannot be added to ${identifier}`
1135
+ );
1136
+ }
1137
+ } else {
1138
+ if (idInput) throw new CommandError(`No label matches id "${input}".`);
1139
+ const resolved = await resolveOrCreateApplicableLabel(ctx, catalog, input, teamId);
1140
+ chosen = resolved.label;
1141
+ catalog = resolved.catalog;
1142
+ }
1143
+ const id = labelId(chosen);
1144
+ if (!ids.includes(id)) ids.push(id);
920
1145
  }
921
1146
  }
922
- const body = await ctx.client.put(`/api/v1/issues/${str2(issue["identifier"])}/labels`, {
923
- label_ids: ids
924
- });
925
- return body.data;
1147
+ return {
1148
+ issue,
1149
+ ids,
1150
+ changed: ids.length !== originalIds.length || ids.some((id, index) => id !== originalIds[index])
1151
+ };
926
1152
  }
927
1153
  async function fetchIssues(ctx, params, opts) {
928
1154
  if (opts.all === true && opts.page !== void 0) {
@@ -1231,13 +1457,36 @@ async function linkAdd(ctx, issue, url, title) {
1231
1457
  return { json: response.data, human: `Linked ${clean(url)} to ${issue}
1232
1458
  ` };
1233
1459
  }
1234
- async function linkRm(ctx, issue, idOrUrl) {
1235
- requireIdentifiers([issue]);
1460
+ async function findLink(ctx, issue, idOrUrl, allowDuplicateUrl = false) {
1236
1461
  const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
1237
- const link = body.data.find((candidate) => candidate["id"] === idOrUrl || candidate["url"] === idOrUrl);
1238
- if (link === void 0) {
1462
+ const idMatch = body.data.find((candidate) => candidate["id"] === idOrUrl);
1463
+ if (idMatch !== void 0) return idMatch;
1464
+ const urlMatches = body.data.filter((candidate) => candidate["url"] === idOrUrl);
1465
+ if (urlMatches.length > 1 && !allowDuplicateUrl) {
1466
+ throw new CommandError(`multiple links on ${issue} match ${idOrUrl}; use a link id`);
1467
+ }
1468
+ if (urlMatches.length === 0) {
1239
1469
  throw new CommandError(`no link on ${issue} matches ${idOrUrl} (by id or exact URL)`);
1240
1470
  }
1471
+ return urlMatches[0];
1472
+ }
1473
+ async function linkUpdate(ctx, issue, idOrUrl, opts) {
1474
+ requireIdentifiers([issue]);
1475
+ let patch = {};
1476
+ patch = putOpt2(patch, "url", opts.url);
1477
+ patch = putOpt2(patch, "title", opts.title);
1478
+ if (Object.keys(patch).length === 0) {
1479
+ throw new UsageError("usage: harness link update <issue> <link-id-or-url> --url <url> | --title <title>");
1480
+ }
1481
+ const id = isUuid(idOrUrl) ? idOrUrl.toLowerCase() : str3((await findLink(ctx, issue, idOrUrl))["id"]);
1482
+ if (!isUuid(idOrUrl)) patch["expected_url"] = idOrUrl;
1483
+ const response = await ctx.client.patch(`/api/v1/issues/${issue}/links/${id}`, patch);
1484
+ return { json: response.data, human: `Updated link ${clean(response.data["url"])} on ${issue}
1485
+ ` };
1486
+ }
1487
+ async function linkRm(ctx, issue, idOrUrl) {
1488
+ requireIdentifiers([issue]);
1489
+ const link = await findLink(ctx, issue, idOrUrl, true);
1241
1490
  await ctx.client.delete(`/api/v1/issues/${issue}/links/${str3(link["id"])}`);
1242
1491
  return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
1243
1492
  ` };
@@ -1709,7 +1958,16 @@ export {
1709
1958
  archive,
1710
1959
  comments,
1711
1960
  dep,
1961
+ RELATION_CREATE_TYPES,
1962
+ RELATION_UPDATE_TYPES,
1963
+ relationList,
1964
+ relationAdd,
1965
+ relationUpdate,
1966
+ relationRm,
1712
1967
  comment,
1968
+ editComment,
1969
+ deleteComment,
1970
+ requireCommentId,
1713
1971
  attachImage,
1714
1972
  projectList,
1715
1973
  projectShow,
@@ -1723,6 +1981,7 @@ export {
1723
1981
  docUpdate,
1724
1982
  linkList,
1725
1983
  linkAdd,
1984
+ linkUpdate,
1726
1985
  linkRm,
1727
1986
  labelList,
1728
1987
  labelCreate,