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.
@@ -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
- /** Parse `origin` remote into GitHub owner/repo when possible. */
7
- export declare function parseGithubOriginRepo(cwd?: string): {
8
- owner: string;
9
- repo: string;
10
- } | null;
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>;
@@ -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
- /** Parse `origin` remote into GitHub owner/repo when possible. */
7
- export function parseGithubOriginRepo(cwd = process.cwd()) {
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
- const url = git(["remote", "get-url", "origin"], cwd).trim();
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
- return null;
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] || "main";
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
- const diff = git(["diff", `${base}...${head}`]);
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
- const poll = await apiFetch(cfg, `/api/v1/reviews/${job.job_id}`, { signal: opts.signal });
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
- throw new CommandError(`Poll failed: ${JSON.stringify(poll.body, null, 2)}`);
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
- const inline = row.findings?.inline ?? [];
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(`Timed out waiting for review. Check: mergestorm status ${job.job_id}`);
192
+ throw new CommandError(withReviewJobRecovery("Timed out waiting for review.", job.job_id));
127
193
  }
@@ -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>;
@@ -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 { branchExists, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
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
- gitTopLevel(cwd);
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 loadStackMeta(cwd);
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 = parseGithubOriginRepo(cwd);
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
- requireGh(cwd);
225
+ requireGhFn(cwd);
211
226
  const opened = [];
212
227
  for (const layer of layers) {
213
228
  const base = layer.parentBranch || meta.trunk;
214
- if (!branchExists(layer.branch, cwd)) {
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
- pushBranch(layer.branch, cwd);
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 = findOpenPrNumber(owner, repo, layer.branch, cwd);
239
+ let prNumber = findOpenPrNumberFn(owner, repo, layer.branch, cwd);
225
240
  let created = false;
226
241
  if (prNumber == null) {
227
- const title = tipCommitSubject(layer.branch, cwd);
228
- const body = buildPrBodyFromCommit(tipCommitMessage(layer.branch, cwd));
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 = createPr({
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 loadConfig();
277
+ const cfg = await loadConfigFn();
248
278
  console.log(ansi.dim(` Registering stack via import (${owner}/${repo}#${registerPr}) …`));
249
- const body = await adoptStack(owner, repo, registerPr, cfg);
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 saveStackMeta(removeActiveStack(meta), cwd);
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/stacks"));
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/stacks"));
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/stacks"));
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/stacks"));
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 (#244 / AUD-07).
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
- if (!sub || sub === "-h" || sub === "--help") {
397
- throw new CommandError(STACK_USAGE);
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);
@@ -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
- const inline = row.findings?.inline ?? [];
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
  }