mergestorm 0.3.64 → 0.3.67

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 (76) hide show
  1. package/README.md +28 -9
  2. package/dist/api.d.ts +77 -11
  3. package/dist/api.js +228 -6
  4. package/dist/automation-catalog.d.ts +9 -1
  5. package/dist/automation-catalog.js +2 -0
  6. package/dist/cli.js +21 -19
  7. package/dist/client/index.d.ts +3 -2
  8. package/dist/client/index.js +3 -2
  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 +157 -31
  18. package/dist/commands/login.js +46 -14
  19. package/dist/commands/logout.js +6 -7
  20. package/dist/commands/memory.d.ts +2 -3
  21. package/dist/commands/memory.js +8 -6
  22. package/dist/commands/registry.d.ts +1 -0
  23. package/dist/commands/registry.js +35 -11
  24. package/dist/commands/review-client.d.ts +0 -3
  25. package/dist/commands/review-client.js +3 -3
  26. package/dist/commands/review.d.ts +1 -1
  27. package/dist/commands/review.js +1 -1
  28. package/dist/commands/settings.d.ts +3 -5
  29. package/dist/commands/settings.js +5 -3
  30. package/dist/commands/stack.d.ts +19 -5
  31. package/dist/commands/stack.js +88 -4
  32. package/dist/commands/whoami.js +14 -10
  33. package/dist/config.d.ts +55 -2
  34. package/dist/config.js +264 -13
  35. package/dist/errors.d.ts +1 -1
  36. package/dist/gh.d.ts +0 -4
  37. package/dist/gh.js +4 -4
  38. package/dist/git.d.ts +1 -1
  39. package/dist/git.js +2 -2
  40. package/dist/memory-api.d.ts +1 -0
  41. package/dist/memory-api.js +2 -2
  42. package/dist/node-version.d.ts +0 -2
  43. package/dist/node-version.js +1 -1
  44. package/dist/pr-body.d.ts +4 -0
  45. package/dist/pr-body.js +15 -0
  46. package/dist/repo-identity.d.ts +2 -1
  47. package/dist/repo-identity.js +8 -4
  48. package/dist/server-publish.d.ts +1 -24
  49. package/dist/server-publish.js +9 -9
  50. package/dist/stack-blockers.d.ts +12 -38
  51. package/dist/stack-blockers.js +57 -33
  52. package/dist/stack-dto.d.ts +3 -0
  53. package/dist/stack-dto.js +15 -2
  54. package/dist/stack-meta.d.ts +2 -2
  55. package/dist/stack-meta.js +1 -1
  56. package/dist/stack-watch-loop.d.ts +2 -13
  57. package/dist/stack-watch-loop.js +12 -12
  58. package/dist/submit-pr-base.d.ts +0 -1
  59. package/dist/submit-pr-base.js +1 -1
  60. package/dist/ui/ansi.d.ts +0 -10
  61. package/dist/ui/ansi.js +2 -2
  62. package/dist/ui/banner.d.ts +3 -1
  63. package/dist/ui/banner.js +13 -3
  64. package/dist/ui/box.d.ts +0 -2
  65. package/dist/ui/box.js +1 -1
  66. package/dist/ui/prompt.d.ts +0 -11
  67. package/dist/ui/prompt.js +7 -7
  68. package/dist/ui/tabs.d.ts +2 -2
  69. package/dist/ui/tabs.js +1 -1
  70. package/dist/ui/width.d.ts +0 -9
  71. package/dist/ui/width.js +3 -3
  72. package/dist/web-api.d.ts +17 -0
  73. package/dist/web-api.js +30 -0
  74. package/package.json +1 -1
  75. package/skill/SKILL.md +4 -0
  76. 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.64 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.67 │
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.64: Memory commands and Overdrive merge batches │
53
+ │ ▀ New in v0.3.67: Issue review: reply once, then one re-check │
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)
@@ -167,25 +171,39 @@ mg stack publish --status # absent | pushed | opened | registered
167
171
  ```
168
172
 
169
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. Reply once in a comment on the issue, accepting each blocking point's suggested fix or saying why not, and wait again; Vortex re-checks only the blocking points, once, and approves the issue. Notes never block; the PR review checks them against the code. A person can approve early 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.
170
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.
171
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.
172
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.
173
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.
174
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.
175
181
 
176
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.
177
183
 
178
184
  ### `login`
179
185
 
180
- 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.
181
189
 
182
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.
183
191
 
184
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.
185
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
+
186
204
  ### `logout`
187
205
 
188
- 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.
189
207
 
190
208
  ### `review [base] [head]`
191
209
 
@@ -234,7 +252,8 @@ Require a logged-in key and API support for `GET /api/v1/me` and `GET /api/v1/re
234
252
 
