@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.
- package/README.md +273 -0
- package/bin/README.md +210 -0
- package/bin/cli/analyze.js +516 -0
- package/bin/cli/apply.js +264 -0
- package/bin/cli/cifix.js +300 -0
- package/bin/cli/close.js +125 -0
- package/bin/cli/doctor.js +161 -0
- package/bin/cli/init.js +306 -0
- package/bin/cli/ink.js +57 -0
- package/bin/cli/issue.js +116 -0
- package/bin/cli/issues.js +155 -0
- package/bin/cli/merge.js +175 -0
- package/bin/cli/refine.js +253 -0
- package/bin/cli/repos.js +91 -0
- package/bin/cli/resolution.js +126 -0
- package/bin/cli/session.js +230 -0
- package/bin/cli/sessionStore.js +214 -0
- package/bin/cli/ship.js +762 -0
- package/bin/cli/utils.js +145 -0
- package/bin/cli/validate.js +280 -0
- package/bin/cli/watch.js +274 -0
- package/bin/forge-agent.js +174 -0
- package/dist/assets/index-CNTCMbW9.js +80 -0
- package/dist/assets/index-InpYS-zg.css +1 -0
- package/dist/favicon.svg +8 -0
- package/dist/index.html +19 -0
- package/dist-lib/favicon.svg +8 -0
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/style.css +1 -0
- package/dist-lib/ui.d.ts +2 -0
- package/dist-lib/ui.js +7390 -0
- package/dist-lib/views/Forge/ForgeDashboard.d.ts +5 -0
- package/dist-lib/views/Forge/activeJobs.d.ts +29 -0
- package/dist-lib/views/Forge/analysisCache.d.ts +64 -0
- package/dist-lib/views/Forge/api.d.ts +4 -0
- package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +13 -0
- package/dist-lib/views/Forge/components/CITerminal.d.ts +44 -0
- package/dist-lib/views/Forge/components/DiffViewer.d.ts +37 -0
- package/dist-lib/views/Forge/components/ForgeConnect.d.ts +6 -0
- package/dist-lib/views/Forge/components/IssueList.d.ts +28 -0
- package/dist-lib/views/Forge/components/MRStatus.d.ts +9 -0
- package/dist-lib/views/Forge/components/NewTaskModal.d.ts +1 -0
- package/dist-lib/views/Forge/components/OpenMRList.d.ts +11 -0
- package/dist-lib/views/Forge/components/PipelineStatus.d.ts +12 -0
- package/dist-lib/views/Forge/components/PipelinesList.d.ts +9 -0
- package/dist-lib/views/Forge/components/RolloutStatus.d.ts +5 -0
- package/dist-lib/views/Forge/components/UsageWidget.d.ts +8 -0
- package/dist-lib/views/Forge/components/ValidationPanel.d.ts +22 -0
- package/dist-lib/views/Forge/context/ForgeProvider.d.ts +23 -0
- package/dist-lib/views/Forge/hooks/useConfig.d.ts +54 -0
- package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +107 -0
- package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +22 -0
- package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +1 -0
- package/dist-lib/views/Forge/phases/IdleView.d.ts +1 -0
- package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +1 -0
- package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +1 -0
- package/dist-lib/views/Forge/phases/MonitoringView.d.ts +1 -0
- package/dist-lib/views/Forge/phases/ReviewingView.d.ts +1 -0
- package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +11 -0
- package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +1 -0
- package/dist-lib/views/Forge/phases/SelectedView.d.ts +1 -0
- package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +1 -0
- package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +14 -0
- package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +9 -0
- package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +1 -0
- package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +13 -0
- package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +4 -0
- package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +1 -0
- package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +6 -0
- package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +16 -0
- package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +7 -0
- package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +7 -0
- package/dist-lib/views/Forge/settings/ReposSection.d.ts +7 -0
- package/dist-lib/views/Forge/settings/SettingsView.d.ts +1 -0
- package/dist-lib/views/Forge/shell/ConfigBar.d.ts +1 -0
- package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +9 -0
- package/dist-lib/views/Forge/shell/ForgeShell.d.ts +7 -0
- package/dist-lib/views/Forge/shell/Sidebar.d.ts +1 -0
- package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +1 -0
- package/dist-lib/views/Forge/shell/SidebarNav.d.ts +1 -0
- package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +10 -0
- package/dist-lib/views/Forge/shell/TopBar.d.ts +5 -0
- package/dist-lib/views/Forge/types.d.ts +235 -0
- package/dist-lib/views/Forge/utils.d.ts +11 -0
- package/package.json +88 -0
- package/server/forge/core/ForgeAgent.js +1144 -0
- package/server/forge/core/agentClient.js +36 -0
- package/server/forge/core/branchName.js +41 -0
- package/server/forge/core/claudeClient.js +356 -0
- package/server/forge/core/configStore.js +214 -0
- package/server/forge/core/gitlabClient.js +340 -0
- package/server/forge/core/jobs.js +180 -0
- package/server/forge/core/k8s.js +54 -0
- package/server/forge/core/localRepo.js +136 -0
- package/server/forge/core/openclawClient.js +125 -0
- package/server/forge/core/prompts.js +369 -0
- package/server/forge/core/providers/azuredevops.js +408 -0
- package/server/forge/core/providers/base.js +208 -0
- package/server/forge/core/providers/github.js +349 -0
- package/server/forge/core/providers/gitlab.js +529 -0
- package/server/forge/core/providers/index.js +47 -0
- package/server/forge/core/quotaClient.js +101 -0
- package/server/forge/core/validator/attribute.js +84 -0
- package/server/forge/core/validator/clone.js +91 -0
- package/server/forge/core/validator/cmd.js +98 -0
- package/server/forge/core/validator/fs.js +9 -0
- package/server/forge/core/validator/overlay.js +52 -0
- package/server/forge/core/validator/projectType.js +87 -0
- package/server/forge/core/validator/runners/go.js +107 -0
- package/server/forge/core/validator/runners/node.js +84 -0
- package/server/forge/core/validator/tdd.js +105 -0
- package/server/forge/core/validator.js +166 -0
- package/server/forge/routes.js +1012 -0
package/bin/cli/apply.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import React, { useEffect, useState } from "react";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { h, Box, Text, useApp, runApp, Confirm } from "./ink.js";
|
|
5
|
+
import { canonicalRepoId, getFlag, projectRoot } from "./utils.js";
|
|
6
|
+
import {
|
|
7
|
+
PHASES,
|
|
8
|
+
appendEvent,
|
|
9
|
+
readAnalysis,
|
|
10
|
+
readState,
|
|
11
|
+
resolveSessionTarget,
|
|
12
|
+
updateState,
|
|
13
|
+
} from "./sessionStore.js";
|
|
14
|
+
|
|
15
|
+
function changesByRepo(changes) {
|
|
16
|
+
const map = new Map();
|
|
17
|
+
for (const c of changes ?? []) {
|
|
18
|
+
const key = c.repo ?? "(unknown)";
|
|
19
|
+
if (!map.has(key)) map.set(key, []);
|
|
20
|
+
map.get(key).push(c);
|
|
21
|
+
}
|
|
22
|
+
return map;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function Summary({ analysis }) {
|
|
26
|
+
const grouped = changesByRepo(analysis.changes);
|
|
27
|
+
const total = analysis.changes?.length ?? 0;
|
|
28
|
+
return h`<${Box} flexDirection="column">
|
|
29
|
+
<${Text} dimColor>${"────────── apply plan ──────────"}</>
|
|
30
|
+
<${Box}>
|
|
31
|
+
<${Text} color="yellow">Issue: </>
|
|
32
|
+
<${Text}>#${analysis.issue?.iid}: ${analysis.issue?.title ?? ""}</>
|
|
33
|
+
</>
|
|
34
|
+
${analysis.mrTitle
|
|
35
|
+
? h`<${Box}>
|
|
36
|
+
<${Text} color="yellow">MR: </>
|
|
37
|
+
<${Text}>${analysis.mrTitle}</>
|
|
38
|
+
</>`
|
|
39
|
+
: null}
|
|
40
|
+
<${Box}>
|
|
41
|
+
<${Text} color="yellow">Total: </>
|
|
42
|
+
<${Text}>${`${total} change(s) across ${grouped.size} repo(s)`}</>
|
|
43
|
+
</>
|
|
44
|
+
<${Box} marginTop=${1}>
|
|
45
|
+
<${Text} dimColor>${"Per repo:"}</>
|
|
46
|
+
</>
|
|
47
|
+
${[...grouped.entries()].map(([repo, changes], i) =>
|
|
48
|
+
h`<${Box} flexDirection="column" key=${i} marginLeft=${2}>
|
|
49
|
+
<${Text} bold>${repo} (${changes.length})</>
|
|
50
|
+
${changes.map((c, j) =>
|
|
51
|
+
h`<${Text} key=${j} dimColor>${` ${c.action.padEnd(7)} ${c.file}`}</>`,
|
|
52
|
+
)}
|
|
53
|
+
</>`,
|
|
54
|
+
)}
|
|
55
|
+
</>`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ApplyView({ analysis, primary, secondaries, bypassYes, onDone }) {
|
|
59
|
+
const { exit } = useApp();
|
|
60
|
+
const [phase, setPhase] = useState("review"); // review → confirm → applying → done | aborted | error
|
|
61
|
+
const [applications, setApplications] = useState([]);
|
|
62
|
+
const [error, setError] = useState(null);
|
|
63
|
+
const [running, setRunning] = useState(null); // current repo being applied
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (phase !== "applying") return;
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
(async () => {
|
|
69
|
+
try {
|
|
70
|
+
const { ForgeAgent } = await import(
|
|
71
|
+
path.join(projectRoot, "server/forge/core/ForgeAgent.js")
|
|
72
|
+
);
|
|
73
|
+
const agent = new ForgeAgent({
|
|
74
|
+
project: primary,
|
|
75
|
+
secondaryProjects: secondaries,
|
|
76
|
+
});
|
|
77
|
+
// applyFix doesn't currently emit per-step events on its own —
|
|
78
|
+
// fall back to a coarse "applying repo X" indicator by inspecting
|
|
79
|
+
// the analysis.changes grouping ahead of the call.
|
|
80
|
+
const grouped = changesByRepo(analysis.changes);
|
|
81
|
+
const ordered = [primary, ...secondaries].filter((r) => grouped.has(r));
|
|
82
|
+
if (ordered.length === 0) {
|
|
83
|
+
// Fallback to whatever the analysis says
|
|
84
|
+
ordered.push(...grouped.keys());
|
|
85
|
+
}
|
|
86
|
+
setRunning(ordered[0] ?? null);
|
|
87
|
+
const result = await agent.applyFix(analysis);
|
|
88
|
+
if (cancelled) return;
|
|
89
|
+
setApplications(result.applications ?? []);
|
|
90
|
+
setRunning(null);
|
|
91
|
+
setPhase("done");
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (cancelled) return;
|
|
94
|
+
setError(err);
|
|
95
|
+
setRunning(null);
|
|
96
|
+
setPhase("error");
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
};
|
|
102
|
+
}, [phase]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (phase === "done" || phase === "aborted" || phase === "error") {
|
|
106
|
+
onDone({
|
|
107
|
+
phase,
|
|
108
|
+
applications,
|
|
109
|
+
error,
|
|
110
|
+
});
|
|
111
|
+
exit();
|
|
112
|
+
}
|
|
113
|
+
}, [phase]);
|
|
114
|
+
|
|
115
|
+
if (phase === "review" || phase === "confirm") {
|
|
116
|
+
return h`<${Box} flexDirection="column">
|
|
117
|
+
<${Summary} analysis=${analysis} />
|
|
118
|
+
<${Confirm}
|
|
119
|
+
message="Apply these changes (creates branch + opens MR per repo)?"
|
|
120
|
+
bypassYes=${bypassYes}
|
|
121
|
+
onSelect=${(yes) => setPhase(yes ? "applying" : "aborted")} />
|
|
122
|
+
</>`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (phase === "applying") {
|
|
126
|
+
return h`<${Box} flexDirection="column">
|
|
127
|
+
<${Summary} analysis=${analysis} />
|
|
128
|
+
<${Box} marginTop=${1}>
|
|
129
|
+
<${Text} color="cyan"><${Spinner} type="dots"/></>
|
|
130
|
+
<${Text}>${` Applying${running ? ` to ${running}` : "…"}`}</>
|
|
131
|
+
</>
|
|
132
|
+
</>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (phase === "done") {
|
|
136
|
+
return h`<${Box} flexDirection="column">
|
|
137
|
+
<${Summary} analysis=${analysis} />
|
|
138
|
+
<${Box} flexDirection="column" marginTop=${1}>
|
|
139
|
+
<${Text} color="green">${`✓ Applied across ${applications.length} repo(s)`}</>
|
|
140
|
+
${applications.map((a, i) =>
|
|
141
|
+
h`<${Box} key=${i} flexDirection="column" marginLeft=${2} marginTop=${1}>
|
|
142
|
+
<${Text} bold>${a.repo}</>
|
|
143
|
+
<${Text}>${` branch: ${a.branchName}`}</>
|
|
144
|
+
${a.mr?.web_url
|
|
145
|
+
? h`<${Text}>${` MR: ${a.mr.web_url}`}</>`
|
|
146
|
+
: a.mr?.iid
|
|
147
|
+
? h`<${Text}>${` MR: !${a.mr.iid}`}</>`
|
|
148
|
+
: h`<${Text} dimColor>${" MR: (not opened)"}</>`}
|
|
149
|
+
${a.pipelineUrl
|
|
150
|
+
? h`<${Text} dimColor>${` pipe: ${a.pipelineUrl}`}</>`
|
|
151
|
+
: null}
|
|
152
|
+
</>`,
|
|
153
|
+
)}
|
|
154
|
+
</>
|
|
155
|
+
</>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (phase === "aborted") {
|
|
159
|
+
return h`<${Box} flexDirection="column">
|
|
160
|
+
<${Summary} analysis=${analysis} />
|
|
161
|
+
<${Box} marginTop=${1}>
|
|
162
|
+
<${Text} dimColor>${"Aborted — no changes applied."}</>
|
|
163
|
+
</>
|
|
164
|
+
</>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return h`<${Box} flexDirection="column">
|
|
168
|
+
<${Summary} analysis=${analysis} />
|
|
169
|
+
<${Box} marginTop=${1}>
|
|
170
|
+
<${Text} color="red">${`✘ ${error?.message ?? String(error)}`}</>
|
|
171
|
+
</>
|
|
172
|
+
</>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function cmdApply(argv) {
|
|
176
|
+
const explicitIid = Number(argv[1]);
|
|
177
|
+
const explicitRepo = getFlag(argv, "--repo");
|
|
178
|
+
const target = await resolveSessionTarget({
|
|
179
|
+
explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
|
|
180
|
+
explicitRepo,
|
|
181
|
+
});
|
|
182
|
+
if (!target?.repo || !target?.iid) {
|
|
183
|
+
console.error(
|
|
184
|
+
"No session target. Pass an <iid>, set --repo, or run analyze first.",
|
|
185
|
+
);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
const analysis = await readAnalysis(target.repo, target.iid);
|
|
189
|
+
if (!analysis) {
|
|
190
|
+
console.error(
|
|
191
|
+
`No cached analysis at ${target.repo}#${target.iid}. Run: forge-agent analyze ${target.iid}`,
|
|
192
|
+
);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const state = (await readState(target.repo, target.iid)) ?? {};
|
|
197
|
+
const primary = state.primary ?? target.repo;
|
|
198
|
+
const secondaries = state.secondaries ?? [];
|
|
199
|
+
|
|
200
|
+
const bypassYes = argv.includes("--yes") || process.env.FORGE_YES === "1";
|
|
201
|
+
const wantValidate = argv.includes("--validate");
|
|
202
|
+
|
|
203
|
+
if (wantValidate) {
|
|
204
|
+
const { cmdValidate } = await import("./validate.js");
|
|
205
|
+
// Re-enter validate using the same session target. cmdValidate exits
|
|
206
|
+
// with code 2 on red — we trust that gating.
|
|
207
|
+
await cmdValidate(["validate", String(target.iid), "--repo", target.repo]);
|
|
208
|
+
// If validate failed it would have exited; we reach here only on green.
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let outcome = { phase: null };
|
|
212
|
+
await runApp(
|
|
213
|
+
h`<${ApplyView}
|
|
214
|
+
analysis=${analysis}
|
|
215
|
+
primary=${primary}
|
|
216
|
+
secondaries=${secondaries}
|
|
217
|
+
bypassYes=${bypassYes}
|
|
218
|
+
onDone=${(o) => (outcome = o)} />`,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
if (outcome.phase === "aborted") {
|
|
222
|
+
await updateState(target.repo, target.iid, {
|
|
223
|
+
phase: "apply-aborted",
|
|
224
|
+
});
|
|
225
|
+
await appendEvent(target.repo, target.iid, { type: "session:apply-aborted" });
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
if (outcome.phase === "error") {
|
|
229
|
+
await updateState(target.repo, target.iid, {
|
|
230
|
+
phase: "apply-failed",
|
|
231
|
+
lastError: outcome.error?.message ?? null,
|
|
232
|
+
});
|
|
233
|
+
await appendEvent(target.repo, target.iid, {
|
|
234
|
+
type: "session:apply-failed",
|
|
235
|
+
error: outcome.error?.message,
|
|
236
|
+
});
|
|
237
|
+
process.exit(2);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Done — record per-repo branch + MR. ApplyFix returns one ApplicationResult
|
|
241
|
+
// per repo; the primary's MR is the canonical session reference.
|
|
242
|
+
const primaryApp =
|
|
243
|
+
outcome.applications?.find(
|
|
244
|
+
(a) => canonicalRepoId(a.repo) === canonicalRepoId(primary),
|
|
245
|
+
) ?? outcome.applications?.[0];
|
|
246
|
+
await updateState(target.repo, target.iid, {
|
|
247
|
+
phase: PHASES.APPLIED,
|
|
248
|
+
branch: primaryApp?.branchName ?? null,
|
|
249
|
+
mrIid: primaryApp?.mr?.iid ?? null,
|
|
250
|
+
mrUrl: primaryApp?.mr?.web_url ?? null,
|
|
251
|
+
pipelineUrl: primaryApp?.pipelineUrl ?? null,
|
|
252
|
+
applications: outcome.applications ?? [],
|
|
253
|
+
lastError: null,
|
|
254
|
+
});
|
|
255
|
+
await appendEvent(target.repo, target.iid, {
|
|
256
|
+
type: "session:apply-done",
|
|
257
|
+
applications: (outcome.applications ?? []).map((a) => ({
|
|
258
|
+
repo: a.repo,
|
|
259
|
+
branchName: a.branchName,
|
|
260
|
+
mrIid: a.mr?.iid ?? null,
|
|
261
|
+
mrUrl: a.mr?.web_url ?? null,
|
|
262
|
+
})),
|
|
263
|
+
});
|
|
264
|
+
}
|
package/bin/cli/cifix.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import React, { useEffect, useState } from "react";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { h, Box, Text, useApp, runApp } from "./ink.js";
|
|
5
|
+
import { getFlag, projectRoot } from "./utils.js";
|
|
6
|
+
import {
|
|
7
|
+
PHASES,
|
|
8
|
+
appendEvent,
|
|
9
|
+
readAnalysis,
|
|
10
|
+
readState,
|
|
11
|
+
resolveSessionTarget,
|
|
12
|
+
updateState,
|
|
13
|
+
writeAnalysis,
|
|
14
|
+
} from "./sessionStore.js";
|
|
15
|
+
|
|
16
|
+
const TRACE_TAIL_LINES = 150; // matches the existing CI refinement pattern
|
|
17
|
+
|
|
18
|
+
async function fetchFailedJobTraces(provider, mrIid) {
|
|
19
|
+
const pipelines = await provider.getMRPipelines(mrIid);
|
|
20
|
+
if (!pipelines?.length) throw new Error(`No pipelines found for MR !${mrIid}`);
|
|
21
|
+
const latest = pipelines[0];
|
|
22
|
+
const jobs = await provider.getPipelineJobs(latest.id);
|
|
23
|
+
const failed = jobs.filter((j) => j.status === "failed");
|
|
24
|
+
if (!failed.length) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Latest pipeline #${latest.id} has no failed jobs (status: ${latest.status}). Nothing to fix.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const traces = await Promise.all(
|
|
30
|
+
failed.map(async (j) => {
|
|
31
|
+
const text = await provider.getJobTrace(j.id).catch(() => "(trace fetch failed)");
|
|
32
|
+
const tail = text.split("\n").slice(-TRACE_TAIL_LINES).join("\n");
|
|
33
|
+
return { name: j.name, stage: j.stage, tail };
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
return { pipeline: latest, traces };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function formatCiContext(traces) {
|
|
40
|
+
return traces
|
|
41
|
+
.map(
|
|
42
|
+
(t) =>
|
|
43
|
+
`### Failed job: ${t.name} (stage: ${t.stage})\n\n\`\`\`\n${t.tail}\n\`\`\``,
|
|
44
|
+
)
|
|
45
|
+
.join("\n\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function StepRow({ step, message, status }) {
|
|
49
|
+
let glyph;
|
|
50
|
+
if (status === "running")
|
|
51
|
+
glyph = h`<${Text} color="cyan"><${Spinner} type="dots"/></>`;
|
|
52
|
+
else if (status === "done") glyph = h`<${Text} color="green">✓</>`;
|
|
53
|
+
else glyph = h`<${Text} color="red">✘</>`;
|
|
54
|
+
return h`<${Box}>
|
|
55
|
+
${glyph}
|
|
56
|
+
<${Text} dimColor> [${step}]</>
|
|
57
|
+
<${Text}> ${message}</>
|
|
58
|
+
</>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function CiFixView({
|
|
62
|
+
repo,
|
|
63
|
+
iid,
|
|
64
|
+
primary,
|
|
65
|
+
secondaries,
|
|
66
|
+
branch,
|
|
67
|
+
mrIid,
|
|
68
|
+
model,
|
|
69
|
+
eventSink,
|
|
70
|
+
onDone,
|
|
71
|
+
}) {
|
|
72
|
+
const { exit } = useApp();
|
|
73
|
+
const [phase, setPhase] = useState("fetching"); // fetching → analyzing → committing → done | error
|
|
74
|
+
const [items, setItems] = useState([]);
|
|
75
|
+
const [traces, setTraces] = useState([]);
|
|
76
|
+
const [pipeline, setPipeline] = useState(null);
|
|
77
|
+
const [result, setResult] = useState(null);
|
|
78
|
+
const [error, setError] = useState(null);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (phase === "done" || phase === "error") {
|
|
82
|
+
onDone({ phase, result, pipeline, error });
|
|
83
|
+
exit();
|
|
84
|
+
}
|
|
85
|
+
}, [phase]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
(async () => {
|
|
90
|
+
try {
|
|
91
|
+
const { createProvider } = await import(
|
|
92
|
+
path.join(projectRoot, "server/forge/core/providers/index.js")
|
|
93
|
+
);
|
|
94
|
+
const provider = createProvider(repo);
|
|
95
|
+
const { pipeline: pipe, traces: tr } = await fetchFailedJobTraces(
|
|
96
|
+
provider,
|
|
97
|
+
mrIid,
|
|
98
|
+
);
|
|
99
|
+
if (cancelled) return;
|
|
100
|
+
setTraces(tr);
|
|
101
|
+
setPipeline(pipe);
|
|
102
|
+
setPhase("analyzing");
|
|
103
|
+
|
|
104
|
+
const ciContext = formatCiContext(tr);
|
|
105
|
+
|
|
106
|
+
const { ForgeAgent } = await import(
|
|
107
|
+
path.join(projectRoot, "server/forge/core/ForgeAgent.js")
|
|
108
|
+
);
|
|
109
|
+
const agent = new ForgeAgent({
|
|
110
|
+
project: primary,
|
|
111
|
+
secondaryProjects: secondaries,
|
|
112
|
+
});
|
|
113
|
+
let final = null;
|
|
114
|
+
|
|
115
|
+
const onEvent = (evt) => {
|
|
116
|
+
if (cancelled) return;
|
|
117
|
+
eventSink?.(evt);
|
|
118
|
+
if (evt.type === "result") {
|
|
119
|
+
final = evt.data;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
setItems((prev) => {
|
|
123
|
+
const closeRunning = (list) =>
|
|
124
|
+
list.map((it) =>
|
|
125
|
+
it.kind === "step" && it.status === "running"
|
|
126
|
+
? { ...it, status: "done" }
|
|
127
|
+
: it,
|
|
128
|
+
);
|
|
129
|
+
switch (evt.type) {
|
|
130
|
+
case "progress":
|
|
131
|
+
return [
|
|
132
|
+
...closeRunning(prev),
|
|
133
|
+
{
|
|
134
|
+
kind: "step",
|
|
135
|
+
step: evt.step,
|
|
136
|
+
message: evt.message,
|
|
137
|
+
status: "running",
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
case "log":
|
|
141
|
+
return [...prev, { kind: "log", text: evt.message }];
|
|
142
|
+
case "error":
|
|
143
|
+
return [
|
|
144
|
+
...prev.map((it) =>
|
|
145
|
+
it.kind === "step" && it.status === "running"
|
|
146
|
+
? { ...it, status: "failed" }
|
|
147
|
+
: it,
|
|
148
|
+
),
|
|
149
|
+
{ kind: "error", message: evt.message },
|
|
150
|
+
];
|
|
151
|
+
default:
|
|
152
|
+
return prev;
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
await agent.ciFixIssue(iid, ciContext, onEvent, model);
|
|
158
|
+
if (cancelled) return;
|
|
159
|
+
setItems((prev) =>
|
|
160
|
+
prev.map((it) =>
|
|
161
|
+
it.kind === "step" && it.status === "running"
|
|
162
|
+
? { ...it, status: "done" }
|
|
163
|
+
: it,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
if (!final) {
|
|
167
|
+
setError(new Error("CI fix produced no result."));
|
|
168
|
+
setPhase("error");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
setResult(final);
|
|
172
|
+
setPhase("committing");
|
|
173
|
+
|
|
174
|
+
await agent.commitToBranch(branch, final, primary);
|
|
175
|
+
if (cancelled) return;
|
|
176
|
+
setPhase("done");
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (cancelled) return;
|
|
179
|
+
setError(err);
|
|
180
|
+
setPhase("error");
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
return () => {
|
|
184
|
+
cancelled = true;
|
|
185
|
+
};
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
return h`<${Box} flexDirection="column">
|
|
189
|
+
<${Box}>
|
|
190
|
+
<${Text} color="yellow">CI fix </>
|
|
191
|
+
<${Text}>${repo} #${iid} → ${branch}</>
|
|
192
|
+
</>
|
|
193
|
+
${phase === "fetching"
|
|
194
|
+
? h`<${Box} marginTop=${1}>
|
|
195
|
+
<${Text} color="cyan"><${Spinner} type="dots"/></>
|
|
196
|
+
<${Text}>${` Fetching failed job traces from MR !${mrIid}…`}</>
|
|
197
|
+
</>`
|
|
198
|
+
: null}
|
|
199
|
+
${traces.length
|
|
200
|
+
? h`<${Box} flexDirection="column" marginTop=${1}>
|
|
201
|
+
<${Text} dimColor>${`Pipeline #${pipeline?.id} failed on:`}</>
|
|
202
|
+
${traces.map((t, i) =>
|
|
203
|
+
h`<${Text} key=${i} dimColor>${` • ${t.stage}/${t.name} (${t.tail.split("\n").length} log lines)`}</>`,
|
|
204
|
+
)}
|
|
205
|
+
</>`
|
|
206
|
+
: null}
|
|
207
|
+
${items.length
|
|
208
|
+
? h`<${Box} flexDirection="column" marginTop=${1}>
|
|
209
|
+
${items.map((it, i) => {
|
|
210
|
+
if (it.kind === "step")
|
|
211
|
+
return h`<${StepRow} key=${i} step=${it.step} message=${it.message} status=${it.status} />`;
|
|
212
|
+
if (it.kind === "log")
|
|
213
|
+
return h`<${Box} key=${i}><${Text} dimColor> ${it.text}</></>`;
|
|
214
|
+
return h`<${Box} key=${i}>
|
|
215
|
+
<${Text} color="red">ERROR</>
|
|
216
|
+
<${Text}>: ${it.message}</>
|
|
217
|
+
</>`;
|
|
218
|
+
})}
|
|
219
|
+
</>`
|
|
220
|
+
: null}
|
|
221
|
+
${phase === "committing"
|
|
222
|
+
? h`<${Box} marginTop=${1}>
|
|
223
|
+
<${Text} color="cyan"><${Spinner} type="dots"/></>
|
|
224
|
+
<${Text}>${` Pushing fix commit to ${branch}…`}</>
|
|
225
|
+
</>`
|
|
226
|
+
: null}
|
|
227
|
+
${phase === "done"
|
|
228
|
+
? h`<${Box} marginTop=${1}>
|
|
229
|
+
<${Text} color="green">${`✓ CI fix pushed to ${branch}. Re-run watch to follow the new pipeline.`}</>
|
|
230
|
+
</>`
|
|
231
|
+
: null}
|
|
232
|
+
${phase === "error"
|
|
233
|
+
? h`<${Box} marginTop=${1}>
|
|
234
|
+
<${Text} color="red">${`✘ ${error?.message ?? String(error)}`}</>
|
|
235
|
+
</>`
|
|
236
|
+
: null}
|
|
237
|
+
</>`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function cmdCiFix(argv) {
|
|
241
|
+
const explicitIid = Number(argv[1]);
|
|
242
|
+
const explicitRepo = getFlag(argv, "--repo");
|
|
243
|
+
const target = await resolveSessionTarget({
|
|
244
|
+
explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
|
|
245
|
+
explicitRepo,
|
|
246
|
+
});
|
|
247
|
+
if (!target?.repo || !target?.iid) {
|
|
248
|
+
console.error(
|
|
249
|
+
"No session target. Pass an <iid>, set --repo, or run analyze + apply first.",
|
|
250
|
+
);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
const state = await readState(target.repo, target.iid);
|
|
254
|
+
if (!state?.branch || !state?.mrIid) {
|
|
255
|
+
console.error(
|
|
256
|
+
`Session ${target.repo}#${target.iid} has no branch/MR yet. Run: forge-agent apply ${target.iid}`,
|
|
257
|
+
);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
const primary = state.primary ?? target.repo;
|
|
261
|
+
const secondaries = state.secondaries ?? [];
|
|
262
|
+
const model = getFlag(argv, "--model") || undefined;
|
|
263
|
+
|
|
264
|
+
let outcome = { phase: null };
|
|
265
|
+
const events = [];
|
|
266
|
+
await runApp(
|
|
267
|
+
h`<${CiFixView}
|
|
268
|
+
repo=${primary}
|
|
269
|
+
iid=${target.iid}
|
|
270
|
+
primary=${primary}
|
|
271
|
+
secondaries=${secondaries}
|
|
272
|
+
branch=${state.branch}
|
|
273
|
+
mrIid=${state.mrIid}
|
|
274
|
+
model=${model}
|
|
275
|
+
eventSink=${(e) => events.push(e)}
|
|
276
|
+
onDone=${(o) => (outcome = o)} />`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (outcome.phase === "done" && outcome.result) {
|
|
280
|
+
await writeAnalysis(target.repo, target.iid, outcome.result);
|
|
281
|
+
await updateState(target.repo, target.iid, {
|
|
282
|
+
phase: PHASES.APPLIED, // stays applied after a refresh commit
|
|
283
|
+
lastError: null,
|
|
284
|
+
});
|
|
285
|
+
await appendEvent(target.repo, target.iid, {
|
|
286
|
+
type: "session:cifix-done",
|
|
287
|
+
pipelineId: outcome.pipeline?.id,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
await updateState(target.repo, target.iid, {
|
|
292
|
+
phase: "cifix-failed",
|
|
293
|
+
lastError: outcome.error?.message ?? null,
|
|
294
|
+
}).catch(() => {});
|
|
295
|
+
await appendEvent(target.repo, target.iid, {
|
|
296
|
+
type: "session:cifix-failed",
|
|
297
|
+
error: outcome.error?.message,
|
|
298
|
+
}).catch(() => {});
|
|
299
|
+
process.exit(2);
|
|
300
|
+
}
|
package/bin/cli/close.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import React, { useEffect, useState } from "react";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { h, Box, Text, useApp, runApp, Confirm } from "./ink.js";
|
|
5
|
+
import { getFlag, projectRoot } from "./utils.js";
|
|
6
|
+
import {
|
|
7
|
+
PHASES,
|
|
8
|
+
appendEvent,
|
|
9
|
+
readState,
|
|
10
|
+
resolveSessionTarget,
|
|
11
|
+
updateState,
|
|
12
|
+
} from "./sessionStore.js";
|
|
13
|
+
|
|
14
|
+
function CloseView({ repo, iid, bypassYes, onDone }) {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const [phase, setPhase] = useState("review");
|
|
17
|
+
const [error, setError] = useState(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (phase === "done" || phase === "aborted" || phase === "error") {
|
|
21
|
+
onDone({ phase, error });
|
|
22
|
+
exit();
|
|
23
|
+
}
|
|
24
|
+
}, [phase]);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (phase !== "closing") return;
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
(async () => {
|
|
30
|
+
try {
|
|
31
|
+
const { createProvider } = await import(
|
|
32
|
+
path.join(projectRoot, "server/forge/core/providers/index.js")
|
|
33
|
+
);
|
|
34
|
+
const provider = createProvider(repo);
|
|
35
|
+
if (typeof provider.closeIssue !== "function") {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`${provider.name ?? "provider"} does not support closeIssue.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
await provider.closeIssue(iid);
|
|
41
|
+
if (cancelled) return;
|
|
42
|
+
setPhase("done");
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (cancelled) return;
|
|
45
|
+
setError(err);
|
|
46
|
+
setPhase("error");
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
return () => {
|
|
50
|
+
cancelled = true;
|
|
51
|
+
};
|
|
52
|
+
}, [phase]);
|
|
53
|
+
|
|
54
|
+
if (phase === "review" || phase === "confirm") {
|
|
55
|
+
return h`<${Box} flexDirection="column">
|
|
56
|
+
<${Box}>
|
|
57
|
+
<${Text} color="yellow">Issue </>
|
|
58
|
+
<${Text}>${repo} #${iid}</>
|
|
59
|
+
</>
|
|
60
|
+
<${Confirm}
|
|
61
|
+
message="Close this issue?"
|
|
62
|
+
bypassYes=${bypassYes}
|
|
63
|
+
onSelect=${(yes) => setPhase(yes ? "closing" : "aborted")} />
|
|
64
|
+
</>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (phase === "closing") {
|
|
68
|
+
return h`<${Box}>
|
|
69
|
+
<${Text} color="cyan"><${Spinner} type="dots"/></>
|
|
70
|
+
<${Text}> Closing issue…</>
|
|
71
|
+
</>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (phase === "done") {
|
|
75
|
+
return h`<${Text} color="green">${`✓ Closed #${iid}`}</>`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (phase === "aborted") {
|
|
79
|
+
return h`<${Text} dimColor>Aborted — issue still open.</>`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return h`<${Box}>
|
|
83
|
+
<${Text} color="red">${`✘ ${error?.message ?? String(error)}`}</>
|
|
84
|
+
</>`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function cmdClose(argv) {
|
|
88
|
+
const explicitIid = Number(argv[1]);
|
|
89
|
+
const explicitRepo = getFlag(argv, "--repo");
|
|
90
|
+
const bypassYes = argv.includes("--yes") || process.env.FORGE_YES === "1";
|
|
91
|
+
|
|
92
|
+
const target = await resolveSessionTarget({
|
|
93
|
+
explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
|
|
94
|
+
explicitRepo,
|
|
95
|
+
});
|
|
96
|
+
if (!target?.repo || !target?.iid) {
|
|
97
|
+
console.error(
|
|
98
|
+
"No session target. Pass an <iid>, set --repo, or run analyze first.",
|
|
99
|
+
);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const state = await readState(target.repo, target.iid);
|
|
103
|
+
const repo = state?.primary ?? target.repo;
|
|
104
|
+
|
|
105
|
+
let outcome = { phase: null };
|
|
106
|
+
await runApp(
|
|
107
|
+
h`<${CloseView}
|
|
108
|
+
repo=${repo}
|
|
109
|
+
iid=${target.iid}
|
|
110
|
+
bypassYes=${bypassYes}
|
|
111
|
+
onDone=${(o) => (outcome = o)} />`,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (outcome.phase === "done") {
|
|
115
|
+
await updateState(target.repo, target.iid, {
|
|
116
|
+
phase: PHASES.CLOSED,
|
|
117
|
+
closedAt: new Date().toISOString(),
|
|
118
|
+
}).catch(() => {});
|
|
119
|
+
await appendEvent(target.repo, target.iid, { type: "session:closed" }).catch(
|
|
120
|
+
() => {},
|
|
121
|
+
);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (outcome.phase === "error") process.exit(2);
|
|
125
|
+
}
|