@allocator-one/harness-cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,7 +53,9 @@ the browser login attributes writes to the real user.
53
53
 
54
54
  `harness mcp` runs a stdio MCP server exposing every operation as a typed
55
55
  tool — issue_list, issue_show, issue_create, issue_update, issue_claim,
56
- issue_close, issue_comment, relation_add/rm, link_list/add/rm,
56
+ issue_close, issue_comment, issue_comment_edit, issue_comment_delete,
57
+ relation_list/create/update/delete, relation_add/rm (blocking shorthand),
58
+ link_list/add/update/rm,
57
59
  project_list/show/create/update, milestone_list/create,
58
60
  document_list/show/create/update, label_list/create, team_list,
59
61
  team_create, workflow_states, user_list, stats, upload. Read-only tools carry
@@ -69,6 +71,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)) {
@@ -1349,13 +1457,36 @@ async function linkAdd(ctx, issue, url, title) {
1349
1457
  return { json: response.data, human: `Linked ${clean(url)} to ${issue}
1350
1458
  ` };
1351
1459
  }
1352
- async function linkRm(ctx, issue, idOrUrl) {
1353
- requireIdentifiers([issue]);
1460
+ async function findLink(ctx, issue, idOrUrl, allowDuplicateUrl = false) {
1354
1461
  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) {
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) {
1357
1469
  throw new CommandError(`no link on ${issue} matches ${idOrUrl} (by id or exact URL)`);
1358
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);
1359
1490
  await ctx.client.delete(`/api/v1/issues/${issue}/links/${str3(link["id"])}`);
1360
1491
  return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
1361
1492
  ` };
@@ -1827,7 +1958,16 @@ export {
1827
1958
  archive,
1828
1959
  comments,
1829
1960
  dep,
1961
+ RELATION_CREATE_TYPES,
1962
+ RELATION_UPDATE_TYPES,
1963
+ relationList,
1964
+ relationAdd,
1965
+ relationUpdate,
1966
+ relationRm,
1830
1967
  comment,
1968
+ editComment,
1969
+ deleteComment,
1970
+ requireCommentId,
1831
1971
  attachImage,
1832
1972
  projectList,
1833
1973
  projectShow,
@@ -1841,6 +1981,7 @@ export {
1841
1981
  docUpdate,
1842
1982
  linkList,
1843
1983
  linkAdd,
1984
+ linkUpdate,
1844
1985
  linkRm,
1845
1986
  labelList,
1846
1987
  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-ZHFW6PRC.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"];
@@ -1313,7 +1340,42 @@ program.command("dep").description("blocking dependencies: dep add|rm <issue> <b
1313
1340
  });
1314
1341
  }
1315
1342
  );
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(
1343
+ var relation = program.command("relation").description("typed issue relations; dep remains the blocking-dependency shorthand");
1344
+ relation.command("list").argument("[issue]").option("--json", "emit JSON").action(async (issue, opts) => {
1345
+ await withConfig(opts.json === true, false, (ctx) => {
1346
+ if (issue === void 0) throw new UsageError("usage: harness relation list <issue>");
1347
+ return relationList(ctx, issue);
1348
+ });
1349
+ });
1350
+ relation.command("add").argument("[issue]").argument("[related-issue]").requiredOption("--type <type>", `relation type: ${RELATION_CREATE_TYPES.join(", ")}`).option("--json", "emit JSON").action(
1351
+ async (issue, relatedIssue, opts) => {
1352
+ await withConfig(opts.json === true, true, (ctx) => {
1353
+ if (issue === void 0 || relatedIssue === void 0) {
1354
+ throw new UsageError("usage: harness relation add <issue> <related-issue> --type <type>");
1355
+ }
1356
+ return relationAdd(ctx, issue, relatedIssue, opts.type);
1357
+ });
1358
+ }
1359
+ );
1360
+ relation.command("update").argument("[issue]").argument("[id-or-issue]").requiredOption("--type <type>", `relation type: ${RELATION_UPDATE_TYPES.join(", ")}`).option("--json", "emit JSON").action(
1361
+ async (issue, idOrIssue, opts) => {
1362
+ await withConfig(opts.json === true, true, (ctx) => {
1363
+ if (issue === void 0 || idOrIssue === void 0) {
1364
+ throw new UsageError("usage: harness relation update <issue> <relation-id-or-issue> --type <type>");
1365
+ }
1366
+ return relationUpdate(ctx, issue, idOrIssue, opts.type);
1367
+ });
1368
+ }
1369
+ );
1370
+ relation.command("rm").argument("[issue]").argument("[id-or-issue]").option("--json", "emit JSON").action(async (issue, idOrIssue, opts) => {
1371
+ await withConfig(opts.json === true, true, (ctx) => {
1372
+ if (issue === void 0 || idOrIssue === void 0) {
1373
+ throw new UsageError("usage: harness relation rm <issue> <relation-id-or-issue>");
1374
+ }
1375
+ return relationRm(ctx, issue, idOrIssue);
1376
+ });
1377
+ });
1378
+ 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
1379
  async (id, text, opts) => {
1318
1380
  await withConfig(opts.json === true, true, (ctx) => {
1319
1381
  if (id === void 0 || opts.bodyFile === void 0 && text.length === 0) {
@@ -1322,25 +1384,39 @@ program.command("comment").description("comment on an issue").argument("[id]").a
1322
1384
  );
1323
1385
  }
1324
1386
  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() === "") {
1387
+ const body = resolvedCommentBody(text, opts.bodyFile);
1388
+ const mentions = opts.mention.filter((name) => name.trim() !== "");
1389
+ return comment(ctx, id, body, mentions);
1390
+ });
1391
+ }
1392
+ );
1393
+ 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(
1394
+ async (issue, commentId, text, opts) => {
1395
+ const parentOpts = commentCommand.opts();
1396
+ const bodyFile = opts.bodyFile ?? parentOpts.bodyFile;
1397
+ await withConfig(opts.json === true || parentOpts.json === true, true, (ctx) => {
1398
+ if (issue === void 0 || commentId === void 0 || bodyFile === void 0 && text.length === 0) {
1335
1399
  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`
1400
+ "usage: harness comment edit <issue> <comment-id> <text> | harness comment edit <issue> <comment-id> --body-file <path>"
1337
1401
  );
1338
1402
  }
