merge-steward 0.24.0 → 0.26.0

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/db/schema.js CHANGED
@@ -76,6 +76,11 @@ export function ensureSchema(connection) {
76
76
  ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
77
77
  ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
78
78
  ensureColumn(connection, "queue_entries", "pr_title", "TEXT");
79
+ // Plan §8.4: stack-aware admission ordering needs the PR's base ref.
80
+ ensureColumn(connection, "queue_entries", "base_ref_name", "TEXT");
81
+ // Plan §5.3: cached identity for patch-id-aware updateHead.
82
+ ensureColumn(connection, "queue_entries", "head_patch_id", "TEXT");
83
+ ensureColumn(connection, "queue_entries", "spec_tree_id", "TEXT");
79
84
  connection.exec(`
80
85
  UPDATE queue_entries
81
86
  SET post_merge_status = 'pending'
@@ -12,9 +12,17 @@ export declare class SqliteStore implements QueueStore {
12
12
  listActive(repoId: string): QueueEntry[];
13
13
  listAll(repoId: string): QueueEntry[];
14
14
  insert(entry: QueueEntry): void;
15
- transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
15
+ transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
16
16
  dequeue(entryId: string): void;
17
17
  updateHead(entryId: string, newHeadSha: string): void;
18
+ rebuildSpecHeadEquivalent(entryId: string, patch: {
19
+ headSha: string;
20
+ specSha: string;
21
+ specBranch: string;
22
+ headPatchId: string;
23
+ specTreeId: string;
24
+ ciRunId: string | null;
25
+ }, detail?: string): void;
18
26
  updatePriority(entryId: string, priority: number, detail?: string): void;
19
27
  insertIncident(incident: IncidentRecord): void;
20
28
  listIncidents(entryId: string): IncidentRecord[];
@@ -30,6 +30,9 @@ function mapEntry(row) {
30
30
  postMergeSummary: row.post_merge_summary === null || row.post_merge_summary === undefined ? null : String(row.post_merge_summary),
31
31
  postMergeCheckedAt: row.post_merge_checked_at === null || row.post_merge_checked_at === undefined ? null : String(row.post_merge_checked_at),
32
32
  prTitle: row.pr_title === null || row.pr_title === undefined ? null : String(row.pr_title),
33
+ baseRefName: row.base_ref_name === null || row.base_ref_name === undefined ? null : String(row.base_ref_name),
34
+ headPatchId: row.head_patch_id === null || row.head_patch_id === undefined ? null : String(row.head_patch_id),
35
+ specTreeId: row.spec_tree_id === null || row.spec_tree_id === undefined ? null : String(row.spec_tree_id),
33
36
  enqueuedAt: String(row.enqueued_at),
34
37
  updatedAt: String(row.updated_at),
35
38
  };
@@ -126,9 +129,9 @@ export class SqliteStore {
126
129
  priority, generation, ci_run_id, ci_retries, retry_attempts,
127
130
  max_retries, last_failed_base_sha, issue_key, wait_detail,
128
131
  post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
129
- pr_title,
132
+ pr_title, base_ref_name,
130
133
  enqueued_at, updated_at)
131
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.enqueuedAt, entry.updatedAt);
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.baseRefName ?? null, entry.enqueuedAt, entry.updatedAt);
132
135
  this.writeEvent(entry.id, null, entry.status);
133
136
  })();
134
137
  }
@@ -199,6 +202,14 @@ export class SqliteStore {
199
202
  sets.push("post_merge_checked_at = ?");
200
203
  values.push(patch.postMergeCheckedAt);
201
204
  }
205
+ if (patch?.headPatchId !== undefined) {
206
+ sets.push("head_patch_id = ?");
207
+ values.push(patch.headPatchId);
208
+ }
209
+ if (patch?.specTreeId !== undefined) {
210
+ sets.push("spec_tree_id = ?");
211
+ values.push(patch.specTreeId);
212
+ }
202
213
  this.conn.prepare(`UPDATE queue_entries SET ${sets.join(", ")} WHERE id = ?`).run(...values, entryId);
203
214
  this.writeEvent(entryId, from, to, detail);
204
215
  })();
@@ -222,11 +233,34 @@ export class SqliteStore {
222
233
  spec_branch = NULL, spec_sha = NULL, spec_based_on = NULL,
223
234
  wait_detail = NULL,
224
235
  post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
236
+ head_patch_id = NULL, spec_tree_id = NULL,
225
237
  updated_at = ?
226
238
  WHERE id = ?`).run(newHeadSha, newGen, isoNow(), entryId);
227
239
  this.writeEvent(entryId, from, "queued", `updateHead: generation ${newGen}`);
228
240
  })();