235
253
  | Variable | Purpose | Default |
236
254
  |----------|---------|---------|
237
- | `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 |
238
257
  | `MERGESTORM_API_URL` | API base URL | `https://api.mergestorm.ai` |
239
258
  | `MERGESTORM_HOME` | Directory for the saved login and local stack state, for machines whose home directory is read-only | `~/.mergestorm` |
240
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,10 +188,10 @@ 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_SWITCH_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default", "merge_queue_batch_enabled", "merge_queue_overdrive"];
186
- export declare const REPO_SETTINGS_KEYS: readonly ["auto_patch_enabled", "auto_review_enabled", "auto_land_default", "merge_queue_batch_enabled", "merge_queue_overdrive", "merge_queue_batch_size", "merge_queue_lands_alone_paths"];
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", "issue_auto_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", "issue_auto_review_enabled", "merge_queue_batch_size", "merge_queue_lands_alone_paths"];
187
193
  export type RepoSettingsKey = (typeof REPO_SETTINGS_KEYS)[number];
188
- export type RepoSettingsValues = Record<(typeof REPO_SETTINGS_SWITCH_KEYS)[number], boolean | null> & {
194
+ type RepoSettingsValues = Record<(typeof REPO_SETTINGS_SWITCH_KEYS)[number], boolean | null> & {
189
195
  merge_queue_batch_size: number | null;
190
196
  merge_queue_lands_alone_paths: string[] | null;
191
197
  };
@@ -219,6 +225,9 @@ export declare function patchSettings(patch: SettingsPatch, cfg?: Config, opts?:
219
225
  export declare function getMe(cfg?: Config, opts?: {
220
226
  timeoutMs?: number;
221
227
  }): Promise<MeResponse | null>;
228
+ export declare function revokeOwnKey(cfg: Config, opts?: {
229
+ timeoutMs?: number;
230
+ }): Promise<boolean>;
222
231
  export declare function listJobs(limit?: number, cfg?: Config, opts?: {
223
232
  timeoutMs?: number;
224
233
  }): Promise<JobListItem[]>;
@@ -257,16 +266,11 @@ export type StackPolicyPatch = {
257
266
  export declare function stackPolicyBody(policy: StackPolicyPatch | undefined): StackPolicyPatch;
258
267
  /** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
259
268
  export declare function adoptStack(owner: string, repo: string, prNumber: number, cfg?: Config, policy?: StackPolicyPatch): Promise<unknown>;
260
- export declare const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
261
- export declare function surgeRefusal(body: unknown): CommandError | null;
262
- export declare const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
263
269
  /** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
264
270
  export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<{
265
271
  freezeBranch: string;
266
272
  created: boolean;
267
273
  }>;
268
- export declare const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
269
- export declare function surgeNotInstalledMessage(owner: string, repo: string, installUrl?: string): string;
270
274
  export declare class LandedQueueReadError extends CommandError {
271
275
  readonly status: number;
272
276
  readonly retryAfterSeconds?: number | undefined;
@@ -336,7 +340,7 @@ export type StackPublicationRequest = {
336
340
  };
337
341
  autoLand?: boolean;
338
342
  };
339
- export type StackPublicationStack = {
343
+ type StackPublicationStack = {
340
344
  id: string;
341
345
  autoPatchOverride: boolean | null;
342
346
  autoLand: boolean;
@@ -383,7 +387,6 @@ export type StackPublication = {
383
387
  };
384
388
  stack: StackPublicationStack;
385
389
  };
386
- export declare const STACK_PUBLICATION_TIMEOUT_MS = 180000;
387
390
  export declare function getStackPublication(cfg: Config, input: {
388
391
  owner: string;
389
392
  repo: string;
@@ -401,6 +404,69 @@ export declare function openStackIssue(input: {
401
404
  url: string;
402
405
  created: boolean;
403
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
+ blocking?: boolean;
413
+ };
414
+ export declare const ISSUE_REVIEW_MODES: readonly ["off", "mention", "auto"];
415
+ export type IssueReviewMode = (typeof ISSUE_REVIEW_MODES)[number];
416
+ export declare const ISSUE_REVIEW_TAG = "@mergestorm-vortex";
417
+ export type IssueReviewStatus = {
418
+ owner: string;
419
+ repo: string;
420
+ number: number;
421
+ enabled: boolean;
422
+ mode: IssueReviewMode;
423
+ state: IssueReviewState;
424
+ approvedBy: "vortex" | "human" | null;
425
+ round: number | null;
426
+ maxRounds: number;
427
+ verdict: "ready" | "ready_with_notes" | "needs_answers" | null;
428
+ summary: string | null;
429
+ points: IssueReviewPoint[];
430
+ commentUrl: string | null;
431
+ next: string;
432
+ };
433
+ export type IssueReviewTarget = {
434
+ owner: string;
435
+ repo: string;
436
+ number: number;
437
+ };
438
+ export declare const ISSUE_REVIEW_POLL_INTERVAL_MS = 10000;
439
+ export declare const ISSUE_REVIEW_DEFAULT_TIMEOUT_MS = 900000;
440
+ export declare function parseIssueReviewStatus(body: unknown, target: IssueReviewTarget): IssueReviewStatus | null;
441
+ export declare function getIssueReview(cfg: Config, target: IssueReviewTarget, opts?: {
442
+ timeoutMs?: number;
443
+ signal?: AbortSignal;
444
+ }): Promise<IssueReviewStatus | null>;
445
+ export declare function getIssueReviewMode(cfg: Config, target: {
446
+ owner: string;
447
+ repo: string;
448
+ }, opts?: {
449
+ timeoutMs?: number;
450
+ }): Promise<IssueReviewMode | null>;
451
+ export declare function issueReviewSettled(status: IssueReviewStatus): boolean;
452
+ export declare function issueReviewBlocksPr(status: IssueReviewStatus): boolean;
453
+ export declare class IssueReviewTimeoutError extends CommandError {
454
+ readonly lastStatus: IssueReviewStatus;
455
+ constructor(lastStatus: IssueReviewStatus);
456
+ }
457
+ export type PollIssueReviewOptions = {
458
+ timeoutMs?: number;
459
+ intervalMs?: number;
460
+ now?: () => number;
461
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
462
+ signal?: AbortSignal;
463
+ onTick?: (status: IssueReviewStatus | null) => void;
464
+ };
465
+ export declare function pollIssueReview(cfg: Config, target: IssueReviewTarget, opts?: PollIssueReviewOptions): Promise<IssueReviewStatus | null>;
466
+ export declare function issueReviewLines(status: IssueReviewStatus): string[];
467
+ export declare function issueNotApprovedError(issues: IssueReviewStatus[], message?: string | null): CommandError;
468
+ export declare const NO_LINKED_ISSUE = "no_linked_issue";
469
+ export declare function issueNotApprovedRefusal(status: number, body: unknown, owner: string, repo: string): CommandError | null;
404
470
  /**
405
471
  * Patch any subset of one owned stack's automation policy
406
472
  * (Bearer PATCH /api/v1/stacks/:id). `null` clears an override.
package/dist/api.js CHANGED
@@ -157,6 +157,13 @@ export async function devicePost(base, route, json, opts = {}) {
157
157
  }
158
158
  return { status: res.status, body: parsed };
159
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
+ }
160
167
  export function isPositiveInteger(value) {
161
168
  return typeof value === "number" && Number.isInteger(value) && value >= 1;
162
169
  }
@@ -180,6 +187,8 @@ export const REPO_SETTINGS_SWITCH_KEYS = [
180
187
  "auto_land_default",
181
188
  "merge_queue_batch_enabled",
182
189
  "merge_queue_overdrive",
190
+ "issue_review_enabled",
191
+ "issue_auto_review_enabled",
183
192
  ];
184
193
  export const REPO_SETTINGS_KEYS = [...REPO_SETTINGS_SWITCH_KEYS, "merge_queue_batch_size", "merge_queue_lands_alone_paths"];
185
194
  function repoSettingsFailure(status, body, retryAfterSeconds, action) {
@@ -293,6 +302,20 @@ export async function getMe(cfg, opts) {
293
302
  return null;
294
303
  }
295
304
  }
305
+ export async function revokeOwnKey(cfg, opts) {
306
+ try {
307
+ const { status } = await apiFetch(cfg, "/api/v1/me/key", {
308
+ method: "DELETE",
309
+ timeoutMs: opts?.timeoutMs,
310
+ });
311
+ return status === 200;
312
+ }
313
+ catch (err) {
314
+ if (err instanceof CommandError)
315
+ return err.code === "auth_invalid";
316
+ return false;
317
+ }
318
+ }
296
319
  export async function listJobs(limit = 10, cfg, opts) {
297
320
  const resolved = cfg ?? (await loadConfig());
298
321
  const capped = Math.min(Math.max(limit, 1), 50);
@@ -432,6 +455,9 @@ export async function adoptStack(owner, repo, prNumber, cfg, policy) {
432
455
  if (status === 429) {
433
456
  throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds });
434
457
  }
458
+ const issueRefusal = issueNotApprovedRefusal(status, body, owner, repo);
459
+ if (issueRefusal)
460
+ throw issueRefusal;
435
461
  if (status !== 200) {
436
462
  const error = body && typeof body === "object" && "error" in body && typeof body.error === "string"
437
463
  ? body.error
@@ -469,13 +495,14 @@ const INFRASTRUCTURE_REFUSAL_CODES = [
469
495
  "cyclone_not_connected",
470
496
  "cyclone_not_installed",
471
497
  "cyclone_permission_missing",
498
+ "surge_other_account",
472
499
  ];
473
500
  function refusalReason(body) {
474
501
  const reason = (body && typeof body === "object" ? body.reason : undefined);
475
502
  return typeof reason === "string" && plainServerText(reason).trim() ? plainServerText(reason).trim() : undefined;
476
503
  }
477
- export const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
478
- export function surgeRefusal(body) {
504
+ const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
505
+ function surgeRefusal(body) {
479
506
  if (!body || typeof body !== "object")
480
507
  return null;
481
508
  const refusal = body;
@@ -489,7 +516,7 @@ export function surgeRefusal(body) {
489
516
  const installUrl = githubAppInstallUrl(refusal.installUrl) ?? "";
490
517
  return new CommandError(installUrl && !message.includes(installUrl) ? `${message} (${installUrl})` : message, 1, error, { reason });
491
518
  }
492
- export const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
519
+ const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
493
520
  function serverMessage(message) {
494
521
  if (typeof message !== "string")
495
522
  return null;
@@ -518,8 +545,8 @@ export async function ensureUpperPark(stackId, cfg) {
518
545
  created: body.created === true,
519
546
  };
520
547
  }
521
- export const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
522
- export function surgeNotInstalledMessage(owner, repo, installUrl = SURGE_INSTALL_URL) {
548
+ const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
549
+ function surgeNotInstalledMessage(owner, repo, installUrl = SURGE_INSTALL_URL) {
523
550
  return (`Opening PRs needs either the GitHub CLI (gh auth login) or Mergestorm Surge installed on ` +
524
551
  `${owner}/${repo} (${installUrl}).`);
525
552
  }
@@ -533,6 +560,9 @@ function stackPullFailure(status, body, owner, repo, failLabel) {
533
560
  const surge = surgeRefusal(body);
534
561
  if (surge)
535
562
  return surge;
563
+ const issueRefusal = issueNotApprovedRefusal(status, body, owner, repo);
564
+ if (issueRefusal)
565
+ return issueRefusal;
536
566
  const text = `${String(rejection.error ?? "")} ${String(rejection.message ?? "")}`;
537
567
  if (rejection.error === "cyclone_not_installed" ||
538
568
  /not installed|installation[^\n]*not found/i.test(text)) {
@@ -599,7 +629,7 @@ export async function openStackPull(cfg, input) {
599
629
  created: row.created === true,
600
630
  };
601
631
  }
602
- export const STACK_PUBLICATION_TIMEOUT_MS = 180_000;
632
+ const STACK_PUBLICATION_TIMEOUT_MS = 180_000;
603
633
  const PUBLICATION_ERROR_CODES = [
604
634
  "head_changed",
605
635
  "base_changed",
@@ -661,6 +691,198 @@ export async function openStackIssue(input, cfg) {
661
691
  }
662
692
  return { number: row.number, url: typeof row.url === "string" ? row.url : "", created: row.created === true };
663
693
  }
694
+ export const ISSUE_REVIEW_STATES = ["approved", "needs_answers", "pending", "unreviewed", "capped", "off"];
695
+ export const ISSUE_REVIEW_MODES = ["off", "mention", "auto"];
696
+ export const ISSUE_REVIEW_TAG = "@mergestorm-vortex";
697
+ export const ISSUE_REVIEW_POLL_INTERVAL_MS = 10_000;
698
+ export const ISSUE_REVIEW_DEFAULT_TIMEOUT_MS = 900_000;
699
+ const ISSUE_REVIEW_MAX_POINTS = 8;
700
+ const ISSUE_REVIEW_DEFAULT_MAX_ROUNDS = 2;
701
+ function issueReviewText(value) {
702
+ if (typeof value !== "string")
703
+ return null;
704
+ const plain = plainServerText(value).trim();
705
+ return plain ? plain : null;
706
+ }
707
+ export function parseIssueReviewStatus(body, target) {
708
+ if (!body || typeof body !== "object")
709
+ return null;
710
+ const row = body;
711
+ const state = ISSUE_REVIEW_STATES.find((known) => known === row.state);
712
+ if (!state)
713
+ return null;
714
+ const points = Array.isArray(row.points)
715
+ ? row.points
716
+ .flatMap((point) => {
717
+ const text = issueReviewText(point?.text);
718
+ if (!text)
719
+ return [];
720
+ return [{ kind: issueReviewText(point.kind) ?? "note", text, blocking: point.blocking !== false }];
721
+ })
722
+ .slice(0, ISSUE_REVIEW_MAX_POINTS)
723
+ : [];
724
+ const verdict = row.verdict === "ready" || row.verdict === "ready_with_notes" || row.verdict === "needs_answers" ? row.verdict : null;
725
+ return {
726
+ owner: typeof row.owner === "string" && row.owner ? row.owner : target.owner,
727
+ repo: typeof row.repo === "string" && row.repo ? row.repo : target.repo,
728
+ number: isPositiveInteger(row.number) ? row.number : target.number,
729
+ enabled: row.enabled === true,
730
+ mode: ISSUE_REVIEW_MODES.find((known) => known === row.mode) ?? (row.enabled === true ? "auto" : "off"),
731
+ state,
732
+ approvedBy: row.approvedBy === "vortex" || row.approvedBy === "human" ? row.approvedBy : null,
733
+ round: isPositiveInteger(row.round) ? row.round : null,
734
+ maxRounds: isPositiveInteger(row.maxRounds) ? row.maxRounds : ISSUE_REVIEW_DEFAULT_MAX_ROUNDS,
735
+ verdict,
736
+ summary: issueReviewText(row.summary),
737
+ points,
738
+ commentUrl: issueReviewText(row.commentUrl),
739
+ next: issueReviewText(row.next) ?? "",
740
+ };
741
+ }
742
+ export async function getIssueReview(cfg, target, opts = {}) {
743
+ const query = new URLSearchParams({ owner: target.owner, repo: target.repo }).toString();
744
+ const { status, body, retryAfterSeconds } = await apiFetch(cfg, `/api/v1/stacks/issues/${encodeURIComponent(String(target.number))}/review?${query}`, opts);
745
+ if (status === 404) {
746
+ const message = serverMessage(body?.message);
747
+ if (!message)
748
+ return null;
749
+ throw new CommandError(`${message} Connect it in Mergestorm, then try again.`, 1, "not_found");
750
+ }
751
+ if (status === 429) {
752
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", retryAfterSeconds !== undefined ? { retryAfterSeconds } : undefined);
753
+ }
754
+ const parsed = status === 200 ? parseIssueReviewStatus(body, target) : null;
755
+ if (!parsed) {
756
+ const message = serverMessage(body?.message);
757
+ 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);
758
+ }
759
+ return parsed;
760
+ }
761
+ export async function getIssueReviewMode(cfg, target, opts = {}) {
762
+ const query = new URLSearchParams({ owner: target.owner, repo: target.repo }).toString();
763
+ const { status, body, retryAfterSeconds } = await apiFetch(cfg, `/api/v1/stacks/issues/review-mode?${query}`, opts);
764
+ if (status === 404)
765
+ return null;
766
+ if (status === 429) {
767
+ throw new CommandError(rateLimitedMessage(retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", retryAfterSeconds !== undefined ? { retryAfterSeconds } : undefined);
768
+ }
769
+ const mode = status === 200 ? ISSUE_REVIEW_MODES.find((known) => known === body?.mode) : undefined;
770
+ if (!mode) {
771
+ const message = serverMessage(body?.message);
772
+ throw new CommandError(message ?? `Failed to read the issue review mode of ${target.owner}/${target.repo} (HTTP ${status})`);
773
+ }
774
+ return mode;
775
+ }
776
+ export function issueReviewSettled(status) {
777
+ if (status.state === "pending")
778
+ return false;
779
+ return !(status.state === "unreviewed" && status.mode === "auto");
780
+ }
781
+ export function issueReviewBlocksPr(status) {
782
+ return status.mode === "auto" && status.state !== "approved" && status.state !== "off";
783
+ }
784
+ export class IssueReviewTimeoutError extends CommandError {
785
+ lastStatus;
786
+ constructor(lastStatus) {
787
+ 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");
788
+ this.name = "IssueReviewTimeoutError";
789
+ this.lastStatus = lastStatus;
790
+ }
791
+ }
792
+ function abortableSleep(ms, signal) {
793
+ return new Promise((resolve, reject) => {
794
+ const abortError = () => Object.assign(new Error("Aborted"), { name: "AbortError" });
795
+ if (signal?.aborted) {
796
+ reject(abortError());
797
+ return;
798
+ }
799
+ const onAbort = () => {
800
+ clearTimeout(timer);
801
+ reject(abortError());
802
+ };
803
+ const timer = setTimeout(() => {
804
+ signal?.removeEventListener("abort", onAbort);
805
+ resolve();
806
+ }, ms);
807
+ signal?.addEventListener("abort", onAbort, { once: true });
808
+ });
809
+ }
810
+ function isTransientIssueReviewError(err) {
811
+ if (!(err instanceof Error) || err.name === "AbortError")
812
+ return false;
813
+ if (err instanceof CommandError) {
814
+ return err.code === "api_timeout" || err.code === "issue_review_unavailable" || err.code === "rate_limited";
815
+ }
816
+ return /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network/i.test(err.message);
817
+ }
818
+ export async function pollIssueReview(cfg, target, opts = {}) {
819
+ const timeoutMs = opts.timeoutMs ?? ISSUE_REVIEW_DEFAULT_TIMEOUT_MS;
820
+ const intervalMs = opts.intervalMs ?? ISSUE_REVIEW_POLL_INTERVAL_MS;
821
+ const now = opts.now ?? Date.now;
822
+ const wait = opts.sleep ?? abortableSleep;
823
+ const deadline = now() + timeoutMs;
824
+ let last = null;
825
+ let failure = null;
826
+ for (;;) {
827
+ try {
828
+ const status = await getIssueReview(cfg, target, { signal: opts.signal });
829
+ if (!status || issueReviewSettled(status))
830
+ return status;
831
+ last = status;
832
+ opts.onTick?.(status);
833
+ }
834
+ catch (err) {
835
+ if (!isTransientIssueReviewError(err) || now() >= deadline)
836
+ throw err;
837
+ failure = err;
838
+ opts.onTick?.(null);
839
+ }
840
+ const remaining = deadline - now();
841
+ if (remaining <= 0) {
842
+ if (last)
843
+ throw new IssueReviewTimeoutError(last);
844
+ throw failure;
845
+ }
846
+ await wait(Math.min(intervalMs, remaining), opts.signal);
847
+ }
848
+ }
849
+ export function issueReviewLines(status) {
850
+ const round = status.round ? ` (round ${status.round} of ${status.maxRounds})` : "";
851
+ const lines = [`${status.owner}/${status.repo}#${status.number}: ${status.state}${round}`];
852
+ if (status.summary)
853
+ lines.push(` ${status.summary}`);
854
+ status.points.forEach((point, i) => lines.push(` ${i + 1}. [${point.blocking === false ? "note: " : ""}${point.kind}] ${point.text}`));
855
+ if (status.commentUrl)
856
+ lines.push(` Review: ${status.commentUrl}`);
857
+ if (status.next)
858
+ lines.push(` Next: ${status.next}`);
859
+ return lines;
860
+ }
861
+ export function issueNotApprovedError(issues, message) {
862
+ const lines = [message?.trim() || "Vortex has not approved the linked issue yet; refusing to open the pull request."];
863
+ for (const issue of issues) {
864
+ lines.push(...issueReviewLines(issue));
865
+ const reply = issue.state === "needs_answers" ? `Reply once in a comment on #${issue.number}, accepting each blocking point's suggested fix or saying why not; then run` : "Run";
866
+ lines.push(` ${reply} \`mergestorm issue wait ${issue.number}\`.`);
867
+ }
868
+ return new CommandError(lines.join("\n"), REVIEW_EXIT.failed, "issue_not_approved", issues.length === 0 ? { reason: NO_LINKED_ISSUE } : undefined);
869
+ }
870
+ export const NO_LINKED_ISSUE = "no_linked_issue";
871
+ export function issueNotApprovedRefusal(status, body, owner, repo) {
872
+ if (status !== 409 || !body || typeof body !== "object")
873
+ return null;
874
+ const refusal = body;
875
+ if (refusal.error !== "issue_not_approved")
876
+ return null;
877
+ const issues = Array.isArray(refusal.issues)
878
+ ? refusal.issues.flatMap((issue) => {
879
+ const n = issue?.number;
880
+ const parsed = parseIssueReviewStatus(issue, { owner, repo, number: isPositiveInteger(n) ? n : 0 });
881
+ return parsed ? [parsed] : [];
882
+ })
883
+ : [];
884
+ return issueNotApprovedError(issues, serverMessage(refusal.message));
885
+ }
664
886
  async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg) {
665
887
  const resolved = cfg ?? (await loadConfig());
666
888
  const { status, body } = await apiFetch(resolved, `/api/v1/stacks/${encodeURIComponent(stackId)}${pathSuffix}`, method === "PATCH"
@@ -42,6 +42,14 @@ 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";
49
+ }, {
50
+ readonly key: "issue_auto_review_enabled";
51
+ readonly flag: "--issue-auto-review";
52
+ readonly label: "Vortex issue auto review";
45
53
  }, {
46
54
  readonly key: "auto_land_default";
47
55
  readonly flag: "--auto-land";
@@ -112,4 +120,4 @@ export type BearerSettingsValues = {
112
120
  values: readonly (infer Value)[];
113
121
  } ? Value : boolean;
114
122
  };
115
- 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" | "merge_queue_lands_alone_paths" | "merge_queue_overdrive" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
123
+ 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" | "issue_auto_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,6 +10,8 @@ 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" },
14
+ { key: "issue_auto_review_enabled", flag: "--issue-auto-review", label: "Vortex issue auto review" },
13
15
  { key: "auto_land_default", flag: "--auto-land", label: "Auto land (new stacks)" },
14
16
  { key: "auto_land_settle_seconds", flag: "--auto-land-settle", label: "Auto land settle (seconds)", kind: "seconds", min: 15, max: 300 },
15
17
  { key: "merge_queue_batch_enabled", flag: "--merge-queue-batch", label: "Merge queue batching" },
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { stdin as input, stdout as output } from "node:process";
3
+ import { splitAccountFlag, withAccount } from "./config.js";
3
4
  import { CommandError } from "./errors.js";
4
5
  import { dispatchCommand, formatUsageBody, } from "./commands/registry.js";
5
6
  import { isSupportedNode, unsupportedNodeMessage, } from "./node-version.js";
@@ -17,37 +18,38 @@ function usage() {
17
18
  ${formatUsageBody()}
18
19
  `);
19
20
  }
20
- async function runCommand(cmd, args) {
21
+ async function runCommand(cmd, args, account) {
21
22
  if (cmd === "help" || cmd === "?") {
22
23
  usage();
23
24
  return;
24
25
  }
25
- const ok = await dispatchCommand(cmd, args, { mode: "oneshot" });
26
+ const ok = await dispatchCommand(cmd, args, { mode: "oneshot", account });
26
27
  if (!ok) {
27
28
  usage();
28
29
  throw new CommandError(`unknown command: ${cmd}`);
29
30
  }
30
31
  }
31
32
  async function main() {
32
- const [, , cmd, ...args] = process.argv;
33
- if (!cmd) {
34
- if (input.isTTY && output.isTTY) {
35
- await runShell();
33
+ try {
34
+ const { account, args: argv } = splitAccountFlag(process.argv.slice(2));
35
+ const [cmd, ...args] = argv;
36
+ if (!cmd) {
37
+ if (input.isTTY && output.isTTY) {
38
+ await withAccount(account, () => runShell());
39
+ return;
40
+ }
41
+ usage();
36
42
  return;
37
43
  }
38
- usage();
39
- return;
40
- }
41
- if (cmd === "-h" || cmd === "--help" || cmd === "help" || cmd === "?") {
42
- usage();
43
- return;
44
- }
45
- if (cmd === "-v" || cmd === "--version" || cmd === "version") {
46
- console.log(cliVersion());
47
- return;
48
- }
49
- try {
50
- await runCommand(cmd, args);
44
+ if (cmd === "-h" || cmd === "--help" || cmd === "help" || cmd === "?") {
45
+ usage();
46
+ return;
47
+ }
48
+ if (cmd === "-v" || cmd === "--version" || cmd === "version") {
49
+ console.log(cliVersion());
50
+ return;
51
+ }
52
+ await runCommand(cmd, args, account);
51
53
  }
52
54
  catch (err) {
53
55
  if (err instanceof CommandError) {