@allocator-one/harness-cli 1.6.0 → 1.8.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,27 @@ 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
+
72
95
  Issue creation resolves `--label` names within the target team first, then at
73
96
  workspace scope. Missing names are created at workspace scope before the issue
74
97
  is filed, and the final label ids travel in the single issue-create request, so
@@ -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) {
@@ -786,8 +790,9 @@ async function comments(ctx, identifier) {
786
790
  if (body.data.length === 0) return { json: body.data, human: "No comments.\n" };
787
791
  const human = body.data.map((comment2) => {
788
792
  const author = clean((comment2["author"] ?? {})["name"] ?? "unknown");
793
+ const id = clean(comment2["id"] ?? "unknown id");
789
794
  const text = cleanMultiline(str2(comment2["body"])).split("\n").map((line) => " " + line).join("\n");
790
- return `[${str2(comment2["inserted_at"])}] ${author}:
795
+ return `[${str2(comment2["inserted_at"])}] ${author} (${id}):
791
796
  ${text}
792
797
  `;
793
798
  }).join("\n");
@@ -815,6 +820,76 @@ async function dep(ctx, verb, issue, blocker) {
815
820
  }
816
821
  throw new UsageError("usage: harness dep add|rm <issue> <blocker> (the blocker blocks the issue)");
817
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
+ }
818
893
  async function comment(ctx, identifier, textWords, mentions) {
819
894
  requireIdentifiers([identifier]);
820
895
  if (textWords.length === 0) {
@@ -826,6 +901,40 @@ async function comment(ctx, identifier, textWords, mentions) {
826
901
  return { json: response.data, human: `Commented on ${identifier}
827
902
  ` };
828
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
+ }
829
938
  var MAX_ATTACHMENT_BYTES2 = 25e6;
830
939
  async function attachImage(ctx, path2) {
831
940
  attachableFile(path2);
@@ -910,7 +1019,6 @@ function validateType(type) {
910
1019
  throw new UsageError(`not an issue type: ${type} (expected ${ISSUE_TYPES.join(", ")})`);
911
1020
  }
912
1021
  }
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
1022
  function requestedLabelNames(names) {
915
1023
  const unique = /* @__PURE__ */ new Map();
916
1024
  for (const name of requestedLabelInputs(names)) {
@@ -1068,6 +1176,8 @@ function listParams(ctx, opts) {
1068
1176
  params = putParam(params, "label", opts.label);
1069
1177
  params = putParam(params, "scope", opts.scope);
1070
1178
  params = putParam(params, "assignee", opts.assignee);
1179
+ params = putParam(params, "created_by", opts.createdBy);
1180
+ params = putParam(params, "involved", opts.involved);
1071
1181
  if (opts.type !== void 0) params = putParam(params, "label", `type/${opts.type}`);
1072
1182
  if (opts.status !== void 0) {
1073
1183
  const stateTypes = Object.hasOwn(STATUS_STATE_TYPES, opts.status) ? STATUS_STATE_TYPES[opts.status] : void 0;
@@ -1349,13 +1459,36 @@ async function linkAdd(ctx, issue, url, title) {
1349
1459
  return { json: response.data, human: `Linked ${clean(url)} to ${issue}
1350
1460
  ` };
1351
1461
  }
1352
- async function linkRm(ctx, issue, idOrUrl) {
1353
- requireIdentifiers([issue]);
1462
+ async function findLink(ctx, issue, idOrUrl, allowDuplicateUrl = false) {
1354
1463
  const body = await ctx.client.get(`/api/v1/issues/${issue}/links`);
1355
- const link = body.data.find((candidate) => candidate["id"] === idOrUrl || candidate["url"] === idOrUrl);
1356
- if (link === void 0) {
1464
+ const idMatch = body.data.find((candidate) => candidate["id"] === idOrUrl);
1465
+ if (idMatch !== void 0) return idMatch;
1466
+ const urlMatches = body.data.filter((candidate) => candidate["url"] === idOrUrl);
1467
+ if (urlMatches.length > 1 && !allowDuplicateUrl) {
1468
+ throw new CommandError(`multiple links on ${issue} match ${idOrUrl}; use a link id`);
1469
+ }
1470
+ if (urlMatches.length === 0) {
1357
1471
  throw new CommandError(`no link on ${issue} matches ${idOrUrl} (by id or exact URL)`);
1358
1472
  }
1473
+ return urlMatches[0];
1474
+ }
1475
+ async function linkUpdate(ctx, issue, idOrUrl, opts) {
1476
+ requireIdentifiers([issue]);
1477
+ let patch = {};
1478
+ patch = putOpt2(patch, "url", opts.url);
1479
+ patch = putOpt2(patch, "title", opts.title);
1480
+ if (Object.keys(patch).length === 0) {
1481
+ throw new UsageError("usage: harness link update <issue> <link-id-or-url> --url <url> | --title <title>");
1482
+ }
1483
+ const id = isUuid(idOrUrl) ? idOrUrl.toLowerCase() : str3((await findLink(ctx, issue, idOrUrl))["id"]);
1484
+ if (!isUuid(idOrUrl)) patch["expected_url"] = idOrUrl;
1485
+ const response = await ctx.client.patch(`/api/v1/issues/${issue}/links/${id}`, patch);
1486
+ return { json: response.data, human: `Updated link ${clean(response.data["url"])} on ${issue}
1487
+ ` };
1488
+ }
1489
+ async function linkRm(ctx, issue, idOrUrl) {
1490
+ requireIdentifiers([issue]);
1491
+ const link = await findLink(ctx, issue, idOrUrl, true);
1359
1492
  await ctx.client.delete(`/api/v1/issues/${issue}/links/${str3(link["id"])}`);
1360
1493
  return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
1361
1494
  ` };
@@ -1827,7 +1960,16 @@ export {
1827
1960
  archive,
1828
1961
  comments,
1829
1962
  dep,
1963
+ RELATION_CREATE_TYPES,
1964
+ RELATION_UPDATE_TYPES,
1965
+ relationList,
1966
+ relationAdd,
1967
+ relationUpdate,
1968
+ relationRm,
1830
1969
  comment,
1970
+ editComment,
1971
+ deleteComment,
1972
+ requireCommentId,
1831
1973
  attachImage,
1832
1974
  projectList,
1833
1975
  projectShow,
@@ -1841,6 +1983,7 @@ export {
1841
1983
  docUpdate,
1842
1984
  linkList,
1843
1985
  linkAdd,
1986
+ linkUpdate,
1844
1987
  linkRm,
1845
1988
  labelList,
1846
1989
  labelCreate,
package/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  DEFAULT_BASE_URL,
7
7
  HttpError,
8
8
  LocalError,
9
+ RELATION_CREATE_TYPES,
10
+ RELATION_UPDATE_TYPES,
9
11
  TransportError,
10
12
  UpgradeRequiredError,
11
13
  UsageError,
@@ -18,12 +20,14 @@ import {
18
20
  comments,
19
21
  create,
20
22
  defaultCredentialsPath,
23
+ deleteComment,
21
24
  deleteCredentials,
22
25
  dep,
23
26
  docCreate,
24
27
  docList,
25
28
  docShow,
26
29
  docUpdate,
30
+ editComment,
27
31
  findRepoConfig,
28
32
  isHostTrusted,
29
33
  json,
@@ -34,6 +38,7 @@ import {
34
38
  linkAdd,
35
39
  linkList,
36
40
  linkRm,
41
+ linkUpdate,
37
42
  list,
38
43
  loginHelp,
39
44
  memberAdd,
@@ -48,6 +53,11 @@ import {
48
53
  projectUpdate,
49
54
  readCredentials,
50
55
  ready,
56
+ relationAdd,
57
+ relationList,
58
+ relationRm,
59
+ relationUpdate,
60
+ requireCommentId,
51
61
  requireIdentifiers,
52
62
  resolveCacheHome,
53
63
  resolveConfig,
@@ -70,7 +80,7 @@ import {
70
80
  version,
71
81
  workflowStates,
72
82
  writeCredentials
73
- } from "./chunk-DQDNAG5E.js";
83
+ } from "./chunk-NQVQNKMC.js";
74
84
 
75
85
  // src/cli.ts
76
86
  import { createInterface as createInterface2 } from "readline";
@@ -1000,6 +1010,23 @@ function integer(value) {
1000
1010
  function collect(value, previous) {
1001
1011
  return [...previous, value];
1002
1012
  }
1013
+ function resolvedCommentBody(text, bodyFile, action = "post") {
1014
+ if (bodyFile !== void 0 && text.length > 0) {
1015
+ throw new UsageError(
1016
+ "comment text and --body-file both given \u2014 pass one; there is no precedence between them"
1017
+ );
1018
+ }
1019
+ const fromFile = resolveTextSources([
1020
+ { name: "body", inline: void 0, file: bodyFile }
1021
+ ]).body;
1022
+ const body = fromFile === void 0 ? text : [fromFile];
1023
+ if (body.join(" ").trim() === "") {
1024
+ throw new UsageError(
1025
+ bodyFile === void 0 ? `the comment text is empty \u2014 nothing to ${action}` : `--body-file ${bodyFile} produced an empty comment \u2014 nothing to ${action}`
1026
+ );
1027
+ }
1028
+ return body;
1029
+ }
1003
1030
  var requireIdentifiers2 = requireIdentifiers;
1004
1031
  function interactive() {
1005
1032
  const ci = process.env["CI"];
@@ -1132,6 +1159,9 @@ program.command("blocked").description("issues waiting on a blocker").option("--
1132
1159
  program.command("list").description("list issues with filters").option("--status <status>", "open | in_progress | closed").option(
1133
1160
  "--assignee <who>",
1134
1161
  'me, a user id, an email, or a full name; "" for unassigned'
1162
+ ).option("--created-by <who>", "me or a creator user id").option(
1163
+ "--involved <who>",
1164
+ "me for issues created by, assigned to, or mentioning you"
1135
1165
  ).option("--priority <name>", "urgent | high | medium | low | none").option("--label <name>", "label filter").option("--type <type>", "task | bug | feature | epic").option("--search <query>", "full-text search").option("--team <key>", "team filter").option("--parent <id>", "children of an epic").option("--scope <scope>", "all | archived").option("--limit <n>", "page size", integer).option("--page <n>", "page number", integer).option("--all", "follow every page").option("--json", "emit JSON").action(async (opts) => {
1136
1166
  await withConfig(
1137
1167
  opts.json === true,
@@ -1313,7 +1343,42 @@ program.command("dep").description("blocking dependencies: dep add|rm <issue> <b
1313
1343
  });
1314
1344
  }
1315
1345
  );
1316
- program.command("comment").description("comment on an issue").argument("[id]").argument("[text...]").option("--body-file <path>", 'read the comment body from a file ("-" for stdin)').option("--mention <name>", "notify a person (repeatable)", collect, []).option("--json", "emit JSON").action(
1346
+ var relation = program.command("relation").description("typed issue relations; dep remains the blocking-dependency shorthand");
1347
+ relation.command("list").argument("[issue]").option("--json", "emit JSON").action(async (issue, opts) => {
1348
+ await withConfig(opts.json === true, false, (ctx) => {
1349
+ if (issue === void 0) throw new UsageError("usage: harness relation list <issue>");
1350
+ return relationList(ctx, issue);
1351
+ });
1352
+ });
1353
+ relation.command("add").argument("[issue]").argument("[related-issue]").requiredOption("--type <type>", `relation type: ${RELATION_CREATE_TYPES.join(", ")}`).option("--json", "emit JSON").action(
1354
+ async (issue, relatedIssue, opts) => {
1355
+ await withConfig(opts.json === true, true, (ctx) => {
1356
+ if (issue === void 0 || relatedIssue === void 0) {
1357
+ throw new UsageError("usage: harness relation add <issue> <related-issue> --type <type>");
1358
+ }
1359
+ return relationAdd(ctx, issue, relatedIssue, opts.type);
1360
+ });
1361
+ }
1362
+ );
1363
+ relation.command("update").argument("[issue]").argument("[id-or-issue]").requiredOption("--type <type>", `relation type: ${RELATION_UPDATE_TYPES.join(", ")}`).option("--json", "emit JSON").action(
1364
+ async (issue, idOrIssue, opts) => {
1365
+ await withConfig(opts.json === true, true, (ctx) => {
1366
+ if (issue === void 0 || idOrIssue === void 0) {
1367
+ throw new UsageError("usage: harness relation update <issue> <relation-id-or-issue> --type <type>");
1368
+ }
1369
+ return relationUpdate(ctx, issue, idOrIssue, opts.type);
1370
+ });
1371
+ }
1372
+ );
1373
+ relation.command("rm").argument("[issue]").argument("[id-or-issue]").option("--json", "emit JSON").action(async (issue, idOrIssue, opts) => {
1374
+ await withConfig(opts.json === true, true, (ctx) => {
1375
+ if (issue === void 0 || idOrIssue === void 0) {
1376
+ throw new UsageError("usage: harness relation rm <issue> <relation-id-or-issue>");
1377
+ }
1378
+ return relationRm(ctx, issue, idOrIssue);
1379
+ });
1380
+ });
1381
+ var commentCommand = program.command("comment").description("comment on an issue").argument("[id]").argument("[text...]").option("--body-file <path>", 'read the comment body from a file ("-" for stdin)').option("--mention <name>", "notify a person (repeatable)", collect, []).option("--json", "emit JSON").action(
1317
1382
  async (id, text, opts) => {
1318
1383
  await withConfig(opts.json === true, true, (ctx) => {
1319
1384
  if (id === void 0 || opts.bodyFile === void 0 && text.length === 0) {
@@ -1322,25 +1387,39 @@ program.command("comment").description("comment on an issue").argument("[id]").a
1322
1387
  );
1323
1388
  }
1324
1389
  requireIdentifiers2([id]);
1325
- if (opts.bodyFile !== void 0 && text.length > 0) {
1326
- throw new UsageError(
1327
- "comment text and --body-file both given \u2014 pass one; there is no precedence between them"
1328
- );
1329
- }
1330
- const fromFile = resolveTextSources([
1331
- { name: "body", inline: void 0, file: opts.bodyFile }
1332
- ]).body;
1333
- const body = fromFile === void 0 ? text : [fromFile];
1334
- if (body.join(" ").trim() === "") {
1390
+ const body = resolvedCommentBody(text, opts.bodyFile);
1391
+ const mentions = opts.mention.filter((name) => name.trim() !== "");
1392
+ return comment(ctx, id, body, mentions);
1393
+ });
1394
+ }
1395
+ );
1396
+ commentCommand.command("edit").description("edit one of your issue comments").argument("[issue]").argument("[comment-id]").argument("[text...]").option("--body-file <path>", 'read the replacement body from a file ("-" for stdin)').option("--json", "emit JSON").action(
1397
+ async (issue, commentId, text, opts) => {
1398
+ const parentOpts = commentCommand.opts();
1399
+ const bodyFile = opts.bodyFile ?? parentOpts.bodyFile;
1400
+ await withConfig(opts.json === true || parentOpts.json === true, true, (ctx) => {
1401
+ if (issue === void 0 || commentId === void 0 || bodyFile === void 0 && text.length === 0) {
1335
1402
  throw new UsageError(
1336
- opts.bodyFile === void 0 ? "the comment text is empty \u2014 nothing to post" : `--body-file ${opts.bodyFile} produced an empty comment \u2014 nothing to post`
1403
+ "usage: harness comment edit <issue> <comment-id> <text> | harness comment edit <issue> <comment-id> --body-file <path>"
1337
1404
  );
1338
1405
  }
1339
- const mentions = opts.mention.filter((name) => name.trim() !== "");
1340
- return comment(ctx, id, body, mentions);
1406
+ requireIdentifiers2([issue]);
1407
+ requireCommentId(commentId);
1408
+ return editComment(ctx, issue, commentId, resolvedCommentBody(text, bodyFile, "edit"));
1341
1409
  });
1342
1410
  }
1343
1411
  );
1412
+ commentCommand.command("delete").alias("rm").description("delete one of your issue comments").argument("[issue]").argument("[comment-id]").option("--json", "emit JSON").action(async (issue, commentId, opts) => {
1413
+ const parentOpts = commentCommand.opts();
1414
+ await withConfig(opts.json === true || parentOpts.json === true, true, (ctx) => {
1415
+ if (issue === void 0 || commentId === void 0) {
1416
+ throw new UsageError("usage: harness comment delete <issue> <comment-id>");
1417
+ }
1418
+ requireIdentifiers2([issue]);
1419
+ requireCommentId(commentId);
1420
+ return deleteComment(ctx, issue, commentId);
1421
+ });
1422
+ });
1344
1423
  program.command("attach-image").description("upload an image; prints markdown to reference it").argument("[path]").option("--json", "emit JSON").action(async (path, opts) => {
1345
1424
  await withConfig(opts.json === true, true, (ctx) => {
1346
1425
  if (path === void 0) {
@@ -1454,6 +1533,18 @@ link.command("add").argument("[issue]").argument("[url]").option("--title <title
1454
1533
  });
1455
1534
  }
1456
1535
  );
1536
+ link.command("update").argument("[issue]").argument("[id-or-url]").option("--url <url>", "replacement URL").option("--title <title>", "replacement title; pass an empty value to clear").option("--json", "emit JSON").action(
1537
+ async (issue, idOrUrl, opts) => {
1538
+ await withConfig(opts.json === true, true, (ctx) => {
1539
+ if (issue === void 0 || idOrUrl === void 0) {
1540
+ throw new UsageError(
1541
+ "usage: harness link update <issue> <link-id-or-url> --url <url> | --title <title>"
1542
+ );
1543
+ }
1544
+ return linkUpdate(ctx, issue, idOrUrl, opts);
1545
+ });
1546
+ }
1547
+ );
1457
1548
  link.command("rm").argument("[issue]").argument("[id-or-url]").option("--json", "emit JSON").action(
1458
1549
  async (issue, idOrUrl, opts) => {
1459
1550
  await withConfig(opts.json === true, true, (ctx) => {
@@ -1567,7 +1658,7 @@ program.command("upload").description("upload a file; prints markdown to referen
1567
1658
  });
1568
1659
  });
1569
1660
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1570
- const { startMcpServer } = await import("./server-WBZ5OPHK.js");
1661
+ const { startMcpServer } = await import("./server-FWNT3HMX.js");
1571
1662
  await startMcpServer();
1572
1663
  });
1573
1664
  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(
@@ -3,6 +3,8 @@ import {
3
3
  CLI_VERSION,
4
4
  Client,
5
5
  HttpError,
6
+ RELATION_CREATE_TYPES,
7
+ RELATION_UPDATE_TYPES,
6
8
  TransportError,
7
9
  UsageError,
8
10
  archive,
@@ -11,11 +13,13 @@ import {
11
13
  comment,
12
14
  comments,
13
15
  create,
16
+ deleteComment,
14
17
  dep,
15
18
  docCreate,
16
19
  docList,
17
20
  docShow,
18
21
  docUpdate,
22
+ editComment,
19
23
  labelCreate,
20
24
  labelDelete,
21
25
  labelList,
@@ -23,6 +27,7 @@ import {
23
27
  linkAdd,
24
28
  linkList,
25
29
  linkRm,
30
+ linkUpdate,
26
31
  list,
27
32
  memberAdd,
28
33
  memberList,
@@ -34,6 +39,10 @@ import {
34
39
  projectShow,
35
40
  projectUpdate,
36
41
  ready,
42
+ relationAdd,
43
+ relationList,
44
+ relationRm,
45
+ relationUpdate,
37
46
  resolveConfig,
38
47
  search,
39
48
  show,
@@ -50,7 +59,7 @@ import {
50
59
  upload,
51
60
  userList,
52
61
  workflowStates
53
- } from "./chunk-DQDNAG5E.js";
62
+ } from "./chunk-NQVQNKMC.js";
54
63
 
55
64
  // src/mcp/server.ts
56
65
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -98,6 +107,7 @@ var page = {
98
107
  all: z.boolean().optional().describe("follow every page")
99
108
  };
100
109
  var identifier = z.string().describe("issue identifier, e.g. IO-123");
110
+ var commentId = z.string().uuid().describe("comment UUID from issue_show or comment_list");
101
111
  var dateField = z.string().optional().describe('YYYY-MM-DD; "" clears the date');
102
112
  var TOOLS = [
103
113
  // ── Issues ──
@@ -111,6 +121,8 @@ var TOOLS = [
111
121
  blocked: z.boolean().optional(),
112
122
  status: z.enum(["open", "in_progress", "closed"]).optional(),
113
123
  assignee: z.string().optional().describe('me, a user id, an email, or a full name; "" for unassigned'),
124
+ created_by: z.string().optional().describe("me or a creator user id"),
125
+ involved: z.literal("me").optional().describe("issues created by, assigned to, or mentioning the caller"),
114
126
  priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(),
115
127
  label: z.string().optional(),
116
128
  type: z.enum(["task", "bug", "feature", "epic"]).optional(),
@@ -123,9 +135,10 @@ var TOOLS = [
123
135
  if (args["ready"] === true && args["blocked"] === true) {
124
136
  throw new UsageError("ready and blocked are mutually exclusive \u2014 pass one, not both");
125
137
  }
126
- if (args["ready"] === true) return ready(ctx, args);
127
- if (args["blocked"] === true) return blocked(ctx, args);
128
- return list(ctx, args);
138
+ const opts = { ...args, createdBy: args["created_by"] };
139
+ if (args["ready"] === true) return ready(ctx, opts);
140
+ if (args["blocked"] === true) return blocked(ctx, opts);
141
+ return list(ctx, opts);
129
142
  }
130
143
  },
131
144
  {
@@ -231,6 +244,27 @@ var TOOLS = [
231
244
  },
232
245
  handler: (ctx, args) => comment(ctx, args["id"], [args["body"]], args["mentions"] ?? [])
233
246
  },
247
+ {
248
+ name: "issue_comment_edit",
249
+ description: "Edit one of your issue comments within its edit window.",
250
+ readOnly: false,
251
+ shape: {
252
+ id: identifier,
253
+ comment_id: commentId,
254
+ body: z.string().min(1).refine((body) => body.trim() !== "", "comment body must not be blank")
255
+ },
256
+ handler: (ctx, args) => editComment(ctx, args["id"], args["comment_id"], [args["body"]])
257
+ },
258
+ {
259
+ name: "issue_comment_delete",
260
+ description: "Delete one of your issue comments.",
261
+ readOnly: false,
262
+ shape: {
263
+ id: identifier,
264
+ comment_id: commentId
265
+ },
266
+ handler: (ctx, args) => deleteComment(ctx, args["id"], args["comment_id"])
267
+ },
234
268
  {
235
269
  name: "issue_archive",
236
270
  description: "Archive issues (or restore them with restore=true). Archived issues show up under scope=archived.",
@@ -245,7 +279,7 @@ var TOOLS = [
245
279
  shape: { id: identifier },
246
280
  handler: (ctx, args) => comments(ctx, args["id"])
247
281
  },
248
- // ── Relations (blocking dependencies) ──
282
+ // ── Relations ──
249
283
  {
250
284
  name: "relation_add",
251
285
  description: "Add a blocking dependency: blocker blocks issue.",
@@ -260,6 +294,42 @@ var TOOLS = [
260
294
  shape: { issue: identifier, blocker: identifier },
261
295
  handler: (ctx, args) => dep(ctx, "rm", args["issue"], args["blocker"])
262
296
  },
297
+ {
298
+ name: "relation_list",
299
+ description: "List every typed relation on an issue, including stable relation ids.",
300
+ readOnly: true,
301
+ shape: { issue: identifier },
302
+ handler: (ctx, args) => relationList(ctx, args["issue"])
303
+ },
304
+ {
305
+ name: "relation_create",
306
+ description: "Create a typed relation between two issues.",
307
+ readOnly: false,
308
+ shape: {
309
+ issue: identifier,
310
+ related_issue: identifier,
311
+ type: z.enum(RELATION_CREATE_TYPES)
312
+ },
313
+ handler: (ctx, args) => relationAdd(ctx, args["issue"], args["related_issue"], args["type"])
314
+ },
315
+ {
316
+ name: "relation_update",
317
+ description: "Change a relation's direction, resolving it by relation id or unique related issue.",
318
+ readOnly: false,
319
+ shape: {
320
+ issue: identifier,
321
+ id_or_issue: z.string(),
322
+ type: z.enum(RELATION_UPDATE_TYPES)
323
+ },
324
+ handler: (ctx, args) => relationUpdate(ctx, args["issue"], args["id_or_issue"], args["type"])
325
+ },
326
+ {
327
+ name: "relation_delete",
328
+ description: "Remove a typed relation by relation id or unique related issue.",
329
+ readOnly: false,
330
+ shape: { issue: identifier, id_or_issue: z.string() },
331
+ handler: (ctx, args) => relationRm(ctx, args["issue"], args["id_or_issue"])
332
+ },
263
333
  // ── Links (URLs on an issue) ──
264
334
  {
265
335
  name: "link_list",
@@ -275,6 +345,20 @@ var TOOLS = [
275
345
  shape: { issue: identifier, url: z.string(), title: z.string().optional() },
276
346
  handler: (ctx, args) => linkAdd(ctx, args["issue"], args["url"], args["title"])
277
347
  },
348
+ {
349
+ name: "link_update",
350
+ description: "Edit a link by link id or exact URL.",
351
+ readOnly: false,
352
+ shape: {
353
+ issue: identifier,
354
+ id_or_url: z.string(),
355
+ change: z.union([
356
+ z.object({ url: z.string(), title: z.string().optional() }),
357
+ z.object({ title: z.string(), url: z.string().optional() })
358
+ ])
359
+ },
360
+ handler: (ctx, args) => linkUpdate(ctx, args["issue"], args["id_or_url"], args["change"])
361
+ },
278
362
  {
279
363
  name: "link_rm",
280
364
  description: "Remove a link from an issue, by link id or exact URL.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allocator-one/harness-cli",
3
- "version": "1.6.0",
3
+ "version": "1.8.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
@@ -288,6 +288,7 @@
288
288
  "type": "string"
289
289
  },
290
290
  "name": {
291
+ "description": "Unique within its workspace or team after trimming Unicode boundary whitespace and Unicode-aware lowercasing. Interior whitespace and Unicode normalization forms remain distinct.",
291
292
  "maxLength": 255,
292
293
  "type": "string"
293
294
  },
@@ -382,7 +383,9 @@
382
383
  "schema": {
383
384
  "properties": {
384
385
  "label_ids": {
386
+ "description": "The complete replacement set. Every label must belong to this workspace and be workspace-scoped or owned by the issue's team; the update is rejected atomically otherwise.",
385
387
  "items": {
388
+ "format": "uuid",
386
389
  "type": "string"
387
390
  },
388
391
  "type": "array"
@@ -564,6 +567,15 @@
564
567
  "type": "string"
565
568
  }
566
569
  },
570
+ {
571
+ "description": "\"me\" keeps issues created by, assigned to, or mentioning the token owner",
572
+ "in": "query",
573
+ "name": "involved",
574
+ "required": false,
575
+ "schema": {
576
+ "type": "string"
577
+ }
578
+ },
567
579
  {
568
580
  "description": "Filter by priority (0-4)",
569
581
  "in": "query",
@@ -1361,7 +1373,7 @@
1361
1373
  "get": {
1362
1374
  "callbacks": {},
1363
1375
  "deprecated": false,
1364
- "description": "Requires the `harness:read` scope AND Harness administrator standing in the organization the credential is bound to — this is a settings write, gated exactly as the settings UI is. Everyone who can reach this organization's tracker: its harness grant holders plus its own admins, who get staff standing without a grant. Each entry carries both `role` (the grant rank, null when none) and `standing` — they differ, and the difference is the point: an org admin holds no rank and is still staff. Distinct from GET /users, which is the deployment-wide assignee picker and never lists a collaborator.",
1376
+ "description": "Requires the `harness:read` scope AND Harness administrator standing in the organization the credential is bound to — this is a settings write, gated exactly as the settings UI is. Everyone who can reach this organization's tracker: its harness grant holders plus its own admins, who get staff standing without a grant. Each entry carries both `role` (the grant rank, null when none) and `standing` — they differ, and the difference is the point: an org admin holds no rank and is still staff. Pending invitations appear with `standing: \"invited\"` and a null user id. Distinct from GET /users, which is the deployment-wide assignee picker and never lists a collaborator.",
1365
1377
  "parameters": [],
1366
1378
  "responses": {
1367
1379
  "200": {
@@ -1389,7 +1401,7 @@
1389
1401
  "post": {
1390
1402
  "callbacks": {},
1391
1403
  "deprecated": false,
1392
- "description": "Requires the `harness:write` scope AND Harness administrator standing in the organization the credential is bound to — this is a settings write, gated exactly as the settings UI is. Mints the organization membership when the person has none, so joining the tracker does not detour through the organization's own people page. The account must already exist: an unknown email is refused with 422 naming that, since the fix is for them to register. Email matching is case-insensitive.",
1404
+ "description": "Requires the `harness:write` scope AND Harness administrator standing in the organization the credential is bound to — this is a settings write, gated exactly as the settings UI is. Mints the organization membership when the person has none, so joining the tracker does not detour through the organization's own people page. An address with no account is INVITED instead of refused (IO-10955): the response is the same member shape with `standing: \"invited\"`, and the rank applies when the invitation is accepted. Email matching is case-insensitive.",
1393
1405
  "parameters": [],
1394
1406
  "requestBody": {
1395
1407
  "content": {
@@ -2158,6 +2170,7 @@
2158
2170
  "type": "string"
2159
2171
  },
2160
2172
  "name": {
2173
+ "description": "Unique within its workspace or team after trimming Unicode boundary whitespace and Unicode-aware lowercasing. Interior whitespace and Unicode normalization forms remain distinct.",
2161
2174
  "maxLength": 255,
2162
2175
  "type": "string"
2163
2176
  }
@@ -2281,6 +2294,302 @@
2281
2294
  "tags": []
2282
2295
  }
2283
2296
  },
2297
+ "/api/v1/issues/{identifier}/comments/{comment_id}": {
2298
+ "delete": {
2299
+ "callbacks": {},
2300
+ "deprecated": false,
2301
+ "description": "Requires the `harness:write` scope. Only the underlying authenticated author may soft-delete a comment, and must also retain issue-comment capability, matching the UI. Missing, deleted, cross-issue, cross-organization, and other authors' comments all answer 404.",
2302
+ "parameters": [
2303
+ {
2304
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
2305
+ "in": "path",
2306
+ "name": "identifier",
2307
+ "required": true,
2308
+ "schema": {
2309
+ "type": "string"
2310
+ }
2311
+ },
2312
+ {
2313
+ "description": "Comment UUID",
2314
+ "in": "path",
2315
+ "name": "comment_id",
2316
+ "required": true,
2317
+ "schema": {
2318
+ "format": "uuid",
2319
+ "type": "string"
2320
+ }
2321
+ }
2322
+ ],
2323
+ "responses": {
2324
+ "200": {
2325
+ "content": {
2326
+ "application/json": {
2327
+ "schema": {
2328
+ "properties": {
2329
+ "data": {
2330
+ "properties": {
2331
+ "deleted": {
2332
+ "type": "boolean"
2333
+ }
2334
+ },
2335
+ "required": [
2336
+ "deleted"
2337
+ ],
2338
+ "type": "object"
2339
+ }
2340
+ },
2341
+ "required": [
2342
+ "data"
2343
+ ],
2344
+ "type": "object"
2345
+ }
2346
+ }
2347
+ },
2348
+ "description": "Success"
2349
+ },
2350
+ "400": {
2351
+ "description": "Bad request"
2352
+ },
2353
+ "401": {
2354
+ "description": "Missing or invalid token"
2355
+ },
2356
+ "403": {
2357
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
2358
+ },
2359
+ "404": {
2360
+ "description": "Not found"
2361
+ },
2362
+ "422": {
2363
+ "description": "Validation error"
2364
+ }
2365
+ },
2366
+ "summary": "Delete an issue comment",
2367
+ "tags": []
2368
+ },
2369
+ "patch": {
2370
+ "callbacks": {},
2371
+ "deprecated": false,
2372
+ "description": "Requires the `harness:write` scope. Only the underlying authenticated author may edit a comment, within 15 minutes of creation. The author must also retain issue-comment capability, matching the UI. The body changes; attribution, metadata, reply target, mentions, and creation time do not. `actor` is accepted for CLI compatibility but ignored for ownership and attribution.",
2373
+ "parameters": [
2374
+ {
2375
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
2376
+ "in": "path",
2377
+ "name": "identifier",
2378
+ "required": true,
2379
+ "schema": {
2380
+ "type": "string"
2381
+ }
2382
+ },
2383
+ {
2384
+ "description": "Comment UUID",
2385
+ "in": "path",
2386
+ "name": "comment_id",
2387
+ "required": true,
2388
+ "schema": {
2389
+ "format": "uuid",
2390
+ "type": "string"
2391
+ }
2392
+ }
2393
+ ],
2394
+ "requestBody": {
2395
+ "content": {
2396
+ "application/json": {
2397
+ "schema": {
2398
+ "anyOf": [
2399
+ {
2400
+ "properties": {
2401
+ "actor": {
2402
+ "description": "Accepted for CLI compatibility; ignored because editing never changes attribution",
2403
+ "type": "string"
2404
+ },
2405
+ "body": {
2406
+ "description": "Replacement comment body; takes precedence when content is also present",
2407
+ "minLength": 1,
2408
+ "type": "string"
2409
+ },
2410
+ "content": {
2411
+ "description": "Alias for body",
2412
+ "minLength": 1,
2413
+ "type": "string"
2414
+ }
2415
+ },
2416
+ "required": [
2417
+ "body"
2418
+ ],
2419
+ "type": "object"
2420
+ },
2421
+ {
2422
+ "properties": {
2423
+ "actor": {
2424
+ "description": "Accepted for CLI compatibility; ignored because editing never changes attribution",
2425
+ "type": "string"
2426
+ },
2427
+ "body": {
2428
+ "description": "Replacement comment body; takes precedence when content is also present",
2429
+ "minLength": 1,
2430
+ "type": "string"
2431
+ },
2432
+ "content": {
2433
+ "description": "Alias for body",
2434
+ "minLength": 1,
2435
+ "type": "string"
2436
+ }
2437
+ },
2438
+ "required": [
2439
+ "content"
2440
+ ],
2441
+ "type": "object"
2442
+ }
2443
+ ]
2444
+ }
2445
+ }
2446
+ },
2447
+ "required": true
2448
+ },
2449
+ "responses": {
2450
+ "200": {
2451
+ "content": {
2452
+ "application/json": {
2453
+ "schema": {
2454
+ "properties": {
2455
+ "data": {
2456
+ "properties": {
2457
+ "author": {
2458
+ "nullable": true,
2459
+ "properties": {
2460
+ "email": {
2461
+ "nullable": true,
2462
+ "type": "string"
2463
+ },
2464
+ "id": {
2465
+ "format": "uuid",
2466
+ "nullable": true,
2467
+ "type": "string"
2468
+ },
2469
+ "name": {
2470
+ "nullable": true,
2471
+ "type": "string"
2472
+ }
2473
+ },
2474
+ "required": [
2475
+ "id",
2476
+ "name",
2477
+ "email"
2478
+ ],
2479
+ "type": "object"
2480
+ },
2481
+ "body": {
2482
+ "type": "string"
2483
+ },
2484
+ "deleted": {
2485
+ "type": "boolean"
2486
+ },
2487
+ "id": {
2488
+ "format": "uuid",
2489
+ "type": "string"
2490
+ },
2491
+ "inserted_at": {
2492
+ "format": "date-time",
2493
+ "type": "string"
2494
+ },
2495
+ "mentions": {
2496
+ "items": {
2497
+ "properties": {
2498
+ "email": {
2499
+ "nullable": true,
2500
+ "type": "string"
2501
+ },
2502
+ "name": {
2503
+ "nullable": true,
2504
+ "type": "string"
2505
+ },
2506
+ "user_id": {
2507
+ "format": "uuid",
2508
+ "nullable": true,
2509
+ "type": "string"
2510
+ }
2511
+ },
2512
+ "required": [
2513
+ "user_id",
2514
+ "name",
2515
+ "email"
2516
+ ],
2517
+ "type": "object"
2518
+ },
2519
+ "type": "array"
2520
+ },
2521
+ "reactions": {
2522
+ "items": {
2523
+ "properties": {
2524
+ "count": {
2525
+ "type": "integer"
2526
+ },
2527
+ "emoji": {
2528
+ "type": "string"
2529
+ },
2530
+ "reacted_by_current_actor": {
2531
+ "type": "boolean"
2532
+ }
2533
+ },
2534
+ "required": [
2535
+ "emoji",
2536
+ "count",
2537
+ "reacted_by_current_actor"
2538
+ ],
2539
+ "type": "object"
2540
+ },
2541
+ "type": "array"
2542
+ },
2543
+ "updated_at": {
2544
+ "format": "date-time",
2545
+ "type": "string"
2546
+ }
2547
+ },
2548
+ "required": [
2549
+ "id",
2550
+ "body",
2551
+ "deleted",
2552
+ "author",
2553
+ "mentions",
2554
+ "reactions",
2555
+ "inserted_at",
2556
+ "updated_at"
2557
+ ],
2558
+ "type": "object"
2559
+ }
2560
+ },
2561
+ "required": [
2562
+ "data"
2563
+ ],
2564
+ "type": "object"
2565
+ }
2566
+ }
2567
+ },
2568
+ "description": "Success"
2569
+ },
2570
+ "400": {
2571
+ "description": "Bad request"
2572
+ },
2573
+ "401": {
2574
+ "description": "Missing or invalid token"
2575
+ },
2576
+ "403": {
2577
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
2578
+ },
2579
+ "404": {
2580
+ "description": "Not found"
2581
+ },
2582
+ "409": {
2583
+ "description": "The 15-minute edit window has closed"
2584
+ },
2585
+ "422": {
2586
+ "description": "Validation error"
2587
+ }
2588
+ },
2589
+ "summary": "Edit an issue comment",
2590
+ "tags": []
2591
+ }
2592
+ },
2284
2593
  "/api/v1/model-keys": {
2285
2594
  "get": {
2286
2595
  "callbacks": {},
@@ -2510,6 +2819,144 @@
2510
2819
  },
2511
2820
  "summary": "Delete a relation",
2512
2821
  "tags": []
2822
+ },
2823
+ "patch": {
2824
+ "callbacks": {},
2825
+ "deprecated": false,
2826
+ "description": "Requires the `harness:write` scope.",
2827
+ "parameters": [
2828
+ {
2829
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
2830
+ "in": "path",
2831
+ "name": "identifier",
2832
+ "required": true,
2833
+ "schema": {
2834
+ "type": "string"
2835
+ }
2836
+ },
2837
+ {
2838
+ "description": "Resource id",
2839
+ "in": "path",
2840
+ "name": "id",
2841
+ "required": true,
2842
+ "schema": {
2843
+ "type": "string"
2844
+ }
2845
+ }
2846
+ ],
2847
+ "requestBody": {
2848
+ "content": {
2849
+ "application/json": {
2850
+ "schema": {
2851
+ "properties": {
2852
+ "type": {
2853
+ "description": "Direction from the issue in the request path",
2854
+ "enum": [
2855
+ "blocks",
2856
+ "blocked_by",
2857
+ "related",
2858
+ "duplicate_of"
2859
+ ],
2860
+ "type": "string"
2861
+ }
2862
+ },
2863
+ "required": [
2864
+ "type"
2865
+ ],
2866
+ "type": "object"
2867
+ }
2868
+ }
2869
+ },
2870
+ "required": true
2871
+ },
2872
+ "responses": {
2873
+ "200": {
2874
+ "content": {
2875
+ "application/json": {
2876
+ "schema": {
2877
+ "properties": {
2878
+ "data": {
2879
+ "properties": {
2880
+ "direction": {
2881
+ "enum": [
2882
+ "blocks",
2883
+ "blocked_by",
2884
+ "related",
2885
+ "duplicate_of",
2886
+ "duplicated_by"
2887
+ ],
2888
+ "type": "string"
2889
+ },
2890
+ "id": {
2891
+ "format": "uuid",
2892
+ "type": "string"
2893
+ },
2894
+ "inserted_at": {
2895
+ "format": "date-time",
2896
+ "type": "string"
2897
+ },
2898
+ "issue": {
2899
+ "type": "string"
2900
+ },
2901
+ "other_issue": {
2902
+ "type": "string"
2903
+ },
2904
+ "related_issue": {
2905
+ "type": "string"
2906
+ },
2907
+ "type": {
2908
+ "enum": [
2909
+ "blocks",
2910
+ "related",
2911
+ "duplicate"
2912
+ ],
2913
+ "type": "string"
2914
+ },
2915
+ "updated_at": {
2916
+ "format": "date-time",
2917
+ "type": "string"
2918
+ }
2919
+ },
2920
+ "required": [
2921
+ "id",
2922
+ "type",
2923
+ "issue",
2924
+ "related_issue",
2925
+ "direction",
2926
+ "other_issue",
2927
+ "inserted_at",
2928
+ "updated_at"
2929
+ ],
2930
+ "type": "object"
2931
+ }
2932
+ },
2933
+ "required": [
2934
+ "data"
2935
+ ],
2936
+ "type": "object"
2937
+ }
2938
+ }
2939
+ },
2940
+ "description": "Success"
2941
+ },
2942
+ "400": {
2943
+ "description": "Bad request"
2944
+ },
2945
+ "401": {
2946
+ "description": "Missing or invalid token"
2947
+ },
2948
+ "403": {
2949
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
2950
+ },
2951
+ "404": {
2952
+ "description": "Not found"
2953
+ },
2954
+ "422": {
2955
+ "description": "Validation error"
2956
+ }
2957
+ },
2958
+ "summary": "Update a relation",
2959
+ "tags": []
2513
2960
  }
2514
2961
  },
2515
2962
  "/api/v1/issues/{identifier}/links/{id}": {
@@ -2559,6 +3006,127 @@
2559
3006
  },
2560
3007
  "summary": "Delete a link",
2561
3008
  "tags": []
3009
+ },
3010
+ "patch": {
3011
+ "callbacks": {},
3012
+ "deprecated": false,
3013
+ "description": "Requires the `harness:write` scope.",
3014
+ "parameters": [
3015
+ {
3016
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
3017
+ "in": "path",
3018
+ "name": "identifier",
3019
+ "required": true,
3020
+ "schema": {
3021
+ "type": "string"
3022
+ }
3023
+ },
3024
+ {
3025
+ "description": "Resource id",
3026
+ "in": "path",
3027
+ "name": "id",
3028
+ "required": true,
3029
+ "schema": {
3030
+ "type": "string"
3031
+ }
3032
+ }
3033
+ ],
3034
+ "requestBody": {
3035
+ "content": {
3036
+ "application/json": {
3037
+ "schema": {
3038
+ "properties": {
3039
+ "expected_url": {
3040
+ "description": "Optional optimistic precondition; mismatch returns 409",
3041
+ "type": "string"
3042
+ },
3043
+ "title": {
3044
+ "type": "string"
3045
+ },
3046
+ "url": {
3047
+ "description": "http(s) URL",
3048
+ "type": "string"
3049
+ }
3050
+ },
3051
+ "type": "object"
3052
+ }
3053
+ }
3054
+ },
3055
+ "required": true
3056
+ },
3057
+ "responses": {
3058
+ "200": {
3059
+ "content": {
3060
+ "application/json": {
3061
+ "schema": {
3062
+ "properties": {
3063
+ "data": {
3064
+ "properties": {
3065
+ "id": {
3066
+ "format": "uuid",
3067
+ "type": "string"
3068
+ },
3069
+ "inserted_at": {
3070
+ "format": "date-time",
3071
+ "type": "string"
3072
+ },
3073
+ "issue_id": {
3074
+ "format": "uuid",
3075
+ "type": "string"
3076
+ },
3077
+ "title": {
3078
+ "nullable": true,
3079
+ "type": "string"
3080
+ },
3081
+ "updated_at": {
3082
+ "format": "date-time",
3083
+ "type": "string"
3084
+ },
3085
+ "url": {
3086
+ "type": "string"
3087
+ }
3088
+ },
3089
+ "required": [
3090
+ "id",
3091
+ "url",
3092
+ "title",
3093
+ "issue_id",
3094
+ "inserted_at",
3095
+ "updated_at"
3096
+ ],
3097
+ "type": "object"
3098
+ }
3099
+ },
3100
+ "required": [
3101
+ "data"
3102
+ ],
3103
+ "type": "object"
3104
+ }
3105
+ }
3106
+ },
3107
+ "description": "Success"
3108
+ },
3109
+ "400": {
3110
+ "description": "Bad request"
3111
+ },
3112
+ "401": {
3113
+ "description": "Missing or invalid token"
3114
+ },
3115
+ "403": {
3116
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
3117
+ },
3118
+ "404": {
3119
+ "description": "Not found"
3120
+ },
3121
+ "409": {
3122
+ "description": "The link URL changed after the client resolved it"
3123
+ },
3124
+ "422": {
3125
+ "description": "Validation error"
3126
+ }
3127
+ },
3128
+ "summary": "Update a link",
3129
+ "tags": []
2562
3130
  }
2563
3131
  },
2564
3132
  "/api/v1/issues/{identifier}/relations": {