mergestorm 0.3.19 → 0.3.20

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
@@ -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.19 │
48
+ │ ▟██▛ mergestorm v0.3.20 │
49
49
  │ ▜▙ ● msk_live_… · maelstrom │
50
50
  │ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used · Resets Sep 5, 10:53am (UTC)│
51
51
  │ │
52
52
  │ review a diff · usage for tabs · /help for all commands │
53
- │ New in v0.3.19: stack wait and held GET waits │
53
+ │ New in v0.3.20: stack watch pair-gate attention and upstack issues │
54
54
  ╰──────────────────────────────────────────────────────────────────────────────╯
55
55
  mergestorm
56
56
  ╭──────────────────────────────────────────────────────────────────────────────╮
@@ -94,7 +94,7 @@ mergestorm stack list [--json] List registered stacks
94
94
  mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
95
95
  mergestorm stack restack <stack-id> Restack descendants
96
96
  mergestorm stack land <stack-id> Land / promote (into review unit when present)
97
- mergestorm stack wait <stack-id> Wait for stack attention (default 45s, max 300s)
97
+ mergestorm stack wait <stack-id> [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait)
98
98
  mergestorm stack reset --force Clear local authoring state (not branches/PRs)
99
99
  ```
100
100
 
@@ -7,3 +7,4 @@ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, type ReviewEnvelopeFal
7
7
  export { discoverTrunk } from "../git-stack.js";
8
8
  export { BOUNCE_KIND_LABELS, mergeQueueBounceLabel } from "../stack-dto.js";
9
9
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, StackWatchError, StackWatchTimeoutError, pollStackWatch, type PollStackWatchOptions, type StackWatchCursor, type StackWatchEnvelope, } from "../stack-watch.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead, type StackBlocker } from "../stack-blockers.js";
@@ -7,3 +7,4 @@ export { REVIEW_JOB_ENVELOPE_SCHEMA, toReviewJobEnvelope, } from "../ui/job-enve
7
7
  export { discoverTrunk } from "../git-stack.js";
8
8
  export { BOUNCE_KIND_LABELS, mergeQueueBounceLabel } from "../stack-dto.js";
9
9
  export { STACK_WATCH_SCHEMA, STACK_WATCH_DEFAULT_TIMEOUT_MS, StackWatchError, StackWatchTimeoutError, pollStackWatch, } from "../stack-watch.js";
10
+ export { stackBlockers, stackBlockersSummary, sameHead } from "../stack-blockers.js";
@@ -223,7 +223,7 @@ export const COMMAND_REGISTRY = [
223
223
  "mergestorm stack reset --force Clear this repo's pre-submit authoring state",
224
224
  "mergestorm stack list [--json] List registered stacks",
225
225
  "mergestorm stack status <stack-id> [--json] Enriched stack status",
226
- "mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s)",
226
+ "mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait)",
227
227
  "mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json] Per-stack policy",
228
228
  "mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] Import an open PR chain",
229
229
  "mergestorm stack restack <stack-id> Restack stack descendants",
@@ -1,6 +1,6 @@
1
1
  import { adoptStack, ensureUpperPark, getEnrichedStack, listStacks, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
2
2
  import { loadConfig } from "../config.js";
3
- import { createPr, findOpenPrNumber } from "../gh.js";
3
+ import { createPrs, findOpenPrNumber } from "../gh.js";
4
4
  import { parseGithubOriginRepo } from "../git.js";
5
5
  import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty } from "../git-stack.js";
6
6
  import { buildPrBodyFromCommit } from "../pr-body.js";
@@ -59,6 +59,7 @@ export declare function parseStackSubmitArgs(args: string[]): {
59
59
  autoLand?: boolean;
60
60
  autoReview?: boolean;
61
61
  autoPatch?: boolean;
62
+ rest: boolean;
62
63
  asJson: boolean;
63
64
  };
64
65
  export declare function parseStackAdoptArgs(args: string[]): {
@@ -114,11 +115,12 @@ export type StackSubmitDeps = {
114
115
  tipCommitSubject?: typeof tipCommitSubject;
115
116
  tipCommitMessage?: typeof tipCommitMessage;
116
117
  buildPrBodyFromCommit?: typeof buildPrBodyFromCommit;
117
- createPr?: typeof createPr;
118
+ createPrs?: typeof createPrs;
118
119
  loadConfig?: typeof loadConfig;
119
120
  listStacks?: typeof listStacks;
120
121
  adoptStack?: typeof adoptStack;
121
122
  ensureUpperPark?: typeof ensureUpperPark;
123
+ env?: NodeJS.ProcessEnv;
122
124
  };
123
125
  export declare function cmdStackSubmit(args: string[], deps?: StackSubmitDeps): Promise<void>;
124
126
  export declare function cmdStackWait(args: string[], deps?: {
@@ -1,7 +1,8 @@
1
+ import { stackBlockersSummary } from "../stack-blockers.js";
1
2
  import { adoptStack, ensureUpperPark, getEnrichedStack, landNextStack, listStacks, restackStack, setStackPolicy, } from "../api.js";
2
3
  import { loadConfig } from "../config.js";
3
4
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
4
- import { createPr, findOpenPrNumber, requireGh } from "../gh.js";
5
+ import { createPrs, findOpenPrNumber, githubApiFromEnv, requireGh, } from "../gh.js";
5
6
  import { parseGithubOriginRepo } from "../git.js";
6
7
  import { branchExists, commitsAheadOf, createBranchFromHead, currentBranch, defaultLayerBranchName, deleteBranch, discoverTrunk, fetchRemoteBranch, pushBranch, tipCommitMessage, tipCommitSubject, worktreeDirty, } from "../git-stack.js";
7
8
  import { buildPrBodyFromCommit } from "../pr-body.js";
@@ -16,7 +17,7 @@ import { canBrowse } from "./browse.js";
16
17
  import { openHelpBrowser } from "./help.js";
17
18
  const STACK_USAGE = `usage:
18
19
  mergestorm stack create [name] [--onto <branch>] [--trunk <branch>] [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
19
- mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
20
+ mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--rest] [--json]
20
21
  mergestorm stack reset --force
21
22
  mergestorm stack list [--json]
22
23
  mergestorm stack status <stack-id> [--json]
