@samaksh/git-worker 0.0.0-stage → 0.2.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 +114 -2
- package/dist/app.d.ts +2 -0
- package/dist/app.js +48 -0
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +111 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +115 -0
- package/dist/config/defaults.d.ts +2 -0
- package/dist/config/defaults.js +44 -0
- package/dist/config/load.d.ts +3 -0
- package/dist/config/load.js +65 -0
- package/dist/config/schema.d.ts +200 -0
- package/dist/config/schema.js +45 -0
- package/dist/core/errors.d.ts +12 -0
- package/dist/core/errors.js +34 -0
- package/dist/core/pipeline.d.ts +3 -0
- package/dist/core/pipeline.js +14 -0
- package/dist/core/types.d.ts +153 -0
- package/dist/core/types.js +16 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/services/editor/index.d.ts +9 -0
- package/dist/services/editor/index.js +59 -0
- package/dist/services/git/ops.d.ts +30 -0
- package/dist/services/git/ops.js +139 -0
- package/dist/services/git/reader.d.ts +14 -0
- package/dist/services/git/reader.js +195 -0
- package/dist/services/report/index.d.ts +18 -0
- package/dist/services/report/index.js +68 -0
- package/dist/services/review/index.d.ts +30 -0
- package/dist/services/review/index.js +59 -0
- package/dist/services/state/index.d.ts +20 -0
- package/dist/services/state/index.js +49 -0
- package/dist/services/template/env.d.ts +23 -0
- package/dist/services/template/env.js +146 -0
- package/dist/services/template/interpolator.d.ts +30 -0
- package/dist/services/template/interpolator.js +75 -0
- package/dist/services/template/transform.d.ts +22 -0
- package/dist/services/template/transform.js +75 -0
- package/dist/services/template/validator.d.ts +5 -0
- package/dist/services/template/validator.js +68 -0
- package/dist/services/workspace/cache.d.ts +32 -0
- package/dist/services/workspace/cache.js +107 -0
- package/dist/services/workspace/manager.d.ts +44 -0
- package/dist/services/workspace/manager.js +301 -0
- package/dist/stages/apply.d.ts +3 -0
- package/dist/stages/apply.js +161 -0
- package/dist/stages/edit-template.d.ts +3 -0
- package/dist/stages/edit-template.js +198 -0
- package/dist/stages/eligibility.d.ts +3 -0
- package/dist/stages/eligibility.js +68 -0
- package/dist/stages/load-template.d.ts +3 -0
- package/dist/stages/load-template.js +168 -0
- package/dist/stages/plan.d.ts +3 -0
- package/dist/stages/plan.js +270 -0
- package/dist/stages/push.d.ts +3 -0
- package/dist/stages/push.js +99 -0
- package/dist/stages/report.d.ts +3 -0
- package/dist/stages/report.js +103 -0
- package/dist/stages/resolve-project.d.ts +3 -0
- package/dist/stages/resolve-project.js +294 -0
- package/dist/stages/review.d.ts +3 -0
- package/dist/stages/review.js +197 -0
- package/dist/stages/select.d.ts +13 -0
- package/dist/stages/select.js +265 -0
- package/dist/stages/sync.d.ts +3 -0
- package/dist/stages/sync.js +47 -0
- package/dist/utils/hash.d.ts +2 -0
- package/dist/utils/hash.js +7 -0
- package/dist/utils/ui.d.ts +21 -0
- package/dist/utils/ui.js +113 -0
- package/dist/utils/which.d.ts +5 -0
- package/dist/utils/which.js +16 -0
- package/docs/FEATURES.md +84 -0
- package/docs/SERVICES.md +57 -0
- package/docs/architecture.md +38 -0
- package/docs/usage.md +337 -0
- package/package.json +58 -4
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ok } from "../core/types.js";
|
|
2
|
+
import { sectionHeader, statusLine, divider, renderBranchTable } from "../utils/ui.js";
|
|
3
|
+
import { saveJson, saveMarkdown } from "../services/report/index.js";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
export const reportResultsStage = async (context, deps) => {
|
|
6
|
+
sectionHeader("Run Report");
|
|
7
|
+
const results = context.results ?? [];
|
|
8
|
+
const pushResults = context.pushResults ?? [];
|
|
9
|
+
// Per-branch status
|
|
10
|
+
renderBranchTable(results.map((r) => ({
|
|
11
|
+
name: r.branch.name,
|
|
12
|
+
status: r.status,
|
|
13
|
+
detail: r.commitSha
|
|
14
|
+
? `commit ${r.commitSha.slice(0, 8)}`
|
|
15
|
+
: r.reason ?? "",
|
|
16
|
+
})));
|
|
17
|
+
// Push results (only non-skipped)
|
|
18
|
+
const activePushResults = pushResults.filter((p) => p.status !== "skipped");
|
|
19
|
+
if (activePushResults.length > 0) {
|
|
20
|
+
console.log("");
|
|
21
|
+
console.log(chalk.gray(" Push:"));
|
|
22
|
+
for (const pr of activePushResults) {
|
|
23
|
+
if (pr.status === "pushed") {
|
|
24
|
+
statusLine("ok", pr.branch.name, "pushed");
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
statusLine("fail", pr.branch.name, pr.reason ?? "push failed");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Aggregate summary
|
|
32
|
+
const committed = results.filter((r) => r.status === "committed").length;
|
|
33
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
34
|
+
const rejected = results.filter((r) => r.status === "rejected").length;
|
|
35
|
+
const unchanged = results.filter((r) => r.status === "unchanged").length;
|
|
36
|
+
const skipped = results.filter((r) => r.status === "skipped").length;
|
|
37
|
+
const pushed = pushResults.filter((p) => p.status === "pushed").length;
|
|
38
|
+
divider();
|
|
39
|
+
console.log("");
|
|
40
|
+
console.log(` ${chalk.bold("Total:")} ` +
|
|
41
|
+
[
|
|
42
|
+
committed > 0 ? chalk.green(`${committed} committed`) : "",
|
|
43
|
+
pushed > 0 ? chalk.green(`${pushed} pushed`) : "",
|
|
44
|
+
failed > 0 ? chalk.red(`${failed} failed`) : "",
|
|
45
|
+
rejected > 0 ? chalk.yellow(`${rejected} rejected`) : "",
|
|
46
|
+
unchanged > 0 ? chalk.gray(`${unchanged} unchanged`) : "",
|
|
47
|
+
skipped > 0 ? chalk.gray(`${skipped} skipped`) : "",
|
|
48
|
+
].filter(Boolean).join(" "));
|
|
49
|
+
console.log("");
|
|
50
|
+
// Save template state if any commits succeeded
|
|
51
|
+
const rawTemplate = context.activeTemplate?.raw ?? context.template?.raw;
|
|
52
|
+
if (committed > 0 && context.project && context.target && rawTemplate) {
|
|
53
|
+
const summary = {
|
|
54
|
+
committed,
|
|
55
|
+
failed,
|
|
56
|
+
skipped: skipped + rejected,
|
|
57
|
+
total: results.length,
|
|
58
|
+
};
|
|
59
|
+
const saveResult = await deps.services.state.saveTemplate(context.project.stateDir, context.target.path, rawTemplate, summary);
|
|
60
|
+
if (saveResult.ok) {
|
|
61
|
+
statusLine("ok", `Template saved for '${context.target.path}' (ready for next run)`);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
statusLine("warn", "Could not save template state", saveResult.error.message);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (committed === 0 && context.project && context.target && rawTemplate) {
|
|
68
|
+
// No commits but everything matches — offer to save reviewed template
|
|
69
|
+
const { clack } = await import("../utils/ui.js");
|
|
70
|
+
if (!context.input.ci) {
|
|
71
|
+
const save = await clack.confirm({
|
|
72
|
+
message: "No changes committed. Save current template for next run?",
|
|
73
|
+
initialValue: true,
|
|
74
|
+
});
|
|
75
|
+
if (!clack.isCancel(save) && save) {
|
|
76
|
+
await deps.services.state.saveTemplate(context.project.stateDir, context.target.path, rawTemplate);
|
|
77
|
+
statusLine("ok", `Template saved for '${context.target.path}'`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Write JSON/Markdown reports if configured
|
|
82
|
+
if (context.project && (deps.config.report.writeJson || deps.config.report.writeMarkdown)) {
|
|
83
|
+
const report = {
|
|
84
|
+
runId: context.runId,
|
|
85
|
+
targetPath: context.target?.path ?? "",
|
|
86
|
+
startedAt: new Date().toISOString(),
|
|
87
|
+
completedAt: new Date().toISOString(),
|
|
88
|
+
plans: context.plans ?? [],
|
|
89
|
+
results,
|
|
90
|
+
pushResults,
|
|
91
|
+
};
|
|
92
|
+
await saveJson(context.project.reportsDir, report, deps.config).catch(() => { });
|
|
93
|
+
await saveMarkdown(context.project.reportsDir, report, deps.config).catch(() => { });
|
|
94
|
+
if (deps.config.report.writeJson) {
|
|
95
|
+
statusLine("info", `JSON report: ${context.project.reportsDir}/${context.runId}.json`);
|
|
96
|
+
}
|
|
97
|
+
if (deps.config.report.writeMarkdown) {
|
|
98
|
+
statusLine("info", `Markdown report: ${context.project.reportsDir}/${context.runId}.md`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
console.log("");
|
|
102
|
+
return ok(context);
|
|
103
|
+
};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { ok, err } from "../core/types.js";
|
|
2
|
+
import { clack, banner, statusLine, sectionHeader, sym } from "../utils/ui.js";
|
|
3
|
+
import { inputCancelledError } from "../core/errors.js";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { listCachedProjects } from "../services/workspace/cache.js";
|
|
8
|
+
import { getWorkspaceRoot } from "../services/workspace/manager.js";
|
|
9
|
+
async function promptCachedRepo(cachedProjects, root) {
|
|
10
|
+
if (cachedProjects.length === 0) {
|
|
11
|
+
clack.log.info(`No cached repositories found in ${root}/projects yet.`);
|
|
12
|
+
return "__BACK__";
|
|
13
|
+
}
|
|
14
|
+
console.log("");
|
|
15
|
+
console.log(chalk.bold(` ${"Name".padEnd(20)} ${"Remote URL / Path".padEnd(46)} Cached Date`));
|
|
16
|
+
console.log(chalk.gray(" " + "─".repeat(80)));
|
|
17
|
+
for (const p of cachedProjects) {
|
|
18
|
+
const name = chalk.cyan(p.name.slice(0, 18).padEnd(20));
|
|
19
|
+
const target = (p.remoteUrl || p.sourceRepoPath || "(unknown)").slice(0, 44).padEnd(46);
|
|
20
|
+
const date = p.createdAt ? p.createdAt.slice(0, 10) : "unknown";
|
|
21
|
+
console.log(` ${name} ${chalk.white(target)} ${chalk.gray(date)}`);
|
|
22
|
+
}
|
|
23
|
+
console.log(chalk.gray(" " + "─".repeat(80)));
|
|
24
|
+
console.log("");
|
|
25
|
+
const selected = await clack.select({
|
|
26
|
+
message: "Select a cached repository to target:",
|
|
27
|
+
options: [
|
|
28
|
+
...cachedProjects.map((p) => ({
|
|
29
|
+
value: p.remoteUrl || p.sourceRepoPath || p.id,
|
|
30
|
+
label: `${sym.branch} ${chalk.bold(p.name)}`,
|
|
31
|
+
hint: `${p.remoteUrl || p.sourceRepoPath}`,
|
|
32
|
+
})),
|
|
33
|
+
{
|
|
34
|
+
value: "__BACK__",
|
|
35
|
+
label: "← Back to repository menu",
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
if (clack.isCancel(selected)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return selected;
|
|
43
|
+
}
|
|
44
|
+
export const resolveProjectStage = async (context, deps) => {
|
|
45
|
+
banner();
|
|
46
|
+
let targetRepo = context.input.repo;
|
|
47
|
+
// If repo was not supplied via CLI argument (-r / --repo)
|
|
48
|
+
if (!targetRepo) {
|
|
49
|
+
const isCi = context.input.ci ?? false;
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
// Check if the current working directory is a git repository
|
|
52
|
+
const localCheck = await deps.services.workspace.checkLocalRepo(cwd, context.input.remote || deps.config.git.remote);
|
|
53
|
+
if (isCi) {
|
|
54
|
+
// In CI mode, cannot prompt: use cwd if git repo, otherwise fail
|
|
55
|
+
if (localCheck.isRepo) {
|
|
56
|
+
targetRepo = cwd;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
clack.log.error(`Current directory '${cwd}' is not a Git repository. Pass --repo <url-or-path> in CI mode.`);
|
|
60
|
+
return err(inputCancelledError("Not a git repository in CI mode without --repo flag"));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// Interactive TUI mode
|
|
65
|
+
sectionHeader("Target Repository");
|
|
66
|
+
const root = getWorkspaceRoot(deps.config);
|
|
67
|
+
while (!targetRepo) {
|
|
68
|
+
const cachedProjects = await listCachedProjects(deps.config);
|
|
69
|
+
// If user specifically requested --cached via CLI
|
|
70
|
+
if (context.input.cached) {
|
|
71
|
+
if (cachedProjects.length === 0) {
|
|
72
|
+
clack.log.warn(`No cached repositories found in ${root}/projects yet.`);
|
|
73
|
+
context.input.cached = false;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const selected = await promptCachedRepo(cachedProjects, root);
|
|
77
|
+
if (selected === null) {
|
|
78
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
79
|
+
}
|
|
80
|
+
if (selected === "__BACK__") {
|
|
81
|
+
context.input.cached = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
targetRepo = selected;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (localCheck.isRepo) {
|
|
88
|
+
// Current directory IS a git repo
|
|
89
|
+
const remoteInfo = localCheck.remoteUrl
|
|
90
|
+
? ` (remote: ${localCheck.remoteUrl})`
|
|
91
|
+
: " (local-only)";
|
|
92
|
+
statusLine("ok", "Detected Git repository in current directory", `${cwd}${remoteInfo}`);
|
|
93
|
+
console.log("");
|
|
94
|
+
const choice = await clack.select({
|
|
95
|
+
message: "What repository would you like to target?",
|
|
96
|
+
options: [
|
|
97
|
+
{
|
|
98
|
+
value: "current",
|
|
99
|
+
label: "Use current repository",
|
|
100
|
+
hint: localCheck.remoteUrl || cwd,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
value: "cached",
|
|
104
|
+
label: `📦 Select from cached repositories (${cachedProjects.length} available)`,
|
|
105
|
+
hint: "list already cloned repositories (name, remote URL)",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
value: "remote",
|
|
109
|
+
label: "Clone a remote repository URL",
|
|
110
|
+
hint: "https://... or git@...",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
value: "local-path",
|
|
114
|
+
label: "Specify a different local repository path",
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
if (clack.isCancel(choice)) {
|
|
119
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
120
|
+
}
|
|
121
|
+
if (choice === "current") {
|
|
122
|
+
targetRepo = cwd;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (choice === "cached") {
|
|
126
|
+
const selected = await promptCachedRepo(cachedProjects, root);
|
|
127
|
+
if (selected === null) {
|
|
128
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
129
|
+
}
|
|
130
|
+
if (selected === "__BACK__") {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
targetRepo = selected;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (choice === "remote") {
|
|
137
|
+
const remoteUrlInput = await clack.text({
|
|
138
|
+
message: "Remote repository URL:",
|
|
139
|
+
placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
|
|
140
|
+
validate: (v) => {
|
|
141
|
+
if (!v || v.trim() === "")
|
|
142
|
+
return "Repository URL cannot be empty";
|
|
143
|
+
const trimmed = v.trim();
|
|
144
|
+
const isUrl = trimmed.startsWith("http://") ||
|
|
145
|
+
trimmed.startsWith("https://") ||
|
|
146
|
+
trimmed.startsWith("git@") ||
|
|
147
|
+
trimmed.startsWith("ssh://") ||
|
|
148
|
+
trimmed.startsWith("git://");
|
|
149
|
+
if (!isUrl) {
|
|
150
|
+
return "Please enter a valid remote URL (e.g. https://github.com/... or git@github.com:...)";
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
if (clack.isCancel(remoteUrlInput)) {
|
|
155
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
156
|
+
}
|
|
157
|
+
targetRepo = remoteUrlInput.trim();
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
if (choice === "local-path") {
|
|
161
|
+
const pathInput = await clack.text({
|
|
162
|
+
message: "Path to local repository:",
|
|
163
|
+
placeholder: "/path/to/repo or ../other-repo",
|
|
164
|
+
validate: (v) => {
|
|
165
|
+
if (!v || v.trim() === "")
|
|
166
|
+
return "Path cannot be empty";
|
|
167
|
+
if (!existsSync(resolve(v.trim())))
|
|
168
|
+
return `Path does not exist: ${v.trim()}`;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
if (clack.isCancel(pathInput)) {
|
|
172
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
173
|
+
}
|
|
174
|
+
targetRepo = pathInput.trim();
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
// Current directory IS NOT a git repo
|
|
180
|
+
statusLine("info", "Current directory is not a Git repository", cwd);
|
|
181
|
+
console.log("");
|
|
182
|
+
const choice = await clack.select({
|
|
183
|
+
message: "How would you like to target a repository?",
|
|
184
|
+
options: [
|
|
185
|
+
...(cachedProjects.length > 0
|
|
186
|
+
? [
|
|
187
|
+
{
|
|
188
|
+
value: "cached",
|
|
189
|
+
label: `📦 Select from cached repositories (${cachedProjects.length} available)`,
|
|
190
|
+
hint: "list already cloned repositories (name, remote URL)",
|
|
191
|
+
},
|
|
192
|
+
]
|
|
193
|
+
: [
|
|
194
|
+
{
|
|
195
|
+
value: "cached",
|
|
196
|
+
label: "📦 Cached repositories (none yet)",
|
|
197
|
+
hint: "view already cloned repositories in ~/.git-worker",
|
|
198
|
+
},
|
|
199
|
+
]),
|
|
200
|
+
{
|
|
201
|
+
value: "remote",
|
|
202
|
+
label: "Clone a remote repository URL",
|
|
203
|
+
hint: "recommended — https://... or git@...",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
value: "local-path",
|
|
207
|
+
label: "Specify path to a local Git repository",
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
});
|
|
211
|
+
if (clack.isCancel(choice)) {
|
|
212
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
213
|
+
}
|
|
214
|
+
if (choice === "cached") {
|
|
215
|
+
const selected = await promptCachedRepo(cachedProjects, root);
|
|
216
|
+
if (selected === null) {
|
|
217
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
218
|
+
}
|
|
219
|
+
if (selected === "__BACK__") {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
targetRepo = selected;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
if (choice === "remote") {
|
|
226
|
+
const remoteUrlInput = await clack.text({
|
|
227
|
+
message: "Remote repository URL:",
|
|
228
|
+
placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git",
|
|
229
|
+
validate: (v) => {
|
|
230
|
+
if (!v || v.trim() === "")
|
|
231
|
+
return "Repository URL cannot be empty";
|
|
232
|
+
const trimmed = v.trim();
|
|
233
|
+
const isUrl = trimmed.startsWith("http://") ||
|
|
234
|
+
trimmed.startsWith("https://") ||
|
|
235
|
+
trimmed.startsWith("git@") ||
|
|
236
|
+
trimmed.startsWith("ssh://") ||
|
|
237
|
+
trimmed.startsWith("git://");
|
|
238
|
+
if (!isUrl) {
|
|
239
|
+
return "Please enter a valid remote URL (e.g. https://github.com/... or git@github.com:...)";
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
if (clack.isCancel(remoteUrlInput)) {
|
|
244
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
245
|
+
}
|
|
246
|
+
targetRepo = remoteUrlInput.trim();
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (choice === "local-path") {
|
|
250
|
+
const pathInput = await clack.text({
|
|
251
|
+
message: "Path to local repository:",
|
|
252
|
+
placeholder: "/path/to/repo or ../other-repo",
|
|
253
|
+
validate: (v) => {
|
|
254
|
+
if (!v || v.trim() === "")
|
|
255
|
+
return "Path cannot be empty";
|
|
256
|
+
if (!existsSync(resolve(v.trim())))
|
|
257
|
+
return `Path does not exist: ${v.trim()}`;
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
if (clack.isCancel(pathInput)) {
|
|
261
|
+
return err(inputCancelledError("Repository selection cancelled"));
|
|
262
|
+
}
|
|
263
|
+
targetRepo = pathInput.trim();
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Now resolve the project workspace
|
|
271
|
+
const s = clack.spinner();
|
|
272
|
+
s.start("Resolving project workspace…");
|
|
273
|
+
const result = await deps.services.workspace.resolveProject(targetRepo, context.input.remote, deps.config);
|
|
274
|
+
if (!result.ok) {
|
|
275
|
+
s.stop(chalk.red("Project resolution failed"));
|
|
276
|
+
clack.log.error(result.error.message);
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
s.stop(chalk.green("Project workspace ready"));
|
|
280
|
+
if (result.value.isCached) {
|
|
281
|
+
statusLine("info", "Using cached repository clone (already downloaded)", result.value.remoteUrl || result.value.managedRepoPath);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
statusLine("info", `Managed workspace: ${result.value.managedRepoPath}`);
|
|
285
|
+
}
|
|
286
|
+
return ok({
|
|
287
|
+
...context,
|
|
288
|
+
input: {
|
|
289
|
+
...context.input,
|
|
290
|
+
repo: targetRepo,
|
|
291
|
+
},
|
|
292
|
+
project: result.value,
|
|
293
|
+
});
|
|
294
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { ok, err } from "../core/types.js";
|
|
2
|
+
import { workspaceError, inputCancelledError } from "../core/errors.js";
|
|
3
|
+
import { writeSnapshots } from "../services/review/index.js";
|
|
4
|
+
import { clack, sectionHeader, statusLine, divider, renderDiff, renderBranchTable, sym } from "../utils/ui.js";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
export const reviewChangesStage = async (context, deps) => {
|
|
7
|
+
if (!context.plans || !context.project) {
|
|
8
|
+
return err(workspaceError("No plans to review"));
|
|
9
|
+
}
|
|
10
|
+
const isCi = context.input.ci ?? false;
|
|
11
|
+
const autoApprove = context.input.autoApprove || deps.config.review.autoApprove;
|
|
12
|
+
sectionHeader("Review Changes");
|
|
13
|
+
// Show summary table first
|
|
14
|
+
renderBranchTable(context.plans.map((p) => ({
|
|
15
|
+
name: p.branch.name,
|
|
16
|
+
status: p.status,
|
|
17
|
+
detail: p.status === "changed"
|
|
18
|
+
? `${p.updatedKeys.length} updated, ${p.addedKeys.length} added`
|
|
19
|
+
: p.status === "error"
|
|
20
|
+
? p.reason ?? "error"
|
|
21
|
+
: "no changes",
|
|
22
|
+
})));
|
|
23
|
+
const changedPlans = context.plans.filter((p) => p.status === "changed");
|
|
24
|
+
const approvals = [];
|
|
25
|
+
// Auto-approve unchanged and errors
|
|
26
|
+
for (const plan of context.plans) {
|
|
27
|
+
if (plan.status !== "changed") {
|
|
28
|
+
approvals.push({
|
|
29
|
+
branchName: plan.branch.name,
|
|
30
|
+
approved: false,
|
|
31
|
+
planHash: plan.planHash,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (changedPlans.length === 0) {
|
|
36
|
+
statusLine("info", "No changed branches to review");
|
|
37
|
+
return ok({ ...context, approvals });
|
|
38
|
+
}
|
|
39
|
+
// Auto-approve all
|
|
40
|
+
if (autoApprove) {
|
|
41
|
+
for (const plan of changedPlans) {
|
|
42
|
+
approvals.push({
|
|
43
|
+
branchName: plan.branch.name,
|
|
44
|
+
approved: true,
|
|
45
|
+
planHash: plan.planHash,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
statusLine("ok", `Auto-approved ${changedPlans.length} branch(es)`);
|
|
49
|
+
return ok({ ...context, approvals });
|
|
50
|
+
}
|
|
51
|
+
if (isCi) {
|
|
52
|
+
// CI without auto-approve: reject all
|
|
53
|
+
for (const plan of changedPlans) {
|
|
54
|
+
approvals.push({
|
|
55
|
+
branchName: plan.branch.name,
|
|
56
|
+
approved: false,
|
|
57
|
+
planHash: plan.planHash,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
statusLine("warn", "CI mode — all branches rejected (use --auto-approve to commit in CI)");
|
|
61
|
+
return ok({ ...context, approvals });
|
|
62
|
+
}
|
|
63
|
+
// Interactive review
|
|
64
|
+
console.log("");
|
|
65
|
+
// Ask if user wants batch action or per-branch review
|
|
66
|
+
const batchChoice = await clack.select({
|
|
67
|
+
message: `Review ${changedPlans.length} changed branch(es):`,
|
|
68
|
+
options: [
|
|
69
|
+
{ value: "per-branch", label: "Review each branch individually", hint: "recommended" },
|
|
70
|
+
{ value: "approve-all", label: `Approve all ${changedPlans.length} branches` },
|
|
71
|
+
{ value: "reject-all", label: "Reject all" },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
if (clack.isCancel(batchChoice)) {
|
|
75
|
+
return err(inputCancelledError("Review cancelled"));
|
|
76
|
+
}
|
|
77
|
+
if (batchChoice === "approve-all") {
|
|
78
|
+
for (const plan of changedPlans) {
|
|
79
|
+
approvals.push({ branchName: plan.branch.name, approved: true, planHash: plan.planHash });
|
|
80
|
+
}
|
|
81
|
+
statusLine("ok", `Approved all ${changedPlans.length} branch(es)`);
|
|
82
|
+
return ok({ ...context, approvals });
|
|
83
|
+
}
|
|
84
|
+
if (batchChoice === "reject-all") {
|
|
85
|
+
for (const plan of changedPlans) {
|
|
86
|
+
approvals.push({ branchName: plan.branch.name, approved: false, planHash: plan.planHash });
|
|
87
|
+
}
|
|
88
|
+
statusLine("warn", "All branches rejected");
|
|
89
|
+
return ok({ ...context, approvals });
|
|
90
|
+
}
|
|
91
|
+
// Per-branch review
|
|
92
|
+
for (const plan of changedPlans) {
|
|
93
|
+
console.log("");
|
|
94
|
+
divider();
|
|
95
|
+
console.log(` ${sym.branch} ${chalk.bold.cyan(plan.branch.name)} ${chalk.gray(plan.baseCommitSha.slice(0, 8))}`);
|
|
96
|
+
console.log(` ${chalk.gray(plan.path)}`);
|
|
97
|
+
console.log("");
|
|
98
|
+
// Show inline diff
|
|
99
|
+
if (deps.config.report.warnOnSecrets) {
|
|
100
|
+
console.log(chalk.yellow(" ⚠ Diff may contain secret values — treat with care"));
|
|
101
|
+
console.log("");
|
|
102
|
+
}
|
|
103
|
+
renderDiff(plan.diff, false);
|
|
104
|
+
// Write snapshots for external diff tool
|
|
105
|
+
const snapResult = await writeSnapshots(context.project.stagingDir, context.runId, plan);
|
|
106
|
+
let openDiff = false;
|
|
107
|
+
if (snapResult.ok) {
|
|
108
|
+
const choice = await clack.select({
|
|
109
|
+
message: "Action:",
|
|
110
|
+
options: [
|
|
111
|
+
{ value: "approve", label: `${chalk.green("✓")} Approve`, hint: "commit this branch" },
|
|
112
|
+
{ value: "reject", label: `${chalk.red("✗")} Reject`, hint: "skip this branch" },
|
|
113
|
+
{ value: "diff", label: `${chalk.cyan("⊞")} Open in diff viewer`, hint: deps.config.editor.name },
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
if (clack.isCancel(choice)) {
|
|
117
|
+
return err(inputCancelledError("Review cancelled"));
|
|
118
|
+
}
|
|
119
|
+
if (choice === "diff") {
|
|
120
|
+
const { beforePath, afterPath } = snapResult.value;
|
|
121
|
+
const diffResult = await deps.services.editor.openForDiff(beforePath, afterPath, deps.config);
|
|
122
|
+
if (!diffResult.ok) {
|
|
123
|
+
clack.log.warn(diffResult.error.message);
|
|
124
|
+
}
|
|
125
|
+
// Ask again after opening diff
|
|
126
|
+
const afterDiff = await clack.select({
|
|
127
|
+
message: "After reviewing the diff:",
|
|
128
|
+
options: [
|
|
129
|
+
{ value: "approve", label: `${chalk.green("✓")} Approve` },
|
|
130
|
+
{ value: "reject", label: `${chalk.red("✗")} Reject` },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
if (clack.isCancel(afterDiff)) {
|
|
134
|
+
return err(inputCancelledError("Review cancelled"));
|
|
135
|
+
}
|
|
136
|
+
approvals.push({
|
|
137
|
+
branchName: plan.branch.name,
|
|
138
|
+
approved: afterDiff === "approve",
|
|
139
|
+
planHash: plan.planHash,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
approvals.push({
|
|
144
|
+
branchName: plan.branch.name,
|
|
145
|
+
approved: choice === "approve",
|
|
146
|
+
planHash: plan.planHash,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// No snapshots — just approve/reject
|
|
152
|
+
const choice = await clack.select({
|
|
153
|
+
message: "Action:",
|
|
154
|
+
options: [
|
|
155
|
+
{ value: "approve", label: `${chalk.green("✓")} Approve` },
|
|
156
|
+
{ value: "reject", label: `${chalk.red("✗")} Reject` },
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
if (clack.isCancel(choice)) {
|
|
160
|
+
return err(inputCancelledError("Review cancelled"));
|
|
161
|
+
}
|
|
162
|
+
approvals.push({
|
|
163
|
+
branchName: plan.branch.name,
|
|
164
|
+
approved: choice === "approve",
|
|
165
|
+
planHash: plan.planHash,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
divider();
|
|
170
|
+
const approved = approvals.filter((a) => a.approved).length;
|
|
171
|
+
const rejected = approvals.filter((a) => !a.approved && context.plans.find((p) => p.branch.name === a.branchName)?.status === "changed").length;
|
|
172
|
+
statusLine("ok", `Review complete — ${approved} approved, ${rejected} rejected`);
|
|
173
|
+
// Confirm before destructive apply
|
|
174
|
+
if (approved > 0) {
|
|
175
|
+
console.log("");
|
|
176
|
+
console.log(chalk.bold(" Summary of approved changes:"));
|
|
177
|
+
for (const plan of changedPlans) {
|
|
178
|
+
const a = approvals.find((ap) => ap.branchName === plan.branch.name);
|
|
179
|
+
if (a?.approved) {
|
|
180
|
+
const detail = [
|
|
181
|
+
plan.updatedKeys.length > 0 ? `update: ${plan.updatedKeys.join(", ")}` : "",
|
|
182
|
+
plan.addedKeys.length > 0 ? `add: ${plan.addedKeys.join(", ")}` : "",
|
|
183
|
+
].filter(Boolean).join(" | ");
|
|
184
|
+
statusLine("ok", plan.branch.name, detail);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
console.log("");
|
|
188
|
+
const confirm = await clack.confirm({
|
|
189
|
+
message: `Proceed to commit ${approved} branch(es)?`,
|
|
190
|
+
initialValue: true,
|
|
191
|
+
});
|
|
192
|
+
if (clack.isCancel(confirm) || !confirm) {
|
|
193
|
+
return err(inputCancelledError("Commit aborted by user"));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return ok({ ...context, approvals });
|
|
197
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Stage } from "../core/pipeline.js";
|
|
2
|
+
import { RunContext, AppDeps } from "../core/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Matches a branch name against a pattern.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Regex: /uat.*\/, /^release\//, /pattern/i
|
|
7
|
+
* - Glob: uat/*, *orders, uat*
|
|
8
|
+
* - Exact name: main, develop
|
|
9
|
+
* - Trailing slash prefix: uat/
|
|
10
|
+
* - Substring fallback: orders
|
|
11
|
+
*/
|
|
12
|
+
export declare function matchesBranchPattern(name: string, pattern: string): boolean;
|
|
13
|
+
export declare const selectTargetsStage: Stage<RunContext, AppDeps>;
|