1339
- const mentions = opts.mention.filter((name) => name.trim() !== "");
1340
- return comment(ctx, id, body, mentions);
1403
+ requireIdentifiers2([issue]);
1404
+ requireCommentId(commentId);
1405
+ return editComment(ctx, issue, commentId, resolvedCommentBody(text, bodyFile, "edit"));
1341
1406
  });
1342
1407
  }
1343
1408
  );
1409
+ 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) => {
1410
+ const parentOpts = commentCommand.opts();
1411
+ await withConfig(opts.json === true || parentOpts.json === true, true, (ctx) => {
1412
+ if (issue === void 0 || commentId === void 0) {
1413
+ throw new UsageError("usage: harness comment delete <issue> <comment-id>");
1414
+ }
1415
+ requireIdentifiers2([issue]);
1416
+ requireCommentId(commentId);
1417
+ return deleteComment(ctx, issue, commentId);
1418
+ });
1419
+ });
1344
1420
  program.command("attach-image").description("upload an image; prints markdown to reference it").argument("[path]").option("--json", "emit JSON").action(async (path, opts) => {
1345
1421
  await withConfig(opts.json === true, true, (ctx) => {
1346
1422
  if (path === void 0) {
@@ -1454,6 +1530,18 @@ link.command("add").argument("[issue]").argument("[url]").option("--title <title
1454
1530
  });
1455
1531
  }
1456
1532
  );
1533
+ 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(
1534
+ async (issue, idOrUrl, opts) => {
1535
+ await withConfig(opts.json === true, true, (ctx) => {
1536
+ if (issue === void 0 || idOrUrl === void 0) {
1537
+ throw new UsageError(
1538
+ "usage: harness link update <issue> <link-id-or-url> --url <url> | --title <title>"
1539
+ );
1540
+ }
1541
+ return linkUpdate(ctx, issue, idOrUrl, opts);
1542
+ });
1543
+ }
1544
+ );
1457
1545
  link.command("rm").argument("[issue]").argument("[id-or-url]").option("--json", "emit JSON").action(
1458
1546
  async (issue, idOrUrl, opts) => {
1459
1547
  await withConfig(opts.json === true, true, (ctx) => {
@@ -1567,7 +1655,7 @@ program.command("upload").description("upload a file; prints markdown to referen
1567
1655
  });
1568
1656
  });
1569
1657
  program.command("mcp").description("run the MCP server over stdio (for agents)").action(async () => {
1570
- const { startMcpServer } = await import("./server-WBZ5OPHK.js");
1658
+ const { startMcpServer } = await import("./server-J6FQFVBJ.js");
1571
1659
  await startMcpServer();
1572
1660
  });
1573
1661
  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-ZHFW6PRC.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 ──
@@ -231,6 +241,27 @@ var TOOLS = [
231
241
  },
232
242
  handler: (ctx, args) => comment(ctx, args["id"], [args["body"]], args["mentions"] ?? [])
233
243
  },
244
+ {
245
+ name: "issue_comment_edit",
246
+ description: "Edit one of your issue comments within its edit window.",
247
+ readOnly: false,
248
+ shape: {
249
+ id: identifier,
250
+ comment_id: commentId,
251
+ body: z.string().min(1).refine((body) => body.trim() !== "", "comment body must not be blank")
252
+ },
253
+ handler: (ctx, args) => editComment(ctx, args["id"], args["comment_id"], [args["body"]])
254
+ },
255
+ {
256
+ name: "issue_comment_delete",
257
+ description: "Delete one of your issue comments.",
258
+ readOnly: false,
259
+ shape: {
260
+ id: identifier,
261
+ comment_id: commentId
262
+ },
263
+ handler: (ctx, args) => deleteComment(ctx, args["id"], args["comment_id"])
264
+ },
234
265
  {
235
266
  name: "issue_archive",
236
267
  description: "Archive issues (or restore them with restore=true). Archived issues show up under scope=archived.",
@@ -245,7 +276,7 @@ var TOOLS = [
245
276
  shape: { id: identifier },
246
277
  handler: (ctx, args) => comments(ctx, args["id"])
247
278
  },
248
- // ── Relations (blocking dependencies) ──
279
+ // ── Relations ──
249
280
  {
250
281
  name: "relation_add",
251
282
  description: "Add a blocking dependency: blocker blocks issue.",
@@ -260,6 +291,42 @@ var TOOLS = [
260
291
  shape: { issue: identifier, blocker: identifier },
261
292
  handler: (ctx, args) => dep(ctx, "rm", args["issue"], args["blocker"])
262
293
  },
294
+ {
295
+ name: "relation_list",
296
+ description: "List every typed relation on an issue, including stable relation ids.",
297
+ readOnly: true,
298
+ shape: { issue: identifier },
299
+ handler: (ctx, args) => relationList(ctx, args["issue"])
300
+ },
301
+ {
302
+ name: "relation_create",
303
+ description: "Create a typed relation between two issues.",
304
+ readOnly: false,
305
+ shape: {
306
+ issue: identifier,
307
+ related_issue: identifier,
308
+ type: z.enum(RELATION_CREATE_TYPES)
309
+ },
310
+ handler: (ctx, args) => relationAdd(ctx, args["issue"], args["related_issue"], args["type"])
311
+ },
312
+ {
313
+ name: "relation_update",
314
+ description: "Change a relation's direction, resolving it by relation id or unique related issue.",
315
+ readOnly: false,
316
+ shape: {
317
+ issue: identifier,
318
+ id_or_issue: z.string(),
319
+ type: z.enum(RELATION_UPDATE_TYPES)
320
+ },
321
+ handler: (ctx, args) => relationUpdate(ctx, args["issue"], args["id_or_issue"], args["type"])
322
+ },
323
+ {
324
+ name: "relation_delete",
325
+ description: "Remove a typed relation by relation id or unique related issue.",
326
+ readOnly: false,
327
+ shape: { issue: identifier, id_or_issue: z.string() },
328
+ handler: (ctx, args) => relationRm(ctx, args["issue"], args["id_or_issue"])
329
+ },
263
330
  // ── Links (URLs on an issue) ──
264
331
  {
265
332
  name: "link_list",
@@ -275,6 +342,20 @@ var TOOLS = [
275
342
  shape: { issue: identifier, url: z.string(), title: z.string().optional() },
276
343
  handler: (ctx, args) => linkAdd(ctx, args["issue"], args["url"], args["title"])
277
344
  },
345
+ {
346
+ name: "link_update",
347
+ description: "Edit a link by link id or exact URL.",
348
+ readOnly: false,
349
+ shape: {
350
+ issue: identifier,
351
+ id_or_url: z.string(),
352
+ change: z.union([
353
+ z.object({ url: z.string(), title: z.string().optional() }),
354
+ z.object({ title: z.string(), url: z.string().optional() })
355
+ ])
356
+ },
357
+ handler: (ctx, args) => linkUpdate(ctx, args["issue"], args["id_or_url"], args["change"])
358
+ },
278
359
  {
279
360
  name: "link_rm",
280
361
  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.7.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"
@@ -1361,7 +1364,7 @@
1361
1364
  "get": {
1362
1365
  "callbacks": {},
1363
1366
  "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.",
1367
+ "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
1368
  "parameters": [],
1366
1369
  "responses": {
1367
1370
  "200": {
@@ -1389,7 +1392,7 @@
1389
1392
  "post": {
1390
1393
  "callbacks": {},
1391
1394
  "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.",
1395
+ "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
1396
  "parameters": [],
1394
1397
  "requestBody": {
1395
1398
  "content": {
@@ -2158,6 +2161,7 @@
2158
2161
  "type": "string"
2159
2162
  },
2160
2163
  "name": {
2164
+ "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
2165
  "maxLength": 255,
2162
2166
  "type": "string"
2163
2167
  }
@@ -2281,6 +2285,302 @@
2281
2285
  "tags": []
2282
2286
  }
2283
2287
  },
2288
+ "/api/v1/issues/{identifier}/comments/{comment_id}": {
2289
+ "delete": {
2290
+ "callbacks": {},
2291
+ "deprecated": false,
2292
+ "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.",
2293
+ "parameters": [
2294
+ {
2295
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
2296
+ "in": "path",
2297
+ "name": "identifier",
2298
+ "required": true,
2299
+ "schema": {
2300
+ "type": "string"
2301
+ }
2302
+ },
2303
+ {
2304
+ "description": "Comment UUID",
2305
+ "in": "path",
2306
+ "name": "comment_id",
2307
+ "required": true,
2308
+ "schema": {
2309
+ "format": "uuid",
2310
+ "type": "string"
2311
+ }
2312
+ }
2313
+ ],
2314
+ "responses": {
2315
+ "200": {
2316
+ "content": {
2317
+ "application/json": {
2318
+ "schema": {
2319
+ "properties": {
2320
+ "data": {
2321
+ "properties": {
2322
+ "deleted": {
2323
+ "type": "boolean"
2324
+ }
2325
+ },
2326
+ "required": [
2327
+ "deleted"
2328
+ ],
2329
+ "type": "object"
2330
+ }
2331
+ },
2332
+ "required": [
2333
+ "data"
2334
+ ],
2335
+ "type": "object"
2336
+ }
2337
+ }
2338
+ },
2339
+ "description": "Success"
2340
+ },
2341
+ "400": {
2342
+ "description": "Bad request"
2343
+ },
2344
+ "401": {
2345
+ "description": "Missing or invalid token"
2346
+ },
2347
+ "403": {
2348
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
2349
+ },
2350
+ "404": {
2351
+ "description": "Not found"
2352
+ },
2353
+ "422": {
2354
+ "description": "Validation error"
2355
+ }
2356
+ },
2357
+ "summary": "Delete an issue comment",
2358
+ "tags": []
2359
+ },
2360
+ "patch": {
2361
+ "callbacks": {},
2362
+ "deprecated": false,
2363
+ "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.",
2364
+ "parameters": [
2365
+ {
2366
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
2367
+ "in": "path",
2368
+ "name": "identifier",
2369
+ "required": true,
2370
+ "schema": {
2371
+ "type": "string"
2372
+ }
2373
+ },
2374
+ {
2375
+ "description": "Comment UUID",
2376
+ "in": "path",
2377
+ "name": "comment_id",
2378
+ "required": true,
2379
+ "schema": {
2380
+ "format": "uuid",
2381
+ "type": "string"
2382
+ }
2383
+ }
2384
+ ],
2385
+ "requestBody": {
2386
+ "content": {
2387
+ "application/json": {
2388
+ "schema": {
2389
+ "anyOf": [
2390
+ {
2391
+ "properties": {
2392
+ "actor": {
2393
+ "description": "Accepted for CLI compatibility; ignored because editing never changes attribution",
2394
+ "type": "string"
2395
+ },
2396
+ "body": {
2397
+ "description": "Replacement comment body; takes precedence when content is also present",
2398
+ "minLength": 1,
2399
+ "type": "string"
2400
+ },
2401
+ "content": {
2402
+ "description": "Alias for body",
2403
+ "minLength": 1,
2404
+ "type": "string"
2405
+ }
2406
+ },
2407
+ "required": [
2408
+ "body"
2409
+ ],
2410
+ "type": "object"
2411
+ },
2412
+ {
2413
+ "properties": {
2414
+ "actor": {
2415
+ "description": "Accepted for CLI compatibility; ignored because editing never changes attribution",
2416
+ "type": "string"
2417
+ },
2418
+ "body": {
2419
+ "description": "Replacement comment body; takes precedence when content is also present",
2420
+ "minLength": 1,
2421
+ "type": "string"
2422
+ },
2423
+ "content": {
2424
+ "description": "Alias for body",
2425
+ "minLength": 1,
2426
+ "type": "string"
2427
+ }
2428
+ },
2429
+ "required": [
2430
+ "content"
2431
+ ],
2432
+ "type": "object"
2433
+ }
2434
+ ]
2435
+ }
2436
+ }
2437
+ },
2438
+ "required": true
2439
+ },
2440
+ "responses": {
2441
+ "200": {
2442
+ "content": {
2443
+ "application/json": {
2444
+ "schema": {
2445
+ "properties": {
2446
+ "data": {
2447
+ "properties": {
2448
+ "author": {
2449
+ "nullable": true,
2450
+ "properties": {
2451
+ "email": {
2452
+ "nullable": true,
2453
+ "type": "string"
2454
+ },
2455
+ "id": {
2456
+ "format": "uuid",
2457
+ "nullable": true,
2458
+ "type": "string"
2459
+ },
2460
+ "name": {
2461
+ "nullable": true,
2462
+ "type": "string"
2463
+ }
2464
+ },
2465
+ "required": [
2466
+ "id",
2467
+ "name",
2468
+ "email"
2469
+ ],
2470
+ "type": "object"
2471
+ },
2472
+ "body": {
2473
+ "type": "string"
2474
+ },
2475
+ "deleted": {
2476
+ "type": "boolean"
2477
+ },
2478
+ "id": {
2479
+ "format": "uuid",
2480
+ "type": "string"
2481
+ },
2482
+ "inserted_at": {
2483
+ "format": "date-time",
2484
+ "type": "string"
2485
+ },
2486
+ "mentions": {
2487
+ "items": {
2488
+ "properties": {
2489
+ "email": {
2490
+ "nullable": true,
2491
+ "type": "string"
2492
+ },
2493
+ "name": {
2494
+ "nullable": true,
2495
+ "type": "string"
2496
+ },
2497
+ "user_id": {
2498
+ "format": "uuid",
2499
+ "nullable": true,
2500
+ "type": "string"
2501
+ }
2502
+ },
2503
+ "required": [
2504
+ "user_id",
2505
+ "name",
2506
+ "email"
2507
+ ],
2508
+ "type": "object"
2509
+ },
2510
+ "type": "array"
2511
+ },
2512
+ "reactions": {
2513
+ "items": {
2514
+ "properties": {
2515
+ "count": {
2516
+ "type": "integer"
2517
+ },
2518
+ "emoji": {
2519
+ "type": "string"
2520
+ },
2521
+ "reacted_by_current_actor": {
2522
+ "type": "boolean"
2523
+ }
2524
+ },
2525
+ "required": [
2526
+ "emoji",
2527
+ "count",
2528
+ "reacted_by_current_actor"
2529
+ ],
2530
+ "type": "object"
2531
+ },
2532
+ "type": "array"
2533
+ },
2534
+ "updated_at": {
2535
+ "format": "date-time",
2536
+ "type": "string"
2537
+ }
2538
+ },
2539
+ "required": [
2540
+ "id",
2541
+ "body",
2542
+ "deleted",
2543
+ "author",
2544
+ "mentions",
2545
+ "reactions",
2546
+ "inserted_at",
2547
+ "updated_at"
2548
+ ],
2549
+ "type": "object"
2550
+ }
2551
+ },
2552
+ "required": [
2553
+ "data"
2554
+ ],
2555
+ "type": "object"
2556
+ }
2557
+ }
2558
+ },
2559
+ "description": "Success"
2560
+ },
2561
+ "400": {
2562
+ "description": "Bad request"
2563
+ },
2564
+ "401": {
2565
+ "description": "Missing or invalid token"
2566
+ },
2567
+ "403": {
2568
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
2569
+ },
2570
+ "404": {
2571
+ "description": "Not found"
2572
+ },
2573
+ "409": {
2574
+ "description": "The 15-minute edit window has closed"
2575
+ },
2576
+ "422": {
2577
+ "description": "Validation error"
2578
+ }
2579
+ },
2580
+ "summary": "Edit an issue comment",
2581
+ "tags": []
2582
+ }
2583
+ },
2284
2584
  "/api/v1/model-keys": {
2285
2585
  "get": {
2286
2586
  "callbacks": {},
@@ -2510,6 +2810,144 @@
2510
2810
  },
2511
2811
  "summary": "Delete a relation",
2512
2812
  "tags": []
2813
+ },
2814
+ "patch": {
2815
+ "callbacks": {},
2816
+ "deprecated": false,
2817
+ "description": "Requires the `harness:write` scope.",
2818
+ "parameters": [
2819
+ {
2820
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
2821
+ "in": "path",
2822
+ "name": "identifier",
2823
+ "required": true,
2824
+ "schema": {
2825
+ "type": "string"
2826
+ }
2827
+ },
2828
+ {
2829
+ "description": "Resource id",
2830
+ "in": "path",
2831
+ "name": "id",
2832
+ "required": true,
2833
+ "schema": {
2834
+ "type": "string"
2835
+ }
2836
+ }
2837
+ ],
2838
+ "requestBody": {
2839
+ "content": {
2840
+ "application/json": {
2841
+ "schema": {
2842
+ "properties": {
2843
+ "type": {
2844
+ "description": "Direction from the issue in the request path",
2845
+ "enum": [
2846
+ "blocks",
2847
+ "blocked_by",
2848
+ "related",
2849
+ "duplicate_of"
2850
+ ],
2851
+ "type": "string"
2852
+ }
2853
+ },
2854
+ "required": [
2855
+ "type"
2856
+ ],
2857
+ "type": "object"
2858
+ }
2859
+ }
2860
+ },
2861
+ "required": true
2862
+ },
2863
+ "responses": {
2864
+ "200": {
2865
+ "content": {
2866
+ "application/json": {
2867
+ "schema": {
2868
+ "properties": {
2869
+ "data": {
2870
+ "properties": {
2871
+ "direction": {
2872
+ "enum": [
2873
+ "blocks",
2874
+ "blocked_by",
2875
+ "related",
2876
+ "duplicate_of",
2877
+ "duplicated_by"
2878
+ ],
2879
+ "type": "string"
2880
+ },
2881
+ "id": {
2882
+ "format": "uuid",
2883
+ "type": "string"
2884
+ },
2885
+ "inserted_at": {
2886
+ "format": "date-time",
2887
+ "type": "string"
2888
+ },
2889
+ "issue": {
2890
+ "type": "string"
2891
+ },
2892
+ "other_issue": {
2893
+ "type": "string"
2894
+ },
2895
+ "related_issue": {
2896
+ "type": "string"
2897
+ },
2898
+ "type": {
2899
+ "enum": [
2900
+ "blocks",
2901
+ "related",
2902
+ "duplicate"
2903
+ ],
2904
+ "type": "string"
2905
+ },
2906
+ "updated_at": {
2907
+ "format": "date-time",
2908
+ "type": "string"
2909
+ }
2910
+ },
2911
+ "required": [
2912
+ "id",
2913
+ "type",
2914
+ "issue",
2915
+ "related_issue",
2916
+ "direction",
2917
+ "other_issue",
2918
+ "inserted_at",
2919
+ "updated_at"
2920
+ ],
2921
+ "type": "object"
2922
+ }
2923
+ },
2924
+ "required": [
2925
+ "data"
2926
+ ],
2927
+ "type": "object"
2928
+ }
2929
+ }
2930
+ },
2931
+ "description": "Success"
2932
+ },
2933
+ "400": {
2934
+ "description": "Bad request"
2935
+ },
2936
+ "401": {
2937
+ "description": "Missing or invalid token"
2938
+ },
2939
+ "403": {
2940
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
2941
+ },
2942
+ "404": {
2943
+ "description": "Not found"
2944
+ },
2945
+ "422": {
2946
+ "description": "Validation error"
2947
+ }
2948
+ },
2949
+ "summary": "Update a relation",
2950
+ "tags": []
2513
2951
  }
2514
2952
  },
2515
2953
  "/api/v1/issues/{identifier}/links/{id}": {
@@ -2559,6 +2997,127 @@
2559
2997
  },
2560
2998
  "summary": "Delete a link",
2561
2999
  "tags": []
3000
+ },
3001
+ "patch": {
3002
+ "callbacks": {},
3003
+ "deprecated": false,
3004
+ "description": "Requires the `harness:write` scope.",
3005
+ "parameters": [
3006
+ {
3007
+ "description": "Issue identifier, e.g. ALL-4901 or CLI-12 (issues minted before team-prefixed numbering carry IO-)",
3008
+ "in": "path",
3009
+ "name": "identifier",
3010
+ "required": true,
3011
+ "schema": {
3012
+ "type": "string"
3013
+ }
3014
+ },
3015
+ {
3016
+ "description": "Resource id",
3017
+ "in": "path",
3018
+ "name": "id",
3019
+ "required": true,
3020
+ "schema": {
3021
+ "type": "string"
3022
+ }
3023
+ }
3024
+ ],
3025
+ "requestBody": {
3026
+ "content": {
3027
+ "application/json": {
3028
+ "schema": {
3029
+ "properties": {
3030
+ "expected_url": {
3031
+ "description": "Optional optimistic precondition; mismatch returns 409",
3032
+ "type": "string"
3033
+ },
3034
+ "title": {
3035
+ "type": "string"
3036
+ },
3037
+ "url": {
3038
+ "description": "http(s) URL",
3039
+ "type": "string"
3040
+ }
3041
+ },
3042
+ "type": "object"
3043
+ }
3044
+ }
3045
+ },
3046
+ "required": true
3047
+ },
3048
+ "responses": {
3049
+ "200": {
3050
+ "content": {
3051
+ "application/json": {
3052
+ "schema": {
3053
+ "properties": {
3054
+ "data": {
3055
+ "properties": {
3056
+ "id": {
3057
+ "format": "uuid",
3058
+ "type": "string"
3059
+ },
3060
+ "inserted_at": {
3061
+ "format": "date-time",
3062
+ "type": "string"
3063
+ },
3064
+ "issue_id": {
3065
+ "format": "uuid",
3066
+ "type": "string"
3067
+ },
3068
+ "title": {
3069
+ "nullable": true,
3070
+ "type": "string"
3071
+ },
3072
+ "updated_at": {
3073
+ "format": "date-time",
3074
+ "type": "string"
3075
+ },
3076
+ "url": {
3077
+ "type": "string"
3078
+ }
3079
+ },
3080
+ "required": [
3081
+ "id",
3082
+ "url",
3083
+ "title",
3084
+ "issue_id",
3085
+ "inserted_at",
3086
+ "updated_at"
3087
+ ],
3088
+ "type": "object"
3089
+ }
3090
+ },
3091
+ "required": [
3092
+ "data"
3093
+ ],
3094
+ "type": "object"
3095
+ }
3096
+ }
3097
+ },
3098
+ "description": "Success"
3099
+ },
3100
+ "400": {
3101
+ "description": "Bad request"
3102
+ },
3103
+ "401": {
3104
+ "description": "Missing or invalid token"
3105
+ },
3106
+ "403": {
3107
+ "description": "Token not permitted (org-scoped, non-admin, or missing scope)"
3108
+ },
3109
+ "404": {
3110
+ "description": "Not found"
3111
+ },
3112
+ "409": {
3113
+ "description": "The link URL changed after the client resolved it"
3114
+ },
3115
+ "422": {
3116
+ "description": "Validation error"
3117
+ }
3118
+ },
3119
+ "summary": "Update a link",
3120
+ "tags": []
2562
3121
  }
2563
3122
  },
2564
3123
  "/api/v1/issues/{identifier}/relations": {