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,137 @@
1
+ import { deleteIssueRefMapping, getIssueRef, listIssueRefs, patchIssueRefMapping, resolveIssueRefMapping, upsertIssueRefMapping, type ListIssueRefsOptions } from "../services/issue-refs";
2
+ import { cleanEnum, cleanString, optionalEnum } from "../validation";
3
+ import { error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
4
+ import { ISSUE_PROVIDERS, ISSUE_REF_STATUSES, isRecord, type IssueProvider, type IssueRefStatus } from "../types";
5
+
6
+ function cleanStatus(value: unknown): IssueRefStatus | undefined {
7
+ return optionalEnum(value, "status", ISSUE_REF_STATUSES) as IssueRefStatus | undefined;
8
+ }
9
+
10
+ function cleanProvider(value: unknown): IssueProvider {
11
+ return cleanEnum(value, "provider", ISSUE_PROVIDERS) as IssueProvider;
12
+ }
13
+
14
+ function cleanTimestamp(value: unknown): number | undefined {
15
+ if (value === undefined || value === null) return undefined;
16
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("statusSyncedAt must be a non-negative integer");
17
+ return value;
18
+ }
19
+
20
+ function cleanUpsertBody(body: unknown) {
21
+ if (!isRecord(body)) throw new Error("issue ref body required");
22
+ return {
23
+ provider: cleanProvider(body.provider),
24
+ repo: cleanString(body.repo, "repo", { required: true, max: 500 })!,
25
+ externalKey: cleanString(body.externalKey, "externalKey", { required: true, max: 240 })!,
26
+ url: cleanString(body.url, "url", { required: true, max: 2000 })!,
27
+ title: cleanString(body.title, "title", { max: 500 }),
28
+ status: cleanStatus(body.status),
29
+ statusSyncedAt: cleanTimestamp(body.statusSyncedAt),
30
+ };
31
+ }
32
+
33
+ function cleanPatchBody(body: unknown) {
34
+ if (!isRecord(body)) throw new Error("issue ref patch body required");
35
+ return {
36
+ url: cleanString(body.url, "url", { max: 2000 }),
37
+ title: cleanString(body.title, "title", { max: 500 }),
38
+ status: cleanStatus(body.status),
39
+ statusSyncedAt: cleanTimestamp(body.statusSyncedAt),
40
+ };
41
+ }
42
+
43
+ function cleanResolveBody(body: unknown) {
44
+ if (!isRecord(body)) throw new Error("issue ref resolve body required");
45
+ return body;
46
+ }
47
+
48
+ export const listIssueRefsRoute: Handler = (req) => {
49
+ try {
50
+ const url = new URL(req.url);
51
+ const rawLimit = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 500, clamp: true });
52
+ if (Number.isNaN(rawLimit)) return error("limit must be an integer");
53
+ const opts: ListIssueRefsOptions = {
54
+ provider: optionalEnum(url.searchParams.get("provider") ?? undefined, "provider", ISSUE_PROVIDERS) as IssueProvider | undefined,
55
+ repo: cleanString(url.searchParams.get("repo") ?? undefined, "repo", { max: 500 }),
56
+ status: optionalEnum(url.searchParams.get("status") ?? undefined, "status", ISSUE_REF_STATUSES) as IssueRefStatus | undefined,
57
+ limit: rawLimit ?? undefined,
58
+ };
59
+ return json({ issueRefs: listIssueRefs(opts) });
60
+ } catch (err) {
61
+ return error(err instanceof Error ? err.message : "issue refs route failed");
62
+ }
63
+ };
64
+
65
+ export const postIssueRefRoute: Handler = async (req) => {
66
+ const parsed = await parseBody<unknown>(req);
67
+ if (!parsed.ok) return error(parsed.error, parsed.status);
68
+ try {
69
+ return json({ issueRef: upsertIssueRefMapping(cleanUpsertBody(parsed.body)) });
70
+ } catch (err) {
71
+ return error(err instanceof Error ? err.message : "issue refs route failed");
72
+ }
73
+ };
74
+
75
+ export const resolveIssueRefRoute: Handler = async (req) => {
76
+ const url = new URL(req.url);
77
+ const body = req.method === "POST" ? await parseBody<unknown>(req) : null;
78
+ if (body && !body.ok) return error(body.error, body.status);
79
+ try {
80
+ const value = body ? cleanResolveBody(body.body) : Object.fromEntries(url.searchParams.entries());
81
+ const ref = cleanString(value.ref, "ref", { max: 1000 });
82
+ const issueRef = ref
83
+ ? resolveIssueRefMapping({
84
+ ref,
85
+ defaultRepo: cleanString(value.defaultRepo ?? value.repo, "defaultRepo", { max: 500 }),
86
+ provider: optionalEnum(value.provider, "provider", ISSUE_PROVIDERS, "github") as IssueProvider,
87
+ title: cleanString(value.title, "title", { max: 500 }),
88
+ status: cleanStatus(value.status),
89
+ statusSyncedAt: cleanTimestamp(value.statusSyncedAt),
90
+ })
91
+ : resolveIssueRefMapping({
92
+ provider: cleanProvider(value.provider),
93
+ repo: cleanString(value.repo, "repo", { required: true, max: 500 })!,
94
+ externalKey: cleanString(value.externalKey, "externalKey", { required: true, max: 240 })!,
95
+ url: cleanString(value.url, "url", { max: 2000 }),
96
+ title: cleanString(value.title, "title", { max: 500 }),
97
+ status: cleanStatus(value.status),
98
+ statusSyncedAt: cleanTimestamp(value.statusSyncedAt),
99
+ });
100
+ return json({ issueRef });
101
+ } catch (err) {
102
+ return error(err instanceof Error ? err.message : "issue refs route failed");
103
+ }
104
+ };
105
+
106
+ export const getIssueRefRoute: Handler = (_req, params) => {
107
+ try {
108
+ const id = cleanString(params.id, "id", { required: true, max: 120 })!;
109
+ const issueRef = getIssueRef(id);
110
+ if (!issueRef) return error("issue ref not found", 404);
111
+ return json({ issueRef });
112
+ } catch (err) {
113
+ return error(err instanceof Error ? err.message : "issue refs route failed");
114
+ }
115
+ };
116
+
117
+ export const patchIssueRefRoute: Handler = async (req, params) => {
118
+ const parsed = await parseBody<unknown>(req);
119
+ if (!parsed.ok) return error(parsed.error, parsed.status);
120
+ try {
121
+ const issueRef = patchIssueRefMapping(cleanString(params.id, "id", { required: true, max: 120 })!, cleanPatchBody(parsed.body));
122
+ if (!issueRef) return error("issue ref not found", 404);
123
+ return json({ issueRef });
124
+ } catch (err) {
125
+ return error(err instanceof Error ? err.message : "issue refs route failed");
126
+ }
127
+ };
128
+
129
+ export const deleteIssueRefRoute: Handler = (_req, params) => {
130
+ try {
131
+ const deleted = deleteIssueRefMapping(cleanString(params.id, "id", { required: true, max: 120 })!);
132
+ if (!deleted) return error("issue ref not found", 404);
133
+ return json({ deleted });
134
+ } catch (err) {
135
+ return error(err instanceof Error ? err.message : "issue refs route failed");
136
+ }
137
+ };
@@ -364,6 +364,26 @@ function orchestratorSelfUpgradeError(orch: ReturnType<typeof getOrchestrator>):
364
364
  return undefined;
