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