@ipul-labs/forge 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +273 -0
  2. package/bin/README.md +210 -0
  3. package/bin/cli/analyze.js +516 -0
  4. package/bin/cli/apply.js +264 -0
  5. package/bin/cli/cifix.js +300 -0
  6. package/bin/cli/close.js +125 -0
  7. package/bin/cli/doctor.js +161 -0
  8. package/bin/cli/init.js +306 -0
  9. package/bin/cli/ink.js +57 -0
  10. package/bin/cli/issue.js +116 -0
  11. package/bin/cli/issues.js +155 -0
  12. package/bin/cli/merge.js +175 -0
  13. package/bin/cli/refine.js +253 -0
  14. package/bin/cli/repos.js +91 -0
  15. package/bin/cli/resolution.js +126 -0
  16. package/bin/cli/session.js +230 -0
  17. package/bin/cli/sessionStore.js +214 -0
  18. package/bin/cli/ship.js +762 -0
  19. package/bin/cli/utils.js +145 -0
  20. package/bin/cli/validate.js +280 -0
  21. package/bin/cli/watch.js +274 -0
  22. package/bin/forge-agent.js +174 -0
  23. package/dist/assets/index-CNTCMbW9.js +80 -0
  24. package/dist/assets/index-InpYS-zg.css +1 -0
  25. package/dist/favicon.svg +8 -0
  26. package/dist/index.html +19 -0
  27. package/dist-lib/favicon.svg +8 -0
  28. package/dist-lib/index.d.ts +1 -0
  29. package/dist-lib/style.css +1 -0
  30. package/dist-lib/ui.d.ts +2 -0
  31. package/dist-lib/ui.js +7390 -0
  32. package/dist-lib/views/Forge/ForgeDashboard.d.ts +5 -0
  33. package/dist-lib/views/Forge/activeJobs.d.ts +29 -0
  34. package/dist-lib/views/Forge/analysisCache.d.ts +64 -0
  35. package/dist-lib/views/Forge/api.d.ts +4 -0
  36. package/dist-lib/views/Forge/components/AnalysisProgress.d.ts +13 -0
  37. package/dist-lib/views/Forge/components/CITerminal.d.ts +44 -0
  38. package/dist-lib/views/Forge/components/DiffViewer.d.ts +37 -0
  39. package/dist-lib/views/Forge/components/ForgeConnect.d.ts +6 -0
  40. package/dist-lib/views/Forge/components/IssueList.d.ts +28 -0
  41. package/dist-lib/views/Forge/components/MRStatus.d.ts +9 -0
  42. package/dist-lib/views/Forge/components/NewTaskModal.d.ts +1 -0
  43. package/dist-lib/views/Forge/components/OpenMRList.d.ts +11 -0
  44. package/dist-lib/views/Forge/components/PipelineStatus.d.ts +12 -0
  45. package/dist-lib/views/Forge/components/PipelinesList.d.ts +9 -0
  46. package/dist-lib/views/Forge/components/RolloutStatus.d.ts +5 -0
  47. package/dist-lib/views/Forge/components/UsageWidget.d.ts +8 -0
  48. package/dist-lib/views/Forge/components/ValidationPanel.d.ts +22 -0
  49. package/dist-lib/views/Forge/context/ForgeProvider.d.ts +23 -0
  50. package/dist-lib/views/Forge/hooks/useConfig.d.ts +54 -0
  51. package/dist-lib/views/Forge/hooks/useForgeDashboard.d.ts +107 -0
  52. package/dist-lib/views/Forge/hooks/useForgeQuota.d.ts +22 -0
  53. package/dist-lib/views/Forge/phases/AnalyzingView.d.ts +1 -0
  54. package/dist-lib/views/Forge/phases/IdleView.d.ts +1 -0
  55. package/dist-lib/views/Forge/phases/MonitoringIssueTab.d.ts +1 -0
  56. package/dist-lib/views/Forge/phases/MonitoringPipelineTab.d.ts +1 -0
  57. package/dist-lib/views/Forge/phases/MonitoringView.d.ts +1 -0
  58. package/dist-lib/views/Forge/phases/ReviewingView.d.ts +1 -0
  59. package/dist-lib/views/Forge/phases/SelectedIssueTab.d.ts +11 -0
  60. package/dist-lib/views/Forge/phases/SelectedMRTab.d.ts +1 -0
  61. package/dist-lib/views/Forge/phases/SelectedView.d.ts +1 -0
  62. package/dist-lib/views/Forge/phases/shared/CloseIssueConfirm.d.ts +1 -0
  63. package/dist-lib/views/Forge/phases/shared/DetailTabs.d.ts +14 -0
  64. package/dist-lib/views/Forge/phases/shared/EnvironmentsPanel.d.ts +9 -0
  65. package/dist-lib/views/Forge/phases/shared/ForgeActivity.d.ts +1 -0
  66. package/dist-lib/views/Forge/phases/shared/IssueHeaderBlock.d.ts +13 -0
  67. package/dist-lib/views/Forge/phases/shared/MergedMRList.d.ts +4 -0
  68. package/dist-lib/views/Forge/phases/shared/PreviousAnalyses.d.ts +1 -0
  69. package/dist-lib/views/Forge/phases/shared/ResolutionCheckPanel.d.ts +6 -0
  70. package/dist-lib/views/Forge/settings/ConnectionRow.d.ts +16 -0
  71. package/dist-lib/views/Forge/settings/ConnectionsSection.d.ts +7 -0
  72. package/dist-lib/views/Forge/settings/DefaultsSection.d.ts +7 -0
  73. package/dist-lib/views/Forge/settings/ReposSection.d.ts +7 -0
  74. package/dist-lib/views/Forge/settings/SettingsView.d.ts +1 -0
  75. package/dist-lib/views/Forge/shell/ConfigBar.d.ts +1 -0
  76. package/dist-lib/views/Forge/shell/ForgeRouter.d.ts +9 -0
  77. package/dist-lib/views/Forge/shell/ForgeShell.d.ts +7 -0
  78. package/dist-lib/views/Forge/shell/Sidebar.d.ts +1 -0
  79. package/dist-lib/views/Forge/shell/SidebarFooterMenu.d.ts +1 -0
  80. package/dist-lib/views/Forge/shell/SidebarNav.d.ts +1 -0
  81. package/dist-lib/views/Forge/shell/SidebarNavItem.d.ts +10 -0
  82. package/dist-lib/views/Forge/shell/TopBar.d.ts +5 -0
  83. package/dist-lib/views/Forge/types.d.ts +235 -0
  84. package/dist-lib/views/Forge/utils.d.ts +11 -0
  85. package/package.json +88 -0
  86. package/server/forge/core/ForgeAgent.js +1144 -0
  87. package/server/forge/core/agentClient.js +36 -0
  88. package/server/forge/core/branchName.js +41 -0
  89. package/server/forge/core/claudeClient.js +356 -0
  90. package/server/forge/core/configStore.js +214 -0
  91. package/server/forge/core/gitlabClient.js +340 -0
  92. package/server/forge/core/jobs.js +180 -0
  93. package/server/forge/core/k8s.js +54 -0
  94. package/server/forge/core/localRepo.js +136 -0
  95. package/server/forge/core/openclawClient.js +125 -0
  96. package/server/forge/core/prompts.js +369 -0
  97. package/server/forge/core/providers/azuredevops.js +408 -0
  98. package/server/forge/core/providers/base.js +208 -0
  99. package/server/forge/core/providers/github.js +349 -0
  100. package/server/forge/core/providers/gitlab.js +529 -0
  101. package/server/forge/core/providers/index.js +47 -0
  102. package/server/forge/core/quotaClient.js +101 -0
  103. package/server/forge/core/validator/attribute.js +84 -0
  104. package/server/forge/core/validator/clone.js +91 -0
  105. package/server/forge/core/validator/cmd.js +98 -0
  106. package/server/forge/core/validator/fs.js +9 -0
  107. package/server/forge/core/validator/overlay.js +52 -0
  108. package/server/forge/core/validator/projectType.js +87 -0
  109. package/server/forge/core/validator/runners/go.js +107 -0
  110. package/server/forge/core/validator/runners/node.js +84 -0
  111. package/server/forge/core/validator/tdd.js +105 -0
  112. package/server/forge/core/validator.js +166 -0
  113. package/server/forge/routes.js +1012 -0
