@allocator-one/harness-cli 1.5.2 → 1.6.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
@@ -69,6 +69,24 @@ Example tool call: `issue_list` with `{"ready": true, "team": "ALL"}`
69
69
  returns the ready queue as JSON; `issue_claim` with `{"id": "IO-123"}`
70
70
  claims atomically (a conflict means someone else holds it).
71
71
 
72
+ Issue creation resolves `--label` names within the target team first, then at
73
+ workspace scope. Missing names are created at workspace scope before the issue
74
+ is filed, and the final label ids travel in the single issue-create request, so
75
+ a preparation or permission failure cannot leave an unlabeled issue behind.
76
+ Creating a missing label requires Harness administrator standing.
77
+
78
+ Label repair is additive and repeatable; it never implicitly replaces the
79
+ whole set:
80
+
81
+ ```sh
82
+ harness update IO-123 --add-label security --add-label needs-review
83
+ harness update IO-123 --remove-label security
84
+ ```
85
+
86
+ Both update flags accept a label name or id. Additions allow only labels from
87
+ the issue's team or workspace. Removal can target an attached legacy foreign
88
+ label; when attached normalized names are ambiguous, use the label id.
89
+
72
90
  Auth resolution is identical to the CLI. Without a credential, tools return
73
91
  an error telling the agent to run `harness login` in a terminal — the
74
92
  server never opens a browser itself.
@@ -619,7 +619,6 @@ async function version(config) {
619
619
  ` };
620
620
  }
621
621
  async function create(ctx, opts) {
622
- const labels = wantedLabels(opts);
623
622
  if (opts.title === void 0) {
624
623
  throw new UsageError("usage: harness create --title <title> [--team <key>] \u2026");
625
624
  }
@@ -627,6 +626,8 @@ async function create(ctx, opts) {
627
626
  const team = requiredTeam(ctx, opts.team);
628
627
  const priority = optionalPriority(opts.priority);
629
628
  const dueDate = optionalDueDate(opts.dueDate);
629
+ const labels = requestedLabelNames(wantedLabels(opts));
630
+ const labelIds = await prepareCreateLabelIds(ctx, team, labels);
630
631
  let body = { title: opts.title };
631
632
  body = putOpt(body, "description", opts.description);
632
633
  body = putOpt(body, "design", opts.design);
@@ -636,23 +637,14 @@ async function create(ctx, opts) {
636
637
  body = putPresent(body, "priority", priority);
637
638
  body = putDueDate(body, dueDate);
638
639
  body = putAssignee(body, opts.assignee);
640
+ if (labelIds.length > 0) body["label_ids"] = labelIds;
639
641
  const created = await ctx.client.post(`/api/v1/teams/${team}/issues`, body);
640
642
  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"])}
643
+ return {
644
+ json: issue,
645
+ human: `Created ${str2(issue["identifier"])}: ${clean(issue["title"])}
646
646
  `
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
- }
647
+ };
656
648
  }
657
649
  async function update(ctx, identifier, opts) {
658
650
  requireIdentifiers([identifier]);
@@ -666,10 +658,15 @@ async function update(ctx, identifier, opts) {
666
658
  }
667
659
  const priority = optionalPriority(opts.priority);
668
660
  const dueDate = optionalDueDate(opts.dueDate);
669
- let issue = null;
661
+ const addLabels = requestedLabelInputs(opts.addLabel ?? []);
662
+ const removeLabels = requestedLabelInputs(opts.removeLabel ?? []);
663
+ const labelPlan = addLabels.length > 0 || removeLabels.length > 0 ? await prepareLabelUpdate(ctx, identifier, addLabels, removeLabels) : null;
664
+ let issue = labelPlan?.issue ?? null;
665
+ let wrote = false;
670
666
  if (opts.claim === true) {
671
667
  const body = await ctx.client.post(`/api/v1/issues/${identifier}/claim`);
672
668
  issue = body.data;
669
+ wrote = true;
673
670
  }
674
671
  let patch = {};
675
672
  patch = putOpt(patch, "title", opts.title);
@@ -692,15 +689,26 @@ ${opts.appendNotes}`;
692
689
  if (Object.keys(patch).length > 0) {
693
690
  const body = await ctx.client.patch(`/api/v1/issues/${identifier}`, patch);
694
691
  issue = body.data;
692
+ wrote = true;
695
693
  }
696
694
  if (opts.state !== void 0) {
697
695
  issue = await setState(ctx, identifier, opts.state);
696
+ wrote = true;
697
+ }
698
+ if (labelPlan?.changed === true) {
699
+ const body = await ctx.client.put(`/api/v1/issues/${identifier}/labels`, {
700
+ label_ids: labelPlan.ids
701
+ });
702
+ issue = body.data;
703
+ wrote = true;
698
704
  }
699
705
  if (issue === null) {
700
706
  throw new UsageError("usage: harness update <id> --claim | --title \u2026 | --state <name> | \u2026");
701
707
  }
702
- return { json: issue, human: `Updated ${str2(issue["identifier"])}
703
- ` };
708
+ const human = wrote ? `Updated ${str2(issue["identifier"])}
709
+ ` : `Labels already up to date for ${str2(issue["identifier"])}
710
+ `;
711
+ return { json: issue, human };
704
712
  }
