merge-steward 0.9.7 → 0.10.1

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
- Speculative merge queue service. It admits approved PRs whose required checks
4
- are green, builds cumulative speculative branches, waits for CI on those exact
5
- integrated SHAs, and then fast-forwards `main` to the tested result. On
6
- failure, it evicts with a durable incident record and GitHub check run.
3
+ `merge-steward` is a self-hosted merge queue for bot-managed and human-managed
4
+ GitHub pull requests. It admits approved PRs whose required checks are green,
5
+ builds cumulative speculative branches, waits for CI on those exact integrated
6
+ SHAs, and then fast-forwards `main` to the tested result. On failure, it evicts
7
+ with a durable incident record and GitHub check run so an agent or human can
8
+ repair the branch and re-queue it.
7
9
 
8
10
  Fully independent of PatchRelay. Communicates through GitHub — PRs, reviews,
9
11
  checks, labels, branches.
@@ -209,34 +211,68 @@ merge-steward doctor --repo repo
209
211
  merge-steward service status
210
212
  merge-steward queue status --repo repo
211
213
  merge-steward queue show --repo repo --pr 123
214
+ merge-steward dashboard
212
215
 
213
216
  # Manual foreground start
214
217
  merge-steward serve
215
218
 
216
- # Live queue watch TUI
217
- merge-steward queue watch --repo app
219
+ # Open one project directly in the dashboard
220
+ merge-steward dashboard --repo app
218
221
  ```
219
222
 
220
- ### Watch TUI
223
+ ### Dashboard
221
224
 
222
- `merge-steward queue watch --repo <id>` gives you a terminal view of the queue:
225
+ `merge-steward dashboard` is the operator surface for day-to-day queue work.
223
226
 
224
- - which PRs are currently queued
225
- - which PR is head-of-line
226
- - current steward tick state
227
- - recent queue transitions
228
- - per-PR detail with incidents and event history
227
+ The first screen shows all configured projects with:
228
+
229
+ - project-level queue health
230
+ - readable queue stats
231
+ - a compact queue chain like `#123 ● #124 ○`
232
+ - clear bad states such as blocked, stuck, or needs attention
233
+
234
+ Press `Enter` on a project to open the second screen. That project detail view shows:
235
+
236
+ - the same top-level queue stats for that project
237
+ - a readable list of PRs in the queue
238
+ - recent queue activity in plain language
239
+ - incidents for evicted PRs
240
+ - direct actions like reconcile and dequeue
241
+
242
+ Use `merge-steward dashboard --repo <id>` to open the project detail screen directly. Use `--pr <number>` to preselect a PR when you already know what you need to inspect.
229
243
 
230
244
  Controls:
231
245
 
232
246
  - `j` / `k` or arrows — move selection
233
- - `Enter` — open selected PR detail
234
- - `Esc` — return to queue view
235
- - `a` — toggle `active` vs `all`
236
- - `r` — run a reconcile tick now
237
- - `d` — dequeue the selected PR
247
+ - `Enter` — open the selected project from overview
248
+ - `Esc` — return to the overview
249
+ - `a` — toggle `active` vs `all` in project view
250
+ - `r` — run a reconcile tick for the selected project
251
+ - `d` — dequeue the selected PR in project view
238
252
  - `q` — quit
239
253
 
254
+ ### Validation, Visibility, And Troubleshooting
255
+
256
+ These are the first commands to reach for after setup or when a queue looks wrong:
257
+
258
+ ```bash
259
+ merge-steward doctor --repo app
260
+ merge-steward service status
261
+ merge-steward service restart
262
+ merge-steward dashboard
263
+ merge-steward queue status --repo app
264
+ merge-steward queue show --repo app --pr 123
265
+ merge-steward service logs --lines 100
266
+ ```
267
+
268
+ Use them this way:
269
+
270
+ - `doctor` checks config, GitHub auth, branch rules, and required checks.
271
+ - `dashboard` is the best live operator view across all configured projects.
272
+ - `queue status` is the fastest text snapshot when you need one repo in a shell script or over SSH.
273
+ - `queue show --pr <number>` is the most direct way to inspect one PR's queue events and incidents.
274
+ - `service logs` helps when the queue is not reacting to webhooks, GitHub auth is failing, or reconcile ticks are erroring.
275
+
240
276
  ### systemd
241
277
 