@@ -0,0 +1,762 @@
1
+ /**
2
+ * `forge-agent ship <iid>` — the headline command.
3
+ *
4
+ * Walks an issue from "open ticket" all the way to "deployed in production"
5
+ * with a yes/no Confirm gate at every state-changing step. Each phase calls
6
+ * the same underlying ForgeAgent / provider / validator code that the
7
+ * standalone subcommands use; this orchestrator just sequences them and
8
+ * keeps the user oriented.
9
+ *
10
+ * The flow:
11
+ *
12
+ * start
13
+ * → analyzing (or load cached if session.phase=ANALYZED+)
14
+ * → review (render proposal)
15
+ * → accept-gate (yes / no=abort / refine=loop with feedback)
16
+ * → refining (back to review)
17
+ * → validating (auto unless --skip-validate)
18
+ * → apply-gate (yes / no=abort)
19
+ * → applying
20
+ * → watching-ci
21
+ * → ci-failed | ci-fix-gate | ci-fixing → watching-ci
22
+ * → merge-gate (yes / no=abort; --auto arms auto-merge instead)
23
+ * → merging
24
+ * → watching-deploy
25
+ * → close-gate (yes / no=skip)
26
+ * → closing
27
+ * → done | aborted | error
28
+ *
29
+ * `--yes` (or FORGE_YES=1) bypasses every gate. Resumable: re-running
30
+ * `ship <iid>` reads session.phase and starts from the next gate.
31
+ */
32
+
33
+ import path from "node:path";
34
+ import React, { useEffect, useState } from "react";
35
+ import Spinner from "ink-spinner";
36
+ import TextInput from "ink-text-input";
37
+ import { h, Box, Text, useApp, runApp, Confirm } from "./ink.js";
38
+ import { canonicalRepoId, getFlag, projectRoot, resolveRepoSet } from "./utils.js";
39
+ import {
40
+ PHASES,
41
+ appendEvent,
42
+ readAnalysis,
43
+ readState,
44
+ resolveSessionTarget,
45
+ setHead,
46
+ updateState,
47
+ writeAnalysis,
48
+ } from "./sessionStore.js";
49
+
50
+ const POLL_MS = 5000;
51
+ const TERMINAL_PIPELINE = new Set([
52
+ "success",
53
+ "failed",
54
+ "canceled",
55
+ "skipped",
56
+ "manual",
57
+ ]);
58
+
59
+ function StepLine({ status, label, detail }) {
60
+ let glyph;
61
+ let color;
62
+ if (status === "running") {
63
+ glyph = h`<${Spinner} type="dots"/>`;
64
+ color = "cyan";
65
+ } else if (status === "ok") {
66
+ glyph = "✓";
67
+ color = "green";
68
+ } else if (status === "fail") {
69
+ glyph = "✘";
70
+ color = "red";
71
+ } else if (status === "skip") {
72
+ glyph = "○";
73
+ color = "gray";
74
+ } else {
75
+ glyph = "·";
76
+ color = undefined;
77
+ }
78
+ return h`<${Box}>
79
+ <${Text} color=${color}>${typeof glyph === "string" ? glyph : glyph}</>
80
+ <${Text}> ${label}</>
81
+ ${detail ? h`<${Text} dimColor> ${detail}</>` : null}
82
+ </>`;
83
+ }
84
+
85
+ function FeedbackInput({ onSubmit }) {
86
+ const [v, setV] = useState("");
87
+ return h`<${Box} flexDirection="column">
88
+ <${Box}>
89
+ <${Text} color="cyan">? </>
90
+ <${Text}>What feedback should the model address?</>
91
+ </>
92
+ <${Box} marginLeft=${2}>
93
+ <${TextInput}
94
+ value=${v}
95
+ onChange=${setV}
96
+ onSubmit=${(text) => {
97
+ const t = text.trim();
98
+ if (!t) return;
99
+ onSubmit(t);
100
+ }} />
101
+ </>
102
+ </>`;
103
+ }
104
+
105
+ function ProposalSummary({ analysis }) {
106
+ const changes = analysis?.changes ?? [];
107
+ return h`<${Box} flexDirection="column">
108
+ <${Text} dimColor>${"────────── proposal ──────────"}</>
109
+ <${Box}><${Text} color="yellow">Issue: </><${Text}>#${analysis.issue?.iid}: ${analysis.issue?.title ?? ""}</></>
110
+ ${analysis.mrTitle
111
+ ? h`<${Box}><${Text} color="yellow">MR: </><${Text}>${analysis.mrTitle}</></>`
112
+ : null}
113
+ <${Box}><${Text} color="yellow">Changes:</><${Text}>${` ${changes.length} file(s) across ${new Set(changes.map((c) => c.repo)).size} repo(s)`}</></>
114
+ ${changes.map((c, i) =>
115
+ h`<${Text} key=${i} dimColor>${` ${c.action.padEnd(7)} ${c.repo}:${c.file}`}</>`,
116
+ )}
117
+ <${Text} dimColor>${" (full diff: forge-agent show " + (analysis.issue?.iid ?? "") + ")"}</>
118
+ </>`;
119
+ }
120
+
121
+ function ShipView({ iid, primary, secondaries, bypassYes, autoMerge, skipValidate, onDone }) {
122
+ const { exit } = useApp();
123
+ const [phase, setPhase] = useState("init"); // see header for phase list
124
+ const [steps, setSteps] = useState([]); // visible step trace
125
+ const [analysis, setAnalysis] = useState(null);
126
+ const [sessionState, setSessionState] = useState(null);
127
+ const [pipeline, setPipeline] = useState(null);
128
+ const [jobs, setJobs] = useState([]);
129
+ const [error, setError] = useState(null);
130
+ const [feedback, setFeedback] = useState(null);
131
+
132
+ const repo = primary;
133
+
134
+ const pushStep = (label, detail) =>
135
+ setSteps((s) => [...s, { label, detail, status: "running" }]);
136
+ const finishStep = (label, status, detail) =>
137
+ setSteps((s) =>
138
+ s.map((row, i) =>
139
+ i === s.length - 1 && row.label === label
140
+ ? { ...row, status, detail: detail ?? row.detail }
141
+ : row,
142
+ ),
143
+ );
144
+
145
+ useEffect(() => {
146
+ if (phase === "done" || phase === "aborted" || phase === "error") {
147
+ onDone({ phase, error });
148
+ exit();
149
+ }
150
+ }, [phase]);
151
+
152
+ // === init: load or start fresh ===
153
+ useEffect(() => {
154
+ if (phase !== "init") return;
155
+ (async () => {
156
+ const state = (await readState(repo, iid).catch(() => null)) ?? null;
157
+ setSessionState(state);
158
+ const cached = await readAnalysis(repo, iid).catch(() => null);
159
+ if (cached && state?.phase) {
160
+ setAnalysis(cached);
161
+ // Resume at the right place. We always re-render the proposal so
162
+ // the user can see what's about to happen.
163
+ if (
164
+ state.phase === PHASES.ANALYZED ||
165
+ state.phase === PHASES.REFINED
166
+ ) {
167
+ setPhase("review");
168
+ return;
169
+ }
170
+ if (state.phase === PHASES.VALIDATED) {
171
+ setPhase("review"); // still want accept gate before apply
172
+ return;
173
+ }
174
+ if (state.phase === PHASES.APPLIED) {
175
+ setPipeline(null);
176
+ setPhase("watching-ci");
177
+ return;
178
+ }
179
+ if (state.phase === PHASES.MERGED) {
180
+ setPhase("watching-deploy");
181
+ return;
182
+ }
183
+ if (state.phase === PHASES.RESOLVED) {
184
+ setPhase("close-gate");
185
+ return;
186
+ }
187
+ if (state.phase === PHASES.CLOSED) {
188
+ pushStep("Session already closed", undefined);
189
+ setPhase("done");
190
+ return;
191
+ }
192
+ }
193
+ setPhase("analyzing");
194
+ })();
195
+ }, [phase]);
196
+
197
+ // === analyzing ===
198
+ useEffect(() => {
199
+ if (phase !== "analyzing") return;
200
+ let cancelled = false;
201
+ pushStep("Analyzing issue", `#${iid}`);
202
+ (async () => {
203
+ try {
204
+ const { ForgeAgent } = await import(
205
+ path.join(projectRoot, "server/forge/core/ForgeAgent.js")
206
+ );
207
+ const agent = new ForgeAgent({
208
+ project: primary,
209
+ secondaryProjects: secondaries,
210
+ });
211
+ let final = null;
212
+ const events = [];
213
+ const onEvent = (evt) => {
214
+ events.push(evt);
215
+ if (evt.type === "result") final = evt.data;
216
+ };
217
+ await agent.analyzeIssue(iid, onEvent, undefined, undefined, undefined, "fresh");
218
+ if (cancelled) return;
219
+ if (!final) throw new Error("analyze produced no result");
220
+ await writeAnalysis(repo, iid, final);
221
+ await updateState(repo, iid, {
222
+ primary,
223
+ secondaries,
224
+ phase: PHASES.ANALYZED,
225
+ lastError: null,
226
+ });
227
+ await setHead(repo, iid);
228
+ for (const e of events) {
229
+ await appendEvent(repo, iid, e).catch(() => {});
230
+ }
231
+ setAnalysis(final);
232
+ finishStep("Analyzing issue", "ok", `${final.changes?.length ?? 0} change(s) proposed`);
233
+ setPhase("review");
234
+ } catch (err) {
235
+ if (cancelled) return;
236
+ finishStep("Analyzing issue", "fail", err.message ?? String(err));
237
+ setError(err);
238
+ setPhase("error");
239
+ }
240
+ })();
241
+ return () => {
242
+ cancelled = true;
243
+ };
244
+ }, [phase]);
245
+
246
+ // === refining ===
247
+ useEffect(() => {
248
+ if (phase !== "refining" || !feedback) return;
249
+ let cancelled = false;
250
+ pushStep("Refining with feedback", feedback);
251
+ (async () => {
252
+ try {
253
+ const { ForgeAgent } = await import(
254
+ path.join(projectRoot, "server/forge/core/ForgeAgent.js")
255
+ );
256
+ const agent = new ForgeAgent({
257
+ project: primary,
258
+ secondaryProjects: secondaries,
259
+ });
260
+ let final = null;
261
+ const onEvent = (evt) => {
262
+ if (evt.type === "result") final = evt.data;
263
+ };
264
+ await agent.refineIssue(
265
+ iid,
266
+ analysis.changes,
267
+ feedback,
268
+ onEvent,
269
+ undefined,
270
+ );
271
+ if (cancelled) return;
272
+ if (!final) throw new Error("refine produced no result");
273
+ await writeAnalysis(repo, iid, final);
274
+ await updateState(repo, iid, {
275
+ phase: PHASES.REFINED,
276
+ lastFeedback: feedback,
277
+ lastError: null,
278
+ });
279
+ setAnalysis(final);
280
+ setFeedback(null);
281
+ finishStep("Refining with feedback", "ok", `${final.changes?.length ?? 0} change(s)`);
282
+ setPhase("review");
283
+ } catch (err) {
284
+ if (cancelled) return;
285
+ finishStep("Refining with feedback", "fail", err.message ?? String(err));
286
+ setError(err);
287
+ setPhase("error");
288
+ }
289
+ })();
290
+ return () => {
291
+ cancelled = true;
292
+ };
293
+ }, [phase, feedback]);
294
+
295
+ // === validating ===
296
+ useEffect(() => {
297
+ if (phase !== "validating") return;
298
+ let cancelled = false;
299
+ pushStep("Validating proposal", "lint / typecheck / test");
300
+ (async () => {
301
+ try {
302
+ const { validate } = await import(
303
+ path.join(projectRoot, "server/forge/core/validator.js")
304
+ );
305
+ const res = await validate(analysis, () => {});
306
+ if (cancelled) return;
307
+ if (!res.allPassed) {
308
+ finishStep("Validating proposal", "fail", "see: forge-agent validate");
309
+ setError(new Error("validation failed"));
310
+ setPhase("error");
311
+ return;
312
+ }
313
+ await updateState(repo, iid, {
314
+ phase: PHASES.VALIDATED,
315
+ validation: { passed: true, checkedAt: new Date().toISOString() },
316
+ });
317
+ finishStep("Validating proposal", "ok", "all checks passed");
318
+ setPhase("apply-gate");
319
+ } catch (err) {
320
+ if (cancelled) return;
321
+ finishStep("Validating proposal", "fail", err.message ?? String(err));
322
+ setError(err);
323
+ setPhase("error");
324
+ }
325
+ })();
326
+ return () => {
327
+ cancelled = true;
328
+ };
329
+ }, [phase]);
330
+
331
+ // === applying ===
332
+ useEffect(() => {
333
+ if (phase !== "applying") return;
334
+ let cancelled = false;
335
+ pushStep("Opening MR", "branch + commit + create MR");
336
+ (async () => {
337
+ try {
338
+ const { ForgeAgent } = await import(
339
+ path.join(projectRoot, "server/forge/core/ForgeAgent.js")
340
+ );
341
+ const agent = new ForgeAgent({
342
+ project: primary,
343
+ secondaryProjects: secondaries,
344
+ });
345
+ const result = await agent.applyFix(analysis);
346
+ if (cancelled) return;
347
+ const primaryApp =
348
+ result.applications?.find(
349
+ (a) => canonicalRepoId(a.repo) === canonicalRepoId(primary),
350
+ ) ?? result.applications?.[0];
351
+ await updateState(repo, iid, {
352
+ phase: PHASES.APPLIED,
353
+ branch: primaryApp?.branchName ?? null,
354
+ mrIid: primaryApp?.mr?.iid ?? null,
355
+ mrUrl: primaryApp?.mr?.web_url ?? null,
356
+ applications: result.applications ?? [],
357
+ lastError: null,
358
+ });
359
+ finishStep(
360
+ "Opening MR",
361
+ "ok",
362
+ primaryApp?.mr?.web_url
363
+ ? `${primaryApp.mr.web_url}`
364
+ : primaryApp?.branchName
365
+ ? `branch ${primaryApp.branchName}`
366
+ : "applied",
367
+ );
368
+ setSessionState((s) => ({
369
+ ...(s ?? {}),
370
+ branch: primaryApp?.branchName,
371
+ mrIid: primaryApp?.mr?.iid,
372
+ }));
373
+ setPhase("watching-ci");
374
+ } catch (err) {
375
+ if (cancelled) return;
376
+ finishStep("Opening MR", "fail", err.message ?? String(err));
377
+ setError(err);
378
+ setPhase("error");
379
+ }
380
+ })();
381
+ return () => {
382
+ cancelled = true;
383
+ };
384
+ }, [phase]);
385
+
386
+ // === watching-ci ===
387
+ useEffect(() => {
388
+ if (phase !== "watching-ci") return;
389
+ let cancelled = false;
390
+ pushStep("Watching CI pipeline", "polling every 5s");
391
+ let timer;
392
+ (async () => {
393
+ try {
394
+ const state = await readState(repo, iid);
395
+ if (!state?.mrIid) throw new Error("session has no mrIid; cannot watch");
396
+ const { createProvider } = await import(
397
+ path.join(projectRoot, "server/forge/core/providers/index.js")
398
+ );
399
+ const provider = createProvider(primary);
400
+ const poll = async () => {
401
+ if (cancelled) return;
402
+ try {
403
+ const pipelines = await provider.getMRPipelines(state.mrIid);
404
+ if (pipelines?.length) {
405
+ const latest = pipelines[0];
406
+ const [pipe, list] = await Promise.all([
407
+ provider.getPipeline(latest.id),
408
+ provider.getPipelineJobs(latest.id),
409
+ ]);
410
+ if (cancelled) return;
411
+ setPipeline(pipe);
412
+ setJobs(list);
413
+ if (TERMINAL_PIPELINE.has(pipe.status)) {
414
+ await updateState(repo, iid, {
415
+ lastPipelineStatus: pipe.status,
416
+ lastPipelineUrl: pipe.web_url,
417
+ });
418
+ if (pipe.status === "success") {
419
+ finishStep("Watching CI pipeline", "ok", `${pipe.status}`);
420
+ setPhase("merge-gate");
421
+ } else {
422
+ finishStep("Watching CI pipeline", "fail", `${pipe.status}`);
423
+ setPhase("ci-fix-gate");
424
+ }
425
+ return;
426
+ }
427
+ }
428
+ } catch (err) {
429
+ // transient — keep polling
430
+ setError(err);
431
+ }
432
+ timer = setTimeout(poll, POLL_MS);
433
+ };
434
+ await poll();
435
+ } catch (err) {
436
+ if (cancelled) return;
437
+ finishStep("Watching CI pipeline", "fail", err.message ?? String(err));
438
+ setError(err);
439
+ setPhase("error");
440
+ }
441
+ })();
442
+ return () => {
443
+ cancelled = true;
444
+ if (timer) clearTimeout(timer);
445
+ };
446
+ }, [phase]);
447
+
448
+ // === ci-fixing ===
449
+ useEffect(() => {
450
+ if (phase !== "ci-fixing") return;
451
+ let cancelled = false;
452
+ pushStep("CI fix", "re-analyzing with failure logs");
453
+ (async () => {
454
+ try {
455
+ const state = await readState(repo, iid);
456
+ const { createProvider } = await import(
457
+ path.join(projectRoot, "server/forge/core/providers/index.js")
458
+ );
459
+ const provider = createProvider(primary);
460
+ const pipelines = await provider.getMRPipelines(state.mrIid);
461
+ const latest = pipelines?.[0];
462
+ if (!latest) throw new Error("no pipeline to inspect");
463
+ const allJobs = await provider.getPipelineJobs(latest.id);
464
+ const failed = allJobs.filter((j) => j.status === "failed");
465
+ if (!failed.length) throw new Error("no failed jobs to fix");
466
+ const traces = await Promise.all(
467
+ failed.map(async (j) => {
468
+ const trace = await provider.getJobTrace(j.id).catch(() => "");
469
+ const tail = trace.split("\n").slice(-150).join("\n");
470
+ return `### ${j.stage}/${j.name}\n\n\`\`\`\n${tail}\n\`\`\``;
471
+ }),
472
+ );
473
+ const ciContext = traces.join("\n\n");
474
+
475
+ const { ForgeAgent } = await import(
476
+ path.join(projectRoot, "server/forge/core/ForgeAgent.js")
477
+ );
478
+ const agent = new ForgeAgent({
479
+ project: primary,
480
+ secondaryProjects: secondaries,
481
+ });
482
+ let final = null;
483
+ const onEvent = (evt) => {
484
+ if (evt.type === "result") final = evt.data;
485
+ };
486
+ await agent.ciFixIssue(iid, ciContext, onEvent);
487
+ if (cancelled) return;
488
+ if (!final) throw new Error("ci-fix produced no result");
489
+ await agent.commitToBranch(state.branch, final, primary);
490
+ await writeAnalysis(repo, iid, final);
491
+ await updateState(repo, iid, { phase: PHASES.APPLIED, lastError: null });
492
+ finishStep("CI fix", "ok", `pushed to ${state.branch}`);
493
+ setPipeline(null);
494
+ setJobs([]);
495
+ setPhase("watching-ci");
496
+ } catch (err) {
497
+ if (cancelled) return;
498
+ finishStep("CI fix", "fail", err.message ?? String(err));
499
+ setError(err);
500
+ setPhase("error");
501
+ }
502
+ })();
503
+ return () => {
504
+ cancelled = true;
505
+ };
506
+ }, [phase]);
507
+
508
+ // === merging ===
509
+ useEffect(() => {
510
+ if (phase !== "merging") return;
511
+ let cancelled = false;
512
+ pushStep(autoMerge ? "Arming auto-merge" : "Merging MR", undefined);
513
+ (async () => {
514
+ try {
515
+ const state = await readState(repo, iid);
516
+ const { createProvider } = await import(
517
+ path.join(projectRoot, "server/forge/core/providers/index.js")
518
+ );
519
+ const provider = createProvider(primary);
520
+ if (autoMerge) {
521
+ if (typeof provider.setAutoMergeMR !== "function")
522
+ throw new Error("provider does not support auto-merge");
523
+ await provider.setAutoMergeMR(state.mrIid);
524
+ } else {
525
+ await provider.mergeMR(state.mrIid);
526
+ }
527
+ if (cancelled) return;
528
+ await updateState(repo, iid, {
529
+ phase: PHASES.MERGED,
530
+ mergedAt: new Date().toISOString(),
531
+ autoMergeArmed: !!autoMerge,
532
+ });
533
+ finishStep(
534
+ autoMerge ? "Arming auto-merge" : "Merging MR",
535
+ "ok",
536
+ autoMerge ? "armed" : "merged",
537
+ );
538
+ if (autoMerge) {
539
+ // auto-merge means we don't know exactly when it lands; stop here
540
+ setPhase("done");
541
+ } else {
542
+ setPhase("watching-deploy");
543
+ }
544
+ } catch (err) {
545
+ if (cancelled) return;
546
+ finishStep(
547
+ autoMerge ? "Arming auto-merge" : "Merging MR",
548
+ "fail",
549
+ err.message ?? String(err),
550
+ );
551
+ setError(err);
552
+ setPhase("error");
553
+ }
554
+ })();
555
+ return () => {
556
+ cancelled = true;
557
+ };
558
+ }, [phase]);
559
+
560
+ // === watching-deploy: poll the post-merge pipeline (target branch) ===
561
+ useEffect(() => {
562
+ if (phase !== "watching-deploy") return;
563
+ let cancelled = false;
564
+ pushStep("Watching deploy pipeline", "post-merge");
565
+ let timer;
566
+ (async () => {
567
+ try {
568
+ const { createProvider } = await import(
569
+ path.join(projectRoot, "server/forge/core/providers/index.js")
570
+ );
571
+ const provider = createProvider(primary);
572
+ const target =
573
+ process.env.GITLAB_BASE_BRANCH ??
574
+ process.env.GITHUB_BASE_BRANCH ??
575
+ "main";
576
+ const poll = async () => {
577
+ if (cancelled) return;
578
+ try {
579
+ const pipelines = await provider.getPipelinesByRef(target);
580
+ if (pipelines?.length) {
581
+ const latest = pipelines[0];
582
+ const pipe = await provider.getPipeline(latest.id);
583
+ if (cancelled) return;
584
+ setPipeline(pipe);
585
+ if (TERMINAL_PIPELINE.has(pipe.status)) {
586
+ await updateState(repo, iid, {
587
+ deployStatus: pipe.status,
588
+ deployedAt: new Date().toISOString(),
589
+ });
590
+ finishStep(
591
+ "Watching deploy pipeline",
592
+ pipe.status === "success" ? "ok" : "fail",
593
+ pipe.status,
594
+ );
595
+ setPhase(pipe.status === "success" ? "close-gate" : "error");
596
+ if (pipe.status !== "success") {
597
+ setError(new Error(`deploy pipeline ${pipe.status}`));
598
+ }
599
+ return;
600
+ }
601
+ }
602
+ } catch (err) {
603
+ setError(err);
604
+ }
605
+ timer = setTimeout(poll, POLL_MS);
606
+ };
607
+ await poll();
608
+ } catch (err) {
609
+ if (cancelled) return;
610
+ finishStep("Watching deploy pipeline", "fail", err.message ?? String(err));
611
+ setError(err);
612
+ setPhase("error");
613
+ }
614
+ })();
615
+ return () => {
616
+ cancelled = true;
617
+ if (timer) clearTimeout(timer);
618
+ };
619
+ }, [phase]);
620
+
621
+ // === closing ===
622
+ useEffect(() => {
623
+ if (phase !== "closing") return;
624
+ let cancelled = false;
625
+ pushStep("Closing issue", `#${iid}`);
626
+ (async () => {
627
+ try {
628
+ const { createProvider } = await import(
629
+ path.join(projectRoot, "server/forge/core/providers/index.js")
630
+ );
631
+ const provider = createProvider(primary);
632
+ await provider.closeIssue(iid);
633
+ if (cancelled) return;
634
+ await updateState(repo, iid, {
635
+ phase: PHASES.CLOSED,
636
+ closedAt: new Date().toISOString(),
637
+ });
638
+ finishStep("Closing issue", "ok");
639
+ setPhase("done");
640
+ } catch (err) {
641
+ if (cancelled) return;
642
+ finishStep("Closing issue", "fail", err.message ?? String(err));
643
+ setError(err);
644
+ setPhase("error");
645
+ }
646
+ })();
647
+ return () => {
648
+ cancelled = true;
649
+ };
650
+ }, [phase]);
651
+
652
+ // ─── Render ─────────────────────────────────────────────────────────────
653
+
654
+ const stepsRender = h`<${Box} flexDirection="column">
655
+ ${steps.map((s, i) => h`<${StepLine} key=${i} ...${s} />`)}
656
+ </>`;
657
+
658
+ const pipelineRender = pipeline
659
+ ? h`<${Box} marginTop=${1}>
660
+ <${Text} dimColor>Pipeline </>
661
+ <${Text} dimColor>#${pipeline.id} </>
662
+ <${Text} color=${pipeline.status === "success" ? "green" : pipeline.status === "failed" ? "red" : "cyan"}>${pipeline.status}</>
663
+ ${pipeline.web_url ? h`<${Text} dimColor> ${pipeline.web_url}</>` : null}
664
+ </>`
665
+ : null;
666
+
667
+ let gate = null;
668
+ if (phase === "review") {
669
+ gate = h`<${Box} flexDirection="column" marginTop=${1}>
670
+ ${analysis ? h`<${ProposalSummary} analysis=${analysis} />` : null}
671
+ <${Box} marginTop=${1}><${Text} dimColor>${"Use 'no' to abort, 'yes' to continue (run `forge-agent refine` separately to iterate)."}</></>
672
+ <${Confirm}
673
+ message="Accept this fix and continue toward production?"
674
+ bypassYes=${bypassYes}
675
+ onSelect=${(yes) => setPhase(yes ? (skipValidate ? "apply-gate" : "validating") : "aborted")} />
676
+ </>`;
677
+ } else if (phase === "apply-gate") {
678
+ gate = h`<${Confirm}
679
+ message="Apply changes (open MR)?"
680
+ bypassYes=${bypassYes}
681
+ onSelect=${(yes) => setPhase(yes ? "applying" : "aborted")} />`;
682
+ } else if (phase === "ci-fix-gate") {
683
+ gate = h`<${Confirm}
684
+ message="CI failed. Re-analyse with failure logs and push a fix?"
685
+ bypassYes=${bypassYes}
686
+ onSelect=${(yes) => setPhase(yes ? "ci-fixing" : "aborted")} />`;
687
+ } else if (phase === "merge-gate") {
688
+ gate = h`<${Confirm}
689
+ message=${autoMerge ? "Arm auto-merge?" : "Merge MR to main?"}
690
+ bypassYes=${bypassYes}
691
+ onSelect=${(yes) => setPhase(yes ? "merging" : "aborted")} />`;
692
+ } else if (phase === "close-gate") {
693
+ gate = h`<${Confirm}
694
+ message=${`Close issue #${iid}?`}
695
+ bypassYes=${bypassYes}
696
+ onSelect=${(yes) => setPhase(yes ? "closing" : "done")} />`;
697
+ }
698
+
699
+ let footer = null;
700
+ if (phase === "done") {
701
+ footer = h`<${Box} marginTop=${1}>
702
+ <${Text} color="green">${`✓ Ship complete for #${iid}.`}</>
703
+ </>`;
704
+ } else if (phase === "aborted") {
705
+ footer = h`<${Box} marginTop=${1}>
706
+ <${Text} dimColor>${`Aborted. Resume with: forge-agent ship ${iid}`}</>
707
+ </>`;
708
+ } else if (phase === "error") {
709
+ footer = h`<${Box} marginTop=${1}>
710
+ <${Text} color="red">${`✘ ${error?.message ?? String(error)}`}</>
711
+ <${Text} dimColor>${` (resume with: forge-agent ship ${iid})`}</>
712
+ </>`;
713
+ }
714
+
715
+ return h`<${Box} flexDirection="column">
716
+ <${Box}>
717
+ <${Text} bold>forge-agent ship </>
718
+ <${Text} color="yellow">#${iid}</>
719
+ <${Text} dimColor>${` (${primary}${secondaries.length ? ` + ${secondaries.length} secondaries` : ""})`}</>
720
+ </>
721
+ ${stepsRender}
722
+ ${pipelineRender}
723
+ ${gate}
724
+ ${footer}
725
+ </>`;
726
+ }
727
+
728
+ export async function cmdShip(argv) {
729
+ const iid = Number(argv[1]);
730
+ if (!Number.isInteger(iid) || iid <= 0) {
731
+ console.error("Usage: forge-agent ship <iid> [options]");
732
+ process.exit(1);
733
+ }
734
+
735
+ const { primary, secondaries } = await resolveRepoSet(argv);
736
+ if (!primary) {
737
+ console.error(
738
+ "No repo. Pass --repo <group/repo>, set FORGE_GITLAB_PROJECT, or run `forge-agent init`.",
739
+ );
740
+ process.exit(1);
741
+ }
742
+
743
+ const bypassYes = argv.includes("--yes") || process.env.FORGE_YES === "1";
744
+ const autoMerge = argv.includes("--auto");
745
+ const skipValidate = argv.includes("--skip-validate");
746
+
747
+ let outcome = { phase: null };
748
+ await runApp(
749
+ h`<${ShipView}
750
+ iid=${iid}
751
+ primary=${primary}
752
+ secondaries=${secondaries}
753
+ bypassYes=${bypassYes}
754
+ autoMerge=${autoMerge}
755
+ skipValidate=${skipValidate}
756
+ onDone=${(o) => (outcome = o)} />`,
757
+ );
758
+
759
+ if (outcome.phase === "done") return;
760
+ if (outcome.phase === "aborted") process.exit(0);
761
+ process.exit(2);
762
+ }