mergestorm 0.3.18 → 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/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
  *
@@ -21,6 +32,31 @@ export type StackReviewStatus = "approved" | "changes_requested" | "reviewed" |
21
32
  * (#1947): Work paints Incomplete, never Reviewed or all clear.
22
33
  */
23
34
  export type StackVortexStatus = "queued" | "skipped" | "reviewing" | "seam_pending" | "all_clear" | "findings" | "throttled" | "failed" | "incomplete";
35
+ /**
36
+ * MS-14 viewer: the one lifecycle status of the Vortex review on a PR head.
37
+ * `pr_reviews` is the authority when a row exists for the SHA; a leftover
38
+ * `github_webhook_jobs` row paints `queued` / `reviewing` only when no pass row
39
+ * exists yet. `none` = never dispatched. `done` = a pass posted; the finer
40
+ * verdict detail lives on `vortexStatus` / `agentRuns`.
41
+ */
42
+ export type StackVortexReviewViewStatus = "none" | "queued" | "reviewing" | "skipped" | "done" | "failed";
43
+ export type StackVortexReviewView = {
44
+ status: StackVortexReviewViewStatus;
45
+ /** `pr_reviews.skip_reason` for `skipped`; null when the writer recorded none. */
46
+ skip_reason: string | null;
47
+ /** SHA + pass identity of the attempt (#2027); null before a pass row exists. */
48
+ pass: number | null;
49
+ /** Head the status speaks for; null when neither table named one. */
50
+ head_sha: string | null;
51
+ /** In-flight sub-state while `reviewing` (fanout / synthesizing). */
52
+ phase: string | null;
53
+ /** Earliest live job start while `reviewing`; null when lease-only. */
54
+ started_at: string | null;
55
+ /** Stop control visibility: true only while a pass can actually be cancelled. */
56
+ stoppable: boolean;
57
+ /** Which table spoke: the authority row, a live job, a lease, or nothing. */
58
+ source: "pr_reviews" | "github_webhook_jobs" | "lease" | "none";
59
+ };
24
60
  export type StackCycloneStatus = "patching" | "awaiting_fix";
25
61
  export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
26
62
  export type StackAgentName = "vortex" | "cyclone" | "tempest";
@@ -140,6 +176,12 @@ export type StackLayerDto = {
140
176
  * Undefined = producer predates runs (clients fall back to the rollup).
141
177
  */
142
178
  agentRuns?: StackAgentRun[];
179
+ /**
180
+ * MS-14 viewer output for this layer's head. Every surface renders the
181
+ * lifecycle status from here; `vortexStatus` / `agentRuns` only add verdict
182
+ * detail once it says `done`. Undefined = producer predates the viewer.
183
+ */
184
+ vortexReview?: StackVortexReviewView;
143
185
  conflictDetail: string | null;
144
186
  restackError?: RestackError | null;
145
187
  lastRestackedSha: string | null;
@@ -249,6 +291,21 @@ export type StackDto = {
249
291
  * Missing on older payloads is off.
250
292
  */
251
293
  autoEnqueueWhenReady?: boolean;
294
+ /**
295
+ * Auto land Settling certificate. The stacks watcher publishes the identity
296
+ * of the settle clock it actually started (action, exact PR and head, server
297
+ * start time) and clears it when that clock is consumed or invalidated.
298
+ * Absent (older payloads) or `null` means no certified settle: paint nothing.
299
+ * Remaining time derives from `startedAt`; a refresh never restarts it.
300
+ * Partial or invalid server rows map to `null`, never to a guessed value.
301
+ */
302
+ autoEnqueueSettle?: {
303
+ action: "ready" | "promote";
304
+ prNumber: number;
305
+ headSha: string;
306
+ /** ISO timestamp of the watcher's clock start. */
307
+ startedAt: string;
308
+ } | null;
252
309
  /**
253
310
  * Per-stack Vortex auto-review override. `null` (or missing) follows the
254
311
  * account `auto_review_enabled` flag; a boolean wins in both directions.
@@ -286,7 +343,11 @@ export type StackDto = {
286
343
  export type MergeQueueEntryState = "queued" | "running" | "waiting" | "landed" | "bounced" | "cancelled";
287
344
  export type MergeQueueEnqueuedBy = "human" | "agent";
288
345
  export type MergeQueueEnqueuedVia = "dashboard" | "cli" | "chat" | "api" | "mcp";
289
- export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "tempest_findings" | "seam_findings" | "restack_conflict" | "pr_draft" | "merge_failed" | "gh_error";
346
+ export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "tempest_findings" | "seam_findings" | "restack_conflict" | "pr_draft" | "must_consolidate" | "merge_failed" | "gh_error";
347
+ /** Every bounce kind understood by the shared contract. */
348
+ export declare const MERGE_QUEUE_BOUNCE_KINDS: readonly ["ci_failure", "ci_timeout", "head_moved", "tempest_findings", "seam_findings", "restack_conflict", "pr_draft", "must_consolidate", "merge_failed", "gh_error"];
349
+ export declare const BOUNCE_KIND_LABELS: Readonly<Record<MergeQueueBounceKind, string>>;
350
+ export declare function mergeQueueBounceLabel(entry: MergeQueueEntryDto): string;
290
351
  /** Structured bounce handoff — enough for the bar chip and the chat quote. */
291
352
  export type MergeQueueBounceDetail = {
292
353
  kind: MergeQueueBounceKind;
package/dist/stack-dto.js CHANGED
@@ -1 +1,55 @@
1
- export {};
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
+ }
19
+ /** Every bounce kind understood by the shared contract. */
20
+ export const MERGE_QUEUE_BOUNCE_KINDS = [
21
+ "ci_failure",
22
+ "ci_timeout",
23
+ "head_moved",
24
+ "tempest_findings",
25
+ "seam_findings",
26
+ "restack_conflict",
27
+ "pr_draft",
28
+ "must_consolidate",
29
+ "merge_failed",
30
+ "gh_error",
31
+ ];
32
+ export const BOUNCE_KIND_LABELS = {
33
+ ci_failure: "CI failed",
34
+ ci_timeout: "CI timed out",
35
+ head_moved: "head moved",
36
+ tempest_findings: "Tempest findings",
37
+ seam_findings: "seam review findings",
38
+ restack_conflict: "restack conflict",
39
+ pr_draft: "draft PR",
40
+ must_consolidate: "needs promote",
41
+ merge_failed: "merge failed",
42
+ gh_error: "GitHub error",
43
+ };
44
+ export function mergeQueueBounceLabel(entry) {
45
+ const detail = entry.bounceDetail;
46
+ const kindLabel = detail ? BOUNCE_KIND_LABELS[detail.kind] : undefined;
47
+ if (kindLabel) {
48
+ const specifics = detail?.failingCheck ??
49
+ detail?.conflictBranch ??
50
+ detail?.message ??
51
+ (detail?.prNumber != null ? `PR #${detail.prNumber}` : null);
52
+ return specifics ? `${kindLabel} — ${specifics}` : kindLabel;
53
+ }
54
+ return entry.bounceReason?.trim() || "bounced";
55
+ }
@@ -0,0 +1,51 @@
1
+ import { type StackBlocker } from "./stack-blockers.js";
2
+ import { apiFetch } from "./api.js";
3
+ import type { Config } from "./config.js";
4
+ export declare const STACK_WATCH_SCHEMA: "mergestorm.stack_watch/v1";
5
+ export declare const STACK_WATCH_DEFAULT_TIMEOUT_MS = 45000;
6
+ /** Resume selectors are fixed for the entire wait, including transient retries. */
7
+ export type StackWatchCursor = {
8
+ readonly stackId: string;
9
+ readonly enrolledHeadSha: string | null;
10
+ readonly afterFinishedAt?: string | null;
11
+ readonly bounceId?: string | null;
12
+ };
13
+ export type StackWatchEnvelope = {
14
+ schema: typeof STACK_WATCH_SCHEMA;
15
+ status: "attention" | "waiting" | "in_progress" | "rate_limited" | "failed";
16
+ stackId: string;
17
+ blocker: string | null;
18
+ bounceKind: string | null;
19
+ prNumber: number | null;
20
+ headSha: string | null;
21
+ cursor: StackWatchCursor;
22
+ issues: StackBlocker[];
23
+ currentCandidate: {
24
+ prNumber: number;
25
+ headSha: string | null;
26
+ } | null;
27
+ assessment: "available" | "unavailable";
28
+ };
29
+ export type PollStackWatchOptions = {
30
+ timeoutMs?: number;
31
+ intervalMs?: number;
32
+ cursor?: StackWatchCursor;
33
+ signal?: AbortSignal;
34
+ onTick?: (envelope: StackWatchEnvelope) => void;
35
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
36
+ now?: () => number;
37
+ random?: () => number;
38
+ /** Test seam at the apiFetch boundary, including HTTP status and Retry-After. */
39
+ fetch?: typeof apiFetch;
40
+ };
41
+ export declare class StackWatchTimeoutError extends Error {
42
+ readonly lastEnvelope: StackWatchEnvelope;
43
+ constructor(lastEnvelope: StackWatchEnvelope);
44
+ }
45
+ export declare class StackWatchError extends Error {
46
+ readonly lastEnvelope: StackWatchEnvelope;
47
+ readonly retryAfterSeconds?: number | undefined;
48
+ constructor(message: string, lastEnvelope: StackWatchEnvelope, options?: ErrorOptions, retryAfterSeconds?: number | undefined);
49
+ }
50
+ /** Wait on queue invalidations, then evaluate a fresh stack snapshot. */
51
+ export declare function pollStackWatch(cfg: Config, stackId: string, opts?: PollStackWatchOptions): Promise<StackWatchEnvelope>;