mergestorm 0.3.63 → 0.3.66

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.
Files changed (79) hide show
  1. package/README.md +30 -10
  2. package/dist/api.d.ts +68 -11
  3. package/dist/api.js +206 -8
  4. package/dist/automation-catalog.d.ts +17 -3
  5. package/dist/automation-catalog.js +4 -1
  6. package/dist/cli.js +21 -19
  7. package/dist/client/index.d.ts +6 -4
  8. package/dist/client/index.js +6 -4
  9. package/dist/commands/account.d.ts +3 -0
  10. package/dist/commands/account.js +75 -0
  11. package/dist/commands/browse.d.ts +1 -9
  12. package/dist/commands/browse.js +10 -8
  13. package/dist/commands/dismiss.d.ts +0 -2
  14. package/dist/commands/dismiss.js +2 -2
  15. package/dist/commands/help.js +3 -2
  16. package/dist/commands/issue.d.ts +24 -6
  17. package/dist/commands/issue.js +153 -31
  18. package/dist/commands/login.d.ts +2 -1
  19. package/dist/commands/login.js +50 -14
  20. package/dist/commands/logout.js +6 -7
  21. package/dist/commands/memory.d.ts +29 -0
  22. package/dist/commands/memory.js +154 -0
  23. package/dist/commands/registry.d.ts +1 -0
  24. package/dist/commands/registry.js +52 -11
  25. package/dist/commands/review-client.d.ts +0 -3
  26. package/dist/commands/review-client.js +3 -3
  27. package/dist/commands/review.d.ts +1 -1
  28. package/dist/commands/review.js +1 -1
  29. package/dist/commands/settings.d.ts +3 -4
  30. package/dist/commands/settings.js +51 -5
  31. package/dist/commands/stack.d.ts +20 -5
  32. package/dist/commands/stack.js +106 -7
  33. package/dist/commands/whoami.js +14 -10
  34. package/dist/config.d.ts +56 -2
  35. package/dist/config.js +271 -13
  36. package/dist/errors.d.ts +1 -1
  37. package/dist/gh.d.ts +0 -4
  38. package/dist/gh.js +4 -4
  39. package/dist/git.d.ts +1 -1
  40. package/dist/git.js +2 -2
  41. package/dist/memory-api.d.ts +74 -0
  42. package/dist/memory-api.js +69 -0
  43. package/dist/node-version.d.ts +0 -2
  44. package/dist/node-version.js +1 -1
  45. package/dist/pr-body.d.ts +4 -0
  46. package/dist/pr-body.js +15 -0
  47. package/dist/repo-identity.d.ts +2 -1
  48. package/dist/repo-identity.js +8 -4
  49. package/dist/server-publish.d.ts +5 -26
  50. package/dist/server-publish.js +47 -40
  51. package/dist/stack-blockers.d.ts +15 -36
  52. package/dist/stack-blockers.js +69 -33
  53. package/dist/stack-dto.d.ts +4 -0
  54. package/dist/stack-dto.js +15 -2
  55. package/dist/stack-meta.d.ts +2 -2
  56. package/dist/stack-meta.js +1 -1
  57. package/dist/stack-watch-loop.d.ts +2 -13
  58. package/dist/stack-watch-loop.js +35 -14
  59. package/dist/stack-watch.d.ts +2 -1
  60. package/dist/stack-watch.js +6 -2
  61. package/dist/submit-pr-base.d.ts +0 -1
  62. package/dist/submit-pr-base.js +1 -1
  63. package/dist/ui/ansi.d.ts +0 -10
  64. package/dist/ui/ansi.js +2 -2
  65. package/dist/ui/banner.d.ts +3 -1
  66. package/dist/ui/banner.js +13 -3
  67. package/dist/ui/box.d.ts +0 -2
  68. package/dist/ui/box.js +1 -1
  69. package/dist/ui/prompt.d.ts +0 -11
  70. package/dist/ui/prompt.js +7 -7
  71. package/dist/ui/tabs.d.ts +2 -2
  72. package/dist/ui/tabs.js +1 -1
  73. package/dist/ui/width.d.ts +0 -9
  74. package/dist/ui/width.js +3 -3
  75. package/dist/web-api.d.ts +17 -0
  76. package/dist/web-api.js +30 -0
  77. package/package.json +3 -1
  78. package/skill/SKILL.md +4 -0
  79. package/skill/mergestorm-bounce-watch/SKILL.md +1 -1
package/README.md CHANGED
@@ -45,12 +45,12 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.63 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.66 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
52
52
  │ ▝▀▀▘ review a diff · usage for tabs · /help for all commands │