@@ -32,6 +33,8 @@ const STACK_USAGE = `usage:
32
33
  stack submit opens PRs with a body generated from each layer tip commit.
33
34
  Layers 1–2 use the git parent as GitHub base. Layer 3+ opens onto the
34
35
  review-unit park freeze (mg-park-*) so adopt does not retarget after open.
36
+ stack submit talks to GitHub GraphQL and falls back to the REST API when
37
+ GraphQL is blocked; --rest (or MERGESTORM_GITHUB_API=rest) forces REST.
35
38
  Parenting onto a branch that already belongs to a registered (submitted)
36
39
  stack requires --extend on create and submit — otherwise mg starts a new
37
40
  stack from trunk. stack land promotes into the review unit when one exists;
@@ -267,6 +270,7 @@ export function parseStackCreateArgs(args) {
267
270
  /** Parse `stack submit` argv. Exported for tests. */
268
271
  export function parseStackSubmitArgs(args) {
269
272
  let extend = false;
273
+ let rest = false;
270
274
  const policy = {};
271
275
  let asJson = false;
272
276
  for (let i = 0; i < args.length; i++) {
@@ -279,18 +283,23 @@ export function parseStackSubmitArgs(args) {
279
283
  extend = true;
280
284
  continue;
281
285
  }
286
+ if (a === "--rest") {
287
+ rest = true;
288
+ continue;
289
+ }
282
290
  const next = takeOpenPolicyFlag(args, i, policy, "stack submit");
283
291
  if (next !== null) {
284
292
  i = next - 1;
285
293
  continue;
286
294
  }
287
- throw new CommandError("usage: mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]");
295
+ throw new CommandError("usage: mergestorm stack submit [--extend] [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--rest] [--json]");
288
296
  }
289
297
  return {
290
298
  extend,
291
299
  autoLand: policy.autoLand,
292
300
  autoReview: policy.autoReview,
293
301
  autoPatch: policy.autoPatch,
302
+ rest,
294
303
  asJson,
295
304
  };
296
305
  }
@@ -523,7 +532,8 @@ export async function cmdStackCreate(args, deps = {}) {
523
532
  await present("Stack", created);
524
533
  }
525
534
  export async function cmdStackSubmit(args, deps = {}) {
526
- const { extend, autoLand, autoReview, autoPatch, asJson } = parseStackSubmitArgs(args);
535
+ const { extend, autoLand, autoReview, autoPatch, rest, asJson } = parseStackSubmitArgs(args);
536
+ const api = rest ? "rest" : githubApiFromEnv(deps.env ?? process.env);
527
537
  const progress = (message) => {
528
538
  if (asJson)
529
539
  console.error(message);
@@ -544,7 +554,7 @@ export async function cmdStackSubmit(args, deps = {}) {
544
554
  const tipCommitSubjectFn = deps.tipCommitSubject ?? tipCommitSubject;
545
555
  const tipCommitMessageFn = deps.tipCommitMessage ?? tipCommitMessage;
546
556
  const buildPrBodyFn = deps.buildPrBodyFromCommit ?? buildPrBodyFromCommit;
547
- const createPrFn = deps.createPr ?? createPr;
557
+ const createPrsFn = deps.createPrs ?? createPrs;
548
558
  const loadConfigFn = deps.loadConfig ?? loadConfig;
549
559
  const listStacksFn = deps.listStacks ?? listStacks;
550
560
  const adoptStackFn = deps.adoptStack ?? adoptStack;
@@ -629,16 +639,11 @@ export async function cmdStackSubmit(args, deps = {}) {
629
639
  }
630
640
  const opened = [];
631
641
  const pendingPark = plans.filter((plan) => {
632
- if (findOpenPrNumberFn(owner, repo, plan.branch, cwd) != null)
642
+ if (findOpenPrNumberFn(owner, repo, plan.branch, cwd, { api }) != null)
633
643
  return false;
634
644
  return plan.githubBase == null;
635
645
  });
636
- const openLayerPr = (branch, base) => {
637
- const existing = findOpenPrNumberFn(owner, repo, branch, cwd);
638
- if (existing != null) {
639
- progress(ansi.dim(` PR #${existing} already open for ${branch}`));
640
- return { prNumber: existing, created: false };
641
- }
646
+ const prepareLayerPr = (branch, base) => {
642
647
  const aheadRef = isMgParkBranch(base) ? `origin/${base}` : base;
643
648
  if (isMgParkBranch(base)) {
644
649
  try {
@@ -662,30 +667,44 @@ export async function cmdStackSubmit(args, deps = {}) {
662
667
  throw new CommandError(`No commits on \`${branch}\` ahead of \`${base}\`. ` +
663
668
  `Commit your changes, then retry \`mg stack submit\`.`);
664
669
  }
665
- const title = tipCommitSubjectFn(branch, cwd);
666
- const body = buildPrBodyFn(tipCommitMessageFn(branch, cwd));
667
- progress(ansi.dim(` Opening PR ${branch} → ${base} …`));
668
670
  return {
669
- prNumber: createPrFn({
670
- owner,
671
- repo,
672
- base,
673
- head: branch,
674
- title,
675
- body,
676
- cwd,
677
- }),
678
- created: true,
671
+ base,
672
+ head: branch,
673
+ title: tipCommitSubjectFn(branch, cwd),
674
+ body: buildPrBodyFn(tipCommitMessageFn(branch, cwd)),
679
675
  };
680
676
  };
681
- for (const plan of plans) {
682
- if (plan.githubBase == null && findOpenPrNumberFn(owner, repo, plan.branch, cwd) == null) {
683
- continue;
677
+ const openLayerWave = (wave) => {
678
+ const rows = [];
679
+ const pending = [];
680
+ for (const { branch, base } of wave) {
681
+ const existing = findOpenPrNumberFn(owner, repo, branch, cwd, { api });
682
+ if (existing != null) {
683
+ progress(ansi.dim(` PR #${existing} already open for ${branch}`));
684
+ rows.push({ branch, base, prNumber: existing, created: false });
685
+ continue;
686
+ }
687
+ pending.push({ index: rows.length, pr: prepareLayerPr(branch, base) });
688
+ rows.push({ branch, base, prNumber: 0, created: true });
684
689
  }
685
- const base = plan.githubBase ?? plan.gitParent;
686
- const { prNumber, created } = openLayerPr(plan.branch, base);
687
- opened.push({ branch: plan.branch, base, prNumber, created });
688
- }
690
+ if (pending.length === 0)
691
+ return rows;
692
+ for (const { pr } of pending) {
693
+ progress(ansi.dim(` Opening PR ${pr.head} → ${pr.base} …`));
694
+ }
695
+ const numbers = createPrsFn({ owner, repo, prs: pending.map((p) => p.pr), cwd, api });
696
+ if (numbers.length !== pending.length) {
697
+ throw new CommandError(`GitHub returned ${numbers.length} PR numbers for ${pending.length} new pull requests.`);
698
+ }
699
+ pending.forEach(({ index }, i) => {
700
+ rows[index].prNumber = numbers[i];
701
+ });
702
+ return rows;
703
+ };
704
+ opened.push(...openLayerWave(plans
705
+ .filter((plan) => plan.githubBase != null ||
706
+ findOpenPrNumberFn(owner, repo, plan.branch, cwd, { api }) != null)
707
+ .map((plan) => ({ branch: plan.branch, base: plan.githubBase ?? plan.gitParent }))));
689
708
  const cfg = await loadConfigFn();
690
709
  let adoptData = null;
691
710
  let stackId = plans.find((plan) => plan.stackId)?.stackId ?? null;
@@ -716,10 +735,7 @@ export async function cmdStackSubmit(args, deps = {}) {
716
735
  }
717
736
  progress(ansi.dim(` Ensuring upper-park freeze for stack ${stackId} …`));
718
737
  const park = await ensureUpperParkFn(stackId, cfg);
719
- for (const plan of pendingPark) {
720
- const { prNumber, created } = openLayerPr(plan.branch, park.freezeBranch);
721
- opened.push({ branch: plan.branch, base: park.freezeBranch, prNumber, created });
722
- }
738
+ opened.push(...openLayerWave(pendingPark.map((plan) => ({ branch: plan.branch, base: park.freezeBranch }))));
723
739
  await registerPr(opened[opened.length - 1].prNumber);
724
740
  }
725
741
  else {
@@ -823,7 +839,7 @@ export async function cmdStackWait(args, deps = {}) {
823
839
  ...envelope,
824
840
  ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
825
841
  }, null, 2)
826
- : `Stack ${envelope.stackId} · ${envelope.status}${envelope.blocker ? ` · ${envelope.blocker}` : ""}`);
842
+ : `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`);
827
843
  try {
828
844
  print(await (deps.pollStackWatch ?? pollStackWatch)(cfg, stackId, {
829
845
  timeoutMs: timeoutS * 1000,
@@ -835,6 +851,10 @@ export async function cmdStackWait(args, deps = {}) {
835
851
  print(err.lastEnvelope, err.retryAfterSeconds);
836
852
  throw new CommandError(rateLimitedMessage(err.retryAfterSeconds), REVIEW_EXIT.rate_limited, "rate_limited", { retryAfterSeconds: err.retryAfterSeconds });
837
853
  }
854
+ if (err instanceof StackWatchError) {
855
+ print(err.lastEnvelope);
856
+ throw err;
857
+ }
838
858
  if (!(err instanceof StackWatchTimeoutError))
839
859
  throw err;
840
860
  print(err.lastEnvelope);
package/dist/gh.d.ts CHANGED
@@ -1,20 +1,82 @@
1
- export declare function runGh(args: string[], cwd?: string): {
1
+ import { CommandError } from "./errors.js";
2
+ export type GhResult = {
2
3
  ok: true;
3
4
  stdout: string;
4
5
  } | {
5
6
  ok: false;
6
7
  stderr: string;
7
8
  status: number | null;
9
+ stdout?: string;
8
10
  };
11
+ export type GhRunner = (args: string[], input?: string) => GhResult;
12
+ export type GithubApi = "graphql" | "rest";
13
+ export declare const GITHUB_API_ENV = "MERGESTORM_GITHUB_API";
14
+ export declare function githubApiFromEnv(env?: NodeJS.ProcessEnv): GithubApi;
15
+ export declare function isGraphqlEndpointBlocked(text: string): boolean;
16
+ export declare class GraphqlBlockedError extends CommandError {
17
+ readonly opened: readonly number[];
18
+ constructor(message: string, opened?: readonly number[]);
19
+ }
20
+ type GhSpawn = (command: string, args: string[], options: {
21
+ cwd: string;
22
+ encoding: "utf8";
23
+ input?: string;
24
+ }) => {
25
+ error?: Error;
26
+ stdout?: string | null;
27
+ stderr?: string | null;
28
+ status: number | null;
29
+ };
30
+ export declare function runGh(args: string[], cwd?: string, input?: string, spawn?: GhSpawn): GhResult;
9
31
  export declare function requireGh(cwd?: string): void;
10
32
  /** 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;
33
+ export declare function findOpenPrNumber(owner: string, repo: string, headBranch: string, cwd?: string, options?: {
34
+ api?: GithubApi;
35
+ gh?: GhRunner;
36
+ }): number | null;
37
+ export declare function findOpenPrNumberViaRest(owner: string, repo: string, headBranch: string, gh: GhRunner): number | null;
38
+ export type NewPr = {
15
39
  base: string;
16
40
  head: string;
17
41
  title: string;
18
- body?: string;
42
+ body: string;
43
+ };
44
+ export type GraphqlRequest = {
45
+ query: string;
46
+ variables: Record<string, unknown>;
47
+ };
48
+ export type GraphqlResponse = {
49
+ data?: Record<string, unknown> | null;
50
+ errors?: {
51
+ message?: string;
52
+ }[];
53
+ };
54
+ export type GraphqlRunner = (request: GraphqlRequest) => GraphqlResponse;
55
+ type GraphqlSpawnResult = {
56
+ error?: Error;
57
+ stdout?: string | null;
58
+ stderr?: string | null;
59
+ status: number | null;
60
+ };
61
+ type GraphqlSpawn = (command: string, args: string[], options: {
62
+ cwd: string;
63
+ encoding: "utf8";
64
+ input: string;
65
+ }) => GraphqlSpawnResult;
66
+ export declare const CREATE_PR_BATCH_SIZE = 5;
67
+ export declare function ghGraphql(request: GraphqlRequest, cwd?: string, spawn?: GraphqlSpawn): GraphqlResponse;
68
+ export declare function chunkCreatePrs<T>(items: readonly T[], size?: number): T[][];
69
+ export declare function buildCreatePrsMutation(repositoryId: string, prs: readonly NewPr[]): GraphqlRequest;
70
+ export declare function fetchRepositoryId(owner: string, repo: string, graphql: GraphqlRunner): string;
71
+ export declare function createPrsOnRepository(repositoryId: string, prs: readonly NewPr[], graphql: GraphqlRunner): number[];
72
+ export declare function createPrsViaRest(owner: string, repo: string, prs: readonly NewPr[], gh: GhRunner, opened?: readonly number[]): number[];
73
+ export declare function createPrs(input: {
74
+ owner: string;
75
+ repo: string;
76
+ prs: readonly NewPr[];
19
77
  cwd?: string;
20
- }): number;
78
+ api?: GithubApi;
79
+ graphql?: GraphqlRunner;
80
+ gh?: GhRunner;
81
+ }): number[];
82
+ export {};
package/dist/gh.js CHANGED
@@ -1,7 +1,28 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { CommandError } from "./errors.js";
3
- export function runGh(args, cwd = process.cwd()) {
4
- const result = spawnSync("gh", args, { cwd, encoding: "utf8" });
3
+ export const GITHUB_API_ENV = "MERGESTORM_GITHUB_API";
4
+ export function githubApiFromEnv(env = process.env) {
5
+ return env[GITHUB_API_ENV]?.trim().toLowerCase() === "rest" ? "rest" : "graphql";
6
+ }
7
+ const GRAPHQL_UNAVAILABLE = /graphql\b[^\n]*\b(?:not available|unavailable|blocked|disabled)\b/i;
8
+ const GRAPHQL_CONTEXT = /\bgraphql\b/i;
9
+ export function isGraphqlEndpointBlocked(text) {
10
+ return GRAPHQL_UNAVAILABLE.test(text) || (/\bHTTP 403\b/.test(text) && GRAPHQL_CONTEXT.test(text));
11
+ }
12
+ export class GraphqlBlockedError extends CommandError {
13
+ opened;
14
+ constructor(message, opened = []) {
15
+ super(message);
16
+ this.name = "GraphqlBlockedError";
17
+ this.opened = opened;
18
+ }
19
+ }
20
+ export function runGh(args, cwd = process.cwd(), input, spawn = spawnSync) {
21
+ const result = spawn("gh", args, {
22
+ cwd,
23
+ encoding: "utf8",
24
+ ...(input !== undefined ? { input } : {}),
25
+ });
5
26
  if (result.error) {
6
27
  const msg = result.error.message || String(result.error);
7
28
  if (result.error.code === "ENOENT") {
@@ -14,6 +35,7 @@ export function runGh(args, cwd = process.cwd()) {
14
35
  ok: false,
15
36
  stderr: (result.stderr || result.stdout || `gh ${args.join(" ")} failed`).trim(),
16
37
  status: result.status,
38
+ stdout: result.stdout ?? "",
17
39
  };
18
40
  }
19
41
  return { ok: true, stdout: result.stdout ?? "" };
@@ -29,8 +51,23 @@ export function requireGh(cwd = process.cwd()) {
29
51
  }
30
52
  }
31
53
  /** 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([
54
+ export function findOpenPrNumber(owner, repo, headBranch, cwd = process.cwd(), options = {}) {
55
+ const gh = options.gh ?? ((args, input) => runGh(args, cwd, input));
56
+ if ((options.api ?? githubApiFromEnv()) === "rest") {
57
+ return findOpenPrNumberViaRest(owner, repo, headBranch, gh);
58
+ }
59
+ try {
60
+ return findOpenPrNumberViaGraphql(owner, repo, headBranch, gh);
61
+ }
62
+ catch (err) {
63
+ if (err instanceof GraphqlBlockedError) {
64
+ return findOpenPrNumberViaRest(owner, repo, headBranch, gh);
65
+ }
66
+ throw err;
67
+ }
68
+ }
69
+ function findOpenPrNumberViaGraphql(owner, repo, headBranch, gh) {
70
+ const res = gh([
34
71
  "pr",
35
72
  "list",
36
73
  "--repo",
@@ -43,12 +80,37 @@ export function findOpenPrNumber(owner, repo, headBranch, cwd = process.cwd()) {
43
80
  "number",
44
81
  "--limit",
45
82
  "1",
46
- ], cwd);
83
+ ]);
84
+ if (!res.ok) {
85
+ const message = `Failed to list PRs for ${headBranch}: ${res.stderr}`;
86
+ if (isGraphqlEndpointBlocked(`${res.stderr}\n${res.stdout ?? ""}`)) {
87
+ throw new GraphqlBlockedError(message);
88
+ }
89
+ throw new CommandError(message);
90
+ }
91
+ return firstPrNumber(res.stdout);
92
+ }
93
+ export function findOpenPrNumberViaRest(owner, repo, headBranch, gh) {
94
+ const res = gh([
95
+ "api",
96
+ "-X",
97
+ "GET",
98
+ `repos/${owner}/${repo}/pulls`,
99
+ "-f",
100
+ `head=${owner}:${headBranch}`,
101
+ "-f",
102
+ "state=open",
103
+ "-f",
104
+ "per_page=1",
105
+ ]);
47
106
  if (!res.ok) {
48
107
  throw new CommandError(`Failed to list PRs for ${headBranch}: ${res.stderr}`);
49
108
  }
109
+ return firstPrNumber(res.stdout);
110
+ }
111
+ function firstPrNumber(stdout) {
50
112
  try {
51
- const rows = JSON.parse(res.stdout);
113
+ const rows = JSON.parse(stdout);
52
114
  const n = rows[0]?.number;
53
115
  return typeof n === "number" && n > 0 ? n : null;
54
116
  }
@@ -56,34 +118,173 @@ export function findOpenPrNumber(owner, repo, headBranch, cwd = process.cwd()) {
56
118
  throw new CommandError(`Failed to parse PR list response: ${e instanceof Error ? e.message : String(e)}`);
57
119
  }
58
120
  }
59
- export function createPr(input) {
121
+ export const CREATE_PR_BATCH_SIZE = 5;
122
+ export function ghGraphql(request, cwd = process.cwd(), spawn = spawnSync) {
123
+ const result = spawn("gh", ["api", "graphql", "--input", "-"], {
124
+ cwd,
125
+ encoding: "utf8",
126
+ input: JSON.stringify(request),
127
+ });
128
+ if (result.error) {
129
+ throw new CommandError(`gh api graphql failed: ${result.error.message || String(result.error)}`);
130
+ }
131
+ const stdout = (result.stdout ?? "").trim();
132
+ if (result.status !== 0 && isGraphqlEndpointBlocked(result.stderr ?? "")) {
133
+ return failGraphql(result.stderr ?? null, stdout, result.status, true);
134
+ }
135
+ try {
136
+ const parsed = JSON.parse(stdout);
137
+ if (parsed && typeof parsed === "object" && ("data" in parsed || "errors" in parsed)) {
138
+ return parsed;
139
+ }
140
+ }
141
+ catch {
142
+ return failGraphql(result.stderr ?? null, stdout, result.status, isGraphqlEndpointBlocked(stdout));
143
+ }
144
+ return failGraphql(result.stderr ?? null, stdout, result.status, isGraphqlEndpointBlocked(stdout));
145
+ }
146
+ function failGraphql(stderr, stdout, status, blocked) {
147
+ const message = `gh api graphql failed: ${(stderr || stdout || `exit ${status}`).trim()}`;
148
+ throw blocked ? new GraphqlBlockedError(message) : new CommandError(message);
149
+ }
150
+ export function chunkCreatePrs(items, size = CREATE_PR_BATCH_SIZE) {
151
+ const chunks = [];
152
+ for (let i = 0; i < items.length; i += size) {
153
+ chunks.push(items.slice(i, i + size));
154
+ }
155
+ return chunks;
156
+ }
157
+ export function buildCreatePrsMutation(repositoryId, prs) {
158
+ const params = prs.map((_, i) => `$in${i}: CreatePullRequestInput!`).join(", ");
159
+ const fields = prs
160
+ .map((_, i) => ` pr${i}: createPullRequest(input: $in${i}) { pullRequest { number } }`)
161
+ .join("\n");
162
+ const variables = {};
163
+ prs.forEach((pr, i) => {
164
+ variables[`in${i}`] = {
165
+ repositoryId,
166
+ baseRefName: pr.base,
167
+ headRefName: pr.head,
168
+ title: pr.title,
169
+ body: pr.body,
170
+ };
171
+ });
172
+ return { query: `mutation MgStackSubmit(${params}) {\n${fields}\n}`, variables };
173
+ }
174
+ function graphqlErrorText(errors) {
175
+ const messages = errors.map((e) => e.message?.trim()).filter((m) => !!m);
176
+ return messages.length > 0 ? messages.join("; ") : "unknown GraphQL error";
177
+ }
178
+ export function fetchRepositoryId(owner, repo, graphql) {
179
+ const res = graphql({
180
+ query: "query MgRepositoryId($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id } }",
181
+ variables: { owner, name: repo },
182
+ });
183
+ const repository = res.data?.repository;
184
+ if (res.errors?.length || typeof repository?.id !== "string" || !repository.id) {
185
+ throw new CommandError(`Could not look up GitHub repository ${owner}/${repo}: ${graphqlErrorText(res.errors ?? [])}`);
186
+ }
187
+ return repository.id;
188
+ }
189
+ export function createPrsOnRepository(repositoryId, prs, graphql) {
190
+ const numbers = [];
191
+ const openedSoFar = () => numbers.map((n, i) => `#${n} (${prs[i].head})`);
192
+ for (const chunk of chunkCreatePrs(prs)) {
193
+ let res;
194
+ try {
195
+ res = graphql(buildCreatePrsMutation(repositoryId, chunk));
196
+ }
197
+ catch (err) {
198
+ if (err instanceof GraphqlBlockedError)
199
+ throw new GraphqlBlockedError(err.message, [...numbers]);
200
+ throw err;
201
+ }
202
+ const chunkNumbers = chunk.map((_, i) => {
203
+ const row = res.data?.[`pr${i}`];
204
+ const n = row?.pullRequest?.number;
205
+ return typeof n === "number" && n > 0 ? n : null;
206
+ });
207
+ const failed = (res.errors?.length ?? 0) > 0 || chunkNumbers.some((n) => n == null);
208
+ if (failed) {
209
+ const partial = chunk
210
+ .map((pr, i) => (chunkNumbers[i] != null ? `#${chunkNumbers[i]} (${pr.head})` : null))
211
+ .filter((s) => s != null);
212
+ const opened = [...openedSoFar(), ...partial];
213
+ const heads = chunk.map((pr) => `${pr.head} → ${pr.base}`).join(", ");
214
+ throw new CommandError(`Failed to open PRs ${heads}: ${graphqlErrorText(res.errors ?? [])}` +
215
+ (opened.length > 0
216
+ ? `\nGitHub did open ${opened.join(", ")}; retry \`mg stack submit\` to reuse them.`
217
+ : ""));
218
+ }
219
+ numbers.push(...chunkNumbers);
220
+ }
221
+ return numbers;
222
+ }
223
+ const repositoryIds = new Map();
224
+ function restErrorText(res) {
225
+ let body;
226
+ try {
227
+ body = JSON.parse(res.stdout ?? "");
228
+ }
229
+ catch {
230
+ return res.stderr;
231
+ }
232
+ const details = (Array.isArray(body?.errors) ? body.errors : [])
233
+ .map((e) => (typeof e?.message === "string" ? e.message.trim() : ""))
234
+ .filter((m) => m.length > 0);
235
+ if (details.length > 0)
236
+ return details.join("; ");
237
+ if (typeof body?.message === "string" && body.message.trim())
238
+ return body.message.trim();
239
+ return res.stderr;
240
+ }
241
+ export function createPrsViaRest(owner, repo, prs, gh, opened = []) {
242
+ const numbers = [...opened];
243
+ for (const pr of prs.slice(numbers.length)) {
244
+ const res = gh(["api", "-X", "POST", `repos/${owner}/${repo}/pulls`, "--input", "-"], JSON.stringify({ title: pr.title, head: pr.head, base: pr.base, body: pr.body }));
245
+ let n = null;
246
+ if (res.ok) {
247
+ try {
248
+ n = JSON.parse(res.stdout).number;
249
+ }
250
+ catch {
251
+ n = null;
252
+ }
253
+ }
254
+ if (typeof n !== "number" || n <= 0) {
255
+ const reason = res.ok ? "GitHub returned no pull request number" : restErrorText(res);
256
+ const openedSoFar = numbers.map((num, i) => `#${num} (${prs[i].head})`);
257
+ throw new CommandError(`Failed to open PRs ${pr.head} → ${pr.base}: ${reason}` +
258
+ (openedSoFar.length > 0
259
+ ? `\nGitHub did open ${openedSoFar.join(", ")}; retry \`mg stack submit\` to reuse them.`
260
+ : ""));
261
+ }
262
+ numbers.push(n);
263
+ }
264
+ return numbers;
265
+ }
266
+ export function createPrs(input) {
267
+ if (input.prs.length === 0)
268
+ return [];
60
269
  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.`);
270
+ const gh = input.gh ?? ((args, stdin) => runGh(args, cwd, stdin));
271
+ if ((input.api ?? githubApiFromEnv()) === "rest") {
272
+ return createPrsViaRest(input.owner, input.repo, input.prs, gh);
273
+ }
274
+ const graphql = input.graphql ?? ((request) => ghGraphql(request, cwd));
275
+ try {
276
+ const key = `${input.owner}/${input.repo}`.toLowerCase();
277
+ let repositoryId = repositoryIds.get(key);
278
+ if (!repositoryId) {
279
+ repositoryId = fetchRepositoryId(input.owner, input.repo, graphql);
280
+ repositoryIds.set(key, repositoryId);
281
+ }
282
+ return createPrsOnRepository(repositoryId, input.prs, graphql);
283
+ }
284
+ catch (err) {
285
+ if (err instanceof GraphqlBlockedError) {
286
+ return createPrsViaRest(input.owner, input.repo, input.prs, gh, err.opened);
287
+ }
288
+ throw err;
289
+ }
89
290
  }
@@ -0,0 +1,25 @@
1
+ import { type MergeQueueEntryDto, type StackDto, type StackLayerDto } from "./stack-dto.js";
2
+ import type { StackWatchCursor } from "./stack-watch.js";
3
+ export type StackBlocker = {
4
+ prNumber: number;
5
+ headSha: string | null;
6
+ blocker: string;
7
+ bounceKind: string | null;
8
+ };
9
+ export declare function isMgParkBase(branch: string | null | undefined): boolean;
10
+ /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
11
+ export declare function boundDirty(layer: StackLayerDto): boolean;
12
+ export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
13
+ export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
14
+ /** Pair-gate attention plus live hard blocks on every other open layer. */
15
+ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
16
+ attention: StackBlocker | null;
17
+ issues: StackBlocker[];
18
+ currentCandidate: {
19
+ prNumber: number;
20
+ headSha: string | null;
21
+ } | null;
22
+ bounce: MergeQueueEntryDto | undefined;
23
+ };
24
+ /** Compact contract text shared by status and wait summaries. */
25
+ export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[]): string;
@@ -0,0 +1,120 @@
1
+ import { mergeQueueBounceLabel, restackRetryPending } from "./stack-dto.js";
2
+ export function isMgParkBase(branch) {
3
+ return (branch ?? "").trim().toLowerCase().startsWith("mg-park-");
4
+ }
5
+ /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
6
+ export function boundDirty(layer) {
7
+ if (isMgParkBase(layer.parentBranch))
8
+ return false;
9
+ if (layer.mergeable == null)
10
+ return false;
11
+ return (!layer.headSha || layer.mergeableHeadSha === layer.headSha) &&
12
+ (layer.mergeable === false || layer.mergeableState?.toLowerCase() === "dirty");
13
+ }
14
+ export function sameHead(a, b) {
15
+ const left = a?.trim().toLowerCase();
16
+ const right = b?.trim().toLowerCase();
17
+ return !!left && !!right && (left.length === right.length
18
+ ? left === right
19
+ : left.startsWith(right) || right.startsWith(left));
20
+ }
21
+ export function currentLayer(stack) {
22
+ const open = stack.layers.filter((layer) => layer.prNumber > 0 && layer.state !== "merged" && layer.state !== "closed");
23
+ const promote = open.filter((layer) => layer.prNumber !== stack.unit?.landPrNumber &&
24
+ !stack.unit?.members?.some((member) => member.prNumber === layer.prNumber && member.promotedHeadSha)).sort((a, b) => a.position - b.position);
25
+ const land = stack.unit?.landPr;
26
+ return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
27
+ (land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
28
+ }
29
+ /** Shared live-fact precedence; bounce history is only passed for the candidate. */
30
+ function layerAttention(stack, layer, queue, cursor) {
31
+ const none = { blocker: null, bounceKind: null };
32
+ if (stack.archivedAt)
33
+ return none;
34
+ const bounce = queue.filter((entry) => entry.state === "bounced")
35
+ .sort((a, b) => (Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0))[0];
36
+ const kind = bounce?.bounceDetail?.kind;
37
+ const recoverable = kind === "head_moved" || kind === "must_consolidate";
38
+ const hardBlock = (blocker) => ({ blocker, bounceKind: null });
39
+ if (layer) {
40
+ if (layer.restackError && layer.state !== "restacking") {
41
+ if (layer.restackError.kind === "rebase_conflict")
42
+ return hardBlock("Conflict");
43
+ if (!restackRetryPending(layer.restackError, layer))
44
+ return hardBlock("Restack failed");
45
+ }
46
+ if (layer.state === "conflict")
47
+ return hardBlock("Conflict");
48
+ if (layer.draft)
49
+ return {
50
+ blocker: "Draft PR", bounceKind: "pr_draft",
51
+ ...(kind === "pr_draft" && sameHead(bounce.bounceDetail?.headSha ?? bounce.verifyHeadSha, layer.headSha) &&
52
+ (bounce.bounceDetail?.prNumber == null || bounce.bounceDetail.prNumber === layer.prNumber) ? { bounce } : {}),
53
+ };
54
+ if (boundDirty(layer))
55
+ return hardBlock(`Merge conflicts${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`);
56
+ if (layer.ciStatus === "failure" || (layer.checks?.failure ?? 0) > 0) {
57
+ const name = layer.checks?.failingName?.trim();
58
+ return hardBlock(`CI failed${name ? ` — ${name}` : ""}`);
59
+ }
60
+ const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
61
+ ["findings", "failed"].includes(run.status) && sameHead(run.sha, layer.headSha));
62
+ if (tempest)
63
+ return hardBlock(`Tempest ${tempest.status}`);
64
+ if (layer.vortexStatus === "failed")
65
+ return hardBlock("Review failed");
66
+ if (stack.unit?.landPrNumber === layer.prNumber) {
67
+ if (stack.unit.landingBlockReason?.trim())
68
+ return hardBlock(stack.unit.landingBlockReason.trim());
69
+ if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
70
+ return hardBlock("Tempest failed");
71
+ }
72
+ }
73
+ if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
74
+ return none;
75
+ if ((cursor.bounceId != null && cursor.bounceId === bounce.id) || (cursor.afterFinishedAt &&
76
+ !(Date.parse(bounce.finishedAt ?? "") > Date.parse(cursor.afterFinishedAt))))
77
+ return none;
78
+ // Compare with the observed promote/land head, never the enrollment or verify head.
79
+ if (!sameHead(bounce.bounceDetail?.headSha ?? bounce.verifyHeadSha, layer.headSha) ||
80
+ (bounce.bounceDetail?.prNumber != null && bounce.bounceDetail.prNumber !== layer.prNumber))
81
+ return none;
82
+ return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
83
+ }
84
+ /** Pair-gate attention plus live hard blocks on every other open layer. */
85
+ export function stackBlockers(stack, entries = [], cursor = {}) {
86
+ const candidate = currentLayer(stack);
87
+ const currentCandidate = candidate ? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null } : null;
88
+ const queue = entries.filter((entry) => entry.stackId.trim().toLowerCase() === stack.id.trim().toLowerCase());
89
+ const open = stack.layers.filter((layer) => layer.prNumber > 0 && !["merged", "closed"].includes(layer.state))
90
+ .sort((a, b) => a.position - b.position);
91
+ if (candidate && !open.some((layer) => layer.prNumber === candidate.prNumber))
92
+ open.push(candidate);
93
+ const child = candidate && open.filter((layer) => layer.position > candidate.position && layer.parentBranch === candidate.branch &&
94
+ (boundDirty(layer) || layer.state === "conflict" ||
95
+ (layer.state !== "restacking" && layer.restackError?.kind === "rebase_conflict"))).sort((a, b) => a.position - b.position)[0];
96
+ let attention = null;
97
+ let bounce;
98
+ const issues = [];
99
+ for (const layer of open) {
100
+ const selected = layer.prNumber === candidate?.prNumber;
101
+ const result = layerAttention(stack, layer, selected ? queue : [], cursor);
102
+ if (!result.blocker)
103
+ continue;
104
+ const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
105
+ blocker: result.blocker, bounceKind: result.bounceKind };
106
+ if (selected || (!attention && layer === child)) {
107
+ attention = issue;
108
+ bounce = result.bounce;
109
+ }
110
+ else if (!candidate || layer.position > candidate.position)
111
+ issues.push(issue);
112
+ }
113
+ return { attention, issues, currentCandidate, bounce };
114
+ }
115
+ /** Compact contract text shared by status and wait summaries. */
116
+ export function stackBlockersSummary(attention, issues) {
117
+ const blocked = attention ? ` · blocked: #${attention.prNumber} ${attention.blocker}` : "";
118
+ const labels = issues.slice(0, 3).map(issue => `#${issue.prNumber} ${issue.blocker}`).join("; ");
119
+ return blocked + (issues.length ? ` · issues: ${labels}${issues.length > 3 ? `; +${issues.length - 3} more` : ""}` : "");
120
+ }
@@ -6,6 +6,17 @@ export type RestackError = {
6
6
  attempts: number;
7
7
  backupRef: string | null;
8
8
  };
9
+ export declare function restackAttemptsExhausted(error: Pick<RestackError, "attempts">): boolean;
10
+ export declare function pushRejectionIsRetryable(message: string): boolean;
11
+ export declare function restackRetryPending(error: {
12
+ kind: string;
13
+ attempts: number;
14
+ detail?: string | null;
15
+ headSha?: string | null;
16
+ }, layer?: {
17
+ state: string;
18
+ headSha?: string | null;
19
+ }): boolean;
9
20
  /**
10
21
  * Wire types returned by the stack list/enrich HTTP endpoints.
11
22
  *
package/dist/stack-dto.js CHANGED
@@ -1,3 +1,21 @@
1
+ const RESTACK_ATTEMPT_CAP = 3;
2
+ export function restackAttemptsExhausted(error) {
3
+ return error.attempts >= RESTACK_ATTEMPT_CAP;
4
+ }
5
+ export function pushRejectionIsRetryable(message) {
6
+ if (/stale info|fetch first/i.test(message))
7
+ return false;
8
+ return /commit_refs|remote rejected[^\n]*\(failure\)|HTTP 5\d\d|returned error: 5\d\d|RPC failed|could not resolve host|failed to connect|connection (?:reset|refused|timed out)|operation timed out|early EOF|unexpected disconnect|remote end hung up|SSL_|gnutls/i.test(message);
9
+ }
10
+ export function restackRetryPending(error, layer) {
11
+ if (error.kind !== "push_failed" || restackAttemptsExhausted(error))
12
+ return false;
13
+ if (layer && (layer.state !== "needs_restack" && layer.state !== "restacking" || layer.headSha !== error.headSha))
14
+ return false;
15
+ if (error.detail == null)
16
+ return false;
17
+ return !/\(origin\/\S+ moved to /.test(error.detail) && pushRejectionIsRetryable(error.detail);
18
+ }
1
19
  /** Every bounce kind understood by the shared contract. */
2
20
  export const MERGE_QUEUE_BOUNCE_KINDS = [
3
21
  "ci_failure",
@@ -1,3 +1,4 @@
1
+ import { type StackBlocker } from "./stack-blockers.js";
1
2
  import { apiFetch } from "./api.js";
2
3
  import type { Config } from "./config.js";
3
4
  export declare const STACK_WATCH_SCHEMA: "mergestorm.stack_watch/v1";
@@ -18,6 +19,12 @@ export type StackWatchEnvelope = {
18
19
  prNumber: number | null;
19
20
  headSha: string | null;
20
21
  cursor: StackWatchCursor;
22
+ issues: StackBlocker[];
23
+ currentCandidate: {
24
+ prNumber: number;
25
+ headSha: string | null;
26
+ } | null;
27
+ assessment: "available" | "unavailable";
21
28
  };
22
29
  export type PollStackWatchOptions = {
23
30
  timeoutMs?: number;
@@ -1,7 +1,7 @@
1
+ import { currentLayer, stackBlockers } from "./stack-blockers.js";
1
2
  import { setTimeout as sleep } from "node:timers/promises";
2
3
  import { apiFetch } from "./api.js";
3
4
  import { REVIEW_POLL_INTERVAL_MS, REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, transientRetryWaitMs, } from "./commands/review-client.js";
4
- import { mergeQueueBounceLabel } from "./stack-dto.js";
5
5
  export const STACK_WATCH_SCHEMA = "mergestorm.stack_watch/v1";
6
6
  export const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45_000;
7
7
  export class StackWatchTimeoutError extends Error {
@@ -22,72 +22,6 @@ export class StackWatchError extends Error {
22
22
  this.name = "StackWatchError";
23
23
  }
24
24
  }
25
- function sameHead(a, b) {
26
- const left = a?.trim().toLowerCase();
27
- const right = b?.trim().toLowerCase();
28
- return !!left && !!right && (left.startsWith(right) || right.startsWith(left));
29
- }
30
- function currentLayer(stack) {
31
- const open = stack.layers.filter((layer) => layer.prNumber > 0 && layer.state !== "merged" && layer.state !== "closed");
32
- const promote = open.filter((layer) => layer.prNumber !== stack.unit?.landPrNumber &&
33
- !stack.unit?.members.some((member) => member.prNumber === layer.prNumber && member.promotedHeadSha)).sort((a, b) => a.position - b.position);
34
- const land = stack.unit?.landPr;
35
- return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
36
- (land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
37
- }
38
- /** Keep label text and precedence aligned with MCP stack-summary.blockerLabel. */
39
- function attention(stack, layer, queue, cursor) {
40
- const none = { blocker: null, bounceKind: null };
41
- if (stack.archivedAt || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
42
- return none;
43
- const bounce = queue.filter((entry) => entry.state === "bounced")
44
- .sort((a, b) => (Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0))[0];
45
- const kind = bounce?.bounceDetail?.kind;
46
- const recoverable = kind === "head_moved" || kind === "must_consolidate";
47
- const recoverableSha = kind === "head_moved"
48
- ? bounce?.bounceDetail?.headSha ?? bounce?.verifyHeadSha
49
- : bounce?.verifyHeadSha ?? bounce?.bounceDetail?.headSha;
50
- const currentRecoverable = recoverable && sameHead(layer?.headSha, recoverableSha);
51
- const hardBlock = (blocker) => ({ blocker, bounceKind: null });
52
- if (layer) {
53
- if (layer.restackError && layer.state !== "restacking") {
54
- return hardBlock(layer.restackError.kind === "rebase_conflict" ? "Conflict" : "Restack failed");
55
- }
56
- if (layer.state === "conflict")
57
- return hardBlock("Conflict");
58
- if (layer.draft)
59
- return hardBlock("Draft PR");
60
- if ((!layer.headSha || layer.mergeableHeadSha === layer.headSha) &&
61
- (layer.mergeable === false || layer.mergeableState?.toLowerCase() === "dirty"))
62
- return hardBlock("Merge conflicts");
63
- if (!currentRecoverable && (layer.ciStatus === "failure" || (layer.checks?.failure ?? 0) > 0)) {
64
- const name = layer.checks?.failingName?.trim();
65
- return hardBlock(`CI failed${name ? ` — ${name}` : ""}`);
66
- }
67
- const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
68
- ["findings", "failed"].includes(run.status) && sameHead(run.sha, layer.headSha));
69
- if (tempest)
70
- return hardBlock(`Tempest ${tempest.status}`);
71
- if (layer.vortexStatus === "failed")
72
- return hardBlock("Review failed");
73
- if (stack.unit?.landPrNumber === layer.prNumber) {
74
- if (stack.unit.landingBlockReason?.trim())
75
- return hardBlock(stack.unit.landingBlockReason.trim());
76
- if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
77
- return hardBlock("Tempest failed");
78
- }
79
- }
80
- if (!bounce || !kind || recoverable || !layer)
81
- return none;
82
- if (cursor.bounceId === bounce.id || (cursor.afterFinishedAt &&
83
- !(Date.parse(bounce.finishedAt ?? "") > Date.parse(cursor.afterFinishedAt))))
84
- return none;
85
- // Compare with the observed promote/land head, never the enrollment or verify head.
86
- if (!sameHead(bounce.bounceDetail?.headSha ?? bounce.verifyHeadSha, layer.headSha) ||
87
- (bounce.bounceDetail?.prNumber != null && bounce.bounceDetail.prNumber !== layer.prNumber))
88
- return none;
89
- return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
90
- }
91
25
  /** Wait on queue invalidations, then evaluate a fresh stack snapshot. */
92
26
  export async function pollStackWatch(cfg, stackId, opts = {}) {
93
27
  const id = stackId.trim().toLowerCase();
@@ -110,11 +44,12 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
110
44
  stackId: id,
111
45
  });
112
46
  let lastEnvelope = {
113
- schema: STACK_WATCH_SCHEMA, status: "waiting", stackId: id, blocker: null,
47
+ schema: STACK_WATCH_SCHEMA, status: "failed", stackId: id, blocker: null,
114
48
  bounceKind: null, prNumber: null, headSha: null, cursor,
49
+ issues: [], currentCandidate: null, assessment: "unavailable",
115
50
  };
116
51
  let transientFailures = 0;
117
- const timeout = () => new StackWatchTimeoutError({ ...lastEnvelope, status: "waiting" });
52
+ const timeout = () => new StackWatchTimeoutError(lastEnvelope);
118
53
  const pause = async (ms) => {
119
54
  const remaining = Math.min(ms, Math.max(0, deadline - now()));
120
55
  if (remaining > 0)
@@ -136,39 +71,44 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
136
71
  catch (err) {
137
72
  if (opts.signal?.aborted || (err instanceof Error && err.name === "AbortError"))
138
73
  throw err;
139
- if (now() >= deadline)
74
+ if (now() >= deadline && timeoutMs !== 0)
140
75
  throw timeout();
141
- if (isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
76
+ if (timeoutMs !== 0 && isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
142
77
  transientFailures += 1;
143
78
  opts.onTick?.(lastEnvelope);
144
79
  await pause(transientRetryWaitMs({ failureCount: transientFailures, random: opts.random }));
145
80
  continue;
146
81
  }
147
- throw new StackWatchError(err instanceof Error ? err.message : String(err), { ...lastEnvelope, status: "failed" }, { cause: err });
82
+ throw new StackWatchError(err instanceof Error ? err.message : String(err), { ...lastEnvelope, status: "failed", assessment: "unavailable" }, { cause: err });
148
83
  }
149
84
  if (response.status === 200)
150
85
  return response.body;
151
- if (isTransientReviewPollStatus(response.status) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
86
+ const retryWaitMs = transientRetryWaitMs({
87
+ failureCount: transientFailures + 1, retryAfterSeconds: response.retryAfterSeconds, random: opts.random,
88
+ });
89
+ if (timeoutMs !== 0 && isTransientReviewPollStatus(response.status) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES &&
90
+ (response.status !== 429 || retryWaitMs < deadline - now())) {
152
91
  transientFailures += 1;
153
92
  opts.onTick?.({ ...lastEnvelope, status: response.status === 429 ? "rate_limited" : lastEnvelope.status });
154
- await pause(transientRetryWaitMs({
155
- failureCount: transientFailures, retryAfterSeconds: response.retryAfterSeconds, random: opts.random,
156
- }));
93
+ await pause(retryWaitMs);
157
94
  continue;
158
95
  }
159
96
  throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, {
160
97
  ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
98
+ assessment: "unavailable",
161
99
  }, undefined, response.retryAfterSeconds);
162
100
  }
163
101
  throw timeout();
164
102
  };
165
- while (now() < deadline) {
166
- const snapshot = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`);
103
+ let firstSnapshot = true;
104
+ while (firstSnapshot || now() < deadline) {
105
+ firstSnapshot = false;
106
+ const snapshot = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`, timeoutMs === 0);
167
107
  let stack = snapshot && Array.isArray(snapshot.stacks)
168
108
  ? snapshot.stacks.find((stack) => stack.id.trim().toLowerCase() === id)
169
109
  : undefined;
170
110
  if (!stack || !Array.isArray(stack.layers)) {
171
- throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed" });
111
+ throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
172
112
  }
173
113
  let layer = currentLayer(stack);
174
114
  if (!enrolled) {
@@ -176,27 +116,30 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
176
116
  enrolled = true;
177
117
  }
178
118
  lastEnvelope = { ...lastEnvelope, cursor, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
179
- const initialQueue = await request(`/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`);
119
+ const initialQueue = await request(`/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}`, timeoutMs === 0);
180
120
  if (!initialQueue || !Array.isArray(initialQueue.entries)) {
181
- throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed" });
121
+ throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
182
122
  }
183
123
  const initialEntries = initialQueue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id);
184
- const initial = attention(stack, layer, initialEntries, cursor);
185
- if (initial.blocker) {
186
- const { bounce, ...result } = initial;
187
- if (bounce) {
188
- cursor = Object.freeze({
189
- ...cursor,
190
- bounceId: bounce.id,
191
- ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}),
192
- });
193
- }
194
- return { ...lastEnvelope, ...result, cursor, status: "attention" };
195
- }
124
+ const evaluate = (stack, entries) => {
125
+ const { attention, issues, currentCandidate, bounce } = stackBlockers(stack, entries, cursor);
126
+ if (bounce)
127
+ cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
128
+ ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
129
+ lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available",
130
+ blocker: attention?.blocker ?? null, bounceKind: attention?.bounceKind ?? null,
131
+ prNumber: attention?.prNumber ?? currentCandidate?.prNumber ?? null,
132
+ headSha: attention ? attention.headSha : currentCandidate?.headSha ?? null,
133
+ status: attention ? "attention" : entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
134
+ ? "in_progress" : "waiting" };
135
+ };
136
+ evaluate(stack, initialEntries);
137
+ if (lastEnvelope.status === "attention" || timeoutMs === 0)
138
+ return lastEnvelope;
196
139
  const seconds = Math.min(45, Math.max(1, Math.ceil((deadline - now()) / 1000)));
