@ipul-labs/forge 1.13.0

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