merge-steward 0.30.2 → 0.31.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/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # merge-steward
2
2
 
3
- Self-hosted serial speculative merge queue for bot-managed and human-managed GitHub pull requests. Admits approved PRs whose required checks are green, builds speculative branches on top of the latest `main`, waits for CI on those integrated SHAs, and fast-forwards `main` to the tested result.
3
+ Self-hosted merge queue for bot-managed and human-managed GitHub pull requests. Merge Steward turns reviewed PRs into a tested landing train: it runs CI on the exact future `main` SHAs, validates several PRs in parallel, and fast-forwards through the green sequence as soon as it is safe.
4
4
 
5
5
  Independent of PatchRelay. Communicates through GitHub only — PRs, reviews, checks, labels, branches. Pairs with `review-quill`; neither requires the other.
6
6
 
7
- For the background story and design trade-offs, read [merge-steward: a self-hosted merge queue without the Enterprise gate](https://blog.krasnoperov.me/posts/merge-steward).
7
+ For the background story and design trade-offs, read [merge-steward: speculative integration, parallel validation, fast-forward landing](https://blog.krasnoperov.me/posts/merge-steward).
8
8
 
9
9
  ## Why this matters
10
10
 
11
- PRs delivered through the queue are tested against `main` as it was at admission time, and re-validated if `main` advances during validation. No more "CI was green yesterday, breaks on merge today" — the queue catches the integration bug before `main` ever sees it.
11
+ The queue keeps delivery fast without pretending branch CI is enough. Each speculative branch is the cumulative queue order on top of the latest base: `main + A`, then `main + A + B`, then `main + A + B + C`. No more "CI was green yesterday, breaks on merge today" — the integration bug is caught before `main` ever sees it.
12
12
 
13
13
  ## How it works
14
14
 
@@ -97,7 +97,7 @@ Neither service calls the other's API.
97
97
 
98
98
  ## Reference
99
99
 
100
- - [merge-steward: a self-hosted merge queue without the Enterprise gate](https://blog.krasnoperov.me/posts/merge-steward) — background essay and design trade-offs
100
+ - [merge-steward: speculative integration, parallel validation, fast-forward landing](https://blog.krasnoperov.me/posts/merge-steward) — background essay and design trade-offs
101
101
  - [docs/merge-steward.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-steward.md) — operator reference: GitHub App permissions, secrets, webhook, repo config, full CLI, HTTP API, queue state machine, systemd, troubleshooting
102
102
  - [docs/merge-queue.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-queue.md) — the two-service delivery story
103
103
  - [docs/github-queue-contract.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/github-queue-contract.md) — shared GitHub artifacts
@@ -11,6 +11,7 @@ export declare class SqliteStore implements QueueStore {
11
11
  getEntryByPR(repoId: string, prNumber: number): QueueEntry | undefined;
12
12
  listActive(repoId: string): QueueEntry[];
13
13
  listAll(repoId: string): QueueEntry[];
14
+ listPostMergePending(repoId: string): QueueEntry[];
14
15
  insert(entry: QueueEntry): void;
15
16
  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
17
  dequeue(entryId: string): void;
@@ -122,6 +122,13 @@ export class SqliteStore {
122
122
  const rows = this.conn.prepare("SELECT * FROM queue_entries WHERE repo_id = ? ORDER BY priority DESC, position ASC").all(repoId);
123
123
  return rows.map(mapEntry);
124
124
  }
125
+ listPostMergePending(repoId) {
126
+ const rows = this.conn.prepare(`SELECT * FROM queue_entries
127
+ WHERE repo_id = ? AND status = 'merged'
128
+ AND (post_merge_status IS NULL OR post_merge_status NOT IN ('pass', 'fail'))
129
+ ORDER BY position ASC`).all(repoId);
130
+ return rows.map(mapEntry);
131
+ }
125
132
  insert(entry) {
126
133
  this.conn.transaction(() => {
127
134
  this.conn.prepare(`INSERT INTO queue_entries
@@ -11,8 +11,10 @@ import { syncQueueStateLabels } from "./reconciler-queue-labels.js";
11
11
  // ─── Main reconcile loop ────────────────────────────────────────
12
12
  export async function reconcile(ctx) {
13
13
  const allActive = ctx.store.listActive(ctx.repoId);
14
- if (allActive.length === 0)
15
- return;
14
+ // Note: do NOT early-return when the active queue is empty. A drained queue
15
+ // can still hold merged entries whose post-merge verification is unresolved
16
+ // (e.g. an externally-merged PR), and verifyMergedEntriesPostPush below is
17
+ // the only thing that advances them. depth=0 simply skips the active loop.
16
18
  // Process up to speculativeDepth entries. GitHub truth checks are
17
19
  // bounded by this window — we never scan the full queue.
18
20
  const depth = Math.min(ctx.speculativeDepth, allActive.length);
@@ -91,10 +93,10 @@ export async function reconcile(ctx) {
91
93
  await verifyMergedEntriesPostPush(ctx);
92
94
  }
93
95
  async function verifyMergedEntriesPostPush(ctx) {
94
- const mergedEntries = ctx.store.listAll(ctx.repoId).filter((entry) => entry.status === "merged");
96
+ // Targeted query (not listAll) so an idle repo with a large merged history
97
+ // doesn't scan every terminal row each tick — only the unresolved ones.
98
+ const mergedEntries = ctx.store.listPostMergePending(ctx.repoId);
95
99
  for (const entry of mergedEntries) {
96
- if (entry.postMergeStatus === "pass" || entry.postMergeStatus === "fail")
97
- continue;
98
100
  const postMergeSha = entry.postMergeSha ?? entry.specSha ?? entry.headSha;
99
101
  if (!postMergeSha) {
100
102
  continue;
package/dist/store.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface QueueStore {
10
10
  getEntryByPR(repoId: string, prNumber: number): QueueEntry | undefined;
11
11
  listActive(repoId: string): QueueEntry[];
12
12
  listAll(repoId: string): QueueEntry[];
13
+ /** Merged entries whose post-merge verification is unresolved (not pass/fail). */
14
+ listPostMergePending(repoId: string): QueueEntry[];
13
15
  insert(entry: QueueEntry): void;
14
16
  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
17
  dequeue(entryId: string): void;
@@ -11,6 +11,9 @@ type ContentLine = {
11
11
  } | {
12
12
  kind: "entry-header";
13
13
  entry: DashboardPrEntry;
14
+ } | {
15
+ kind: "stack-line";
16
+ text: string;
14
17
  } | {
15
18
  kind: "summary-line";
16
19
  text: string;
@@ -20,6 +20,9 @@ export function buildContentLines(repo, width) {
20
20
  if (index > 0)
21
21
  lines.push({ kind: "blank" });
22
22
  lines.push({ kind: "entry-header", entry });
23
+ if (entry.stackedOnPr != null) {
24
+ lines.push({ kind: "stack-line", text: `↳ stacked on #${entry.stackedOnPr} (merges after it)` });
25
+ }
23
26
  if (entry.summary) {
24
27
  const summaryText = clipSummary(entry.summary, {
25
28
  maxLines: 3,
@@ -45,6 +48,9 @@ function EntryHeaderRow({ entry, width }) {
45
48
  function renderLine(line, key, width) {
46
49
  if (line.kind === "blank")
47
50
  return _jsx(Box, { children: _jsx(Text, { children: " " }) }, key);
51
+ if (line.kind === "stack-line") {
52
+ return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { color: "gray", children: line.text })] }, key));
53
+ }
48
54
  if (line.kind === "summary-line") {
49
55
  return (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: line.text })] }, key));
50
56
  }
@@ -20,6 +20,12 @@ export interface DashboardToken {
20
20
  color: DashboardTokenColor;
21
21
  kind: DashboardTokenKind;
22
22
  eventAt: number;
23
+ /** Queue position; active entries are ordered by this so the stack/queue order shows. */
24
+ position: number;
25
+ /** Whether the entry is still in flight (not merged/evicted/dequeued). */
26
+ active: boolean;
27
+ /** PR number this entry's speculative spec is stacked on, when that parent is still active. */
28
+ stackedOnPr: number | null;
23
29
  }
24
30
  export interface DashboardPrEntry extends DashboardToken {
25
31
  phrase: string;
@@ -127,6 +127,13 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
127
127
  .filter((entry) => isActive(entry.status))
128
128
  .sort((a, b) => a.position - b.position)[0] ?? null;
129
129
  const queueBlocked = queueBlockMatchesEntry(snapshot.queueBlock, head);
130
+ // Resolve speculative stacking: an entry's spec can be built on top of
131
+ // another entry's spec (specBasedOn -> that entry's id). Map ids to PR
132
+ // numbers so the views can show "stacked on #N". Only surface the link
133
+ // when the parent is still active, since a merged parent collapses the stack.
134
+ const idToEntry = new Map();
135
+ for (const entry of latest)
136
+ idToEntry.set(entry.id, entry);
130
137
  const byPr = new Map();
131
138
  for (const entry of latest) {
132
139
  const active = isActive(entry.status);
@@ -140,6 +147,8 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
140
147
  const glyph = GLYPH[kind];
141
148
  const color = COLOR[kind];
142
149
  const phrase = entryPhrase(entry, { isHead, queueBlocked });
150
+ const parent = entry.specBasedOn ? idToEntry.get(entry.specBasedOn) ?? null : null;
151
+ const stackedOnPr = parent && isActive(parent.status) ? parent.prNumber : null;
143
152
  const item = {
144
153
  prNumber: entry.prNumber,
145
154
  glyph,
@@ -147,6 +156,9 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
147
156
  kind,
148
157
  phrase,
149
158
  eventAt: timestamp(entry.updatedAt),
159
+ position: entry.position,
160
+ active,
161
+ stackedOnPr,
150
162
  };
151
163
  const summary = entrySummary(entry);
152
164
  if (summary)
@@ -156,7 +168,16 @@ function repoEntriesFromSnapshot(snapshot, cutoff) {
156
168
  item.title = title;
157
169
  byPr.set(entry.prNumber, item);
158
170
  }
171
+ // Active entries first, in queue/stack order (by position, head first) so
172
+ // speculative stacks read top-to-bottom; decided entries follow, newest-first.
159
173
  return [...byPr.values()].sort((left, right) => {
174
+ if (left.active !== right.active)
175
+ return left.active ? -1 : 1;
176
+ if (left.active) {
177
+ if (left.position !== right.position)
178
+ return left.position - right.position;
179
+ return left.prNumber - right.prNumber;
180
+ }
160
181
  const leftOrder = tokenSortOrder(left.kind);
161
182
  const rightOrder = tokenSortOrder(right.kind);
162
183
  if (leftOrder !== rightOrder)
@@ -193,7 +214,7 @@ export function buildDashboard(repos, opts = {}) {
193
214
  return {
194
215
  repoId: repo.repoId,
195
216
  repoFullName: repo.repoFullName,
196
- tokens: entries.map(({ prNumber, glyph, color, kind, eventAt }) => ({ prNumber, glyph, color, kind, eventAt })),
217
+ tokens: entries.map(({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr }) => ({ prNumber, glyph, color, kind, eventAt, position, active, stackedOnPr })),
197
218
  entries,
198
219
  latestActivityAt,
199
220
  hasActivity: entries.length > 0,
@@ -11,6 +11,7 @@ export declare function formatRepoTokenText(token: {
11
11
  prNumber: number;
12
12
  glyph: string;
13
13
  eventAt: number;
14
+ stackedOnPr?: number | null;
14
15
  }): string;
15
16
  export declare function mergeWaitState(entry: {
16
17
  status: QueueEntryStatus;
@@ -49,7 +49,10 @@ export function formatTokenAge(eventAt) {
49
49
  return relativeTime(eventAt).padStart(4, " ");
50
50
  }
51
51
  export function formatRepoTokenText(token) {
52
- return `#${token.prNumber} ${token.glyph} ${relativeTime(token.eventAt)}`;
52
+ // A leading connector marks an entry whose spec is speculatively stacked on
53
+ // the PR shown just before it, so the queue order reads as a stack.
54
+ const stackMark = token.stackedOnPr != null ? "↳" : "";
55
+ return `${stackMark}#${token.prNumber} ${token.glyph} ${relativeTime(token.eventAt)}`;
53
56
  }
54
57
  export function mergeWaitState(entry) {
55
58
  if (!entry || entry.status !== "merging" || !entry.waitDetail) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.30.2",
3
+ "version": "0.31.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": {