@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 +42 -1
- package/dist/{chunk-CIW4OU5G.js → chunk-ZHFW6PRC.js} +299 -40
- package/dist/cli.js +121 -18
- package/dist/{server-J2V5JXFY.js → server-J6FQFVBJ.js} +89 -4
- package/package.json +1 -1
- package/spec/openapi.json +569 -2
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,
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
const
|
|
908
|
-
|
|
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
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
|
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
|
|
1238
|
-
if (
|
|
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,
|