365
365
  }
366
366
 
367
+ // One-time, server-authority migration of host-local provider config files into the
368
+ // central provider-config rows (#465). The orchestrator reports its files via the
369
+ // command result; the server seeds the rows (see patchCommand). Capability-gated so
370
+ // pre-#465 orchestrators that lack the handler never get a command they'd reject.
371
+ function dispatchProviderConfigMigration(
372
+ orch: NonNullable<ReturnType<typeof getOrchestrator>>,
373
+ requestedBy = "system",
374
+ ): ReturnType<typeof createCommand> | null {
375
+ if (!orch.capabilities?.migrateProviderConfig) return null;
376
+ const command = createCommand({
377
+ type: "orchestrator.migrate-provider-config",
378
+ source: "system",
379
+ target: orch.agentId,
380
+ ttlMs: 10 * 60_000,
381
+ params: { orchestratorId: orch.id, requestedBy },
382
+ });
383
+ emitCommand(command);
384
+ return command;
385
+ }
386
+
367
387
  function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>): void {
368
388
  if (!orch) return;
369
389
  const up = orch.upgrade;
@@ -389,6 +409,9 @@ function reconcileOrchestratorUpgrade(orch: ReturnType<typeof getOrchestrator>):
389
409
  view: "orchestrators",
390
410
  metadata: { orchestratorId: orch.id, version: reported, commandId: up.commandId },
391
411
  });
