@ipul-labs/forge 1.16.7 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -21
- package/lib/bin/cli/analyze.d.ts +9 -0
- package/lib/bin/cli/analyze.d.ts.map +1 -0
- package/lib/bin/cli/analyze.js +491 -0
- package/lib/bin/cli/analyze.js.map +1 -0
- package/lib/bin/cli/apply.d.ts +2 -0
- package/lib/bin/cli/apply.d.ts.map +1 -0
- package/lib/bin/cli/apply.js +262 -0
- package/lib/bin/cli/apply.js.map +1 -0
- package/lib/bin/cli/cifix.d.ts +2 -0
- package/lib/bin/cli/cifix.d.ts.map +1 -0
- package/lib/bin/cli/cifix.js +310 -0
- package/lib/bin/cli/cifix.js.map +1 -0
- package/lib/bin/cli/close.d.ts +2 -0
- package/lib/bin/cli/close.d.ts.map +1 -0
- package/lib/bin/cli/close.js +113 -0
- package/lib/bin/cli/close.js.map +1 -0
- package/lib/bin/cli/createIssue.d.ts +2 -0
- package/lib/bin/cli/createIssue.d.ts.map +1 -0
- package/lib/bin/cli/createIssue.js +108 -0
- package/lib/bin/cli/createIssue.js.map +1 -0
- package/lib/bin/cli/doctor.d.ts +2 -0
- package/lib/bin/cli/doctor.d.ts.map +1 -0
- package/lib/bin/cli/doctor.js +145 -0
- package/lib/bin/cli/doctor.js.map +1 -0
- package/lib/bin/cli/init.d.ts +4 -0
- package/lib/bin/cli/init.d.ts.map +1 -0
- package/lib/bin/cli/init.js +288 -0
- package/lib/bin/cli/init.js.map +1 -0
- package/lib/bin/cli/ink.d.ts +23 -0
- package/lib/bin/cli/ink.d.ts.map +1 -0
- package/{bin → lib/bin}/cli/ink.js +18 -19
- package/lib/bin/cli/ink.js.map +1 -0
- package/lib/bin/cli/issue.d.ts +2 -0
- package/lib/bin/cli/issue.d.ts.map +1 -0
- package/lib/bin/cli/issue.js +114 -0
- package/lib/bin/cli/issue.js.map +1 -0
- package/lib/bin/cli/issues.d.ts +2 -0
- package/lib/bin/cli/issues.d.ts.map +1 -0
- package/lib/bin/cli/issues.js +149 -0
- package/lib/bin/cli/issues.js.map +1 -0
- package/lib/bin/cli/merge.d.ts +2 -0
- package/lib/bin/cli/merge.d.ts.map +1 -0
- package/lib/bin/cli/merge.js +162 -0
- package/lib/bin/cli/merge.js.map +1 -0
- package/lib/bin/cli/refine.d.ts +2 -0
- package/lib/bin/cli/refine.d.ts.map +1 -0
- package/lib/bin/cli/refine.js +249 -0
- package/lib/bin/cli/refine.js.map +1 -0
- package/lib/bin/cli/repl.d.ts +2 -0
- package/lib/bin/cli/repl.d.ts.map +1 -0
- package/lib/bin/cli/repl.js +309 -0
- package/lib/bin/cli/repl.js.map +1 -0
- package/lib/bin/cli/repos.d.ts +2 -0
- package/lib/bin/cli/repos.d.ts.map +1 -0
- package/lib/bin/cli/repos.js +86 -0
- package/lib/bin/cli/repos.js.map +1 -0
- package/lib/bin/cli/resolution.d.ts +2 -0
- package/lib/bin/cli/resolution.d.ts.map +1 -0
- package/lib/bin/cli/resolution.js +139 -0
- package/lib/bin/cli/resolution.js.map +1 -0
- package/lib/bin/cli/serverClient.d.ts +26 -0
- package/lib/bin/cli/serverClient.d.ts.map +1 -0
- package/lib/bin/cli/serverClient.js +111 -0
- package/lib/bin/cli/serverClient.js.map +1 -0
- package/lib/bin/cli/session.d.ts +8 -0
- package/lib/bin/cli/session.d.ts.map +1 -0
- package/lib/bin/cli/session.js +212 -0
- package/lib/bin/cli/session.js.map +1 -0
- package/lib/bin/cli/sessionStore.d.ts +69 -0
- package/lib/bin/cli/sessionStore.d.ts.map +1 -0
- package/lib/bin/cli/sessionStore.js +196 -0
- package/lib/bin/cli/sessionStore.js.map +1 -0
- package/lib/bin/cli/ship.d.ts +33 -0
- package/lib/bin/cli/ship.d.ts.map +1 -0
- package/lib/bin/cli/ship.js +737 -0
- package/lib/bin/cli/ship.js.map +1 -0
- package/lib/bin/cli/types.d.ts +59 -0
- package/lib/bin/cli/types.d.ts.map +1 -0
- package/lib/bin/cli/types.js +2 -0
- package/lib/bin/cli/types.js.map +1 -0
- package/lib/bin/cli/update.d.ts +2 -0
- package/lib/bin/cli/update.d.ts.map +1 -0
- package/lib/bin/cli/update.js +87 -0
- package/lib/bin/cli/update.js.map +1 -0
- package/lib/bin/cli/utils.d.ts +43 -0
- package/lib/bin/cli/utils.d.ts.map +1 -0
- package/lib/bin/cli/utils.js +171 -0
- package/lib/bin/cli/utils.js.map +1 -0
- package/lib/bin/cli/validate.d.ts +2 -0
- package/lib/bin/cli/validate.d.ts.map +1 -0
- package/lib/bin/cli/validate.js +283 -0
- package/lib/bin/cli/validate.js.map +1 -0
- package/lib/bin/cli/watch.d.ts +5 -0
- package/lib/bin/cli/watch.d.ts.map +1 -0
- package/lib/bin/cli/watch.js +263 -0
- package/lib/bin/cli/watch.js.map +1 -0
- package/lib/bin/forge-agent.d.ts +3 -0
- package/lib/bin/forge-agent.d.ts.map +1 -0
- package/{bin → lib/bin}/forge-agent.js +104 -105
- package/lib/bin/forge-agent.js.map +1 -0
- package/lib/server/forge/core/ForgeAgent.d.ts +260 -0
- package/lib/server/forge/core/ForgeAgent.d.ts.map +1 -0
- package/lib/server/forge/core/ForgeAgent.js +995 -0
- package/lib/server/forge/core/ForgeAgent.js.map +1 -0
- package/lib/server/forge/core/agentClient.d.ts +39 -0
- package/lib/server/forge/core/agentClient.d.ts.map +1 -0
- package/{server → lib/server}/forge/core/agentClient.js +7 -12
- package/lib/server/forge/core/agentClient.js.map +1 -0
- package/lib/server/forge/core/branchName.d.ts +20 -0
- package/lib/server/forge/core/branchName.d.ts.map +1 -0
- package/lib/server/forge/core/branchName.js +39 -0
- package/lib/server/forge/core/branchName.js.map +1 -0
- package/lib/server/forge/core/claudeClient.d.ts +60 -0
- package/lib/server/forge/core/claudeClient.d.ts.map +1 -0
- package/lib/server/forge/core/claudeClient.js +347 -0
- package/lib/server/forge/core/claudeClient.js.map +1 -0
- package/lib/server/forge/core/configStore.d.ts +76 -0
- package/lib/server/forge/core/configStore.d.ts.map +1 -0
- package/lib/server/forge/core/configStore.js +213 -0
- package/lib/server/forge/core/configStore.js.map +1 -0
- package/lib/server/forge/core/gitlabClient.d.ts +144 -0
- package/lib/server/forge/core/gitlabClient.d.ts.map +1 -0
- package/{server → lib/server}/forge/core/gitlabClient.js +98 -180
- package/lib/server/forge/core/gitlabClient.js.map +1 -0
- package/lib/server/forge/core/jobs.d.ts +67 -0
- package/lib/server/forge/core/jobs.d.ts.map +1 -0
- package/lib/server/forge/core/jobs.js +185 -0
- package/lib/server/forge/core/jobs.js.map +1 -0
- package/lib/server/forge/core/k8s.d.ts +18 -0
- package/lib/server/forge/core/k8s.d.ts.map +1 -0
- package/lib/server/forge/core/k8s.js +49 -0
- package/lib/server/forge/core/k8s.js.map +1 -0
- package/lib/server/forge/core/localRepo.d.ts +36 -0
- package/lib/server/forge/core/localRepo.d.ts.map +1 -0
- package/lib/server/forge/core/localRepo.js +165 -0
- package/lib/server/forge/core/localRepo.js.map +1 -0
- package/lib/server/forge/core/openclawClient.d.ts +61 -0
- package/lib/server/forge/core/openclawClient.d.ts.map +1 -0
- package/lib/server/forge/core/openclawClient.js +111 -0
- package/lib/server/forge/core/openclawClient.js.map +1 -0
- package/lib/server/forge/core/prompts.d.ts +96 -0
- package/lib/server/forge/core/prompts.d.ts.map +1 -0
- package/lib/server/forge/core/prompts.js +450 -0
- package/lib/server/forge/core/prompts.js.map +1 -0
- package/lib/server/forge/core/providers/azuredevops.d.ts +134 -0
- package/lib/server/forge/core/providers/azuredevops.d.ts.map +1 -0
- package/lib/server/forge/core/providers/azuredevops.js +360 -0
- package/lib/server/forge/core/providers/azuredevops.js.map +1 -0
- package/lib/server/forge/core/providers/base.d.ts +114 -0
- package/lib/server/forge/core/providers/base.d.ts.map +1 -0
- package/lib/server/forge/core/providers/base.js +98 -0
- package/lib/server/forge/core/providers/base.js.map +1 -0
- package/lib/server/forge/core/providers/github.d.ts +133 -0
- package/lib/server/forge/core/providers/github.d.ts.map +1 -0
- package/lib/server/forge/core/providers/github.js +307 -0
- package/lib/server/forge/core/providers/github.js.map +1 -0
- package/lib/server/forge/core/providers/gitlab.d.ts +144 -0
- package/lib/server/forge/core/providers/gitlab.d.ts.map +1 -0
- package/lib/server/forge/core/providers/gitlab.js +407 -0
- package/lib/server/forge/core/providers/gitlab.js.map +1 -0
- package/lib/server/forge/core/providers/index.d.ts +21 -0
- package/lib/server/forge/core/providers/index.d.ts.map +1 -0
- package/lib/server/forge/core/providers/index.js +43 -0
- package/lib/server/forge/core/providers/index.js.map +1 -0
- package/lib/server/forge/core/providers/types.d.ts +80 -0
- package/lib/server/forge/core/providers/types.d.ts.map +1 -0
- package/lib/server/forge/core/providers/types.js +2 -0
- package/lib/server/forge/core/providers/types.js.map +1 -0
- package/lib/server/forge/core/quotaClient.d.ts +48 -0
- package/lib/server/forge/core/quotaClient.d.ts.map +1 -0
- package/lib/server/forge/core/quotaClient.js +88 -0
- package/lib/server/forge/core/quotaClient.js.map +1 -0
- package/lib/server/forge/core/types.d.ts +160 -0
- package/lib/server/forge/core/types.d.ts.map +1 -0
- package/lib/server/forge/core/types.js +2 -0
- package/lib/server/forge/core/types.js.map +1 -0
- package/lib/server/forge/core/validator/attribute.d.ts +31 -0
- package/lib/server/forge/core/validator/attribute.d.ts.map +1 -0
- package/lib/server/forge/core/validator/attribute.js +82 -0
- package/lib/server/forge/core/validator/attribute.js.map +1 -0
- package/lib/server/forge/core/validator/clone.d.ts +14 -0
- package/lib/server/forge/core/validator/clone.d.ts.map +1 -0
- package/lib/server/forge/core/validator/clone.js +99 -0
- package/lib/server/forge/core/validator/clone.js.map +1 -0
- package/lib/server/forge/core/validator/cmd.d.ts +23 -0
- package/lib/server/forge/core/validator/cmd.d.ts.map +1 -0
- package/lib/server/forge/core/validator/cmd.js +87 -0
- package/lib/server/forge/core/validator/cmd.js.map +1 -0
- package/lib/server/forge/core/validator/fs.d.ts +5 -0
- package/lib/server/forge/core/validator/fs.d.ts.map +1 -0
- package/{server → lib/server}/forge/core/validator/fs.js +8 -3
- package/lib/server/forge/core/validator/fs.js.map +1 -0
- package/lib/server/forge/core/validator/overlay.d.ts +13 -0
- package/lib/server/forge/core/validator/overlay.d.ts.map +1 -0
- package/lib/server/forge/core/validator/overlay.js +53 -0
- package/lib/server/forge/core/validator/overlay.js.map +1 -0
- package/lib/server/forge/core/validator/projectType.d.ts +20 -0
- package/lib/server/forge/core/validator/projectType.d.ts.map +1 -0
- package/lib/server/forge/core/validator/projectType.js +94 -0
- package/lib/server/forge/core/validator/projectType.js.map +1 -0
- package/lib/server/forge/core/validator/runners/go.d.ts +21 -0
- package/lib/server/forge/core/validator/runners/go.d.ts.map +1 -0
- package/lib/server/forge/core/validator/runners/go.js +99 -0
- package/lib/server/forge/core/validator/runners/go.js.map +1 -0
- package/lib/server/forge/core/validator/runners/node.d.ts +12 -0
- package/lib/server/forge/core/validator/runners/node.d.ts.map +1 -0
- package/lib/server/forge/core/validator/runners/node.js +75 -0
- package/lib/server/forge/core/validator/runners/node.js.map +1 -0
- package/lib/server/forge/core/validator/tdd.d.ts +30 -0
- package/lib/server/forge/core/validator/tdd.d.ts.map +1 -0
- package/lib/server/forge/core/validator/tdd.js +96 -0
- package/lib/server/forge/core/validator/tdd.js.map +1 -0
- package/lib/server/forge/core/validator.d.ts +58 -0
- package/lib/server/forge/core/validator.d.ts.map +1 -0
- package/lib/server/forge/core/validator.js +150 -0
- package/lib/server/forge/core/validator.js.map +1 -0
- package/lib/server/forge/routes.d.ts +22 -0
- package/lib/server/forge/routes.d.ts.map +1 -0
- package/lib/server/forge/routes.js +984 -0
- package/lib/server/forge/routes.js.map +1 -0
- package/lib/server.d.ts +2 -0
- package/lib/server.d.ts.map +1 -0
- package/lib/server.js +52 -0
- package/lib/server.js.map +1 -0
- package/lib/shared/events.d.ts +12 -0
- package/lib/shared/events.d.ts.map +1 -0
- package/lib/shared/events.js +12 -0
- package/lib/shared/events.js.map +1 -0
- package/package.json +17 -13
- package/scripts/fix-shebang.cjs +10 -0
- package/bin/cli/analyze.js +0 -516
- package/bin/cli/apply.js +0 -264
- package/bin/cli/cifix.js +0 -300
- package/bin/cli/close.js +0 -125
- package/bin/cli/createIssue.js +0 -112
- package/bin/cli/doctor.js +0 -161
- package/bin/cli/init.js +0 -306
- package/bin/cli/issue.js +0 -116
- package/bin/cli/issues.js +0 -155
- package/bin/cli/merge.js +0 -175
- package/bin/cli/refine.js +0 -253
- package/bin/cli/repl.js +0 -301
- package/bin/cli/repos.js +0 -91
- package/bin/cli/resolution.js +0 -126
- package/bin/cli/session.js +0 -230
- package/bin/cli/sessionStore.js +0 -211
- package/bin/cli/ship.js +0 -762
- package/bin/cli/update.js +0 -95
- package/bin/cli/utils.js +0 -145
- package/bin/cli/validate.js +0 -280
- package/bin/cli/watch.js +0 -274
- package/server/forge/core/ForgeAgent.js +0 -1179
- package/server/forge/core/ForgeAgent.test.js +0 -179
- package/server/forge/core/branchName.js +0 -41
- package/server/forge/core/claudeClient.js +0 -378
- package/server/forge/core/configStore.js +0 -229
- package/server/forge/core/configStore.test.js +0 -89
- package/server/forge/core/jobs.js +0 -180
- package/server/forge/core/k8s.js +0 -54
- package/server/forge/core/localRepo.js +0 -153
- package/server/forge/core/openclawClient.js +0 -125
- package/server/forge/core/prompts.js +0 -483
- package/server/forge/core/prompts.test.js +0 -219
- package/server/forge/core/providers/azuredevops.js +0 -409
- package/server/forge/core/providers/base.js +0 -208
- package/server/forge/core/providers/github.js +0 -348
- package/server/forge/core/providers/gitlab.js +0 -529
- package/server/forge/core/providers/index.js +0 -47
- package/server/forge/core/quotaClient.js +0 -101
- package/server/forge/core/validator/attribute.js +0 -84
- package/server/forge/core/validator/clone.js +0 -105
- package/server/forge/core/validator/cmd.js +0 -98
- package/server/forge/core/validator/overlay.js +0 -52
- package/server/forge/core/validator/projectType.js +0 -87
- package/server/forge/core/validator/runners/go.js +0 -107
- package/server/forge/core/validator/runners/node.js +0 -84
- package/server/forge/core/validator/tdd.js +0 -105
- package/server/forge/core/validator.js +0 -167
- package/server/forge/routes.js +0 -1026
package/server/forge/routes.js
DELETED
|
@@ -1,1026 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* server/forge/routes.js
|
|
3
|
-
*
|
|
4
|
-
* Express router for /api/forge/*.
|
|
5
|
-
*
|
|
6
|
-
* Required env vars:
|
|
7
|
-
* FORGE_TOKEN — dashboard password
|
|
8
|
-
*
|
|
9
|
-
* Project + token resolved via configStore (Settings UI) with env-var fallbacks.
|
|
10
|
-
*
|
|
11
|
-
* Multi-repo support
|
|
12
|
-
* ──────────────────
|
|
13
|
-
* Every endpoint accepts an optional `repo` param (primary, where the issue
|
|
14
|
-
* lives) and `secondaryRepos` param (additional repos that can participate in
|
|
15
|
-
* a full-stack fix). `secondaryRepos` may be sent as a comma-separated string
|
|
16
|
-
* in the query string, or as a JSON array in a POST body.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { Router } from "express";
|
|
20
|
-
|
|
21
|
-
import * as configStore from "./core/configStore.js";
|
|
22
|
-
import { listDeployments } from "./core/k8s.js";
|
|
23
|
-
import { ForgeAgent } from "./core/ForgeAgent.js";
|
|
24
|
-
import crypto from "node:crypto";
|
|
25
|
-
import { probeQuota } from "./core/quotaClient.js";
|
|
26
|
-
import { validate as runValidation } from "./core/validator.js";
|
|
27
|
-
import { createProvider } from "./core/providers/index.js";
|
|
28
|
-
import * as jobStore from "./core/jobs.js";
|
|
29
|
-
|
|
30
|
-
const router = Router();
|
|
31
|
-
|
|
32
|
-
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
function requireAuth(req, res, next) {
|
|
35
|
-
const token = process.env.FORGE_TOKEN;
|
|
36
|
-
|
|
37
|
-
if (!token) {
|
|
38
|
-
return res.status(503).json({
|
|
39
|
-
error: "Forge is not configured. Set FORGE_TOKEN env var.",
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const auth = req.headers.authorization ?? `Bearer ${req.query.token ?? ""}`;
|
|
44
|
-
|
|
45
|
-
if (!auth?.startsWith("Bearer ")) {
|
|
46
|
-
return res.status(401).json({ error: "Missing Authorization header." });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const provided = Buffer.from(auth.slice(7));
|
|
50
|
-
const expected = Buffer.from(token);
|
|
51
|
-
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
|
|
52
|
-
return res.status(403).json({ error: "Invalid token." });
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
next();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ─── Error handler ────────────────────────────────────────────────────────────
|
|
59
|
-
|
|
60
|
-
function redactSecrets(text) {
|
|
61
|
-
return text
|
|
62
|
-
.replace(/(?:token|key|secret|password|bearer)[=:\s]+\S+/gi, (m) => m.slice(0, m.indexOf(m.match(/[=:\s]/)[0]) + 1) + "***")
|
|
63
|
-
.replace(/oauth2:[^@]+@/gi, "oauth2:***@")
|
|
64
|
-
.replace(/ghp_\w+/g, "ghp_***")
|
|
65
|
-
.replace(/glpat-\w+/g, "glpat-***");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function handleError(res, error, operation) {
|
|
69
|
-
const msg = error?.message ?? "Unknown error";
|
|
70
|
-
console.error(`[forge] ${operation} failed:`, redactSecrets(msg));
|
|
71
|
-
const upstream = /^(?:GitLab|GitHub) (\d{3}) on /.exec(msg);
|
|
72
|
-
const status = error?.status ?? (upstream ? Number(upstream[1]) : 500);
|
|
73
|
-
res.status(status).json({ error: redactSecrets(msg), operation });
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ─── Repo helpers ─────────────────────────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
function getAvailableRepos() {
|
|
79
|
-
// Precedence: persisted config → env vars → empty.
|
|
80
|
-
const fromConfig = configStore.get().repos;
|
|
81
|
-
if (fromConfig.length > 0) return fromConfig;
|
|
82
|
-
|
|
83
|
-
const raw = process.env.FORGE_REPOS ?? process.env.FORGE_GITLAB_PROJECT ?? "";
|
|
84
|
-
return raw
|
|
85
|
-
.split(",")
|
|
86
|
-
.map((s) => s.trim())
|
|
87
|
-
.filter(Boolean)
|
|
88
|
-
.map((path) => ({ id: path, label: path.split("/").pop() }));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function parseRepo(req) {
|
|
92
|
-
const repos = getAvailableRepos();
|
|
93
|
-
const requested = req.query.repo ?? req.body?.repo ?? null;
|
|
94
|
-
if (!requested) return repos[0]?.id ?? configStore.getPrimaryProject();
|
|
95
|
-
const match = repos.find((r) => r.id === requested);
|
|
96
|
-
return match?.id ?? repos[0]?.id ?? configStore.getPrimaryProject();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Normalises `secondaryRepos` from either a query string (comma-separated) or
|
|
101
|
-
* a JSON body array, validating each entry against the configured repo list.
|
|
102
|
-
* The primary repo is always filtered out.
|
|
103
|
-
*/
|
|
104
|
-
function parseSecondaryRepos(req, primary) {
|
|
105
|
-
const repos = getAvailableRepos();
|
|
106
|
-
const valid = new Set(repos.map((r) => r.id));
|
|
107
|
-
|
|
108
|
-
let raw = req.body?.secondaryRepos ?? req.query.secondaryRepos ?? null;
|
|
109
|
-
if (!raw) return [];
|
|
110
|
-
|
|
111
|
-
let list;
|
|
112
|
-
if (Array.isArray(raw)) {
|
|
113
|
-
list = raw;
|
|
114
|
-
} else if (typeof raw === "string") {
|
|
115
|
-
list = raw.split(",");
|
|
116
|
-
} else {
|
|
117
|
-
return [];
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return list
|
|
121
|
-
.map((s) => (typeof s === "string" ? s.trim() : ""))
|
|
122
|
-
.filter((s) => s && s !== primary && valid.has(s));
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ─── Status ───────────────────────────────────────────────────────────────────
|
|
126
|
-
|
|
127
|
-
const MODEL_RATES = {
|
|
128
|
-
"claude-opus-4-7": { input: 15, output: 75 },
|
|
129
|
-
"claude-sonnet-4-6": { input: 3, output: 15 },
|
|
130
|
-
"claude-haiku-4-5-20251001": { input: 0.80, output: 4 },
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
const FALLBACK_MODELS = [
|
|
134
|
-
{ id: "claude-opus-4-7", label: "Claude Opus 4.7", provider: "claude" },
|
|
135
|
-
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", provider: "claude" },
|
|
136
|
-
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5", provider: "claude" },
|
|
137
|
-
];
|
|
138
|
-
|
|
139
|
-
async function fetchAnthropicModels() {
|
|
140
|
-
const controller = new AbortController();
|
|
141
|
-
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
142
|
-
try {
|
|
143
|
-
const res = await fetch("https://api.anthropic.com/v1/models?limit=100", {
|
|
144
|
-
headers: {
|
|
145
|
-
"x-api-key": process.env.ANTHROPIC_API_KEY,
|
|
146
|
-
"anthropic-version": "2023-06-01",
|
|
147
|
-
},
|
|
148
|
-
signal: controller.signal,
|
|
149
|
-
});
|
|
150
|
-
if (!res.ok) throw new Error(`Anthropic models API ${res.status}`);
|
|
151
|
-
const { data } = await res.json();
|
|
152
|
-
return data
|
|
153
|
-
.filter((m) => m.type === "model" && m.id.startsWith("claude-"))
|
|
154
|
-
.map((m) => ({
|
|
155
|
-
id: m.id,
|
|
156
|
-
label: m.display_name,
|
|
157
|
-
provider: "claude",
|
|
158
|
-
...(MODEL_RATES[m.id] ? { rates: MODEL_RATES[m.id] } : {}),
|
|
159
|
-
}));
|
|
160
|
-
} finally {
|
|
161
|
-
clearTimeout(timeout);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
router.get("/status", requireAuth, async (_req, res) => {
|
|
166
|
-
let models = FALLBACK_MODELS.map((m) => ({
|
|
167
|
-
...m,
|
|
168
|
-
...(MODEL_RATES[m.id] ? { rates: MODEL_RATES[m.id] } : {}),
|
|
169
|
-
}));
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
if (process.env.ANTHROPIC_API_KEY) {
|
|
173
|
-
models = await fetchAnthropicModels();
|
|
174
|
-
}
|
|
175
|
-
} catch (e) {
|
|
176
|
-
console.warn("[forge] fetchAnthropicModels failed, using fallback:", e?.message);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const repos = getAvailableRepos();
|
|
180
|
-
const agents = [
|
|
181
|
-
{ id: "claude", label: "Claude", configured: true },
|
|
182
|
-
{
|
|
183
|
-
id: "openclaw",
|
|
184
|
-
label: "OpenClaw",
|
|
185
|
-
configured: Boolean(process.env.OPENCLAW_GATEWAY_URL),
|
|
186
|
-
},
|
|
187
|
-
];
|
|
188
|
-
|
|
189
|
-
res.json({
|
|
190
|
-
ok: true,
|
|
191
|
-
project: configStore.getPrimaryProject(),
|
|
192
|
-
repos,
|
|
193
|
-
supportsMultiRepo: repos.length > 1,
|
|
194
|
-
hasGitlabToken: Boolean(configStore.getConnection("gitlab").token),
|
|
195
|
-
defaultModel: process.env.FORGE_MODEL ?? "claude-sonnet-4-6",
|
|
196
|
-
models,
|
|
197
|
-
agents,
|
|
198
|
-
defaultAgent: process.env.OPENCLAW_GATEWAY_URL ? "openclaw" : "claude",
|
|
199
|
-
hasApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
|
|
200
|
-
// Test-deploy URL template stored in configStore.defaults — editable
|
|
201
|
-
// from Settings → Defaults. Supports "{branch}" / "{slug}" placeholders
|
|
202
|
-
// for ephemeral per-branch deploys. Empty string means "no link".
|
|
203
|
-
testDeployUrlTemplate: configStore.get().defaults?.testDeployUrlTemplate || null,
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
router.use(requireAuth);
|
|
208
|
-
|
|
209
|
-
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
210
|
-
|
|
211
|
-
router.get("/config", (_req, res) => {
|
|
212
|
-
res.json(configStore.getPublic());
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
router.put("/config", async (req, res) => {
|
|
216
|
-
try {
|
|
217
|
-
await configStore.patch(req.body ?? {});
|
|
218
|
-
res.json(configStore.getPublic());
|
|
219
|
-
} catch (err) {
|
|
220
|
-
handleError(res, err, "updating config");
|
|
221
|
-
}
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
// ─── Issues ───────────────────────────────────────────────────────────────────
|
|
225
|
-
|
|
226
|
-
router.get("/issues", async (req, res) => {
|
|
227
|
-
try {
|
|
228
|
-
const provider = createProvider(parseRepo(req));
|
|
229
|
-
const [issues, forgeBranches, mergedLinks] = await Promise.all([
|
|
230
|
-
provider.listOpenIssues(),
|
|
231
|
-
provider.listBranchesByPrefix("forge/issue-").catch(() => []),
|
|
232
|
-
provider.listRecentMergedMRIssueLinks(50).catch(() => []),
|
|
233
|
-
]);
|
|
234
|
-
// Map issue iid → most specific forge branch name. There may be several
|
|
235
|
-
// (old analyses that never merged), so prefer one with a slug suffix.
|
|
236
|
-
const branchByIid = new Map();
|
|
237
|
-
for (const name of forgeBranches) {
|
|
238
|
-
const m = name.match(/^forge\/issue-(\d+)(?:-|$)/);
|
|
239
|
-
if (!m) continue;
|
|
240
|
-
const iid = Number(m[1]);
|
|
241
|
-
const hasSlug = name.length > `forge/issue-${iid}`.length;
|
|
242
|
-
const existing = branchByIid.get(iid);
|
|
243
|
-
if (!existing || (!/-/.test(existing.slice(`forge/issue-${iid}`.length)) && hasSlug)) {
|
|
244
|
-
branchByIid.set(iid, name);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
// Map issue iid → most-recent merged MR that claims to close it.
|
|
248
|
-
// Primary signal: closing keywords in the MR description.
|
|
249
|
-
const fixByIid = new Map();
|
|
250
|
-
for (const link of mergedLinks) {
|
|
251
|
-
for (const iid of link.closedIssueIids) {
|
|
252
|
-
const prior = fixByIid.get(iid);
|
|
253
|
-
if (!prior || (link.mergedAt ?? "") > (prior.mergedAt ?? "")) {
|
|
254
|
-
fixByIid.set(iid, { mrIid: link.mrIid, webUrl: link.webUrl, mergedAt: link.mergedAt });
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
// Fallback signal: merged MRs whose source branch encodes the issue iid
|
|
259
|
-
// (`forge/issue-<iid>[-slug]`). Catches MRs that merged without a
|
|
260
|
-
// closing keyword in the description — e.g. manually opened MRs on a
|
|
261
|
-
// Forge branch. Only fills gaps; the keyword match above wins on
|
|
262
|
-
// conflicts since it's a stronger signal (the MR explicitly says so).
|
|
263
|
-
for (const link of mergedLinks) {
|
|
264
|
-
const m = link.sourceBranch?.match(/^forge\/issue-(\d+)(?:-|$)/);
|
|
265
|
-
if (!m) continue;
|
|
266
|
-
const iid = Number(m[1]);
|
|
267
|
-
if (fixByIid.has(iid)) continue;
|
|
268
|
-
fixByIid.set(iid, { mrIid: link.mrIid, webUrl: link.webUrl, mergedAt: link.mergedAt });
|
|
269
|
-
}
|
|
270
|
-
// Only compare branches we actually care about — issues shown in the list.
|
|
271
|
-
const compareTargets = issues
|
|
272
|
-
.map((i) => ({ iid: i.iid, branch: branchByIid.get(i.iid) }))
|
|
273
|
-
.filter((e) => e.branch);
|
|
274
|
-
const compareResults = await Promise.all(
|
|
275
|
-
compareTargets.map((e) =>
|
|
276
|
-
provider.compareRefs("main", e.branch).catch(() => null),
|
|
277
|
-
),
|
|
278
|
-
);
|
|
279
|
-
const aheadByIid = new Map();
|
|
280
|
-
compareTargets.forEach((e, idx) => {
|
|
281
|
-
aheadByIid.set(e.iid, compareResults[idx]?.aheadCount ?? null);
|
|
282
|
-
});
|
|
283
|
-
const enriched = issues.map((i) => {
|
|
284
|
-
const out = { ...i };
|
|
285
|
-
const b = branchByIid.get(i.iid);
|
|
286
|
-
if (b) {
|
|
287
|
-
out.forgeBranch = b;
|
|
288
|
-
if (aheadByIid.has(i.iid)) out.branchAhead = aheadByIid.get(i.iid);
|
|
289
|
-
}
|
|
290
|
-
const fix = fixByIid.get(i.iid);
|
|
291
|
-
if (fix) out.likelyFixedByMr = { iid: fix.mrIid, webUrl: fix.webUrl };
|
|
292
|
-
return out;
|
|
293
|
-
});
|
|
294
|
-
res.json({ issues: enriched });
|
|
295
|
-
} catch (error) {
|
|
296
|
-
handleError(res, error, "listOpenIssues");
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
router.post("/issues", async (req, res) => {
|
|
301
|
-
const { title, description = "" } = req.body;
|
|
302
|
-
if (!title?.trim()) {
|
|
303
|
-
return res.status(400).json({ error: "title is required." });
|
|
304
|
-
}
|
|
305
|
-
try {
|
|
306
|
-
const issue = await createProvider(parseRepo(req)).createIssue(title.trim(), description.trim());
|
|
307
|
-
res.json({ issue });
|
|
308
|
-
} catch (error) {
|
|
309
|
-
handleError(res, error, "createIssue");
|
|
310
|
-
}
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
router.put("/issues/:iid", async (req, res) => {
|
|
314
|
-
const { title, description = "" } = req.body;
|
|
315
|
-
if (!title?.trim()) {
|
|
316
|
-
return res.status(400).json({ error: "title is required." });
|
|
317
|
-
}
|
|
318
|
-
try {
|
|
319
|
-
const issue = await createProvider(parseRepo(req)).updateIssue(
|
|
320
|
-
Number(req.params.iid),
|
|
321
|
-
{ title: title.trim(), description: description.trim() },
|
|
322
|
-
);
|
|
323
|
-
res.json({ issue });
|
|
324
|
-
} catch (error) {
|
|
325
|
-
handleError(res, error, "updateIssue");
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
router.post("/issues/:iid/close", async (req, res) => {
|
|
330
|
-
try {
|
|
331
|
-
const iid = Number(req.params.iid);
|
|
332
|
-
const provider = createProvider(parseRepo(req));
|
|
333
|
-
|
|
334
|
-
// Refuse if there's an open Forge MR — the user should merge or close the
|
|
335
|
-
// MR first. The frontend surfaces this message verbatim.
|
|
336
|
-
const openMrs = await provider.listForgeMRsForIssue(iid).catch(() => []);
|
|
337
|
-
if (openMrs.length > 0) {
|
|
338
|
-
const mr = openMrs[0];
|
|
339
|
-
return res.status(409).json({
|
|
340
|
-
error: `Cannot close: active MR !${mr.iid} is still open. Merge or close it first.`,
|
|
341
|
-
openMr: { iid: mr.iid, web_url: mr.web_url, title: mr.title },
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Stop any ephemeral environments attached to forge branches for this
|
|
346
|
-
// issue BEFORE deleting branches — on most providers deleting the
|
|
347
|
-
// branch orphans the environment rather than tearing it down. Each
|
|
348
|
-
// stop is best-effort (already-stopped envs 4xx).
|
|
349
|
-
const branches = await provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []);
|
|
350
|
-
const baseBranch = `forge/issue-${iid}`;
|
|
351
|
-
const all = [...branches, baseBranch];
|
|
352
|
-
const envs = (
|
|
353
|
-
await Promise.all(
|
|
354
|
-
all.map((b) => provider.listEnvironmentsForBranch(b).catch(() => [])),
|
|
355
|
-
)
|
|
356
|
-
).flat();
|
|
357
|
-
await Promise.all(
|
|
358
|
-
envs.map((e) => provider.stopEnvironment(e.id).catch(() => {})),
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
// Now delete the branches. Also best-effort.
|
|
362
|
-
await Promise.all(
|
|
363
|
-
all.map((b) => provider.deleteBranch(b).catch(() => {})),
|
|
364
|
-
);
|
|
365
|
-
|
|
366
|
-
const issue = await provider.closeIssue(iid);
|
|
367
|
-
res.json({ issue, stoppedEnvironments: envs.length });
|
|
368
|
-
} catch (error) {
|
|
369
|
-
handleError(res, error, "closeIssue");
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
router.get("/issues/:iid", async (req, res) => {
|
|
374
|
-
try {
|
|
375
|
-
const issue = await createProvider(parseRepo(req)).getIssue(Number(req.params.iid));
|
|
376
|
-
res.json({ issue });
|
|
377
|
-
} catch (error) {
|
|
378
|
-
handleError(res, error, "getIssue");
|
|
379
|
-
}
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
// ─── Open Forge MRs ───────────────────────────────────────────────────────────
|
|
383
|
-
|
|
384
|
-
router.get("/mrs", async (req, res) => {
|
|
385
|
-
try {
|
|
386
|
-
const rawState = typeof req.query.state === "string" ? req.query.state : "open";
|
|
387
|
-
const state = ["open", "merged", "closed"].includes(rawState) ? rawState : "open";
|
|
388
|
-
const mrs = await createProvider(parseRepo(req)).listAllForgeMRs(state);
|
|
389
|
-
res.json({
|
|
390
|
-
mrs: mrs.map((mr) => ({
|
|
391
|
-
iid: mr.iid,
|
|
392
|
-
title: mr.title,
|
|
393
|
-
web_url: mr.web_url,
|
|
394
|
-
source_branch: mr.source_branch,
|
|
395
|
-
target_branch: mr.target_branch,
|
|
396
|
-
state: mr.state,
|
|
397
|
-
created_at: mr.created_at,
|
|
398
|
-
merged_at: mr.merged_at ?? null,
|
|
399
|
-
author: { name: mr.author?.name ?? "unknown" },
|
|
400
|
-
has_conflicts: mr.has_conflicts ?? false,
|
|
401
|
-
merge_status: mr.merge_status ?? "unchecked",
|
|
402
|
-
pipeline: mr.head_pipeline
|
|
403
|
-
? { status: mr.head_pipeline.status, web_url: mr.head_pipeline.web_url }
|
|
404
|
-
: null,
|
|
405
|
-
})),
|
|
406
|
-
});
|
|
407
|
-
} catch (error) {
|
|
408
|
-
handleError(res, error, "listAllForgeMRs");
|
|
409
|
-
}
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
// ─── MR detection ─────────────────────────────────────────────────────────────
|
|
413
|
-
|
|
414
|
-
router.get("/issues/:iid/mr", async (req, res) => {
|
|
415
|
-
try {
|
|
416
|
-
const mrs = await createProvider(parseRepo(req)).listForgeMRsForIssue(Number(req.params.iid));
|
|
417
|
-
const latest = mrs[0] ?? null;
|
|
418
|
-
res.json({
|
|
419
|
-
mr: latest
|
|
420
|
-
? { iid: latest.iid, web_url: latest.web_url, title: latest.title, branchName: latest.source_branch }
|
|
421
|
-
: null,
|
|
422
|
-
});
|
|
423
|
-
} catch (error) {
|
|
424
|
-
handleError(res, error, "listForgeMRsForIssue");
|
|
425
|
-
}
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
router.get("/issues/:iid/merged-mrs", async (req, res) => {
|
|
429
|
-
try {
|
|
430
|
-
const provider = createProvider(parseRepo(req));
|
|
431
|
-
const mrs = await provider.listMergedForgeMRsForIssue(Number(req.params.iid));
|
|
432
|
-
|
|
433
|
-
// Attach the post-merge pipeline (the one that ran on the merge commit)
|
|
434
|
-
// to each MR so the UI can render pass/fail badges.
|
|
435
|
-
const enriched = await Promise.all(
|
|
436
|
-
mrs.map(async (mr) => {
|
|
437
|
-
let pipeline = null;
|
|
438
|
-
if (mr.merge_commit_sha) {
|
|
439
|
-
try {
|
|
440
|
-
const pipelines = await provider.getPipelinesBySha(mr.merge_commit_sha);
|
|
441
|
-
const latest = pipelines[0];
|
|
442
|
-
if (latest) {
|
|
443
|
-
pipeline = { id: latest.id, status: latest.status, web_url: latest.web_url };
|
|
444
|
-
}
|
|
445
|
-
} catch {
|
|
446
|
-
// Non-fatal — pipelines can 404 for older merges.
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
return {
|
|
450
|
-
iid: mr.iid,
|
|
451
|
-
web_url: mr.web_url,
|
|
452
|
-
title: mr.title,
|
|
453
|
-
merged_at: mr.merged_at,
|
|
454
|
-
branchName: mr.source_branch,
|
|
455
|
-
merge_commit_sha: mr.merge_commit_sha,
|
|
456
|
-
pipeline,
|
|
457
|
-
};
|
|
458
|
-
}),
|
|
459
|
-
);
|
|
460
|
-
res.json({ mrs: enriched });
|
|
461
|
-
} catch (error) {
|
|
462
|
-
handleError(res, error, "listMergedForgeMRsForIssue");
|
|
463
|
-
}
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
router.get("/mr/:mrIid/detail", async (req, res) => {
|
|
467
|
-
try {
|
|
468
|
-
const mr = await createProvider(parseRepo(req)).getMR(Number(req.params.mrIid));
|
|
469
|
-
res.json({
|
|
470
|
-
iid: mr.iid,
|
|
471
|
-
title: mr.title,
|
|
472
|
-
web_url: mr.web_url,
|
|
473
|
-
source_branch: mr.source_branch,
|
|
474
|
-
target_branch: mr.target_branch,
|
|
475
|
-
state: mr.state,
|
|
476
|
-
has_conflicts: mr.has_conflicts ?? false,
|
|
477
|
-
rebase_in_progress: mr.rebase_in_progress ?? false,
|
|
478
|
-
merge_status: mr.merge_status ?? "unchecked",
|
|
479
|
-
detailed_merge_status: mr.detailed_merge_status ?? null,
|
|
480
|
-
merge_when_pipeline_succeeds: mr.merge_when_pipeline_succeeds ?? false,
|
|
481
|
-
draft: mr.draft ?? false,
|
|
482
|
-
});
|
|
483
|
-
} catch (error) {
|
|
484
|
-
handleError(res, error, "getMR");
|
|
485
|
-
}
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
router.post("/mr/:mrIid/rebase", async (req, res) => {
|
|
489
|
-
try {
|
|
490
|
-
const provider = createProvider(parseRepo(req));
|
|
491
|
-
if (!provider.supportsRebase) {
|
|
492
|
-
return res.status(422).json({ error: `${provider.name} does not support server-side rebase.` });
|
|
493
|
-
}
|
|
494
|
-
await provider.rebaseMR(Number(req.params.mrIid));
|
|
495
|
-
res.json({ ok: true });
|
|
496
|
-
} catch (error) {
|
|
497
|
-
handleError(res, error, "rebaseMR");
|
|
498
|
-
}
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
router.get("/mr/:mrIid/diffs", async (req, res) => {
|
|
502
|
-
try {
|
|
503
|
-
const diffs = await createProvider(parseRepo(req)).getMRDiffs(Number(req.params.mrIid));
|
|
504
|
-
res.json({ diffs });
|
|
505
|
-
} catch (error) {
|
|
506
|
-
handleError(res, error, "getMRDiffs");
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
router.post("/mr/:mrIid/auto-merge", async (req, res) => {
|
|
511
|
-
try {
|
|
512
|
-
const provider = createProvider(parseRepo(req));
|
|
513
|
-
await provider.setAutoMergeMR(Number(req.params.mrIid));
|
|
514
|
-
res.json({ ok: true });
|
|
515
|
-
} catch (error) {
|
|
516
|
-
handleError(res, error, "setAutoMergeMR");
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
|
|
520
|
-
router.post("/mr/:mrIid/cancel-auto-merge", async (req, res) => {
|
|
521
|
-
try {
|
|
522
|
-
const provider = createProvider(parseRepo(req));
|
|
523
|
-
await provider.cancelAutoMergeMR(Number(req.params.mrIid));
|
|
524
|
-
res.json({ ok: true });
|
|
525
|
-
} catch (error) {
|
|
526
|
-
handleError(res, error, "cancelAutoMergeMR");
|
|
527
|
-
}
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
router.post("/mr/:mrIid/merge", async (req, res) => {
|
|
531
|
-
try {
|
|
532
|
-
const provider = createProvider(parseRepo(req));
|
|
533
|
-
await provider.mergeMR(Number(req.params.mrIid));
|
|
534
|
-
res.json({ ok: true });
|
|
535
|
-
} catch (error) {
|
|
536
|
-
handleError(res, error, "mergeMR");
|
|
537
|
-
}
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
router.post("/mr/:mrIid/close", async (req, res) => {
|
|
541
|
-
try {
|
|
542
|
-
const provider = createProvider(parseRepo(req));
|
|
543
|
-
await provider.closeMR(Number(req.params.mrIid));
|
|
544
|
-
res.json({ ok: true });
|
|
545
|
-
} catch (error) {
|
|
546
|
-
handleError(res, error, "closeMR");
|
|
547
|
-
}
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
router.post("/mr/:mrIid/reopen", async (req, res) => {
|
|
551
|
-
try {
|
|
552
|
-
const provider = createProvider(parseRepo(req));
|
|
553
|
-
await provider.reopenMR(Number(req.params.mrIid));
|
|
554
|
-
res.json({ ok: true });
|
|
555
|
-
} catch (error) {
|
|
556
|
-
handleError(res, error, "reopenMR");
|
|
557
|
-
}
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
router.post("/mr/:mrIid/draft", async (req, res) => {
|
|
561
|
-
try {
|
|
562
|
-
const draft = Boolean(req.body?.draft);
|
|
563
|
-
const provider = createProvider(parseRepo(req));
|
|
564
|
-
await provider.setMRDraft(Number(req.params.mrIid), draft);
|
|
565
|
-
res.json({ ok: true });
|
|
566
|
-
} catch (error) {
|
|
567
|
-
handleError(res, error, "setMRDraft");
|
|
568
|
-
}
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
// ─── Analysis (SSE) ───────────────────────────────────────────────────────────
|
|
572
|
-
// The job is kicked off synchronously and runs to completion regardless of
|
|
573
|
-
// whether a client is listening. The client opens /jobs/:jobId/stream to
|
|
574
|
-
// attach (or reattach after a reload).
|
|
575
|
-
|
|
576
|
-
router.post("/issues/:iid/analyze", (req, res) => {
|
|
577
|
-
const { iid } = req.params;
|
|
578
|
-
const primary = parseRepo(req);
|
|
579
|
-
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
580
|
-
const agent = new ForgeAgent({
|
|
581
|
-
project: primary,
|
|
582
|
-
secondaryProjects,
|
|
583
|
-
agent: req.body.agent || "claude",
|
|
584
|
-
});
|
|
585
|
-
const kind = ["fresh", "new", "update", "retry"].includes(req.body.kind)
|
|
586
|
-
? req.body.kind
|
|
587
|
-
: "fresh";
|
|
588
|
-
|
|
589
|
-
const previousChanges = Array.isArray(req.body.previousChanges) ? req.body.previousChanges : null;
|
|
590
|
-
|
|
591
|
-
const jobId = jobStore.createJob("analyze", (send, signal) =>
|
|
592
|
-
agent.analyzeIssue(
|
|
593
|
-
Number(iid),
|
|
594
|
-
send,
|
|
595
|
-
req.body.model || undefined,
|
|
596
|
-
undefined,
|
|
597
|
-
req.body.prompt || undefined,
|
|
598
|
-
kind,
|
|
599
|
-
{ signal },
|
|
600
|
-
previousChanges,
|
|
601
|
-
),
|
|
602
|
-
);
|
|
603
|
-
res.json({ jobId });
|
|
604
|
-
});
|
|
605
|
-
|
|
606
|
-
// ─── Refine ───────────────────────────────────────────────────────────────────
|
|
607
|
-
|
|
608
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
609
|
-
|
|
610
|
-
router.post("/issues/:iid/refine", (req, res) => {
|
|
611
|
-
const { iid } = req.params;
|
|
612
|
-
const { feedback, previousChanges, ciContext, validationState, claudeSessionId } = req.body;
|
|
613
|
-
|
|
614
|
-
if (!feedback?.trim() && !ciContext?.trim()) {
|
|
615
|
-
return res.status(400).json({ error: "feedback or ciContext is required." });
|
|
616
|
-
}
|
|
617
|
-
if (!Array.isArray(previousChanges)) {
|
|
618
|
-
return res.status(400).json({ error: "previousChanges must be an array." });
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// Only forward a syntactically valid UUID — anything else becomes an
|
|
622
|
-
// unrecognized --resume arg that the CLI would reject with an error we'd
|
|
623
|
-
// have to parse. Safer to drop it silently and let ForgeAgent mint a fresh
|
|
624
|
-
// session (the user-visible outcome is just "no resume this turn").
|
|
625
|
-
const safeSessionId = typeof claudeSessionId === "string" && UUID_RE.test(claudeSessionId)
|
|
626
|
-
? claudeSessionId
|
|
627
|
-
: undefined;
|
|
628
|
-
|
|
629
|
-
const primary = parseRepo(req);
|
|
630
|
-
const agent = new ForgeAgent({
|
|
631
|
-
project: primary,
|
|
632
|
-
secondaryProjects: parseSecondaryRepos(req, primary),
|
|
633
|
-
agent: req.body.agent || "claude",
|
|
634
|
-
});
|
|
635
|
-
|
|
636
|
-
const jobId = jobStore.createJob("refine", (send, signal) =>
|
|
637
|
-
agent.refineIssue(
|
|
638
|
-
Number(iid),
|
|
639
|
-
previousChanges,
|
|
640
|
-
feedback?.trim() ?? "",
|
|
641
|
-
send,
|
|
642
|
-
undefined,
|
|
643
|
-
ciContext?.trim(),
|
|
644
|
-
validationState ?? null,
|
|
645
|
-
safeSessionId,
|
|
646
|
-
{ signal },
|
|
647
|
-
),
|
|
648
|
-
);
|
|
649
|
-
res.json({ jobId });
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
router.post("/issues/:iid/ci-fix", (req, res) => {
|
|
653
|
-
const { iid } = req.params;
|
|
654
|
-
const { ciContext, model } = req.body;
|
|
655
|
-
|
|
656
|
-
if (!ciContext?.trim()) {
|
|
657
|
-
return res.status(400).json({ error: "ciContext is required." });
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const primary = parseRepo(req);
|
|
661
|
-
const agent = new ForgeAgent({
|
|
662
|
-
project: primary,
|
|
663
|
-
secondaryProjects: parseSecondaryRepos(req, primary),
|
|
664
|
-
agent: req.body.agent || "claude",
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
const jobId = jobStore.createJob("ci-fix", (send, signal) =>
|
|
668
|
-
agent.ciFixIssue(Number(iid), ciContext.trim(), send, model, { signal }),
|
|
669
|
-
);
|
|
670
|
-
res.json({ jobId });
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
router.get("/jobs/:jobId/trace", async (req, res) => {
|
|
674
|
-
try {
|
|
675
|
-
const trace = await createProvider(parseRepo(req)).getJobTrace(Number(req.params.jobId));
|
|
676
|
-
res.json({ trace });
|
|
677
|
-
} catch (error) {
|
|
678
|
-
handleError(res, error, "getJobTrace");
|
|
679
|
-
}
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
// Snapshot a job's current status — used by the client on mount to decide
|
|
683
|
-
// whether to reattach (running), hydrate from localStorage (done), or clear
|
|
684
|
-
// a stale sessionStorage key (404).
|
|
685
|
-
router.get("/jobs/:jobId", (req, res) => {
|
|
686
|
-
const snap = jobStore.snapshot(req.params.jobId);
|
|
687
|
-
if (!snap) return res.status(404).json({ error: "Job not found." });
|
|
688
|
-
res.json(snap);
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
router.get("/jobs/:jobId/stream", (req, res) => {
|
|
692
|
-
const jobId = req.params.jobId;
|
|
693
|
-
if (!jobStore.getJob(jobId)) {
|
|
694
|
-
return res.status(404).json({ error: "Job not found." });
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
res.setHeader("Content-Type", "text/event-stream");
|
|
698
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
699
|
-
res.setHeader("Connection", "keep-alive");
|
|
700
|
-
res.flushHeaders();
|
|
701
|
-
|
|
702
|
-
let unsubscribe = () => {};
|
|
703
|
-
const write = (event) => {
|
|
704
|
-
if (res.writableEnded) return;
|
|
705
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
706
|
-
if (event.type === "done" || event.type === "error" || event.type === "cancelled") {
|
|
707
|
-
unsubscribe();
|
|
708
|
-
res.end();
|
|
709
|
-
}
|
|
710
|
-
};
|
|
711
|
-
|
|
712
|
-
const sub = jobStore.subscribe(jobId, write);
|
|
713
|
-
if (!sub) {
|
|
714
|
-
if (!res.writableEnded) res.end();
|
|
715
|
-
return;
|
|
716
|
-
}
|
|
717
|
-
unsubscribe = sub;
|
|
718
|
-
|
|
719
|
-
req.on("close", () => {
|
|
720
|
-
unsubscribe();
|
|
721
|
-
if (!res.writableEnded) res.end();
|
|
722
|
-
});
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
router.post("/jobs/:jobId/cancel", (req, res) => {
|
|
726
|
-
const { jobId } = req.params;
|
|
727
|
-
if (!jobStore.getJob(jobId)) {
|
|
728
|
-
return res.status(404).json({ error: "Job not found." });
|
|
729
|
-
}
|
|
730
|
-
const cancelled = jobStore.cancelJob(jobId);
|
|
731
|
-
res.json({ cancelled });
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
// ─── Apply fix ────────────────────────────────────────────────────────────────
|
|
735
|
-
|
|
736
|
-
// ─── Validate ─────────────────────────────────────────────────────────────────
|
|
737
|
-
// Pre-flight check: overlay the proposed changes on a cached clone of the
|
|
738
|
-
// target repo, run lint/typecheck/test, and report results. Nothing is pushed
|
|
739
|
-
// to GitLab — this is a sandbox check to prevent CI errors.
|
|
740
|
-
|
|
741
|
-
router.post("/validate", (req, res) => {
|
|
742
|
-
const { analysis } = req.body;
|
|
743
|
-
if (!analysis?.changes?.length) {
|
|
744
|
-
return res.status(400).json({ error: "analysis with changes is required." });
|
|
745
|
-
}
|
|
746
|
-
// Take the user's *current* repo selection — analysis.repos is a stale
|
|
747
|
-
// snapshot from when the analysis ran (the user may have toggled
|
|
748
|
-
// secondaries on/off since then).
|
|
749
|
-
const primary = parseRepo(req);
|
|
750
|
-
const secondaryRepos = parseSecondaryRepos(req, primary);
|
|
751
|
-
const repos = [primary, ...secondaryRepos].filter(Boolean);
|
|
752
|
-
const enrichedAnalysis = repos.length ? { ...analysis, repos } : analysis;
|
|
753
|
-
|
|
754
|
-
const jobId = jobStore.createJob(
|
|
755
|
-
"validate",
|
|
756
|
-
(send) => runValidation(enrichedAnalysis, send),
|
|
757
|
-
{ idPrefix: "val" },
|
|
758
|
-
);
|
|
759
|
-
res.json({ jobId });
|
|
760
|
-
});
|
|
761
|
-
|
|
762
|
-
router.post("/apply", async (req, res) => {
|
|
763
|
-
const { analysis } = req.body;
|
|
764
|
-
|
|
765
|
-
if (!analysis?.issue || !analysis?.changes?.length) {
|
|
766
|
-
return res.status(400).json({ error: "Invalid or missing analysis payload." });
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
try {
|
|
770
|
-
const primary = parseRepo(req);
|
|
771
|
-
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
772
|
-
const agent = new ForgeAgent({ project: primary, secondaryProjects });
|
|
773
|
-
const result = await agent.applyFix(analysis);
|
|
774
|
-
res.json(result);
|
|
775
|
-
} catch (error) {
|
|
776
|
-
handleError(res, error, "applyFix");
|
|
777
|
-
}
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
router.post("/commit-to-branch", async (req, res) => {
|
|
781
|
-
const { branchName, analysis, repo: changeRepo } = req.body;
|
|
782
|
-
|
|
783
|
-
if (!branchName?.trim()) {
|
|
784
|
-
return res.status(400).json({ error: "branchName is required." });
|
|
785
|
-
}
|
|
786
|
-
if (!analysis?.issue || !analysis?.changes?.length) {
|
|
787
|
-
return res.status(400).json({ error: "Invalid or missing analysis payload." });
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
try {
|
|
791
|
-
const primary = parseRepo(req);
|
|
792
|
-
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
793
|
-
const agent = new ForgeAgent({ project: primary, secondaryProjects });
|
|
794
|
-
const targetRepo = changeRepo && [primary, ...secondaryProjects].includes(changeRepo)
|
|
795
|
-
? changeRepo
|
|
796
|
-
: primary;
|
|
797
|
-
const result = await agent.commitToBranch(branchName.trim(), analysis, targetRepo);
|
|
798
|
-
res.json(result);
|
|
799
|
-
} catch (error) {
|
|
800
|
-
handleError(res, error, "commitToBranch");
|
|
801
|
-
}
|
|
802
|
-
});
|
|
803
|
-
|
|
804
|
-
router.post("/promote-branch", async (req, res) => {
|
|
805
|
-
const { branchName, repo: targetRepo, issueIid } = req.body;
|
|
806
|
-
|
|
807
|
-
if (!branchName?.trim()) {
|
|
808
|
-
return res.status(400).json({ error: "branchName is required." });
|
|
809
|
-
}
|
|
810
|
-
if (!Number.isFinite(Number(issueIid))) {
|
|
811
|
-
return res.status(400).json({ error: "issueIid is required." });
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
try {
|
|
815
|
-
const primary = parseRepo(req);
|
|
816
|
-
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
817
|
-
const agent = new ForgeAgent({ project: primary, secondaryProjects });
|
|
818
|
-
const result = await agent.promoteBranchToMR(
|
|
819
|
-
branchName.trim(),
|
|
820
|
-
targetRepo || primary,
|
|
821
|
-
Number(issueIid),
|
|
822
|
-
);
|
|
823
|
-
res.json(result);
|
|
824
|
-
} catch (error) {
|
|
825
|
-
handleError(res, error, "promoteBranchToMR");
|
|
826
|
-
}
|
|
827
|
-
});
|
|
828
|
-
|
|
829
|
-
router.post("/deploy-branch", async (req, res) => {
|
|
830
|
-
const { analysis } = req.body;
|
|
831
|
-
|
|
832
|
-
if (!analysis?.issue || !analysis?.changes?.length) {
|
|
833
|
-
return res.status(400).json({ error: "Invalid or missing analysis payload." });
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
try {
|
|
837
|
-
const primary = parseRepo(req);
|
|
838
|
-
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
839
|
-
const agent = new ForgeAgent({ project: primary, secondaryProjects });
|
|
840
|
-
const result = await agent.deployBranch(analysis);
|
|
841
|
-
res.json(result);
|
|
842
|
-
} catch (error) {
|
|
843
|
-
handleError(res, error, "deployBranch");
|
|
844
|
-
}
|
|
845
|
-
});
|
|
846
|
-
|
|
847
|
-
// ─── Pipeline status ──────────────────────────────────────────────────────────
|
|
848
|
-
|
|
849
|
-
router.get("/mr/:mrIid/pipeline", async (req, res) => {
|
|
850
|
-
try {
|
|
851
|
-
const provider = createProvider(parseRepo(req));
|
|
852
|
-
const pipelines = await provider.getMRPipelines(Number(req.params.mrIid));
|
|
853
|
-
|
|
854
|
-
if (!pipelines.length) {
|
|
855
|
-
return res.json({ pipeline: null, jobs: [] });
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
const latest = pipelines[0];
|
|
859
|
-
const [pipeline, jobs] = await Promise.all([
|
|
860
|
-
provider.getPipeline(latest.id),
|
|
861
|
-
provider.getPipelineJobs(latest.id),
|
|
862
|
-
]);
|
|
863
|
-
|
|
864
|
-
res.json({ pipeline, jobs });
|
|
865
|
-
} catch (error) {
|
|
866
|
-
handleError(res, error, "getPipelineStatus");
|
|
867
|
-
}
|
|
868
|
-
});
|
|
869
|
-
|
|
870
|
-
// Returns the latest branch pipeline (and its jobs) for any ref.
|
|
871
|
-
// Used by Deploy-to-Test, which creates a branch with no MR, so MR-scoped
|
|
872
|
-
// pipeline lookups don't apply.
|
|
873
|
-
router.get("/branch-pipeline", async (req, res) => {
|
|
874
|
-
try {
|
|
875
|
-
const branch = req.query.branch;
|
|
876
|
-
if (!branch) return res.status(400).json({ error: "branch is required" });
|
|
877
|
-
const provider = createProvider(parseRepo(req));
|
|
878
|
-
const pipelines = await provider.getPipelinesByRef(branch);
|
|
879
|
-
if (!pipelines.length) return res.json({ pipeline: null, jobs: [] });
|
|
880
|
-
const [pipeline, jobs] = await Promise.all([
|
|
881
|
-
provider.getPipeline(pipelines[0].id),
|
|
882
|
-
provider.getPipelineJobs(pipelines[0].id),
|
|
883
|
-
]);
|
|
884
|
-
res.json({ pipeline, jobs });
|
|
885
|
-
} catch (error) {
|
|
886
|
-
handleError(res, error, "getBranchPipeline");
|
|
887
|
-
}
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
// Lists Forge branches for an issue that aren't yet tied to an open or merged
|
|
891
|
-
// MR — i.e. Deploy-to-Test branches. Each entry carries the latest pipeline
|
|
892
|
-
// summary so the UI can surface CI status without a separate round-trip.
|
|
893
|
-
router.get("/issues/:iid/deploy-branches", async (req, res) => {
|
|
894
|
-
try {
|
|
895
|
-
const iid = Number(req.params.iid);
|
|
896
|
-
const provider = createProvider(parseRepo(req));
|
|
897
|
-
const [branches, openMrs, mergedMrs] = await Promise.all([
|
|
898
|
-
provider.listBranchesByPrefix(`forge/issue-${iid}-`),
|
|
899
|
-
provider.listForgeMRsForIssue(iid),
|
|
900
|
-
provider.listMergedForgeMRsForIssue(iid),
|
|
901
|
-
]);
|
|
902
|
-
const mrBranches = new Set(
|
|
903
|
-
[...openMrs, ...mergedMrs].map((m) => m.source_branch).filter(Boolean),
|
|
904
|
-
);
|
|
905
|
-
const deployOnly = branches.filter((b) => !mrBranches.has(b));
|
|
906
|
-
|
|
907
|
-
const enriched = await Promise.all(
|
|
908
|
-
deployOnly.map(async (branchName) => {
|
|
909
|
-
try {
|
|
910
|
-
const pipelines = await provider.getPipelinesByRef(branchName);
|
|
911
|
-
const latest = pipelines[0] ?? null;
|
|
912
|
-
return {
|
|
913
|
-
branchName,
|
|
914
|
-
pipeline: latest
|
|
915
|
-
? { id: latest.id, status: latest.status, web_url: latest.web_url, created_at: latest.created_at }
|
|
916
|
-
: null,
|
|
917
|
-
};
|
|
918
|
-
} catch {
|
|
919
|
-
return { branchName, pipeline: null };
|
|
920
|
-
}
|
|
921
|
-
}),
|
|
922
|
-
);
|
|
923
|
-
// Newest first — branch names end in a timestamp so lexicographic = chronological.
|
|
924
|
-
enriched.sort((a, b) => b.branchName.localeCompare(a.branchName));
|
|
925
|
-
res.json({ branches: enriched });
|
|
926
|
-
} catch (error) {
|
|
927
|
-
handleError(res, error, "listDeployBranches");
|
|
928
|
-
}
|
|
929
|
-
});
|
|
930
|
-
|
|
931
|
-
// Ephemeral environments (review apps) attached to any forge/issue-:iid-*
|
|
932
|
-
// branch. Returns a flat list — each entry carries the branch it's tied to
|
|
933
|
-
// so the UI can colocate it with its pipeline.
|
|
934
|
-
router.get("/issues/:iid/environments", async (req, res) => {
|
|
935
|
-
try {
|
|
936
|
-
const iid = Number(req.params.iid);
|
|
937
|
-
const provider = createProvider(parseRepo(req));
|
|
938
|
-
const branches = await provider.listBranchesByPrefix(`forge/issue-${iid}-`).catch(() => []);
|
|
939
|
-
const all = [...branches, `forge/issue-${iid}`];
|
|
940
|
-
const envsByBranch = await Promise.all(
|
|
941
|
-
all.map(async (branch) => {
|
|
942
|
-
const envs = await provider.listEnvironmentsForBranch(branch).catch(() => []);
|
|
943
|
-
return envs.map((e) => ({ ...e, branchName: branch }));
|
|
944
|
-
}),
|
|
945
|
-
);
|
|
946
|
-
res.json({ environments: envsByBranch.flat() });
|
|
947
|
-
} catch (error) {
|
|
948
|
-
handleError(res, error, "listEnvironments");
|
|
949
|
-
}
|
|
950
|
-
});
|
|
951
|
-
|
|
952
|
-
router.get("/issues/:iid/post-merge-pipeline", async (req, res) => {
|
|
953
|
-
try {
|
|
954
|
-
const provider = createProvider(parseRepo(req));
|
|
955
|
-
const mrs = await provider.listMergedForgeMRsForIssue(Number(req.params.iid));
|
|
956
|
-
if (!mrs.length) return res.json({ pipeline: null, jobs: [] });
|
|
957
|
-
|
|
958
|
-
const latestMr = await provider.getMR(mrs[0].iid);
|
|
959
|
-
const sha = latestMr.merge_commit_sha;
|
|
960
|
-
if (!sha) return res.json({ pipeline: null, jobs: [] });
|
|
961
|
-
|
|
962
|
-
const pipelines = await provider.getPipelinesBySha(sha);
|
|
963
|
-
if (!pipelines.length) return res.json({ pipeline: null, jobs: [] });
|
|
964
|
-
|
|
965
|
-
const [pipeline, jobs] = await Promise.all([
|
|
966
|
-
provider.getPipeline(pipelines[0].id),
|
|
967
|
-
provider.getPipelineJobs(pipelines[0].id),
|
|
968
|
-
]);
|
|
969
|
-
res.json({ pipeline, jobs });
|
|
970
|
-
} catch (error) {
|
|
971
|
-
handleError(res, error, "getPostMergePipeline");
|
|
972
|
-
}
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
// ─── Resolution check ─────────────────────────────────────────────────────────
|
|
976
|
-
|
|
977
|
-
router.post("/issues/:iid/resolution-check", async (req, res) => {
|
|
978
|
-
try {
|
|
979
|
-
const primary = parseRepo(req);
|
|
980
|
-
const secondaryProjects = parseSecondaryRepos(req, primary);
|
|
981
|
-
const provider = createProvider(primary);
|
|
982
|
-
const mrs = await provider.listMergedForgeMRsForIssue(Number(req.params.iid));
|
|
983
|
-
if (!mrs.length) {
|
|
984
|
-
return res.status(404).json({ error: "No merged MR found for this issue." });
|
|
985
|
-
}
|
|
986
|
-
const agent = new ForgeAgent({ project: primary, secondaryProjects, agent: req.body.agent || "claude" });
|
|
987
|
-
const result = await agent.checkResolution(Number(req.params.iid), mrs[0].iid, primary);
|
|
988
|
-
res.json(result);
|
|
989
|
-
} catch (error) {
|
|
990
|
-
handleError(res, error, "checkResolution");
|
|
991
|
-
}
|
|
992
|
-
});
|
|
993
|
-
|
|
994
|
-
// ─── Rollout status ───────────────────────────────────────────────────────────
|
|
995
|
-
|
|
996
|
-
router.get("/rollout", async (_req, res) => {
|
|
997
|
-
try {
|
|
998
|
-
const deployments = await listDeployments("test");
|
|
999
|
-
res.json({ deployments });
|
|
1000
|
-
} catch (error) {
|
|
1001
|
-
handleError(res, error, "rollout");
|
|
1002
|
-
}
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
// ─── Quota probe ──────────────────────────────────────────────────────────────
|
|
1006
|
-
// User-triggered: hits the Anthropic Messages API once with a 1-token request
|
|
1007
|
-
// just to read `anthropic-ratelimit-*` response headers. All real Forge work
|
|
1008
|
-
// still runs through the `claude` CLI (subscription).
|
|
1009
|
-
|
|
1010
|
-
router.get("/quota", async (_req, res) => {
|
|
1011
|
-
try {
|
|
1012
|
-
const result = await probeQuota();
|
|
1013
|
-
res.json(result);
|
|
1014
|
-
} catch (error) {
|
|
1015
|
-
// Propagate the Anthropic status code if the probe itself failed,
|
|
1016
|
-
// but still return whatever rate-limit headers came back.
|
|
1017
|
-
const status = error?.status ?? 500;
|
|
1018
|
-
res.status(status).json({
|
|
1019
|
-
error: error?.message ?? "Quota probe failed",
|
|
1020
|
-
rateLimit: error?.rateLimit ?? null,
|
|
1021
|
-
operation: "quota",
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
export default router;
|