mergestorm 0.3.7 → 0.3.9
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 +21 -38
- package/dist/api.d.ts +16 -4
- package/dist/api.js +98 -17
- package/dist/cli.js +20 -68
- package/dist/commands/jobs.js +1 -1
- package/dist/commands/login.d.ts +8 -2
- package/dist/commands/login.js +33 -21
- package/dist/commands/registry.d.ts +32 -0
- package/dist/commands/registry.js +219 -0
- package/dist/commands/review.d.ts +14 -5
- package/dist/commands/review.js +97 -31
- package/dist/commands/stack.d.ts +27 -0
- package/dist/commands/stack.js +56 -24
- package/dist/commands/status.js +2 -13
- package/dist/errors.d.ts +8 -1
- package/dist/errors.js +6 -1
- package/dist/git-stack.d.ts +5 -0
- package/dist/git-stack.js +12 -0
- package/dist/git.d.ts +6 -1
- package/dist/git.js +31 -5
- package/dist/node-version.d.ts +11 -0
- package/dist/node-version.js +19 -0
- package/dist/pr-body.d.ts +1 -1
- package/dist/pr-body.js +1 -1
- package/dist/repo-identity.js +22 -3
- package/dist/shell.d.ts +2 -2
- package/dist/shell.js +15 -71
- package/dist/stack-meta.js +4 -8
- package/dist/ui/banner.js +43 -6
- package/dist/ui/box.d.ts +8 -1
- package/dist/ui/box.js +15 -3
- package/dist/ui/findings.d.ts +28 -0
- package/dist/ui/findings.js +16 -0
- package/dist/ui/prompt.js +15 -8
- package/dist/ui/secret-input.d.ts +5 -0
- package/dist/ui/secret-input.js +73 -0
- package/package.json +14 -3
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { DEFAULT_API } from "../config.js";
|
|
2
|
+
import { CommandError } from "../errors.js";
|
|
3
|
+
import { cmdBranches, cmdChain } from "./branches.js";
|
|
4
|
+
import { cmdCredits } from "./credits.js";
|
|
5
|
+
import { cmdJobs, cmdThread } from "./jobs.js";
|
|
6
|
+
import { cmdLogin } from "./login.js";
|
|
7
|
+
import { cmdLogout } from "./logout.js";
|
|
8
|
+
import { cmdReview } from "./review.js";
|
|
9
|
+
import { cmdStack } from "./stack.js";
|
|
10
|
+
import { cmdStatus } from "./status.js";
|
|
11
|
+
import { cmdWhoami } from "./whoami.js";
|
|
12
|
+
/**
|
|
13
|
+
* Single source of truth for CLI commands (STRUCT-01).
|
|
14
|
+
* Derive oneshot usage, shell COMMANDS, and both dispatchers from this table.
|
|
15
|
+
*/
|
|
16
|
+
export const COMMAND_REGISTRY = [
|
|
17
|
+
{
|
|
18
|
+
name: "login",
|
|
19
|
+
summary: "Sign in via browser (or paste a key)",
|
|
20
|
+
usage: [
|
|
21
|
+
"mergestorm login Sign in via browser and store an API key",
|
|
22
|
+
"mergestorm login --key Paste an existing API key instead (headless/CI)",
|
|
23
|
+
],
|
|
24
|
+
async run(args, ctx) {
|
|
25
|
+
await cmdLogin(args);
|
|
26
|
+
if (ctx.afterAuth)
|
|
27
|
+
await ctx.afterAuth();
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "logout",
|
|
32
|
+
summary: "Remove the stored API key",
|
|
33
|
+
usage: ["mergestorm logout Remove the stored API key"],
|
|
34
|
+
async run(_args, ctx) {
|
|
35
|
+
await cmdLogout();
|
|
36
|
+
if (ctx.afterAuth)
|
|
37
|
+
await ctx.afterAuth();
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "review",
|
|
42
|
+
summary: "Review git diff (default origin/HEAD or main or master)",
|
|
43
|
+
usage: [
|
|
44
|
+
"mergestorm review [base] [head] Review git diff base...head (default: origin/HEAD or main or master)",
|
|
45
|
+
],
|
|
46
|
+
async run(args, ctx) {
|
|
47
|
+
if (ctx.mode === "shell") {
|
|
48
|
+
await cmdReview(args, { signal: ctx.signal, interactive: true });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await cmdReview(args);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "status",
|
|
56
|
+
summary: "Show a review job",
|
|
57
|
+
usage: [
|
|
58
|
+
"mergestorm status <job_id> Fetch a review job and print JSON (one shot)",
|
|
59
|
+
],
|
|
60
|
+
async run(args, ctx) {
|
|
61
|
+
if (!args[0]) {
|
|
62
|
+
throw new CommandError(ctx.mode === "shell"
|
|
63
|
+
? "usage: status <job_id>"
|
|
64
|
+
: "usage: mergestorm status <job_id>");
|
|
65
|
+
}
|
|
66
|
+
if (ctx.mode === "shell") {
|
|
67
|
+
await cmdStatus(args[0], { format: "pretty", signal: ctx.signal });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await cmdStatus(args[0], { format: "json" });
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "credits",
|
|
75
|
+
aliases: ["usage"],
|
|
76
|
+
summary: "Credit balance with usage bars",
|
|
77
|
+
usage: ["mergestorm credits [--json] Credit balance (usage bars)"],
|
|
78
|
+
async run(args) {
|
|
79
|
+
await cmdCredits(args);
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "jobs",
|
|
84
|
+
summary: "Recent review jobs (default 10)",
|
|
85
|
+
usage: ["mergestorm jobs [n] [--json] Recent review jobs"],
|
|
86
|
+
async run(args) {
|
|
87
|
+
await cmdJobs(args);
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "branches",
|
|
92
|
+
aliases: ["chains"],
|
|
93
|
+
summary: "Pick a recently reviewed branch",
|
|
94
|
+
usage: ["mergestorm branches [n] Recently reviewed branches (pick on TTY)"],
|
|
95
|
+
async run(args) {
|
|
96
|
+
await cmdBranches(args);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "chain",
|
|
101
|
+
summary: "Show a branch review-chain timeline",
|
|
102
|
+
usage: [
|
|
103
|
+
"mergestorm chain [slug] Branch review-chain timeline (default: current)",
|
|
104
|
+
],
|
|
105
|
+
async run(args) {
|
|
106
|
+
await cmdChain(args);
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "whoami",
|
|
111
|
+
summary: "Key + plan + API base",
|
|
112
|
+
usage: ["mergestorm whoami [--json] Key + plan + API base"],
|
|
113
|
+
async run(args) {
|
|
114
|
+
await cmdWhoami(args);
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "thread",
|
|
119
|
+
summary: "Jobs in a review thread",
|
|
120
|
+
usage: ["mergestorm thread <slug> Jobs in a review thread"],
|
|
121
|
+
async run(args, ctx) {
|
|
122
|
+
if (!args[0]) {
|
|
123
|
+
throw new CommandError(ctx.mode === "shell"
|
|
124
|
+
? "usage: thread <slug>"
|
|
125
|
+
: "usage: mergestorm thread <slug>");
|
|
126
|
+
}
|
|
127
|
+
await cmdThread(args[0], args.slice(1));
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "stack",
|
|
132
|
+
summary: "Stacks: create, submit, list, adopt, restack, land",
|
|
133
|
+
usage: [
|
|
134
|
+
"mergestorm stack create [name] New local stack layer (CLI-managed authoring state)",
|
|
135
|
+
"mergestorm stack submit Push active local stack, open PRs, register stack",
|
|
136
|
+
"mergestorm stack reset --force Clear this repo's pre-submit authoring state",
|
|
137
|
+
"mergestorm stack list [--json] List registered stacks",
|
|
138
|
+
"mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain",
|
|
139
|
+
"mergestorm stack restack <stack-id> Restack stack descendants",
|
|
140
|
+
"mergestorm stack land <stack-id> Land bottom PR (or promote into unit)",
|
|
141
|
+
"mergestorm stack auto-promote on|off <stack-id> Toggle auto-promote when green",
|
|
142
|
+
],
|
|
143
|
+
async run(args) {
|
|
144
|
+
await cmdStack(args);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "shell",
|
|
149
|
+
summary: "Explicit shell entry",
|
|
150
|
+
usage: ["mergestorm shell Explicit shell entry"],
|
|
151
|
+
/** Shell entry is oneshot-only; interactive shell does not list it. */
|
|
152
|
+
async run() {
|
|
153
|
+
const { runShell } = await import("../shell.js");
|
|
154
|
+
await runShell();
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
/** Shell meta commands (not dispatched via {@link COMMAND_REGISTRY}). */
|
|
159
|
+
const SHELL_META = [
|
|
160
|
+
{ name: "help", summary: "Show available commands" },
|
|
161
|
+
{ name: "clear", summary: "Clear screen and reprint banner" },
|
|
162
|
+
{ name: "exit", summary: "Leave the shell" },
|
|
163
|
+
{ name: "quit", summary: "Leave the shell" },
|
|
164
|
+
];
|
|
165
|
+
/** Commands omitted from shell autocomplete (oneshot-only entrypoints). */
|
|
166
|
+
const SHELL_OMIT = new Set(["shell"]);
|
|
167
|
+
export function findCommand(name) {
|
|
168
|
+
const key = name.toLowerCase();
|
|
169
|
+
return COMMAND_REGISTRY.find((c) => c.name === key || (c.aliases?.includes(key) ?? false));
|
|
170
|
+
}
|
|
171
|
+
/** Autocomplete / `/help` list for the interactive shell. */
|
|
172
|
+
export function shellCommandSpecs() {
|
|
173
|
+
const specs = [{ name: "help", summary: SHELL_META[0].summary }];
|
|
174
|
+
for (const c of COMMAND_REGISTRY) {
|
|
175
|
+
if (SHELL_OMIT.has(c.name))
|
|
176
|
+
continue;
|
|
177
|
+
specs.push({ name: c.name, summary: c.summary });
|
|
178
|
+
for (const alias of c.aliases ?? []) {
|
|
179
|
+
specs.push({ name: alias, summary: c.summary });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
specs.push({ name: "clear", summary: SHELL_META[1].summary }, { name: "exit", summary: SHELL_META[2].summary }, { name: "quit", summary: SHELL_META[3].summary });
|
|
183
|
+
return specs;
|
|
184
|
+
}
|
|
185
|
+
export function formatUsageBody() {
|
|
186
|
+
// Historical order: interactive entry → shell → version → registry (minus shell).
|
|
187
|
+
const shellEntry = COMMAND_REGISTRY.find((c) => c.name === "shell");
|
|
188
|
+
const others = COMMAND_REGISTRY.filter((c) => c.name !== "shell");
|
|
189
|
+
const cmdLines = [
|
|
190
|
+
"mergestorm Interactive shell (TTY)",
|
|
191
|
+
...(shellEntry?.usage ?? []),
|
|
192
|
+
"mergestorm -v, --version Print CLI version",
|
|
193
|
+
...others.flatMap((c) => c.usage),
|
|
194
|
+
]
|
|
195
|
+
.map((line) => ` ${line}`)
|
|
196
|
+
.join("\n");
|
|
197
|
+
return `${cmdLines}
|
|
198
|
+
|
|
199
|
+
mg is a short alias for mergestorm (same binary).
|
|
200
|
+
|
|
201
|
+
Env:
|
|
202
|
+
MERGESTORM_API_KEY API key (overrides config)
|
|
203
|
+
MERGESTORM_API_URL API base (default ${DEFAULT_API})
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Run a registered command. Returns false when `name` is unknown
|
|
208
|
+
* (caller prints usage / shell hint).
|
|
209
|
+
*/
|
|
210
|
+
export async function dispatchCommand(name, args, ctx) {
|
|
211
|
+
const cmd = findCommand(name);
|
|
212
|
+
if (!cmd)
|
|
213
|
+
return false;
|
|
214
|
+
// `shell` as a subcommand is oneshot-only.
|
|
215
|
+
if (cmd.name === "shell" && ctx.mode === "shell")
|
|
216
|
+
return false;
|
|
217
|
+
await cmd.run(args, ctx);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
@@ -1,11 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff base when the user omits args[0]. Prefers origin/HEAD / main / master
|
|
3
|
+
* via discoverTrunk (same as stack create) instead of hardcoding "main".
|
|
4
|
+
*/
|
|
5
|
+
export declare function resolveReviewBase(explicit: string | undefined, cwd?: string): string;
|
|
1
6
|
export type ReviewOptions = {
|
|
2
7
|
signal?: AbortSignal;
|
|
3
8
|
/** When true, show Ctrl+C detach hint during poll. */
|
|
4
9
|
interactive?: boolean;
|
|
5
10
|
};
|
|
6
|
-
/**
|
|
7
|
-
export declare
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
/** Max consecutive transient poll failures before surfacing an error. */
|
|
12
|
+
export declare const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
|
|
13
|
+
export declare function reviewStatusHint(jobId: string): string;
|
|
14
|
+
/** Append recovery hint once a job id exists (idempotent). */
|
|
15
|
+
export declare function withReviewJobRecovery(message: string, jobId: string): string;
|
|
16
|
+
/** HTTP statuses worth retrying during review poll (not 401/402/404). */
|
|
17
|
+
export declare function isTransientReviewPollStatus(status: number): boolean;
|
|
18
|
+
/** Network / request-timeout errors worth retrying during review poll. */
|
|
19
|
+
export declare function isTransientReviewPollError(err: unknown): boolean;
|
|
11
20
|
export declare function cmdReview(args: string[], opts?: ReviewOptions): Promise<void>;
|
package/dist/commands/review.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import { apiFetch, getMe } from "../api.js";
|
|
2
2
|
import { loadConfig } from "../config.js";
|
|
3
|
-
import { CommandError, DetachedError } from "../errors.js";
|
|
4
|
-
import { collectChangedFiles, git } from "../git.js";
|
|
3
|
+
import { CommandError, DetachedError, isCommandErrorCode } from "../errors.js";
|
|
4
|
+
import { collectChangedFiles, git, parseGithubOriginRepo } from "../git.js";
|
|
5
|
+
import { discoverTrunk } from "../git-stack.js";
|
|
5
6
|
import { ansi } from "../ui/ansi.js";
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import { renderFindings } from "../ui/findings.js";
|
|
8
|
+
/**
|
|
9
|
+
* Diff base when the user omits args[0]. Prefers origin/HEAD / main / master
|
|
10
|
+
* via discoverTrunk (same as stack create) instead of hardcoding "main".
|
|
11
|
+
*/
|
|
12
|
+
export function resolveReviewBase(explicit, cwd = process.cwd()) {
|
|
13
|
+
if (explicit)
|
|
14
|
+
return explicit;
|
|
8
15
|
try {
|
|
9
|
-
|
|
10
|
-
if (!url)
|
|
11
|
-
return null;
|
|
12
|
-
const m = url.match(/^(?:https?:\/\/|git@)?github\.com[:/]([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/i);
|
|
13
|
-
if (!m?.[1] || !m[2])
|
|
14
|
-
return null;
|
|
15
|
-
return { owner: m[1], repo: m[2] };
|
|
16
|
+
return discoverTrunk(cwd);
|
|
16
17
|
}
|
|
17
18
|
catch {
|
|
18
|
-
|
|
19
|
+
throw new CommandError("Could not discover trunk branch. Pass an explicit base: mergestorm review <base> [head]");
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
function sleep(ms, signal) {
|
|
@@ -32,13 +33,56 @@ function sleep(ms, signal) {
|
|
|
32
33
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
33
34
|
});
|
|
34
35
|
}
|
|
36
|
+
/** Max consecutive transient poll failures before surfacing an error. */
|
|
37
|
+
export const REVIEW_POLL_MAX_TRANSIENT_RETRIES = 3;
|
|
38
|
+
export function reviewStatusHint(jobId) {
|
|
39
|
+
return `Check: mergestorm status ${jobId}`;
|
|
40
|
+
}
|
|
41
|
+
/** Append recovery hint once a job id exists (idempotent). */
|
|
42
|
+
export function withReviewJobRecovery(message, jobId) {
|
|
43
|
+
const hint = reviewStatusHint(jobId);
|
|
44
|
+
if (message.includes(`mergestorm status ${jobId}`))
|
|
45
|
+
return message;
|
|
46
|
+
return `${message.replace(/\s+$/, "")}\n${hint}`;
|
|
47
|
+
}
|
|
48
|
+
/** HTTP statuses worth retrying during review poll (not 401/402/404). */
|
|
49
|
+
export function isTransientReviewPollStatus(status) {
|
|
50
|
+
return status === 408 || status === 429 || status >= 500;
|
|
51
|
+
}
|
|
52
|
+
/** Network / request-timeout errors worth retrying during review poll. */
|
|
53
|
+
export function isTransientReviewPollError(err) {
|
|
54
|
+
if (!(err instanceof Error))
|
|
55
|
+
return false;
|
|
56
|
+
if (err.name === "AbortError")
|
|
57
|
+
return false;
|
|
58
|
+
if (err instanceof CommandError) {
|
|
59
|
+
return err.code === "api_timeout";
|
|
60
|
+
}
|
|
61
|
+
if (err instanceof TypeError && /fetch|network|connection/i.test(err.message))
|
|
62
|
+
return true;
|
|
63
|
+
return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up/i.test(err.message);
|
|
64
|
+
}
|
|
65
|
+
function asPollCommandError(err, jobId) {
|
|
66
|
+
if (err instanceof CommandError) {
|
|
67
|
+
return new CommandError(withReviewJobRecovery(err.message, jobId), err.exitCode, err.code);
|
|
68
|
+
}
|
|
69
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
70
|
+
return new CommandError(withReviewJobRecovery(`Poll failed: ${detail}`, jobId));
|
|
71
|
+
}
|
|
35
72
|
export async function cmdReview(args, opts = {}) {
|
|
36
|
-
const base = args[0]
|
|
73
|
+
const base = resolveReviewBase(args[0]);
|
|
37
74
|
const head = args[1] || "HEAD";
|
|
38
75
|
const cfg = await loadConfig();
|
|
39
76
|
const meBefore = opts.interactive ? await getMe(cfg) : null;
|
|
40
77
|
console.log(`Collecting diff ${base}...${head} …`);
|
|
41
|
-
|
|
78
|
+
let diff;
|
|
79
|
+
try {
|
|
80
|
+
diff = git(["diff", `${base}...${head}`]);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
84
|
+
throw new CommandError(`Could not diff ${base}...${head}: ${detail}. Try \`mergestorm review <base>\` with your trunk branch.`);
|
|
85
|
+
}
|
|
42
86
|
if (!diff.trim()) {
|
|
43
87
|
console.log("No changes to review.");
|
|
44
88
|
return;
|
|
@@ -72,13 +116,41 @@ export async function cmdReview(args, opts = {}) {
|
|
|
72
116
|
? ` Waiting… (${ansi.dim("ctrl+c to detach — job keeps running")})`
|
|
73
117
|
: " Waiting…";
|
|
74
118
|
console.log(`Job ${job.job_id} (${job.status}).${waitHint}`);
|
|
119
|
+
let transientFailures = 0;
|
|
75
120
|
try {
|
|
76
121
|
for (let i = 0; i < 90; i += 1) {
|
|
77
122
|
await sleep(2000, opts.signal);
|
|
78
|
-
|
|
123
|
+
let poll;
|
|
124
|
+
try {
|
|
125
|
+
poll = await apiFetch(cfg, `/api/v1/reviews/${job.job_id}`, { signal: opts.signal });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (opts.interactive && err instanceof Error && err.name === "AbortError") {
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
if (opts.interactive && isCommandErrorCode(err, "api_timeout")) {
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
if (isTransientReviewPollError(err) &&
|
|
135
|
+
transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
|
|
136
|
+
transientFailures += 1;
|
|
137
|
+
if (opts.interactive)
|
|
138
|
+
process.stdout.write("?");
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
throw asPollCommandError(err, job.job_id);
|
|
142
|
+
}
|
|
79
143
|
if (poll.status !== 200) {
|
|
80
|
-
|
|
144
|
+
if (isTransientReviewPollStatus(poll.status) &&
|
|
145
|
+
transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
|
|
146
|
+
transientFailures += 1;
|
|
147
|
+
if (opts.interactive)
|
|
148
|
+
process.stdout.write("?");
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
throw new CommandError(withReviewJobRecovery(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`, job.job_id));
|
|
81
152
|
}
|
|
153
|
+
transientFailures = 0;
|
|
82
154
|
const row = poll.body;
|
|
83
155
|
if (row.status === "queued" || row.status === "in_progress") {
|
|
84
156
|
process.stdout.write(".");
|
|
@@ -86,23 +158,11 @@ export async function cmdReview(args, opts = {}) {
|
|
|
86
158
|
}
|
|
87
159
|
console.log("");
|
|
88
160
|
if (row.status === "failed" || row.status === "quota_exceeded") {
|
|
89
|
-
throw new CommandError(`Review ${row.status}: ${row.error ?? ""}
|
|
161
|
+
throw new CommandError(withReviewJobRecovery(`Review ${row.status}: ${row.error ?? ""}`, job.job_id));
|
|
90
162
|
}
|
|
91
163
|
console.log(`\nVerdict: ${ansi.bold(row.verdict ?? "comment")}`);
|
|
92
164
|
console.log(row.summary ?? "");
|
|
93
|
-
|
|
94
|
-
const off = row.findings?.off_diff ?? row.findings?.offDiff ?? [];
|
|
95
|
-
for (const c of inline) {
|
|
96
|
-
console.log(`\n[${c.severity}] ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
|
|
97
|
-
console.log(c.body);
|
|
98
|
-
}
|
|
99
|
-
for (const c of off) {
|
|
100
|
-
console.log(`\n[${c.severity}] off-diff ${c.path ?? ""}`);
|
|
101
|
-
console.log(c.body);
|
|
102
|
-
}
|
|
103
|
-
if (inline.length === 0 && off.length === 0) {
|
|
104
|
-
console.log("\nNo findings.");
|
|
105
|
-
}
|
|
165
|
+
renderFindings(row.findings, { emptyMessage: true });
|
|
106
166
|
if (opts.interactive && meBefore) {
|
|
107
167
|
const meAfter = await getMe(cfg);
|
|
108
168
|
if (meAfter) {
|
|
@@ -121,7 +181,13 @@ export async function cmdReview(args, opts = {}) {
|
|
|
121
181
|
if (opts.interactive && err instanceof Error && err.name === "AbortError") {
|
|
122
182
|
throw new DetachedError(job.job_id);
|
|
123
183
|
}
|
|
184
|
+
if (opts.interactive && isCommandErrorCode(err, "api_timeout")) {
|
|
185
|
+
throw new DetachedError(job.job_id);
|
|
186
|
+
}
|
|
187
|
+
if (err instanceof CommandError) {
|
|
188
|
+
throw new CommandError(withReviewJobRecovery(err.message, job.job_id), err.exitCode, err.code);
|
|
189
|
+
}
|
|
124
190
|
throw err;
|
|
125
191
|
}
|
|
126
|
-
throw new CommandError(
|
|
192
|
+
throw new CommandError(withReviewJobRecovery("Timed out waiting for review.", job.job_id));
|
|
127
193
|
}
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { adoptStack } from "../api.js";
|
|
2
|
+
import { loadConfig } from "../config.js";
|
|
3
|
+
import { createPr, findOpenPrNumber } from "../gh.js";
|
|
4
|
+
import { parseGithubOriginRepo } from "../git.js";
|
|
5
|
+
import { branchExists, commitsAheadOf, pushBranch, tipCommitMessage, tipCommitSubject } from "../git-stack.js";
|
|
6
|
+
import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
7
|
+
import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
|
|
1
8
|
export declare function requireStackId(raw: string | undefined, usage: string): string;
|
|
2
9
|
/** Parse `owner/repo#12` or `owner/repo 12`. */
|
|
3
10
|
export declare function parseAdoptTarget(args: string[]): {
|
|
@@ -13,4 +20,24 @@ export declare function parseStackCreateArgs(args: string[]): {
|
|
|
13
20
|
asJson: boolean;
|
|
14
21
|
};
|
|
15
22
|
export declare function parseStackResetArgs(args: string[]): void;
|
|
23
|
+
/** Injectable seams for unit tests (STRUCT-04). Production callers omit deps. */
|
|
24
|
+
export type StackSubmitDeps = {
|
|
25
|
+
cwd?: string;
|
|
26
|
+
gitTopLevel?: (cwd?: string) => string;
|
|
27
|
+
loadStackMeta?: typeof loadStackMeta;
|
|
28
|
+
saveStackMeta?: typeof saveStackMeta;
|
|
29
|
+
parseGithubOriginRepo?: typeof parseGithubOriginRepo;
|
|
30
|
+
requireGh?: (cwd?: string) => void;
|
|
31
|
+
branchExists?: typeof branchExists;
|
|
32
|
+
pushBranch?: typeof pushBranch;
|
|
33
|
+
findOpenPrNumber?: typeof findOpenPrNumber;
|
|
34
|
+
commitsAheadOf?: typeof commitsAheadOf;
|
|
35
|
+
tipCommitSubject?: typeof tipCommitSubject;
|
|
36
|
+
tipCommitMessage?: typeof tipCommitMessage;
|
|
37
|
+
buildPrBodyFromCommit?: typeof buildPrBodyFromCommit;
|
|
38
|
+
createPr?: typeof createPr;
|
|
39
|
+
loadConfig?: typeof loadConfig;
|
|
40
|
+
adoptStack?: typeof adoptStack;
|
|
41
|
+
};
|
|
42
|
+
export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
|
|
16
43
|
export declare function cmdStack(args: string[]): Promise<void>;
|
package/dist/commands/stack.js
CHANGED
|
@@ -2,12 +2,12 @@ import { adoptStack, landNextStack, listStacks, restackStack, setStackAutoPromot
|
|
|
2
2
|
import { loadConfig } from "../config.js";
|
|
3
3
|
import { CommandError } from "../errors.js";
|
|
4
4
|
import { createPr, findOpenPrNumber, requireGh } from "../gh.js";
|
|
5
|
-
import {
|
|
5
|
+
import { parseGithubOriginRepo } from "../git.js";
|
|
6
|
+
import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
|
|
6
7
|
import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
7
8
|
import { gitTopLevel } from "../repo-identity.js";
|
|
8
9
|
import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, } from "../stack-meta.js";
|
|
9
10
|
import { ansi } from "../ui/ansi.js";
|
|
10
|
-
import { parseGithubOriginRepo } from "./review.js";
|
|
11
11
|
const STACK_USAGE = `usage:
|
|
12
12
|
mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--json]
|
|
13
13
|
mergestorm stack submit [--json]
|
|
@@ -185,49 +185,79 @@ async function cmdStackCreate(args) {
|
|
|
185
185
|
console.log(ansi.dim(` trunk: ${meta.trunk} · local stacks: ${meta.stacks.length} · active layers: ${layers.length}`));
|
|
186
186
|
console.log(ansi.dim(" authoring state: managed automatically outside the repository"));
|
|
187
187
|
}
|
|
188
|
-
async function cmdStackSubmit(args) {
|
|
188
|
+
export async function cmdStackSubmit(args, deps = {}) {
|
|
189
189
|
const asJson = args.includes("--json");
|
|
190
190
|
if (args.some((a) => a !== "--json")) {
|
|
191
191
|
throw new CommandError("usage: mergestorm stack submit [--json]");
|
|
192
192
|
}
|
|
193
|
-
const cwd = process.cwd();
|
|
193
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
194
|
+
const gitTopLevelFn = deps.gitTopLevel ?? gitTopLevel;
|
|
195
|
+
const loadStackMetaFn = deps.loadStackMeta ?? loadStackMeta;
|
|
196
|
+
const saveStackMetaFn = deps.saveStackMeta ?? saveStackMeta;
|
|
197
|
+
const parseOriginFn = deps.parseGithubOriginRepo ?? parseGithubOriginRepo;
|
|
198
|
+
const requireGhFn = deps.requireGh ?? requireGh;
|
|
199
|
+
const branchExistsFn = deps.branchExists ?? branchExists;
|
|
200
|
+
const pushBranchFn = deps.pushBranch ?? pushBranch;
|
|
201
|
+
const findOpenPrNumberFn = deps.findOpenPrNumber ?? findOpenPrNumber;
|
|
202
|
+
const commitsAheadOfFn = deps.commitsAheadOf ?? commitsAheadOf;
|
|
203
|
+
const tipCommitSubjectFn = deps.tipCommitSubject ?? tipCommitSubject;
|
|
204
|
+
const tipCommitMessageFn = deps.tipCommitMessage ?? tipCommitMessage;
|
|
205
|
+
const buildPrBodyFn = deps.buildPrBodyFromCommit ?? buildPrBodyFromCommit;
|
|
206
|
+
const createPrFn = deps.createPr ?? createPr;
|
|
207
|
+
const loadConfigFn = deps.loadConfig ?? loadConfig;
|
|
208
|
+
const adoptStackFn = deps.adoptStack ?? adoptStack;
|
|
194
209
|
try {
|
|
195
|
-
|
|
210
|
+
gitTopLevelFn(cwd);
|
|
196
211
|
}
|
|
197
212
|
catch {
|
|
198
213
|
throw new CommandError("Not in a git repository. Run `mg stack submit` from inside the repo whose stack you want to push.");
|
|
199
214
|
}
|
|
200
|
-
const meta = await
|
|
215
|
+
const meta = await loadStackMetaFn(cwd);
|
|
201
216
|
const layers = meta ? activeLayers(meta) : [];
|
|
202
217
|
if (!meta || layers.length === 0) {
|
|
203
218
|
throw new CommandError("No local stack layers. Run `mg stack create` (then commit your code) before `stack submit`.");
|
|
204
219
|
}
|
|
205
|
-
const origin =
|
|
220
|
+
const origin = parseOriginFn(cwd);
|
|
206
221
|
if (!origin) {
|
|
207
222
|
throw new CommandError("Could not parse GitHub owner/repo from `origin`. Set a github.com remote and retry.");
|
|
208
223
|
}
|
|
209
224
|
const { owner, repo } = origin;
|
|
210
|
-
|
|
225
|
+
requireGhFn(cwd);
|
|
211
226
|
const opened = [];
|
|
212
227
|
for (const layer of layers) {
|
|
213
228
|
const base = layer.parentBranch || meta.trunk;
|
|
214
|
-
if (!
|
|
229
|
+
if (!branchExistsFn(layer.branch, cwd)) {
|
|
215
230
|
throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
|
|
216
231
|
}
|
|
217
232
|
console.log(ansi.dim(` Pushing ${layer.branch} …`));
|
|
218
233
|
try {
|
|
219
|
-
|
|
234
|
+
pushBranchFn(layer.branch, cwd);
|
|
220
235
|
}
|
|
221
236
|
catch (err) {
|
|
222
237
|
throw new CommandError(err instanceof Error ? err.message : String(err));
|
|
223
238
|
}
|
|
224
|
-
let prNumber =
|
|
239
|
+
let prNumber = findOpenPrNumberFn(owner, repo, layer.branch, cwd);
|
|
225
240
|
let created = false;
|
|
226
241
|
if (prNumber == null) {
|
|
227
|
-
|
|
228
|
-
|
|
242
|
+
// Refuse empty layers before `gh pr create` dumps opaque stderr.
|
|
243
|
+
if (!branchExistsFn(base, cwd)) {
|
|
244
|
+
throw new CommandError(`Stack base branch missing locally: ${base}`);
|
|
245
|
+
}
|
|
246
|
+
let ahead;
|
|
247
|
+
try {
|
|
248
|
+
ahead = commitsAheadOfFn(base, layer.branch, cwd);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
throw new CommandError(err instanceof Error ? err.message : String(err));
|
|
252
|
+
}
|
|
253
|
+
if (ahead < 1) {
|
|
254
|
+
throw new CommandError(`No commits on \`${layer.branch}\` ahead of \`${base}\`. ` +
|
|
255
|
+
`Commit your changes, then retry \`mg stack submit\`.`);
|
|
256
|
+
}
|
|
257
|
+
const title = tipCommitSubjectFn(layer.branch, cwd);
|
|
258
|
+
const body = buildPrBodyFn(tipCommitMessageFn(layer.branch, cwd));
|
|
229
259
|
console.log(ansi.dim(` Opening PR ${layer.branch} → ${base} …`));
|
|
230
|
-
prNumber =
|
|
260
|
+
prNumber = createPrFn({
|
|
231
261
|
owner,
|
|
232
262
|
repo,
|
|
233
263
|
base,
|
|
@@ -244,9 +274,9 @@ async function cmdStackSubmit(args) {
|
|
|
244
274
|
opened.push({ branch: layer.branch, base, prNumber, created });
|
|
245
275
|
}
|
|
246
276
|
const registerPr = opened[0].prNumber;
|
|
247
|
-
const cfg = await
|
|
277
|
+
const cfg = await loadConfigFn();
|
|
248
278
|
console.log(ansi.dim(` Registering stack via import (${owner}/${repo}#${registerPr}) …`));
|
|
249
|
-
const body = await
|
|
279
|
+
const body = await adoptStackFn(owner, repo, registerPr, cfg);
|
|
250
280
|
if (typeof body !== "object" || body === null) {
|
|
251
281
|
throw new CommandError("Unexpected response shape from adopt API");
|
|
252
282
|
}
|
|
@@ -259,7 +289,7 @@ async function cmdStackSubmit(args) {
|
|
|
259
289
|
}
|
|
260
290
|
const stackId = data.stack.id;
|
|
261
291
|
// Drop the submitted local stack so the next create onto trunk starts fresh.
|
|
262
|
-
await
|
|
292
|
+
await saveStackMetaFn(removeActiveStack(meta), cwd);
|
|
263
293
|
if (asJson) {
|
|
264
294
|
console.log(JSON.stringify({
|
|
265
295
|
owner,
|
|
@@ -281,7 +311,7 @@ async function cmdStackSubmit(args) {
|
|
|
281
311
|
else {
|
|
282
312
|
console.log(ansi.dim(" Stack registration returned no stack ID"));
|
|
283
313
|
}
|
|
284
|
-
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard
|
|
314
|
+
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
|
|
285
315
|
}
|
|
286
316
|
async function cmdStackReset(args) {
|
|
287
317
|
parseStackResetArgs(args);
|
|
@@ -299,7 +329,7 @@ async function cmdStackList(args) {
|
|
|
299
329
|
if (stacks.length === 0) {
|
|
300
330
|
console.log(ansi.dim(" No stacks yet. Author with `stack create` → commit → `stack submit`."));
|
|
301
331
|
console.log(ansi.dim(" Import an existing chain only: `stack adopt owner/repo#pr`"));
|
|
302
|
-
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard
|
|
332
|
+
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
|
|
303
333
|
return;
|
|
304
334
|
}
|
|
305
335
|
for (const s of stacks) {
|
|
@@ -307,7 +337,7 @@ async function cmdStackList(args) {
|
|
|
307
337
|
console.log(line);
|
|
308
338
|
console.log("");
|
|
309
339
|
}
|
|
310
|
-
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard
|
|
340
|
+
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
|
|
311
341
|
}
|
|
312
342
|
async function cmdStackAdopt(args) {
|
|
313
343
|
const asJson = args.includes("--json");
|
|
@@ -334,7 +364,7 @@ async function cmdStackAdopt(args) {
|
|
|
334
364
|
if (stack?.trunkBranch) {
|
|
335
365
|
console.log(ansi.dim(` trunk: ${stack.trunkBranch}`));
|
|
336
366
|
}
|
|
337
|
-
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard
|
|
367
|
+
console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard"));
|
|
338
368
|
}
|
|
339
369
|
async function cmdStackRestack(args) {
|
|
340
370
|
const asJson = args.includes("--json");
|
|
@@ -352,7 +382,7 @@ async function cmdStackLand(args) {
|
|
|
352
382
|
const asJson = args.includes("--json");
|
|
353
383
|
const id = requireStackId(args.find((a) => a !== "--json"), "usage: mergestorm stack land <stack-id>");
|
|
354
384
|
const cfg = await loadConfig();
|
|
355
|
-
// Server routes unit stacks to promote-next (
|
|
385
|
+
// Server routes unit stacks to promote-next (land tip into review unit).
|
|
356
386
|
const body = await landNextStack(id, cfg);
|
|
357
387
|
if (asJson) {
|
|
358
388
|
console.log(JSON.stringify(body, null, 2));
|
|
@@ -393,8 +423,10 @@ async function cmdStackAutoLand(args) {
|
|
|
393
423
|
export async function cmdStack(args) {
|
|
394
424
|
const sub = args[0]?.toLowerCase();
|
|
395
425
|
const rest = args.slice(1);
|
|
396
|
-
|
|
397
|
-
|
|
426
|
+
// Help must exit 0 — print usage, do not throw CommandError.
|
|
427
|
+
if (!sub || sub === "-h" || sub === "--help" || sub === "help") {
|
|
428
|
+
console.log(STACK_USAGE);
|
|
429
|
+
return;
|
|
398
430
|
}
|
|
399
431
|
if (sub === "create")
|
|
400
432
|
return cmdStackCreate(rest);
|
package/dist/commands/status.js
CHANGED
|
@@ -2,6 +2,7 @@ import { apiFetch } from "../api.js";
|
|
|
2
2
|
import { loadConfig } from "../config.js";
|
|
3
3
|
import { CommandError } from "../errors.js";
|
|
4
4
|
import { ansi } from "../ui/ansi.js";
|
|
5
|
+
import { renderFindings } from "../ui/findings.js";
|
|
5
6
|
export async function cmdStatus(jobId, opts = {}) {
|
|
6
7
|
const format = opts.format ?? "json";
|
|
7
8
|
const cfg = await loadConfig();
|
|
@@ -23,17 +24,5 @@ export async function cmdStatus(jobId, opts = {}) {
|
|
|
23
24
|
console.log(row.summary);
|
|
24
25
|
if (row.error)
|
|
25
26
|
console.log(ansi.red(`Error: ${row.error}`));
|
|
26
|
-
|
|
27
|
-
const off = row.findings?.off_diff ?? row.findings?.offDiff ?? [];
|
|
28
|
-
for (const c of inline) {
|
|
29
|
-
console.log(`\n[${c.severity}] ${c.path}:${c.line}${c.title ? ` — ${c.title}` : ""}`);
|
|
30
|
-
console.log(c.body);
|
|
31
|
-
}
|
|
32
|
-
for (const c of off) {
|
|
33
|
-
console.log(`\n[${c.severity}] off-diff ${c.path ?? ""}`);
|
|
34
|
-
console.log(c.body);
|
|
35
|
-
}
|
|
36
|
-
if (inline.length === 0 && off.length === 0 && row.status === "completed") {
|
|
37
|
-
console.log("\nNo findings.");
|
|
38
|
-
}
|
|
27
|
+
renderFindings(row.findings, { emptyMessage: row.status === "completed" });
|
|
39
28
|
}
|