merge-steward 0.30.0 → 0.30.2
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 +15 -11
- package/dist/cli/commands/pr-status.d.ts +15 -0
- package/dist/cli/commands/pr-status.js +27 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# merge-steward
|
|
2
2
|
|
|
3
|
-
Self-hosted serial 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 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.
|
|
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).
|
|
8
|
+
|
|
7
9
|
## Why this matters
|
|
8
10
|
|
|
9
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.
|
|
@@ -36,17 +38,18 @@ Prerequisites: Node.js 24+, `gh` CLI in `PATH`, `git`.
|
|
|
36
38
|
```bash
|
|
37
39
|
pnpm add -g merge-steward
|
|
38
40
|
merge-steward init https://queue.example.com
|
|
39
|
-
merge-steward attach owner/repo --base-branch main
|
|
41
|
+
merge-steward repo attach owner/repo --base-branch main
|
|
40
42
|
merge-steward doctor --repo repo
|
|
41
43
|
merge-steward service status
|
|
42
44
|
merge-steward queue status --repo repo
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
- `init` writes config files
|
|
46
|
-
- `
|
|
47
|
+
- `init` writes config files and a systemd unit, then prints the webhook URL to configure in GitHub.
|
|
48
|
+
- You still need to install `merge-steward-webhook-secret` and `merge-steward-github-app-pem` via systemd credentials, or provide the documented environment/file fallbacks.
|
|
49
|
+
- `repo attach` discovers the default branch from GitHub and stores a per-repo config.
|
|
47
50
|
- Required checks are learned from GitHub branch protection at runtime — the steward does not keep a local copy.
|
|
48
51
|
|
|
49
|
-
Full setup (GitHub App permissions, secrets, webhook events, systemd, HTTP API): [docs/merge-steward.md](
|
|
52
|
+
Full setup (GitHub App permissions, secrets, webhook events, systemd, HTTP API): [docs/merge-steward.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-steward.md).
|
|
50
53
|
|
|
51
54
|
## Everyday commands
|
|
52
55
|
|
|
@@ -77,7 +80,7 @@ The real gate is:
|
|
|
77
80
|
- configured required checks are green
|
|
78
81
|
- the steward's speculative integrated branch also passes CI
|
|
79
82
|
|
|
80
|
-
`review-quill/verdict` only matters if you include it in the repo's required checks. Branch protection is useful as defense in depth, but the steward merges by fast-forwarding `main` to the already-tested speculative SHA — not by pressing GitHub's merge button. Successful merges therefore depend on the steward App being allowed to push to the protected branch. See [docs/merge-steward.md](
|
|
83
|
+
`review-quill/verdict` only matters if you include it in the repo's required checks. Branch protection is useful as defense in depth, but the steward merges by fast-forwarding `main` to the already-tested speculative SHA — not by pressing GitHub's merge button. Successful merges therefore depend on the steward App being allowed to push to the protected branch. See [docs/merge-steward.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-steward.md) for the full App permission set.
|
|
81
84
|
|
|
82
85
|
**`main`'s own CI is information-only.** The speculative SHA the steward tests *is* the exact tree that becomes `main`, so re-testing `main` after the push adds no signal — it only catches flakiness or out-of-band changes (direct pushes, hotfixes). The queue therefore **ignores `main`'s CI entirely** for advancement: it does not gate landing on `main` being green, does not wait for `main` CI before the next landing, and is never "paused" by a red `main`. A red `main` with a green speculative SHA simply means the red was flaky or is fixed by landing — so the steward lands. Use `main`'s CI as a project-health canary, not a queue control.
|
|
83
86
|
|
|
@@ -94,8 +97,9 @@ Neither service calls the other's API.
|
|
|
94
97
|
|
|
95
98
|
## Reference
|
|
96
99
|
|
|
97
|
-
- [
|
|
98
|
-
- [docs/merge-
|
|
99
|
-
- [docs/
|
|
100
|
-
- [docs/
|
|
101
|
-
- [
|
|
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
|
|
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
|
+
- [docs/merge-queue.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-queue.md) — the two-service delivery story
|
|
103
|
+
- [docs/github-queue-contract.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/github-queue-contract.md) — shared GitHub artifacts
|
|
104
|
+
- [docs/design-docs/merge-steward.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/design-docs/merge-steward.md) — design rationale
|
|
105
|
+
- [README.md](https://github.com/krasnoperov/patchrelay/blob/main/README.md) — the three-service stack overview
|
|
@@ -4,6 +4,19 @@ import { type ResolveCommandRunner } from "../resolve.ts";
|
|
|
4
4
|
import { type PrGitHubOverview } from "./pr-github.ts";
|
|
5
5
|
export type PrStatusKind = "merged" | "merged_outside_queue" | "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "dequeued" | "closed" | "changes_requested" | "checks_failing" | "checks_pending" | "approved_clean" | "not_queued";
|
|
6
6
|
export type PrStatusSource = "queue" | "github";
|
|
7
|
+
/**
|
|
8
|
+
* Live position of an entry among the entries actually still in the queue,
|
|
9
|
+
* ordered the way the reconciler processes them (priority DESC, position
|
|
10
|
+
* ASC). This is what an operator means by "where am I in line" — unlike the
|
|
11
|
+
* raw `position`, which is a lifetime admission counter that only ever
|
|
12
|
+
* grows (every PR ever queued bumps it), so a value like 290 reads as "289
|
|
13
|
+
* ahead" when in reality only a couple of entries are active.
|
|
14
|
+
*/
|
|
15
|
+
export interface QueueActiveRank {
|
|
16
|
+
rank: number;
|
|
17
|
+
activeTotal: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function computeActiveRank(entries: QueueEntry[], target: QueueEntry): QueueActiveRank | undefined;
|
|
7
20
|
export interface PrStatusReport {
|
|
8
21
|
repoId: string;
|
|
9
22
|
repoFullName: string;
|
|
@@ -15,6 +28,7 @@ export interface PrStatusReport {
|
|
|
15
28
|
reason?: string;
|
|
16
29
|
queueEntry?: QueueEntry;
|
|
17
30
|
queueSource?: "service" | "database";
|
|
31
|
+
queueActiveRank?: QueueActiveRank;
|
|
18
32
|
queueRuntime?: QueueRuntimeStatus;
|
|
19
33
|
queueLatestEvent?: QueueEventSummary;
|
|
20
34
|
github?: PrGitHubOverview;
|
|
@@ -32,6 +46,7 @@ export interface BuildReportOptions {
|
|
|
32
46
|
prNumber: number;
|
|
33
47
|
queueEntry?: QueueEntry | undefined;
|
|
34
48
|
queueSource?: "service" | "database" | undefined;
|
|
49
|
+
queueActiveRank?: QueueActiveRank | undefined;
|
|
35
50
|
queueRuntime?: QueueRuntimeStatus | undefined;
|
|
36
51
|
queueLatestEvent?: QueueEventSummary | undefined;
|
|
37
52
|
github?: PrGitHubOverview | undefined;
|
|
@@ -5,6 +5,18 @@ import { defaultResolveRunner, resolvePrNumber, resolveRepo } from "../resolve.j
|
|
|
5
5
|
import { parseIntegerFlag } from "../args.js";
|
|
6
6
|
import { fetchPrGitHubOverview } from "./pr-github.js";
|
|
7
7
|
import { formatRuntimeActivity } from "../../runtime-format.js";
|
|
8
|
+
const TERMINAL_QUEUE_STATUSES = new Set(["merged", "evicted", "dequeued"]);
|
|
9
|
+
export function computeActiveRank(entries, target) {
|
|
10
|
+
if (TERMINAL_QUEUE_STATUSES.has(target.status))
|
|
11
|
+
return undefined;
|
|
12
|
+
const active = entries
|
|
13
|
+
.filter((entry) => !TERMINAL_QUEUE_STATUSES.has(entry.status))
|
|
14
|
+
.sort((a, b) => (b.priority - a.priority) || (a.position - b.position));
|
|
15
|
+
const index = active.findIndex((entry) => entry.id === target.id);
|
|
16
|
+
if (index < 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
return { rank: index + 1, activeTotal: active.length };
|
|
19
|
+
}
|
|
8
20
|
export function classifyQueueEntry(entry) {
|
|
9
21
|
switch (entry.status) {
|
|
10
22
|
case "merged": return "merged";
|
|
@@ -85,11 +97,13 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
85
97
|
if (!entry)
|
|
86
98
|
return { kind: "not_found" };
|
|
87
99
|
const latestEvent = latestEventForPr(snapshot, prNumber);
|
|
100
|
+
const activeRank = computeActiveRank(snapshot.entries, entry);
|
|
88
101
|
return {
|
|
89
102
|
kind: "found",
|
|
90
103
|
entry,
|
|
91
104
|
source: "service",
|
|
92
105
|
runtime: snapshot.runtime,
|
|
106
|
+
...(activeRank ? { activeRank } : {}),
|
|
93
107
|
...(latestEvent ? { latestEvent } : {}),
|
|
94
108
|
};
|
|
95
109
|
}
|
|
@@ -99,7 +113,8 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
99
113
|
}
|
|
100
114
|
const store = new SqliteStore(config.database.path);
|
|
101
115
|
try {
|
|
102
|
-
const
|
|
116
|
+
const all = store.listAll(config.repoId);
|
|
117
|
+
const entries = all.filter((entry) => entry.prNumber === prNumber);
|
|
103
118
|
if (entries.length === 0)
|
|
104
119
|
return { kind: "not_found" };
|
|
105
120
|
entries.sort((left, right) => {
|
|
@@ -109,7 +124,9 @@ async function loadQueueEntry(config, prNumber) {
|
|
|
109
124
|
return leftTerminal - rightTerminal;
|
|
110
125
|
return right.position - left.position;
|
|
111
126
|
});
|
|
112
|
-
|
|
127
|
+
const entry = entries[0];
|
|
128
|
+
const activeRank = computeActiveRank(all, entry);
|
|
129
|
+
return { kind: "found", entry, source: "database", ...(activeRank ? { activeRank } : {}) };
|
|
113
130
|
}
|
|
114
131
|
finally {
|
|
115
132
|
store.close();
|
|
@@ -138,6 +155,7 @@ export function buildPrStatusReport(options) {
|
|
|
138
155
|
exitCode: exitCodeForKind(kind),
|
|
139
156
|
queueEntry: options.queueEntry,
|
|
140
157
|
...(options.queueSource ? { queueSource: options.queueSource } : {}),
|
|
158
|
+
...(options.queueActiveRank ? { queueActiveRank: options.queueActiveRank } : {}),
|
|
141
159
|
...(options.queueRuntime ? { queueRuntime: options.queueRuntime } : {}),
|
|
142
160
|
...(options.queueLatestEvent ? { queueLatestEvent: options.queueLatestEvent } : {}),
|
|
143
161
|
checkedAt,
|
|
@@ -172,7 +190,12 @@ export function formatReportText(report) {
|
|
|
172
190
|
lines.push(`Reason: ${report.reason}`);
|
|
173
191
|
if (report.queueEntry) {
|
|
174
192
|
const entry = report.queueEntry;
|
|
175
|
-
|
|
193
|
+
// Show live rank among active entries — NOT the raw `position`, which is
|
|
194
|
+
// a lifetime admission counter and reads as a huge phantom backlog.
|
|
195
|
+
if (report.queueActiveRank) {
|
|
196
|
+
const { rank, activeTotal } = report.queueActiveRank;
|
|
197
|
+
lines.push(`Queue position: ${rank} of ${activeTotal} active`);
|
|
198
|
+
}
|
|
176
199
|
lines.push(`Branch: ${entry.branch}`);
|
|
177
200
|
lines.push(`Head SHA: ${entry.headSha}`);
|
|
178
201
|
if (entry.waitDetail)
|
|
@@ -260,6 +283,7 @@ export async function handlePrStatus(options) {
|
|
|
260
283
|
prNumber: resolvedPr.prNumber,
|
|
261
284
|
queueEntry: queueResult.entry,
|
|
262
285
|
queueSource: queueResult.source,
|
|
286
|
+
...(queueResult.activeRank ? { queueActiveRank: queueResult.activeRank } : {}),
|
|
263
287
|
...(queueResult.runtime ? { queueRuntime: queueResult.runtime } : {}),
|
|
264
288
|
...(queueResult.latestEvent ? { queueLatestEvent: queueResult.latestEvent } : {}),
|
|
265
289
|
});
|