agent-relay-server 0.61.1 → 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.
- package/docs/openapi.json +479 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CX63ZV4X.js → MessageBubble-BP0Ym3xb.js} +2 -2
- package/public/assets/{MessageBubble-CX63ZV4X.js.map → MessageBubble-BP0Ym3xb.js.map} +1 -1
- package/public/assets/PlanGraphPanel-DKc3gdou.js +2 -0
- package/public/assets/PlanGraphPanel-DKc3gdou.js.map +1 -0
- package/public/assets/{activity-2xPOScu_.js → activity-CRLG6ICe.js} +2 -2
- package/public/assets/{activity-2xPOScu_.js.map → activity-CRLG6ICe.js.map} +1 -1
- package/public/assets/{agent-profiles-BfGTTbeY.js → agent-profiles-DItyEkXP.js} +2 -2
- package/public/assets/{agent-profiles-BfGTTbeY.js.map → agent-profiles-DItyEkXP.js.map} +1 -1
- package/public/assets/{agents-Bg3WOBRV.js → agents-5mKiZ1rz.js} +2 -2
- package/public/assets/{agents-Bg3WOBRV.js.map → agents-5mKiZ1rz.js.map} +1 -1
- package/public/assets/{analytics-De0cEZGx.js → analytics-J5pPcS-v.js} +3 -3
- package/public/assets/{analytics-De0cEZGx.js.map → analytics-J5pPcS-v.js.map} +1 -1
- package/public/assets/{automation-CHxhzq2t.js → automation-BG2HWK5N.js} +2 -2
- package/public/assets/{automation-CHxhzq2t.js.map → automation-BG2HWK5N.js.map} +1 -1
- package/public/assets/{badge-DhLyCPag.js → badge-CZhIdlTJ.js} +2 -2
- package/public/assets/{badge-DhLyCPag.js.map → badge-CZhIdlTJ.js.map} +1 -1
- package/public/assets/{branch-state-badge-DSyWKluD.js → branch-state-badge-BZMoxs65.js} +2 -2
- package/public/assets/{branch-state-badge-DSyWKluD.js.map → branch-state-badge-BZMoxs65.js.map} +1 -1
- package/public/assets/{button-K20wAZG1.js → button-2taQeNHW.js} +2 -2
- package/public/assets/{button-K20wAZG1.js.map → button-2taQeNHW.js.map} +1 -1
- package/public/assets/card-DzIsAI41.js +2 -0
- package/public/assets/{card-CcZ9hLI7.js.map → card-DzIsAI41.js.map} +1 -1
- package/public/assets/channels-B2PeqiRT.js +2 -0
- package/public/assets/{channels-BG0Nib7y.js.map → channels-B2PeqiRT.js.map} +1 -1
- package/public/assets/chat-CLRJVagA.js +2 -0
- package/public/assets/{chat-CjP_crSl.js.map → chat-CLRJVagA.js.map} +1 -1
- package/public/assets/{connectors-DWh7MwV_.js → connectors-Bzvkxsgo.js} +2 -2
- package/public/assets/{connectors-DWh7MwV_.js.map → connectors-Bzvkxsgo.js.map} +1 -1
- package/public/assets/copy-button-HEkwi6xX.js +2 -0
- package/public/assets/{copy-button-eF3cKp4Q.js.map → copy-button-HEkwi6xX.js.map} +1 -1
- package/public/assets/display-DzNvOu1j.js.map +1 -1
- package/public/assets/dist-1rXN5FN9.js +2 -0
- package/public/assets/{dist-YpHfnnIA.js.map → dist-1rXN5FN9.js.map} +1 -1
- package/public/assets/{dist-1dhfdhm9.js → dist-AbEQuFoK.js} +2 -2
- package/public/assets/{dist-1dhfdhm9.js.map → dist-AbEQuFoK.js.map} +1 -1
- package/public/assets/{dist-COQuunEv.js → dist-B7p1zkvf.js} +2 -2
- package/public/assets/{dist-COQuunEv.js.map → dist-B7p1zkvf.js.map} +1 -1
- package/public/assets/{dist-Ci5Y37nj.js → dist-CG10hM1-.js} +2 -2
- package/public/assets/{dist-Ci5Y37nj.js.map → dist-CG10hM1-.js.map} +1 -1
- package/public/assets/dist-Cfm5_zPB.js +2 -0
- package/public/assets/{dist-qd198ib9.js.map → dist-Cfm5_zPB.js.map} +1 -1
- package/public/assets/{dist-BrQsqBbc.js → dist-Cgw85UcE.js} +2 -2
- package/public/assets/{dist-BrQsqBbc.js.map → dist-Cgw85UcE.js.map} +1 -1
- package/public/assets/{dist-CoROafNg.js → dist-D3_TJKTC.js} +2 -2
- package/public/assets/{dist-CoROafNg.js.map → dist-D3_TJKTC.js.map} +1 -1
- package/public/assets/{dist-I-MFUv51.js → dist-DQkLq0hs.js} +2 -2
- package/public/assets/{dist-I-MFUv51.js.map → dist-DQkLq0hs.js.map} +1 -1
- package/public/assets/{dist-UQGKwqZH.js → dist-D_sl7u_f.js} +2 -2
- package/public/assets/{dist-UQGKwqZH.js.map → dist-D_sl7u_f.js.map} +1 -1
- package/public/assets/{dist-yGOq6sB3.js → dist-Ilb3r1im.js} +2 -2
- package/public/assets/{dist-yGOq6sB3.js.map → dist-Ilb3r1im.js.map} +1 -1
- package/public/assets/dist-Pf_3Gf38.js +2 -0
- package/public/assets/{dist-BLBF7qqr.js.map → dist-Pf_3Gf38.js.map} +1 -1
- package/public/assets/{es2015-CRxz9lOj.js → es2015-GSepNaIS.js} +2 -2
- package/public/assets/{es2015-CRxz9lOj.js.map → es2015-GSepNaIS.js.map} +1 -1
- package/public/assets/{formatted-body-impl-u0W_hWYA.js → formatted-body-impl-QqHxdMGP.js} +2 -2
- package/public/assets/{formatted-body-impl-u0W_hWYA.js.map → formatted-body-impl-QqHxdMGP.js.map} +1 -1
- package/public/assets/index-CCp1V35c.js +22 -0
- package/public/assets/index-CCp1V35c.js.map +1 -0
- package/public/assets/index-DiR-fqy_.css +2 -0
- package/public/assets/{input-BHYNpXAG.js → input-BsNqlONJ.js} +2 -2
- package/public/assets/{input-BHYNpXAG.js.map → input-BsNqlONJ.js.map} +1 -1
- package/public/assets/{insights-CAF9Hi5D.js → insights-D-EyDfoZ.js} +2 -2
- package/public/assets/{insights-CAF9Hi5D.js.map → insights-D-EyDfoZ.js.map} +1 -1
- package/public/assets/{integrations-CG9qyHUf.js → integrations-D1kc-yNi.js} +2 -2
- package/public/assets/{integrations-CG9qyHUf.js.map → integrations-D1kc-yNi.js.map} +1 -1
- package/public/assets/{lib-Df4aph5u.js → lib-bXPu6xCL.js} +2 -2
- package/public/assets/{lib-Df4aph5u.js.map → lib-bXPu6xCL.js.map} +1 -1
- package/public/assets/lucide-react-DWYjU-AF.js +9 -0
- package/public/assets/{lucide-react-L2UxFDic.js.map → lucide-react-DWYjU-AF.js.map} +1 -1
- package/public/assets/{maintenance-DgHA35ve.js → maintenance-COqID7aq.js} +2 -2
- package/public/assets/{maintenance-DgHA35ve.js.map → maintenance-COqID7aq.js.map} +1 -1
- package/public/assets/{managed-agents-Rk6ghKR9.js → managed-agents-b4B8qewQ.js} +2 -2
- package/public/assets/{managed-agents-Rk6ghKR9.js.map → managed-agents-b4B8qewQ.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-NWMtJdYz.js → markdown-preview-impl-D7o8jS07.js} +2 -2
- package/public/assets/{markdown-preview-impl-NWMtJdYz.js.map → markdown-preview-impl-D7o8jS07.js.map} +1 -1
- package/public/assets/memory-Q0qF7_i3.js +2 -0
- package/public/assets/{memory-DamwWa9A.js.map → memory-Q0qF7_i3.js.map} +1 -1
- package/public/assets/{messages--NU-YIL6.js → messages-m4EpkK6s.js} +2 -2
- package/public/assets/{messages--NU-YIL6.js.map → messages-m4EpkK6s.js.map} +1 -1
- package/public/assets/{orchestrators-DQnIn6qx.js → orchestrators-CnAtGfUN.js} +2 -2
- package/public/assets/{orchestrators-DQnIn6qx.js.map → orchestrators-CnAtGfUN.js.map} +1 -1
- package/public/assets/{overview-DCmNgXDf.js → overview-D7CF88ik.js} +2 -2
- package/public/assets/{overview-DCmNgXDf.js.map → overview-D7CF88ik.js.map} +1 -1
- package/public/assets/pairs-Q6PlFJtH.js +2 -0
- package/public/assets/{pairs-DNELeC9P.js.map → pairs-Q6PlFJtH.js.map} +1 -1
- package/public/assets/{react-dom-CrP_AtcL.js → react-dom-CPghDLpB.js} +2 -2
- package/public/assets/{react-dom-CrP_AtcL.js.map → react-dom-CPghDLpB.js.map} +1 -1
- package/public/assets/{security-Dd3Kh0oC.js → security-BeV-6SdA.js} +2 -2
- package/public/assets/{security-Dd3Kh0oC.js.map → security-BeV-6SdA.js.map} +1 -1
- package/public/assets/{select-DQbeJZX0.js → select-Dchc0_Jg.js} +2 -2
- package/public/assets/{select-DQbeJZX0.js.map → select-Dchc0_Jg.js.map} +1 -1
- package/public/assets/{settings-BMAJrBIH.js → settings-hPwL-31u.js} +3 -3
- package/public/assets/{settings-BMAJrBIH.js.map → settings-hPwL-31u.js.map} +1 -1
- package/public/assets/store-Buwkqv_7.js +9 -0
- package/public/assets/store-Buwkqv_7.js.map +1 -0
- package/public/assets/tasks-DKUMPEgk.js +2 -0
- package/public/assets/{tasks-BYR4L9GP.js.map → tasks-DKUMPEgk.js.map} +1 -1
- package/public/assets/teams-T5fFsdOX.js +2 -0
- package/public/assets/teams-T5fFsdOX.js.map +1 -0
- package/public/assets/{terminal-viewer-impl-B1YzfpE6.js → terminal-viewer-impl-Bk0u_Mav.js} +3 -3
- package/public/assets/{terminal-viewer-impl-B1YzfpE6.js.map → terminal-viewer-impl-Bk0u_Mav.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-DrRH_QzG.js → use-keyboard-viewport-NKw1O5gC.js} +2 -2
- package/public/assets/{use-keyboard-viewport-DrRH_QzG.js.map → use-keyboard-viewport-NKw1O5gC.js.map} +1 -1
- package/public/assets/{work-queue-x57vlgyZ.js → work-queue-D26jok09.js} +2 -2
- package/public/assets/{work-queue-x57vlgyZ.js.map → work-queue-D26jok09.js.map} +1 -1
- package/public/assets/{workspaces-CYm5bLg9.js → workspaces-BMj6zx23.js} +3 -3
- package/public/assets/{workspaces-CYm5bLg9.js.map → workspaces-BMj6zx23.js.map} +1 -1
- package/public/index.html +21 -21
- package/public/sw.js +32 -8
- package/src/automations.ts +40 -7
- package/src/branch-landed.ts +5 -0
- package/src/bus.ts +3 -0
- package/src/cli/workspace.ts +9 -17
- package/src/db/activity.ts +191 -3
- package/src/db/integrations.ts +1 -0
- package/src/db/issue-associations.ts +128 -0
- package/src/db/issue-lifecycle.ts +187 -0
- package/src/db/issue-refs.ts +254 -0
- package/src/db/mappers.ts +2 -0
- package/src/db/migrations.ts +47 -0
- package/src/db/schema.ts +2 -2
- package/src/issue-tracker/adapter.ts +24 -0
- package/src/issue-tracker/github.ts +136 -0
- package/src/issue-tracker/index.ts +13 -0
- package/src/issue-tracker/registry.ts +26 -0
- package/src/maintenance.ts +22 -169
- package/src/mcp-issue-activity-tools.ts +114 -0
- package/src/mcp-issue-association-tools.ts +130 -0
- package/src/mcp-issue-lifecycle-tools.ts +55 -0
- package/src/mcp-issue-ref-tools.ts +174 -0
- package/src/mcp-team-tools.ts +1 -1
- package/src/mcp.ts +3 -3
- package/src/provider-config-store.ts +30 -8
- package/src/routes/activity.ts +6 -1
- package/src/routes/commands.ts +29 -0
- package/src/routes/index.ts +19 -0
- package/src/routes/issue-activity.ts +78 -0
- package/src/routes/issue-associations.ts +76 -0
- package/src/routes/issue-lifecycle.ts +34 -0
- package/src/routes/issue-refs.ts +137 -0
- package/src/routes/orchestrator.ts +42 -1
- package/src/security.ts +8 -0
- package/src/services/issue-activity.ts +30 -0
- package/src/services/issue-associations.ts +72 -0
- package/src/services/issue-lifecycle.ts +308 -0
- package/src/services/issue-refs.ts +32 -0
- package/src/services/plan-graph.ts +40 -2
- package/src/services/team.ts +4 -0
- package/src/token-db.ts +3 -3
- package/src/workspace-actions.ts +8 -1
- package/src/workspace-auto-merge.ts +228 -0
- package/src/workspace-probe.ts +125 -0
- package/public/assets/PlanGraphPanel-Dby7wq9c.js +0 -2
- package/public/assets/PlanGraphPanel-Dby7wq9c.js.map +0 -1
- package/public/assets/card-CcZ9hLI7.js +0 -2
- package/public/assets/channels-BG0Nib7y.js +0 -2
- package/public/assets/chat-CjP_crSl.js +0 -2
- package/public/assets/copy-button-eF3cKp4Q.js +0 -2
- package/public/assets/dist-BLBF7qqr.js +0 -2
- package/public/assets/dist-YpHfnnIA.js +0 -2
- package/public/assets/dist-qd198ib9.js +0 -2
- package/public/assets/index-BwfVOzUG.js +0 -21
- package/public/assets/index-BwfVOzUG.js.map +0 -1
- package/public/assets/index-Dn_GDXbi.css +0 -2
- package/public/assets/lucide-react-L2UxFDic.js +0 -9
- package/public/assets/memory-DamwWa9A.js +0 -2
- package/public/assets/pairs-DNELeC9P.js +0 -2
- package/public/assets/store-B8xP6z6T.js +0 -9
- package/public/assets/store-B8xP6z6T.js.map +0 -1
- package/public/assets/tasks-BYR4L9GP.js +0 -2
- package/public/assets/teams-CoTifrq_.js +0 -2
- package/public/assets/teams-CoTifrq_.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
|
+
}
|