197
140
  const queue = await request(`/api/v1/stacks/queue?stackId=${encodeURIComponent(id)}&wait=${seconds}`);
198
141
  if (!queue || !Array.isArray(queue.entries)) {
199
- throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed" });
142
+ throw new StackWatchError("Invalid merge queue response", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
200
143
  }
201
144
  if (now() < deadline) {
202
145
  const refreshed = await request(`/api/v1/stacks/enrich?stackId=${encodeURIComponent(id)}`, true);
@@ -204,26 +147,14 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
204
147
  ? refreshed.stacks.find((candidate) => candidate.id.trim().toLowerCase() === id)
205
148
  : undefined;
206
149
  if (!stack || !Array.isArray(stack.layers)) {
207
- throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed" });
150
+ throw new StackWatchError("Stack snapshot missing or invalid", { ...lastEnvelope, status: "failed", assessment: "unavailable" });
208
151
  }
209
152
  layer = currentLayer(stack);
210
153
  lastEnvelope = { ...lastEnvelope, prNumber: layer?.prNumber ?? null, headSha: layer?.headSha ?? null };
211
154
  }
212
155
  transientFailures = 0;
213
156
  const entries = queue.entries.filter((entry) => entry.stackId.trim().toLowerCase() === id);
214
- const { bounce, ...result } = attention(stack, layer, entries, cursor);
215
- if (bounce) {
216
- cursor = Object.freeze({
217
- ...cursor,
218
- bounceId: bounce.id,
219
- ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}),
220
- });
221
- }
222
- lastEnvelope = {
223
- ...lastEnvelope, ...result, cursor,
224
- status: result.blocker ? "attention" : entries.some((entry) => ["queued", "running", "waiting"].includes(entry.state))
225
- ? "in_progress" : "waiting",
226
- };
157
+ evaluate(stack, entries);
227
158
  if (lastEnvelope.status === "attention")