242
278
  ```ini
@@ -264,15 +300,15 @@ WantedBy=multi-user.target
264
300
  | Endpoint | Method | Description |
265
301
  |-|-|-|
266
302
  | `/health` | GET | Liveness check |
267
- | `/queue/status` | GET | All queue entries |
268
- | `/queue/watch` | GET | Queue snapshot for the operator TUI |
269
- | `/queue/enqueue` | POST | Manually enqueue a PR |
270
- | `/queue/reconcile` | POST | Trigger one reconcile tick immediately |
271
- | `/queue/entries/:id/detail` | GET | Entry detail with recent events and incidents |
272
- | `/queue/entries/:id/dequeue` | POST | Remove from queue (non-destructive) |
273
- | `/queue/entries/:id/update-head` | POST | Update head SHA (force-push) |
274
- | `/queue/incidents/:id` | GET | Get incident details |
275
- | `/queue/entries/:id/incidents` | GET | List incidents for an entry |
303
+ | `/repos/:repoId/queue/status` | GET | All queue entries for one configured repo |
304
+ | `/repos/:repoId/queue/watch` | GET | Queue snapshot used by the dashboard |
305
+ | `/repos/:repoId/queue/enqueue` | POST | Manually enqueue a PR |
306
+ | `/repos/:repoId/queue/reconcile` | POST | Trigger one reconcile tick immediately |
307
+ | `/repos/:repoId/queue/entries/:id/detail` | GET | Entry detail with recent events and incidents |
308
+ | `/repos/:repoId/queue/entries/:id/dequeue` | POST | Remove from queue (non-destructive) |
309
+ | `/repos/:repoId/queue/entries/:id/update-head` | POST | Update head SHA (force-push) |
310
+ | `/repos/:repoId/queue/incidents/:id` | GET | Get incident details |
311
+ | `/repos/:repoId/queue/entries/:id/incidents` | GET | List incidents for an entry |
276
312
  | `/webhooks/github` | POST | GitHub webhook receiver for all configured repos |
277
313
 
278
314
  ## Queue state machine
package/dist/cli/args.js CHANGED
@@ -57,6 +57,11 @@ export function validateFlags(parsed) {
57
57
  case "serve":
58
58
  assertKnownFlags(parsed, "root", []);
59
59
  return;
60
+ case "dashboard":
61
+ case "dash":
62
+ case "d":
63
+ assertKnownFlags(parsed, "root", ["repo", "pr"]);
64
+ return;
60
65
  case "attach":
61
66
  assertKnownFlags(parsed, "repos", ["base-branch", "required-check", "label", "merge-queue-check-name", "refresh", "json"]);
62
67
  return;
@@ -97,15 +102,15 @@ export function validateFlags(parsed) {
97
102
  }
98
103
  case "queue":
99
104
  switch (subcommand) {
105
+ case "dashboard":
106
+ assertKnownFlags(parsed, "queue", ["repo", "pr"]);
107
+ return;
100
108
  case "status":
101
109
  assertKnownFlags(parsed, "queue", ["repo", "events", "json"]);
102
110
  return;
103
111
  case "show":
104
112
  assertKnownFlags(parsed, "queue", ["repo", "entry", "pr", "events", "json"]);
105
113
  return;
106
- case "watch":
107
- assertKnownFlags(parsed, "queue", ["repo", "pr"]);
108
- return;
109
114
  case "reconcile":
110
115
  assertKnownFlags(parsed, "queue", ["repo", "json"]);
111
116
  return;
@@ -0,0 +1,2 @@
1
+ import type { ParsedArgs } from "../types.ts";
2
+ export declare function handleDashboard(parsed: ParsedArgs): Promise<number>;
@@ -0,0 +1,24 @@
1
+ import { parseIntegerFlag } from "../args.js";
2
+ import { loadRepoConfigById } from "../system.js";
3
+ export async function handleDashboard(parsed) {
4
+ const initialRepoRef = typeof parsed.flags.get("repo") === "string"
5
+ ? String(parsed.flags.get("repo"))
6
+ : undefined;
7
+ const initialPrNumber = parseIntegerFlag(parsed.flags.get("pr"), "--pr");
8
+ const options = {};
9
+ if (initialRepoRef) {
10
+ loadRepoConfigById(initialRepoRef);
11
+ options.initialRepoRef = initialRepoRef;
12
+ }
13
+ if (initialPrNumber !== undefined) {
14
+ options.initialPrNumber = initialPrNumber;
15
+ }
16
+ if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") {
17
+ process.stderr.write("merge-steward dashboard requires an interactive TTY.\n");
18
+ process.stderr.write("Use `merge-steward queue status --repo <id>` or run the dashboard from a terminal.\n");
19
+ return 1;
20
+ }
21
+ const { startDashboard } = await import("../../watch/index.js");
22
+ await startDashboard(options);
23
+ return 0;
24
+ }
@@ -117,11 +117,11 @@ export async function handleQueue(parsed, stdout) {
117
117
  throw new UsageError("merge-steward queue requires a subcommand.", "queue");
118
118
  }
119
119
  if (subcommand === "watch") {
120
- const repoId = resolveRepoId(parsed, 2, "queue");
121
- const { configPath } = loadRepoConfigById(repoId);
122
- const { startWatch } = await import("../../watch/index.js");
123
- await startWatch(configPath, parseIntegerFlag(parsed.flags.get("pr"), "--pr"));
124
- return 0;
120
+ throw new UsageError("`merge-steward queue watch` was replaced by `merge-steward dashboard [--repo <id>] [--pr <number>]`.", "queue");
121
+ }
122
+ if (subcommand === "dashboard") {
123
+ const { handleDashboard } = await import("./dashboard.js");
124
+ return await handleDashboard(parsed);
125
125
  }
126
126
  const repoId = resolveRepoId(parsed, 2, "queue");
127
127
  const { config } = loadRepoConfigById(repoId);
@@ -2,7 +2,7 @@ import { installServiceUnit } from "../../install.js";
2
2
  import { UsageError } from "../types.js";
3
3
  import { parseIntegerFlag } from "../args.js";
4
4
  import { formatJson, writeOutput } from "../output.js";
5
- import { formatCommandFailure, parseSystemctlShowOutput, runSystemctl } from "../system.js";
5
+ import { fetchServiceHealthStatus, formatCommandFailure, parseSystemctlShowOutput, runSystemctl } from "../system.js";
6
6
  const UNIT_NAME = "merge-steward.service";
7
7
  export async function handleService(parsed, stdout, runCommand) {
8
8
  const subcommand = parsed.positionals[1];
@@ -31,6 +31,8 @@ export async function handleService(parsed, stdout, runCommand) {
31
31
  const restart = await runSystemctl(runCommand, ["reload-or-restart", UNIT_NAME]);
32
32
  if (parsed.flags.get("json") === true) {
33
33
  writeOutput(stdout, formatJson({
34
+ service: "merge-steward",
35
+ unit: UNIT_NAME,
34
36
  daemonReloaded: daemonReload.ok,
35
37
  restarted: restart.ok,
36
38
  errors: [
@@ -55,8 +57,9 @@ export async function handleService(parsed, stdout, runCommand) {
55
57
  throw new Error(status.error);
56
58
  }
57
59
  const properties = parseSystemctlShowOutput(status.result.stdout);
60
+ const health = await fetchServiceHealthStatus();
58
61
  if (parsed.flags.get("json") === true) {
59
- writeOutput(stdout, formatJson({ unit: UNIT_NAME, systemd: properties }));
62
+ writeOutput(stdout, formatJson({ service: "merge-steward", unit: UNIT_NAME, systemd: properties, health }));
60
63
  return 0;
61
64
  }
62
65
  writeOutput(stdout, [
@@ -65,6 +68,9 @@ export async function handleService(parsed, stdout, runCommand) {
65
68
  `Enabled: ${properties.UnitFileState ?? "unknown"}`,
66
69
  `Active: ${properties.ActiveState ?? "unknown"}${properties.SubState ? ` (${properties.SubState})` : ""}`,
67
70
  properties.ExecMainPID ? `Main PID: ${properties.ExecMainPID}` : undefined,
71
+ health.reachable
72
+ ? `Health: ${health.ok ? "ok" : "unhealthy"} (HTTP ${health.status})`
73
+ : `Health: not reachable (${health.error})`,
68
74
  ]
69
75
  .filter(Boolean)
70
76
  .join("\n") + "\n");
package/dist/cli/help.js CHANGED
@@ -20,12 +20,12 @@ function rootHelpText() {
20
20
  " repo list [--json] List attached repositories",
21
21
  " repo show <id> [--json] Show one repo config",
22
22
  " doctor [--repo <id>] [--json] Validate config, secrets, auth, and required binaries",
23
- " service status [--json] Show systemd state",
23
+ " service status [--json] Show systemd state and local health",
24
24
  " service logs [--lines <count>] [--json] Show recent journal logs",
25
25
  " queue status --repo <id> [--json] Show queue summary and current entries",
26
26
  " queue show --repo <id> (--entry <id> | --pr <num>) [--events <count>] [--json]",
27
27
  " Show one queue entry with events and incidents",
28
- " queue watch --repo <id> [--pr <number>] Open the queue watch TUI",
28
+ " dashboard [--repo <id>] [--pr <number>] Open the multi-repo merge queue dashboard",
29
29
  "",
30
30
  "Service management:",
31
31
  " service install [--force] [--json] Reinstall the systemd unit",
@@ -83,7 +83,7 @@ function serviceHelpText() {
83
83
  "Commands:",
84
84
  " install [--force] [--json] Reinstall the systemd unit",
85
85
  " restart [--json] Reload-or-restart the service",
86
- " status [--json] Show systemd state",
86
+ " status [--json] Show systemd state and local health",
87
87
  " logs [--lines <count>] [--json]",
88
88
  " Show recent journal logs",
89
89
  ].join("\n");
@@ -94,9 +94,9 @@ function queueHelpText() {
94
94
  " merge-steward queue <command> [options]",
95
95
  "",
96
96
  "Commands:",
97
+ " dashboard [--repo <id>] [--pr <number>] Open the multi-repo merge queue dashboard",
97
98
  " status --repo <id> Show queue summary and entries",
98
99
  " show --repo <id> (--entry <id> | --pr <num>) Show one queue entry with events and incidents",
99
- " watch --repo <id> [--pr <number>] Open the queue watch TUI",
100
100
  " reconcile --repo <id> [--json] Ask the service to reconcile immediately",
101
101
  ].join("\n");
102
102
  }
@@ -40,6 +40,14 @@ export declare function fetchGatewayJson<T>(relativePath: string, options?: {
40
40
  method?: string;
41
41
  body?: unknown;
42
42
  }): Promise<T>;
43
+ export declare function fetchServiceHealthStatus(): Promise<{
44
+ reachable: true;
45
+ ok: boolean;
46
+ status: number;
47
+ } | {
48
+ reachable: false;
49
+ error: string;
50
+ }>;
43
51
  export declare function fetchLocalJson<T>(repoId: string, relativePath: string, options?: {
44
52
  method?: string;
45
53
  }): Promise<T>;
@@ -161,7 +161,7 @@ export function getGatewayBaseUrl() {
161
161
  throw new Error("merge-steward home not initialized.");
162
162
  }
163
163
  const homeConfig = parseHomeConfigObject(readFileSync(homeConfigPath, "utf8"), homeConfigPath);
164
- const bind = homeConfig.server.bind;
164
+ const bind = homeConfig.server.bind === "0.0.0.0" ? "127.0.0.1" : homeConfig.server.bind;
165
165
  const port = homeConfig.server.gateway_port ?? (homeConfig.server.port_base - 1);
166
166
  return `http://${bind}:${port}`;
