@ipul-labs/forge 1.16.8 → 1.17.1

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