228
159
  return lastEnvelope;
229
160
  opts.onTick?.(lastEnvelope);
package/dist/ui/banner.js CHANGED
@@ -93,6 +93,9 @@ const WHATS_NEW = {
93
93
  "0.3.19": [
94
94
  "stack wait and held GET waits",
95
95
  ],
96
+ "0.3.20": [
97
+ "stack watch pair-gate attention and upstack issues",
98
+ ],
96
99
  };
97
100
  function densityFromVariant(variant) {
98
101
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.19",
3
+ "version": "0.3.20",
4
4
  "description": "Mergestorm CLI (mg) — local reviews & stacked PRs. MIT; build from source: https://github.com/marginsystems/mergestorm-cli",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,11 +16,12 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
16
16
  ## Watch and patch
17
17
 
18
18
  1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). The shipped tool accepts these selectors, not a nested cursor argument.
19
- 2. Keep the same cursor across retries. Timeout `waiting` is not failure — call `stack_wait` again in 45s slices with the same cursor. Do the same for `in_progress`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, stop and report the reason.
20
- 3. On `attention`, inspect the current `blocker`, `prNumber`, `headSha`, and `bounceKind`. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing.
21
- 4. Fix the current blocker with the smallest patch on that PR's head branch, and run the relevant checks. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention.
22
- 5. Recheck Auto land and the remote PR head immediately before pushing. Commit the patch and push the PR head with an ordinary push. If the remote head moved or the push is rejected because it moved, stop. Do not rebase and retry or force-push.
23
- 6. After your successful push, start a new watch cycle for that named stack with `enrolled_head_sha` set to the SHA you pushed. If you handled a bounce, retain its `finishedAt` as `after_finished_at` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue or re-arm anything. If further progress requires re-arming, stop and tell the human.
19
+ 2. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, stop and report the reason.
20
+ 3. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy.
21
+ 4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch.
22
+ 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention.
23
+ 6. Recheck Auto land and the remote PR head immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
24
+ 7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed. If you handled a bounce, retain its `finishedAt` as `after_finished_at` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue or re-arm anything. If further progress requires re-arming, stop and tell the human.
24
25
 