705
713
  async function close(ctx, identifiers, opts) {
706
714
  if (identifiers.length === 0) {
@@ -902,27 +910,137 @@ function validateType(type) {
902
910
  throw new UsageError(`not an issue type: ${type} (expected ${ISSUE_TYPES.join(", ")})`);
903
911
  }
904
912
  }
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"]]));
913
+ var UUID_FORMAT = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
914
+ function requestedLabelNames(names) {
915
+ const unique = /* @__PURE__ */ new Map();
916
+ for (const name of requestedLabelInputs(names)) {
917
+ const normalized = normalizeLabelName(name);
918
+ if (!unique.has(normalized)) unique.set(normalized, name);
919
+ }
920
+ return [...unique.values()];
921
+ }
922
+ function requestedLabelInputs(values) {
923
+ return values.map((value) => {
924
+ const trimmed = value.trim();
925
+ if (trimmed === "") throw new UsageError("label names and ids cannot be empty");
926
+ return trimmed;
927
+ });
928
+ }
929
+ function normalizeLabelName(value) {
930
+ return str2(value).trim().toLowerCase();
931
+ }
932
+ async function prepareCreateLabelIds(ctx, teamKey, names) {
933
+ if (names.length === 0) return [];
934
+ const teams = await ctx.client.get("/api/v1/teams");
935
+ const target = teams.data.find(
936
+ (team) => str2(team["key"]).toLowerCase() === teamKey.toLowerCase()
937
+ );
938
+ if (target === void 0) throw new CommandError(`No team matches key "${teamKey}".`);
939
+ const teamId = str2(target["id"]);
940
+ if (teamId === "") throw new CommandError(`team ${teamKey} carried no id`);
941
+ return prepareApplicableLabelIds(ctx, teamId, names);
942
+ }
943
+ async function prepareApplicableLabelIds(ctx, teamId, names) {
944
+ let labels = (await ctx.client.get("/api/v1/labels")).data;
909
945
  const ids = [];
910
946
  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"]);
947
+ const resolved = await resolveOrCreateApplicableLabel(ctx, labels, name, teamId);
948
+ labels = resolved.catalog;
949
+ ids.push(labelId(resolved.label));
950
+ }
951
+ return [...new Set(ids)];
952
+ }
953
+ function applicableLabel(labels, input, teamId) {
954
+ const normalized = normalizeLabelName(input);
955
+ const named = labels.filter((label) => normalizeLabelName(label["name"]) === normalized);
956
+ const teamMatches = named.filter((label) => label["team_id"] === teamId);
957
+ if (teamMatches.length > 1) {
958
+ throw new CommandError(`more than one team label matches "${input}"; use a label id`);
959
+ }
960
+ if (teamMatches.length === 1) return teamMatches[0];
961
+ const workspaceMatches = named.filter(
962
+ (label) => label["team_id"] === null || label["team_id"] === void 0
963
+ );
964
+ if (workspaceMatches.length > 1) {
965
+ throw new CommandError(`more than one workspace label matches "${input}"; use a label id`);
966
+ }
967
+ return workspaceMatches[0];
968
+ }
969
+ async function resolveOrCreateApplicableLabel(ctx, catalog, name, teamId) {
970
+ const existing = applicableLabel(catalog, name, teamId);
971
+ if (existing !== void 0) return { label: existing, catalog };
972
+ try {
973
+ const created = await ctx.client.post("/api/v1/labels", {
974
+ name,
975
+ color: "gray"
976
+ });
977
+ return { label: created.data, catalog: [...catalog, created.data] };
978
+ } catch (error) {
979
+ if (!(error instanceof HttpError) || error.status !== 422) throw error;
980
+ const refreshed = (await ctx.client.get("/api/v1/labels")).data;
981
+ const winner = applicableLabel(refreshed, name, teamId);
982
+ if (winner === void 0) throw error;
983
+ return { label: winner, catalog: refreshed };
984
+ }
985
+ }
986
+ function labelId(label) {
987
+ const id = str2(label["id"]);
988
+ if (id === "") throw new CommandError("the label response carried no id");
989
+ return id;
990
+ }
991
+ async function prepareLabelUpdate(ctx, identifier, addInputs, removeInputs) {
992
+ const issue = (await ctx.client.get(`/api/v1/issues/${identifier}`)).data;
993
+ const attached = Array.isArray(issue["labels"]) ? issue["labels"] : [];
994
+ const originalIds = attached.map(labelId);
995
+ const removed = /* @__PURE__ */ new Set();
996
+ for (const input of removeInputs) {
997
+ const idInput = UUID_FORMAT.test(input);
998
+ const exact = attached.find((label) => {
999
+ const id = labelId(label);
1000
+ return idInput ? id.toLowerCase() === input.toLowerCase() : id === input;
1001
+ });
1002
+ if (exact !== void 0) {
1003
+ removed.add(labelId(exact));
1004
+ continue;
1005
+ }
1006
+ const normalized = normalizeLabelName(input);
1007
+ const matches = attached.filter((label) => normalizeLabelName(label["name"]) === normalized);
1008
+ if (matches.length > 1) {
1009
+ throw new CommandError(`more than one attached label matches "${input}"; remove by label id`);
1010
+ }
1011
+ if (matches.length === 1) removed.add(labelId(matches[0]));
1012
+ }
1013
+ const ids = originalIds.filter((id) => !removed.has(id));
1014
+ if (addInputs.length > 0) {
1015
+ const team = issue["team"] ?? {};
1016
+ const teamId = str2(team["id"]);
1017
+ if (teamId === "") throw new CommandError(`issue ${identifier} carried no team id`);
1018
+ let catalog = (await ctx.client.get("/api/v1/labels")).data;
1019
+ for (const input of addInputs) {
1020
+ const idInput = UUID_FORMAT.test(input);
1021
+ let chosen = idInput ? catalog.find((label) => labelId(label).toLowerCase() === input.toLowerCase()) : void 0;
1022
+ if (chosen !== void 0) {
1023
+ const owner = chosen["team_id"];
1024
+ if (owner !== null && owner !== void 0 && owner !== teamId) {
1025
+ throw new CommandError(
1026
+ `label ${input} belongs to another team and cannot be added to ${identifier}`
1027
+ );
1028
+ }
1029
+ } else {
1030
+ if (idInput) throw new CommandError(`No label matches id "${input}".`);
1031
+ const resolved = await resolveOrCreateApplicableLabel(ctx, catalog, input, teamId);
1032
+ chosen = resolved.label;
1033
+ catalog = resolved.catalog;
1034
+ }
1035
+ const id = labelId(chosen);
1036
+ if (!ids.includes(id)) ids.push(id);
920
1037
  }
921
1038
  }
922
- const body = await ctx.client.put(`/api/v1/issues/${str2(issue["identifier"])}/labels`, {
923
- label_ids: ids
924
- });
925
- return body.data;
1039
+ return {
1040
+ issue,
1041
+ ids,
1042
+ changed: ids.length !== originalIds.length || ids.some((id, index) => id !== originalIds[index])
1043
+ };
926
1044
  }
