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