167
167
  }
@@ -193,6 +193,34 @@ export async function fetchGatewayJson(relativePath, options) {
193
193
  const base = getGatewayBaseUrl();
194
194
  return await requestGatewayJson(`${base}${relativePath}`, options);
195
195
  }
196
+ export async function fetchServiceHealthStatus() {
197
+ try {
198
+ const response = await fetch(`${getGatewayBaseUrl()}/health`, {
199
+ signal: AbortSignal.timeout(2_000),
200
+ });
201
+ let ok = response.ok;
202
+ try {
203
+ const body = await response.json();
204
+ if (typeof body.ok === "boolean") {
205
+ ok = response.ok && body.ok;
206
+ }
207
+ }
208
+ catch {
209
+ ok = response.ok;
210
+ }
211
+ return {
212
+ reachable: true,
213
+ ok,
214
+ status: response.status,
215
+ };
216
+ }
217
+ catch (error) {
218
+ return {
219
+ reachable: false,
220
+ error: error instanceof Error ? error.message : String(error),
221
+ };
222
+ }
223
+ }
196
224
  export async function fetchLocalJson(repoId, relativePath, options) {
197
225
  return await fetchGatewayJson(`/repos/${repoId}${relativePath}`, options);
198
226
  }
package/dist/cli.js CHANGED
@@ -21,7 +21,10 @@ export async function runCli(argv, options) {
21
21
  return 0;
22
22
  }