25
26
  ## Bounce kinds
26
27
 
@@ -41,10 +42,10 @@ A kind is context, not permission to mutate queue or stack policy. Diagnose the
41
42
 
42
43
  ## Never
43
44
 
44
- - Do not land, merge, enqueue, run `mg queue add`, `stack land`, or `land-unit`, or call `queue_enqueue` (not a shipped tool).
45
+ - Do not land, merge PRs, enqueue, run `mg queue add`, `stack land`, or `land-unit`, or call `queue_enqueue` (not a shipped tool).
45
46
  - Do not call `settings_set`, change account auto-patch settings, or turn account auto-patch off.
46
47
  - Do not set `auto_patch: true` on a stack.
47
48
  - Do not replace Cyclone or change stack auto-patch policy to take over its work.
48
49
  - Do not auto re-arm Auto land or a bounced queue entry.
49
- - Do not force-push (including force-with-lease), or rebase and retry after the remote head moved.
50
- - Do not push any branch other than the current blocker's PR head.
50
+ - Do not force-push except the same-live-parent rebase with the explicit starting-SHA lease above. Never rebase and retry after the remote head moved.
51
+ - Do not push any branch other than the named blocker's PR head (or the one selected live CI issue after the gate is clean).
@@ -56,7 +56,8 @@ mg skill install --claude --cursor --agents
56
56
 
57
57
  Map these to `mergestorm.pr_review/v1` envelope fields; do not invent statuses.
58
58
 
59
- - `finding_count` is 0 (empty inline and offDiff): stop. A skipped GitHub review body (account Skip All-clear) is still a completed pass — do not wait for an Approve / All-clear comment. Tell the human the PR is clear and that Auto land owns landing. Never merge it yourself.
59
+ - `finding_count` is 0 and (the pass verdict is `comment` or a finding title in `findings.inline` or `findings.offDiff` is Decision required): stop. Surface the pass, and quote that note when it is present. Do not patch.
60
+ - `finding_count` is 0 and the pass verdict is `approve`: stop. A skipped GitHub review body (account Skip All-clear) is still a completed pass — do not wait for an Approve / All-clear comment. Tell the human the PR is clear and that Auto land owns landing. Never merge it yourself.
60
61
  - status is `rate_limited`, or `patch_policy.mode` is `"hold"`: surface the findings to the human and stop. Do not patch.
61
62
  - status is `failed`: report to the human. Do not keep pushing to retrigger.
62
63
  - status is `in_progress` and the envelope has a `head_sha`: the pass is still running; call `review_wait_pr` again with the same `after_sha` and the same `after_pass`.