53
- │ ▀ New in v0.3.63: Stack create starts a new stack unless --extend│
53
+ │ ▀ New in v0.3.66: `issue wait`; submit needs an approved issue │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -75,11 +75,14 @@ One-shot subcommands work the same inside the shell and from argv (scripts/CI):
75
75
  ```
76
76
  mergestorm Interactive shell (TTY only)
77
77
  mergestorm shell Explicit shell entry
78
- mergestorm login Sign in via browser; stores an API key in ~/.mergestorm/config.json
78
+ mergestorm login Sign in via browser and add the account (up to 3 saved)
79
79
  mergestorm login --start Print a sign-in URL and code, then exit (agents)
80
80
  mergestorm login --finish Store the API key once the code from --start is approved
81
81
  mergestorm login --key Paste an existing API key instead (headless/CI)
82
- mergestorm logout Remove the stored API key
82
+ mergestorm logout Log out of the active account (or --account <name>) and revoke its key
83
+ mergestorm account list [--json] Saved accounts (up to 3), the active one marked
84
+ mergestorm account use <name> Make <name> the active account
85
+ mergestorm account remove <name> Log out of <name> and revoke its key
83
86
  mergestorm review [base] [head] Review git diff base...head (default: origin/HEAD or main or master)
84
87
  mergestorm status <job_id> Fetch a review job (envelope; --json --wait --timeout)
85
88
  mergestorm status Tabbed Status / Usage / Jobs browser (TTY)
@@ -87,10 +90,11 @@ mergestorm credits [--json] Usage panel (bar + last 5 jobs); tabbed browser
87
90
  mergestorm jobs [n] [--json] Recent review jobs (default 10, max 50)
88
91
  mergestorm branches [n] Recently reviewed branches (arrow-pick on TTY)
89
92
  mergestorm chain [slug] Branch review-chain timeline (default: current branch)
90
- mergestorm whoami [--json] Key prefix, plan, API base, config path
93
+ mergestorm whoami [--json] Active account, key prefix, plan, API base, config path
91
94
  mergestorm skill install --claude|--cursor|--agents Copy the Mergestorm skills into this repo
92
95
  mergestorm thread <slug> Jobs in a review thread
93
- mergestorm issue create --title <t> Open a GitHub issue through Mergestorm, no `gh` (`--body`, `--body-file`, `--label`, `--repo`)
96
+ mergestorm issue create --title <t> Open a GitHub issue through Mergestorm, no `gh` (`--body`, `--body-file`, `--label`, `--repo`, `--wait`)
97
+ mergestorm issue wait <n> Wait for the Vortex issue review; exits 0 once approved, 4 with points to answer
94
98
  mergestorm stack create [name] New local stack layer (optional `--onto` / `--trunk` / `--extend`)
95
99
  mergestorm stack submit Push layers, open PRs (`gh`), register via adopt (optional `--extend`, `--server-side`)
96
100
  mergestorm stack publish Publish the current branch through Mergestorm with no GitHub credentials (`--base <branch> --extend` to stack, `--status` to read it)
@@ -113,7 +117,8 @@ mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --
113
117
  - It prints nothing while the stack is `waiting` or `in_progress`, including while Cyclone or Vortex holds the blocked PR (`busy[]`, `actAfter: "agents_idle"`).
114
118
  - On attention it prints `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` and exits 3. Attention names the lowest layer with a blocker an agent must fix, on any layer of the stack, not only the current candidate.
115
119
  - A confirmed landing prints `MS-WATCH LANDED stack=<id> reason=landed` and exits 0. `closed`, `archived` and `not_found` end the watch with `MS-WATCH ATTENTION stack=<id> reason=<reason>` and exit 3. A missing stack does not prove a landing; verify each PR before reporting it merged.
116
- - A failed read is retried once. If the retry fails too, it prints `MS-WATCH ATTENTION failed stack=<id> error="<message>"` and exits 3. A rate limit is waited out.
120
+ - A permanent read error is retried once; if the retry fails too, it prints `MS-WATCH ATTENTION failed stack=<id> error="<message>"` and exits 3. Timeouts, 5xx and rate limits are retried, but after 15 minutes with no stack read it prints the same line and exits 3.
121
+ - A watch that cannot start (bad flags, a config it cannot read, a crash) prints `MS-WATCH ATTENTION failed` too, so the command never exits without an `MS-WATCH` line unless you stop it (Ctrl+C or an abort, exit 130). Pass each flag as its own word: in zsh an unquoted `$args` holding several flags stays one word and the command refuses it.
117
122
  - `--head <sha>` enrolls the SHA you just pushed. After fixing a blocker, restart the watcher with it.
118
123
  - `--ignore <text>` keeps it silent on the first attention whose blocker contains that text (case-insensitive), until the blocker, its PR or its head changes. Use it for a blocker a human has taken over. Repeat the flag for more than one.
119
124
  - `--until landed` keeps running after an attention, printing each new attention once, and exits only when the watch is done or reads keep failing.
@@ -166,25 +171,39 @@ mg stack publish --status # absent | pushed | opened | registered
166
171
  ```
167
172
 
168
173
  - **Issues too.** `mg issue create` opens the issue a PR needs through the same Surge App, with existing labels only. Running it again with the same title returns the issue it already opened. Put `Fixes #<n>` in the commit message; `stack submit` carries it into the PR body.
174
+ - **Issue reviews first.** On a repository with Vortex issue reviews on, Vortex reviews each new issue before any PR. `mg issue wait <n>` (or `mg issue create --wait`) waits for that review: it exits 0 once the issue is approved, or exits 4 with the numbered points to answer. Answer them in a comment on the issue and wait again; Vortex re-reviews on each reply, up to four rounds, after which a person approves with an issue comment whose first line is `mergestorm-issue: approve`. `stack submit` refuses to open a PR whose tip commit links an issue that is not approved yet, or links no issue at all while issue reviews are on.
169
175
  - **Exact contents.** Every commit travels with its Git tree SHA and the whole content of each changed file, so binaries, deletions, renames, executable bits and symlinks arrive unchanged. Mergestorm moves the branch only when GitHub builds the same tree, and `mg` checks the returned tree against your local branch.
170
176
  - **Never overwrites surprises.** `mg` looks at the branch on GitHub first. If its files match one of your local commits, only the commits after that one are appended. If they match none (someone else pushed, or you rebased), `mg` stops with `head_changed`; `--replace-head <sha>` replaces exactly that head and nothing else. Mergestorm refuses `main`, `master`, the default branch, protected branches and its own `mg-stack-*`, `mg-park-*` and `mq-batch-*` branches.
