@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/utils.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
export const projectRoot = path.dirname(path.dirname(__dirname));
|
|
7
|
+
|
|
8
|
+
export async function readPkg() {
|
|
9
|
+
const raw = await fs.readFile(path.join(projectRoot, "package.json"), "utf8");
|
|
10
|
+
return JSON.parse(raw);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getFlag(argv, name) {
|
|
14
|
+
const idx = argv.indexOf(name);
|
|
15
|
+
if (idx !== -1 && argv[idx + 1] && !argv[idx + 1].startsWith("-")) return argv[idx + 1];
|
|
16
|
+
const eq = argv.find((a) => a.startsWith(`${name}=`));
|
|
17
|
+
return eq ? eq.slice(name.length + 1) : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Collect every value of a repeatable flag: --secondary a --secondary b → ["a", "b"].
|
|
22
|
+
*/
|
|
23
|
+
export function getFlagAll(argv, name) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
if (argv[i] === name && argv[i + 1] && !argv[i + 1].startsWith("-")) {
|
|
27
|
+
out.push(argv[i + 1]);
|
|
28
|
+
i += 1;
|
|
29
|
+
} else if (argv[i].startsWith(`${name}=`)) {
|
|
30
|
+
out.push(argv[i].slice(name.length + 1));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function parsePort(argv) {
|
|
37
|
+
let raw;
|
|
38
|
+
const i = argv.indexOf("-p");
|
|
39
|
+
const j = argv.indexOf("--port");
|
|
40
|
+
if (i !== -1 && argv[i + 1]) raw = argv[i + 1];
|
|
41
|
+
else if (j !== -1 && argv[j + 1]) raw = argv[j + 1];
|
|
42
|
+
else {
|
|
43
|
+
const eq = argv.find((a) => a.startsWith("--port="));
|
|
44
|
+
if (eq) raw = eq.slice("--port=".length);
|
|
45
|
+
}
|
|
46
|
+
if (raw == null) return null;
|
|
47
|
+
const n = Number(raw);
|
|
48
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
49
|
+
console.error(`Invalid port: ${raw} (must be 1–65535)`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function relativeAge(iso) {
|
|
56
|
+
if (!iso) return "";
|
|
57
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
58
|
+
if (Number.isNaN(ms) || ms < 0) return "";
|
|
59
|
+
const s = Math.floor(ms / 1000);
|
|
60
|
+
if (s < 60) return `${s}s`;
|
|
61
|
+
const m = Math.floor(s / 60);
|
|
62
|
+
if (m < 60) return `${m}m`;
|
|
63
|
+
const h = Math.floor(m / 60);
|
|
64
|
+
if (h < 24) return `${h}h`;
|
|
65
|
+
const d = Math.floor(h / 24);
|
|
66
|
+
if (d < 30) return `${d}d`;
|
|
67
|
+
const mo = Math.floor(d / 30);
|
|
68
|
+
if (mo < 12) return `${mo}mo`;
|
|
69
|
+
return `${Math.floor(mo / 12)}y`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function resolvePrimaryRepo(argv) {
|
|
73
|
+
const repoFlag = getFlag(argv, "--repo");
|
|
74
|
+
let primary = repoFlag || process.env.FORGE_GITLAB_PROJECT;
|
|
75
|
+
if (!primary) {
|
|
76
|
+
const { get } = await import(
|
|
77
|
+
path.join(projectRoot, "server/forge/core/configStore.js")
|
|
78
|
+
);
|
|
79
|
+
primary = get().repos?.[0]?.id;
|
|
80
|
+
}
|
|
81
|
+
return primary;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Strip a "gitlab:" / "github:" / "azuredevops:" prefix so two repo strings
|
|
86
|
+
* that point at the same project compare equal regardless of where they came
|
|
87
|
+
* from (env vars are usually unprefixed; configStore entries are prefixed).
|
|
88
|
+
*/
|
|
89
|
+
export function canonicalRepoId(id) {
|
|
90
|
+
if (!id) return id;
|
|
91
|
+
return id.replace(/^(gitlab|github|azuredevops):/, "");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve { primary, secondaries } from argv. Honours:
|
|
96
|
+
* - --repo <id> primary repo (overrides default)
|
|
97
|
+
* - --secondary <id> additional repo (repeatable)
|
|
98
|
+
* - --all-repos all configured repos except primary become secondaries
|
|
99
|
+
*
|
|
100
|
+
* Dedup is prefix-insensitive so an env-provided primary doesn't double up
|
|
101
|
+
* with its configStore-prefixed twin.
|
|
102
|
+
*/
|
|
103
|
+
export async function resolveRepoSet(argv) {
|
|
104
|
+
const primary = await resolvePrimaryRepo(argv);
|
|
105
|
+
const explicit = getFlagAll(argv, "--secondary");
|
|
106
|
+
const allRepos = argv.includes("--all-repos");
|
|
107
|
+
|
|
108
|
+
const primaryCanon = canonicalRepoId(primary);
|
|
109
|
+
let secondaries = explicit.filter((id) => canonicalRepoId(id) !== primaryCanon);
|
|
110
|
+
|
|
111
|
+
if (allRepos) {
|
|
112
|
+
const { get } = await import(
|
|
113
|
+
path.join(projectRoot, "server/forge/core/configStore.js")
|
|
114
|
+
);
|
|
115
|
+
const configured = (get().repos ?? []).map((r) => r.id);
|
|
116
|
+
const fromConfig = configured.filter(
|
|
117
|
+
(id) => canonicalRepoId(id) !== primaryCanon,
|
|
118
|
+
);
|
|
119
|
+
const merged = [...secondaries, ...fromConfig];
|
|
120
|
+
// Dedup secondaries against each other (prefix-insensitive, keep first).
|
|
121
|
+
const seen = new Set();
|
|
122
|
+
secondaries = merged.filter((id) => {
|
|
123
|
+
const key = canonicalRepoId(id);
|
|
124
|
+
if (seen.has(key)) return false;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { primary, secondaries };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compare a conventional-commits prefix on two titles. Returns null if both
|
|
134
|
+
* prefixes match or either is missing; otherwise returns { issue, mr } so the
|
|
135
|
+
* caller can warn (e.g. issue is "feat:" but mrTitle starts "fix:").
|
|
136
|
+
*/
|
|
137
|
+
export function prefixDivergence(issueTitle, mrTitle) {
|
|
138
|
+
if (!issueTitle || !mrTitle) return null;
|
|
139
|
+
const re = /^([a-zA-Z]+)(?:\([^)]*\))?!?:/;
|
|
140
|
+
const ip = issueTitle.match(re)?.[1]?.toLowerCase();
|
|
141
|
+
const mp = mrTitle.match(re)?.[1]?.toLowerCase();
|
|
142
|
+
if (!ip || !mp) return null;
|
|
143
|
+
if (ip === mp) return null;
|
|
144
|
+
return { issue: ip, mr: mp };
|
|
145
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
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
|
+
} from "./sessionStore.js";
|
|
14
|
+
|
|
15
|
+
function StepRow({ step, status }) {
|
|
16
|
+
let glyph;
|
|
17
|
+
let color;
|
|
18
|
+
if (status === "running") {
|
|
19
|
+
glyph = h`<${Spinner} type="dots"/>`;
|
|
20
|
+
color = "cyan";
|
|
21
|
+
} else if (status === "passed") {
|
|
22
|
+
glyph = h`<${Text}>✓</>`;
|
|
23
|
+
color = "green";
|
|
24
|
+
} else if (status === "failed") {
|
|
25
|
+
glyph = h`<${Text}>✘</>`;
|
|
26
|
+
color = "red";
|
|
27
|
+
} else {
|
|
28
|
+
glyph = h`<${Text}>○</>`;
|
|
29
|
+
color = "gray";
|
|
30
|
+
}
|
|
31
|
+
return h`<${Box} marginLeft=${4}>
|
|
32
|
+
<${Text} color=${color}>${glyph}</>
|
|
33
|
+
<${Text}> ${step}</>
|
|
34
|
+
</>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function RepoBlock({ repo, projectType, steps, status, allPassed }) {
|
|
38
|
+
const headerColor = status === "done" ? (allPassed ? "green" : "red") : "yellow";
|
|
39
|
+
const headerText =
|
|
40
|
+
status === "done"
|
|
41
|
+
? allPassed
|
|
42
|
+
? `✓ ${repo} (${projectType ?? "?"}) — all checks passed`
|
|
43
|
+
: `✘ ${repo} (${projectType ?? "?"}) — failed`
|
|
44
|
+
: `▸ ${repo} (${projectType ?? "?"})`;
|
|
45
|
+
return h`<${Box} flexDirection="column" marginTop=${1}>
|
|
46
|
+
<${Text} color=${headerColor}>${headerText}</>
|
|
47
|
+
${steps.map((s, i) => h`<${StepRow} key=${i} ...${s} />`)}
|
|
48
|
+
</>`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function LogLine({ message, repo }) {
|
|
52
|
+
return h`<${Box} marginLeft=${4}>
|
|
53
|
+
<${Text} dimColor>${repo ? `[${repo}] ` : ""}${message}</>
|
|
54
|
+
</>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ValidateView({ analysis, eventSink, onDone }) {
|
|
58
|
+
const { exit } = useApp();
|
|
59
|
+
const [repos, setRepos] = useState({});
|
|
60
|
+
const [logs, setLogs] = useState([]);
|
|
61
|
+
const [finished, setFinished] = useState(null);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!finished) return;
|
|
65
|
+
onDone(finished);
|
|
66
|
+
exit();
|
|
67
|
+
}, [finished]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let cancelled = false;
|
|
71
|
+
(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const { validate } = await import(
|
|
74
|
+
path.join(projectRoot, "server/forge/core/validator.js")
|
|
75
|
+
);
|
|
76
|
+
let allOk = true;
|
|
77
|
+
|
|
78
|
+
const onEvent = (evt) => {
|
|
79
|
+
if (cancelled) return;
|
|
80
|
+
eventSink?.(evt);
|
|
81
|
+
const repo = evt.repo;
|
|
82
|
+
|
|
83
|
+
if (evt.type === "repo_started" && repo) {
|
|
84
|
+
setRepos((prev) => ({
|
|
85
|
+
...prev,
|
|
86
|
+
[repo]: {
|
|
87
|
+
projectType: evt.projectType,
|
|
88
|
+
stepOrder: evt.stepOrder ?? [],
|
|
89
|
+
steps: (evt.stepOrder ?? []).map((s) => ({
|
|
90
|
+
step: s,
|
|
91
|
+
status: "pending",
|
|
92
|
+
})),
|
|
93
|
+
status: "running",
|
|
94
|
+
allPassed: null,
|
|
95
|
+
},
|
|
96
|
+
}));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (evt.type === "progress" && repo && evt.step) {
|
|
101
|
+
setRepos((prev) => {
|
|
102
|
+
const cur = prev[repo];
|
|
103
|
+
if (!cur) return prev;
|
|
104
|
+
return {
|
|
105
|
+
...prev,
|
|
106
|
+
[repo]: {
|
|
107
|
+
...cur,
|
|
108
|
+
steps: cur.steps.map((s) =>
|
|
109
|
+
s.step === evt.step ? { ...s, status: "running" } : s,
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (evt.type === "step_result" && repo && evt.step) {
|
|
118
|
+
setRepos((prev) => {
|
|
119
|
+
const cur = prev[repo];
|
|
120
|
+
if (!cur) return prev;
|
|
121
|
+
return {
|
|
122
|
+
...prev,
|
|
123
|
+
[repo]: {
|
|
124
|
+
...cur,
|
|
125
|
+
steps: cur.steps.map((s) =>
|
|
126
|
+
s.step === evt.step
|
|
127
|
+
? { ...s, status: evt.passed ? "passed" : "failed" }
|
|
128
|
+
: s,
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
if (!evt.passed) allOk = false;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (evt.type === "repo_result" && repo) {
|
|
138
|
+
setRepos((prev) => {
|
|
139
|
+
const cur = prev[repo];
|
|
140
|
+
if (!cur) return prev;
|
|
141
|
+
return {
|
|
142
|
+
...prev,
|
|
143
|
+
[repo]: { ...cur, status: "done", allPassed: evt.allPassed },
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
if (!evt.allPassed) allOk = false;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (evt.type === "log") {
|
|
151
|
+
setLogs((prev) => [
|
|
152
|
+
...prev,
|
|
153
|
+
{ message: evt.message, repo: evt.repo },
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const result = await validate(analysis, onEvent);
|
|
159
|
+
if (cancelled) return;
|
|
160
|
+
setFinished({ ok: true, allPassed: allOk && result.allPassed, result });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (cancelled) return;
|
|
163
|
+
setLogs((prev) => [
|
|
164
|
+
...prev,
|
|
165
|
+
{ message: `ERROR: ${err.message ?? String(err)}` },
|
|
166
|
+
]);
|
|
167
|
+
setFinished({ ok: false, error: err });
|
|
168
|
+
}
|
|
169
|
+
})();
|
|
170
|
+
return () => {
|
|
171
|
+
cancelled = true;
|
|
172
|
+
};
|
|
173
|
+
}, []);
|
|
174
|
+
|
|
175
|
+
return h`<${Box} flexDirection="column">
|
|
176
|
+
<${Text}>${`Validating ${analysis.changes?.length ?? 0} change(s)…`}</>
|
|
177
|
+
${Object.entries(repos).map(([repo, r], i) =>
|
|
178
|
+
h`<${RepoBlock} key=${i} repo=${repo} ...${r} />`,
|
|
179
|
+
)}
|
|
180
|
+
${logs.length > 0
|
|
181
|
+
? h`<${Box} flexDirection="column" marginTop=${1}>
|
|
182
|
+
<${Text} dimColor>${"─── log ───"}</>
|
|
183
|
+
${logs.slice(-12).map((l, i) => h`<${LogLine} key=${i} ...${l} />`)}
|
|
184
|
+
</>`
|
|
185
|
+
: null}
|
|
186
|
+
${finished
|
|
187
|
+
? h`<${Box} marginTop=${1}>
|
|
188
|
+
<${Text} color=${finished.ok && finished.allPassed ? "green" : "red"}>
|
|
189
|
+
${finished.ok && finished.allPassed ? "✓ All checks passed." : "✘ Validation failed."}
|
|
190
|
+
</>
|
|
191
|
+
</>`
|
|
192
|
+
: null}
|
|
193
|
+
</>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function cmdValidate(argv) {
|
|
197
|
+
const json = argv.includes("--json");
|
|
198
|
+
const explicitIid = Number(argv[1]);
|
|
199
|
+
const explicitRepo = getFlag(argv, "--repo");
|
|
200
|
+
const target = await resolveSessionTarget({
|
|
201
|
+
explicitIid: Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
|
|
202
|
+
explicitRepo,
|
|
203
|
+
});
|
|
204
|
+
if (!target?.repo || !target?.iid) {
|
|
205
|
+
console.error(
|
|
206
|
+
"No session target. Pass an <iid>, set --repo, or run analyze first.",
|
|
207
|
+
);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
const analysis = await readAnalysis(target.repo, target.iid);
|
|
211
|
+
if (!analysis) {
|
|
212
|
+
console.error(
|
|
213
|
+
`No cached analysis at ${target.repo}#${target.iid}. Run: forge-agent analyze ${target.iid}`,
|
|
214
|
+
);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (json) {
|
|
219
|
+
const { validate } = await import(
|
|
220
|
+
path.join(projectRoot, "server/forge/core/validator.js")
|
|
221
|
+
);
|
|
222
|
+
const events = [];
|
|
223
|
+
const onEvent = (evt) => {
|
|
224
|
+
events.push(evt);
|
|
225
|
+
process.stdout.write(`${JSON.stringify(evt)}\n`);
|
|
226
|
+
};
|
|
227
|
+
try {
|
|
228
|
+
const result = await validate(analysis, onEvent);
|
|
229
|
+
await persistValidateOutcome(target.repo, target.iid, events, result.allPassed, null);
|
|
230
|
+
if (!result.allPassed) process.exit(2);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
process.stdout.write(
|
|
233
|
+
`${JSON.stringify({ type: "error", message: err.message })}\n`,
|
|
234
|
+
);
|
|
235
|
+
await persistValidateOutcome(target.repo, target.iid, events, false, err);
|
|
236
|
+
process.exit(2);
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let outcome = { ok: false };
|
|
242
|
+
const events = [];
|
|
243
|
+
await runApp(
|
|
244
|
+
h`<${ValidateView}
|
|
245
|
+
analysis=${analysis}
|
|
246
|
+
eventSink=${(e) => events.push(e)}
|
|
247
|
+
onDone=${(o) => (outcome = o)} />`,
|
|
248
|
+
);
|
|
249
|
+
await persistValidateOutcome(
|
|
250
|
+
target.repo,
|
|
251
|
+
target.iid,
|
|
252
|
+
events,
|
|
253
|
+
outcome.ok && outcome.allPassed,
|
|
254
|
+
outcome.error,
|
|
255
|
+
);
|
|
256
|
+
if (!outcome.ok || !outcome.allPassed) process.exit(2);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function persistValidateOutcome(repo, iid, events, passed, error) {
|
|
260
|
+
try {
|
|
261
|
+
await updateState(repo, iid, {
|
|
262
|
+
validation: {
|
|
263
|
+
passed: !!passed,
|
|
264
|
+
checkedAt: new Date().toISOString(),
|
|
265
|
+
error: error?.message ?? null,
|
|
266
|
+
},
|
|
267
|
+
phase: passed ? PHASES.VALIDATED : "validate-failed",
|
|
268
|
+
lastError: error?.message ?? null,
|
|
269
|
+
});
|
|
270
|
+
await appendEvent(repo, iid, { type: "session:validate-start" });
|
|
271
|
+
for (const e of events) await appendEvent(repo, iid, e);
|
|
272
|
+
await appendEvent(repo, iid, {
|
|
273
|
+
type: "session:validate-end",
|
|
274
|
+
passed: !!passed,
|
|
275
|
+
error: error?.message,
|
|
276
|
+
});
|
|
277
|
+
} catch (err) {
|
|
278
|
+
process.stderr.write(`forge: failed to persist session: ${err.message}\n`);
|
|
279
|
+
}
|
|
280
|
+
}
|
package/bin/cli/watch.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
readState,
|
|
10
|
+
resolveSessionTarget,
|
|
11
|
+
updateState,
|
|
12
|
+
} from "./sessionStore.js";
|
|
13
|
+
|
|
14
|
+
const POLL_MS = 5000;
|
|
15
|
+
const TERMINAL_PIPELINE = new Set([
|
|
16
|
+
"success",
|
|
17
|
+
"failed",
|
|
18
|
+
"canceled",
|
|
19
|
+
"skipped",
|
|
20
|
+
"manual",
|
|
21
|
+
]);
|
|
22
|
+
const TERMINAL_JOB = new Set([
|
|
23
|
+
"success",
|
|
24
|
+
"failed",
|
|
25
|
+
"canceled",
|
|
26
|
+
"skipped",
|
|
27
|
+
"manual",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function jobStatusIcon(status) {
|
|
31
|
+
if (status === "success") return { glyph: "✓", color: "green" };
|
|
32
|
+
if (status === "failed") return { glyph: "✘", color: "red" };
|
|
33
|
+
if (status === "canceled" || status === "skipped") return { glyph: "○", color: "gray" };
|
|
34
|
+
if (status === "manual") return { glyph: "▶", color: "yellow" };
|
|
35
|
+
if (status === "running") return { glyph: "●", color: "cyan" };
|
|
36
|
+
if (status === "pending" || status === "created" || status === "scheduled")
|
|
37
|
+
return { glyph: "·", color: "yellow" };
|
|
38
|
+
return { glyph: "?", color: undefined };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pipelineStatusColor(status) {
|
|
42
|
+
if (status === "success") return "green";
|
|
43
|
+
if (status === "failed") return "red";
|
|
44
|
+
if (status === "running" || status === "pending") return "cyan";
|
|
45
|
+
if (status === "canceled" || status === "skipped") return "gray";
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fmtDuration(s) {
|
|
50
|
+
if (s == null) return "";
|
|
51
|
+
if (s < 60) return `${Math.round(s)}s`;
|
|
52
|
+
const m = Math.floor(s / 60);
|
|
53
|
+
const r = Math.round(s - m * 60);
|
|
54
|
+
return `${m}m${r > 0 ? `${r}s` : ""}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function PipelineRow({ pipeline }) {
|
|
58
|
+
if (!pipeline) {
|
|
59
|
+
return h`<${Box}>
|
|
60
|
+
<${Text} color="cyan"><${Spinner} type="dots"/></>
|
|
61
|
+
<${Text}> Waiting for pipeline to start…</>
|
|
62
|
+
</>`;
|
|
63
|
+
}
|
|
64
|
+
const color = pipelineStatusColor(pipeline.status);
|
|
65
|
+
return h`<${Box}>
|
|
66
|
+
<${Text}>Pipeline </>
|
|
67
|
+
<${Text} dimColor>#${pipeline.id} </>
|
|
68
|
+
<${Text} color=${color} bold>${pipeline.status.padEnd(8)}</>
|
|
69
|
+
${pipeline.web_url ? h`<${Text} dimColor> ${pipeline.web_url}</>` : null}
|
|
70
|
+
</>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function JobsTable({ jobs }) {
|
|
74
|
+
if (!jobs?.length) return null;
|
|
75
|
+
const stages = [...new Set(jobs.map((j) => j.stage ?? "(no-stage)"))];
|
|
76
|
+
return h`<${Box} flexDirection="column" marginTop=${1}>
|
|
77
|
+
${stages.map((stage, si) =>
|
|
78
|
+
h`<${Box} flexDirection="column" key=${si}>
|
|
79
|
+
<${Text} dimColor>${stage}</>
|
|
80
|
+
${jobs
|
|
81
|
+
.filter((j) => (j.stage ?? "(no-stage)") === stage)
|
|
82
|
+
.map((j, ji) => {
|
|
83
|
+
const { glyph, color } = jobStatusIcon(j.status);
|
|
84
|
+
return h`<${Box} key=${ji} marginLeft=${2}>
|
|
85
|
+
<${Text} color=${color}>${glyph}</>
|
|
86
|
+
<${Text}> ${j.name.padEnd(28)}</>
|
|
87
|
+
<${Text} color=${color}>${j.status.padEnd(10)}</>
|
|
88
|
+
<${Text} dimColor>${fmtDuration(j.duration)}</>
|
|
89
|
+
</>`;
|
|
90
|
+
})}
|
|
91
|
+
</>`,
|
|
92
|
+
)}
|
|
93
|
+
</>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function WatchView({ repo, mrIid, onDone }) {
|
|
97
|
+
const { exit } = useApp();
|
|
98
|
+
const [pipeline, setPipeline] = useState(null);
|
|
99
|
+
const [jobs, setJobs] = useState([]);
|
|
100
|
+
const [error, setError] = useState(null);
|
|
101
|
+
const [tick, setTick] = useState(0);
|
|
102
|
+
const [done, setDone] = useState(null);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!done) return;
|
|
106
|
+
onDone(done);
|
|
107
|
+
exit();
|
|
108
|
+
}, [done]);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
let cancelled = false;
|
|
112
|
+
let timer;
|
|
113
|
+
|
|
114
|
+
(async () => {
|
|
115
|
+
try {
|
|
116
|
+
const { createProvider } = await import(
|
|
117
|
+
path.join(projectRoot, "server/forge/core/providers/index.js")
|
|
118
|
+
);
|
|
119
|
+
const provider = createProvider(repo);
|
|
120
|
+
|
|
121
|
+
const poll = async () => {
|
|
122
|
+
if (cancelled) return;
|
|
123
|
+
try {
|
|
124
|
+
const pipelines = await provider.getMRPipelines(mrIid);
|
|
125
|
+
if (!pipelines?.length) {
|
|
126
|
+
setPipeline(null);
|
|
127
|
+
setJobs([]);
|
|
128
|
+
} else {
|
|
129
|
+
const latest = pipelines[0];
|
|
130
|
+
const [pipe, list] = await Promise.all([
|
|
131
|
+
provider.getPipeline(latest.id),
|
|
132
|
+
provider.getPipelineJobs(latest.id),
|
|
133
|
+
]);
|
|
134
|
+
if (cancelled) return;
|
|
135
|
+
setPipeline(pipe);
|
|
136
|
+
setJobs(list);
|
|
137
|
+
if (TERMINAL_PIPELINE.has(pipe.status)) {
|
|
138
|
+
setDone({ ok: true, pipeline: pipe, jobs: list });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (cancelled) return;
|
|
144
|
+
setError(err.message ?? String(err));
|
|
145
|
+
}
|
|
146
|
+
setTick((t) => t + 1);
|
|
147
|
+
timer = setTimeout(poll, POLL_MS);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
await poll();
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (cancelled) return;
|
|
153
|
+
setDone({ ok: false, error: err });
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
|
|
157
|
+
return () => {
|
|
158
|
+
cancelled = true;
|
|
159
|
+
if (timer) clearTimeout(timer);
|
|
160
|
+
};
|
|
161
|
+
}, []);
|
|
162
|
+
|
|
163
|
+
return h`<${Box} flexDirection="column">
|
|
164
|
+
<${Box}>
|
|
165
|
+
<${Text} color="yellow">Watching </>
|
|
166
|
+
<${Text}>${repo} !${mrIid}</>
|
|
167
|
+
<${Text} dimColor>${` (poll #${tick + 1}, every ${POLL_MS / 1000}s)`}</>
|
|
168
|
+
</>
|
|
169
|
+
<${Box} marginTop=${1}><${PipelineRow} pipeline=${pipeline} /></>
|
|
170
|
+
<${JobsTable} jobs=${jobs} />
|
|
171
|
+
${error ? h`<${Box} marginTop=${1}><${Text} color="red">${error}</></>` : null}
|
|
172
|
+
${done?.ok
|
|
173
|
+
? h`<${Box} marginTop=${1}>
|
|
174
|
+
<${Text} color=${pipelineStatusColor(done.pipeline.status)}>
|
|
175
|
+
${`Pipeline finished: ${done.pipeline.status}`}
|
|
176
|
+
</>
|
|
177
|
+
</>`
|
|
178
|
+
: null}
|
|
179
|
+
</>`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function cmdWatch(argv) {
|
|
183
|
+
const json = argv.includes("--json");
|
|
184
|
+
const explicitIid = Number(argv[1]);
|
|
185
|
+
const explicitRepo = getFlag(argv, "--repo");
|
|
186
|
+
const explicitMr = Number(getFlag(argv, "--mr"));
|
|
187
|
+
|
|
188
|
+
let repo;
|
|
189
|
+
let mrIid;
|
|
190
|
+
|
|
191
|
+
if (Number.isInteger(explicitMr) && explicitMr > 0) {
|
|
192
|
+
repo = explicitRepo;
|
|
193
|
+
mrIid = explicitMr;
|
|
194
|
+
} else {
|
|
195
|
+
const target = await resolveSessionTarget({
|
|
196
|
+
explicitIid:
|
|
197
|
+
Number.isInteger(explicitIid) && explicitIid > 0 ? explicitIid : null,
|
|
198
|
+
explicitRepo,
|
|
199
|
+
});
|
|
200
|
+
if (!target?.repo || !target?.iid) {
|
|
201
|
+
console.error(
|
|
202
|
+
"No session target. Pass --mr <iid> --repo <repo>, or run analyze + apply first.",
|
|
203
|
+
);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
const state = await readState(target.repo, target.iid);
|
|
207
|
+
if (!state?.mrIid) {
|
|
208
|
+
console.error(
|
|
209
|
+
`Session ${target.repo}#${target.iid} has no MR yet. Run: forge-agent apply ${target.iid}`,
|
|
210
|
+
);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
repo = state.primary ?? target.repo;
|
|
214
|
+
mrIid = state.mrIid;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!repo) {
|
|
218
|
+
console.error("Repo is required: pass --repo <id>");
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (json) {
|
|
223
|
+
const { createProvider } = await import(
|
|
224
|
+
path.join(projectRoot, "server/forge/core/providers/index.js")
|
|
225
|
+
);
|
|
226
|
+
const provider = createProvider(repo);
|
|
227
|
+
while (true) {
|
|
228
|
+
const pipelines = await provider.getMRPipelines(mrIid).catch((err) => {
|
|
229
|
+
process.stdout.write(JSON.stringify({ type: "error", message: err.message }) + "\n");
|
|
230
|
+
return null;
|
|
231
|
+
});
|
|
232
|
+
if (!pipelines?.length) {
|
|
233
|
+
process.stdout.write(JSON.stringify({ type: "tick", pipeline: null }) + "\n");
|
|
234
|
+
} else {
|
|
235
|
+
const latest = pipelines[0];
|
|
236
|
+
const [pipe, list] = await Promise.all([
|
|
237
|
+
provider.getPipeline(latest.id),
|
|
238
|
+
provider.getPipelineJobs(latest.id),
|
|
239
|
+
]);
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
JSON.stringify({ type: "tick", pipeline: pipe, jobs: list }) + "\n",
|
|
242
|
+
);
|
|
243
|
+
if (TERMINAL_PIPELINE.has(pipe.status)) {
|
|
244
|
+
process.exit(pipe.status === "success" ? 0 : 2);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let outcome = { ok: false };
|
|
252
|
+
await runApp(
|
|
253
|
+
h`<${WatchView}
|
|
254
|
+
repo=${repo}
|
|
255
|
+
mrIid=${mrIid}
|
|
256
|
+
onDone=${(o) => (outcome = o)} />`,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Persist last pipeline state if we have a session
|
|
260
|
+
if (Number.isInteger(explicitIid) && explicitIid > 0 && explicitRepo) {
|
|
261
|
+
await updateState(explicitRepo, explicitIid, {
|
|
262
|
+
lastPipelineStatus: outcome.pipeline?.status ?? null,
|
|
263
|
+
lastPipelineUrl: outcome.pipeline?.web_url ?? null,
|
|
264
|
+
}).catch(() => {});
|
|
265
|
+
await appendEvent(explicitRepo, explicitIid, {
|
|
266
|
+
type: "session:watch-end",
|
|
267
|
+
status: outcome.pipeline?.status,
|
|
268
|
+
}).catch(() => {});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!outcome.ok || outcome.pipeline?.status !== "success") process.exit(2);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export { TERMINAL_PIPELINE, TERMINAL_JOB };
|