@ipul-labs/forge 1.13.0

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