171
177
  - **Safe to run again.** After a timeout or an error, run the same command again. It finds the branch, pull request and stack it already made and finishes the missing step; it does not create duplicates.
172
178
  - **Read-only home directory.** `mg` keeps the saved login and the local stack state of `stack create` / `stack submit` under `~/.mergestorm`. Where the home directory cannot be written, set `MERGESTORM_HOME` to a writable directory (outside the repository) and every command uses that instead. `MERGESTORM_API_KEY` supplies the key without `mg login`.
179
+ - **Several accounts.** `mg login` keeps up to three accounts signed in; `mg account use <name>` picks the active one, and `--account <name>` or `MERGESTORM_ACCOUNT` runs one command as another.
173
180
  - **Limits.** 20 commits, 500 changed paths, 2 MiB a file and 8 MiB in total per publication. Submodules and merge commits are refused with the path or the reason. `stack submit --server-side` publishes the first two layers of a stack; a third layer needs a park freeze, which this mode does not create, so add it once the first two are registered.
174
181
 
175
182
  If local authoring state is stale or malformed, use `mg stack reset --force`; this clears only pre-submit CLI state and never deletes branches, PRs, or registered stacks.
176
183
 
177
184
  ### `login`
178
185
 
179
- Runs a browser device-authorization flow: prints a short code, opens `https://mergestorm.ai/cli/auth`, and — once you approve — mints and stores an API key in `~/.mergestorm/config.json` (mode `600`), or in `$MERGESTORM_HOME/config.json` when `MERGESTORM_HOME` is set. No key copy/paste needed.
186
+ Runs a browser device-authorization flow: prints a short code, opens `https://mergestorm.ai/cli/auth`, and — once you approve — mints an API key and saves it as an account in `~/.mergestorm/config.json` (mode `600`), or in `$MERGESTORM_HOME/config.json` when `MERGESTORM_HOME` is set. No key copy/paste needed. The account is named by its GitHub login (or its email when there is no GitHub login), as `mg whoami` reports it, and becomes the active account.
187
+
188
+ Up to three accounts can stay logged in at once. `mg login` again adds another account, or replaces the key of an account that is already saved. A fourth login is refused with the three names; remove one first. A login saved by an older `mg` migrates on first read into an account named by its key prefix (`msk_live_abcd`), and takes its real name the next time a command such as `mg whoami` reaches the API. The file also keeps the active account's key at its top level, so an older `mg` or `mergestorm-mcp` on the same machine keeps signing in as the active account.
180
189
 