229
241
  }
242
+ rebuildSpecHeadEquivalent(entryId, patch, detail) {
243
+ this.conn.transaction(() => {
244
+ const current = this.conn.prepare("SELECT status, generation FROM queue_entries WHERE id = ?").get(entryId);
245
+ if (!current)
246
+ return;
247
+ const from = String(current.status);
248
+ if (TERMINAL_STATUSES.includes(from))
249
+ return;
250
+ const newGen = Number(current.generation) + 1;
251
+ this.conn.prepare(`UPDATE queue_entries SET
252
+ head_sha = ?, status = 'validating', generation = ?,
253
+ ci_run_id = ?, ci_retries = 0, retry_attempts = 0,
254
+ last_failed_base_sha = NULL,
255
+ spec_branch = ?, spec_sha = ?,
256
+ head_patch_id = ?, spec_tree_id = ?,
257
+ wait_detail = NULL,
258
+ post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
259
+ updated_at = ?
260
+ WHERE id = ?`).run(patch.headSha, newGen, patch.ciRunId, patch.specBranch, patch.specSha, patch.headPatchId, patch.specTreeId, isoNow(), entryId);
261
+ this.writeEvent(entryId, from, "validating", detail ?? `patch-id-equivalent rebuild: generation ${newGen}`);
262
+ })();
263
+ }
230
264
  updatePriority(entryId, priority, detail) {
231
265
  this.conn.transaction(() => {
232
266
  const current = this.conn.prepare("SELECT status, priority FROM queue_entries WHERE id = ?").get(entryId);
@@ -24,7 +24,9 @@ export class GitHubPRClient {
24
24
  const result = await exec("gh", [
25
25
  "pr", "view", String(prNumber),
26
26
  "--repo", this.repoFullName,
27
- "--json", "number,title,headRefName,headRefOid,reviewDecision,state,mergeStateStatus",
27
+ // Plan §8.4 change 3a: include baseRefName so the queue can
28
+ // detect stacked PRs at admission time.
29
+ "--json", "number,title,headRefName,headRefOid,baseRefName,reviewDecision,state,mergeStateStatus",
28
30
  ], { githubRepoFullName: this.repoFullName });
29
31
  const data = JSON.parse(result.stdout);
30
32
  return {
@@ -32,6 +34,7 @@ export class GitHubPRClient {
32
34
  branch: data.headRefName,
33
35
  headSha: data.headRefOid,
34
36
  ...(data.title ? { title: data.title } : {}),
37
+ ...(data.baseRefName ? { baseRefName: data.baseRefName } : {}),
35
38
  mergeable: data.state === "OPEN",
36
39
  mergeStateStatus: data.mergeStateStatus,
37
40
  reviewDecision: data.reviewDecision,
@@ -25,6 +25,11 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
25
25
  headSha(branch: string): Promise<string>;
26
26
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
27
27
  push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
28
+ patchIdAgainst(base: string, headSha: string): Promise<string | undefined>;
29
+ integrationTreeId(base: string, headSha: string): Promise<string | undefined>;
30
+ treeId(commitSha: string): Promise<string | undefined>;
31
+ commitTree(tree: string, parents: string[], message: string): Promise<string | undefined>;
32
+ pushCommit(commitSha: string, branch: string): Promise<void>;
28
33
  /**
29
34
  * Build a speculative merge branch using an isolated git worktree.
30
35
  * Each call gets its own working directory — no shared mutable state.
@@ -1,6 +1,10 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { exec } from "../exec.js";
4
+ /** Shell-quote a string for sh -c invocation (single-quote, escape inner quotes). */
5
+ function shellQuote(value) {
6
+ return `'${value.replaceAll("'", "'\\''")}'`;
7
+ }
4
8
  /** Extract conflict file names from git merge stderr. */
5
9
  function parseConflicts(stderr) {
6
10
  const files = stderr
@@ -90,6 +94,64 @@ export class ShellGitOperations {
90
94
  args.push("origin", refspec);
91
95
  await this.git(args, { timeoutMs: 60_000 });
92
96
  }
97
+ // Plan §5.3: identity primitives for patch-id-aware updateHead.
98
+ async patchIdAgainst(base, headSha) {
99
+ // `git diff base..head | git patch-id --stable` — invoke as one
100
+ // pipeline through a shell so we don't have to plumb stdin/stdout.
101
+ const result = await exec("sh", ["-c", `${this.gitBin} -C ${shellQuote(this.clonePath)} diff ${shellQuote(base)}..${shellQuote(headSha)} | ${this.gitBin} patch-id --stable`], {
102
+ timeoutMs: 30_000,
103
+ allowNonZero: true,
104
+ githubRepoFullName: this.repoFullName,
105
+ });
106
+ if (result.exitCode !== 0)
107
+ return undefined;
108
+ const first = result.stdout.split(/\s+/, 1)[0]?.trim();
109
+ return first ? first : undefined;
110
+ }
111
+ async integrationTreeId(base, headSha) {
112
+ const result = await this.git(["merge-tree", "--write-tree", "--no-messages", base, headSha], { allowNonZero: true, timeoutMs: 60_000 });
113
+ if (result.exitCode !== 0)
114
+ return undefined;
115
+ const tree = result.stdout.trim().split(/\s+/, 1)[0];
116
+ return tree || undefined;
117
+ }
118
+ async treeId(commitSha) {
119
+ const result = await this.git(["rev-parse", `${commitSha}^{tree}`], { allowNonZero: true });
120
+ if (result.exitCode !== 0)
121
+ return undefined;
122
+ const tree = result.stdout.trim();
123
+ return tree || undefined;
124
+ }
125
+ async commitTree(tree, parents, message) {
126
+ const args = ["commit-tree", tree];
127
+ for (const parent of parents) {
128
+ args.push("-p", parent);
129
+ }
130
+ args.push("-m", message);
131
+ const env = {};
132
+ if (this.botIdentity) {
133
+ env.GIT_AUTHOR_NAME = this.botIdentity.name;
134
+ env.GIT_AUTHOR_EMAIL = this.botIdentity.email;
135
+ env.GIT_COMMITTER_NAME = this.botIdentity.name;
136
+ env.GIT_COMMITTER_EMAIL = this.botIdentity.email;
137
+ }
138
+ const result = await exec(this.gitBin, ["-C", this.clonePath, ...args], {
139
+ timeoutMs: 30_000,
140
+ allowNonZero: true,
141
+ githubRepoFullName: this.repoFullName,
142
+ ...(Object.keys(env).length > 0 ? { env } : {}),
143
+ });
144
+ if (result.exitCode !== 0)
145
+ return undefined;
146
+ const sha = result.stdout.trim();
147
+ return sha || undefined;
148
+ }
149
+ async pushCommit(commitSha, branch) {
150
+ // Force-update the named branch on origin to a known commit
151
+ // SHA. Used by the patch-id-aware rebuild path which writes a
152
+ // synthetic commit-tree result without checking it out.
153
+ await this.git(["push", "--force", "origin", `${commitSha}:refs/heads/${branch}`], { timeoutMs: 60_000 });
154
+ }
93
155
  // ─── SpeculativeBranchBuilder ───────────────────────────────
94
156
  /**
95
157
  * Build a speculative merge branch using an isolated git worktree.
@@ -8,6 +8,16 @@ export interface GitOperations {
8
8
  headSha(branch: string): Promise<string>;
9
9
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
10
10
  push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
11
+ /** Stable patch-id of head's diff against base. Returns undefined on git error. */
12
+ patchIdAgainst?(base: string, headSha: string): Promise<string | undefined>;
13
+ /** `git merge-tree --write-tree base headSha` — returns tree-id, or undefined on conflict/error. */
14
+ integrationTreeId?(base: string, headSha: string): Promise<string | undefined>;
15
+ /** Tree id of a commit (`commit^{tree}`). */
16
+ treeId?(commitSha: string): Promise<string | undefined>;
17
+ /** `git commit-tree tree -p p1 -p p2 -m message`. Returns the new commit SHA. */
18
+ commitTree?(tree: string, parents: string[], message: string): Promise<string | undefined>;
19
+ /** Force-push a known commit SHA to a branch (overrides the target ref directly). */
20
+ pushCommit?(commitSha: string, branch: string): Promise<void>;
11
21
  }
12
22
  /**
13
23
  * Builds and manages speculative cumulative branches.
@@ -41,6 +41,18 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
41
41
  const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
42
42
  if (currentRef !== entry.headSha) {
43
43
  emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
44
+ // Plan §5.3: patch-id-aware short-circuit. If the new head is
45
+ // patch-id-equivalent to the prior head AND the freshly merged
46
+ // tree matches the cached spec tree, rebuild the spec commit
47
+ // with the new head as its second parent so GitHub still marks
48
+ // the PR merged after the lander's fast-forward — but skip the
49
+ // prepare cycle. CI must re-run on the new spec SHA (check_runs
50
+ // are SHA-anchored). Falls back to the standard `updateHead`
51
+ // path if the git plumbing is unavailable or if the identities
52
+ // don't match.
53
+ const shortCircuited = await tryPatchIdEquivalentRebuild(ctx, entry, base, currentRef);
54
+ if (shortCircuited)
55
+ return;
44
56
  ctx.store.updateHead(entry.id, currentRef);
45
57
  return;
46
58
  }
@@ -170,6 +182,16 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
170
182
  }
171
183
  const runId = await ctx.ci.triggerRun(specName, specSha);
172
184
  emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
185
+ // Plan §5.3: cache identity for the patch-id-aware short-circuit
186
+ // on subsequent updateHead detections. Best-effort — if the git
187
+ // primitives are missing the cached identity stays null and the
188
+ // short-circuit never fires (existing behavior).
189
+ const headPatchId = ctx.git.patchIdAgainst
190
+ ? await ctx.git.patchIdAgainst(base, entry.headSha).catch(() => undefined)
191
+ : undefined;
192
+ const specTreeId = ctx.git.treeId
193
+ ? await ctx.git.treeId(specSha).catch(() => undefined)
194
+ : undefined;
173
195
  ctx.store.transition(entry.id, "validating", {
174
196
  baseSha,
175
197
  ciRunId: runId,
@@ -177,5 +199,84 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
177
199
  specBranch: specName,
178
200
  specSha,
179
201
  specBasedOn: isHead ? null : prevEntry.id,
202
+ ...(headPatchId !== undefined ? { headPatchId } : {}),
203
+ ...(specTreeId !== undefined ? { specTreeId } : {}),
180
204
  }, `spec ready, CI ${runId.slice(0, 12)}`);
181
205
  }
206
+ async function tryPatchIdEquivalentRebuild(ctx, entry, base, newHead) {
207
+ // Need the cached identity *and* the git primitives required to
208
+ // verify the equivalence and rebuild the spec commit. If any
209
+ // piece is missing, fall back.
210
+ if (!entry.headPatchId || !entry.specTreeId || !entry.specBranch || !entry.specSha) {
211
+ return false;
212
+ }
213
+ const git = ctx.git;
214
+ if (!git.patchIdAgainst || !git.integrationTreeId || !git.commitTree || !git.pushCommit) {
215
+ return false;
216
+ }
217
+ let newPatchId;
218
+ let newTreeId;
219
+ let baseSha;
220
+ try {
221
+ [newPatchId, newTreeId, baseSha] = await Promise.all([
222
+ git.patchIdAgainst(base, newHead),
223
+ git.integrationTreeId(base, newHead),
224
+ git.headSha(base),
225
+ ]);
226
+ }
227
+ catch {
228
+ return false;
229
+ }
230
+ if (!newPatchId || !newTreeId || !baseSha)
231
+ return false;
232
+ if (newPatchId !== entry.headPatchId)
233
+ return false;
234
+ if (newTreeId !== entry.specTreeId)
235
+ return false;
236
+ // Tree-equivalent. Rebuild spec commit with the new head as the
237
+ // second parent so post-merge PR detection still works.
238
+ const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
239
+ const message = `Merge PR #${entry.prNumber}: ${branchSuffix} (rebased)`;
240
+ let newSpecSha;
241
+ try {
242
+ newSpecSha = await git.commitTree(entry.specTreeId, [baseSha, newHead], message);
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ if (!newSpecSha)
248
+ return false;
249
+ try {
250
+ await git.pushCommit(newSpecSha, entry.specBranch);
251
+ }
252
+ catch {
253
+ return false;
254
+ }
255
+ emit(ctx, entry, "spec_build_succeeded", {
256
+ specBranch: entry.specBranch,
257
+ detail: "patch-id-equivalent rebuild",
258
+ });
259
+ // Plan §5.2: re-announce spec availability on the new head.
260
+ if (ctx.eviction.reportSpecReady) {
261
+ await ctx.eviction.reportSpecReady(entry, entry.specBranch, newSpecSha).catch(() => undefined);
262
+ }
263
+ let runId = null;
264
+ try {
265
+ runId = await ctx.ci.triggerRun(entry.specBranch, newSpecSha);
266
+ emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: entry.specBranch });
267
+ }
268
+ catch {
269
+ runId = null;
270
+ }
271
+ ctx.store.rebuildSpecHeadEquivalent(entry.id, {
272
+ headSha: newHead,
273
+ specSha: newSpecSha,
274
+ specBranch: entry.specBranch,
275
+ headPatchId: newPatchId,
276
+ specTreeId: newTreeId,
277
+ ciRunId: runId,
278
+ }, runId
279
+ ? `patch-id-equivalent rebuild, CI ${runId.slice(0, 12)}`
280
+ : "patch-id-equivalent rebuild (CI trigger pending)");
281
+ return true;
282
+ }
@@ -19,6 +19,12 @@ export declare class MergeStewardQueueCommands {
19
19
  issueKey?: string;
20
20
  priority?: number;
21
21
  prTitle?: string;
22
+ /**
23
+ * Plan §8.4: PR's base ref. When this matches another open PR's
24
+ * `branch` (head ref), the entry is stacked and admission orders
25
+ * it immediately behind the parent.
26
+ */
27
+ baseRefName?: string;
22
28
  }): QueueEntry | undefined;
23
29
  scanStartupAdmissions(): Promise<void>;
24
30
  scanEligibleOpenPrs(): Promise<{
@@ -34,4 +40,5 @@ export declare class MergeStewardQueueCommands {
34
40
  private findAffectedEntriesAfterPriorityChange;
35
41
  private requeueAffectedEntries;
36
42
  private nextPosition;
43
+ private findActiveEntryByBranch;
37
44
  }
@@ -63,7 +63,10 @@ export class MergeStewardQueueCommands {
63
63
  postMergeSha: null,
64
64
  postMergeSummary: null,
65
65
  postMergeCheckedAt: null,
66
+ headPatchId: null,
67
+ specTreeId: null,
66
68
  prTitle: params.prTitle ?? null,
69
+ baseRefName: params.baseRefName ?? null,
67
70
  enqueuedAt: new Date().toISOString(),
68
71
  updatedAt: new Date().toISOString(),
69
72
  };
@@ -183,12 +186,36 @@ export class MergeStewardQueueCommands {
183
186
  return false;
184
187
  }
185
188
  }
189
+ // Plan §8.4: stack-aware admission. When the PR's base ref
190
+ // names another open PR's branch (head ref), the entry is
191
+ // stacked. Enqueue it only when the parent is already in the
192
+ // queue — otherwise the child's spec would build against the
193
+ // unmerged parent's pre-spec content. Positions are monotonic
194
+ // (`nextPosition`), so the child's position is always greater
195
+ // than the parent's; sibling PRs admitted between the parent's
196
+ // enqueue and the child's tryAdmit can sit between them. That's
197
+ // fine: queue head selection orders by (priority, position), so
198
+ // the parent is still processed before the child. The functional
199
+ // guarantee is "parent before child", not strict adjacency.
200
+ const baseRefName = status.baseRefName ?? null;
201
+ if (baseRefName && baseRefName !== this.config.baseBranch) {
202
+ const parentEntry = this.findActiveEntryByBranch(baseRefName);
203
+ if (!parentEntry) {
204
+ this.logger.debug({
205
+ prNumber,
206
+ baseRefName,
207
+ repoBaseBranch: this.config.baseBranch,
208
+ }, "Deferring admission for stacked PR — parent branch is not in the queue yet");
209
+ return false;
210
+ }
211
+ }
186
212
  this.enqueue({
187
213
  prNumber,
188
214
  branch,
189
215
  headSha,
190
216
  priority,
191
217
  ...(status.title ? { prTitle: status.title } : {}),
218
+ ...(baseRefName ? { baseRefName } : {}),
192
219
  });
193
220
  return true;
194
221
  }
@@ -277,4 +304,10 @@ export class MergeStewardQueueCommands {
277
304
  }
278
305
  return next;
279
306
  }
307
+ // Plan §8.4: lookup helper used by stack-aware admission. Returns
308
+ // the active queue entry whose `branch` (head ref) matches `name`,
309
+ // or undefined when no such entry is in the queue.
310
+ findActiveEntryByBranch(name) {
311
+ return this.store.listActive(this.config.repoId).find((entry) => entry.branch === name);
312
+ }
280
313
  }
package/dist/store.d.ts CHANGED
@@ -11,9 +11,27 @@ export interface QueueStore {
11
11
  listActive(repoId: string): QueueEntry[];
12
12
  listAll(repoId: string): QueueEntry[];
13
13
  insert(entry: QueueEntry): void;
14
- transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
14
+ transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
15
15
  dequeue(entryId: string): void;
16
16
  updateHead(entryId: string, newHeadSha: string): void;
17
+ /**
18
+ * Plan §5.3: patch-id-aware short-circuit. The new head is
19
+ * patch-id-equivalent to the cached identity AND the freshly
20
+ * computed merge tree matches the cached spec tree. We rebuild the
21
+ * spec commit with the new head as its second parent (so GitHub
22
+ * marks the PR merged after fast-forward) but do not blow away the
23
+ * spec branch or restart the prepare cycle. CI must re-run on the
24
+ * new spec SHA — check_runs are SHA-anchored and there is no API
25
+ * to reuse a passing verdict.
26
+ */
27
+ rebuildSpecHeadEquivalent(entryId: string, patch: {
28
+ headSha: string;
29
+ specSha: string;
30
+ specBranch: string;
31
+ headPatchId: string;
32
+ specTreeId: string;
33
+ ciRunId: string | null;
34
+ }, detail?: string): void;
17
35
  updatePriority(entryId: string, priority: number, detail?: string): void;
18
36
  insertIncident(incident: IncidentRecord): void;
19
37
  listIncidents(entryId: string): IncidentRecord[];
package/dist/types.d.ts CHANGED
@@ -50,6 +50,28 @@ export interface QueueEntry {
50
50
  postMergeSummary: string | null;
51
51
  /** Timestamp of the most recent post-merge verification attempt. */
52
52
  postMergeCheckedAt: string | null;
53
+ /**
54
+ * Plan §8.4: PR's base ref captured at admission. When this names
55
+ * another open PR's `branch` (head ref), the entry is stacked and
56
+ * the queue holds it behind the parent until the parent is itself
57
+ * admitted. `null` when the PR was opened against the repo default.
58
+ */
59
+ baseRefName: string | null;
60
+ /**
61
+ * Plan §5.3: stable patch-id (`git patch-id --stable`) of the
62
+ * current `headSha` against the entry's `baseSha`. Cached at spec
63
+ * build time so the prepare path can short-circuit on
64
+ * patch-id-equivalent updateHeads without re-running the merge.
65
+ * Null until the spec is first built.
66
+ */
67
+ headPatchId: string | null;
68
+ /**
69
+ * Plan §5.3: tree id of `specSha` (i.e. `git rev-parse specSha^{tree}`).
70
+ * Cached at spec build time so we can compare against a freshly
71
+ * computed `merge-tree --write-tree` for the new head and decide
72
+ * whether the merged tree is unchanged.
73
+ */
74
+ specTreeId: string | null;
53
75
  enqueuedAt: string;
54
76
  updatedAt: string;
55
77
  }
@@ -183,6 +205,12 @@ export interface PRStatus {
183
205
  branch: string;
184
206
  headSha: string;
185
207
  title?: string | undefined;
208
+ /**
209
+ * Plan §8.4: PR's base ref. When this names another open PR's
210
+ * `branch` (head ref), the PR is stacked and admission must defer
211
+ * until the parent is in the queue.
212
+ */
213
+ baseRefName?: string | undefined;
186
214
  mergeable: boolean;
187
215
  mergeStateStatus?: string | undefined;
188
216
  reviewDecision?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {