mergestorm 0.3.19 → 0.3.21
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 +3 -3
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/commands/registry.js +1 -1
- package/dist/commands/stack.d.ts +4 -2
- package/dist/commands/stack.js +57 -37
- package/dist/gh.d.ts +69 -7
- package/dist/gh.js +236 -35
- package/dist/stack-blockers.d.ts +25 -0
- package/dist/stack-blockers.js +120 -0
- package/dist/stack-dto.d.ts +12 -1
- package/dist/stack-dto.js +18 -0
- package/dist/stack-watch.d.ts +7 -0
- package/dist/stack-watch.js +39 -108
- package/dist/ui/banner.js +6 -0
- package/package.json +1 -1
- package/skill/mergestorm-bounce-watch/SKILL.md +9 -8
- package/skill/mergestorm-pr-loop/SKILL.md +2 -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.
|
|
48
|
+
│ ▟██▛ mergestorm v0.3.21 │
|
|
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.
|
|
53
|
+
│ New in v0.3.21: stack wait holds an unchanged queue instead of spinning│
|
|
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>
|
|
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
|
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/client/index.js
CHANGED
|
@@ -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",
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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?: {
|
package/dist/commands/stack.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4
|
-
|
|
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
|
|
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
|
-
]
|
|
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(
|
|
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
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
input.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
input.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -46,7 +57,7 @@ export type StackVortexReviewView = {
|
|
|
46
57
|
/** Which table spoke: the authority row, a live job, a lease, or nothing. */
|
|
47
58
|
source: "pr_reviews" | "github_webhook_jobs" | "lease" | "none";
|
|
48
59
|
};
|
|
49
|
-
export type StackCycloneStatus = "patching" | "awaiting_fix";
|
|
60
|
+
export type StackCycloneStatus = "patching" | "awaiting_fix" | "failed";
|
|
50
61
|
export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
|
|
51
62
|
export type StackAgentName = "vortex" | "cyclone" | "tempest";
|
|
52
63
|
/**
|
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",
|
package/dist/stack-watch.d.ts
CHANGED
|
@@ -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;
|
package/dist/stack-watch.js
CHANGED
|
@@ -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: "
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
166
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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,12 @@ 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
|
+
],
|
|
99
|
+
"0.3.21": [
|
|
100
|
+
"stack wait holds an unchanged queue instead of spinning",
|
|
101
|
+
],
|
|
96
102
|
};
|
|
97
103
|
function densityFromVariant(variant) {
|
|
98
104
|
if (variant === "mini" || variant === "nano")
|
package/package.json
CHANGED
|
@@ -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.
|
|
20
|
-
3. On `attention`,
|
|
21
|
-
4. Fix the current blocker with the smallest patch
|
|
22
|
-
5.
|
|
23
|
-
6.
|
|
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
|
|
50
|
-
- Do not push any branch other than the
|
|
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
|
|
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`.
|