23
23
  validateFlags(parsed);
24
- const command = parsed.positionals[0] ?? "help";
24
+ const requestedCommand = parsed.positionals[0] ?? "help";
25
+ const command = requestedCommand === "dash" || requestedCommand === "d"
26
+ ? "dashboard"
27
+ : requestedCommand;
25
28
  if (hasHelpFlag(parsed) || command === "help") {
26
29
  const topic = command === "help"
27
30
  ? (parsed.positionals[1] ?? "root")
@@ -64,6 +67,8 @@ export async function runCli(argv, options) {
64
67
  }
65
68
  case "doctor":
66
69
  return await (await import("./cli/commands/doctor.js")).handleDoctor(parsed, stdout);
70
+ case "dashboard":
71
+ return await (await import("./cli/commands/dashboard.js")).handleDashboard(parsed);
67
72
  case "service":
68
73
  return await (await import("./cli/commands/service.js")).handleService(parsed, stdout, runCommand);
69
74
  case "queue":
@@ -1,6 +1,9 @@
1
+ import type { DashboardRepoConfig } from "./dashboard-model.ts";
1
2
  interface AppProps {
2
- baseUrl: string;
3
+ gatewayBaseUrl: string;
4
+ repos: DashboardRepoConfig[];
5
+ initialRepoRef?: string | undefined;
3
6
  initialPrNumber?: number | undefined;
4
7
  }
5
- export declare function App({ baseUrl, initialPrNumber }: AppProps): React.JSX.Element;
8
+ export declare function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }: AppProps): React.JSX.Element;
6
9
  export {};