mergestorm 0.3.3 → 0.3.5

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 CHANGED
@@ -48,14 +48,14 @@ mergestorm # uses your local build
48
48
  On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a **full-width** welcome panel (logo + status on the left, tips / what's new on the right) and a **full-width** bordered input box with a live `/` slash-command dropdown. Non-TTY (CI/pipes) prints usage instead — no hanging prompt.
49
49
 
50
50
  ```
51
- ╭─ mergestorm v0.3.3 ──────────────────────────────────────────────────────────╮
51
+ ╭─ mergestorm v0.3.5 ──────────────────────────────────────────────────────────╮
52
52
  │ ▟██████████▛ Tips for getting started │
53
53
  │ ▜████████▛ review review main...HEAD │
54
- │ ▝▜████▛▘ stack list / adopt / restack / land │
54
+ │ ▝▜████▛▘ stack create → submit → restack → land │
55
55
  │ ▜██▛ /help list all commands │
56
56
  │ ██ │
57
- │ ▝▘ What's new in v0.3.3 │
58
- │ ● msk… · maelstrom • stack list / adopt / restack / land │
57
+ │ ▝▘ What's new in v0.3.5 │
58
+ │ ● msk… · maelstrom • stack submit fills PR body from tip commit │
59
59
  │ [████░░░░░░░░░░] 12% used │
60
60
  ╰──────────────────────────────────────────────────────────────────────────────╯
61
61
  mergestorm
@@ -92,14 +92,20 @@ mergestorm branches [n] Recently reviewed branches (arrow-pick on TTY)
92
92
  mergestorm chain [slug] Branch review-chain timeline (default: current branch)
93
93
  mergestorm whoami [--json] Key prefix, plan, API base, config path
94
94
  mergestorm thread <slug> Jobs in a review thread
95
- mergestorm stack list [--json] List adopted stacks
96
- mergestorm stack adopt <owner/repo>#<pr> Adopt an open PR chain
95
+ mergestorm stack create [name] New local stack layer (optional `--onto` / `--trunk`)
96
+ mergestorm stack submit Push layers, open chained PRs (`gh`), register via adopt
97
+ mergestorm stack list [--json] List registered stacks
98
+ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
97
99
  mergestorm stack restack <stack-id> Restack descendants
98
100
  mergestorm stack land <stack-id> Land the bottom open PR
99
101
  mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
100
102
  ```
101
103
 
102
- `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack list`. Stack ops use the same login key as review commands (`/api/v1/stacks`).
104
+ `mg` is a short alias for `mergestorm` (same binary), e.g. `mg stack create`. Happy path: **create → commit → submit → restack → land**. `stack adopt` is for importing a chain that already exists on GitHub (legacy / Graphite). Restack/land use the same login key (`/api/v1/stacks`).
105
+
106
+ `stack create` is local-only: it checks out a new branch from the current tip (or `--onto <branch>`), discovers trunk (`main` / `master` / `origin/HEAD`, overridable with `--trunk`), and appends `{ branch, parentBranch }` under **`.mergestorm/stack.json`**. No GitHub or API calls.
107
+
108
+ `stack submit` walks that meta: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), then registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key). Push/PR auth is your local `git` + `gh auth` — not a Cyclone app token.
103
109
 
104
110
  ### `login`
105
111
 
package/dist/api.d.ts CHANGED
@@ -91,6 +91,13 @@ export type StackLayerDto = {
91
91
  htmlUrl: string | null;
92
92
  ciStatus: string;
93
93
  reviewStatus: string;
94
+ checks: {
95
+ total: number;
96
+ success: number;
97
+ pending: number;
98
+ failure: number;
99
+ failingName: string | null;
100
+ } | null;
94
101
  conflictDetail: string | null;
95
102
  lastRestackedSha: string | null;
96
103
  };
@@ -109,7 +116,9 @@ export declare function adoptStack(owner: string, repo: string, prNumber: number
109
116
  /** Restack descendants (Bearer POST /api/v1/stacks/:id/restack). */
110
117
  export declare function restackStack(stackId: string, cfg?: Config): Promise<unknown>;
111
118
  /** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
112
- export declare function landNextStack(stackId: string, cfg?: Config): Promise<unknown>;
119
+ export declare function landNextStack(stackId: string, cfg?: Config, opts?: {
120
+ prNumber?: number;
121
+ }): Promise<unknown>;
113
122
  /** Toggle auto-land when green (Bearer PATCH /api/v1/stacks/:id). */
114
123
  export declare function setStackAutoLand(stackId: string, autoLandWhenGreen: boolean, cfg?: Config): Promise<unknown>;
115
124
  /** Recently reviewed branches/threads (Bearer GET /api/v1/threads). */
package/dist/api.js CHANGED
@@ -93,7 +93,7 @@ export async function adoptStack(owner, repo, prNumber, cfg) {
93
93
  throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
94
94
  }
95
95
  if (status !== 200) {
96
- throw new CommandError(`Failed to adopt stack (HTTP ${status}): ${JSON.stringify(body)}`);
96
+ throw new CommandError(`Failed to import stack (HTTP ${status}): ${JSON.stringify(body)}`);
97
97
  }
98
98
  return body;
99
99
  }
