agent-relay-server 0.61.2 → 0.62.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.
Files changed (174) hide show
  1. package/docs/openapi.json +479 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DVvlvi4B.js → MessageBubble-BP0Ym3xb.js} +2 -2
  4. package/public/assets/{MessageBubble-DVvlvi4B.js.map → MessageBubble-BP0Ym3xb.js.map} +1 -1
  5. package/public/assets/PlanGraphPanel-DKc3gdou.js +2 -0
  6. package/public/assets/PlanGraphPanel-DKc3gdou.js.map +1 -0
  7. package/public/assets/{activity-IYl3MEXC.js → activity-CRLG6ICe.js} +2 -2
  8. package/public/assets/{activity-IYl3MEXC.js.map → activity-CRLG6ICe.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-DItyEkXP.js} +2 -2
  10. package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-DItyEkXP.js.map} +1 -1
  11. package/public/assets/{agents-BquvREQr.js → agents-5mKiZ1rz.js} +2 -2
  12. package/public/assets/{agents-BquvREQr.js.map → agents-5mKiZ1rz.js.map} +1 -1
  13. package/public/assets/{analytics-De0cEZGx.js → analytics-J5pPcS-v.js} +3 -3
  14. package/public/assets/{analytics-De0cEZGx.js.map → analytics-J5pPcS-v.js.map} +1 -1
  15. package/public/assets/{automation-Ddbermbi.js → automation-BG2HWK5N.js} +2 -2
  16. package/public/assets/{automation-Ddbermbi.js.map → automation-BG2HWK5N.js.map} +1 -1
  17. package/public/assets/{badge-DhLyCPag.js → badge-CZhIdlTJ.js} +2 -2
  18. package/public/assets/{badge-DhLyCPag.js.map → badge-CZhIdlTJ.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-DSyWKluD.js → branch-state-badge-BZMoxs65.js} +2 -2
  20. package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-BZMoxs65.js.map} +1 -1
  21. package/public/assets/{button-K20wAZG1.js → button-2taQeNHW.js} +2 -2
  22. package/public/assets/{button-K20wAZG1.js.map → button-2taQeNHW.js.map} +1 -1
  23. package/public/assets/card-DzIsAI41.js +2 -0
  24. package/public/assets/{card-CcZ9hLI7.js.map → card-DzIsAI41.js.map} +1 -1
  25. package/public/assets/channels-B2PeqiRT.js +2 -0
  26. package/public/assets/{channels-BG0Nib7y.js.map → channels-B2PeqiRT.js.map} +1 -1
  27. package/public/assets/chat-CLRJVagA.js +2 -0
  28. package/public/assets/{chat-Dx6qWNxb.js.map → chat-CLRJVagA.js.map} +1 -1
  29. package/public/assets/{connectors-DWh7MwV_.js → connectors-Bzvkxsgo.js} +2 -2
  30. package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Bzvkxsgo.js.map} +1 -1
  31. package/public/assets/copy-button-HEkwi6xX.js +2 -0
  32. package/public/assets/{copy-button-eF3cKp4Q.js.map → copy-button-HEkwi6xX.js.map} +1 -1
  33. package/public/assets/display-DzNvOu1j.js.map +1 -1
  34. package/public/assets/dist-1rXN5FN9.js +2 -0
  35. package/public/assets/{dist-YpHfnnIA.js.map → dist-1rXN5FN9.js.map} +1 -1
  36. package/public/assets/{dist-1dhfdhm9.js → dist-AbEQuFoK.js} +2 -2
  37. package/public/assets/{dist-1dhfdhm9.js.map → dist-AbEQuFoK.js.map} +1 -1
  38. package/public/assets/{dist-COQuunEv.js → dist-B7p1zkvf.js} +2 -2
  39. package/public/assets/{dist-COQuunEv.js.map → dist-B7p1zkvf.js.map} +1 -1
  40. package/public/assets/{dist-Ci5Y37nj.js → dist-CG10hM1-.js} +2 -2
  41. package/public/assets/{dist-Ci5Y37nj.js.map → dist-CG10hM1-.js.map} +1 -1
  42. package/public/assets/dist-Cfm5_zPB.js +2 -0
  43. package/public/assets/{dist-qd198ib9.js.map → dist-Cfm5_zPB.js.map} +1 -1
  44. package/public/assets/{dist-BrQsqBbc.js → dist-Cgw85UcE.js} +2 -2
  45. package/public/assets/{dist-BrQsqBbc.js.map → dist-Cgw85UcE.js.map} +1 -1
  46. package/public/assets/{dist-CoROafNg.js → dist-D3_TJKTC.js} +2 -2
  47. package/public/assets/{dist-CoROafNg.js.map → dist-D3_TJKTC.js.map} +1 -1
  48. package/public/assets/{dist-I-MFUv51.js → dist-DQkLq0hs.js} +2 -2
  49. package/public/assets/{dist-I-MFUv51.js.map → dist-DQkLq0hs.js.map} +1 -1
  50. package/public/assets/{dist-UQGKwqZH.js → dist-D_sl7u_f.js} +2 -2
  51. package/public/assets/{dist-UQGKwqZH.js.map → dist-D_sl7u_f.js.map} +1 -1
  52. package/public/assets/{dist-yGOq6sB3.js → dist-Ilb3r1im.js} +2 -2
  53. package/public/assets/{dist-yGOq6sB3.js.map → dist-Ilb3r1im.js.map} +1 -1
  54. package/public/assets/dist-Pf_3Gf38.js +2 -0
  55. package/public/assets/{dist-BLBF7qqr.js.map → dist-Pf_3Gf38.js.map} +1 -1
  56. package/public/assets/{es2015-CRxz9lOj.js → es2015-GSepNaIS.js} +2 -2
  57. package/public/assets/{es2015-CRxz9lOj.js.map → es2015-GSepNaIS.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-DMm6jeqi.js → formatted-body-impl-QqHxdMGP.js} +2 -2
  59. package/public/assets/{formatted-body-impl-DMm6jeqi.js.map → formatted-body-impl-QqHxdMGP.js.map} +1 -1
  60. package/public/assets/index-CCp1V35c.js +22 -0
  61. package/public/assets/index-CCp1V35c.js.map +1 -0
  62. package/public/assets/index-DiR-fqy_.css +2 -0
  63. package/public/assets/{input-BHYNpXAG.js → input-BsNqlONJ.js} +2 -2
  64. package/public/assets/{input-BHYNpXAG.js.map → input-BsNqlONJ.js.map} +1 -1
  65. package/public/assets/{insights-C7wKt1_l.js → insights-D-EyDfoZ.js} +2 -2
  66. package/public/assets/{insights-C7wKt1_l.js.map → insights-D-EyDfoZ.js.map} +1 -1
  67. package/public/assets/{integrations-CG9qyHUf.js → integrations-D1kc-yNi.js} +2 -2
  68. package/public/assets/{integrations-CG9qyHUf.js.map → integrations-D1kc-yNi.js.map} +1 -1
  69. package/public/assets/{lib-Df4aph5u.js → lib-bXPu6xCL.js} +2 -2
  70. package/public/assets/{lib-Df4aph5u.js.map → lib-bXPu6xCL.js.map} +1 -1
  71. package/public/assets/lucide-react-DWYjU-AF.js +9 -0
  72. package/public/assets/{lucide-react-L2UxFDic.js.map → lucide-react-DWYjU-AF.js.map} +1 -1
  73. package/public/assets/{maintenance-CjpvnV_U.js → maintenance-COqID7aq.js} +2 -2
  74. package/public/assets/{maintenance-CjpvnV_U.js.map → maintenance-COqID7aq.js.map} +1 -1
  75. package/public/assets/{managed-agents-CdfuBKJA.js → managed-agents-b4B8qewQ.js} +2 -2
  76. package/public/assets/{managed-agents-CdfuBKJA.js.map → managed-agents-b4B8qewQ.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js → markdown-preview-impl-D7o8jS07.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-Cf4xCmWt.js.map → markdown-preview-impl-D7o8jS07.js.map} +1 -1
  79. package/public/assets/memory-Q0qF7_i3.js +2 -0
  80. package/public/assets/{memory-xKWduhyr.js.map → memory-Q0qF7_i3.js.map} +1 -1
  81. package/public/assets/{messages-BpZYOUtJ.js → messages-m4EpkK6s.js} +2 -2
  82. package/public/assets/{messages-BpZYOUtJ.js.map → messages-m4EpkK6s.js.map} +1 -1
  83. package/public/assets/{orchestrators-BrJvGIOc.js → orchestrators-CnAtGfUN.js} +2 -2
  84. package/public/assets/{orchestrators-BrJvGIOc.js.map → orchestrators-CnAtGfUN.js.map} +1 -1
  85. package/public/assets/{overview-BUORwePi.js → overview-D7CF88ik.js} +2 -2
  86. package/public/assets/{overview-BUORwePi.js.map → overview-D7CF88ik.js.map} +1 -1
  87. package/public/assets/pairs-Q6PlFJtH.js +2 -0
  88. package/public/assets/{pairs-DR0GizDN.js.map → pairs-Q6PlFJtH.js.map} +1 -1
  89. package/public/assets/{react-dom-CrP_AtcL.js → react-dom-CPghDLpB.js} +2 -2
  90. package/public/assets/{react-dom-CrP_AtcL.js.map → react-dom-CPghDLpB.js.map} +1 -1
  91. package/public/assets/{security-DLwSqTc_.js → security-BeV-6SdA.js} +2 -2
  92. package/public/assets/{security-DLwSqTc_.js.map → security-BeV-6SdA.js.map} +1 -1
  93. package/public/assets/{select-pEru111r.js → select-Dchc0_Jg.js} +2 -2
  94. package/public/assets/{select-pEru111r.js.map → select-Dchc0_Jg.js.map} +1 -1
  95. package/public/assets/{settings-B-ueGvwV.js → settings-hPwL-31u.js} +3 -3
  96. package/public/assets/{settings-B-ueGvwV.js.map → settings-hPwL-31u.js.map} +1 -1
  97. package/public/assets/store-Buwkqv_7.js +9 -0
  98. package/public/assets/store-Buwkqv_7.js.map +1 -0
  99. package/public/assets/tasks-DKUMPEgk.js +2 -0
  100. package/public/assets/{tasks-C2rRQm6E.js.map → tasks-DKUMPEgk.js.map} +1 -1
  101. package/public/assets/teams-T5fFsdOX.js +2 -0
  102. package/public/assets/teams-T5fFsdOX.js.map +1 -0
  103. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js → terminal-viewer-impl-Bk0u_Mav.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-BM_3qoeN.js.map → terminal-viewer-impl-Bk0u_Mav.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js → use-keyboard-viewport-NKw1O5gC.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-DrRH_QzG.js.map → use-keyboard-viewport-NKw1O5gC.js.map} +1 -1
  107. package/public/assets/{work-queue-CkIYDAZD.js → work-queue-D26jok09.js} +2 -2
  108. package/public/assets/{work-queue-CkIYDAZD.js.map → work-queue-D26jok09.js.map} +1 -1
  109. package/public/assets/{workspaces-CYm5bLg9.js → workspaces-BMj6zx23.js} +3 -3
  110. package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-BMj6zx23.js.map} +1 -1
  111. package/public/index.html +21 -21
  112. package/src/automations.ts +40 -7
  113. package/src/branch-landed.ts +5 -0
  114. package/src/bus.ts +3 -0
  115. package/src/cli/workspace.ts +9 -17
  116. package/src/db/activity.ts +191 -3
  117. package/src/db/integrations.ts +1 -0
  118. package/src/db/issue-associations.ts +128 -0
  119. package/src/db/issue-lifecycle.ts +187 -0
  120. package/src/db/issue-refs.ts +254 -0
  121. package/src/db/mappers.ts +2 -0
  122. package/src/db/migrations.ts +47 -0
  123. package/src/db/schema.ts +2 -2
  124. package/src/issue-tracker/adapter.ts +24 -0
  125. package/src/issue-tracker/github.ts +136 -0
  126. package/src/issue-tracker/index.ts +13 -0
  127. package/src/issue-tracker/registry.ts +26 -0
  128. package/src/maintenance.ts +22 -169
  129. package/src/mcp-issue-activity-tools.ts +114 -0
  130. package/src/mcp-issue-association-tools.ts +130 -0
  131. package/src/mcp-issue-lifecycle-tools.ts +55 -0
  132. package/src/mcp-issue-ref-tools.ts +174 -0
  133. package/src/mcp-team-tools.ts +1 -1
  134. package/src/mcp.ts +3 -3
  135. package/src/provider-config-store.ts +30 -8
  136. package/src/routes/activity.ts +6 -1
  137. package/src/routes/commands.ts +29 -0
  138. package/src/routes/index.ts +19 -0
  139. package/src/routes/issue-activity.ts +78 -0
  140. package/src/routes/issue-associations.ts +76 -0
  141. package/src/routes/issue-lifecycle.ts +34 -0
  142. package/src/routes/issue-refs.ts +137 -0
  143. package/src/routes/orchestrator.ts +42 -1
  144. package/src/security.ts +8 -0
  145. package/src/services/issue-activity.ts +30 -0
  146. package/src/services/issue-associations.ts +72 -0
  147. package/src/services/issue-lifecycle.ts +308 -0
  148. package/src/services/issue-refs.ts +32 -0
  149. package/src/services/plan-graph.ts +40 -2
  150. package/src/services/team.ts +4 -0
  151. package/src/token-db.ts +3 -3
  152. package/src/workspace-actions.ts +8 -1
  153. package/src/workspace-auto-merge.ts +228 -0
  154. package/src/workspace-probe.ts +125 -0
  155. package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
  156. package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
  157. package/public/assets/card-CcZ9hLI7.js +0 -2
  158. package/public/assets/channels-BG0Nib7y.js +0 -2
  159. package/public/assets/chat-Dx6qWNxb.js +0 -2
  160. package/public/assets/copy-button-eF3cKp4Q.js +0 -2
  161. package/public/assets/dist-BLBF7qqr.js +0 -2
  162. package/public/assets/dist-YpHfnnIA.js +0 -2
  163. package/public/assets/dist-qd198ib9.js +0 -2
  164. package/public/assets/index-B3wOeAPO.js +0 -21
  165. package/public/assets/index-B3wOeAPO.js.map +0 -1
  166. package/public/assets/index-Dn_GDXbi.css +0 -2
  167. package/public/assets/lucide-react-L2UxFDic.js +0 -9
  168. package/public/assets/memory-xKWduhyr.js +0 -2
  169. package/public/assets/pairs-DR0GizDN.js +0 -2
  170. package/public/assets/store-B8xP6z6T.js +0 -9
  171. package/public/assets/store-B8xP6z6T.js.map +0 -1
  172. package/public/assets/tasks-C2rRQm6E.js +0 -2
  173. package/public/assets/teams-Bo0sdBTp.js +0 -2
  174. package/public/assets/teams-Bo0sdBTp.js.map +0 -1
