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 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, a systemd unit, and a generated webhook secret.
46
- - `attach` discovers the default branch from GitHub and stores a per-repo config.
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](../../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](../../docs/merge-steward.md) for the full App permission set.
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
- - [docs/merge-steward.md](../../docs/merge-steward.md) — operator reference: GitHub App permissions, secrets, webhook, repo config, full CLI, HTTP API, queue state machine, systemd, troubleshooting
98
- - [docs/merge-queue.md](../../docs/merge-queue.md) — the two-service delivery story
99
- - [docs/github-queue-contract.md](../../docs/github-queue-contract.md) — shared GitHub artifacts
100
- - [docs/design-docs/merge-steward.md](../../docs/design-docs/merge-steward.md) — design rationale
101
- - [../../README.md](../../README.md) — the three-service stack overview
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 entries = store.listAll(config.repoId).filter((entry) => entry.prNumber === prNumber);
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
- return { kind: "found", entry: entries[0], source: "database" };
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
- lines.push(`Queue position: ${entry.position}`);
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.30.0",
3
+ "version": "0.30.2",
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": {