927
1045
  async function fetchIssues(ctx, params, opts) {
928
1046
  if (opts.all === true && opts.page !== void 0) {
package/dist/cli.js CHANGED
@@ -70,7 +70,7 @@ import {
70
70
  version,
71
71
  workflowStates,
72
72
  writeCredentials
73
- } from "./chunk-CIW4OU5G.js";
73
+ } from "./chunk-DQDNAG5E.js";
74
74
 
75
75
  // src/cli.ts
76
76
  import { createInterface as createInterface2 } from "readline";
@@ -1208,10 +1208,15 @@ function requireNonBlankTitle(text, titleFile) {
1208
1208
  titleFile === void 0 ? "--title is empty \u2014 an issue needs a title" : `--title-file ${titleFile} produced an empty title`
1209
1209
  );
1210
1210
  }
1211
- program.command("create").description("create an issue").option("--title <text>", "issue title (required)").option("--description <text>", "problem statement").option("--design <text>", "design notes").option("--notes <text>", "working notes").option("--acceptance <text>", "acceptance criteria").option("--title-file <path>", 'read the title from a file ("-" for stdin)').option("--description-file <path>", 'read the description from a file ("-" for stdin)').option("--design-file <path>", 'read the design notes from a file ("-" for stdin)').option("--notes-file <path>", 'read the notes from a file ("-" for stdin)').option("--acceptance-file <path>", 'read the acceptance criteria from a file ("-" for stdin)').option(
1211
+ program.command("create").description("create an issue atomically after resolving requested labels").option("--title <text>", "issue title (required)").option("--description <text>", "problem statement").option("--design <text>", "design notes").option("--notes <text>", "working notes").option("--acceptance <text>", "acceptance criteria").option("--title-file <path>", 'read the title from a file ("-" for stdin)').option("--description-file <path>", 'read the description from a file ("-" for stdin)').option("--design-file <path>", 'read the design notes from a file ("-" for stdin)').option("--notes-file <path>", 'read the notes from a file ("-" for stdin)').option("--acceptance-file <path>", 'read the acceptance criteria from a file ("-" for stdin)').option(
1212
1212
  "--type <type>",
1213
1213
  "task | bug | feature | epic (applied as a type/ label)"
1214
- ).option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", "YYYY-MM-DD").option("--parent <id>", "parent epic").option("--assignee <who>", "me, a user id, an email, or a full name").option("--team <key>", "team key (or HARNESS_TEAM)").option("--label <name>", "label to apply (repeatable)", collect, []).option("--json", "emit JSON").action(async (opts) => {
1214
+ ).option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", "YYYY-MM-DD").option("--parent <id>", "parent epic").option("--assignee <who>", "me, a user id, an email, or a full name").option("--team <key>", "team key (or HARNESS_TEAM)").option(
1215
+ "--label <name>",
1216
+ "resolve team-first, then workspace; create missing workspace labels before the issue (repeatable)",
1217
+ collect,
1218
+ []
1219
+ ).option("--json", "emit JSON").action(async (opts) => {
1215
1220
  await withConfig(opts.json === true, true, (ctx) => {
1216
1221
  if (opts.title === void 0 && opts.titleFile === void 0) {
1217
1222
  throw new UsageError(
@@ -1227,6 +1232,16 @@ program.command("create").description("create an issue").option("--title <text>"
1227
1232
  program.command("update").description("update an issue; --claim takes it atomically").argument("[id]").option("--claim", "claim atomically and move to the first started state").option("--state <name>", "workflow state by name or type").option("--title <text>").option("--description <text>").option("--design <text>").option("--notes <text>", "replace the notes").option("--append-notes <text>", "append to the notes").option("--acceptance <text>").option("--title-file <path>", 'read the title from a file ("-" for stdin)').option("--description-file <path>", 'read the description from a file ("-" for stdin)').option("--design-file <path>", 'read the design notes from a file ("-" for stdin)').option("--notes-file <path>", 'replace the notes from a file ("-" for stdin)').option("--append-notes-file <path>", 'append to the notes from a file ("-" for stdin)').option("--acceptance-file <path>", 'read the acceptance criteria from a file ("-" for stdin)').option("--priority <name>", "urgent | high | medium | low | none").option("--due-date <date>", 'YYYY-MM-DD; "" clears').option("--parent <id>").option(
1228
1233
  "--assignee <who>",
1229
1234
  'me, a user id, an email, or a full name; "" unassigns'
1235
+ ).option(
1236
+ "--add-label <name-or-id>",
1237
+ "add a label without replacing existing labels (repeatable)",
1238
+ collect,
1239
+ []
1240
+ ).option(
1241
+ "--remove-label <name-or-id>",
1242
+ "remove an attached label without replacing others (repeatable)",
1243
+ collect,
1244
+ []
1230
1245
  ).option("--json", "emit JSON").action(
1231
1246
  async (id, opts) => {
1232
1247
  await withConfig(opts.json === true, true, (ctx) => {
@@ -1552,7 +1567,7 @@ program.command("upload").description("upload a file; prints markdown to referen
1552
1567
  });
1553
1568
  });
1554
1569
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1555
- const { startMcpServer } = await import("./server-J2V5JXFY.js");
1570
+ const { startMcpServer } = await import("./server-WBZ5OPHK.js");
1556
1571
  await startMcpServer();
1557
1572
  });
1558
1573
  program.command("login").description("sign in via the browser (infra.one session)").option("--url <url>", "host to sign in to").option("--no-browser", "print the URL and prompt for a pasted token instead").option(
@@ -50,7 +50,7 @@ import {
50
50
  upload,
51
51
  userList,
52
52
  workflowStates
53
- } from "./chunk-CIW4OU5G.js";
53
+ } from "./chunk-DQDNAG5E.js";
54
54
 
55
55
  // src/mcp/server.ts
56
56
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -189,13 +189,17 @@ var TOOLS = [
189
189
  due_date: dateField,
190
190
  state: z.string().optional().describe("workflow state by name or type"),
191
191
  parent: z.string().optional(),
192
- assignee: z.string().optional().describe('"" unassigns')
192
+ assignee: z.string().optional().describe('"" unassigns'),
193
+ add_labels: z.array(z.string()).optional().describe("label names or ids to add without replacing existing labels"),
194
+ remove_labels: z.array(z.string()).optional().describe("attached label names or ids to remove without replacing others")
193
195
  },
194
196
  handler: (ctx, args) => update(ctx, args["id"], {
195
197
  ...args,
196
198
  acceptance: args["acceptance_criteria"],
197
199
  appendNotes: args["append_notes"],
198
- dueDate: args["due_date"]
200
+ dueDate: args["due_date"],
201
+ addLabel: args["add_labels"],
202
+ removeLabel: args["remove_labels"]
199
203
  })
200
204
  },
201
205
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "CLI and MCP server for Harness, allocator.one's issue tracker",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/spec/openapi.json CHANGED
@@ -1038,6 +1038,14 @@
1038
1038
  "format": "date",
1039
1039
  "type": "string"
1040
1040
  },
1041
+ "label_ids": {
1042
+ "description": "Optional label IDs from the workspace or the same team; attached atomically with the issue",
1043
+ "items": {
1044
+ "format": "uuid",
1045
+ "type": "string"
1046
+ },
1047
+ "type": "array"
1048
+ },
1041
1049
  "notes": {
1042
1050
  "description": "Working notes (markdown)",
1043
1051
  "type": "string"