@@ -119,8 +119,11 @@ export async function restackStack(stackId, cfg) {
119
119
  return stackMutation(stackId, "/restack", "POST", {}, "Failed to restack", cfg);
120
120
  }
121
121
  /** Land the bottom open layer (Bearer POST /api/v1/stacks/:id/land-next). */
122
- export async function landNextStack(stackId, cfg) {
123
- return stackMutation(stackId, "/land-next", "POST", {}, "Failed to land next", cfg);
122
+ export async function landNextStack(stackId, cfg, opts) {
123
+ const json = opts?.prNumber != null && Number.isInteger(opts.prNumber) && opts.prNumber > 0
124
+ ? { prNumber: opts.prNumber }
125
+ : {};
126
+ return stackMutation(stackId, "/land-next", "POST", json, "Failed to land next", cfg);
124
127
  }
125
128
  /** Toggle auto-land when green (Bearer PATCH /api/v1/stacks/:id). */
126
129
  export async function setStackAutoLand(stackId, autoLandWhenGreen, cfg) {
package/dist/cli.js CHANGED
@@ -30,10 +30,12 @@ function usage() {
30
30
  mergestorm chain [slug] Branch review-chain timeline (default: current)
31
31
  mergestorm whoami [--json] Key + plan + API base
32
32
  mergestorm thread <slug> Jobs in a review thread
33
- mergestorm stack list [--json] List adopted stacks
34
- mergestorm stack adopt <owner/repo>#<pr> Adopt an open PR chain
33
+ mergestorm stack create [name] New local stack layer (.mergestorm/stack.json)
34
+ mergestorm stack submit Push layers, open chained PRs, register stack
35
+ mergestorm stack list [--json] List registered stacks
36
+ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
35
37
  mergestorm stack restack <stack-id> Restack stack descendants
36
- mergestorm stack land <stack-id> Land the bottom open PR
38
+ mergestorm stack land <stack-id> Land bottom PR (or promote into unit)
37
39
  mergestorm stack auto-land on|off <stack-id> Toggle auto-land when green
38
40
 
39
41
  mg is a short alias for mergestorm (same binary).
@@ -5,4 +5,11 @@ export declare function parseAdoptTarget(args: string[]): {
5
5
  repo: string;
6
6
  prNumber: number;
7
7
  };
8
+ /** Parse `stack create` argv into options. Exported for tests. */
9
+ export declare function parseStackCreateArgs(args: string[]): {
10
+ name?: string;
11
+ onto?: string;
12
+ trunk?: string;
13
+ asJson: boolean;
14
+ };
8
15
  export declare function cmdStack(args: string[]): Promise<void>;
@@ -1,13 +1,24 @@
1
1
  import { adoptStack, landNextStack, listStacks, restackStack, setStackAutoLand, } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
3
  import { CommandError } from "../errors.js";
4
+ import { createPr, findOpenPrNumber, requireGh } from "../gh.js";
5
+ import { branchExists, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
6
+ import { buildPrBodyFromCommit } from "../pr-body.js";
7
+ import { appendLayer, loadStackMeta, saveStackMeta, } from "../stack-meta.js";
4
8
  import { ansi } from "../ui/ansi.js";
9
+ import { parseGithubOriginRepo } from "./review.js";
5
10
  const STACK_USAGE = `usage:
11
+ mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--json]
12
+ mergestorm stack submit [--json]
6
13
  mergestorm stack list [--json]
7
14
  mergestorm stack adopt <owner/repo>#<pr>
8
15
  mergestorm stack restack <stack-id> [--json]
9
16
  mergestorm stack land <stack-id> [--json]
10
- mergestorm stack auto-land on|off <stack-id> [--json]`;
17
+ mergestorm stack auto-land on|off <stack-id> [--json]
18
+
19
+ stack submit opens PRs with a body generated from each layer tip commit.
20
+ stack land promotes into the review unit when one exists; otherwise lands
21
+ the bottom open PR (unit-less stacks).`;
11
22
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
12
23
  export function requireStackId(raw, usage) {
13
24
  const id = raw?.trim() ?? "";
@@ -52,6 +63,196 @@ function formatStackHuman(s) {
52
63
  }
53
64
  return lines;
54
65
  }
66
+ /** Parse `stack create` argv into options. Exported for tests. */
67
+ export function parseStackCreateArgs(args) {
68
+ let name;
69
+ let onto;
70
+ let trunk;
71
+ let asJson = false;
72
+ for (let i = 0; i < args.length; i++) {
73
+ const a = args[i];
74
+ if (a === "--json") {
75
+ asJson = true;
76
+ continue;
77
+ }
78
+ if (a === "--onto") {
79
+ const v = args[++i];
80
+ if (!v)
81
+ throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>]");
82
+ onto = v;
83
+ continue;
84
+ }
85
+ if (a.startsWith("--onto=")) {
86
+ onto = a.slice("--onto=".length);
87
+ continue;
88
+ }
89
+ if (a === "--trunk") {
90
+ const v = args[++i];
91
+ if (!v)
92
+ throw new CommandError("usage: mergestorm stack create [name] [--trunk <branch>]");
93
+ trunk = v;
94
+ continue;
95
+ }
96
+ if (a.startsWith("--trunk=")) {
97
+ trunk = a.slice("--trunk=".length);
98
+ continue;
99
+ }
100
+ if (a.startsWith("-")) {
101
+ throw new CommandError(`${STACK_USAGE}\nunknown flag: ${a}`);
102
+ }
103
+ if (name != null) {
104
+ throw new CommandError("usage: mergestorm stack create [name] [--onto <branch>] [--trunk <branch>]");
105
+ }
106
+ name = a;
107
+ }
108
+ return { name, onto, trunk, asJson };
109
+ }
110
+ async function cmdStackCreate(args) {
111
+ const { name, onto, trunk: trunkFlag, asJson } = parseStackCreateArgs(args);
112
+ const cwd = process.cwd();
113
+ if (worktreeDirty(cwd)) {
114
+ throw new CommandError("Working tree is dirty. Commit or stash changes before `stack create`.");
115
+ }
116
+ const parentBranch = onto?.trim() || currentBranch(cwd);
117
+ if (!parentBranch) {
118
+ throw new CommandError("Detached HEAD. Check out a branch (or pass --onto <branch>) before `stack create`.");
119
+ }
120
+ if (!branchExists(parentBranch, cwd)) {
121
+ throw new CommandError(`Parent branch not found: ${parentBranch}`);
122
+ }
123
+ const branch = defaultLayerBranchName(name);
124
+ if (branchExists(branch, cwd)) {
125
+ throw new CommandError(`Branch already exists: ${branch}`);
126
+ }
127
+ let meta = await loadStackMeta(cwd);
128
+ const trunk = trunkFlag?.trim() || meta?.trunk || discoverTrunk(cwd);
129
+ if (!meta) {
130
+ meta = { version: 1, trunk, layers: [] };
131
+ }
132
+ else if (trunkFlag?.trim() && meta.trunk !== trunk) {
133
+ throw new CommandError(`Stack trunk is ${meta.trunk}; refusing --trunk ${trunk}. Edit .mergestorm/stack.json or start fresh.`);
134
+ }
135
+ // Parent must be trunk or an existing layer (linear local stack).
136
+ if (meta.layers.length > 0 &&
137
+ parentBranch !== meta.trunk &&
138
+ !meta.layers.some((l) => l.branch === parentBranch)) {
139
+ throw new CommandError(`Parent ${parentBranch} is not trunk (${meta.trunk}) or a stack layer. Use --onto <branch>.`);
140
+ }
141
+ try {
142
+ createBranchFromHead(branch, cwd);
143
+ }
144
+ catch (err) {
145
+ throw new CommandError(err instanceof Error ? err.message : String(err));
146
+ }
147
+ let next;
148
+ try {
149
+ next = appendLayer(meta, { branch, parentBranch });
150
+ await saveStackMeta(next, cwd);
151
+ }
152
+ catch (err) {
153
+ try {
154
+ deleteBranch(branch, cwd);
155
+ }
156
+ catch { /* rollback best-effort */ }
157
+ throw new CommandError(err instanceof Error ? err.message : String(err));
158
+ }
159
+ if (asJson) {
160
+ console.log(JSON.stringify({ branch, parentBranch, trunk: next.trunk, meta: next }, null, 2));
161
+ return;
162
+ }
163
+ console.log(ansi.green(` Created ${branch} (onto ${parentBranch})`));
164
+ console.log(ansi.dim(` trunk: ${next.trunk} · layers: ${next.layers.length}`));
165
+ console.log(ansi.dim(` meta: .mergestorm/stack.json`));
166
+ }
167
+ async function cmdStackSubmit(args) {
168
+ const asJson = args.includes("--json");
169
+ if (args.some((a) => a !== "--json")) {
170
+ throw new CommandError("usage: mergestorm stack submit [--json]");
171
+ }
172
+ const cwd = process.cwd();
173
+ const meta = await loadStackMeta(cwd);
174
+ if (!meta || meta.layers.length === 0) {
175
+ throw new CommandError("No local stack layers. Run `mg stack create` (and commit) before `stack submit`.");
176
+ }
177
+ const origin = parseGithubOriginRepo();
178
+ if (!origin) {
179
+ throw new CommandError("Could not parse GitHub owner/repo from `origin`. Set a github.com remote and retry.");
180
+ }
181
+ const { owner, repo } = origin;
182
+ requireGh(cwd);
183
+ const opened = [];
184
+ for (const layer of meta.layers) {
185
+ const base = layer.parentBranch || meta.trunk;
186
+ if (!branchExists(layer.branch, cwd)) {
187
+ throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
188
+ }
189
+ console.log(ansi.dim(` Pushing ${layer.branch} …`));
190
+ try {
191
+ pushBranch(layer.branch, cwd);
192
+ }
193
+ catch (err) {
194
+ throw new CommandError(err instanceof Error ? err.message : String(err));
195
+ }
196
+ let prNumber = findOpenPrNumber(owner, repo, layer.branch, cwd);
197
+ let created = false;
198
+ if (prNumber == null) {
199
+ const title = tipCommitSubject(layer.branch, cwd);
200
+ const body = buildPrBodyFromCommit(tipCommitMessage(layer.branch, cwd));
201
+ console.log(ansi.dim(` Opening PR ${layer.branch} → ${base} …`));
202
+ prNumber = createPr({
203
+ owner,
204
+ repo,
205
+ base,
206
+ head: layer.branch,
207
+ title,
208
+ body,
209
+ cwd,
210
+ });
211
+ created = true;
212
+ }
213
+ else {
214
+ console.log(ansi.dim(` PR #${prNumber} already open for ${layer.branch}`));
215
+ }
216
+ opened.push({ branch: layer.branch, base, prNumber, created });
217
+ }
218
+ const registerPr = opened[0].prNumber;
219
+ const cfg = await loadConfig();
220
+ console.log(ansi.dim(` Registering stack via import (${owner}/${repo}#${registerPr}) …`));
221
+ const body = await adoptStack(owner, repo, registerPr, cfg);
222
+ if (typeof body !== "object" || body === null) {
223
+ throw new CommandError("Unexpected response shape from adopt API");
224
+ }
225
+ const data = body;
226
+ if (data.error) {
227
+ throw new CommandError(data.error);
228
+ }
229
+ if (!data.stack) {
230
+ throw new CommandError("Stack was not registered");
231
+ }
232
+ const stackId = data.stack.id;
233
+ if (asJson) {
234
+ console.log(JSON.stringify({
235
+ owner,
236
+ repo,
237
+ layers: opened,
238
+ stackId: stackId ?? null,
239
+ trunk: data.stack?.trunkBranch ?? meta.trunk,
240
+ chain: data.chain ?? null,
241
+ }, null, 2));
242
+ return;
243
+ }
244
+ for (const row of opened) {
245
+ const verb = row.created ? "opened" : "exists";
246
+ console.log(ansi.green(` #${row.prNumber} ${row.branch} → ${row.base} (${verb})`));
247
+ }
248
+ if (stackId) {
249
+ console.log(ansi.green(` Registered stack ${stackId}`));
250
+ }
251
+ else {
252
+ console.log(ansi.dim(" Stack registration returned no stack ID"));
253
+ }
254
+ console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
255
+ }
55
256
  async function cmdStackList(args) {
56
257
  const asJson = args.includes("--json");
57
258
  const cfg = await loadConfig();
@@ -61,7 +262,8 @@ async function cmdStackList(args) {
61
262
  return;
62
263
  }
63
264
  if (stacks.length === 0) {
64
- console.log(ansi.dim(" No stacks yet. Adopt one: `stack adopt owner/repo#pr`"));
265
+ console.log(ansi.dim(" No stacks yet. Author with `stack create` → commit → `stack submit`."));
266
+ console.log(ansi.dim(" Import an existing chain only: `stack adopt owner/repo#pr`"));
65
267
  console.log(ansi.dim(" Dashboard: https://mergestorm.ai/dashboard/stacks"));
66
268
  return;
67
269
  }
@@ -91,7 +293,7 @@ async function cmdStackAdopt(args) {
91
293
  const data = body;
92
294
  const stack = data.stack;
93
295
  const chain = data.chain;
94
- console.log(ansi.green(` Adopted ${owner}/${repo}#${prNumber}` +
296
+ console.log(ansi.green(` Imported ${owner}/${repo}#${prNumber}` +
95
297
  (stack?.id ? ` → ${stack.id}` : "") +
96
298
  (Array.isArray(chain) ? ` (${chain.length} layer(s))` : "")));
97
299
  if (stack?.trunkBranch) {
@@ -115,6 +317,7 @@ async function cmdStackLand(args) {
115
317
  const asJson = args.includes("--json");
116
318
  const id = requireStackId(args.find((a) => a !== "--json"), "usage: mergestorm stack land <stack-id>");
117
319
  const cfg = await loadConfig();
320
+ // Server routes unit stacks to promote-next (#244 / AUD-07).
118
321
  const body = await landNextStack(id, cfg);
119
322
  if (asJson) {
120
323
  console.log(JSON.stringify(body, null, 2));
@@ -122,6 +325,15 @@ async function cmdStackLand(args) {
122
325
  }
123
326
  const pr = body.mergedPrNumber;
124
327
  const branch = body.mergedBranch;
328
+ const uNumber = body.uNumber;
329
+ if (typeof uNumber === "number" && uNumber > 0) {
330
+ console.log(ansi.green(` Promoted` +
331
+ (pr != null ? ` #${pr}` : "") +
332
+ ` into U${uNumber}` +
333
+ (branch ? ` (${branch})` : "") +
334
+ ` on ${id}`));
335
+ return;
336
+ }
125
337
  console.log(ansi.green(` Landed` +
126
338
  (pr != null ? ` #${pr}` : "") +
127
339
  (branch ? ` (${branch})` : "") +
@@ -149,6 +361,10 @@ export async function cmdStack(args) {
149
361
  if (!sub || sub === "-h" || sub === "--help") {
150
362
  throw new CommandError(STACK_USAGE);
151
363
  }
364
+ if (sub === "create")
365
+ return cmdStackCreate(rest);
366
+ if (sub === "submit")
367
+ return cmdStackSubmit(rest);
152
368
  if (sub === "list" || sub === "ls")
153
369
  return cmdStackList(rest);
154
370
  if (sub === "adopt")
package/dist/gh.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ export declare function runGh(args: string[], cwd?: string): {
2
+ ok: true;
3
+ stdout: string;
4
+ } | {
5
+ ok: false;
6
+ stderr: string;
7
+ status: number | null;
8
+ };
9
+ export declare function requireGh(cwd?: string): void;
10
+ /** Open PR number for `head` branch in owner/repo, or null. */
11
+ export declare function findOpenPrNumber(owner: string, repo: string, headBranch: string, cwd?: string): number | null;
12
+ export declare function createPr(input: {
13
+ owner: string;
14
+ repo: string;
15
+ base: string;
16
+ head: string;
17
+ title: string;
18
+ body?: string;
19
+ cwd?: string;
20
+ }): number;
package/dist/gh.js ADDED
@@ -0,0 +1,89 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { CommandError } from "./errors.js";
3
+ export function runGh(args, cwd = process.cwd()) {
4
+ const result = spawnSync("gh", args, { cwd, encoding: "utf8" });
5
+ if (result.error) {
6
+ const msg = result.error.message || String(result.error);
7
+ if (result.error.code === "ENOENT") {
8
+ return { ok: false, stderr: "gh not found", status: null };
9
+ }
10
+ return { ok: false, stderr: msg, status: result.status };
11
+ }
12
+ if (result.status !== 0) {
13
+ return {
14
+ ok: false,
15
+ stderr: (result.stderr || result.stdout || `gh ${args.join(" ")} failed`).trim(),
16
+ status: result.status,
17
+ };
18
+ }
19
+ return { ok: true, stdout: result.stdout ?? "" };
20
+ }
21
+ export function requireGh(cwd = process.cwd()) {
22
+ const check = runGh(["--version"], cwd);
23
+ if (!check.ok) {
24
+ throw new CommandError("GitHub CLI (`gh`) is required for `stack submit`. Install https://cli.github.com and run `gh auth login`.");
25
+ }
26
+ const auth = runGh(["auth", "status"], cwd);
27
+ if (!auth.ok) {
28
+ throw new CommandError("`gh` is installed but not authenticated. Run `gh auth login`, then retry `stack submit`.");
29
+ }
30
+ }
31
+ /** Open PR number for `head` branch in owner/repo, or null. */
32
+ export function findOpenPrNumber(owner, repo, headBranch, cwd = process.cwd()) {
33
+ const res = runGh([
34
+ "pr",
35
+ "list",
36
+ "--repo",
37
+ `${owner}/${repo}`,
38
+ "--head",
39
+ headBranch,
40
+ "--state",
41
+ "open",
42
+ "--json",
43
+ "number",
44
+ "--limit",
45
+ "1",
46
+ ], cwd);
47
+ if (!res.ok) {
48
+ throw new CommandError(`Failed to list PRs for ${headBranch}: ${res.stderr}`);
49
+ }
50
+ try {
51
+ const rows = JSON.parse(res.stdout);
52
+ const n = rows[0]?.number;
53
+ return typeof n === "number" && n > 0 ? n : null;
54
+ }
55
+ catch (e) {
56
+ throw new CommandError(`Failed to parse PR list response: ${e instanceof Error ? e.message : String(e)}`);
57
+ }
58
+ }
59
+ export function createPr(input) {
60
+ const cwd = input.cwd ?? process.cwd();
61
+ const args = [
62
+ "pr",
63
+ "create",
64
+ "--repo",
65
+ `${input.owner}/${input.repo}`,
66
+ "--base",
67
+ input.base,
68
+ "--head",
69
+ input.head,
70
+ "--title",
71
+ input.title,
72
+ "--body",
73
+ input.body ?? "",
74
+ ];
75
+ const res = runGh(args, cwd);
76
+ if (!res.ok) {
77
+ throw new CommandError(`Failed to open PR ${input.head} → ${input.base}: ${res.stderr}`);
78
+ }
79
+ // stdout is typically the PR URL
80
+ const url = res.stdout.trim();
81
+ const m = /\/pull\/(\d+)/.exec(url);
82
+ if (m?.[1])
83
+ return Number(m[1]);
84
+ // Fallback: re-list
85
+ const n = findOpenPrNumber(input.owner, input.repo, input.head, cwd);
86
+ if (n != null)
87
+ return n;
88
+ throw new CommandError(`Opened PR at ${url || "(unknown URL)"} but could not determine PR number. Check your repository's pull requests.`);
89
+ }
@@ -0,0 +1,24 @@
1
+ /** Current branch name, or null when detached. */
2
+ export declare function currentBranch(cwd?: string): string | null;
3
+ /**
4
+ * True when the worktree has staged/unstaged/untracked changes that should
5
+ * block `stack create`. Ignores `.mergestorm/stack.json` so local stack meta
6
+ * written by create does not prevent the next layer.
7
+ */
8
+ export declare function worktreeDirty(cwd?: string): boolean;
9
+ /**
10
+ * Resolve trunk branch: --trunk flag, then origin/HEAD, then main/master if present.
11
+ */
12
+ export declare function discoverTrunk(cwd?: string): string;
13
+ export declare function branchExists(name: string, cwd?: string): boolean;
14
+ export declare function deleteBranch(name: string, cwd?: string): void;
15
+ export declare function createBranchFromHead(name: string, cwd?: string): void;
16
+ /** Slugify a free-form name into a safe branch segment. */
17
+ export declare function slugifyBranchName(raw: string): string;
18
+ export declare function defaultLayerBranchName(explicit?: string): string;
19
+ /** Push branch to origin and set upstream. */
20
+ export declare function pushBranch(branch: string, cwd?: string): void;
21
+ /** First line of the tip commit on `branch` (for PR title). */
22
+ export declare function tipCommitSubject(branch: string, cwd?: string): string;
23
+ /** Full tip commit message on `branch` (subject + body; for PR description). */
24
+ export declare function tipCommitMessage(branch: string, cwd?: string): string;
@@ -0,0 +1,129 @@
1
+ import { git } from "./git.js";
2
+ /** Current branch name, or null when detached. */
3
+ export function currentBranch(cwd = process.cwd()) {
4
+ try {
5
+ const name = git(["rev-parse", "--abbrev-ref", "HEAD"], cwd).trim();
6
+ if (!name || name === "HEAD")
7
+ return null;
8
+ return name;
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ }
14
+ /**
15
+ * True when the worktree has staged/unstaged/untracked changes that should
16
+ * block `stack create`. Ignores `.mergestorm/stack.json` so local stack meta
17
+ * written by create does not prevent the next layer.
18
+ */
19
+ export function worktreeDirty(cwd = process.cwd()) {
20
+ const out = git(["status", "--porcelain"], cwd);
21
+ for (const line of out.split("\n")) {
22
+ const trimmed = line.trimEnd();
23
+ if (!trimmed)
24
+ continue;
25
+ // porcelain: XY PATH or XY ORIG -> PATH (renames only)
26
+ const status = trimmed.slice(0, 2);
27
+ const pathPart = (status.includes("R") || status.includes("C"))
28
+ ? trimmed.slice(3).split(" -> ").pop()?.trim() ?? ""
29
+ : trimmed.slice(3).trim();
30
+ if (pathPart === ".mergestorm/stack.json" || pathPart.endsWith("/.mergestorm/stack.json")) {
31
+ continue;
32
+ }
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+ /**
38
+ * Resolve trunk branch: --trunk flag, then origin/HEAD, then main/master if present.
39
+ */
40
+ export function discoverTrunk(cwd = process.cwd()) {
41
+ try {
42
+ const sym = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd).trim();
43
+ // origin/main → main
44
+ const m = /^origin\/(.+)$/.exec(sym);
45
+ if (m?.[1])
46
+ return m[1];
47
+ }
48
+ catch {
49
+ // no origin/HEAD
50
+ }
51
+ for (const candidate of ["main", "master"]) {
52
+ try {
53
+ git(["rev-parse", "--verify", candidate], cwd);
54
+ return candidate;
55
+ }
56
+ catch {
57
+ // try next
58
+ }
59
+ }
60
+ throw new Error("Could not discover trunk branch. Pass --trunk <name> or set origin/HEAD.");
61
+ }
62
+ export function branchExists(name, cwd = process.cwd()) {
63
+ try {
64
+ git(["rev-parse", "--verify", "--quiet", `refs/heads/${name}`], cwd);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ export function deleteBranch(name, cwd = process.cwd()) {
72
+ git(["branch", "-D", name], cwd);
73
+ }
74
+ export function createBranchFromHead(name, cwd = process.cwd()) {
75
+ git(["checkout", "-b", name], cwd);
76
+ }
77
+ /** Slugify a free-form name into a safe branch segment. */
78
+ export function slugifyBranchName(raw) {
79
+ const s = raw
80
+ .trim()
81
+ .toLowerCase()
82
+ .replace(/[^a-z0-9/_-]+/g, "-")
83
+ .replace(/\/+/g, "/")
84
+ .replace(/^-+|-+$/g, "");
85
+ return s || `layer-${Date.now().toString(36)}`;
86
+ }
87
+ export function defaultLayerBranchName(explicit) {
88
+ if (explicit?.trim()) {
89
+ const s = slugifyBranchName(explicit);
90
+ return s.includes("/") ? s : `ms/${s}`;
91
+ }
92
+ return `ms/${new Date().toISOString().slice(0, 10)}-${Date.now().toString(36).slice(-4)}`;
93
+ }
94
+ /** Push branch to origin and set upstream. */
95
+ export function pushBranch(branch, cwd = process.cwd()) {
96
+ try {
97
+ git(["push", "-u", "origin", branch], cwd);
98
+ }
99
+ catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ throw new Error(`git push failed for ${branch}. Check remote auth (SSH key or credential helper).\n${msg}`);
102
+ }
103
+ }
104
+ /** First line of the tip commit on `branch` (for PR title). */
105
+ export function tipCommitSubject(branch, cwd = process.cwd()) {
106
+ try {
107
+ const s = git(["log", "-1", "--format=%s", branch], cwd).trim();
108
+ if (s)
109
+ return s;
110
+ console.warn(`Warning: no commit message on ${branch}; using branch name as PR title`);
111
+ return branch;
112
+ }
113
+ catch {
114
+ console.warn(`Warning: could not read commit message on ${branch}; using branch name as PR title`);
115
+ return branch;
116
+ }
117
+ }
118
+ /** Full tip commit message on `branch` (subject + body; for PR description). */
119
+ export function tipCommitMessage(branch, cwd = process.cwd()) {
120
+ try {
121
+ const s = git(["log", "-1", "--format=%B", branch], cwd);
122
+ if (s.trim())
123
+ return s;
124
+ return tipCommitSubject(branch, cwd);
125
+ }
126
+ catch {
127
+ return tipCommitSubject(branch, cwd);
128
+ }
129
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Build a GitHub PR body from a tip commit message (#217).
3
+ * Pure — no git / network.
4
+ */
5
+ /**
6
+ * Turn `git log -1 --format=%B` output into a non-empty PR description.
7
+ */
8
+ export declare function buildPrBodyFromCommit(fullMessage: string): string;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Build a GitHub PR body from a tip commit message (#217).
3
+ * Pure — no git / network.
4
+ */
5
+ const TRAILER_RE = /^(fixes|closes|resolves)\s+#(\d+)\s*$/i;
6
+ /**
7
+ * Turn `git log -1 --format=%B` output into a non-empty PR description.
8
+ */
9
+ export function buildPrBodyFromCommit(fullMessage) {
10
+ const lines = fullMessage.replace(/\r\n/g, "\n").split("\n");
11
+ const subject = (lines[0] ?? "").trim() || "Update";
12
+ const trailers = [];
13
+ const bodyLines = [];
14
+ for (const raw of lines.slice(1)) {
15
+ const line = raw.trim();
16
+ if (!line)
17
+ continue;
18
+ const m = TRAILER_RE.exec(line);
19
+ if (m) {
20
+ const verb = m[1][0].toUpperCase() + m[1].slice(1).toLowerCase();
21
+ trailers.push(`${verb} #${m[2]}`);
22
+ continue;
23
+ }
24
+ bodyLines.push(line);
25
+ }
26
+ // Also pick trailers that appear as the whole subject (unusual but cheap).
27
+ const subjectTrailer = TRAILER_RE.exec(subject);
28
+ const summaryBullets = [];
29
+ if (subjectTrailer) {
30
+ const verb = subjectTrailer[1][0].toUpperCase() + subjectTrailer[1].slice(1).toLowerCase();
31
+ trailers.unshift(`${verb} #${subjectTrailer[2]}`);
32
+ summaryBullets.push(`- ${subject}`);
33
+ }
34
+ else {
35
+ summaryBullets.push(`- ${subject}`);
36
+ }
37
+ for (const para of bodyLines) {
38
+ summaryBullets.push(`- ${para}`);
39
+ }
40
+ const parts = [
41
+ "## Summary",
42
+ ...summaryBullets,
43
+ "",
44
+ "## Test plan",
45
+ "- [ ] CI green",
46
+ ];
47
+ if (trailers.length > 0) {
48
+ parts.push("", ...uniqueTrailers(trailers));
49
+ }
50
+ return parts.join("\n") + "\n";
51
+ }
52
+ function uniqueTrailers(trailers) {
53
+ const seen = new Set();
54
+ const out = [];
55
+ for (const t of trailers) {
56
+ const key = t.toLowerCase();
57
+ if (seen.has(key))
58
+ continue;
59
+ seen.add(key);
60
+ out.push(t);
61
+ }
62
+ return out;
63
+ }
package/dist/shell.js CHANGED
@@ -28,7 +28,7 @@ export const COMMANDS = [
28
28
  { name: "chain", summary: "Show a branch review-chain timeline" },
29
29
  { name: "whoami", summary: "Key + plan + API base" },
30
30
  { name: "thread", summary: "Jobs in a review thread" },
31
- { name: "stack", summary: "Stacks: list, adopt, restack, land, auto-land" },
31
+ { name: "stack", summary: "Stacks: create, submit, list, adopt, restack, land" },
32
32
  { name: "clear", summary: "Clear screen and reprint banner" },
33
33
  { name: "exit", summary: "Leave the shell" },
34
34
  { name: "quit", summary: "Leave the shell" },
@@ -0,0 +1,15 @@
1
+ export type StackLayerMeta = {
2
+ branch: string;
3
+ /** Parent branch name (trunk for the bottom layer). */
4
+ parentBranch: string;
5
+ };
6
+ export type StackMeta = {
7
+ version: 1;
8
+ trunk: string;
9
+ layers: StackLayerMeta[];
10
+ };
11
+ export declare const STACK_META_REL: string;
12
+ export declare function stackMetaPath(cwd?: string): string;
13
+ export declare function loadStackMeta(cwd?: string): Promise<StackMeta | null>;
14
+ export declare function saveStackMeta(meta: StackMeta, cwd?: string): Promise<void>;
15
+ export declare function appendLayer(meta: StackMeta, layer: StackLayerMeta): StackMeta;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Local stack metadata for `mg stack create` / `submit`.
3
+ * Stored at `.mergestorm/stack.json` in the repo root.
4
+ */
5
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ export const STACK_META_REL = path.join(".mergestorm", "stack.json");
8
+ export function stackMetaPath(cwd = process.cwd()) {
9
+ return path.join(cwd, STACK_META_REL);
10
+ }
11
+ export async function loadStackMeta(cwd = process.cwd()) {
12
+ try {
13
+ const raw = await readFile(stackMetaPath(cwd), "utf8");
14
+ const parsed = JSON.parse(raw);
15
+ if (!parsed ||
16
+ parsed.version !== 1 ||
17
+ typeof parsed.trunk !== "string" ||
18
+ !Array.isArray(parsed.layers) ||
19
+ !parsed.layers.every(l => typeof l.branch === 'string' && typeof l.parentBranch === 'string')) {
20
+ return null;
21
+ }
22
+ return parsed;
23
+ }
24
+ catch (err) {
25
+ const code = err?.code;
26
+ if (code === "ENOENT")
27
+ return null;
28
+ throw err;
29
+ }
30
+ }
31
+ export async function saveStackMeta(meta, cwd = process.cwd()) {
32
+ const file = stackMetaPath(cwd);
33
+ await mkdir(path.dirname(file), { recursive: true });
34
+ await writeFile(file, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
35
+ }
36
+ export function appendLayer(meta, layer) {
37
+ if (meta.layers.some((l) => l.branch === layer.branch)) {
38
+ throw new Error(`branch already in stack: ${layer.branch}`);
39
+ }
40
+ return { ...meta, layers: [...meta.layers, layer] };
41
+ }
package/dist/ui/banner.js CHANGED
@@ -38,6 +38,14 @@ const WHATS_NEW = {
38
38
  "0.3.3": [
39
39
  "`stack list` / `adopt` / `restack` / `land` / `auto-land`",
40
40
  ],
41
+ "0.3.4": [
42
+ "`stack create` / `submit` — author stacks with `mg` (not Graphite)",
43
+ "Adopt is import-only; happy path is create → submit → restack → land",
44
+ ],
45
+ "0.3.5": [
46
+ "`stack submit` fills PR body from the tip commit (keeps Fixes/Closes)",
47
+ "Unit-stack `stack land` promotes (matches server gates)",
48
+ ],
41
49
  };
42
50
  function formatStatusLines(me, key, barWidth) {
43
51
  if (!key) {
@@ -61,7 +69,7 @@ function tipsLines() {
61
69
  return [
62
70
  ansi.dim("Tips for getting started"),
63
71
  ` ${ansi.green("review")} review main...HEAD`,
64
- ` ${ansi.green("branches")} pick a reviewed branch`,
72
+ ` ${ansi.green("stack")} create → submit → restack → land`,
65
73
  ` ${ansi.green("/help")} list all commands`,
66
74
  ];
67
75
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "MergeStorm CLI — interactive shell and one-shot review commands",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "dev": "tsx src/cli.ts",
25
25
  "start": "node dist/cli.js",
26
26
  "typecheck": "tsc -p tsconfig.json --noEmit",
27
- "test": "tsx --test src/**/*.test.ts"
27
+ "test": "tsx --test src/*.test.ts src/**/*.test.ts"
28
28
  },
29
29
  "engines": {
30
30
  "node": ">=22.0.0"