@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
@@ -0,0 +1,995 @@
1
+ /**
2
+ * server/forge/core/ForgeAgent.ts
3
+ *
4
+ * Orchestrates the full Forge pipeline across one or more repositories.
5
+ *
6
+ * analyzeIssue → identify files across all repos → fetch contents → propose fix
7
+ * applyFix → group changes by repo → per repo: branch + commit + MR
8
+ * refineIssue → re-run pass 2 with feedback; reuses prior file paths per-repo
9
+ * ciFixIssue → target fix at CI failure logs
10
+ *
11
+ * Progress is reported via an optional onProgress callback so that
12
+ * the calling route can stream SSE events to the client.
13
+ *
14
+ * Multi-repo model
15
+ * ────────────────
16
+ * - `primaryProject` is the repo where the issue lives.
17
+ * - `secondaryProjects` are additional repos the fix can span (full-stack).
18
+ * - The AI sees every repo's file tree in pass 1 and tags files with repo id.
19
+ * - In pass 2 it proposes changes tagged with the repo they target.
20
+ * - `applyFix` groups by repo and creates one branch + MR per repo.
21
+ */
22
+ import { randomUUID } from "node:crypto";
23
+ import { checkResolution, identifyRelevantFiles, proposeFix, refineOrAsk } from "./agentClient.js";
24
+ import { branchNameFor } from "./branchName.js";
25
+ import * as configStore from "./configStore.js";
26
+ import { ensureWorkingTree, overlayChanges } from "./localRepo.js";
27
+ import { classifyIssue } from "./prompts.js";
28
+ import { createProvider } from "./providers/index.js";
29
+ import { reattributeChanges } from "./validator/attribute.js";
30
+ /**
31
+ * Whether Refine should give the model real Read/Glob/Grep access to a local
32
+ * checkout of the primary repo. Primary control is the Settings UI flag
33
+ * (configStore.defaults.refineRepoAccess, default true). The
34
+ * FORGE_REFINE_REPO_ACCESS env var, when set, overrides the config — useful
35
+ * for forcing it on/off in CI or dev without touching the persisted config.
36
+ * Only honours GitLab primaries at runtime (matches validator/localRepo).
37
+ */
38
+ function refineRepoAccessEnabled() {
39
+ const raw = process.env.FORGE_REFINE_REPO_ACCESS;
40
+ if (raw != null) {
41
+ const v = raw.trim().toLowerCase();
42
+ return !(v === "0" || v === "false" || v === "off" || v === "no");
43
+ }
44
+ return configStore.get().defaults?.refineRepoAccess ?? true;
45
+ }
46
+ function supportsLocalClone(repo) {
47
+ const colon = repo.indexOf(":");
48
+ if (colon <= 0)
49
+ return true;
50
+ return repo.slice(0, colon).toLowerCase() === "gitlab";
51
+ }
52
+ /**
53
+ * Heuristic: does this CLI error look like a missing/corrupt Claude session?
54
+ * We can't pattern-match on an exact string because the CLI wording drifts
55
+ * between versions — as of today it emits "No conversation found with session
56
+ * ID: <uuid>" for a missing resume, but older/newer builds have said "session
57
+ * not found" / "Unable to resume" / "expired". Match on session-specific
58
+ * vocabulary only so we don't fall back on unrelated failures like "Unknown
59
+ * model".
60
+ */
61
+ function isLikelyResumeFailure(err) {
62
+ const msg = String((err instanceof Error ? err.message : err) ?? "").toLowerCase();
63
+ return /session|resume|conversation|expired/.test(msg);
64
+ }
65
+ // ─── Types (JSDoc) ────────────────────────────────────────────────────────────
66
+ /**
67
+ * @typedef {{ type: 'progress', step: string, message: string }} ProgressEvent
68
+ * @typedef {{ type: 'log', message: string }} LogEvent
69
+ * @typedef {{ type: 'result', data: AnalysisResult }} ResultEvent
70
+ * @typedef {{ type: 'error', message: string }} ErrorEvent
71
+ *
72
+ * @typedef {{
73
+ * repo: string,
74
+ * file: string,
75
+ * action: 'create'|'modify'|'delete',
76
+ * proposed: string,
77
+ * original: string|null,
78
+ * summary: string
79
+ * }} ForgeChange
80
+ *
81
+ * @typedef {{
82
+ * issue: object,
83
+ * mrTitle?: string,
84
+ * explanation: string,
85
+ * reasoning: string,
86
+ * changes: ForgeChange[],
87
+ * repos: string[]
88
+ * }} AnalysisResult
89
+ *
90
+ * @typedef {{
91
+ * repo: string,
92
+ * branchName: string,
93
+ * mr: object|null,
94
+ * pipelineUrl: string|null
95
+ * }} ApplicationResult
96
+ */
97
+ const MAX_FILE_BYTES = 40_000; // ~40 KB per file to keep the context window healthy
98
+ const CONV_PREFIX = /^(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(?:\([^)]+\))?!?:\s*/i;
99
+ const FILE_CAPS = {
100
+ feature: { total: 18, perRepo: 15 },
101
+ refactor: { total: 18, perRepo: 15 },
102
+ fix: { total: 12, perRepo: 8 },
103
+ chore: { total: 12, perRepo: 8 },
104
+ };
105
+ /**
106
+ * Builds a conventional-commit subject line from the analysis. Prefers
107
+ * `analysis.mrTitle` (the model's own commit-style summary), falling back to
108
+ * the issue title with a prefix derived from the issue kind.
109
+ */
110
+ function buildCommitSubject(analysis) {
111
+ const mrTitle = analysis.mrTitle?.trim();
112
+ if (mrTitle)
113
+ return mrTitle;
114
+ const kind = classifyIssue(analysis.issue);
115
+ const prefix = kind === "feature" ? "feat" : kind === "refactor" ? "refactor" : "fix";
116
+ return `${prefix}: ${analysis.issue.title.replace(CONV_PREFIX, "")}`;
117
+ }
118
+ /**
119
+ * Assembles the full commit message: subject + per-file change list + trailers.
120
+ * Trailers let external tooling (and humans) identify Forge commits without
121
+ * parsing the subject line.
122
+ */
123
+ function buildCommitMessage(subject, changes, analysis, mode) {
124
+ const body = changes.map((c) => `- ${c.file}: ${c.summary}`).join("\n");
125
+ const trailers = [
126
+ `Forge-Issue: #${analysis.issue.iid}`,
127
+ `Forge-Mode: ${mode}`,
128
+ ].join("\n");
129
+ return `${subject}\n\n${body}\n\n${trailers}`;
130
+ }
131
+ /**
132
+ * Returns the progress-step copy for a given analysis kind.
133
+ *
134
+ * "fresh" — first analysis for an issue
135
+ * "new" — rerunning analysis after discarding a stored result
136
+ * "update" — re-analyzing because an MR already exists
137
+ * "retry" — retrying because a merged MR didn't actually resolve the issue
138
+ *
139
+ * Keeping the messaging here (not inlined) means each phase gets its own
140
+ * voice without the analyze function turning into a switch statement.
141
+ *
142
+ * @param {"fresh"|"new"|"update"|"retry"} kind
143
+ * @param {number} iid
144
+ * @param {string} agentLabel
145
+ * @param {string} [repoLine] Extra " (repos: …)" suffix appended to the intro line.
146
+ */
147
+ function buildStepMessages(kind, iid, agentLabel, repoLine = "") {
148
+ const files = (n) => `${n} file${n !== 1 ? "s" : ""}`;
149
+ switch (kind) {
150
+ case "new":
151
+ return {
152
+ intro: `Discarding stored analysis — starting fresh for issue #${iid}${repoLine}`,
153
+ fetch_issue: `Reloading issue #${iid} for a clean slate...`,
154
+ scan_repo: "Rescanning repository from scratch...",
155
+ identify_files: "Re-evaluating which files matter this time...",
156
+ fetch_files: (n) => `Re-pulling ${files(n)} at current main...`,
157
+ generate_fix: `Drafting a new fix with ${agentLabel}...`,
158
+ };
159
+ case "update":
160
+ return {
161
+ intro: `Updating existing MR for issue #${iid}${repoLine}`,
162
+ fetch_issue: `Loading issue #${iid} to update the open MR...`,
163
+ scan_repo: "Checking repository for drift since the last analysis...",
164
+ identify_files: "Selecting files that still need changes...",
165
+ fetch_files: (n) => `Loading ${files(n)} for the revised proposal...`,
166
+ generate_fix: `Revising the fix with ${agentLabel}...`,
167
+ };
168
+ case "retry":
169
+ return {
170
+ intro: `Retrying analysis for issue #${iid} — prior fix left it unresolved${repoLine}`,
171
+ fetch_issue: `Reloading issue #${iid} for a follow-up attempt...`,
172
+ scan_repo: "Rescanning repository with what we learned...",
173
+ identify_files: "Looking for files the first attempt missed...",
174
+ fetch_files: (n) => `Loading ${files(n)} for the retry...`,
175
+ generate_fix: `Attempting a different fix with ${agentLabel}...`,
176
+ };
177
+ case "fresh":
178
+ default:
179
+ return {
180
+ intro: `Analysis started for issue #${iid}${repoLine}`,
181
+ fetch_issue: `Fetching issue #${iid}...`,
182
+ scan_repo: "Scanning repository...",
183
+ identify_files: "Identifying relevant files...",
184
+ fetch_files: (n) => `Loading ${files(n)}...`,
185
+ generate_fix: `Generating fix with ${agentLabel}...`,
186
+ };
187
+ }
188
+ }
189
+ export class ForgeAgent {
190
+ primaryProject;
191
+ secondaryProjects;
192
+ allRepos;
193
+ agent;
194
+ providers;
195
+ provider;
196
+ constructor(opts = {}) {
197
+ const primary = opts.project ?? configStore.getPrimaryProject();
198
+ if (!primary)
199
+ throw new Error("ForgeAgent requires a primary project.");
200
+ const secondaries = (opts.secondaryProjects ?? [])
201
+ .filter((p) => !!p && p !== primary);
202
+ this.primaryProject = primary;
203
+ this.secondaryProjects = [...new Set(secondaries)];
204
+ this.allRepos = [this.primaryProject, ...this.secondaryProjects];
205
+ this.agent = opts.agent ?? "claude";
206
+ this.providers = new Map();
207
+ for (const repo of this.allRepos) {
208
+ this.providers.set(repo, createProvider(repo));
209
+ }
210
+ // Back-compat: `this.provider` is the primary repo's provider.
211
+ this.provider = this.providers.get(this.primaryProject);
212
+ }
213
+ _provider(repo) {
214
+ const p = this.providers.get(repo);
215
+ if (!p)
216
+ throw new Error(`ForgeAgent: no provider configured for repo "${repo}"`);
217
+ return p;
218
+ }
219
+ get _agentLabel() {
220
+ return this.agent === "openclaw" ? "OpenClaw" : "Claude";
221
+ }
222
+ get _isMultiRepo() {
223
+ return this.allRepos.length > 1;
224
+ }
225
+ // ─── Shared helpers ────────────────────────────────────────────────────────
226
+ async _fetchAllTrees(emit) {
227
+ const results = await Promise.all(this.allRepos.map(async (repo) => {
228
+ try {
229
+ const files = await this._provider(repo).getFileTree("", "main");
230
+ return { repo, files };
231
+ }
232
+ catch (err) {
233
+ const message = err instanceof Error ? err.message : String(err);
234
+ emit({ type: "log", message: `Could not scan ${repo}: ${message}` });
235
+ return { repo, files: [] };
236
+ }
237
+ }));
238
+ return results;
239
+ }
240
+ async _fetchFiles(entries, emit) {
241
+ const byRepo = new Map();
242
+ for (const entry of entries) {
243
+ const list = byRepo.get(entry.repo) ?? [];
244
+ list.push(entry.path);
245
+ byRepo.set(entry.repo, list);
246
+ }
247
+ const all = await Promise.all([...byRepo.entries()].map(async ([repo, paths]) => {
248
+ const provider = this._provider(repo);
249
+ return Promise.all(paths.map(async (filePath) => {
250
+ try {
251
+ const content = await provider.getFileContent(filePath, "main");
252
+ const truncated = content.length > MAX_FILE_BYTES;
253
+ const body = truncated
254
+ ? content.slice(0, MAX_FILE_BYTES) + "\n// [truncated — file exceeds size limit]"
255
+ : content;
256
+ const sizeKB = (content.length / 1024).toFixed(1);
257
+ emit({ type: "log", message: `Loaded ${repo}:${filePath} (${sizeKB} KB${truncated ? ", truncated" : ""})` });
258
+ return { repo, path: filePath, content: body };
259
+ }
260
+ catch (err) {
261
+ const message = err instanceof Error ? err.message : String(err);
262
+ emit({ type: "log", message: `Skipped ${repo}:${filePath}: ${message}` });
263
+ return null;
264
+ }
265
+ }));
266
+ }));
267
+ return all.flat().filter((f) => f != null);
268
+ }
269
+ /**
270
+ * Normalises raw file entries coming back from the model.
271
+ * The schema now asks for `{ repo, path }` objects, but older prompts /
272
+ * smaller models occasionally return plain strings — default those to the
273
+ * primary repo.
274
+ */
275
+ _normalizeFileSelections(rawEntries) {
276
+ return (rawEntries ?? [])
277
+ .map((entry) => {
278
+ if (!entry)
279
+ return null;
280
+ if (typeof entry === "string")
281
+ return { repo: this.primaryProject, path: entry };
282
+ const obj = entry;
283
+ if (obj.path && obj.repo)
284
+ return { repo: String(obj.repo), path: String(obj.path) };
285
+ if (obj.path)
286
+ return { repo: this.primaryProject, path: String(obj.path) };
287
+ return null;
288
+ })
289
+ .filter((e) => e != null);
290
+ }
291
+ _validateSelections(entries, repoTrees) {
292
+ const treeSet = new Map(repoTrees.map((t) => [t.repo, new Set(t.files)]));
293
+ const valid = [];
294
+ const skipped = [];
295
+ const seen = new Set();
296
+ for (const { repo, path } of entries) {
297
+ const key = `${repo}::${path}`;
298
+ if (seen.has(key))
299
+ continue;
300
+ seen.add(key);
301
+ const set = treeSet.get(repo);
302
+ if (!set) {
303
+ skipped.push(`${repo}:${path} (unknown repo)`);
304
+ continue;
305
+ }
306
+ if (!set.has(path)) {
307
+ skipped.push(`${repo}:${path} (not in tree)`);
308
+ continue;
309
+ }
310
+ valid.push({ repo, path });
311
+ }
312
+ return { valid, skipped };
313
+ }
314
+ /** Enforces the per-repo and total caps, preserving model order. */
315
+ _capSelections(entries, issueKind) {
316
+ const caps = FILE_CAPS[issueKind] ?? FILE_CAPS.fix;
317
+ const capped = [];
318
+ const perRepo = new Map();
319
+ for (const entry of entries) {
320
+ if (capped.length >= caps.total)
321
+ break;
322
+ const count = perRepo.get(entry.repo) ?? 0;
323
+ if (count >= caps.perRepo)
324
+ continue;
325
+ capped.push(entry);
326
+ perRepo.set(entry.repo, count + 1);
327
+ }
328
+ return capped;
329
+ }
330
+ _normalizeChanges(rawChanges, fileContents) {
331
+ return (rawChanges ?? []).map((change) => {
332
+ const repo = change.repo && this.allRepos.includes(change.repo)
333
+ ? change.repo
334
+ : this.primaryProject;
335
+ const original = fileContents.find((f) => f.repo === repo && f.path === change.file)?.content ?? null;
336
+ return { ...change, repo, original };
337
+ });
338
+ }
339
+ // ─── Analyze ───────────────────────────────────────────────────────────────
340
+ /**
341
+ * Runs the full analysis pipeline for an issue across all configured repos.
342
+ *
343
+ * @param {number} issueIid
344
+ * @param {(event: ProgressEvent|LogEvent|ResultEvent|ErrorEvent) => void} [onProgress]
345
+ * @param {string} [model]
346
+ * @param {string} [ciContext]
347
+ * @param {string} [userPrompt]
348
+ * @returns {Promise<AnalysisResult>}
349
+ */
350
+ async analyzeIssue(issueIid, onProgress, model, ciContext, userPrompt, kind = "fresh", opts = {}, previousChanges = null) {
351
+ const emit = onProgress ?? (() => { });
352
+ const { signal } = opts;
353
+ const repoLine = this._isMultiRepo
354
+ ? ` (repos: ${this.allRepos.join(", ")})`
355
+ : "";
356
+ const msgs = buildStepMessages(kind, issueIid, this._agentLabel, repoLine);
357
+ emit({ type: "log", message: msgs.intro });
358
+ // Step 1 — fetch the issue from the primary repo
359
+ emit({ type: "progress", step: "fetch_issue", message: msgs.fetch_issue });
360
+ const issue = await this.provider.getIssue(issueIid);
361
+ // Step 2 — scan all repos in parallel
362
+ emit({
363
+ type: "progress",
364
+ step: "scan_repo",
365
+ message: this._isMultiRepo
366
+ ? `Scanning ${this.allRepos.length} repositories...`
367
+ : msgs.scan_repo,
368
+ });
369
+ const repoTrees = await this._fetchAllTrees(emit);
370
+ for (const { repo, files } of repoTrees) {
371
+ emit({ type: "log", message: `${repo}: ${files.length} file${files.length !== 1 ? "s" : ""} in tree` });
372
+ }
373
+ // Step 3 — identify relevant files (Claude pass 1)
374
+ emit({ type: "progress", step: "identify_files", message: msgs.identify_files });
375
+ const ident = await identifyRelevantFiles(issue, repoTrees, model, this.agent, { signal });
376
+ const { files: rawFiles, reasoning = "", cost: cost1 = 0, serviceTier, inputTokens: it1 = 0, outputTokens: ot1 = 0, cacheCreationTokens: cc1 = 0, cacheReadTokens: cr1 = 0, } = ident;
377
+ const issueKind = classifyIssue(issue);
378
+ emit({ type: "log", message: `Issue classified as: ${issueKind}` });
379
+ const normalized = this._normalizeFileSelections(rawFiles);
380
+ const { valid, skipped } = this._validateSelections(normalized, repoTrees);
381
+ const capped = this._capSelections(valid, issueKind);
382
+ if (capped.length) {
383
+ emit({ type: "log", message: `Identified ${capped.length} file(s): ${capped.map((f) => `${f.repo}:${f.path}`).join(", ")}` });
384
+ }
385
+ else {
386
+ emit({ type: "log", message: "No files identified — the model returned no valid selections." });
387
+ }
388
+ if (skipped.length)
389
+ emit({ type: "log", message: `Skipped: ${skipped.join(", ")}` });
390
+ if (reasoning)
391
+ emit({ type: "log", message: `Reasoning: ${reasoning}` });
392
+ // Step 4 — fetch file contents across repos
393
+ emit({
394
+ type: "progress",
395
+ step: "fetch_files",
396
+ message: msgs.fetch_files(capped.length),
397
+ });
398
+ const fileContents = await this._fetchFiles(capped, emit);
399
+ // Step 5 — propose fix (pass 2)
400
+ emit({ type: "progress", step: "generate_fix", message: msgs.generate_fix });
401
+ const promptRules = configStore.get().defaults?.promptRules || "";
402
+ const propose = await proposeFix(issue, fileContents, { ciContext, userPrompt, previousChanges, repos: this.allRepos, promptRules }, model, this.agent, { signal });
403
+ const { title: mrTitle, explanation, changes, removals = [], cost: cost2 = 0, inputTokens: it2 = 0, outputTokens: ot2 = 0, cacheCreationTokens: cc2 = 0, cacheReadTokens: cr2 = 0, rateLimit: rl2 = null, } = propose;
404
+ const enrichedChanges = this._normalizeChanges(changes ?? [], fileContents);
405
+ emit({
406
+ type: "log",
407
+ message: `Proposed ${enrichedChanges.length} change(s): ${enrichedChanges.map((c) => `${c.repo}:${c.file} (${c.action})`).join(", ")}`,
408
+ });
409
+ if (removals.length > 0) {
410
+ emit({
411
+ type: "log",
412
+ message: `⚠ ${removals.length} removal(s) declared: ${removals.map((r) => `${r.file}: ${r.what}`).join("; ")}`,
413
+ });
414
+ }
415
+ const result = {
416
+ issue,
417
+ mrTitle,
418
+ explanation,
419
+ reasoning,
420
+ changes: enrichedChanges,
421
+ removals,
422
+ repos: this.allRepos,
423
+ };
424
+ emit({ type: "result", data: result });
425
+ emit({
426
+ type: "usage",
427
+ cost: cost1 + cost2,
428
+ serviceTier: serviceTier ?? "unknown",
429
+ inputTokens: it1 + it2,
430
+ outputTokens: ot1 + ot2,
431
+ cacheCreationTokens: cc1 + cc2,
432
+ cacheReadTokens: cr1 + cr2,
433
+ // Most recent rate-limit snapshot wins (pass 2 is last).
434
+ rateLimit: rl2 ?? ident.rateLimit ?? null,
435
+ });
436
+ return result;
437
+ }
438
+ // ─── Refine ────────────────────────────────────────────────────────────────
439
+ /**
440
+ * Re-runs pass 2 with reviewer feedback and the prior proposed changes as
441
+ * additional context. Reuses the previous files (per-repo) — no new file
442
+ * discovery.
443
+ */
444
+ async refineIssue(issueIid, previousChanges, feedback, onProgress, model, ciContext, validationState, claudeSessionId, opts = {}) {
445
+ const emit = onProgress ?? (() => { });
446
+ const { signal } = opts;
447
+ // Session continuity: only the user-feedback refine path on Claude carries
448
+ // a multi-turn session. CI-fix refines route through proposeFix (stateless)
449
+ // and OpenClaw has no session API. Generate a fresh UUID for turn 1 so we
450
+ // own the id from the start and can --resume it on turn 2+.
451
+ const useSession = this.agent === "claude" && !ciContext;
452
+ const resumeSession = Boolean(useSession && claudeSessionId);
453
+ const sessionId = useSession ? (claudeSessionId || randomUUID()) : null;
454
+ emit({ type: "progress", step: "fetch_issue", message: `Fetching issue #${issueIid}...` });
455
+ const issue = await this.provider.getIssue(issueIid);
456
+ // Pull the unique (repo, path) pairs from the previous changes.
457
+ const uniq = new Map();
458
+ for (const c of previousChanges ?? []) {
459
+ const repo = c.repo && this.allRepos.includes(c.repo) ? c.repo : this.primaryProject;
460
+ const key = `${repo}::${c.file}`;
461
+ if (!uniq.has(key))
462
+ uniq.set(key, { repo, path: c.file });
463
+ }
464
+ const selections = [...uniq.values()];
465
+ emit({
466
+ type: "progress",
467
+ step: "fetch_files",
468
+ message: `Loading ${selections.length} file${selections.length !== 1 ? "s" : ""}...`,
469
+ });
470
+ const fileContents = await this._fetchFiles(selections, emit);
471
+ // CI-context refines are always directive (the failure logs describe what
472
+ // broke), so skip classification and go straight through proposeFix. This
473
+ // keeps the CI-fix loop tight and avoids an "answer" being emitted when
474
+ // the caller expects fresh changes.
475
+ if (ciContext) {
476
+ emit({ type: "progress", step: "generate_fix", message: `Fixing CI failure with ${this._agentLabel}...` });
477
+ const ciRefineRules = configStore.get().defaults?.promptRules || "";
478
+ const propose = await proposeFix(issue, fileContents, { feedback, previousChanges, ciContext, repos: this.allRepos, promptRules: ciRefineRules }, model, this.agent, { signal });
479
+ return this._emitRefineResult(propose, fileContents, issue, emit, "ci-fix");
480
+ }
481
+ // Give the refine model access to the full primary repo: prepare a local
482
+ // clone with previousChanges overlaid and hand Claude Read/Glob/Grep via
483
+ // cwd. Falls back to the lighter "file tree only" path when disabled,
484
+ // when using a non-Claude agent, or when the primary repo isn't GitLab
485
+ // (validator/localRepo are GitLab-only for now).
486
+ let repoCwd = null;
487
+ let repoTrees = null;
488
+ const canUseLocalClone = refineRepoAccessEnabled()
489
+ && this.agent !== "openclaw"
490
+ && supportsLocalClone(this.primaryProject);
491
+ if (canUseLocalClone) {
492
+ try {
493
+ emit({ type: "progress", step: "prepare_repo", message: `Preparing local checkout of ${this.primaryProject}...` });
494
+ const branchName = branchNameFor(issue.iid, { issue });
495
+ const dir = await ensureWorkingTree(this.primaryProject, branchName, (msg) => emit({ type: "log", message: msg }));
496
+ const primaryChanges = (previousChanges ?? []).filter((c) => (c.repo ?? this.primaryProject) === this.primaryProject);
497
+ if (primaryChanges.length) {
498
+ await overlayChanges(dir, primaryChanges, (msg) => emit({ type: "log", message: msg }));
499
+ }
500
+ repoCwd = dir;
501
+ emit({ type: "log", message: `Refine session has Read/Glob/Grep access at ${dir}` });
502
+ }
503
+ catch (err) {
504
+ const message = err instanceof Error ? err.message : String(err);
505
+ emit({ type: "log", message: `Could not prepare local checkout (${message}); falling back to file-tree mode.` });
506
+ repoCwd = null;
507
+ }
508
+ }
509
+ if (!repoCwd) {
510
+ try {
511
+ repoTrees = await this._fetchAllTrees(emit);
512
+ }
513
+ catch (err) {
514
+ const message = err instanceof Error ? err.message : String(err);
515
+ emit({ type: "log", message: `Could not scan repo trees: ${message}` });
516
+ }
517
+ }
518
+ emit({ type: "progress", step: "generate_fix", message: `Refining fix with ${this._agentLabel}...` });
519
+ if (sessionId)
520
+ emit({ type: "session", sessionId, resumed: resumeSession });
521
+ const refinePromptRules = configStore.get().defaults?.promptRules || "";
522
+ const callRefine = (sid, resume) => refineOrAsk(issue, fileContents, {
523
+ feedback,
524
+ previousChanges,
525
+ validationState,
526
+ repos: this.allRepos,
527
+ repoCwd,
528
+ repoTrees,
529
+ repoAccessMode: repoCwd ? "tools" : "tree",
530
+ promptRules: refinePromptRules,
531
+ ...(sid ? { sessionId: sid, resumeSession: resume } : {}),
532
+ }, model, this.agent, (evt) => emit(evt), { signal });
533
+ let activeSessionId = sessionId;
534
+ let response;
535
+ try {
536
+ response = await callRefine(activeSessionId, resumeSession);
537
+ }
538
+ catch (err) {
539
+ // Resume failures (session pruned by the CLI, format drift after an
540
+ // upgrade, crashed half-turn leaving the session unreadable) should
541
+ // not kill the user's refine. Regenerate the session, tell the
542
+ // frontend to adopt the new id, and retry once with the full prompt.
543
+ if (resumeSession && isLikelyResumeFailure(err)) {
544
+ const fresh = randomUUID();
545
+ const errMsg = err instanceof Error ? err.message : String(err);
546
+ emit({ type: "log", message: `Could not resume Claude session ${activeSessionId} (${errMsg}). Starting fresh session ${fresh}.` });
547
+ activeSessionId = fresh;
548
+ emit({ type: "session", sessionId: fresh, resumed: false });
549
+ response = await callRefine(fresh, false);
550
+ }
551
+ else {
552
+ throw err;
553
+ }
554
+ }
555
+ // CLI sometimes forks on resume and returns a different session id than
556
+ // the one we passed. Re-emit so the frontend persists the authoritative
557
+ // id — otherwise next refine would --resume a stale handle.
558
+ if (response?.sessionId && response.sessionId !== activeSessionId) {
559
+ emit({ type: "log", message: `Claude session forked: ${activeSessionId} → ${response.sessionId}` });
560
+ activeSessionId = response.sessionId;
561
+ emit({ type: "session", sessionId: activeSessionId, resumed: false });
562
+ }
563
+ const { kind, answer, cost = 0, serviceTier, inputTokens = 0, outputTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0, rateLimit = null, } = response;
564
+ const usageEvent = {
565
+ type: "usage",
566
+ cost,
567
+ serviceTier: serviceTier ?? "unknown",
568
+ inputTokens,
569
+ outputTokens,
570
+ cacheCreationTokens,
571
+ cacheReadTokens,
572
+ rateLimit,
573
+ };
574
+ if (kind === "answer") {
575
+ const text = answer?.trim() || "(no answer produced)";
576
+ emit({ type: "log", message: "Refine classified as question — responding without changing code." });
577
+ emit({ type: "answer", text });
578
+ emit(usageEvent);
579
+ return { kind: "answer", answer: text };
580
+ }
581
+ // kind === "refine" (default to this if the model omitted kind but gave changes)
582
+ return this._emitRefineResult({ ...response, title: response.title, explanation: answer }, fileContents, issue, emit, "refine");
583
+ }
584
+ /**
585
+ * Shared tail for both ciFix-style proposeFix results and directive refine
586
+ * results: normalises changes, emits the result + usage events, and returns
587
+ * the AnalysisResult so callers can still await it.
588
+ */
589
+ _emitRefineResult(propose, fileContents, issue, emit, label) {
590
+ const { title: mrTitle, explanation, changes, cost = 0, serviceTier, inputTokens = 0, outputTokens = 0, cacheCreationTokens = 0, cacheReadTokens = 0, rateLimit = null, } = propose;
591
+ const enrichedChanges = this._normalizeChanges(changes ?? [], fileContents);
592
+ emit({
593
+ type: "log",
594
+ message: `${label === "ci-fix" ? "CI fix" : "Refined"} to ${enrichedChanges.length} change(s): ${enrichedChanges.map((c) => `${c.repo}:${c.file} (${c.action})`).join(", ")}`,
595
+ });
596
+ const result = {
597
+ issue,
598
+ mrTitle,
599
+ explanation: explanation ?? "",
600
+ reasoning: "",
601
+ changes: enrichedChanges,
602
+ repos: this.allRepos,
603
+ };
604
+ emit({ type: "result", data: result });
605
+ emit({
606
+ type: "usage",
607
+ cost,
608
+ serviceTier: serviceTier ?? "unknown",
609
+ inputTokens,
610
+ outputTokens,
611
+ cacheCreationTokens,
612
+ cacheReadTokens,
613
+ rateLimit,
614
+ });
615
+ return result;
616
+ }
617
+ // ─── CI Fix ────────────────────────────────────────────────────────────────
618
+ /**
619
+ * Targeted fix for a CI pipeline failure. Uses the CI logs as the primary
620
+ * signal for file selection instead of the issue description.
621
+ */
622
+ async ciFixIssue(issueIid, ciContext, onProgress, model, opts = {}) {
623
+ const emit = onProgress ?? (() => { });
624
+ const { signal } = opts;
625
+ emit({ type: "log", message: `CI fix started for issue #${issueIid}` });
626
+ emit({ type: "progress", step: "fetch_issue", message: `Fetching issue #${issueIid}...` });
627
+ const issue = await this.provider.getIssue(issueIid);
628
+ emit({ type: "progress", step: "scan_repo", message: "Scanning repositories..." });
629
+ const repoTrees = await this._fetchAllTrees(emit);
630
+ emit({ type: "progress", step: "identify_files", message: "Identifying files from CI failure..." });
631
+ const ciIssue = { ...issue, description: `CI pipeline failure:\n\n${ciContext}` };
632
+ const ident = await identifyRelevantFiles(ciIssue, repoTrees, model, this.agent, { signal });
633
+ const { files: rawFiles, cost: cost1 = 0, serviceTier, inputTokens: it1 = 0, outputTokens: ot1 = 0, cacheCreationTokens: cc1 = 0, cacheReadTokens: cr1 = 0, } = ident;
634
+ const { valid } = this._validateSelections(this._normalizeFileSelections(rawFiles), repoTrees);
635
+ const capped = this._capSelections(valid, "fix");
636
+ emit({
637
+ type: "progress",
638
+ step: "fetch_files",
639
+ message: `Loading ${capped.length} file${capped.length !== 1 ? "s" : ""}...`,
640
+ });
641
+ const fileContents = await this._fetchFiles(capped, emit);
642
+ emit({ type: "progress", step: "generate_fix", message: `Fixing CI failure with ${this._agentLabel}...` });
643
+ const ciPromptRules = configStore.get().defaults?.promptRules || "";
644
+ const propose = await proposeFix(issue, fileContents, { ciContext, repos: this.allRepos, promptRules: ciPromptRules }, model, this.agent, { signal });
645
+ const { title: mrTitle, explanation, changes, cost: cost2 = 0, inputTokens: it2 = 0, outputTokens: ot2 = 0, cacheCreationTokens: cc2 = 0, cacheReadTokens: cr2 = 0, rateLimit: rl2 = null, } = propose;
646
+ const enrichedChanges = this._normalizeChanges(changes ?? [], fileContents);
647
+ emit({
648
+ type: "log",
649
+ message: `Proposed ${enrichedChanges.length} change(s): ${enrichedChanges.map((c) => `${c.repo}:${c.file} (${c.action})`).join(", ")}`,
650
+ });
651
+ const result = {
652
+ issue,
653
+ mrTitle,
654
+ explanation,
655
+ reasoning: "",
656
+ changes: enrichedChanges,
657
+ repos: this.allRepos,
658
+ };
659
+ emit({ type: "result", data: result });
660
+ emit({
661
+ type: "usage",
662
+ cost: cost1 + cost2,
663
+ serviceTier: serviceTier ?? "unknown",
664
+ inputTokens: it1 + it2,
665
+ outputTokens: ot1 + ot2,
666
+ cacheCreationTokens: cc1 + cc2,
667
+ cacheReadTokens: cr1 + cr2,
668
+ rateLimit: rl2 ?? ident.rateLimit ?? null,
669
+ });
670
+ return result;
671
+ }
672
+ // ─── Resolution check ──────────────────────────────────────────────────────
673
+ /**
674
+ * Asks the model whether the diffs in a merged MR actually fix the issue.
675
+ * The issue lives in the primary repo; the MR is fetched from whichever
676
+ * provider created it.
677
+ */
678
+ async checkResolution(issueIid, mrIid, repo = this.primaryProject) {
679
+ const provider = this._provider(repo);
680
+ const [issue, diffs] = await Promise.all([
681
+ this.provider.getIssue(issueIid),
682
+ provider.getMRDiffs(mrIid),
683
+ ]);
684
+ return checkResolution(issue, diffs, undefined, this.agent, {});
685
+ }
686
+ // ─── Apply ─────────────────────────────────────────────────────────────────
687
+ /**
688
+ * Applies an approved analysis: creates one branch + commit + MR per repo
689
+ * that has changes. Primary repo is always processed first so its MR exists
690
+ * before any secondary MRs that cross-reference it.
691
+ *
692
+ * @param {AnalysisResult} analysis
693
+ * @returns {Promise<{ applications: ApplicationResult[] }>}
694
+ */
695
+ async applyFix(analysis) {
696
+ // The model's `c.repo` is occasionally wrong (a `.go` file routed to a
697
+ // Node primary by the normalize fallback). Reattribute against the SCM
698
+ // API before grouping so we never try to commit a file to a repo that
699
+ // doesn't have it.
700
+ const corrected = await this._correctAttribution(analysis.changes);
701
+ const grouped = this._groupChangesByRepo(corrected);
702
+ if (grouped.size === 0) {
703
+ throw new Error("No changes to apply.");
704
+ }
705
+ const ordered = this._orderedRepos(grouped);
706
+ const applications = [];
707
+ for (const repo of ordered) {
708
+ const changes = grouped.get(repo);
709
+ const app = await this._applyToRepo(repo, changes, analysis, applications);
710
+ applications.push(app);
711
+ }
712
+ return { applications };
713
+ }
714
+ /**
715
+ * Pushes a new commit to an existing branch in a specific repo (e.g. to fix
716
+ * a failing pipeline without creating a new MR). Only affects the one repo.
717
+ *
718
+ * @param {string} branchName
719
+ * @param {AnalysisResult} analysis
720
+ * @param {string} [repo] Defaults to primary.
721
+ */
722
+ async commitToBranch(branchName, analysis, repo = this.primaryProject) {
723
+ const corrected = await this._correctAttribution(analysis.changes);
724
+ const grouped = this._groupChangesByRepo(corrected);
725
+ const changes = grouped.get(repo);
726
+ if (!changes?.length) {
727
+ throw new Error(`No changes for repo "${repo}" to commit.`);
728
+ }
729
+ const provider = this._provider(repo);
730
+ // Rebase onto latest main before pushing so CI runs against current code.
731
+ try {
732
+ const mrs = await provider.listAllForgeMRs();
733
+ const mr = mrs.find((m) => m.source_branch === branchName);
734
+ if (mr && provider.supportsRebase) {
735
+ await provider.rebaseMR(mr.iid);
736
+ }
737
+ }
738
+ catch { /* rebase failure is non-fatal — push will still work */ }
739
+ const actions = changes.map((c) => ({ action: c.action, file_path: c.file, content: c.proposed ?? "" }));
740
+ const subject = buildCommitSubject(analysis);
741
+ const commitMessage = buildCommitMessage(subject, changes, analysis, "ci-fix");
742
+ await provider.commitFiles(branchName, commitMessage, actions);
743
+ return { repo, branchName };
744
+ }
745
+ /**
746
+ * Opens an MR for a branch that was previously pushed via deployBranch.
747
+ * Used when a test-deploy pipeline goes green and the user wants to promote
748
+ * the change to a real MR.
749
+ *
750
+ * @param {string} branchName
751
+ * @param {string} repo
752
+ * @param {number} issueIid
753
+ * @returns {Promise<ApplicationResult>}
754
+ */
755
+ async promoteBranchToMR(branchName, repo, issueIid) {
756
+ const targetRepo = repo && this.allRepos.includes(repo) ? repo : this.primaryProject;
757
+ const provider = this._provider(targetRepo);
758
+ const isPrimary = targetRepo === this.primaryProject;
759
+ // Refuse to open an MR that has nothing to merge. `compareRefs` returns
760
+ // `null` on providers that can't answer, which we treat as "allowed" so
761
+ // we don't block GitHub/Azure until they implement it.
762
+ const cmp = await provider.compareRefs("main", branchName);
763
+ if (cmp && cmp.aheadCount === 0) {
764
+ throw new Error(`Branch \`${branchName}\` has no commits ahead of main — nothing to promote.`);
765
+ }
766
+ const issue = await this._provider(this.primaryProject).getIssue(issueIid);
767
+ const rawTitle = issue.title.replace(CONV_PREFIX, "");
768
+ const subject = `fix: ${rawTitle}`;
769
+ const mrTitle = this._isMultiRepo
770
+ ? `${subject} [${this._repoShortLabel(targetRepo)}]`
771
+ : subject;
772
+ const linkedIssue = isPrimary
773
+ ? `Closes #${issueIid}`
774
+ : `Relates to ${this.primaryProject}#${issueIid}`;
775
+ const description = `## 🎯 Summary
776
+
777
+ Promoted to MR from test-deploy branch \`${branchName}\` after the branch pipeline went green.
778
+
779
+ ${linkedIssue}
780
+
781
+ ---
782
+
783
+ _Opened by Forge._`;
784
+ const mr = await this._ensureMR(provider, {
785
+ sourceBranch: branchName,
786
+ targetBranch: "main",
787
+ title: mrTitle,
788
+ description,
789
+ labels: ["forge", `forge:issue-${issueIid}`],
790
+ });
791
+ let pipelineUrl = null;
792
+ try {
793
+ const pipelines = await provider.getMRPipelines(mr.iid);
794
+ pipelineUrl = pipelines[0]?.web_url ?? null;
795
+ }
796
+ catch {
797
+ // Pipeline lookup is best-effort.
798
+ }
799
+ return { repo: targetRepo, branchName, mr, pipelineUrl };
800
+ }
801
+ /**
802
+ * Pushes a fix as a new branch per repo without opening MRs. Used for the
803
+ * "Deploy to Test" flow where a branch pipeline deploys to a test namespace.
804
+ *
805
+ * @param {AnalysisResult} analysis
806
+ * @returns {Promise<{ applications: ApplicationResult[] }>}
807
+ */
808
+ async deployBranch(analysis) {
809
+ const corrected = await this._correctAttribution(analysis.changes);
810
+ const grouped = this._groupChangesByRepo(corrected);
811
+ if (grouped.size === 0)
812
+ throw new Error("No changes to deploy.");
813
+ const ordered = this._orderedRepos(grouped);
814
+ const applications = [];
815
+ const branchName = branchNameFor(analysis.issue.iid, analysis);
816
+ for (const repo of ordered) {
817
+ const changes = grouped.get(repo);
818
+ const provider = this._provider(repo);
819
+ await this._ensureBranch(provider, branchName, "main");
820
+ const actions = changes.map((c) => ({ action: c.action, file_path: c.file, content: c.proposed ?? "" }));
821
+ const subject = buildCommitSubject(analysis);
822
+ const commitMessage = buildCommitMessage(subject, changes, analysis, "deploy-to-test");
823
+ await provider.commitFiles(branchName, commitMessage, actions);
824
+ let pipelineUrl = null;
825
+ try {
826
+ const pipelines = await provider.getPipelinesByRef(branchName);
827
+ pipelineUrl = pipelines[0]?.web_url ?? null;
828
+ }
829
+ catch {
830
+ // Pipeline may not have kicked off yet — non-fatal.
831
+ }
832
+ applications.push({ repo, branchName, mr: null, pipelineUrl });
833
+ }
834
+ return { applications };
835
+ }
836
+ // ─── Private apply helpers ─────────────────────────────────────────────────
837
+ /**
838
+ * Resolves each change's `repo` against the SCM (does the file actually
839
+ * exist there?) so we don't try to commit to the wrong repo when the
840
+ * model omitted or guessed `c.repo` wrong. Mirror of the validator's
841
+ * auto-attribution; returns a flat changes array with corrected repo.
842
+ * Falls back to the input on attribution failure so we never block apply
843
+ * on a flaky API call.
844
+ */
845
+ async _correctAttribution(changes) {
846
+ try {
847
+ const { changes: corrected, unattributed } = await reattributeChanges(changes ?? [], this.allRepos);
848
+ if (unattributed.length) {
849
+ console.warn(`[forge] Dropping ${unattributed.length} unattributable change(s):`, unattributed.map((c) => c.file).join(", "));
850
+ }
851
+ return corrected;
852
+ }
853
+ catch (err) {
854
+ const message = err instanceof Error ? err.message : String(err);
855
+ console.warn("[forge] Attribution lookup failed, using model-supplied repo:", message);
856
+ return changes ?? [];
857
+ }
858
+ }
859
+ _groupChangesByRepo(changes) {
860
+ const grouped = new Map();
861
+ for (const change of changes ?? []) {
862
+ const repo = change.repo && this.allRepos.includes(change.repo)
863
+ ? change.repo
864
+ : this.primaryProject;
865
+ const list = grouped.get(repo) ?? [];
866
+ list.push(change);
867
+ grouped.set(repo, list);
868
+ }
869
+ return grouped;
870
+ }
871
+ /** Primary always first, secondaries after in allRepos order. */
872
+ _orderedRepos(grouped) {
873
+ const present = new Set(grouped.keys());
874
+ return this.allRepos.filter((r) => present.has(r));
875
+ }
876
+ async _applyToRepo(repo, changes, analysis, priorApplications) {
877
+ const provider = this._provider(repo);
878
+ const { issue, explanation } = analysis;
879
+ const branchName = branchNameFor(issue.iid, analysis);
880
+ await this._ensureBranch(provider, branchName, "main");
881
+ const actions = changes.map((c) => ({ action: c.action, file_path: c.file, content: c.proposed ?? "" }));
882
+ const subject = buildCommitSubject(analysis);
883
+ const commitMessage = buildCommitMessage(subject, changes, analysis, "mr");
884
+ await provider.commitFiles(branchName, commitMessage, actions);
885
+ const isPrimary = repo === this.primaryProject;
886
+ const mrTitle = this._isMultiRepo
887
+ ? `${subject} [${this._repoShortLabel(repo)}]`
888
+ : subject;
889
+ const mrDescription = this._buildMRDescription(issue, explanation, changes, {
890
+ isPrimary,
891
+ repo,
892
+ priorApplications,
893
+ });
894
+ const mr = await this._ensureMR(provider, {
895
+ sourceBranch: branchName,
896
+ targetBranch: "main",
897
+ title: mrTitle,
898
+ description: mrDescription,
899
+ labels: ["forge", `forge:issue-${issue.iid}`],
900
+ });
901
+ let pipelineUrl = null;
902
+ try {
903
+ const pipelines = await provider.getMRPipelines(mr.iid);
904
+ pipelineUrl = pipelines[0]?.web_url ?? null;
905
+ }
906
+ catch {
907
+ // Pipeline may not have kicked off yet — non-fatal.
908
+ }
909
+ return { repo, branchName, mr, pipelineUrl };
910
+ }
911
+ /**
912
+ * Creates a branch, tolerating the case where it already exists — the whole
913
+ * point of intent-based names is that multiple applies/refines reuse the
914
+ * same branch. GitLab returns 400 "Branch already exists"; other providers
915
+ * may use different strings, so we match loosely.
916
+ */
917
+ async _ensureBranch(provider, branchName, ref) {
918
+ try {
919
+ return await provider.createBranch(branchName, ref);
920
+ }
921
+ catch (err) {
922
+ const message = err instanceof Error ? err.message : String(err);
923
+ if (/already exists/i.test(message))
924
+ return null;
925
+ throw err;
926
+ }
927
+ }
928
+ /**
929
+ * Opens an MR for the given branch, or returns the existing open one if
930
+ * the user is re-applying to a branch that already has an MR attached.
931
+ * Source-branch match is exact; label updates are intentionally skipped on
932
+ * the reuse path to keep this idempotent.
933
+ */
934
+ async _ensureMR(provider, mrSpec) {
935
+ try {
936
+ return await provider.createMR(mrSpec);
937
+ }
938
+ catch (err) {
939
+ const message = err instanceof Error ? err.message : String(err);
940
+ if (!/already|exist/i.test(message))
941
+ throw err;
942
+ const open = await provider.listAllForgeMRs();
943
+ const match = open.find((mr) => mr.source_branch === mrSpec.sourceBranch);
944
+ if (match)
945
+ return match;
946
+ throw err;
947
+ }
948
+ }
949
+ _repoShortLabel(repo) {
950
+ return repo.split("/").pop() || repo;
951
+ }
952
+ _buildMRDescription(issue, explanation, changes, opts = {}) {
953
+ const { isPrimary = true, repo = this.primaryProject, priorApplications = [] } = opts;
954
+ const changeList = changes
955
+ .map((c) => `- **${c.file}** (${c.action}): ${c.summary}`)
956
+ .join("\n");
957
+ const linkedIssue = isPrimary
958
+ ? `Closes #${issue.iid}`
959
+ : `Relates to ${this.primaryProject}#${issue.iid}`;
960
+ let companionBlock = "";
961
+ if (this._isMultiRepo) {
962
+ const companions = priorApplications
963
+ .filter((a) => a.mr && a.repo !== repo)
964
+ .map((a) => `- [${a.repo}!${a.mr.iid}](${a.mr.web_url}) — ${this._repoShortLabel(a.repo)}`);
965
+ const heading = "## 🔗 Companion MRs";
966
+ if (companions.length) {
967
+ companionBlock = `\n\n${heading}\n\nThis is part of a full-stack change spanning multiple repositories.\n\n${companions.join("\n")}`;
968
+ }
969
+ else {
970
+ companionBlock = `\n\n${heading}\n\nThis is part of a full-stack change — companion MRs will be posted in other repos.`;
971
+ }
972
+ }
973
+ return `## 🎯 Summary
974
+
975
+ Automated fix for ${this.primaryProject}#${issue.iid} generated by Forge.
976
+
977
+ ${explanation}
978
+
979
+ ${linkedIssue}${companionBlock}
980
+
981
+ ## ✨ Changes (${this._repoShortLabel(repo)})
982
+
983
+ ${changeList}
984
+
985
+ ## 🧪 Testing
986
+
987
+ - ⏳ Automated CI pipeline
988
+ - ⏳ Manual review of diff before merge
989
+
990
+ ---
991
+
992
+ _Generated by [Forge](https://iplayulisten.com/admin/forge)_`;
993
+ }
994
+ }
995
+ //# sourceMappingURL=ForgeAgent.js.map