412
+ // Upgrade settled — the orchestrator is now running new-enough code to handle
413
+ // the provider-config migration, so seed the central rows from its local files (#465).
414
+ dispatchProviderConfigMigration(orch);
392
415
  emitOrchestratorStatus(orch.id);
393
416
  } else if (up.status === "pending" && Date.now() - up.requestedAt > ORCH_UPGRADE_DEADLINE_MS) {
394
417
  const errMsg = `upgrade timed out; orchestrator still reports ${reported ?? "unknown"} (wanted ${up.desiredVersion})`;
@@ -416,9 +439,27 @@ export const postOrchestratorAction: Handler = async (req, params) => {
416
439
  if (!orch) return error("orchestrator not found", 404);
417
440
 
418
441
  if (!isRecord(parsed.body)) return error("body required");
419
- const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
442
+ const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade", "migrate-provider-config"] as const);
420
443
  if (!action) return error("action required");
421
444
 
445
+ if (action === "migrate-provider-config") {
446
+ const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
447
+ if (denied) return denied;
448
+ const command = dispatchProviderConfigMigration(orch, "dashboard");
449
+ if (!command) return error(`orchestrator ${orch.id} does not support provider-config migration; upgrade it first`, 409);
450
+ auditEvent({
451
+ clientId: `server-orchestrator-migrate-provider-config-${orch.id}-${command.id}`,
452
+ kind: "state",
453
+ title: "Provider config migration requested",
454
+ body: orch.id,
455
+ meta: orch.id,
456
+ icon: "ti-arrow-up-circle",
457
+ view: "orchestrators",
458
+ metadata: { orchestratorId: orch.id, commandId: command.id, ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
459
+ });
460
+ return json({ ok: true, action, command }, 202);
461
+ }
462
+
422
463
  if (action === "upgrade") {
423
464
  const denied = authorizeRoute(req, { scope: "command:write", resource: { orchestratorId: orch.id } });
424
465
  if (denied) return denied;
package/src/security.ts CHANGED
@@ -188,6 +188,10 @@ export function requiredScopeFor(method: string, pathname: string): string | nul
188
188
  if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
189
189
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
190
190
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
191
+ if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
192
+ if (pathname.startsWith("/api/issue-activity")) return method === "GET" ? "issue:read" : "issue:write";
193
+ if (pathname.startsWith("/api/issue-lifecycle")) return method === "GET" ? "issue:read" : "issue:write";
194
+ if (pathname.startsWith("/api/issue-refs")) return method === "GET" ? "issue:read" : "issue:write";
191
195
  if (pathname.startsWith("/api/projects")) return method === "GET" ? "project:read" : "project:write";
192
196
  // Insights config (the feature toggle) stays admin-only via the default; only the
193
197
  // mechanical observation feed is writable by lower-privilege callers.
@@ -269,6 +273,10 @@ export function requiredComponentScopeFor(method: string, pathname: string): str
269
273
  if (pathname.startsWith("/api/plans")) return method === "GET" ? "plans:read" : "plans:write";
270
274
  if (pathname.startsWith("/api/teams")) return method === "GET" ? "teams:read" : "teams:write";
271
275
  if (pathname.startsWith("/api/blackboard")) return method === "GET" ? "blackboard:read" : "blackboard:write";
276
+ if (pathname.startsWith("/api/issue-associations")) return method === "GET" ? "issue:read" : "issue:write";
277
+ if (pathname.startsWith("/api/issue-activity")) return method === "GET" ? "issue:read" : "issue:write";
278
+ if (pathname.startsWith("/api/issue-lifecycle")) return method === "GET" ? "issue:read" : "issue:write";
279
+ if (pathname.startsWith("/api/issue-refs")) return method === "GET" ? "issue:read" : "issue:write";
272
280
  // Fail-closed default returns system:admin, so this case is load-bearing: without it
273
281
  // every /api/projects call from a scoped component token would 403-blackhole (#405).
274
282
  if (pathname.startsWith("/api/projects")) return method === "GET" ? "project:read" : "project:write";
@@ -0,0 +1,30 @@
1
+ import {
2
+ ISSUE_ACTIVITY_ACTIONS,
3
+ listAgentIssueActivity,
4
+ listIssueActivityAgents,
5
+ recordIssueActivity,
6
+ resolveIssueActivityRef,
7
+ type IssueActivityAction,
8
+ type RecordIssueActivityInput,
9
+ } from "../db/activity";
10
+ import { emitActivityEvent } from "../sse";
11
+ import type { IssueProvider } from "../types";
12
+
13
+ export {
14
+ ISSUE_ACTIVITY_ACTIONS,
15
+ listAgentIssueActivity,
16
+ listIssueActivityAgents,
17
+ recordIssueActivity,
18
+ type IssueActivityAction,
19
+ type RecordIssueActivityInput,
20
+ };
21
+
22
+ export function recordAndEmitIssueActivity(input: RecordIssueActivityInput) {
23
+ const event = recordIssueActivity(input);
24
+ emitActivityEvent(event);
25
+ return event;
26
+ }
27
+
28
+ export function resolveIssueActivityMapping(input: { issueRefId?: string; issueRef?: string; defaultRepo?: string; provider?: IssueProvider }) {
29
+ return resolveIssueActivityRef(input);
30
+ }
@@ -0,0 +1,72 @@
1
+ import {
2
+ associateIssue,
3
+ listEntitiesForIssue,
4
+ listIssuesForEntity,
5
+ removeAssociation,
6
+ } from "../db/issue-associations";
7
+ import { getIssueRef, resolveIssueRefByExternalKey, resolveIssueRefFromString } from "../db/issue-refs";
8
+ import type { IssueAssociationEntityType, IssueAssociationIssue, IssueProvider } from "../types";
9
+ import { ValidationError } from "../db/connection";
10
+
11
+ export interface ResolveAssociationIssueInput {
12
+ issueRefId?: string;
13
+ ref?: string;
14
+ defaultRepo?: string;
15
+ provider?: IssueProvider;
16
+ repo?: string;
17
+ externalKey?: string;
18
+ url?: string;
19
+ title?: string;
20
+ }
21
+
22
+ export interface AssociateIssueInput extends ResolveAssociationIssueInput {
23
+ entityType: IssueAssociationEntityType;
24
+ entityId: string;
25
+ }
26
+
27
+ export function resolveAssociationIssueRef(input: ResolveAssociationIssueInput) {
28
+ if (input.issueRefId) {
29
+ const issueRef = getIssueRef(input.issueRefId);
30
+ if (!issueRef) throw new ValidationError(`issue ref ${input.issueRefId} not found`);
31
+ return issueRef;
32
+ }
33
+ if (input.ref) {
34
+ return resolveIssueRefFromString({
35
+ ref: input.ref,
36
+ defaultRepo: input.defaultRepo ?? input.repo,
37
+ provider: input.provider,
38
+ title: input.title,
39
+ });
40
+ }
41
+ if (input.provider && input.repo && input.externalKey) {
42
+ return resolveIssueRefByExternalKey({
43
+ provider: input.provider,
44
+ repo: input.repo,
45
+ externalKey: input.externalKey,
46
+ url: input.url,
47
+ title: input.title,
48
+ });
49
+ }
50
+ throw new ValidationError("issueRefId, ref, or provider/repo/externalKey required");
51
+ }
52
+
53
+ export function associateIssueRef(input: AssociateIssueInput) {
54
+ const issueRef = resolveAssociationIssueRef(input);
55
+ return {
56
+ association: associateIssue(issueRef.id, input.entityType, input.entityId),
57
+ issueRef,
58
+ };
59
+ }
60
+
61
+ export function listIssueRefsForEntity(entityType: IssueAssociationEntityType, entityId: string): IssueAssociationIssue[] {
62
+ return listIssuesForEntity(entityType, entityId);
63
+ }
64
+
65
+ export function listIssueEntities(issueRefId: string) {
66
+ return listEntitiesForIssue(issueRefId);
67
+ }
68
+
69
+ export function removeIssueAssociation(input: AssociateIssueInput): { deleted: boolean } {
70
+ const issueRef = resolveAssociationIssueRef(input);
71
+ return { deleted: removeAssociation(issueRef.id, input.entityType, input.entityId) };
72
+ }
@@ -0,0 +1,308 @@
1
+ import { errMessage } from "agent-relay-sdk";
2
+ import { getAgent, listPlansWithBoundOwner } from "../db";
3
+ import { listIssuesForEntity, planNodeIssueEntityId } from "../db/issue-associations";
4
+ import {
5
+ getSuccessfulIssueLifecycleAction,
6
+ listSuccessfulCloseActionsForCommit,
7
+ upsertIssueLifecycleAction,
8
+ } from "../db/issue-lifecycle";
9
+ import { getIssueRef, resolveIssueRefByExternalKey, resolveIssueRefFromString, updateIssueRef } from "../db/issue-refs";
10
+ import { associateIssue } from "../db/issue-associations";
11
+ import { resolveIssueTrackerAdapter } from "../issue-tracker";
12
+ import type { IssueTrackerAdapter } from "../issue-tracker";
13
+ import { recordAndEmitIssueActivity } from "./issue-activity";
14
+ import type { IssueAssociationEntityType, IssueProvider, IssueRef, WorkspaceRecord } from "../types";
15
+
16
+ type LifecycleWorkspace = Pick<WorkspaceRecord, "id" | "repoRoot" | "branch" | "baseRef" | "ownerAgentId">;
17
+ type AdapterResolver = (ref: IssueRef) => IssueTrackerAdapter;
18
+ type LogFn = (message: string) => void;
19
+
20
+ export interface LandedIssueLifecycleInput {
21
+ workspace: LifecycleWorkspace;
22
+ mergedSha?: string;
23
+ subject?: string;
24
+ source?: string;
25
+ }
26
+
27
+ export interface ReopenIssueLifecycleInput {
28
+ issueRefId?: string;
29
+ ref?: string;
30
+ defaultRepo?: string;
31
+ provider?: IssueProvider;
32
+ repo?: string;
33
+ externalKey?: string;
34
+ url?: string;
35
+ title?: string;
36
+ comment?: string;
37
+ agentId?: string;
38
+ entityType?: IssueAssociationEntityType;
39
+ entityId?: string;
40
+ }
41
+
42
+ export interface IssueLifecycleOptions {
43
+ adapterResolver?: AdapterResolver;
44
+ log?: LogFn;
45
+ }
46
+
47
+ export type CloseIssueLifecycleResult =
48
+ | { issueRef: IssueRef; action: "closed"; source: "association" | "commit"; landKey: string }
49
+ | { issueRef: IssueRef; action: "skipped"; reason: "already-closed" | "already-processed"; landKey: string }
50
+ | { issueRef: IssueRef; action: "failed"; error: string; landKey: string };
51
+
52
+ export type ReopenIssueLifecycleResult =
53
+ | { issueRef: IssueRef; action: "reopened"; landKey: string }
54
+ | { issueRef: IssueRef; action: "skipped"; reason: "already-open"; landKey: string }
55
+ | { issueRef: IssueRef; action: "failed"; error: string; landKey: string };
56
+
57
+ let adapterResolverForTests: AdapterResolver | undefined;
58
+
59
+ export function __setIssueLifecycleAdapterResolverForTests(resolver: AdapterResolver | undefined): void {
60
+ adapterResolverForTests = resolver;
61
+ }
62
+
63
+ function adapterFor(ref: IssueRef, opts: IssueLifecycleOptions): IssueTrackerAdapter {
64
+ return (opts.adapterResolver ?? adapterResolverForTests ?? resolveIssueTrackerAdapter)(ref);
65
+ }
66
+
67
+ function logFailure(opts: IssueLifecycleOptions, message: string): void {
68
+ if (opts.log) opts.log(message);
69
+ else console.warn(message);
70
+ }
71
+
72
+ function defaultIssueRepo(): string | undefined {
73
+ return process.env.AGENT_RELAY_DEFAULT_ISSUE_REPO || process.env.GITHUB_REPOSITORY || undefined;
74
+ }
75
+
76
+ function cleanText(value: string | undefined): string | undefined {
77
+ const cleaned = value?.trim();
78
+ return cleaned || undefined;
79
+ }
80
+
81
+ function landKey(input: LandedIssueLifecycleInput): string {
82
+ const base = input.workspace.baseRef ?? "main";
83
+ const sha = cleanText(input.mergedSha);
84
+ if (sha) return `land:${base}:${sha}`;
85
+ return `land:${base}:${input.workspace.id}:${input.workspace.branch ?? "unknown"}:${input.subject ?? ""}`.slice(0, 240);
86
+ }
87
+
88
+ function manualReopenKey(issueRef: IssueRef): string {
89
+ return `manual-reopen:${issueRef.id}:${Date.now()}`;
90
+ }
91
+
92
+ function issueRefMap(refs: IssueRef[]): IssueRef[] {
93
+ const out = new Map<string, IssueRef>();
94
+ for (const ref of refs) out.set(ref.id, ref);
95
+ return [...out.values()];
96
+ }
97
+
98
+ function associatedIssueRefs(workspace: LifecycleWorkspace): IssueRef[] {
99
+ const refs: IssueRef[] = [];
100
+ const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
101
+ if (owner?.teamId) {
102
+ refs.push(...listIssuesForEntity("team", owner.teamId).map((item) => item.issueRef));
103
+ }
104
+ if (workspace.ownerAgentId) {
105
+ for (const plan of listPlansWithBoundOwner(workspace.ownerAgentId)) {
106
+ for (const node of plan.nodes) {
107
+ if (node.ownerRef?.agentId !== workspace.ownerAgentId) continue;
108
+ refs.push(...listIssuesForEntity("plan-node", planNodeIssueEntityId(plan.id, node.id)).map((item) => item.issueRef));
109
+ }
110
+ }
111
+ }
112
+ return issueRefMap(refs);
113
+ }
114
+
115
+ const CLOSING_KEYWORD_RE = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+([^\n\r]+)/gi;
116
+ const ISSUE_REF_AT_START_RE = /^(https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/issues\/[0-9]+(?:[/?#][^\s,)]*)?|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[0-9]+|#[0-9]+)/;
117
+ const REF_SEPARATOR_RE = /^(?:\s*,\s*|\s+(?:and|&)\s+)/i;
118
+
119
+ export function parseClosingIssueRefs(text: string | undefined, fallbackDefaultRepo = defaultIssueRepo()): IssueRef[] {
120
+ if (!text) return [];
121
+ const refs: IssueRef[] = [];
122
+ for (const match of text.matchAll(CLOSING_KEYWORD_RE)) {
123
+ let rest = match[1] ?? "";
124
+ while (rest.trim()) {
125
+ rest = rest.trimStart();
126
+ const refMatch = rest.match(ISSUE_REF_AT_START_RE);
127
+ if (!refMatch) break;
128
+ try {
129
+ refs.push(resolveIssueRefFromString({ ref: refMatch[1]!, defaultRepo: fallbackDefaultRepo }));
130
+ } catch {
131
+ // Invalid or bare-without-default refs are ignored; the land itself must never fail here.
132
+ }
133
+ rest = rest.slice(refMatch[0].length);
134
+ const separator = rest.match(REF_SEPARATOR_RE);
135
+ if (!separator) break;
136
+ rest = rest.slice(separator[0].length);
137
+ }
138
+ }
139
+ return issueRefMap(refs);
140
+ }
141
+
142
+ export function resolveIssueRefsForLand(input: LandedIssueLifecycleInput): { source: "association" | "commit"; refs: IssueRef[] } {
143
+ const associated = associatedIssueRefs(input.workspace);
144
+ if (associated.length > 0) return { source: "association", refs: associated };
145
+ return { source: "commit", refs: parseClosingIssueRefs(input.subject) };
146
+ }
147
+
148
+ function closeAuditComment(input: LandedIssueLifecycleInput): string {
149
+ const commit = input.mergedSha ? input.mergedSha.slice(0, 12) : input.workspace.branch ?? input.workspace.id;
150
+ return `Closed by \`${commit}\` on merge to \`main\` - reopen if release surfaces more work.`;
151
+ }
152
+
153
+ async function closeIssueRefForLand(
154
+ issueRef: IssueRef,
155
+ source: "association" | "commit",
156
+ input: LandedIssueLifecycleInput,
157
+ opts: IssueLifecycleOptions,
158
+ ): Promise<CloseIssueLifecycleResult> {
159
+ const key = landKey(input);
160
+ if (getSuccessfulIssueLifecycleAction(issueRef.id, "close", key)) return { issueRef, action: "skipped", reason: "already-processed", landKey: key };
161
+ const latest = getIssueRef(issueRef.id) ?? issueRef;
162
+ if (latest.status === "closed") return { issueRef: latest, action: "skipped", reason: "already-closed", landKey: key };
163
+
164
+ const comment = closeAuditComment(input);
165
+ try {
166
+ await adapterFor(latest, opts).close(latest, { comment, labels: ["fixed"] });
167
+ const closed = updateIssueRef(latest.id, { status: "closed", statusSyncedAt: Date.now() }) ?? latest;
168
+ upsertIssueLifecycleAction({
169
+ issueRefId: latest.id,
170
+ action: "close",
171
+ landKey: key,
172
+ workspaceId: input.workspace.id,
173
+ branch: input.workspace.branch,
174
+ baseRef: input.workspace.baseRef ?? "main",
175
+ commitSha: input.mergedSha,
176
+ commitSubject: input.subject,
177
+ agentId: input.workspace.ownerAgentId,
178
+ source,
179
+ comment,
180
+ status: "succeeded",
181
+ });
182
+ if (input.workspace.ownerAgentId) {
183
+ recordAndEmitIssueActivity({
184
+ agentId: input.workspace.ownerAgentId,
185
+ action: "done",
186
+ issueRefId: latest.id,
187
+ clientId: `issue-close:${latest.id}:${key}`,
188
+ title: "Issue closed on land",
189
+ body: comment,
190
+ metadata: { workspaceId: input.workspace.id, branch: input.workspace.branch, commitSha: input.mergedSha, source },
191
+ });
192
+ }
193
+ return { issueRef: closed, action: "closed", source, landKey: key };
194
+ } catch (error) {
195
+ const message = errMessage(error);
196
+ upsertIssueLifecycleAction({
197
+ issueRefId: latest.id,
198
+ action: "close",
199
+ landKey: key,
200
+ workspaceId: input.workspace.id,
201
+ branch: input.workspace.branch,
202
+ baseRef: input.workspace.baseRef ?? "main",
203
+ commitSha: input.mergedSha,
204
+ commitSubject: input.subject,
205
+ agentId: input.workspace.ownerAgentId,
206
+ source,
207
+ comment,
208
+ status: "failed",
209
+ error: message,
210
+ });
211
+ logFailure(opts, `issue lifecycle close failed for ${latest.id} on ${key}: ${message}`);
212
+ return { issueRef: latest, action: "failed", error: message, landKey: key };
213
+ }
214
+ }
215
+
216
+ function revertedCommitLookup(subject: string | undefined): { commitSha?: string; commitSubject?: string } {
217
+ const text = cleanText(subject);
218
+ if (!text) return {};
219
+ const sha = text.match(/\bThis reverts commit ([0-9a-f]{7,64})\b/i)?.[1];
220
+ if (sha) return { commitSha: sha };
221
+ const revertedSubject = text.match(/^Revert "(.+)"$/)?.[1];
222
+ return revertedSubject ? { commitSubject: revertedSubject } : {};
223
+ }
224
+
225
+ async function reopenIssuesForRevert(input: LandedIssueLifecycleInput, opts: IssueLifecycleOptions): Promise<ReopenIssueLifecycleResult[]> {
226
+ const lookup = revertedCommitLookup(input.subject);
227
+ const closedActions = listSuccessfulCloseActionsForCommit(lookup);
228
+ const results: ReopenIssueLifecycleResult[] = [];
229
+ for (const action of closedActions) {
230
+ results.push(await reopenIssueLifecycle({
231
+ issueRefId: action.issueRefId,
232
+ comment: `Reopened by revert \`${input.mergedSha?.slice(0, 12) ?? input.workspace.branch ?? input.workspace.id}\` on merge to \`main\`.`,
233
+ agentId: input.workspace.ownerAgentId,
234
+ }, opts));
235
+ }
236
+ return results;
237
+ }
238
+
239
+ export async function handleLandedIssueLifecycle(input: LandedIssueLifecycleInput, opts: IssueLifecycleOptions = {}): Promise<{
240
+ source: "association" | "commit";
241
+ closed: CloseIssueLifecycleResult[];
242
+ reopened: ReopenIssueLifecycleResult[];
243
+ skipped: string[];
244
+ }> {
245
+ if ((input.workspace.baseRef ?? "main") !== "main") return { source: "commit", closed: [], reopened: [], skipped: ["non-default-branch"] };
246
+ const resolved = resolveIssueRefsForLand(input);
247
+ const closed: CloseIssueLifecycleResult[] = [];
248
+ for (const ref of resolved.refs) {
249
+ closed.push(await closeIssueRefForLand(ref, resolved.source, input, opts));
250
+ }
251
+ const reopened = await reopenIssuesForRevert(input, opts);
252
+ return { source: resolved.source, closed, reopened, skipped: [] };
253
+ }
254
+
255
+ function resolveReopenRef(input: ReopenIssueLifecycleInput): IssueRef {
256
+ if (input.issueRefId) {
257
+ const issueRef = getIssueRef(input.issueRefId);
258
+ if (!issueRef) throw new Error(`issue ref ${input.issueRefId} not found`);
259
+ return issueRef;
260
+ }
261
+ if (input.ref) return resolveIssueRefFromString({ ref: input.ref, defaultRepo: input.defaultRepo ?? input.repo, provider: input.provider, title: input.title });
262
+ if (input.provider && input.repo && input.externalKey) {
263
+ return resolveIssueRefByExternalKey({
264
+ provider: input.provider,
265
+ repo: input.repo,
266
+ externalKey: input.externalKey,
267
+ url: input.url,
268
+ title: input.title,
269
+ });
270
+ }
271
+ throw new Error("issueRefId, ref, or provider/repo/externalKey required");
272
+ }
273
+
274
+ export async function reopenIssueLifecycle(input: ReopenIssueLifecycleInput, opts: IssueLifecycleOptions = {}): Promise<ReopenIssueLifecycleResult> {
275
+ const issueRef = resolveReopenRef(input);
276
+ const key = manualReopenKey(issueRef);
277
+ if (issueRef.status === "open") return { issueRef, action: "skipped", reason: "already-open", landKey: key };
278
+ const comment = input.comment ?? "Reopened by Agent Relay issue lifecycle.";
279
+ try {
280
+ await adapterFor(issueRef, opts).reopen(issueRef, { comment });
281
+ const opened = updateIssueRef(issueRef.id, { status: "open", statusSyncedAt: Date.now() }) ?? issueRef;
282
+ upsertIssueLifecycleAction({
283
+ issueRefId: issueRef.id,
284
+ action: "reopen",
285
+ landKey: key,
286
+ agentId: input.agentId,
287
+ comment,
288
+ status: "succeeded",
289
+ source: "explicit",
290
+ });
291
+ if (input.entityType && input.entityId) associateIssue(issueRef.id, input.entityType, input.entityId);
292
+ return { issueRef: opened, action: "reopened", landKey: key };
293
+ } catch (error) {
294
+ const message = errMessage(error);
295
+ upsertIssueLifecycleAction({
296
+ issueRefId: issueRef.id,
297
+ action: "reopen",
298
+ landKey: key,
299
+ agentId: input.agentId,
300
+ comment,
301
+ status: "failed",
302
+ error: message,
303
+ source: "explicit",
304
+ });
305
+ logFailure(opts, `issue lifecycle reopen failed for ${issueRef.id}: ${message}`);
306
+ return { issueRef, action: "failed", error: message, landKey: key };
307
+ }
308
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ deleteIssueRef,
3
+ getIssueRef,
4
+ listIssueRefs,
5
+ resolveIssueRefByExternalKey,
6
+ resolveIssueRefFromString,
7
+ updateIssueRef,
8
+ upsertIssueRef,
9
+ type ListIssueRefsOptions,
10
+ type ResolveIssueRefByExternalKeyInput,
11
+ type ResolveIssueRefFromStringInput,
12
+ type UpdateIssueRefInput,
13
+ type UpsertIssueRefInput,
14
+ } from "../db/issue-refs";
15
+
16
+ export { getIssueRef, listIssueRefs, type ListIssueRefsOptions };
17
+
18
+ export function upsertIssueRefMapping(input: UpsertIssueRefInput) {
19
+ return upsertIssueRef(input);
20
+ }
21
+
22
+ export function resolveIssueRefMapping(input: ResolveIssueRefByExternalKeyInput | ResolveIssueRefFromStringInput) {
23
+ return "ref" in input ? resolveIssueRefFromString(input) : resolveIssueRefByExternalKey(input);
24
+ }
25
+
26
+ export function patchIssueRefMapping(id: string, input: UpdateIssueRefInput) {
27
+ return updateIssueRef(id, input);
28
+ }
29
+
30
+ export function deleteIssueRefMapping(id: string) {
31
+ return deleteIssueRef(id);
32
+ }