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 +4 -4
- package/dist/db/sqlite-store.d.ts +1 -0
- package/dist/db/sqlite-store.js +7 -0
- package/dist/reconciler.js +7 -5
- package/dist/store.d.ts +2 -0
- package/dist/watch/ProjectDetailView.d.ts +3 -0
- package/dist/watch/ProjectDetailView.js +6 -0
- package/dist/watch/dashboard-model.d.ts +6 -0
- package/dist/watch/dashboard-model.js +22 -1
- package/dist/watch/format.d.ts +1 -0
- package/dist/watch/format.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# merge-steward
|
|
2
2
|
|
|
3
|
-
Self-hosted
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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;
|
package/dist/db/sqlite-store.js
CHANGED
|
@@ -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
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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,
|
package/dist/watch/format.d.ts
CHANGED
package/dist/watch/format.js
CHANGED
|
@@ -49,7 +49,10 @@ export function formatTokenAge(eventAt) {
|
|
|
49
49
|
return relativeTime(eventAt).padStart(4, " ");
|
|
50
50
|
}
|
|
51
51
|
export function formatRepoTokenText(token) {
|
|
52
|
-
|
|
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) {
|