181
190
  For headless or CI environments, use `mergestorm login --key` to paste an existing `msk_live_…` key (create one on the dashboard [Settings → API](https://mergestorm.ai/settings#api)). On a TTY the paste is hidden (not echoed); the CLI verifies the key with the API before saving.
182
191
 
183
192
  For an agent, which cannot sit in a prompt while a person signs in, use two steps. `mergestorm login --start` prints the URL and the short code and exits. Give both to the account owner, who opens [mergestorm.ai/cli/auth](https://mergestorm.ai/cli/auth) on any device, signs in, and approves the code. Then `mergestorm login --finish` stores the API key. The code lasts 10 minutes, and `--finish` can be run again if it is interrupted before the approval.
184
193
 
194
+ ### `account`
195
+
196
+ ```bash
197
+ mg account list # saved accounts, the active one marked ● (--json for machines)
198
+ mg account use work # make "work" the active account for every later command
199
+ mg account remove work # log "work" out: revoke its API key and drop it
200
+ ```
201
+
202
+ Every command runs as the active account. To run one command as another saved account without switching, pass `--account <name>` anywhere on the command line (`mg --account work stack submit`, `mg whoami --account work`), or set `MERGESTORM_ACCOUNT=<name>`; the flag wins over the variable, and both win over the active account. An unknown name stops the command and lists the saved accounts. `MERGESTORM_API_KEY` still overrides every saved account. The `mergestorm-mcp` server reads the same store, so `MERGESTORM_ACCOUNT` in its environment picks the account it serves.
203
+
185
204
  ### `logout`
186
205
 
187
- Removes `~/.mergestorm/config.json` (or `$MERGESTORM_HOME/config.json` when `MERGESTORM_HOME` is set) so the stored key is no longer used.
206
+ Logs out of the active account (or the one named with `--account`): revokes that API key on the server when it can, then drops the account from the store. The next saved account, if any, becomes active. `mg account remove <name>` does the same for a named account.
188
207
 
189
208
  ### `review [base] [head]`
190
209
 
@@ -233,7 +252,8 @@ Require a logged-in key and API support for `GET /api/v1/me` and `GET /api/v1/re
233
252
 
234
253
  | Variable | Purpose | Default |
235
254
  |----------|---------|---------|
236
- | `MERGESTORM_API_KEY` | API key (overrides the stored config) | — |
255
+ | `MERGESTORM_API_KEY` | API key (overrides every saved account) | — |
256
+ | `MERGESTORM_ACCOUNT` | Saved account to run as; `--account <name>` on a command wins over it | the active account |
237
257
  | `MERGESTORM_API_URL` | API base URL | `https://api.mergestorm.ai` |
238
258
  | `MERGESTORM_HOME` | Directory for the saved login and local stack state, for machines whose home directory is read-only | `~/.mergestorm` |
239
259
  | `NO_COLOR` | Disable ANSI colors | — |
package/dist/api.d.ts CHANGED
@@ -30,14 +30,20 @@ export declare function devicePost(base: string, route: string, json: unknown, o
30
30
  status: number;
31
31
  body: any;
32
32
  }>;
33
- export type SurgeUsage = {
33
+ type SurgeUsage = {
34
34
  includedMinutes: number;
35
35
  usedMinutes: number;
36
36
  walletMinutes: number;
37
37
  windowStart: string;
38
38
  pricePerMinuteUsd: number;
39
39
  };
40
+ export type AccountIdentity = {
41
+ github_login: string | null;
42
+ email: string | null;
43
+ };
44
+ export declare function accountLabel(account: AccountIdentity | null | undefined): string | null;
40
45
  export type MeResponse = {
46
+ account?: AccountIdentity;
41
47
  key: {
42
48
  prefix: string;
43
49
  name: string;
@@ -182,13 +188,18 @@ export type SettingsResponse = import("./automation-catalog.js").BearerSettingsV
182
188
  github_connected: boolean;
183
189
  stacks_ready?: boolean;
184
190
  };
185
- export declare const REPO_SETTINGS_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default"];
191
+ export declare const REPO_SETTINGS_SWITCH_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default", "merge_queue_batch_enabled", "merge_queue_overdrive", "issue_review_enabled"];
192
+ export declare const REPO_SETTINGS_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default", "merge_queue_batch_enabled", "merge_queue_overdrive", "issue_review_enabled", "merge_queue_batch_size", "merge_queue_lands_alone_paths"];
186
193
  export type RepoSettingsKey = (typeof REPO_SETTINGS_KEYS)[number];
194
+ type RepoSettingsValues = Record<(typeof REPO_SETTINGS_SWITCH_KEYS)[number], boolean | null> & {
195
+ merge_queue_batch_size: number | null;
196
+ merge_queue_lands_alone_paths: string[] | null;
197
+ };
187
198
  export type RepoSettings = {
188
199
  owner: string;
189
200
  repo: string;
190
- } & Record<RepoSettingsKey, boolean | null>;
191
- export type RepoSettingsPatch = Partial<Record<RepoSettingsKey, boolean | null>>;
201
+ } & RepoSettingsValues;
202
+ export type RepoSettingsPatch = Partial<RepoSettingsValues>;
192
203
  export declare function listRepoSettings(cfg?: Config, opts?: {
193
204
  timeoutMs?: number;
194
205
  }): Promise<RepoSettings[]>;
@@ -214,6 +225,9 @@ export declare function patchSettings(patch: SettingsPatch, cfg?: Config, opts?:
214
225
  export declare function getMe(cfg?: Config, opts?: {
215
226
  timeoutMs?: number;
216
227
  }): Promise<MeResponse | null>;
228
+ export declare function revokeOwnKey(cfg: Config, opts?: {
229
+ timeoutMs?: number;
230
+ }): Promise<boolean>;
217
231
  export declare function listJobs(limit?: number, cfg?: Config, opts?: {
218
232
  timeoutMs?: number;
219
233
  }): Promise<JobListItem[]>;
@@ -252,16 +266,11 @@ export type StackPolicyPatch = {
252
266
  export declare function stackPolicyBody(policy: StackPolicyPatch | undefined): StackPolicyPatch;
253
267
  /** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
254
268
  export declare function adoptStack(owner: string, repo: string, prNumber: number, cfg?: Config, policy?: StackPolicyPatch): Promise<unknown>;
255
- export declare const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
256
- export declare function surgeRefusal(body: unknown): CommandError | null;
257
- export declare const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
258
269
  /** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
259
270
  export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<{
260
271
  freezeBranch: string;
261
272
  created: boolean;
262
273
  }>;
263
- export declare const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
264
- export declare function surgeNotInstalledMessage(owner: string, repo: string, installUrl?: string): string;
265
274
  export declare class LandedQueueReadError extends CommandError {
266
275
  readonly status: number;
267
276
  readonly retryAfterSeconds?: number | undefined;
@@ -331,7 +340,7 @@ export type StackPublicationRequest = {
331
340
  };
332
341
  autoLand?: boolean;
333
342
  };
334
- export type StackPublicationStack = {
343
+ type StackPublicationStack = {
335
344
  id: string;
336
345
  autoPatchOverride: boolean | null;
337
346
  autoLand: boolean;
@@ -378,7 +387,6 @@ export type StackPublication = {
378
387
  };
379
388
  stack: StackPublicationStack;
380
389
  };
381
- export declare const STACK_PUBLICATION_TIMEOUT_MS = 180000;
382
390
  export declare function getStackPublication(cfg: Config, input: {
383
391
  owner: string;
384
392
  repo: string;
@@ -396,6 +404,55 @@ export declare function openStackIssue(input: {
396
404
  url: string;
397
405
  created: boolean;
398
406
  }>;
407
+ export declare const ISSUE_REVIEW_STATES: readonly ["approved", "needs_answers", "pending", "unreviewed", "capped", "off"];
408
+ export type IssueReviewState = (typeof ISSUE_REVIEW_STATES)[number];
409
+ export type IssueReviewPoint = {
410
+ kind: string;
411
+ text: string;
412
+ };
413
+ export type IssueReviewStatus = {
414
+ owner: string;
415
+ repo: string;
416
+ number: number;
417
+ enabled: boolean;
418
+ state: IssueReviewState;
419
+ approvedBy: "vortex" | "human" | null;
420
+ round: number | null;
421
+ maxRounds: number;
422
+ verdict: "ready" | "ready_with_notes" | "needs_answers" | null;
423
+ summary: string | null;
424
+ points: IssueReviewPoint[];
425
+ commentUrl: string | null;
426
+ next: string;
427
+ };
428
+ export type IssueReviewTarget = {
429
+ owner: string;
430
+ repo: string;
431
+ number: number;
432
+ };
433
+ export declare const ISSUE_REVIEW_POLL_INTERVAL_MS = 10000;
434
+ export declare const ISSUE_REVIEW_DEFAULT_TIMEOUT_MS = 900000;
435
+ export declare function parseIssueReviewStatus(body: unknown, target: IssueReviewTarget): IssueReviewStatus | null;
436
+ export declare function getIssueReview(cfg: Config, target: IssueReviewTarget, opts?: {
437
+ timeoutMs?: number;
438
+ signal?: AbortSignal;
439
+ }): Promise<IssueReviewStatus | null>;
440
+ export declare class IssueReviewTimeoutError extends CommandError {
441
+ readonly lastStatus: IssueReviewStatus;
442
+ constructor(lastStatus: IssueReviewStatus);
443
+ }
444
+ export type PollIssueReviewOptions = {
445
+ timeoutMs?: number;
446
+ intervalMs?: number;
447
+ now?: () => number;
448
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
449
+ signal?: AbortSignal;
450
+ onTick?: (status: IssueReviewStatus | null) => void;
451
+ };
452
+ export declare function pollIssueReview(cfg: Config, target: IssueReviewTarget, opts?: PollIssueReviewOptions): Promise<IssueReviewStatus | null>;
453
+ export declare function issueReviewLines(status: IssueReviewStatus): string[];
454
+ export declare function issueNotApprovedError(issues: IssueReviewStatus[], message?: string | null): CommandError;
455
+ export declare function issueNotApprovedRefusal(status: number, body: unknown, owner: string, repo: string): CommandError | null;
399
456
  /**
400
457
  * Patch any subset of one owned stack's automation policy
401
458
  * (Bearer PATCH /api/v1/stacks/:id). `null` clears an override.
package/dist/api.js CHANGED
@@ -1,4 +1,4 @@
1
- import { apiBase, loadConfig, resolveApiKey } from "./config.js";
1
+ import { apiBase, loadConfig, resolveApiKey, resolveChatId } from "./config.js";
2
2
  import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS, } from "./automation-catalog.js";
3
3
  import { CommandError, REVIEW_EXIT, rateLimitedMessage, plainServerText, } from "./errors.js";
4
4
  /** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
@@ -84,8 +84,10 @@ export async function apiFetch(cfg, route, init = {}) {
84
84
  throw new CommandError("No API key. Run `mergestorm login` (agents: `mergestorm login --start`, then `--finish`) or set MERGESTORM_API_KEY.", 1, "missing_api_key");
85
85
  }
86
86
  const { json, timeoutMs = DEFAULT_API_TIMEOUT_MS, signal: userSignal, ...rest } = init;
87
+ const chatId = resolveChatId(cfg);
87
88
  const headers = {
88
89
  Authorization: `Bearer ${key}`,
90
+ ...(chatId ? { "x-mergestorm-chat-id": chatId } : {}),
89
91
  ...rest.headers,
90
92
  };
91
93
  let body = rest.body;
@@ -155,6 +157,13 @@ export async function devicePost(base, route, json, opts = {}) {
155
157
  }
156
158
  return { status: res.status, body: parsed };
157
159
  }
160
+ export function accountLabel(account) {
161
+ const login = account?.github_login?.trim();
162
+ if (login)
163
+ return login;
164
+ const email = account?.email?.trim();
165
+ return email || null;
166
+ }
158
167
  export function isPositiveInteger(value) {
159
168
  return typeof value === "number" && Number.isInteger(value) && value >= 1;
160
169
  }
@@ -172,7 +181,15 @@ export function applyPrReviewQuery(query, opts) {
172
181
  * The connected flags are read-only there and deliberately absent here.
173
182
  */
174
183
  export { SETTINGS_WRITABLE_KEYS };
175
- export const REPO_SETTINGS_KEYS = ["auto_patch_enabled", "auto_review_enabled", "auto_land_default"];
184
+ export const REPO_SETTINGS_SWITCH_KEYS = [
185
+ "auto_patch_enabled",
186
+ "auto_review_enabled",
187
+ "auto_land_default",
188
+ "merge_queue_batch_enabled",
189
+ "merge_queue_overdrive",
190
+ "issue_review_enabled",
191
+ ];
192
+ export const REPO_SETTINGS_KEYS = [...REPO_SETTINGS_SWITCH_KEYS, "merge_queue_batch_size", "merge_queue_lands_alone_paths"];
176
193
  function repoSettingsFailure(status, body, retryAfterSeconds, action) {
177
194
  if (status === 429) {
178
195
  return new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", {
@@ -284,6 +301,20 @@ export async function getMe(cfg, opts) {
284
301
  return null;
285
302
  }
286
303
  }
304
+ export async function revokeOwnKey(cfg, opts) {
305
+ try {
306
+ const { status } = await apiFetch(cfg, "/api/v1/me/key", {
307
+ method: "DELETE",
308
+ timeoutMs: opts?.timeoutMs,
309
+ });
310
+ return status === 200;
311
+ }
312
+ catch (err) {
313
+ if (err instanceof CommandError)
314
+ return err.code === "auth_invalid";
315
+ return false;
316
+ }
317
+ }
287
318
  export async function listJobs(limit = 10, cfg, opts) {
288
319
  const resolved = cfg ?? (await loadConfig());
289
320
  const capped = Math.min(Math.max(limit, 1), 50);
@@ -423,6 +454,9 @@ export async function adoptStack(owner, repo, prNumber, cfg, policy) {
423
454
  if (status === 429) {
424
455
  throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
425
456
  }
457
+ const issueRefusal = issueNotApprovedRefusal(status, body, owner, repo);
458
+ if (issueRefusal)
459
+ throw issueRefusal;
426
460
  if (status !== 200) {
427
461
  const error = body && typeof body === "object" && "error" in body && typeof body.error === "string"
428
462
  ? body.error
@@ -460,13 +494,14 @@ const INFRASTRUCTURE_REFUSAL_CODES = [
460
494
  "cyclone_not_connected",
461
495
  "cyclone_not_installed",
462
496
  "cyclone_permission_missing",
497
+ "surge_other_account",
463
498
  ];
464
499
  function refusalReason(body) {
465
500
  const reason = (body && typeof body === "object" ? body.reason : undefined);
466
501
  return typeof reason === "string" && plainServerText(reason).trim() ? plainServerText(reason).trim() : undefined;
467
502
  }
468
- export const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
469
- export function surgeRefusal(body) {
503
+ const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
504
+ function surgeRefusal(body) {
470
505
  if (!body || typeof body !== "object")
471
506
  return null;
472
507
  const refusal = body;
@@ -480,7 +515,7 @@ export function surgeRefusal(body) {
480
515
  const installUrl = githubAppInstallUrl(refusal.installUrl) ?? "";
481
516
  return new CommandError(installUrl && !message.includes(installUrl) ? `${message} (${installUrl})` : message, 1, error, { reason });
482
517
  }
483
- export const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
518
+ const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
484
519
  function serverMessage(message) {
485
520
  if (typeof message !== "string")
486
521
  return null;
@@ -509,8 +544,8 @@ export async function ensureUpperPark(stackId, cfg) {
509
544
  created: body.created === true,
510
545
  };
511
546
  }
512
- export const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
513
- export function surgeNotInstalledMessage(owner, repo, installUrl = SURGE_INSTALL_URL) {
547
+ const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
548
+ function surgeNotInstalledMessage(owner, repo, installUrl = SURGE_INSTALL_URL) {
514
549
  return (`Opening PRs needs either the GitHub CLI (gh auth login) or Mergestorm Surge installed on ` +
515
550
  `${owner}/${repo} (${installUrl}).`);
516
551
  }
@@ -524,6 +559,9 @@ function stackPullFailure(status, body, owner, repo, failLabel) {
524
559
  const surge = surgeRefusal(body);
525
560
  if (surge)
526
561
  return surge;
562
+ const issueRefusal = issueNotApprovedRefusal(status, body, owner, repo);
563
+ if (issueRefusal)
564
+ return issueRefusal;
527
565
  const text = `${String(rejection.error ?? "")} ${String(rejection.message ?? "")}`;
528
566
  if (rejection.error === "cyclone_not_installed" ||
529
567
  /not installed|installation[^\n]*not found/i.test(text)) {
@@ -590,7 +628,7 @@ export async function openStackPull(cfg, input) {
590
628
  created: row.created === true,
591
629
  };
592
630
  }
593
- export const STACK_PUBLICATION_TIMEOUT_MS = 180_000;
631
+ const STACK_PUBLICATION_TIMEOUT_MS = 180_000;
594
632
  const PUBLICATION_ERROR_CODES = [
595
633
  "head_changed",
596
634
  "base_changed",
@@ -652,6 +690,166 @@ export async function openStackIssue(input, cfg) {
652
690
  }
653
691
  return { number: row.number, url: typeof row.url === "string" ? row.url : "", created: row.created === true };
654
692
  }
693
+ export const ISSUE_REVIEW_STATES = ["approved", "needs_answers", "pending", "unreviewed", "capped", "off"];
694
+ export const ISSUE_REVIEW_POLL_INTERVAL_MS = 10_000;
695
+ export const ISSUE_REVIEW_DEFAULT_TIMEOUT_MS = 900_000;
696
+ const ISSUE_REVIEW_MAX_POINTS = 8;
697
+ const ISSUE_REVIEW_DEFAULT_MAX_ROUNDS = 4;
698
+ function issueReviewText(value) {
699
+ if (typeof value !== "string")
700
+ return null;
701
+ const plain = plainServerText(value).trim();
702
+ return plain ? plain : null;
703
+ }
704
+ export function parseIssueReviewStatus(body, target) {
705
+ if (!body || typeof body !== "object")
706
+ return null;
707
+ const row = body;
708
+ const state = ISSUE_REVIEW_STATES.find((known) => known === row.state);
709
+ if (!state)
710
+ return null;
711
+ const points = Array.isArray(row.points)
712
+ ? row.points
713
+ .flatMap((point) => {
714
+ const text = issueReviewText(point?.text);
715
+ if (!text)
716
+ return [];
717
+ return [{ kind: issueReviewText(point.kind) ?? "note", text }];
718
+ })
719
+ .slice(0, ISSUE_REVIEW_MAX_POINTS)
720
+ : [];
721
+ const verdict = row.verdict === "ready" || row.verdict === "ready_with_notes" || row.verdict === "needs_answers" ? row.verdict : null;
722
+ return {
723
+ owner: typeof row.owner === "string" && row.owner ? row.owner : target.owner,
724
+ repo: typeof row.repo === "string" && row.repo ? row.repo : target.repo,
725
+ number: isPositiveInteger(row.number) ? row.number : target.number,
726
+ enabled: row.enabled === true,
727
+ state,
728
+ approvedBy: row.approvedBy === "vortex" || row.approvedBy === "human" ? row.approvedBy : null,
729
+ round: isPositiveInteger(row.round) ? row.round : null,
730
+ maxRounds: isPositiveInteger(row.maxRounds) ? row.maxRounds : ISSUE_REVIEW_DEFAULT_MAX_ROUNDS,
731
+ verdict,
732
+ summary: issueReviewText(row.summary),
733
+ points,
734
+ commentUrl: issueReviewText(row.commentUrl),
735
+ next: issueReviewText(row.next) ?? "",
736
+ };
737
+ }
738
+ export async function getIssueReview(cfg, target, opts = {}) {
739
+ const query = new URLSearchParams({ owner: target.owner, repo: target.repo }).toString();
740
+ const { status, body, retryAfterSeconds } = await apiFetch(cfg, `/api/v1/stacks/issues/${encodeURIComponent(String(target.number))}/review?${query}`, opts);
741
+ if (status === 404)
742
+ return null;
743
+ if (status === 429) {
744
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", retryAfterSeconds !== undefined ? { retryAfterSeconds } : undefined);
745
+ }
746
+ const parsed = status === 200 ? parseIssueReviewStatus(body, target) : null;
747
+ if (!parsed) {
748
+ const message = serverMessage(body?.message);
749
+ throw new CommandError(message ?? `Failed to read the issue review of ${target.owner}/${target.repo}#${target.number} (HTTP ${status})`, 1, status >= 500 ? "issue_review_unavailable" : undefined);
750
+ }
751
+ return parsed;
752
+ }
753
+ export class IssueReviewTimeoutError extends CommandError {
754
+ lastStatus;
755
+ constructor(lastStatus) {
756
+ super(`Timed out waiting for the Vortex review of ${lastStatus.owner}/${lastStatus.repo}#${lastStatus.number}. Run \`mergestorm issue wait ${lastStatus.number}\` again.`, REVIEW_EXIT.timeout, "review_timeout");
757
+ this.name = "IssueReviewTimeoutError";
758
+ this.lastStatus = lastStatus;
759
+ }
760
+ }
761
+ function abortableSleep(ms, signal) {
762
+ return new Promise((resolve, reject) => {
763
+ const abortError = () => Object.assign(new Error("Aborted"), { name: "AbortError" });
764
+ if (signal?.aborted) {
765
+ reject(abortError());
766
+ return;
767
+ }
768
+ const onAbort = () => {
769
+ clearTimeout(timer);
770
+ reject(abortError());
771
+ };
772
+ const timer = setTimeout(() => {
773
+ signal?.removeEventListener("abort", onAbort);
774
+ resolve();
775
+ }, ms);
776
+ signal?.addEventListener("abort", onAbort, { once: true });
777
+ });
778
+ }
779
+ function isTransientIssueReviewError(err) {
780
+ if (!(err instanceof Error) || err.name === "AbortError")
781
+ return false;
782
+ if (err instanceof CommandError) {
783
+ return err.code === "api_timeout" || err.code === "issue_review_unavailable" || err.code === "rate_limited";
784
+ }
785
+ return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network/i.test(err.message);
786
+ }
787
+ export async function pollIssueReview(cfg, target, opts = {}) {
788
+ const timeoutMs = opts.timeoutMs ?? ISSUE_REVIEW_DEFAULT_TIMEOUT_MS;
789
+ const intervalMs = opts.intervalMs ?? ISSUE_REVIEW_POLL_INTERVAL_MS;
790
+ const now = opts.now ?? Date.now;
791
+ const wait = opts.sleep ?? abortableSleep;
792
+ const deadline = now() + timeoutMs;
793
+ let last = null;
794
+ let failure = null;
795
+ for (;;) {
796
+ try {
797
+ const status = await getIssueReview(cfg, target, { signal: opts.signal });
798
+ if (!status || status.state !== "pending")
799
+ return status;
800
+ last = status;
801
+ opts.onTick?.(status);
802
+ }
803
+ catch (err) {
804
+ if (!isTransientIssueReviewError(err) || now() >= deadline)
805
+ throw err;
806
+ failure = err;
807
+ opts.onTick?.(null);
808
+ }
809
+ const remaining = deadline - now();
810
+ if (remaining <= 0) {
811
+ if (last)
812
+ throw new IssueReviewTimeoutError(last);
813
+ throw failure;
814
+ }
815
+ await wait(Math.min(intervalMs, remaining), opts.signal);
816
+ }
817
+ }
818
+ export function issueReviewLines(status) {
819
+ const round = status.round ? ` (round ${status.round} of ${status.maxRounds})` : "";
820
+ const lines = [`${status.owner}/${status.repo}#${status.number}: ${status.state}${round}`];
821
+ if (status.summary)
822
+ lines.push(` ${status.summary}`);
823
+ status.points.forEach((point, i) => lines.push(` ${i + 1}. [${point.kind}] ${point.text}`));
824
+ if (status.commentUrl)
825
+ lines.push(` Review: ${status.commentUrl}`);
826
+ if (status.next)
827
+ lines.push(` Next: ${status.next}`);
828
+ return lines;
829
+ }
830
+ export function issueNotApprovedError(issues, message) {
831
+ const lines = [message?.trim() || "Vortex has not approved the linked issue yet; refusing to open the pull request."];
832
+ for (const issue of issues) {
833
+ lines.push(...issueReviewLines(issue));
834
+ lines.push(` Reply in a comment on #${issue.number}, then run \`mergestorm issue wait ${issue.number}\`.`);
835
+ }
836
+ return new CommandError(lines.join("\n"), REVIEW_EXIT.failed, "issue_not_approved");
837
+ }
838
+ export function issueNotApprovedRefusal(status, body, owner, repo) {
839
+ if (status !== 409 || !body || typeof body !== "object")
840
+ return null;
841
+ const refusal = body;
842
+ if (refusal.error !== "issue_not_approved")
843
+ return null;
844
+ const issues = Array.isArray(refusal.issues)
845
+ ? refusal.issues.flatMap((issue) => {
846
+ const n = issue?.number;
847
+ const parsed = parseIssueReviewStatus(issue, { owner, repo, number: isPositiveInteger(n) ? n : 0 });
848
+ return parsed ? [parsed] : [];
849
+ })
850
+ : [];
851
+ return issueNotApprovedError(issues, serverMessage(refusal.message));
852
+ }
655
853
  async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg) {
656
854
  const resolved = cfg ?? (await loadConfig());
657
855
  const { status, body } = await apiFetch(resolved, `/api/v1/stacks/${encodeURIComponent(stackId)}${pathSuffix}`, method === "PATCH"
@@ -42,6 +42,10 @@ export declare const BEARER_SETTINGS: readonly [{
42
42
  readonly key: "vortex_seam_specialist_enabled";
43
43
  readonly flag: "--vortex-seam";
44
44
  readonly label: "Vortex seam specialist";
45
+ }, {
46
+ readonly key: "issue_review_enabled";
47
+ readonly flag: "--issue-review";
48
+ readonly label: "Vortex issue reviews";
45
49
  }, {
46
50
  readonly key: "auto_land_default";
47
51
  readonly flag: "--auto-land";
@@ -63,7 +67,17 @@ export declare const BEARER_SETTINGS: readonly [{
63
67
  readonly label: "Merge queue batch size (pull requests)";
64
68
  readonly kind: "count";
65
69
  readonly min: 2;
66
- readonly max: 8;
70
+ readonly max: 10;
71
+ }, {
72
+ readonly key: "merge_queue_lands_alone_paths";
73
+ readonly flag: "--merge-queue-lands-alone";
74
+ readonly label: "Merge queue lands-alone paths";
75
+ readonly kind: "paths";
76
+ readonly defaults: readonly ["**/migrations/**", "db/migrate/**", ".github/workflows/**"];
77
+ }, {
78
+ readonly key: "merge_queue_overdrive";
79
+ readonly flag: "--merge-queue-overdrive";
80
+ readonly label: "Merge queue overdrive (up to 10 per batch)";
67
81
  }, {
68
82
  readonly key: "ignored_bot_logins";
69
83
  readonly flag: "--ignore-bot";
@@ -95,11 +109,11 @@ export declare const BEARER_SETTINGS_LABELS: Record<BearerSettingsKey, string>;
95
109
  /** Omitted kind means boolean for the original toggle rows. */
96
110
  export type BearerSettingsValues = {
97
111
  [Row in (typeof BEARER_SETTINGS)[number] as Row["key"]]: Row extends {
98
- kind: "logins";
112
+ kind: "logins" | "paths";
99
113
  } ? string[] : Row extends {
100
114
  kind: "seconds" | "count";
101
115
  } ? number : Row extends {
102
116
  values: readonly (infer Value)[];
103
117
  } ? Value : boolean;
104
118
  };
105
- export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
119
+ export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("auto_review_enabled" | "auto_patch_enabled" | "auto_resolve_conflicts_enabled" | "auto_fix_ci_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "issue_review_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
@@ -10,10 +10,13 @@ export const BEARER_SETTINGS = [
10
10
  { key: "vortex_auto_overflow_enabled", flag: "--vortex-auto-overflow", label: "Vortex auto overflow" },
11
11
  { key: "vortex_skip_all_clear_comments", flag: "--vortex-skip-all-clear", label: "Vortex skip All-clear comments" },
12
12
  { key: "vortex_seam_specialist_enabled", flag: "--vortex-seam", label: "Vortex seam specialist" },
13
+ { key: "issue_review_enabled", flag: "--issue-review", label: "Vortex issue reviews" },
13
14
  { key: "auto_land_default", flag: "--auto-land", label: "Auto land (new stacks)" },
14
15
  { key: "auto_land_settle_seconds", flag: "--auto-land-settle", label: "Auto land settle (seconds)", kind: "seconds", min: 15, max: 300 },
15
16
  { key: "merge_queue_batch_enabled", flag: "--merge-queue-batch", label: "Merge queue batching" },
16
- { key: "merge_queue_batch_size", flag: "--merge-queue-batch-size", label: "Merge queue batch size (pull requests)", kind: "count", min: 2, max: 8 },
17
+ { key: "merge_queue_batch_size", flag: "--merge-queue-batch-size", label: "Merge queue batch size (pull requests)", kind: "count", min: 2, max: 10 },
18
+ { key: "merge_queue_lands_alone_paths", flag: "--merge-queue-lands-alone", label: "Merge queue lands-alone paths", kind: "paths", defaults: ["**/migrations/**", "db/migrate/**", ".github/workflows/**"] },
19
+ { key: "merge_queue_overdrive", flag: "--merge-queue-overdrive", label: "Merge queue overdrive (up to 10 per batch)" },
17
20
  { key: "ignored_bot_logins", flag: "--ignore-bot", label: "Ignored bot logins", kind: "logins" },
18
21
  { key: "vortex_bot_skip_check", flag: "--vortex-skip-check", label: "Vortex bot skip check", kind: "enum", values: ["none", "neutral"] },
19
22
  { key: "vortex_findings_check", flag: "--vortex-findings", label: "Vortex findings check", kind: "enum", values: ["failure", "neutral", "success"] },