@@ -0,0 +1,174 @@
1
+ import { deleteIssueRef, getIssueRef, listIssueRefs, resolveIssueRefByExternalKey, resolveIssueRefFromString, updateIssueRef, upsertIssueRef } from "./db/issue-refs";
2
+ import { McpNotFoundError } from "./mcp-errors";
3
+ import { cleanEnum, cleanString, optionalEnum } from "./validation";
4
+ import { ISSUE_PROVIDERS, ISSUE_REF_STATUSES, isRecord, type IssueProvider, type IssueRefStatus } from "./types";
5
+ import { ValidationError } from "./db";
6
+
7
+ export const ISSUE_REF_TOOLS = [
8
+ {
9
+ name: "relay_issue_ref_upsert",
10
+ description: "Create or update the canonical IssueRef for provider/repo/externalKey. Idempotent for the same external issue.",
11
+ requiredScopes: ["issue:write"],
12
+ inputSchema: {
13
+ type: "object",
14
+ properties: {
15
+ provider: { type: "string", enum: ISSUE_PROVIDERS },
16
+ repo: { type: "string" },
17
+ externalKey: { type: "string" },
18
+ url: { type: "string" },
19
+ title: { type: "string" },
20
+ status: { type: "string", enum: ISSUE_REF_STATUSES },
21
+ statusSyncedAt: { type: "integer", minimum: 0 },
22
+ },
23
+ required: ["provider", "repo", "externalKey", "url"],
24
+ additionalProperties: false,
25
+ },
26
+ },
27
+ {
28
+ name: "relay_issue_ref_resolve",
29
+ description: "Resolve a raw issue string (`#466`, `edimuj/agent-relay#466`, GitHub issue URL, or `issue_<uuid>`) or provider/repo/externalKey to the canonical IssueRef.",
30
+ requiredScopes: ["issue:write"],
31
+ inputSchema: {
32
+ type: "object",
33
+ properties: {
34
+ ref: { type: "string" },
35
+ defaultRepo: { type: "string" },
36
+ provider: { type: "string", enum: ISSUE_PROVIDERS },
37
+ repo: { type: "string" },
38
+ externalKey: { type: "string" },
39
+ url: { type: "string" },
40
+ title: { type: "string" },
41
+ status: { type: "string", enum: ISSUE_REF_STATUSES },
42
+ statusSyncedAt: { type: "integer", minimum: 0 },
43
+ },
44
+ additionalProperties: false,
45
+ },
46
+ },
47
+ {
48
+ name: "relay_issue_ref_get",
49
+ description: "Fetch one IssueRef by its opaque internal id.",
50
+ requiredScopes: ["issue:read"],
51
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
52
+ },
53
+ {
54
+ name: "relay_issue_ref_list",
55
+ description: "List IssueRefs, optionally filtered by provider, repo, or cached status.",
56
+ requiredScopes: ["issue:read"],
57
+ inputSchema: {
58
+ type: "object",
59
+ properties: {
60
+ provider: { type: "string", enum: ISSUE_PROVIDERS },
61
+ repo: { type: "string" },
62
+ status: { type: "string", enum: ISSUE_REF_STATUSES },
63
+ limit: { type: "integer", minimum: 1, maximum: 500 },
64
+ },
65
+ additionalProperties: false,
66
+ },
67
+ },
68
+ {
69
+ name: "relay_issue_ref_update",
70
+ description: "Update cached IssueRef display fields without changing provider/repo/externalKey.",
71
+ requiredScopes: ["issue:write"],
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: {
75
+ id: { type: "string" },
76
+ url: { type: "string" },
77
+ title: { type: "string" },
78
+ status: { type: "string", enum: ISSUE_REF_STATUSES },
79
+ statusSyncedAt: { type: "integer", minimum: 0 },
80
+ },
81
+ required: ["id"],
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ {
86
+ name: "relay_issue_ref_delete",
87
+ description: "Delete an IssueRef mapping row. Foundation-only cleanup; lifecycle automation lives elsewhere.",
88
+ requiredScopes: ["issue:write"],
89
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
90
+ },
91
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
92
+
93
+ type IssueRefToolAuth = { kind: string; actor: string; scopes?: string[] };
94
+
95
+ function providerArg(value: unknown): IssueProvider {
96
+ return cleanEnum(value, "provider", ISSUE_PROVIDERS) as IssueProvider;
97
+ }
98
+
99
+ function statusArg(value: unknown): IssueRefStatus | undefined {
100
+ return optionalEnum(value, "status", ISSUE_REF_STATUSES) as IssueRefStatus | undefined;
101
+ }
102
+
103
+ function timestampArg(value: unknown): number | undefined {
104
+ if (value === undefined || value === null) return undefined;
105
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ValidationError("statusSyncedAt must be a non-negative integer");
106
+ return value;
107
+ }
108
+
109
+ export function relayIssueRefTool(_auth: IssueRefToolAuth, name: string, args: Record<string, unknown>): Record<string, unknown> {
110
+ if (name === "relay_issue_ref_upsert") {
111
+ return upsertIssueRef({
112
+ provider: providerArg(args.provider),
113
+ repo: cleanString(args.repo, "repo", { required: true, max: 500 })!,
114
+ externalKey: cleanString(args.externalKey, "externalKey", { required: true, max: 240 })!,
115
+ url: cleanString(args.url, "url", { required: true, max: 2000 })!,
116
+ title: cleanString(args.title, "title", { max: 500 }),
117
+ status: statusArg(args.status),
118
+ statusSyncedAt: timestampArg(args.statusSyncedAt),
119
+ }) as unknown as Record<string, unknown>;
120
+ }
121
+ if (name === "relay_issue_ref_resolve") {
122
+ const ref = cleanString(args.ref, "ref", { max: 1000 });
123
+ return (ref
124
+ ? resolveIssueRefFromString({
125
+ ref,
126
+ defaultRepo: cleanString(args.defaultRepo ?? args.repo, "defaultRepo", { max: 500 }),
127
+ provider: optionalEnum(args.provider, "provider", ISSUE_PROVIDERS, "github") as IssueProvider,
128
+ title: cleanString(args.title, "title", { max: 500 }),
129
+ status: statusArg(args.status),
130
+ statusSyncedAt: timestampArg(args.statusSyncedAt),
131
+ })
132
+ : resolveIssueRefByExternalKey({
133
+ provider: providerArg(args.provider),
134
+ repo: cleanString(args.repo, "repo", { required: true, max: 500 })!,
135
+ externalKey: cleanString(args.externalKey, "externalKey", { required: true, max: 240 })!,
136
+ url: cleanString(args.url, "url", { max: 2000 }),
137
+ title: cleanString(args.title, "title", { max: 500 }),
138
+ status: statusArg(args.status),
139
+ statusSyncedAt: timestampArg(args.statusSyncedAt),
140
+ })) as unknown as Record<string, unknown>;
141
+ }
142
+ if (name === "relay_issue_ref_get") {
143
+ const issueRef = getIssueRef(cleanString(args.id, "id", { required: true, max: 120 })!);
144
+ if (!issueRef) throw new McpNotFoundError("issue ref not found");
145
+ return { issueRef };
146
+ }
147
+ if (name === "relay_issue_ref_list") {
148
+ return {
149
+ issueRefs: listIssueRefs({
150
+ provider: optionalEnum(args.provider, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
151
+ repo: cleanString(args.repo, "repo", { max: 500 }),
152
+ status: statusArg(args.status),
153
+ limit: typeof args.limit === "number" ? args.limit : undefined,
154
+ }),
155
+ };
156
+ }
157
+ if (name === "relay_issue_ref_update") {
158
+ const issueRef = updateIssueRef(cleanString(args.id, "id", { required: true, max: 120 })!, {
159
+ url: cleanString(args.url, "url", { max: 2000 }),
160
+ title: cleanString(args.title, "title", { max: 500 }),
161
+ status: statusArg(args.status),
162
+ statusSyncedAt: timestampArg(args.statusSyncedAt),
163
+ });
164
+ if (!issueRef) throw new McpNotFoundError("issue ref not found");
165
+ return { issueRef };
166
+ }
167
+ if (name === "relay_issue_ref_delete") {
168
+ const deleted = deleteIssueRef(cleanString(args.id, "id", { required: true, max: 120 })!);
169
+ if (!deleted) throw new McpNotFoundError("issue ref not found");
170
+ return { deleted };
171
+ }
172
+ if (!isRecord(args)) throw new ValidationError("arguments must be an object");
173
+ throw new ValidationError(`unknown tool: ${name}`);
174
+ }
@@ -11,7 +11,7 @@ const OUTCOME_STATUSES = ["success", "partial", "failed"] as const;
11
11
  export const TEAM_TOOLS = [
12
12
  {
13
13
  name: "relay_team_get",
14
- description: "Return the caller's team row, live roster, shared brief/definition of done, and any bound plan.",
14
+ description: "Return the caller's team row, live roster, shared brief/definition of done, associated issues, and any bound plan.",
15
15
  requiredScopes: ["teams:read"],
16
16
  inputSchema: {
17
17
  type: "object",
package/src/mcp.ts CHANGED
@@ -49,7 +49,7 @@ import { ServiceAuthError } from "./services/errors";
49
49
  import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
51
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
- import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
52
+ import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "./mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "./mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "./mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "./mcp-issue-lifecycle-tools";
53
53
  import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "./mcp-merge-tools";
54
54
  import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
55
55
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action"; import { providerCatalogToolInputSchema } from "./mcp-provider-catalog";
@@ -461,7 +461,7 @@ const TOOLS: ToolDefinition[] = [
461
461
  },
462
462
  },
463
463
  ...MERGE_TOOLS, ...TEAM_TOOLS,
464
- ...PLAN_TOOLS,
464
+ ...PLAN_TOOLS, ...ISSUE_REF_TOOLS, ...ISSUE_ASSOCIATION_TOOLS, ...ISSUE_ACTIVITY_TOOLS, ...ISSUE_LIFECYCLE_TOOLS,
465
465
  ...BLACKBOARD_TOOLS,
466
466
  ...PROJECT_TOOLS,
467
467
  ...NOTIFICATION_SUBSCRIPTION_TOOLS,
@@ -580,7 +580,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
580
580
  else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
581
581
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
582
582
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
583
- else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
583
+ else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
584
584
  else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
585
585
  else throw new ValidationError(`unknown tool: ${name}`);
586
586
 
@@ -123,21 +123,43 @@ export function providerConfigPublic(entry: ProviderConfigEntry, opts: { maskEnv
123
123
  };
124
124
  }
125
125
 
126
+ /**
127
+ * Idempotently seed central provider-config rows for `host` from raw per-provider
128
+ * config objects (#465). A real stored row (version >= 1) always wins — a stale
129
+ * local file never clobbers it. Unknown providers and malformed bodies are skipped.
130
+ * This is the server-authority write path: callers are in-process (local migration
131
+ * or the orchestrator command-bus result handler), so no HTTP scope is required.
132
+ */
133
+ export function seedProviderConfigs(host: string, rawByProvider: Record<string, unknown>): ProviderConfigEntry[] {
134
+ const seeded: ProviderConfigEntry[] = [];
135
+ for (const [provider, raw] of Object.entries(rawByProvider)) {
136
+ if (!VALID_PROVIDER_CONFIGS.includes(provider as SpawnProvider)) continue;
137
+ const key = providerConfigKey(host, provider);
138
+ if (getConfig(PROVIDER_CONFIG_NAMESPACE, key)) continue;
139
+ // Shallow-merge defaults so a partial local file (e.g. missing `command`)
140
+ // still validates; the relay owns the schema, the reporter stays dumb.
141
+ const value = { ...providerConfigDefaults(provider), ...(isRecord(raw) ? raw : {}) };
142
+ try {
143
+ const entry = setProviderConfig(provider, value, { host, updatedBy: `migration:${host}` });
144
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
145
+ seeded.push(entry);
146
+ } catch {
147
+ // A single invalid provider config shouldn't abort seeding the rest.
148
+ }
149
+ }
150
+ return seeded;
151
+ }
152
+
126
153
  export function migrateLocalProviderConfigs(home = agentRelayHome(), host = defaultProviderConfigHost()): ProviderConfigEntry[] {
127
154
  const dir = join(home, "providers");
128
155
  if (!existsSync(dir)) return [];
129
- const migrated: ProviderConfigEntry[] = [];
156
+ const raw: Record<string, unknown> = {};
130
157
  for (const file of readdirSync(dir)) {
131
158
  const provider = file.endsWith(".json") ? file.slice(0, -".json".length) : "";
132
159
  if (!VALID_PROVIDER_CONFIGS.includes(provider as SpawnProvider)) continue;
133
- const key = providerConfigKey(host, provider);
134
- if (getConfig(PROVIDER_CONFIG_NAMESPACE, key)) continue;
135
- const local = loadLocalProviderConfig(provider, home);
136
- const entry = setProviderConfig(provider, local, { host, updatedBy: `migration:${host}` });
137
- emitConfigChanged(entry.namespace, entry.key, entry.version);
138
- migrated.push(entry);
160
+ raw[provider] = loadLocalProviderConfig(provider, home);
139
161
  }
140
- return migrated;
162
+ return seedProviderConfigs(host, raw);
141
163
  }
142
164
 
143
165
  function normalizeProviderConfigHost(host: string): string {
@@ -5,8 +5,9 @@ import { emitActivityEvent } from "../sse";
5
5
  import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
6
6
  import { isRecord } from "agent-relay-sdk";
7
7
  import { type ActivityEventInput, type ActivityKind } from "../types";
8
+ import { ISSUE_ACTIVITY_ACTIONS } from "../services/issue-activity";
8
9
 
9
- const VALID_ACTIVITY_KINDS = ["message", "reply", "question", "operator", "pair", "task", "state"] as const;
10
+ const VALID_ACTIVITY_KINDS = ["message", "reply", "question", "operator", "pair", "task", "state", "issue"] as const;
10
11
 
11
12
  function normalizeActivityInput(body: unknown): ActivityEventInput {
12
13
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
@@ -24,6 +25,8 @@ function normalizeActivityInput(body: unknown): ActivityEventInput {
24
25
  pairId: cleanString(body.pairId, "pairId", { max: 120 }),
25
26
  taskId: cleanPositiveId(body.taskId, "taskId"),
26
27
  agentId: cleanString(body.agentId, "agentId", { max: 200 }),
28
+ issueRefId: cleanString(body.issueRefId, "issueRefId", { max: 120 }),
29
+ issueAction: optionalEnum(body.issueAction, "issueAction", ISSUE_ACTIVITY_ACTIONS),
27
30
  metadata: cleanMeta(body.metadata),
28
31
  };
29
32
  }
@@ -38,6 +41,8 @@ export const getActivityEvents: Handler = (req) => {
38
41
  return json(listActivityEvents({
39
42
  operatorId: cleanString(url.searchParams.get("operatorId") ?? undefined, "operatorId", { max: 200 }),
40
43
  agentId: cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 200 }),
44
+ issueRefId: cleanString(url.searchParams.get("issueRefId") ?? undefined, "issueRefId", { max: 120 }),
45
+ issueAction: optionalEnum(url.searchParams.get("issueAction") ?? undefined, "issueAction", ISSUE_ACTIVITY_ACTIONS),
41
46
  limit: limitRaw ?? 200,
42
47
  since: sinceRaw ?? undefined,
43
48
  }));
@@ -13,8 +13,10 @@ import { authContextFromRequest } from "../services/auth-context";
13
13
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
14
  import { ServiceAuthError } from "../services/errors";
15
15
  import { notifyBranchLanded } from "../branch-landed";
16
+ import { seedProviderConfigs } from "../provider-config-store";
16
17
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
17
18
  import { handleReviewerMergeResult } from "../reviewer-pipeline";
19
+ import { scheduleRepoMerge } from "../workspace-auto-merge";
18
20
  import { enqueueContinuationInjectionAfterClear } from "../self-resume";
19
21
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
20
22
 
@@ -189,6 +191,12 @@ export const patchCommand: Handler = async (req, params) => {
189
191
  // base merge can proceed (issue #157).
190
192
  if (command.status === "succeeded" || command.status === "failed") {
191
193
  releaseMergeLease({ commandId: command.id });
194
+ // #237: the lease just freed — drain the next ready/review_requested row for
195
+ // this repo right now instead of waiting for the backstop sweep. This is the
196
+ // on-settle link of the sequential per-repo drain chain (no-op if nothing else
197
+ // is queued, or if AUTO_MERGE is disabled).
198
+ const settledRepoRoot = typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined;
199
+ scheduleRepoMerge(settledRepoRoot);
192
200
  }
193
201
  if (command.status === "succeeded" && isRecord(command.result)) {
194
202
  if (handleReviewerMergeResult(command, command.result)) {
@@ -357,6 +365,27 @@ export const patchCommand: Handler = async (req, params) => {
357
365
  });
358
366
  }
359
367
  }
368
+ if (command.type === "orchestrator.migrate-provider-config" && command.status === "succeeded" && isRecord(command.result)) {
369
+ // The orchestrator reported its host-local provider config files; seed the
370
+ // central rows with server authority (#465). Idempotent — a real stored row wins.
371
+ const host = cleanString(command.result.host, "result.host", { max: 120 });
372
+ const configs = isRecord(command.result.configs) ? command.result.configs : undefined;
373
+ if (host && configs) {
374
+ const seeded = seedProviderConfigs(host, configs);
375
+ if (seeded.length) {
376
+ auditEvent({
377
+ clientId: `server-provider-config-migrated-${host}-${command.id}`,
378
+ kind: "state",
379
+ title: "Provider config migrated",
380
+ body: `${host}: ${seeded.map((entry) => entry.key.split("/").at(-1)).join(", ")}`,
381
+ meta: host,
382
+ icon: "ti-arrow-up-circle",
383
+ view: "orchestrators",
384
+ metadata: { host, commandId: command.id, providers: seeded.map((entry) => entry.key) },
385
+ });
386
+ }
387
+ }
388
+ }
360
389
  settleFailedOrchestratorUpgrade(command);
361
390
  emitCommand(command);
362
391
  auditCommandOutcome(command);
@@ -28,6 +28,10 @@ import { getOrchestratorDirectories, getOrchestratorFileRead, getOrchestratorFil
28
28
  import { getPairById, getPairs, postAcceptPair, postHangupPair, postPair, postPairMessage, postRejectPair } from "./pairs";
29
29
  import { getPlanByIdRoute, getPlansRoute } from "./plans";
30
30
  import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
31
+ import { deleteIssueAssociationRoute, getIssueAssociationEntityIssuesRoute, getIssueAssociationIssueEntitiesRoute, postIssueAssociationRoute } from "./issue-associations";
32
+ import { getAgentIssueActivityRoute, getIssueActivityAgentsRoute, postIssueActivityRoute } from "./issue-activity";
33
+ import { postIssueLifecycleReopenRoute } from "./issue-lifecycle";
34
+ import { deleteIssueRefRoute, getIssueRefRoute, listIssueRefsRoute, patchIssueRefRoute, postIssueRefRoute, resolveIssueRefRoute } from "./issue-refs";
31
35
  import { getProjectEntriesRoute, getProjectEntryRoute, getProjectRoute, getProjectsRoute } from "./projects";
32
36
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
33
37
  import { getSettingRoute, getSettingsNamespaceRoute, getSettingsRoute, putSettingRoute, putSettingsDefaultRoute } from "./settings";
@@ -298,6 +302,21 @@ const routes: Route[] = [
298
302
  route("GET", "/api/plans/:id", getPlanByIdRoute),
299
303
  route("GET", "/api/blackboard", getBlackboardRoute),
300
304
  route("GET", "/api/blackboard/:id", getBlackboardEntryRoute),
305
+ route("POST", "/api/issue-associations", postIssueAssociationRoute),
306
+ route("DELETE", "/api/issue-associations", deleteIssueAssociationRoute),
307
+ route("GET", "/api/issue-associations/entities/:entityType/:entityId/issues", getIssueAssociationEntityIssuesRoute),
308
+ route("GET", "/api/issue-associations/issues/:issueRefId/entities", getIssueAssociationIssueEntitiesRoute),
309
+ route("POST", "/api/issue-activity", postIssueActivityRoute),
310
+ route("GET", "/api/issue-activity/issues", getIssueActivityAgentsRoute),
311
+ route("GET", "/api/issue-activity/agents/:id", getAgentIssueActivityRoute),
312
+ route("POST", "/api/issue-lifecycle/reopen", postIssueLifecycleReopenRoute),
313
+ route("POST", "/api/issue-refs", postIssueRefRoute),
314
+ route("GET", "/api/issue-refs", listIssueRefsRoute),
315
+ route("GET", "/api/issue-refs/resolve", resolveIssueRefRoute),
316
+ route("POST", "/api/issue-refs/resolve", resolveIssueRefRoute),
317
+ route("GET", "/api/issue-refs/:id", getIssueRefRoute),
318
+ route("PATCH", "/api/issue-refs/:id", patchIssueRefRoute),
319
+ route("DELETE", "/api/issue-refs/:id", deleteIssueRefRoute),
301
320
  route("GET", "/api/projects", getProjectsRoute),
302
321
  route("GET", "/api/projects/:id", getProjectRoute),
303
322
  route("GET", "/api/projects/:id/entries", getProjectEntriesRoute),
@@ -0,0 +1,78 @@
1
+ import { ValidationError } from "../db";
2
+ import { getIssueRef } from "../services/issue-refs";
3
+ import {
4
+ ISSUE_ACTIVITY_ACTIONS,
5
+ listAgentIssueActivity,
6
+ listIssueActivityAgents,
7
+ recordAndEmitIssueActivity,
8
+ type IssueActivityAction,
9
+ } from "../services/issue-activity";
10
+ import { cleanEnum, cleanMeta, cleanString, optionalEnum } from "../validation";
11
+ import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
12
+ import { ISSUE_PROVIDERS, isRecord, type IssueProvider } from "../types";
13
+
14
+ function cleanProvider(value: unknown): IssueProvider | undefined {
15
+ return optionalEnum(value, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined;
16
+ }
17
+
18
+ function cleanAction(value: unknown): IssueActivityAction {
19
+ return cleanEnum(value, "action", ISSUE_ACTIVITY_ACTIONS) as IssueActivityAction;
20
+ }
21
+
22
+ function cleanLimit(req: Request): number | undefined | Response {
23
+ const url = new URL(req.url);
24
+ const rawLimit = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500, clamp: true });
25
+ if (Number.isNaN(rawLimit)) return error("limit must be an integer");
26
+ return rawLimit ?? undefined;
27
+ }
28
+
29
+ export const postIssueActivityRoute: Handler = async (req) => {
30
+ const parsed = await parseBody<unknown>(req);
31
+ if (!parsed.ok) return error(parsed.error, parsed.status);
32
+ try {
33
+ if (!isRecord(parsed.body)) throw new ValidationError("issue activity body required");
34
+ const event = recordAndEmitIssueActivity({
35
+ agentId: cleanString(parsed.body.agentId, "agentId", { required: true, max: 200 })!,
36
+ action: cleanAction(parsed.body.action),
37
+ issueRefId: cleanString(parsed.body.issueRefId, "issueRefId", { max: 120 }),
38
+ issueRef: cleanString(parsed.body.issueRef ?? parsed.body.ref, "issueRef", { max: 1000 }),
39
+ defaultRepo: cleanString(parsed.body.defaultRepo ?? parsed.body.repo, "defaultRepo", { max: 500 }),
40
+ provider: cleanProvider(parsed.body.provider),
41
+ clientId: cleanString(parsed.body.clientId, "clientId", { max: 240 }),
42
+ title: cleanString(parsed.body.title, "title", { max: 200 }),
43
+ body: cleanString(parsed.body.body, "body", { max: 1000 }),
44
+ metadata: cleanMeta(parsed.body.metadata),
45
+ });
46
+ return json({ event }, 201);
47
+ } catch (err) {
48
+ if (err instanceof ValidationError) return error(err.message, 400);
49
+ throw err;
50
+ }
51
+ };
52
+
53
+ export const getIssueActivityAgentsRoute: Handler = (req) => {
54
+ try {
55
+ const url = new URL(req.url);
56
+ const limit = cleanLimit(req);
57
+ if (limit instanceof Response) return limit;
58
+ const issueRefId = cleanString(url.searchParams.get("issueRefId") ?? undefined, "issueRefId", { required: true, max: 120 })!;
59
+ const issueRef = getIssueRef(issueRefId);
60
+ if (!issueRef) return error("issue ref not found", 404);
61
+ return json({ issueRef, agents: listIssueActivityAgents(issueRef.id, { limit }) });
62
+ } catch (err) {
63
+ if (err instanceof ValidationError) return error(err.message, 400);
64
+ throw err;
65
+ }
66
+ };
67
+
68
+ export const getAgentIssueActivityRoute: Handler = (req, params) => {
69
+ try {
70
+ const limit = cleanLimit(req);
71
+ if (limit instanceof Response) return limit;
72
+ const agentId = cleanString(params.id, "agentId", { required: true, max: 200 })!;
73
+ return json({ agentId, issues: listAgentIssueActivity(agentId, { limit }) });
74
+ } catch (err) {
75
+ if (err instanceof ValidationError) return error(err.message, 400);
76
+ throw err;
77
+ }
78
+ };
@@ -0,0 +1,76 @@
1
+ import { ISSUE_ASSOCIATION_ENTITY_TYPES, ISSUE_PROVIDERS, isRecord, type IssueAssociationEntityType, type IssueProvider } from "../types";
2
+ import { associateIssueRef, listIssueEntities, listIssueRefsForEntity, removeIssueAssociation, resolveAssociationIssueRef } from "../services/issue-associations";
3
+ import { cleanEnum, cleanString, optionalEnum } from "../validation";
4
+ import { error, json, parseBody, type Handler } from "./_shared";
5
+
6
+ function cleanEntityType(value: unknown): IssueAssociationEntityType {
7
+ return cleanEnum(value, "entityType", ISSUE_ASSOCIATION_ENTITY_TYPES) as IssueAssociationEntityType;
8
+ }
9
+
10
+ function cleanIssueInput(value: Record<string, unknown>) {
11
+ return {
12
+ issueRefId: cleanString(value.issueRefId, "issueRefId", { max: 120 }),
13
+ ref: cleanString(value.ref, "ref", { max: 1000 }),
14
+ defaultRepo: cleanString(value.defaultRepo, "defaultRepo", { max: 500 }),
15
+ provider: optionalEnum(value.provider, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
16
+ repo: cleanString(value.repo, "repo", { max: 500 }),
17
+ externalKey: cleanString(value.externalKey, "externalKey", { max: 240 }),
18
+ url: cleanString(value.url, "url", { max: 2000 }),
19
+ title: cleanString(value.title, "title", { max: 500 }),
20
+ };
21
+ }
22
+
23
+ function cleanAssociationBody(body: unknown) {
24
+ if (!isRecord(body)) throw new Error("issue association body required");
25
+ return {
26
+ ...cleanIssueInput(body),
27
+ entityType: cleanEntityType(body.entityType),
28
+ entityId: cleanString(body.entityId, "entityId", { required: true, max: 240 })!,
29
+ };
30
+ }
31
+
32
+ function routeError(err: unknown): Response {
33
+ return error(err instanceof Error ? err.message : "issue associations route failed");
34
+ }
35
+
36
+ export const postIssueAssociationRoute: Handler = async (req) => {
37
+ const parsed = await parseBody<unknown>(req);
38
+ if (!parsed.ok) return error(parsed.error, parsed.status);
39
+ try {
40
+ return json(associateIssueRef(cleanAssociationBody(parsed.body)));
41
+ } catch (err) {
42
+ return routeError(err);
43
+ }
44
+ };
45
+
46
+ export const getIssueAssociationEntityIssuesRoute: Handler = (_req, params) => {
47
+ try {
48
+ return json({
49
+ issues: listIssueRefsForEntity(
50
+ cleanEntityType(params.entityType),
51
+ cleanString(params.entityId, "entityId", { required: true, max: 240 })!,
52
+ ),
53
+ });
54
+ } catch (err) {
55
+ return routeError(err);
56
+ }
57
+ };
58
+
59
+ export const getIssueAssociationIssueEntitiesRoute: Handler = (_req, params) => {
60
+ try {
61
+ const issueRef = resolveAssociationIssueRef({ issueRefId: cleanString(params.issueRefId, "issueRefId", { required: true, max: 120 })! });
62
+ return json({ issueRef, entities: listIssueEntities(issueRef.id) });
63
+ } catch (err) {
64
+ return routeError(err);
65
+ }
66
+ };
67
+
68
+ export const deleteIssueAssociationRoute: Handler = async (req) => {
69
+ const parsed = await parseBody<unknown>(req);
70
+ if (!parsed.ok) return error(parsed.error, parsed.status);
71
+ try {
72
+ return json(removeIssueAssociation(cleanAssociationBody(parsed.body)));
73
+ } catch (err) {
74
+ return routeError(err);
75
+ }
76
+ };
@@ -0,0 +1,34 @@
1
+ import { reopenIssueLifecycle } from "../services/issue-lifecycle";
2
+ import { cleanEnum, cleanString, optionalEnum } from "../validation";
3
+ import { error, json, parseBody, type Handler } from "./_shared";
4
+ import { ISSUE_ASSOCIATION_ENTITY_TYPES, ISSUE_PROVIDERS, isRecord, type IssueAssociationEntityType, type IssueProvider } from "../types";
5
+
6
+ function cleanReopenBody(body: unknown) {
7
+ if (!isRecord(body)) throw new Error("issue lifecycle reopen body required");
8
+ return {
9
+ issueRefId: cleanString(body.issueRefId, "issueRefId", { max: 120 }),
10
+ ref: cleanString(body.ref, "ref", { max: 1000 }),
11
+ defaultRepo: cleanString(body.defaultRepo ?? body.repo, "defaultRepo", { max: 500 }),
12
+ provider: optionalEnum(body.provider, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
13
+ repo: cleanString(body.repo, "repo", { max: 500 }),
14
+ externalKey: cleanString(body.externalKey, "externalKey", { max: 240 }),
15
+ url: cleanString(body.url, "url", { max: 2000 }),
16
+ title: cleanString(body.title, "title", { max: 500 }),
17
+ comment: cleanString(body.comment, "comment", { max: 2000 }),
18
+ agentId: cleanString(body.agentId, "agentId", { max: 200 }),
19
+ entityType: body.entityType === undefined ? undefined : cleanEnum(body.entityType, "entityType", ISSUE_ASSOCIATION_ENTITY_TYPES) as IssueAssociationEntityType,
20
+ entityId: cleanString(body.entityId, "entityId", { max: 240 }),
21
+ };
22
+ }
23
+
24
+ export const postIssueLifecycleReopenRoute: Handler = async (req) => {
25
+ const parsed = await parseBody<unknown>(req);
26
+ if (!parsed.ok) return error(parsed.error, parsed.status);
27
+ try {
28
+ const result = await reopenIssueLifecycle(cleanReopenBody(parsed.body));
29
+ if (result.action === "failed") return error(result.error, 502);
30
+ return json(result);
31
+ } catch (err) {
32
+ return error(err instanceof Error ? err.message : "issue lifecycle route failed");
33
+ }
34
+ };