@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,155 @@
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 { projectRoot, getFlag, relativeAge, resolvePrimaryRepo } from "./utils.js";
6
+
7
+ function applyFilters(list, { label, mine, currentUser }) {
8
+ let out = list;
9
+ if (label) {
10
+ const want = label.toLowerCase();
11
+ out = out.filter((i) => (i.labels ?? []).some((l) => l.toLowerCase() === want));
12
+ }
13
+ if (mine && currentUser) {
14
+ const want = currentUser.toLowerCase();
15
+ out = out.filter((i) => (i.author?.name ?? "").toLowerCase() === want);
16
+ }
17
+ return out;
18
+ }
19
+
20
+ function IssuesView({ primary, limit, label, mine, onError }) {
21
+ const { exit } = useApp();
22
+ const [phase, setPhase] = useState("loading");
23
+ const [issues, setIssues] = useState([]);
24
+ const [error, setError] = useState(null);
25
+
26
+ useEffect(() => {
27
+ let cancelled = false;
28
+ (async () => {
29
+ try {
30
+ const { createProvider } = await import(
31
+ path.join(projectRoot, "server/forge/core/providers/index.js")
32
+ );
33
+ const provider = createProvider(primary);
34
+ let list = await provider.listOpenIssues();
35
+ if (cancelled) return;
36
+ list = applyFilters(list, {
37
+ label,
38
+ mine,
39
+ currentUser: process.env.FORGE_USER ?? process.env.USER ?? null,
40
+ });
41
+ if (Number.isInteger(limit) && limit > 0) list = list.slice(0, limit);
42
+ setIssues(list);
43
+ setPhase("done");
44
+ } catch (err) {
45
+ if (cancelled) return;
46
+ setError(err.message || String(err));
47
+ setPhase("error");
48
+ onError(err);
49
+ }
50
+ })();
51
+ return () => {
52
+ cancelled = true;
53
+ };
54
+ }, []);
55
+
56
+ useEffect(() => {
57
+ if (phase === "done" || phase === "error") exit();
58
+ }, [phase]);
59
+
60
+ if (phase === "loading") {
61
+ return h`<${Box}>
62
+ <${Text} color="cyan"><${Spinner} type="dots"/></>
63
+ <${Text}> Fetching issues from </>
64
+ <${Text} dimColor>${primary}</>
65
+ </>`;
66
+ }
67
+
68
+ if (phase === "error") {
69
+ return h`<${Box}>
70
+ <${Text} color="red">✘</>
71
+ <${Text}> Failed to fetch issues: ${error}</>
72
+ </>`;
73
+ }
74
+
75
+ const count = issues.length;
76
+ const header = h`<${Box} marginBottom=${1}>
77
+ <${Text} color="green">✓</>
78
+ <${Text}> ${count} open issue${count === 1 ? "" : "s"} in </>
79
+ <${Text} dimColor>${primary}</>
80
+ </>`;
81
+
82
+ if (count === 0) return header;
83
+
84
+ const rows = issues.map((i) => ({
85
+ age: relativeAge(i.created_at),
86
+ iid: `#${i.iid}`,
87
+ title: i.title.length > 60 ? `${i.title.slice(0, 57)}…` : i.title,
88
+ labels: (i.labels ?? []).slice(0, 3).join(","),
89
+ }));
90
+ const wAge = Math.max(3, ...rows.map((r) => r.age.length));
91
+ const wIid = Math.max(3, ...rows.map((r) => r.iid.length));
92
+
93
+ return h`<${Box} flexDirection="column">
94
+ ${header}
95
+ ${rows.map((r, i) =>
96
+ h`<${Box} key=${i}>
97
+ <${Box} width=${wAge + 2}><${Text} dimColor>${r.age}</></>
98
+ <${Box} width=${wIid + 2}><${Text} color="yellow">${r.iid}</></>
99
+ <${Box} flexGrow=${1}><${Text}>${r.title}</></>
100
+ <${Text} dimColor> ${r.labels}</>
101
+ </>`,
102
+ )}
103
+ <${Box} marginTop=${1}>
104
+ <${Text} dimColor>${"Analyze: forge-agent analyze <iid>"}</>
105
+ </>
106
+ </>`;
107
+ }
108
+
109
+ export async function cmdIssues(argv) {
110
+ const primary = await resolvePrimaryRepo(argv);
111
+ if (!primary) {
112
+ console.error(
113
+ "No repo. Pass --repo <group/repo>, set FORGE_GITLAB_PROJECT, or run `forge-agent init`.",
114
+ );
115
+ process.exit(1);
116
+ }
117
+
118
+ const json = argv.includes("--json");
119
+ const limitStr = getFlag(argv, "--limit");
120
+ const limit = limitStr ? parseInt(limitStr, 10) : null;
121
+ const label = getFlag(argv, "--label");
122
+ const mine = argv.includes("--mine");
123
+
124
+ if (json) {
125
+ const { createProvider } = await import(
126
+ path.join(projectRoot, "server/forge/core/providers/index.js")
127
+ );
128
+ const provider = createProvider(primary);
129
+ try {
130
+ let issues = await provider.listOpenIssues();
131
+ issues = applyFilters(issues, {
132
+ label,
133
+ mine,
134
+ currentUser: process.env.FORGE_USER ?? process.env.USER ?? null,
135
+ });
136
+ if (Number.isInteger(limit) && limit > 0) issues = issues.slice(0, limit);
137
+ process.stdout.write(`${JSON.stringify(issues, null, 2)}\n`);
138
+ } catch (err) {
139
+ process.stdout.write(`${JSON.stringify({ error: err.message })}\n`);
140
+ process.exit(2);
141
+ }
142
+ return;
143
+ }
144
+
145
+ let failed = false;
146
+ await runApp(
147
+ h`<${IssuesView}
148
+ primary=${primary}
149
+ limit=${limit}
150
+ label=${label}
151
+ mine=${mine}
152
+ onError=${() => (failed = true)} />`,
153
+ );
154
+ if (failed) process.exit(2);
155
+ }
@@ -0,0 +1,175 @@
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 MergeView({ repo, mrIid, autoMerge, bypassYes, onDone }) {
15
+ const { exit } = useApp();
16
+ const [phase, setPhase] = useState("review"); // review → merging → done | aborted | error
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 !== "merging") 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 (autoMerge) {
36
+ if (typeof provider.setAutoMergeMR !== "function") {
37
+ throw new Error(
38
+ `${provider.name ?? "provider"} does not support arming auto-merge.`,
39
+ );
40
+ }
41
+ await provider.setAutoMergeMR(mrIid);
42
+ } else {
43
+ await provider.mergeMR(mrIid);
44
+ }
45
+ if (cancelled) return;
46
+ setPhase("done");
47
+ } catch (err) {
48
+ if (cancelled) return;
49
+ setError(err);
50
+ setPhase("error");
51
+ }
52
+ })();
53
+ return () => {
54
+ cancelled = true;
55
+ };
56
+ }, [phase]);
57
+
58
+ const action = autoMerge
59
+ ? "Arm auto-merge for this MR (will merge once CI is green)?"
60
+ : "Merge this MR now?";
61
+
62
+ if (phase === "review" || phase === "confirm") {
63
+ return h`<${Box} flexDirection="column">
64
+ <${Box}>
65
+ <${Text} color="yellow">MR </>
66
+ <${Text}>${repo} !${mrIid}</>
67
+ </>
68
+ <${Confirm}
69
+ message=${action}
70
+ bypassYes=${bypassYes}
71
+ onSelect=${(yes) => setPhase(yes ? "merging" : "aborted")} />
72
+ </>`;
73
+ }
74
+
75
+ if (phase === "merging") {
76
+ return h`<${Box}>
77
+ <${Text} color="cyan"><${Spinner} type="dots"/></>
78
+ <${Text}>${autoMerge ? " Arming auto-merge…" : " Merging…"}</>
79
+ </>`;
80
+ }
81
+
82
+ if (phase === "done") {
83
+ return h`<${Box}>
84
+ <${Text} color="green">${autoMerge ? "✓ Auto-merge armed." : "✓ Merged."}</>
85
+ </>`;
86
+ }
87
+
88
+ if (phase === "aborted") {
89
+ return h`<${Text} dimColor>Aborted — no merge.</>`;
90
+ }
91
+
92
+ return h`<${Box}>
93
+ <${Text} color="red">${`✘ ${error?.message ?? String(error)}`}</>
94
+ </>`;
95
+ }
96
+
97
+ export async function cmdMerge(argv) {
98
+ const explicitIid = Number(argv[1]);
99
+ const explicitRepo = getFlag(argv, "--repo");
100
+ const explicitMr = Number(getFlag(argv, "--mr"));
101
+ const autoMerge = argv.includes("--auto");
102
+ const bypassYes = argv.includes("--yes") || process.env.FORGE_YES === "1";
103
+
104
+ let repo;
105
+ let mrIid;
106
+ let sessionRef = null;
107
+
108
+ if (Number.isInteger(explicitMr) && explicitMr > 0) {
109
+ repo = explicitRepo;
110
+ mrIid = explicitMr;
111
+ } else {
112
+ const target = await resolveSessionTarget({
113
+ explicitIid:
114
+ Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
115
+ explicitRepo,
116
+ });
117
+ if (!target?.repo || !target?.iid) {
118
+ console.error(
119
+ "No session target. Pass --mr <iid> --repo <repo>, or run analyze + apply first.",
120
+ );
121
+ process.exit(1);
122
+ }
123
+ const state = await readState(target.repo, target.iid);
124
+ if (!state?.mrIid) {
125
+ console.error(
126
+ `Session ${target.repo}#${target.iid} has no MR. Run: forge-agent apply ${target.iid}`,
127
+ );
128
+ process.exit(1);
129
+ }
130
+ repo = state.primary ?? target.repo;
131
+ mrIid = state.mrIid;
132
+ sessionRef = target;
133
+ }
134
+
135
+ if (!repo) {
136
+ console.error("Repo is required: pass --repo <id>");
137
+ process.exit(1);
138
+ }
139
+
140
+ let outcome = { phase: null };
141
+ await runApp(
142
+ h`<${MergeView}
143
+ repo=${repo}
144
+ mrIid=${mrIid}
145
+ autoMerge=${autoMerge}
146
+ bypassYes=${bypassYes}
147
+ onDone=${(o) => (outcome = o)} />`,
148
+ );
149
+
150
+ if (sessionRef) {
151
+ if (outcome.phase === "done") {
152
+ await updateState(sessionRef.repo, sessionRef.iid, {
153
+ phase: PHASES.MERGED,
154
+ mergedAt: new Date().toISOString(),
155
+ autoMergeArmed: !!autoMerge,
156
+ lastError: null,
157
+ }).catch(() => {});
158
+ await appendEvent(sessionRef.repo, sessionRef.iid, {
159
+ type: "session:merge-done",
160
+ autoMerge: !!autoMerge,
161
+ }).catch(() => {});
162
+ } else if (outcome.phase === "error") {
163
+ await updateState(sessionRef.repo, sessionRef.iid, {
164
+ lastError: outcome.error?.message ?? null,
165
+ }).catch(() => {});
166
+ await appendEvent(sessionRef.repo, sessionRef.iid, {
167
+ type: "session:merge-failed",
168
+ error: outcome.error?.message,
169
+ }).catch(() => {});
170
+ }
171
+ }
172
+
173
+ if (outcome.phase === "error") process.exit(2);
174
+ if (outcome.phase === "aborted") process.exit(0);
175
+ }
@@ -0,0 +1,253 @@
1
+ import path from "node:path";
2
+ import React, { useEffect, useState } from "react";
3
+ import Spinner from "ink-spinner";
4
+ import TextInput from "ink-text-input";
5
+ import { h, Box, Text, useApp, useInput, runApp } from "./ink.js";
6
+ import { getFlag, projectRoot } from "./utils.js";
7
+ import {
8
+ PHASES,
9
+ appendEvent,
10
+ readAnalysis,
11
+ readState,
12
+ resolveSessionTarget,
13
+ updateState,
14
+ writeAnalysis,
15
+ } from "./sessionStore.js";
16
+
17
+ function StepRow({ step, message, status }) {
18
+ let glyph;
19
+ if (status === "running")
20
+ glyph = h`<${Text} color="cyan"><${Spinner} type="dots"/></>`;
21
+ else if (status === "done") glyph = h`<${Text} color="green">✓</>`;
22
+ else glyph = h`<${Text} color="red">✘</>`;
23
+ return h`<${Box}>
24
+ ${glyph}
25
+ <${Text} dimColor> [${step}]</>
26
+ <${Text}> ${message}</>
27
+ </>`;
28
+ }
29
+
30
+ function FeedbackPrompt({ onSubmit }) {
31
+ const { exit } = useApp();
32
+ const [input, setInput] = useState("");
33
+ return h`<${Box} flexDirection="column">
34
+ <${Box}>
35
+ <${Text} color="cyan">? </>
36
+ <${Text}>What feedback should the model address?</>
37
+ </>
38
+ <${Box} marginLeft=${2}>
39
+ <${TextInput}
40
+ value=${input}
41
+ onChange=${setInput}
42
+ onSubmit=${(v) => {
43
+ const trimmed = v.trim();
44
+ if (!trimmed) return; // require non-empty
45
+ onSubmit(trimmed);
46
+ exit();
47
+ }} />
48
+ </>
49
+ </>`;
50
+ }
51
+
52
+ function RefineView({ analysis, primary, secondaries, feedback, model, eventSink, onDone }) {
53
+ const { exit } = useApp();
54
+ const [items, setItems] = useState([]);
55
+ const [result, setResult] = useState(null);
56
+ const [finished, setFinished] = useState(null);
57
+
58
+ useEffect(() => {
59
+ if (!finished) return;
60
+ onDone(finished);
61
+ exit();
62
+ }, [finished]);
63
+
64
+ useEffect(() => {
65
+ let cancelled = false;
66
+ (async () => {
67
+ try {
68
+ const { ForgeAgent } = await import(
69
+ path.join(projectRoot, "server/forge/core/ForgeAgent.js")
70
+ );
71
+ const agent = new ForgeAgent({
72
+ project: primary,
73
+ secondaryProjects: secondaries,
74
+ });
75
+ let final = null;
76
+
77
+ const onEvent = (evt) => {
78
+ if (cancelled) return;
79
+ eventSink?.(evt);
80
+ if (evt.type === "result") {
81
+ final = evt.data;
82
+ return;
83
+ }
84
+ setItems((prev) => {
85
+ const closeRunning = (list) =>
86
+ list.map((it) =>
87
+ it.kind === "step" && it.status === "running"
88
+ ? { ...it, status: "done" }
89
+ : it,
90
+ );
91
+ switch (evt.type) {
92
+ case "progress":
93
+ return [
94
+ ...closeRunning(prev),
95
+ {
96
+ kind: "step",
97
+ step: evt.step,
98
+ message: evt.message,
99
+ status: "running",
100
+ },
101
+ ];
102
+ case "log":
103
+ return [...prev, { kind: "log", text: evt.message }];
104
+ case "error":
105
+ return [
106
+ ...prev.map((it) =>
107
+ it.kind === "step" && it.status === "running"
108
+ ? { ...it, status: "failed" }
109
+ : it,
110
+ ),
111
+ { kind: "error", message: evt.message },
112
+ ];
113
+ default:
114
+ return prev;
115
+ }
116
+ });
117
+ };
118
+
119
+ await agent.refineIssue(
120
+ analysis.issue.iid,
121
+ analysis.changes,
122
+ feedback,
123
+ onEvent,
124
+ model,
125
+ undefined, // ciContext
126
+ undefined, // validationState
127
+ undefined, // claudeSessionId — TODO: persist in session for multi-turn
128
+ );
129
+ if (cancelled) return;
130
+ setItems((prev) =>
131
+ prev.map((it) =>
132
+ it.kind === "step" && it.status === "running"
133
+ ? { ...it, status: "done" }
134
+ : it,
135
+ ),
136
+ );
137
+ if (final) setResult(final);
138
+ setFinished({ ok: true, result: final });
139
+ } catch (err) {
140
+ if (cancelled) return;
141
+ setItems((prev) => [
142
+ ...prev.map((it) =>
143
+ it.kind === "step" && it.status === "running"
144
+ ? { ...it, status: "failed" }
145
+ : it,
146
+ ),
147
+ { kind: "error", message: err.message ?? String(err) },
148
+ ]);
149
+ setFinished({ ok: false, error: err });
150
+ }
151
+ })();
152
+ return () => {
153
+ cancelled = true;
154
+ };
155
+ }, []);
156
+
157
+ return h`<${Box} flexDirection="column">
158
+ <${Box}>
159
+ <${Text} dimColor>Feedback: </>
160
+ <${Text}>${feedback}</>
161
+ </>
162
+ ${items.map((it, i) => {
163
+ if (it.kind === "step")
164
+ return h`<${StepRow} key=${i} step=${it.step} message=${it.message} status=${it.status} />`;
165
+ if (it.kind === "log")
166
+ return h`<${Box} key=${i}><${Text} dimColor> ${it.text}</></>`;
167
+ return h`<${Box} key=${i}>
168
+ <${Text} color="red">ERROR</>
169
+ <${Text}>: ${it.message}</>
170
+ </>`;
171
+ })}
172
+ ${result
173
+ ? h`<${Box} marginTop=${1}>
174
+ <${Text} color="green">${`✓ Refined ${result.changes?.length ?? 0} change(s) — view with: forge-agent show`}</>
175
+ </>`
176
+ : null}
177
+ </>`;
178
+ }
179
+
180
+ export async function cmdRefine(argv) {
181
+ const explicitIid = Number(argv[1]);
182
+ const explicitRepo = getFlag(argv, "--repo");
183
+ const target = await resolveSessionTarget({
184
+ explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
185
+ explicitRepo,
186
+ });
187
+ if (!target?.repo || !target?.iid) {
188
+ console.error(
189
+ "No session target. Pass an <iid>, set --repo, or run analyze first.",
190
+ );
191
+ process.exit(1);
192
+ }
193
+ const analysis = await readAnalysis(target.repo, target.iid);
194
+ if (!analysis) {
195
+ console.error(
196
+ `No cached analysis at ${target.repo}#${target.iid}. Run: forge-agent analyze ${target.iid}`,
197
+ );
198
+ process.exit(1);
199
+ }
200
+ const state = (await readState(target.repo, target.iid)) ?? {};
201
+ const primary = state.primary ?? target.repo;
202
+ const secondaries = state.secondaries ?? [];
203
+ const model = getFlag(argv, "--model") || undefined;
204
+
205
+ let feedback = getFlag(argv, "--feedback");
206
+ if (!feedback) {
207
+ // Interactive prompt
208
+ await runApp(
209
+ h`<${FeedbackPrompt} onSubmit=${(v) => (feedback = v)} />`,
210
+ );
211
+ if (!feedback) {
212
+ console.error("Feedback is required. Use --feedback \"...\" or enter it interactively.");
213
+ process.exit(1);
214
+ }
215
+ }
216
+
217
+ let outcome = { ok: false };
218
+ const events = [];
219
+ await runApp(
220
+ h`<${RefineView}
221
+ analysis=${analysis}
222
+ primary=${primary}
223
+ secondaries=${secondaries}
224
+ feedback=${feedback}
225
+ model=${model}
226
+ eventSink=${(e) => events.push(e)}
227
+ onDone=${(o) => (outcome = o)} />`,
228
+ );
229
+
230
+ if (outcome.ok && outcome.result) {
231
+ await writeAnalysis(target.repo, target.iid, outcome.result);
232
+ await updateState(target.repo, target.iid, {
233
+ phase: PHASES.REFINED,
234
+ lastError: null,
235
+ lastFeedback: feedback,
236
+ });
237
+ await appendEvent(target.repo, target.iid, {
238
+ type: "session:refine-done",
239
+ feedback,
240
+ });
241
+ } else {
242
+ await updateState(target.repo, target.iid, {
243
+ phase: "refine-failed",
244
+ lastError: outcome.error?.message ?? null,
245
+ });
246
+ await appendEvent(target.repo, target.iid, {
247
+ type: "session:refine-failed",
248
+ feedback,
249
+ error: outcome.error?.message,
250
+ });
251
+ process.exit(2);
252
+ }
253
+ }
@@ -0,0 +1,91 @@
1
+ import path from "node:path";
2
+ import React from "react";
3
+ import { h, Box, Text, runApp } from "./ink.js";
4
+ import { projectRoot } from "./utils.js";
5
+
6
+ function ReposList({ repos }) {
7
+ if (repos.length === 0) {
8
+ return h`<${Box} flexDirection="column">
9
+ <${Text} dimColor>No repos configured.</>
10
+ <${Text}>${"Add one: forge-agent repos add <group/repo>"}</>
11
+ </>`;
12
+ }
13
+ return h`<${Box} flexDirection="column" marginTop=${1}>
14
+ ${repos.map((r, i) =>
15
+ h`<${Box} key=${i}>
16
+ ${i === 0
17
+ ? h`<${Text} color="green"> *</>`
18
+ : h`<${Text}> </>`}
19
+ <${Text}> ${r.id}</>
20
+ </>`,
21
+ )}
22
+ <${Box} marginTop=${1}>
23
+ <${Text} dimColor>${"* default — override per-call with --repo <group/repo>"}</>
24
+ </>
25
+ </>`;
26
+ }
27
+
28
+ export async function cmdRepos(argv) {
29
+ const { get, patch } = await import(
30
+ path.join(projectRoot, "server/forge/core/configStore.js")
31
+ );
32
+ const sub = argv[1] ?? "list";
33
+
34
+ if (sub === "list") {
35
+ const json = argv.includes("--json");
36
+ const repos = get().repos ?? [];
37
+ if (json) {
38
+ process.stdout.write(`${JSON.stringify(repos, null, 2)}\n`);
39
+ return;
40
+ }
41
+ await runApp(h`<${ReposList} repos=${repos} />`);
42
+ return;
43
+ }
44
+
45
+ const repoArg = argv[2];
46
+ if (!repoArg) {
47
+ console.error(`Usage: forge-agent repos ${sub} <group/repo>`);
48
+ process.exit(1);
49
+ }
50
+
51
+ const repos = get().repos ?? [];
52
+
53
+ if (sub === "add") {
54
+ if (repos.some((r) => r.id === repoArg)) {
55
+ console.error(`Already configured: ${repoArg}`);
56
+ process.exit(1);
57
+ }
58
+ await patch({ repos: [...repos, { id: repoArg, label: repoArg }] });
59
+ process.stderr.write(`✓ Added ${repoArg}\n`);
60
+ if (repos.length === 0) {
61
+ process.stderr.write(` (set as default)\n`);
62
+ }
63
+ return;
64
+ }
65
+
66
+ if (sub === "remove" || sub === "rm") {
67
+ if (!repos.some((r) => r.id === repoArg)) {
68
+ console.error(`Not configured: ${repoArg}`);
69
+ process.exit(1);
70
+ }
71
+ await patch({ repos: repos.filter((r) => r.id !== repoArg) });
72
+ process.stderr.write(`✓ Removed ${repoArg}\n`);
73
+ return;
74
+ }
75
+
76
+ if (sub === "set-default" || sub === "default") {
77
+ const target = repos.find((r) => r.id === repoArg);
78
+ if (!target) {
79
+ console.error(`Not configured. Add it first: forge-agent repos add ${repoArg}`);
80
+ process.exit(1);
81
+ }
82
+ const rest = repos.filter((r) => r.id !== repoArg);
83
+ await patch({ repos: [target, ...rest] });
84
+ process.stderr.write(`✓ Default: ${repoArg}\n`);
85
+ return;
86
+ }
87
+
88
+ console.error(`Unknown subcommand: ${sub}`);
89
+ console.error("Subcommands: list, add, set-default, remove");
90
+ process.exit(1);
91
+ }