@ipul-labs/forge 1.22.0 → 1.35.1
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/LICENSE +21 -0
- package/README.md +153 -78
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/index-CNvSOdpC.js +27 -0
- package/dist/assets/index-CSH1OQYQ.css +1 -0
- package/dist/boot-check.js +98 -0
- package/dist/favicon.svg +8 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/icon.svg +12 -0
- package/dist/index.html +33 -0
- package/dist/manifest.webmanifest +18 -0
- package/dist/theme-init.js +11 -0
- package/lib/bin/cli/analyze.d.ts.map +1 -1
- package/lib/bin/cli/analyze.js +3 -44
- package/lib/bin/cli/analyze.js.map +1 -1
- package/lib/bin/cli/apply.d.ts.map +1 -1
- package/lib/bin/cli/apply.js +2 -20
- package/lib/bin/cli/apply.js.map +1 -1
- package/lib/bin/cli/cifix.d.ts.map +1 -1
- package/lib/bin/cli/cifix.js +4 -28
- package/lib/bin/cli/cifix.js.map +1 -1
- package/lib/bin/cli/close.d.ts.map +1 -1
- package/lib/bin/cli/close.js +2 -33
- package/lib/bin/cli/close.js.map +1 -1
- package/lib/bin/cli/createIssue.d.ts.map +1 -1
- package/lib/bin/cli/createIssue.js +3 -12
- package/lib/bin/cli/createIssue.js.map +1 -1
- package/lib/bin/cli/doctor.d.ts.map +1 -1
- package/lib/bin/cli/doctor.js +3 -4
- package/lib/bin/cli/doctor.js.map +1 -1
- package/lib/bin/cli/init.js +1 -10
- package/lib/bin/cli/init.js.map +1 -1
- package/lib/bin/cli/issue.d.ts.map +1 -1
- package/lib/bin/cli/issue.js +3 -12
- package/lib/bin/cli/issue.js.map +1 -1
- package/lib/bin/cli/issues.d.ts.map +1 -1
- package/lib/bin/cli/issues.js +3 -12
- package/lib/bin/cli/issues.js.map +1 -1
- package/lib/bin/cli/merge.d.ts.map +1 -1
- package/lib/bin/cli/merge.js +4 -20
- package/lib/bin/cli/merge.js.map +1 -1
- package/lib/bin/cli/refine.d.ts.map +1 -1
- package/lib/bin/cli/refine.js +2 -26
- package/lib/bin/cli/refine.js.map +1 -1
- package/lib/bin/cli/repl.d.ts.map +1 -1
- package/lib/bin/cli/repl.js +4 -13
- package/lib/bin/cli/repl.js.map +1 -1
- package/lib/bin/cli/repos.d.ts.map +1 -1
- package/lib/bin/cli/repos.js +1 -11
- package/lib/bin/cli/repos.js.map +1 -1
- package/lib/bin/cli/resetPassword.d.ts +21 -0
- package/lib/bin/cli/resetPassword.d.ts.map +1 -0
- package/lib/bin/cli/resetPassword.js +69 -0
- package/lib/bin/cli/resetPassword.js.map +1 -0
- package/lib/bin/cli/resolution.d.ts.map +1 -1
- package/lib/bin/cli/resolution.js +2 -19
- package/lib/bin/cli/resolution.js.map +1 -1
- package/lib/bin/cli/serverClient.d.ts +2 -0
- package/lib/bin/cli/serverClient.d.ts.map +1 -1
- package/lib/bin/cli/serverClient.js +8 -4
- package/lib/bin/cli/serverClient.js.map +1 -1
- package/lib/bin/cli/session.d.ts.map +1 -1
- package/lib/bin/cli/session.js.map +1 -1
- package/lib/bin/cli/sessionStore.d.ts +1 -1
- package/lib/bin/cli/sessionStore.js +2 -2
- package/lib/bin/cli/sessionStore.js.map +1 -1
- package/lib/bin/cli/ship.d.ts.map +1 -1
- package/lib/bin/cli/ship.js +15 -23
- package/lib/bin/cli/ship.js.map +1 -1
- package/lib/bin/cli/update.d.ts.map +1 -1
- package/lib/bin/cli/update.js +34 -28
- package/lib/bin/cli/update.js.map +1 -1
- package/lib/bin/cli/updatePlan.d.ts +30 -0
- package/lib/bin/cli/updatePlan.d.ts.map +1 -0
- package/lib/bin/cli/updatePlan.js +50 -0
- package/lib/bin/cli/updatePlan.js.map +1 -0
- package/lib/bin/cli/utils.d.ts +3 -0
- package/lib/bin/cli/utils.d.ts.map +1 -1
- package/lib/bin/cli/utils.js +7 -23
- package/lib/bin/cli/utils.js.map +1 -1
- package/lib/bin/cli/validate.d.ts.map +1 -1
- package/lib/bin/cli/validate.js +3 -25
- package/lib/bin/cli/validate.js.map +1 -1
- package/lib/bin/cli/watch.d.ts.map +1 -1
- package/lib/bin/cli/watch.js +3 -12
- package/lib/bin/cli/watch.js.map +1 -1
- package/lib/bin/forge-agent.js +10 -22
- package/lib/bin/forge-agent.js.map +1 -1
- package/lib/server/app.d.ts +24 -0
- package/lib/server/app.d.ts.map +1 -0
- package/lib/server/app.js +58 -0
- package/lib/server/app.js.map +1 -0
- package/lib/server/forge/analyses/analysisStore.d.ts +53 -0
- package/lib/server/forge/analyses/analysisStore.d.ts.map +1 -0
- package/lib/server/forge/analyses/analysisStore.js +77 -0
- package/lib/server/forge/analyses/analysisStore.js.map +1 -0
- package/lib/server/forge/auth/apiTokens.d.ts +34 -0
- package/lib/server/forge/auth/apiTokens.d.ts.map +1 -0
- package/lib/server/forge/auth/apiTokens.js +44 -0
- package/lib/server/forge/auth/apiTokens.js.map +1 -0
- package/lib/server/forge/auth/auditLog.d.ts +36 -0
- package/lib/server/forge/auth/auditLog.d.ts.map +1 -0
- package/lib/server/forge/auth/auditLog.js +56 -0
- package/lib/server/forge/auth/auditLog.js.map +1 -0
- package/lib/server/forge/auth/authConfig.d.ts +13 -0
- package/lib/server/forge/auth/authConfig.d.ts.map +1 -0
- package/lib/server/forge/auth/authConfig.js +37 -0
- package/lib/server/forge/auth/authConfig.js.map +1 -0
- package/lib/server/forge/auth/createAuth.d.ts +130 -0
- package/lib/server/forge/auth/createAuth.d.ts.map +1 -0
- package/lib/server/forge/auth/createAuth.js +387 -0
- package/lib/server/forge/auth/createAuth.js.map +1 -0
- package/lib/server/forge/auth/middleware.d.ts +10 -0
- package/lib/server/forge/auth/middleware.d.ts.map +1 -0
- package/lib/server/forge/auth/middleware.js +22 -0
- package/lib/server/forge/auth/middleware.js.map +1 -0
- package/lib/server/forge/auth/oidc.d.ts +38 -0
- package/lib/server/forge/auth/oidc.d.ts.map +1 -0
- package/lib/server/forge/auth/oidc.js +63 -0
- package/lib/server/forge/auth/oidc.js.map +1 -0
- package/lib/server/forge/auth/passwords.d.ts +7 -0
- package/lib/server/forge/auth/passwords.d.ts.map +1 -0
- package/lib/server/forge/auth/passwords.js +28 -0
- package/lib/server/forge/auth/passwords.js.map +1 -0
- package/lib/server/forge/auth/resetPassword.d.ts +14 -0
- package/lib/server/forge/auth/resetPassword.d.ts.map +1 -0
- package/lib/server/forge/auth/resetPassword.js +29 -0
- package/lib/server/forge/auth/resetPassword.js.map +1 -0
- package/lib/server/forge/auth/roles.d.ts +7 -0
- package/lib/server/forge/auth/roles.d.ts.map +1 -0
- package/lib/server/forge/auth/roles.js +15 -0
- package/lib/server/forge/auth/roles.js.map +1 -0
- package/lib/server/forge/auth/sessions.d.ts +27 -0
- package/lib/server/forge/auth/sessions.d.ts.map +1 -0
- package/lib/server/forge/auth/sessions.js +37 -0
- package/lib/server/forge/auth/sessions.js.map +1 -0
- package/lib/server/forge/auth/testHarness.d.ts +104 -0
- package/lib/server/forge/auth/testHarness.d.ts.map +1 -0
- package/lib/server/forge/auth/testHarness.js +53 -0
- package/lib/server/forge/auth/testHarness.js.map +1 -0
- package/lib/server/forge/auth/tokenHash.d.ts +3 -0
- package/lib/server/forge/auth/tokenHash.d.ts.map +1 -0
- package/lib/server/forge/auth/tokenHash.js +4 -0
- package/lib/server/forge/auth/tokenHash.js.map +1 -0
- package/lib/server/forge/auth/users.d.ts +51 -0
- package/lib/server/forge/auth/users.d.ts.map +1 -0
- package/lib/server/forge/auth/users.js +118 -0
- package/lib/server/forge/auth/users.js.map +1 -0
- package/lib/server/forge/core/ForgeAgent.d.ts +43 -1
- package/lib/server/forge/core/ForgeAgent.d.ts.map +1 -1
- package/lib/server/forge/core/ForgeAgent.js +209 -61
- package/lib/server/forge/core/ForgeAgent.js.map +1 -1
- package/lib/server/forge/core/agentClient.d.ts +7 -23
- package/lib/server/forge/core/agentClient.d.ts.map +1 -1
- package/lib/server/forge/core/agentClient.js +10 -25
- package/lib/server/forge/core/agentClient.js.map +1 -1
- package/lib/server/forge/core/claudeClient.d.ts +24 -1
- package/lib/server/forge/core/claudeClient.d.ts.map +1 -1
- package/lib/server/forge/core/claudeClient.js +25 -7
- package/lib/server/forge/core/claudeClient.js.map +1 -1
- package/lib/server/forge/core/configStore.d.ts +11 -1
- package/lib/server/forge/core/configStore.d.ts.map +1 -1
- package/lib/server/forge/core/configStore.js +33 -2
- package/lib/server/forge/core/configStore.js.map +1 -1
- package/lib/server/forge/core/dependencyPaths.d.ts +19 -0
- package/lib/server/forge/core/dependencyPaths.d.ts.map +1 -0
- package/lib/server/forge/core/dependencyPaths.js +32 -0
- package/lib/server/forge/core/dependencyPaths.js.map +1 -0
- package/lib/server/forge/core/evidence.d.ts +43 -0
- package/lib/server/forge/core/evidence.d.ts.map +1 -0
- package/lib/server/forge/core/evidence.js +108 -0
- package/lib/server/forge/core/evidence.js.map +1 -0
- package/lib/server/forge/core/jobs.d.ts +2 -0
- package/lib/server/forge/core/jobs.d.ts.map +1 -1
- package/lib/server/forge/core/jobs.js +8 -0
- package/lib/server/forge/core/jobs.js.map +1 -1
- package/lib/server/forge/core/localRepo.d.ts +31 -10
- package/lib/server/forge/core/localRepo.d.ts.map +1 -1
- package/lib/server/forge/core/localRepo.js +48 -82
- package/lib/server/forge/core/localRepo.js.map +1 -1
- package/lib/server/forge/core/prompts.d.ts +3 -1
- package/lib/server/forge/core/prompts.d.ts.map +1 -1
- package/lib/server/forge/core/prompts.js +14 -5
- package/lib/server/forge/core/prompts.js.map +1 -1
- package/lib/server/forge/core/providers/azuredevops.d.ts +4 -2
- package/lib/server/forge/core/providers/azuredevops.d.ts.map +1 -1
- package/lib/server/forge/core/providers/azuredevops.js +12 -2
- package/lib/server/forge/core/providers/azuredevops.js.map +1 -1
- package/lib/server/forge/core/providers/base.d.ts +29 -4
- package/lib/server/forge/core/providers/base.d.ts.map +1 -1
- package/lib/server/forge/core/providers/base.js +21 -3
- package/lib/server/forge/core/providers/base.js.map +1 -1
- package/lib/server/forge/core/providers/github.d.ts +7 -7
- package/lib/server/forge/core/providers/github.d.ts.map +1 -1
- package/lib/server/forge/core/providers/github.js +13 -4
- package/lib/server/forge/core/providers/github.js.map +1 -1
- package/lib/server/forge/core/providers/gitlab.d.ts +20 -3
- package/lib/server/forge/core/providers/gitlab.d.ts.map +1 -1
- package/lib/server/forge/core/providers/gitlab.js +65 -5
- package/lib/server/forge/core/providers/gitlab.js.map +1 -1
- package/lib/server/forge/core/providers/types.d.ts +20 -0
- package/lib/server/forge/core/providers/types.d.ts.map +1 -1
- package/lib/server/forge/core/secretBox.d.ts +17 -0
- package/lib/server/forge/core/secretBox.d.ts.map +1 -0
- package/lib/server/forge/core/secretBox.js +74 -0
- package/lib/server/forge/core/secretBox.js.map +1 -0
- package/lib/server/forge/core/types.d.ts +7 -1
- package/lib/server/forge/core/types.d.ts.map +1 -1
- package/lib/server/forge/core/validator/clone.d.ts +5 -4
- package/lib/server/forge/core/validator/clone.d.ts.map +1 -1
- package/lib/server/forge/core/validator/clone.js +6 -86
- package/lib/server/forge/core/validator/clone.js.map +1 -1
- package/lib/server/forge/core/validator/cmd.d.ts.map +1 -1
- package/lib/server/forge/core/validator/cmd.js +4 -1
- package/lib/server/forge/core/validator/cmd.js.map +1 -1
- package/lib/server/forge/core/validator/projectType.d.ts.map +1 -1
- package/lib/server/forge/core/validator/projectType.js +2 -1
- package/lib/server/forge/core/validator/projectType.js.map +1 -1
- package/lib/server/forge/core/validator/runners/node.d.ts.map +1 -1
- package/lib/server/forge/core/validator/runners/node.js +6 -2
- package/lib/server/forge/core/validator/runners/node.js.map +1 -1
- package/lib/server/forge/core/validator.d.ts.map +1 -1
- package/lib/server/forge/core/validator.js +28 -22
- package/lib/server/forge/core/validator.js.map +1 -1
- package/lib/server/forge/core/workspaces.d.ts +70 -0
- package/lib/server/forge/core/workspaces.d.ts.map +1 -0
- package/lib/server/forge/core/workspaces.js +348 -0
- package/lib/server/forge/core/workspaces.js.map +1 -0
- package/lib/server/forge/db/database.d.ts +22 -0
- package/lib/server/forge/db/database.d.ts.map +1 -0
- package/lib/server/forge/db/database.js +71 -0
- package/lib/server/forge/db/database.js.map +1 -0
- package/lib/server/forge/db/migrations.d.ts +11 -0
- package/lib/server/forge/db/migrations.d.ts.map +1 -0
- package/lib/server/forge/db/migrations.js +87 -0
- package/lib/server/forge/db/migrations.js.map +1 -0
- package/lib/server/forge/routes.d.ts.map +1 -1
- package/lib/server/forge/routes.js +413 -163
- package/lib/server/forge/routes.js.map +1 -1
- package/lib/server/forge/sse.d.ts +10 -0
- package/lib/server/forge/sse.d.ts.map +1 -0
- package/lib/server/forge/sse.js +22 -0
- package/lib/server/forge/sse.js.map +1 -0
- package/lib/server/spa.d.ts +12 -0
- package/lib/server/spa.d.ts.map +1 -0
- package/lib/server/spa.js +23 -0
- package/lib/server/spa.js.map +1 -0
- package/lib/server.js +9 -24
- package/lib/server.js.map +1 -1
- package/lib/shared/events.d.ts +3 -0
- package/lib/shared/events.d.ts.map +1 -1
- package/lib/shared/events.js +3 -0
- package/lib/shared/events.js.map +1 -1
- package/lib/shared/packageRoot.d.ts +11 -0
- package/lib/shared/packageRoot.d.ts.map +1 -0
- package/lib/shared/packageRoot.js +23 -0
- package/lib/shared/packageRoot.js.map +1 -0
- package/lib/shared/project.d.ts +3 -0
- package/lib/shared/project.d.ts.map +1 -0
- package/lib/shared/project.js +3 -0
- package/lib/shared/project.js.map +1 -0
- package/package.json +23 -10
- package/lib/bin/cli/improve.d.ts +0 -2
- package/lib/bin/cli/improve.d.ts.map +0 -1
- package/lib/bin/cli/improve.js +0 -189
- package/lib/bin/cli/improve.js.map +0 -1
- package/lib/bin/cli/stats.d.ts +0 -2
- package/lib/bin/cli/stats.d.ts.map +0 -1
- package/lib/bin/cli/stats.js +0 -78
- package/lib/bin/cli/stats.js.map +0 -1
- package/lib/server/forge/core/k8s.d.ts +0 -18
- package/lib/server/forge/core/k8s.d.ts.map +0 -1
- package/lib/server/forge/core/k8s.js +0 -49
- package/lib/server/forge/core/k8s.js.map +0 -1
- package/lib/server/forge/core/openclawClient.d.ts +0 -61
- package/lib/server/forge/core/openclawClient.d.ts.map +0 -1
- package/lib/server/forge/core/openclawClient.js +0 -111
- package/lib/server/forge/core/openclawClient.js.map +0 -1
- package/lib/server/forge/core/quotaClient.d.ts +0 -48
- package/lib/server/forge/core/quotaClient.d.ts.map +0 -1
- package/lib/server/forge/core/quotaClient.js +0 -88
- package/lib/server/forge/core/quotaClient.js.map +0 -1
- package/lib/server/forge/core/selfImprove.d.ts +0 -43
- package/lib/server/forge/core/selfImprove.d.ts.map +0 -1
- package/lib/server/forge/core/selfImprove.js +0 -372
- package/lib/server/forge/core/selfImprove.js.map +0 -1
- package/lib/server/forge/core/telemetry.d.ts +0 -100
- package/lib/server/forge/core/telemetry.d.ts.map +0 -1
- package/lib/server/forge/core/telemetry.js +0 -273
- package/lib/server/forge/core/telemetry.js.map +0 -1
- package/scripts/fix-shebang.cjs +0 -11
- package/scripts/inject-readme.cjs +0 -23
- package/scripts/local-rollout.sh +0 -134
|
@@ -16,40 +16,21 @@
|
|
|
16
16
|
* in the query string, or as a JSON array in a POST body.
|
|
17
17
|
*/
|
|
18
18
|
import { Router } from "express";
|
|
19
|
+
import { openEventStream } from "./sse.js";
|
|
19
20
|
import { parseCiTrace } from "./core/ciTraceParser.js";
|
|
20
21
|
import * as configStore from "./core/configStore.js";
|
|
21
|
-
import { listDeployments } from "./core/k8s.js";
|
|
22
22
|
import { ForgeAgent } from "./core/ForgeAgent.js";
|
|
23
|
-
import crypto from "node:crypto";
|
|
24
|
-
import { probeQuota } from "./core/quotaClient.js";
|
|
25
23
|
import { validate as runValidation } from "./core/validator.js";
|
|
26
24
|
import { createProvider } from "./core/providers/index.js";
|
|
27
25
|
import * as jobStore from "./core/jobs.js";
|
|
26
|
+
import { workspaces } from "./core/localRepo.js";
|
|
27
|
+
import { WorkspaceBusyError } from "./core/workspaces.js";
|
|
28
28
|
import { FORGE_EVENTS } from "../../shared/events.js";
|
|
29
|
+
import { requirePrincipal, requireRole } from "./auth/middleware.js";
|
|
29
30
|
const router = Router();
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (!token) {
|
|
34
|
-
res.status(503).json({
|
|
35
|
-
error: "Forge is not configured. Set FORGE_TOKEN env var.",
|
|
36
|
-
});
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
const auth = req.headers.authorization
|
|
40
|
-
?? (req.query.token ? `Bearer ${req.query.token}` : null);
|
|
41
|
-
if (!auth?.startsWith("Bearer ")) {
|
|
42
|
-
res.status(401).json({ error: "Missing Authorization header." });
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const provided = Buffer.from(auth.slice(7));
|
|
46
|
-
const expected = Buffer.from(token);
|
|
47
|
-
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
48
|
-
res.status(403).json({ error: "Invalid token." });
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
next();
|
|
52
|
-
}
|
|
31
|
+
// Authentication happens upstream (server.ts mounts createAuth's stack);
|
|
32
|
+
// this router only enforces that it ran, plus per-route minimum roles.
|
|
33
|
+
router.use(requirePrincipal);
|
|
53
34
|
// ─── Error handler ────────────────────────────────────────────────────────────
|
|
54
35
|
function redactSecrets(text) {
|
|
55
36
|
return text
|
|
@@ -121,13 +102,17 @@ const FALLBACK_MODELS = [
|
|
|
121
102
|
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "claude" },
|
|
122
103
|
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", provider: "claude" },
|
|
123
104
|
];
|
|
105
|
+
// A workspace key from Settings wins; ANTHROPIC_API_KEY covers self-hosted installs.
|
|
106
|
+
function modelListKey() {
|
|
107
|
+
return configStore.getAnthropicKey() || process.env.ANTHROPIC_API_KEY || "";
|
|
108
|
+
}
|
|
124
109
|
async function fetchAnthropicModels() {
|
|
125
110
|
const controller = new AbortController();
|
|
126
111
|
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
127
112
|
try {
|
|
128
113
|
const res = await fetch("https://api.anthropic.com/v1/models?limit=100", {
|
|
129
114
|
headers: {
|
|
130
|
-
"x-api-key":
|
|
115
|
+
"x-api-key": modelListKey(),
|
|
131
116
|
"anthropic-version": "2023-06-01",
|
|
132
117
|
},
|
|
133
118
|
signal: controller.signal,
|
|
@@ -148,13 +133,13 @@ async function fetchAnthropicModels() {
|
|
|
148
133
|
clearTimeout(timeout);
|
|
149
134
|
}
|
|
150
135
|
}
|
|
151
|
-
router.get("/status",
|
|
136
|
+
router.get("/status", async (_req, res) => {
|
|
152
137
|
let models = FALLBACK_MODELS.map((m) => ({
|
|
153
138
|
...m,
|
|
154
139
|
...(MODEL_RATES[m.id] ? { rates: MODEL_RATES[m.id] } : {}),
|
|
155
140
|
}));
|
|
156
141
|
try {
|
|
157
|
-
if (
|
|
142
|
+
if (modelListKey()) {
|
|
158
143
|
models = await fetchAnthropicModels();
|
|
159
144
|
}
|
|
160
145
|
}
|
|
@@ -163,14 +148,7 @@ router.get("/status", requireAuth, async (_req, res) => {
|
|
|
163
148
|
console.warn("[forge] fetchAnthropicModels failed, using fallback:", err?.message);
|
|
164
149
|
}
|
|
165
150
|
const repos = getAvailableRepos();
|
|
166
|
-
const agents = [
|
|
167
|
-
{ id: "claude", label: "Claude", configured: true },
|
|
168
|
-
{
|
|
169
|
-
id: "openclaw",
|
|
170
|
-
label: "OpenClaw",
|
|
171
|
-
configured: Boolean(process.env.OPENCLAW_GATEWAY_URL),
|
|
172
|
-
},
|
|
173
|
-
];
|
|
151
|
+
const agents = [{ id: "claude", label: "Claude", configured: true }];
|
|
174
152
|
res.json({
|
|
175
153
|
ok: true,
|
|
176
154
|
project: configStore.getPrimaryProject(),
|
|
@@ -180,8 +158,8 @@ router.get("/status", requireAuth, async (_req, res) => {
|
|
|
180
158
|
defaultModel: process.env.FORGE_MODEL ?? "claude-sonnet-4-6",
|
|
181
159
|
models,
|
|
182
160
|
agents,
|
|
183
|
-
defaultAgent:
|
|
184
|
-
hasApiKey: Boolean(
|
|
161
|
+
defaultAgent: "claude",
|
|
162
|
+
hasApiKey: Boolean(modelListKey()),
|
|
185
163
|
// Test-deploy URL template stored in configStore.defaults — editable
|
|
186
164
|
// from Settings → Defaults. Supports "{branch}" / "{slug}" placeholders
|
|
187
165
|
// for ephemeral per-branch deploys. Empty string means "no link".
|
|
@@ -189,12 +167,11 @@ router.get("/status", requireAuth, async (_req, res) => {
|
|
|
189
167
|
maxCiFixAttempts: configStore.get().defaults?.maxCiFixAttempts ?? 3,
|
|
190
168
|
});
|
|
191
169
|
});
|
|
192
|
-
router.use(requireAuth);
|
|
193
170
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
194
171
|
router.get("/config", (_req, res) => {
|
|
195
172
|
res.json(configStore.getPublic());
|
|
196
173
|
});
|
|
197
|
-
router.put("/config", async (req, res) => {
|
|
174
|
+
router.put("/config", requireRole("admin"), async (req, res) => {
|
|
198
175
|
try {
|
|
199
176
|
await configStore.patch(req.body ?? {});
|
|
200
177
|
res.json(configStore.getPublic());
|
|
@@ -203,6 +180,52 @@ router.put("/config", async (req, res) => {
|
|
|
203
180
|
handleError(res, err, "updating config");
|
|
204
181
|
}
|
|
205
182
|
});
|
|
183
|
+
// ─── Workspaces ───────────────────────────────────────────────────────────────
|
|
184
|
+
// Local checkouts (base clones + per-issue worktrees) under the cache root.
|
|
185
|
+
// Admin-only: they expose this machine's disk and deleting one is permanent.
|
|
186
|
+
function workspaceError(res, err, operation) {
|
|
187
|
+
if (err instanceof WorkspaceBusyError) {
|
|
188
|
+
res.status(409).json({ error: err.message });
|
|
189
|
+
}
|
|
190
|
+
else if (err instanceof Error && /^Unknown workspace/.test(err.message)) {
|
|
191
|
+
res.status(404).json({ error: err.message });
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
handleError(res, err, operation);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
router.get("/workspaces", requireRole("admin"), async (_req, res) => {
|
|
198
|
+
try {
|
|
199
|
+
const list = await workspaces.list();
|
|
200
|
+
// Ids are root-relative; the absolute path stays on the server.
|
|
201
|
+
res.json({ workspaces: list.map(({ path: _path, ...rest }) => rest) });
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
workspaceError(res, err, "listing workspaces");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
router.delete("/workspaces", requireRole("admin"), async (req, res) => {
|
|
208
|
+
const id = typeof req.query.id === "string" ? req.query.id : "";
|
|
209
|
+
if (!id) {
|
|
210
|
+
res.status(400).json({ error: "Workspace id is required." });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await workspaces.remove(id);
|
|
215
|
+
res.json({ removed: id });
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
workspaceError(res, err, "deleting workspace");
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
router.post("/workspaces/purge", requireRole("admin"), async (req, res) => {
|
|
222
|
+
try {
|
|
223
|
+
res.json(await workspaces.purge({ includeBases: req.body?.includeBases === true }));
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
workspaceError(res, err, "purging workspaces");
|
|
227
|
+
}
|
|
228
|
+
});
|
|
206
229
|
// ─── Issues ───────────────────────────────────────────────────────────────────
|
|
207
230
|
router.get("/issues", async (req, res) => {
|
|
208
231
|
try {
|
|
@@ -279,69 +302,187 @@ router.get("/issues", async (req, res) => {
|
|
|
279
302
|
handleError(res, error, "listOpenIssues");
|
|
280
303
|
}
|
|
281
304
|
});
|
|
282
|
-
|
|
305
|
+
const MAX_LABELS = 50;
|
|
306
|
+
const MAX_LABEL_LENGTH = 255;
|
|
307
|
+
/**
|
|
308
|
+
* Validates an optional `labels` body field: `labels` is undefined when the
|
|
309
|
+
* field was absent, the trimmed, de-duplicated list otherwise. Commas are
|
|
310
|
+
* refused because GitLab sends labels as one comma-separated string.
|
|
311
|
+
*/
|
|
312
|
+
function parseLabels(raw) {
|
|
313
|
+
if (raw === undefined)
|
|
314
|
+
return {};
|
|
315
|
+
if (!Array.isArray(raw) || raw.length > MAX_LABELS)
|
|
316
|
+
return { error: `labels must be an array of at most ${MAX_LABELS} strings.` };
|
|
317
|
+
const labels = [];
|
|
318
|
+
for (const item of raw) {
|
|
319
|
+
if (typeof item !== "string")
|
|
320
|
+
return { error: "labels must be strings." };
|
|
321
|
+
const label = item.trim();
|
|
322
|
+
if (!label)
|
|
323
|
+
continue;
|
|
324
|
+
if (label.length > MAX_LABEL_LENGTH || label.includes(","))
|
|
325
|
+
return { error: `Invalid label: ${label.slice(0, 40)}` };
|
|
326
|
+
if (!labels.includes(label))
|
|
327
|
+
labels.push(label);
|
|
328
|
+
}
|
|
329
|
+
return { labels };
|
|
330
|
+
}
|
|
331
|
+
router.post("/issues", requireRole("operator"), async (req, res) => {
|
|
283
332
|
const { title, description = "" } = req.body;
|
|
284
333
|
if (!title?.trim()) {
|
|
285
334
|
res.status(400).json({ error: "title is required." });
|
|
286
335
|
return;
|
|
287
336
|
}
|
|
337
|
+
const { labels, error: labelsError } = parseLabels(req.body.labels);
|
|
338
|
+
if (labelsError) {
|
|
339
|
+
res.status(400).json({ error: labelsError });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
288
342
|
try {
|
|
289
|
-
const issue = await createProvider(parseRepo(req)).createIssue(title.trim(), description.trim());
|
|
343
|
+
const issue = await createProvider(parseRepo(req)).createIssue(title.trim(), description.trim(), labels);
|
|
290
344
|
res.json({ issue });
|
|
291
|
-
|
|
345
|
+
broadcastFor(req, { type: FORGE_EVENTS.ISSUE_CREATED, iid: issue.iid });
|
|
292
346
|
}
|
|
293
347
|
catch (error) {
|
|
294
348
|
handleError(res, error, "createIssue");
|
|
295
349
|
}
|
|
296
350
|
});
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
351
|
+
/** Partial update: only the fields present in the body change. */
|
|
352
|
+
router.put("/issues/:iid", requireRole("operator"), async (req, res) => {
|
|
353
|
+
const { title, description } = req.body;
|
|
354
|
+
if (title !== undefined && !(typeof title === "string" && title.trim())) {
|
|
355
|
+
res.status(400).json({ error: "title can't be blank." });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (description !== undefined && typeof description !== "string") {
|
|
359
|
+
res.status(400).json({ error: "description must be a string." });
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const { labels, error: labelsError } = parseLabels(req.body.labels);
|
|
363
|
+
if (labelsError) {
|
|
364
|
+
res.status(400).json({ error: labelsError });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const changes = {};
|
|
368
|
+
if (title !== undefined)
|
|
369
|
+
changes.title = title.trim();
|
|
370
|
+
if (description !== undefined)
|
|
371
|
+
changes.description = description.trim();
|
|
372
|
+
if (labels !== undefined)
|
|
373
|
+
changes.labels = labels;
|
|
374
|
+
if (Object.keys(changes).length === 0) {
|
|
375
|
+
res.status(400).json({ error: "Nothing to update." });
|
|
301
376
|
return;
|
|
302
377
|
}
|
|
303
378
|
try {
|
|
304
|
-
const
|
|
379
|
+
const iid = Number(req.params.iid);
|
|
380
|
+
const issue = await createProvider(parseRepo(req)).updateIssue(iid, changes);
|
|
305
381
|
res.json({ issue });
|
|
382
|
+
broadcastFor(req, { type: FORGE_EVENTS.ISSUE_UPDATED, iid });
|
|
306
383
|
}
|
|
307
384
|
catch (error) {
|
|
308
385
|
handleError(res, error, "updateIssue");
|
|
309
386
|
}
|
|
310
387
|
});
|
|
311
|
-
|
|
388
|
+
/** What closing or deleting an issue would tear down. Lookups are best-effort. */
|
|
389
|
+
async function issueFootprint(provider, iid) {
|
|
390
|
+
const [openMrs, prefixed] = await Promise.all([
|
|
391
|
+
provider.listForgeMRsForIssue(iid).catch(() => []),
|
|
392
|
+
provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []),
|
|
393
|
+
]);
|
|
394
|
+
const environments = (await Promise.all(teardownBranches(prefixed, iid).map((b) => provider.listEnvironmentsForBranch(b).catch(() => [])))).flat();
|
|
395
|
+
return { openMrs, branches: prefixed, environments };
|
|
396
|
+
}
|
|
397
|
+
/** Branches to tear down: the suffixed ones plus the legacy `forge/issue-:iid`, which may not exist. */
|
|
398
|
+
function teardownBranches(branches, iid) {
|
|
399
|
+
return [...branches, `forge/issue-${iid}`];
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Shared teardown before an issue is closed or deleted. Refuses (409) while a
|
|
403
|
+
* Forge MR is open — the user should merge or close it first; the frontend
|
|
404
|
+
* surfaces the message verbatim. Otherwise stops the issue's ephemeral
|
|
405
|
+
* environments and deletes its forge branches. Returns the stopped-environment
|
|
406
|
+
* count, or null when it already answered.
|
|
407
|
+
*/
|
|
408
|
+
async function retireIssueWork(res, provider, iid, verb) {
|
|
409
|
+
const { openMrs, branches, environments } = await issueFootprint(provider, iid);
|
|
410
|
+
if (openMrs.length > 0) {
|
|
411
|
+
const mr = openMrs[0];
|
|
412
|
+
res.status(409).json({
|
|
413
|
+
error: `Cannot ${verb}: active MR !${mr.iid} is still open. Merge or close it first.`,
|
|
414
|
+
openMr: { iid: mr.iid, web_url: mr.web_url, title: mr.title },
|
|
415
|
+
});
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
// Stop environments BEFORE deleting branches — on most providers deleting
|
|
419
|
+
// the branch orphans the environment rather than tearing it down. Each
|
|
420
|
+
// stop is best-effort (already-stopped envs 4xx).
|
|
421
|
+
await Promise.all(environments.map((e) => provider.stopEnvironment(e.id).catch(() => { })));
|
|
422
|
+
// Now delete the branches. Also best-effort.
|
|
423
|
+
await Promise.all(teardownBranches(branches, iid).map((b) => provider.deleteBranch(b).catch(() => { })));
|
|
424
|
+
return environments.length;
|
|
425
|
+
}
|
|
426
|
+
// Preview of what closing or deleting the issue would do, so the confirm
|
|
427
|
+
// dialog can say it (and name a blocking MR) before anything happens.
|
|
428
|
+
router.get("/issues/:iid/footprint", async (req, res) => {
|
|
429
|
+
const iid = Number(req.params.iid);
|
|
430
|
+
if (!Number.isInteger(iid) || iid <= 0) {
|
|
431
|
+
res.status(400).json({ error: "Invalid issue id." });
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const { openMrs, branches, environments } = await issueFootprint(createProvider(parseRepo(req)), iid);
|
|
436
|
+
const mr = openMrs[0];
|
|
437
|
+
res.json({
|
|
438
|
+
openMr: mr ? { iid: mr.iid, web_url: mr.web_url, title: mr.title } : null,
|
|
439
|
+
branches: branches.length,
|
|
440
|
+
environments: environments.length,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
handleError(res, error, "getIssueFootprint");
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
router.post("/issues/:iid/close", requireRole("operator"), async (req, res) => {
|
|
312
448
|
try {
|
|
313
449
|
const iid = Number(req.params.iid);
|
|
314
450
|
const provider = createProvider(parseRepo(req));
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const openMrs = await provider.listForgeMRsForIssue(iid).catch(() => []);
|
|
318
|
-
if (openMrs.length > 0) {
|
|
319
|
-
const mr = openMrs[0];
|
|
320
|
-
res.status(409).json({
|
|
321
|
-
error: `Cannot close: active MR !${mr.iid} is still open. Merge or close it first.`,
|
|
322
|
-
openMr: { iid: mr.iid, web_url: mr.web_url, title: mr.title },
|
|
323
|
-
});
|
|
451
|
+
const stoppedEnvironments = await retireIssueWork(res, provider, iid, "close");
|
|
452
|
+
if (stoppedEnvironments === null)
|
|
324
453
|
return;
|
|
325
|
-
}
|
|
326
|
-
// Stop any ephemeral environments attached to forge branches for this
|
|
327
|
-
// issue BEFORE deleting branches — on most providers deleting the
|
|
328
|
-
// branch orphans the environment rather than tearing it down. Each
|
|
329
|
-
// stop is best-effort (already-stopped envs 4xx).
|
|
330
|
-
const branches = await provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []);
|
|
331
|
-
const baseBranch = `forge/issue-${iid}`;
|
|
332
|
-
const all = [...branches, baseBranch];
|
|
333
|
-
const envs = (await Promise.all(all.map((b) => provider.listEnvironmentsForBranch(b).catch(() => [])))).flat();
|
|
334
|
-
await Promise.all(envs.map((e) => provider.stopEnvironment(e.id).catch(() => { })));
|
|
335
|
-
// Now delete the branches. Also best-effort.
|
|
336
|
-
await Promise.all(all.map((b) => provider.deleteBranch(b).catch(() => { })));
|
|
337
454
|
const issue = await provider.closeIssue(iid);
|
|
338
|
-
res.json({ issue, stoppedEnvironments
|
|
339
|
-
|
|
455
|
+
res.json({ issue, stoppedEnvironments });
|
|
456
|
+
broadcastFor(req, { type: FORGE_EVENTS.ISSUE_CLOSED, iid });
|
|
340
457
|
}
|
|
341
458
|
catch (error) {
|
|
342
459
|
handleError(res, error, "closeIssue");
|
|
343
460
|
}
|
|
344
461
|
});
|
|
462
|
+
// Permanent and irreversible on GitLab, so it needs approver like merging.
|
|
463
|
+
router.delete("/issues/:iid", requireRole("approver"), async (req, res) => {
|
|
464
|
+
try {
|
|
465
|
+
const iid = Number(req.params.iid);
|
|
466
|
+
const provider = createProvider(parseRepo(req));
|
|
467
|
+
const stoppedEnvironments = await retireIssueWork(res, provider, iid, "delete");
|
|
468
|
+
if (stoppedEnvironments === null)
|
|
469
|
+
return;
|
|
470
|
+
await provider.deleteIssue(iid);
|
|
471
|
+
res.json({ deleted: true, iid, stoppedEnvironments });
|
|
472
|
+
broadcastFor(req, { type: FORGE_EVENTS.ISSUE_DELETED, iid });
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
handleError(res, error, "deleteIssue");
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
router.get("/labels", async (req, res) => {
|
|
479
|
+
try {
|
|
480
|
+
res.json({ labels: await createProvider(parseRepo(req)).listLabels() });
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
handleError(res, error, "listLabels");
|
|
484
|
+
}
|
|
485
|
+
});
|
|
345
486
|
router.get("/issues/:iid", async (req, res) => {
|
|
346
487
|
try {
|
|
347
488
|
const issue = await createProvider(parseRepo(req)).getIssue(Number(req.params.iid));
|
|
@@ -453,7 +594,7 @@ router.get("/mr/:mrIid/detail", async (req, res) => {
|
|
|
453
594
|
handleError(res, error, "getMR");
|
|
454
595
|
}
|
|
455
596
|
});
|
|
456
|
-
router.post("/mr/:mrIid/rebase", async (req, res) => {
|
|
597
|
+
router.post("/mr/:mrIid/rebase", requireRole("operator"), async (req, res) => {
|
|
457
598
|
try {
|
|
458
599
|
const provider = createProvider(parseRepo(req));
|
|
459
600
|
if (!provider.supportsRebase) {
|
|
@@ -476,7 +617,7 @@ router.get("/mr/:mrIid/diffs", async (req, res) => {
|
|
|
476
617
|
handleError(res, error, "getMRDiffs");
|
|
477
618
|
}
|
|
478
619
|
});
|
|
479
|
-
router.post("/mr/:mrIid/auto-merge", async (req, res) => {
|
|
620
|
+
router.post("/mr/:mrIid/auto-merge", requireRole("approver"), async (req, res) => {
|
|
480
621
|
try {
|
|
481
622
|
const provider = createProvider(parseRepo(req));
|
|
482
623
|
await provider.setAutoMergeMR(Number(req.params.mrIid));
|
|
@@ -486,7 +627,7 @@ router.post("/mr/:mrIid/auto-merge", async (req, res) => {
|
|
|
486
627
|
handleError(res, error, "setAutoMergeMR");
|
|
487
628
|
}
|
|
488
629
|
});
|
|
489
|
-
router.post("/mr/:mrIid/cancel-auto-merge", async (req, res) => {
|
|
630
|
+
router.post("/mr/:mrIid/cancel-auto-merge", requireRole("approver"), async (req, res) => {
|
|
490
631
|
try {
|
|
491
632
|
const provider = createProvider(parseRepo(req));
|
|
492
633
|
await provider.cancelAutoMergeMR(Number(req.params.mrIid));
|
|
@@ -496,29 +637,29 @@ router.post("/mr/:mrIid/cancel-auto-merge", async (req, res) => {
|
|
|
496
637
|
handleError(res, error, "cancelAutoMergeMR");
|
|
497
638
|
}
|
|
498
639
|
});
|
|
499
|
-
router.post("/mr/:mrIid/merge", async (req, res) => {
|
|
640
|
+
router.post("/mr/:mrIid/merge", requireRole("approver"), async (req, res) => {
|
|
500
641
|
try {
|
|
501
642
|
const provider = createProvider(parseRepo(req));
|
|
502
643
|
await provider.mergeMR(Number(req.params.mrIid));
|
|
503
644
|
res.json({ ok: true });
|
|
504
|
-
|
|
645
|
+
broadcastFor(req, { type: FORGE_EVENTS.MR_MERGED, mrIid: Number(req.params.mrIid) });
|
|
505
646
|
}
|
|
506
647
|
catch (error) {
|
|
507
648
|
handleError(res, error, "mergeMR");
|
|
508
649
|
}
|
|
509
650
|
});
|
|
510
|
-
router.post("/mr/:mrIid/close", async (req, res) => {
|
|
651
|
+
router.post("/mr/:mrIid/close", requireRole("approver"), async (req, res) => {
|
|
511
652
|
try {
|
|
512
653
|
const provider = createProvider(parseRepo(req));
|
|
513
654
|
await provider.closeMR(Number(req.params.mrIid));
|
|
514
655
|
res.json({ ok: true });
|
|
515
|
-
|
|
656
|
+
broadcastFor(req, { type: FORGE_EVENTS.MR_CLOSED, mrIid: Number(req.params.mrIid) });
|
|
516
657
|
}
|
|
517
658
|
catch (error) {
|
|
518
659
|
handleError(res, error, "closeMR");
|
|
519
660
|
}
|
|
520
661
|
});
|
|
521
|
-
router.post("/mr/:mrIid/reopen", async (req, res) => {
|
|
662
|
+
router.post("/mr/:mrIid/reopen", requireRole("approver"), async (req, res) => {
|
|
522
663
|
try {
|
|
523
664
|
const provider = createProvider(parseRepo(req));
|
|
524
665
|
await provider.reopenMR(Number(req.params.mrIid));
|
|
@@ -528,7 +669,7 @@ router.post("/mr/:mrIid/reopen", async (req, res) => {
|
|
|
528
669
|
handleError(res, error, "reopenMR");
|
|
529
670
|
}
|
|
530
671
|
});
|
|
531
|
-
router.post("/mr/:mrIid/draft", async (req, res) => {
|
|
672
|
+
router.post("/mr/:mrIid/draft", requireRole("operator"), async (req, res) => {
|
|
532
673
|
try {
|
|
533
674
|
const draft = Boolean(req.body?.draft);
|
|
534
675
|
const provider = createProvider(parseRepo(req));
|
|
@@ -543,7 +684,7 @@ router.post("/mr/:mrIid/draft", async (req, res) => {
|
|
|
543
684
|
// The job is kicked off synchronously and runs to completion regardless of
|
|
544
685
|
// whether a client is listening. The client opens /jobs/:jobId/stream to
|
|
545
686
|
// attach (or reattach after a reload).
|
|
546
|
-
router.post("/issues/:iid/analyze", (req, res) => {
|
|
687
|
+
router.post("/issues/:iid/analyze", requireRole("operator"), (req, res) => {
|
|
547
688
|
const { iid } = req.params;
|
|
548
689
|
const primary = parseRepo(req);
|
|
549
690
|
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
@@ -556,13 +697,83 @@ router.post("/issues/:iid/analyze", (req, res) => {
|
|
|
556
697
|
? req.body.kind
|
|
557
698
|
: "fresh";
|
|
558
699
|
const previousChanges = Array.isArray(req.body.previousChanges) ? req.body.previousChanges : null;
|
|
559
|
-
const
|
|
560
|
-
|
|
700
|
+
const analyses = res.locals.analyses;
|
|
701
|
+
const repoKey = analysisRepoKey(req);
|
|
702
|
+
const existingMrIid = Number.isInteger(req.body.existingMrIid) ? req.body.existingMrIid : undefined;
|
|
703
|
+
const jobId = jobStore.createJob("analyze", async (send, signal) => {
|
|
704
|
+
let result = null;
|
|
705
|
+
const capture = (event) => {
|
|
706
|
+
if (event.type === "result")
|
|
707
|
+
result = event.data;
|
|
708
|
+
send(event);
|
|
709
|
+
};
|
|
710
|
+
const out = await agent.analyzeIssue(Number(iid), capture, req.body.model || undefined, undefined, req.body.prompt || undefined, kind, { signal, agentMode: req.body.agentMode }, previousChanges);
|
|
711
|
+
// Saved before `done`, so the result outlives this job's stream and buffer:
|
|
712
|
+
// any browser, any time later, gets it from GET /issues/:iid/analyses.
|
|
713
|
+
if (result && analyses && !signal.aborted) {
|
|
714
|
+
const analysis = await analyses.create({
|
|
715
|
+
repo: repoKey,
|
|
716
|
+
issueIid: Number(iid),
|
|
717
|
+
id: jobId,
|
|
718
|
+
result,
|
|
719
|
+
model: req.body.model || "",
|
|
720
|
+
mrIid: existingMrIid,
|
|
721
|
+
prompt: req.body.prompt,
|
|
722
|
+
});
|
|
723
|
+
send({ type: "analysis_saved", analysis: { ...analysis } });
|
|
724
|
+
}
|
|
725
|
+
return out;
|
|
726
|
+
}, { meta: { iid: Number(iid) }, onComplete: () => broadcastFor(req, { type: FORGE_EVENTS.ANALYSIS_DONE, iid: Number(iid) }) });
|
|
727
|
+
broadcastFor(req, { type: FORGE_EVENTS.ANALYSIS_STARTED, iid: Number(iid), jobId });
|
|
561
728
|
res.json({ jobId });
|
|
562
729
|
});
|
|
730
|
+
// ─── Saved analyses ───────────────────────────────────────────────────────────
|
|
731
|
+
// Same repo key the browser cache uses: the selected repo, or "default".
|
|
732
|
+
function analysisRepoKey(req) {
|
|
733
|
+
const fromQuery = typeof req.query.repo === "string" ? req.query.repo : "";
|
|
734
|
+
const fromBody = typeof req.body?.repo === "string" ? req.body.repo : "";
|
|
735
|
+
return fromQuery || fromBody || "default";
|
|
736
|
+
}
|
|
737
|
+
function analysisStore(res) {
|
|
738
|
+
const store = res.locals.analyses;
|
|
739
|
+
if (!store)
|
|
740
|
+
res.status(503).json({ error: "Saved analyses aren't available." });
|
|
741
|
+
return store;
|
|
742
|
+
}
|
|
743
|
+
router.get("/issues/:iid/analyses", async (req, res) => {
|
|
744
|
+
const store = analysisStore(res);
|
|
745
|
+
if (!store)
|
|
746
|
+
return;
|
|
747
|
+
res.json({ analyses: await store.list(analysisRepoKey(req), Number(req.params.iid)) });
|
|
748
|
+
});
|
|
749
|
+
router.put("/issues/:iid/analyses/:id", requireRole("operator"), async (req, res) => {
|
|
750
|
+
const store = analysisStore(res);
|
|
751
|
+
if (!store)
|
|
752
|
+
return;
|
|
753
|
+
const entry = req.body?.analysis;
|
|
754
|
+
if (!entry || entry.id !== req.params.id || typeof entry.result !== "object" || entry.result === null) {
|
|
755
|
+
res.status(400).json({ error: "analysis with a matching id and a result is required." });
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
res.json({ analysis: await store.put(analysisRepoKey(req), Number(req.params.iid), entry) });
|
|
759
|
+
});
|
|
760
|
+
router.delete("/issues/:iid/analyses/:id", requireRole("operator"), async (req, res) => {
|
|
761
|
+
const store = analysisStore(res);
|
|
762
|
+
if (!store)
|
|
763
|
+
return;
|
|
764
|
+
await store.remove(analysisRepoKey(req), Number(req.params.iid), req.params.id);
|
|
765
|
+
res.json({ deleted: true });
|
|
766
|
+
});
|
|
767
|
+
router.delete("/issues/:iid/analyses", requireRole("operator"), async (req, res) => {
|
|
768
|
+
const store = analysisStore(res);
|
|
769
|
+
if (!store)
|
|
770
|
+
return;
|
|
771
|
+
await store.clear(analysisRepoKey(req), Number(req.params.iid));
|
|
772
|
+
res.json({ deleted: true });
|
|
773
|
+
});
|
|
563
774
|
// ─── Refine ───────────────────────────────────────────────────────────────────
|
|
564
775
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
565
|
-
router.post("/issues/:iid/refine", (req, res) => {
|
|
776
|
+
router.post("/issues/:iid/refine", requireRole("operator"), (req, res) => {
|
|
566
777
|
const { iid } = req.params;
|
|
567
778
|
const { feedback, previousChanges, ciContext, validationState, claudeSessionId } = req.body;
|
|
568
779
|
if (!feedback?.trim() && !ciContext?.trim()) {
|
|
@@ -586,10 +797,10 @@ router.post("/issues/:iid/refine", (req, res) => {
|
|
|
586
797
|
secondaryProjects: parseSecondaryRepos(req, primary),
|
|
587
798
|
agent: req.body.agent || "claude",
|
|
588
799
|
});
|
|
589
|
-
const jobId = jobStore.createJob("refine", (send, signal) => agent.refineIssue(Number(iid), previousChanges, feedback?.trim() ?? "", send, undefined, ciContext?.trim(), validationState ?? null, safeSessionId, { signal }), { onComplete: () =>
|
|
800
|
+
const jobId = jobStore.createJob("refine", (send, signal) => agent.refineIssue(Number(iid), previousChanges, feedback?.trim() ?? "", send, undefined, ciContext?.trim(), validationState ?? null, safeSessionId, { signal }), { onComplete: () => broadcastFor(req, { type: FORGE_EVENTS.REFINE_DONE, iid: Number(iid) }) });
|
|
590
801
|
res.json({ jobId });
|
|
591
802
|
});
|
|
592
|
-
router.post("/issues/:iid/ci-fix", (req, res) => {
|
|
803
|
+
router.post("/issues/:iid/ci-fix", requireRole("operator"), (req, res) => {
|
|
593
804
|
const { iid } = req.params;
|
|
594
805
|
const { ciContext, model } = req.body;
|
|
595
806
|
if (!ciContext?.trim()) {
|
|
@@ -602,7 +813,7 @@ router.post("/issues/:iid/ci-fix", (req, res) => {
|
|
|
602
813
|
secondaryProjects: parseSecondaryRepos(req, primary),
|
|
603
814
|
agent: req.body.agent || "claude",
|
|
604
815
|
});
|
|
605
|
-
const jobId = jobStore.createJob("ci-fix", (send, signal) => agent.ciFixIssue(Number(iid), ciContext.trim(), send, model, { signal }), { onComplete: () =>
|
|
816
|
+
const jobId = jobStore.createJob("ci-fix", (send, signal) => agent.ciFixIssue(Number(iid), ciContext.trim(), send, model, { signal }), { onComplete: () => broadcastFor(req, { type: FORGE_EVENTS.CI_FIX_DONE, iid: Number(iid) }) });
|
|
606
817
|
res.json({ jobId });
|
|
607
818
|
});
|
|
608
819
|
router.get("/jobs/:jobId/trace", async (req, res) => {
|
|
@@ -641,10 +852,7 @@ router.get("/jobs/:jobId/stream", (req, res) => {
|
|
|
641
852
|
res.status(404).json({ error: "Job not found." });
|
|
642
853
|
return;
|
|
643
854
|
}
|
|
644
|
-
|
|
645
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
646
|
-
res.setHeader("Connection", "keep-alive");
|
|
647
|
-
res.flushHeaders();
|
|
855
|
+
openEventStream(req, res);
|
|
648
856
|
let unsubscribe = () => { };
|
|
649
857
|
const write = (event) => {
|
|
650
858
|
if (res.writableEnded)
|
|
@@ -668,7 +876,7 @@ router.get("/jobs/:jobId/stream", (req, res) => {
|
|
|
668
876
|
res.end();
|
|
669
877
|
});
|
|
670
878
|
});
|
|
671
|
-
router.post("/jobs/:jobId/cancel", (req, res) => {
|
|
879
|
+
router.post("/jobs/:jobId/cancel", requireRole("operator"), (req, res) => {
|
|
672
880
|
const jobId = req.params.jobId;
|
|
673
881
|
if (!jobStore.getJob(jobId)) {
|
|
674
882
|
res.status(404).json({ error: "Job not found." });
|
|
@@ -682,7 +890,7 @@ router.post("/jobs/:jobId/cancel", (req, res) => {
|
|
|
682
890
|
// Pre-flight check: overlay the proposed changes on a cached clone of the
|
|
683
891
|
// target repo, run lint/typecheck/test, and report results. Nothing is pushed
|
|
684
892
|
// to GitLab — this is a sandbox check to prevent CI errors.
|
|
685
|
-
router.post("/validate", (req, res) => {
|
|
893
|
+
router.post("/validate", requireRole("operator"), (req, res) => {
|
|
686
894
|
const { analysis } = req.body;
|
|
687
895
|
if (!analysis?.changes?.length) {
|
|
688
896
|
res.status(400).json({ error: "analysis with changes is required." });
|
|
@@ -695,10 +903,17 @@ router.post("/validate", (req, res) => {
|
|
|
695
903
|
const secondaryRepos = parseSecondaryRepos(req, primary);
|
|
696
904
|
const repos = [primary, ...secondaryRepos].filter(Boolean);
|
|
697
905
|
const enrichedAnalysis = repos.length ? { ...analysis, repos } : analysis;
|
|
698
|
-
const
|
|
906
|
+
const iid = Number(analysis.issue?.iid) || null;
|
|
907
|
+
const jobId = jobStore.createJob("validate", (send) => runValidation(enrichedAnalysis, send), {
|
|
908
|
+
// No meta.iid: /issues/:iid/active-job would take it for an analysis.
|
|
909
|
+
idPrefix: "val",
|
|
910
|
+
// Lets a client that stopped watching clear its "validating" badge.
|
|
911
|
+
onComplete: () => { if (iid)
|
|
912
|
+
broadcastFor(req, { type: FORGE_EVENTS.VALIDATION_DONE, iid, jobId }); },
|
|
913
|
+
});
|
|
699
914
|
res.json({ jobId });
|
|
700
915
|
});
|
|
701
|
-
router.post("/apply", async (req, res) => {
|
|
916
|
+
router.post("/apply", requireRole("operator"), async (req, res) => {
|
|
702
917
|
const { analysis } = req.body;
|
|
703
918
|
if (!analysis?.issue || !analysis?.changes?.length) {
|
|
704
919
|
res.status(400).json({ error: "Invalid or missing analysis payload." });
|
|
@@ -710,13 +925,13 @@ router.post("/apply", async (req, res) => {
|
|
|
710
925
|
const agent = new ForgeAgent({ project: primary, secondaryProjects });
|
|
711
926
|
const result = await agent.applyFix(analysis);
|
|
712
927
|
res.json(result);
|
|
713
|
-
|
|
928
|
+
broadcastFor(req, { type: FORGE_EVENTS.FIX_APPLIED, iid: analysis.issue?.iid });
|
|
714
929
|
}
|
|
715
930
|
catch (error) {
|
|
716
931
|
handleError(res, error, "applyFix");
|
|
717
932
|
}
|
|
718
933
|
});
|
|
719
|
-
router.post("/commit-to-branch", async (req, res) => {
|
|
934
|
+
router.post("/commit-to-branch", requireRole("operator"), async (req, res) => {
|
|
720
935
|
const { branchName, analysis, repo: changeRepo } = req.body;
|
|
721
936
|
if (!branchName?.trim()) {
|
|
722
937
|
res.status(400).json({ error: "branchName is required." });
|
|
@@ -740,7 +955,7 @@ router.post("/commit-to-branch", async (req, res) => {
|
|
|
740
955
|
handleError(res, error, "commitToBranch");
|
|
741
956
|
}
|
|
742
957
|
});
|
|
743
|
-
router.post("/promote-branch", async (req, res) => {
|
|
958
|
+
router.post("/promote-branch", requireRole("operator"), async (req, res) => {
|
|
744
959
|
const { branchName, repo: targetRepo, issueIid } = req.body;
|
|
745
960
|
if (!branchName?.trim()) {
|
|
746
961
|
res.status(400).json({ error: "branchName is required." });
|
|
@@ -761,7 +976,7 @@ router.post("/promote-branch", async (req, res) => {
|
|
|
761
976
|
handleError(res, error, "promoteBranchToMR");
|
|
762
977
|
}
|
|
763
978
|
});
|
|
764
|
-
router.post("/deploy-branch", async (req, res) => {
|
|
979
|
+
router.post("/deploy-branch", requireRole("operator"), async (req, res) => {
|
|
765
980
|
const { analysis } = req.body;
|
|
766
981
|
if (!analysis?.issue || !analysis?.changes?.length) {
|
|
767
982
|
res.status(400).json({ error: "Invalid or missing analysis payload." });
|
|
@@ -798,6 +1013,70 @@ router.get("/mr/:mrIid/pipeline", async (req, res) => {
|
|
|
798
1013
|
handleError(res, error, "getPipelineStatus");
|
|
799
1014
|
}
|
|
800
1015
|
});
|
|
1016
|
+
const PIPELINE_HISTORY_PAGE_SIZE = 30;
|
|
1017
|
+
// Pipeline history for the repo: every ref and trigger (main, MRs, branches),
|
|
1018
|
+
// newest first. `nextPage` is null once a short page shows there's no more.
|
|
1019
|
+
router.get("/pipelines", async (req, res) => {
|
|
1020
|
+
const requested = Number(req.query.page);
|
|
1021
|
+
const page = Number.isInteger(requested) && requested > 0 ? requested : 1;
|
|
1022
|
+
try {
|
|
1023
|
+
const pipelines = await createProvider(parseRepo(req)).listRecentPipelines({
|
|
1024
|
+
page,
|
|
1025
|
+
perPage: PIPELINE_HISTORY_PAGE_SIZE,
|
|
1026
|
+
});
|
|
1027
|
+
res.json({ pipelines, nextPage: pipelines.length === PIPELINE_HISTORY_PAGE_SIZE ? page + 1 : null });
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
handleError(res, error, "listRecentPipelines");
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
// The first environment a reviewer can actually open, or null.
|
|
1034
|
+
function firstLiveEnvironment(envs) {
|
|
1035
|
+
return envs.find((e) => e.state === "available" && e.external_url) ?? null;
|
|
1036
|
+
}
|
|
1037
|
+
// Git refuses longer ref names on most hosts; anything past this is noise.
|
|
1038
|
+
const MAX_BRANCH_LENGTH = 255;
|
|
1039
|
+
// The MR's live review environment (per-MR ephemeral deploy, or a deploy from
|
|
1040
|
+
// its source branch), or null when the pipeline hasn't deployed one. Lookup
|
|
1041
|
+
// failures read as "none" — the UI only shows the link when there's something
|
|
1042
|
+
// to open, and keeps polling until there is.
|
|
1043
|
+
router.get("/mr/:mrIid/environment", async (req, res) => {
|
|
1044
|
+
const mrIid = Number(req.params.mrIid);
|
|
1045
|
+
if (!Number.isInteger(mrIid) || mrIid <= 0) {
|
|
1046
|
+
res.status(400).json({ error: "Invalid MR id." });
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
try {
|
|
1050
|
+
const provider = createProvider(parseRepo(req));
|
|
1051
|
+
const sourceBranch = await provider
|
|
1052
|
+
.getMR(mrIid)
|
|
1053
|
+
.then((mr) => mr.source_branch || undefined, () => undefined);
|
|
1054
|
+
const envs = await provider
|
|
1055
|
+
.listEnvironmentsForMR(mrIid, sourceBranch)
|
|
1056
|
+
.catch(() => []);
|
|
1057
|
+
res.json({ environment: firstLiveEnvironment(envs) });
|
|
1058
|
+
}
|
|
1059
|
+
catch (error) {
|
|
1060
|
+
handleError(res, error, "getMREnvironment");
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
// Same as above for a branch with no MR (Deploy-to-Test branches).
|
|
1064
|
+
router.get("/branch-environment", async (req, res) => {
|
|
1065
|
+
const branch = req.query.branch;
|
|
1066
|
+
if (typeof branch !== "string" || !branch || branch.length > MAX_BRANCH_LENGTH) {
|
|
1067
|
+
res.status(400).json({ error: "branch is required" });
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
try {
|
|
1071
|
+
const envs = await createProvider(parseRepo(req))
|
|
1072
|
+
.listEnvironmentsForBranch(branch)
|
|
1073
|
+
.catch(() => []);
|
|
1074
|
+
res.json({ environment: firstLiveEnvironment(envs) });
|
|
1075
|
+
}
|
|
1076
|
+
catch (error) {
|
|
1077
|
+
handleError(res, error, "getBranchEnvironment");
|
|
1078
|
+
}
|
|
1079
|
+
});
|
|
801
1080
|
// Returns the latest branch pipeline (and its jobs) for any ref.
|
|
802
1081
|
// Used by Deploy-to-Test, which creates a branch with no MR, so MR-scoped
|
|
803
1082
|
// pipeline lookups don't apply.
|
|
@@ -861,20 +1140,33 @@ router.get("/issues/:iid/deploy-branches", async (req, res) => {
|
|
|
861
1140
|
handleError(res, error, "listDeployBranches");
|
|
862
1141
|
}
|
|
863
1142
|
});
|
|
864
|
-
// Ephemeral environments (review apps)
|
|
865
|
-
// branch
|
|
866
|
-
//
|
|
1143
|
+
// Ephemeral environments (review apps) for an issue: those deployed from any
|
|
1144
|
+
// forge/issue-:iid-* branch, plus those its open Forge MRs' pipelines deployed
|
|
1145
|
+
// (MR pipelines deploy from the merge-request ref, not the branch). Returns a
|
|
1146
|
+
// flat list — each entry carries the branch it's tied to so the UI can
|
|
1147
|
+
// colocate it with its pipeline.
|
|
867
1148
|
router.get("/issues/:iid/environments", async (req, res) => {
|
|
868
1149
|
try {
|
|
869
1150
|
const iid = Number(req.params.iid);
|
|
870
1151
|
const provider = createProvider(parseRepo(req));
|
|
871
|
-
const branches = await
|
|
1152
|
+
const [branches, openMrs] = await Promise.all([
|
|
1153
|
+
provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []),
|
|
1154
|
+
provider.listForgeMRsForIssue(iid).catch(() => []),
|
|
1155
|
+
]);
|
|
872
1156
|
const all = [...branches, `forge/issue-${iid}`];
|
|
873
|
-
const
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
1157
|
+
const envLists = await Promise.all([
|
|
1158
|
+
...all.map(async (branch) => {
|
|
1159
|
+
const envs = await provider.listEnvironmentsForBranch(branch).catch(() => []);
|
|
1160
|
+
return envs.map((e) => ({ ...e, branchName: branch }));
|
|
1161
|
+
}),
|
|
1162
|
+
...openMrs.map(async (mr) => {
|
|
1163
|
+
const envs = await provider.listEnvironmentsForMR(mr.iid).catch(() => []);
|
|
1164
|
+
return envs.map((e) => ({ ...e, branchName: mr.source_branch }));
|
|
1165
|
+
}),
|
|
1166
|
+
]);
|
|
1167
|
+
const seen = new Set();
|
|
1168
|
+
const environments = envLists.flat().filter((e) => !seen.has(e.id) && seen.add(e.id));
|
|
1169
|
+
res.json({ environments });
|
|
878
1170
|
}
|
|
879
1171
|
catch (error) {
|
|
880
1172
|
handleError(res, error, "listEnvironments");
|
|
@@ -910,7 +1202,7 @@ router.get("/issues/:iid/post-merge-pipeline", async (req, res) => {
|
|
|
910
1202
|
}
|
|
911
1203
|
});
|
|
912
1204
|
// ─── Resolution check ─────────────────────────────────────────────────────────
|
|
913
|
-
router.post("/issues/:iid/resolution-check", async (req, res) => {
|
|
1205
|
+
router.post("/issues/:iid/resolution-check", requireRole("operator"), async (req, res) => {
|
|
914
1206
|
try {
|
|
915
1207
|
const primary = parseRepo(req);
|
|
916
1208
|
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
@@ -928,57 +1220,18 @@ router.post("/issues/:iid/resolution-check", async (req, res) => {
|
|
|
928
1220
|
handleError(res, error, "checkResolution");
|
|
929
1221
|
}
|
|
930
1222
|
});
|
|
931
|
-
// ─── Rollout status ───────────────────────────────────────────────────────────
|
|
932
|
-
router.get("/rollout", async (_req, res) => {
|
|
933
|
-
try {
|
|
934
|
-
const deployments = await listDeployments("test");
|
|
935
|
-
res.json({ deployments });
|
|
936
|
-
}
|
|
937
|
-
catch (error) {
|
|
938
|
-
handleError(res, error, "rollout");
|
|
939
|
-
}
|
|
940
|
-
});
|
|
941
|
-
// ─── Quota probe ──────────────────────────────────────────────────────────────
|
|
942
|
-
// User-triggered: hits the Anthropic Messages API once with a 1-token request
|
|
943
|
-
// just to read `anthropic-ratelimit-*` response headers. All real Forge work
|
|
944
|
-
// still runs through the `claude` CLI (subscription).
|
|
945
|
-
router.get("/quota", async (_req, res) => {
|
|
946
|
-
try {
|
|
947
|
-
const result = await probeQuota();
|
|
948
|
-
res.json(result);
|
|
949
|
-
}
|
|
950
|
-
catch (e) {
|
|
951
|
-
// Propagate the Anthropic status code if the probe itself failed,
|
|
952
|
-
// but still return whatever rate-limit headers came back.
|
|
953
|
-
const error = e;
|
|
954
|
-
const status = error?.status ?? 500;
|
|
955
|
-
res.status(status).json({
|
|
956
|
-
error: error?.message ?? "Quota probe failed",
|
|
957
|
-
rateLimit: error?.rateLimit ?? null,
|
|
958
|
-
operation: "quota",
|
|
959
|
-
});
|
|
960
|
-
}
|
|
961
|
-
});
|
|
962
|
-
// ─── Telemetry ───────────────────────────────────────────────────────────────
|
|
963
|
-
router.get("/telemetry", async (_req, res) => {
|
|
964
|
-
try {
|
|
965
|
-
const { getSummary, getFailurePatterns, getRecentEvents } = await import("./core/telemetry.js");
|
|
966
|
-
const [summary, patterns, recent] = await Promise.all([
|
|
967
|
-
getSummary(),
|
|
968
|
-
getFailurePatterns(),
|
|
969
|
-
getRecentEvents(50),
|
|
970
|
-
]);
|
|
971
|
-
res.json({ summary, patterns, recentEvents: recent });
|
|
972
|
-
}
|
|
973
|
-
catch (error) {
|
|
974
|
-
handleError(res, error, "getTelemetry");
|
|
975
|
-
}
|
|
976
|
-
});
|
|
977
1223
|
// ─── Live event bus ──────────────────────────────────────────────────────────
|
|
978
1224
|
// SSE channel that broadcasts state-change events so the UI can refetch
|
|
979
1225
|
// without polling. The CLI and API endpoints call broadcast() after any
|
|
980
1226
|
// mutating operation (analyze, apply, merge, close, etc.).
|
|
981
1227
|
const sseClients = new Set();
|
|
1228
|
+
/**
|
|
1229
|
+
* Tags an event with the repo the request was for, so a browser looking at
|
|
1230
|
+
* another repo ignores it (issue numbers repeat across repos).
|
|
1231
|
+
*/
|
|
1232
|
+
function broadcastFor(req, event) {
|
|
1233
|
+
broadcast({ ...event, repo: parseRepo(req) });
|
|
1234
|
+
}
|
|
982
1235
|
function broadcast(event) {
|
|
983
1236
|
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
984
1237
|
for (const res of sseClients) {
|
|
@@ -987,21 +1240,18 @@ function broadcast(event) {
|
|
|
987
1240
|
}
|
|
988
1241
|
}
|
|
989
1242
|
router.get("/events", (req, res) => {
|
|
990
|
-
|
|
991
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
992
|
-
res.setHeader("Connection", "keep-alive");
|
|
993
|
-
res.flushHeaders();
|
|
1243
|
+
openEventStream(req, res);
|
|
994
1244
|
res.write(":\n\n");
|
|
995
1245
|
sseClients.add(res);
|
|
996
1246
|
req.on("close", () => sseClients.delete(res));
|
|
997
1247
|
});
|
|
998
|
-
router.post("/events", (req, res) => {
|
|
1248
|
+
router.post("/events", requireRole("operator"), (req, res) => {
|
|
999
1249
|
const { type, iid, mrIid } = req.body ?? {};
|
|
1000
1250
|
if (!type) {
|
|
1001
1251
|
res.status(400).json({ error: "type is required." });
|
|
1002
1252
|
return;
|
|
1003
1253
|
}
|
|
1004
|
-
|
|
1254
|
+
broadcastFor(req, { type, ...(iid != null && { iid }), ...(mrIid != null && { mrIid }) });
|
|
1005
1255
|
res.json({ ok: true });
|
|
1006
1256
|
});
|
|
1007
1257
|
export { broadcast };
|