merge-steward 0.9.7 → 0.10.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,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,67 @@ 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 dashboard
262
+ merge-steward queue status --repo app
263
+ merge-steward queue show --repo app --pr 123
264
+ merge-steward service logs --lines 100
265
+ ```
266
+
267
+ Use them this way:
268
+
269
+ - `doctor` checks config, GitHub auth, branch rules, and required checks.
270
+ - `dashboard` is the best live operator view across all configured projects.
271
+ - `queue status` is the fastest text snapshot when you need one repo in a shell script or over SSH.
272
+ - `queue show --pr <number>` is the most direct way to inspect one PR's queue events and incidents.
273
+ - `service logs` helps when the queue is not reacting to webhooks, GitHub auth is failing, or reconcile ticks are erroring.
274
+
240
275
  ### systemd
241
276
 
242
277
  ```ini
@@ -264,15 +299,15 @@ WantedBy=multi-user.target
264
299
  | Endpoint | Method | Description |
265
300
  |-|-|-|
266
301
  | `/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 |
302
+ | `/repos/:repoId/queue/status` | GET | All queue entries for one configured repo |
303
+ | `/repos/:repoId/queue/watch` | GET | Queue snapshot used by the dashboard |
304
+ | `/repos/:repoId/queue/enqueue` | POST | Manually enqueue a PR |
305
+ | `/repos/:repoId/queue/reconcile` | POST | Trigger one reconcile tick immediately |
306
+ | `/repos/:repoId/queue/entries/:id/detail` | GET | Entry detail with recent events and incidents |
307
+ | `/repos/:repoId/queue/entries/:id/dequeue` | POST | Remove from queue (non-destructive) |
308
+ | `/repos/:repoId/queue/entries/:id/update-head` | POST | Update head SHA (force-push) |
309
+ | `/repos/:repoId/queue/incidents/:id` | GET | Get incident details |
310
+ | `/repos/:repoId/queue/entries/:id/incidents` | GET | List incidents for an entry |
276
311
  | `/webhooks/github` | POST | GitHub webhook receiver for all configured repos |
277
312
 
278
313
  ## 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);
package/dist/cli/help.js CHANGED
@@ -25,7 +25,7 @@ function rootHelpText() {
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",
@@ -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
  }
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 {};
package/dist/watch/App.js CHANGED
@@ -1,48 +1,47 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useMemo, useState } from "react";
3
3
  import { Box, Text, useApp, useInput } from "ink";
4
- import { buildDisplayEntries } from "./display-filter.js";
5
4
  import { dequeueEntry, fetchEntryDetail, fetchSnapshot, triggerReconcile } from "./api.js";
6
- import { DetailView } from "./DetailView.js";
5
+ import { getDefaultEntryId, getVisibleEntries, matchRepoRef } from "./dashboard-model.js";
7
6
  import { HelpBar } from "./HelpBar.js";
8
- import { QueueListView } from "./QueueListView.js";
7
+ import { OverviewView } from "./OverviewView.js";
8
+ import { ProjectDetailView } from "./ProjectDetailView.js";
9
9
  import { StatusBar } from "./StatusBar.js";
10
10
  const REFRESH_INTERVAL_MS = 1_500;
11
- function isActiveEntry(entry) {
12
- return entry.status !== "merged" && entry.status !== "evicted" && entry.status !== "dequeued";
13
- }
14
- function nextSelection(entries, selectedEntryId, direction) {
15
- if (entries.length === 0) {
11
+ function nextSelection(items, selectedId, direction) {
12
+ if (items.length === 0) {
16
13
  return null;
17
14
  }
18
- const index = entries.findIndex((entry) => entry.id === selectedEntryId);
15
+ const index = items.findIndex((item) => item.id === selectedId);
19
16
  const currentIndex = index === -1 ? 0 : index;
20
17
  const nextIndex = direction === "next"
21
- ? (currentIndex + 1) % entries.length
22
- : (currentIndex - 1 + entries.length) % entries.length;
23
- return entries[nextIndex]?.id ?? null;
18
+ ? (currentIndex + 1) % items.length
19
+ : (currentIndex - 1 + items.length) % items.length;
20
+ return items[nextIndex]?.id ?? null;
24
21
  }
25
- export function App({ baseUrl, initialPrNumber }) {
22
+ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }) {
26
23
  const { exit } = useApp();
27
- const [connected, setConnected] = useState(false);
28
- const [snapshot, setSnapshot] = useState(null);
24
+ const [repoStates, setRepoStates] = useState(() => repos.map((repo) => ({
25
+ ...repo,
26
+ snapshot: null,
27
+ error: null,
28
+ lastSnapshotReceivedAt: null,
29
+ })));
29
30
  const [lastSnapshotReceivedAt, setLastSnapshotReceivedAt] = useState(null);
30
31
  const [detail, setDetail] = useState(null);
31
- const [selectedEntryId, setSelectedEntryId] = useState(null);
32
- const [view, setView] = useState("list");
32
+ const [selectedRepoId, setSelectedRepoId] = useState(() => {
33
+ const initialRepo = repos.find((repo) => matchRepoRef(repo, initialRepoRef));
34
+ return initialRepo?.repoId ?? repos[0]?.repoId ?? null;
35
+ });
36
+ const [selectedEntryIds, setSelectedEntryIds] = useState({});
37
+ const [view, setView] = useState(initialRepoRef || initialPrNumber !== undefined ? "project" : "overview");
33
38
  const [filter, setFilter] = useState("active");
34
39
  const [flashMessage, setFlashMessage] = useState(null);
35
- const visibleEntries = useMemo(() => buildDisplayEntries(snapshot?.entries ?? [], filter), [filter, snapshot?.entries]);
36
- const selectedEntry = useMemo(() => visibleEntries.find((entry) => entry.id === selectedEntryId) ?? null, [selectedEntryId, visibleEntries]);
37
- const activeEntries = useMemo(() => (snapshot?.entries ?? []).filter(isActiveEntry), [snapshot?.entries]);
38
- const selectedActiveIndex = useMemo(() => {
39
- if (!selectedEntryId) {
40
- return null;
41
- }
42
- const index = activeEntries.findIndex((entry) => entry.id === selectedEntryId);
43
- return index === -1 ? null : index + 1;
44
- }, [activeEntries, selectedEntryId]);
45
- const isHeadSelected = activeEntries[0]?.id === selectedEntryId;
40
+ const [pendingInitialPrNumber, setPendingInitialPrNumber] = useState(initialPrNumber ?? null);
41
+ const repoSelectionItems = useMemo(() => repoStates.map((repo) => ({ id: repo.repoId })), [repoStates]);
42
+ const currentRepo = useMemo(() => repoStates.find((repo) => repo.repoId === selectedRepoId) ?? null, [repoStates, selectedRepoId]);
43
+ const currentVisibleEntries = useMemo(() => getVisibleEntries(currentRepo?.snapshot ?? null, filter), [currentRepo?.snapshot, filter]);
44
+ const selectedEntryId = currentRepo ? (selectedEntryIds[currentRepo.repoId] ?? null) : null;
46
45
  useEffect(() => {
47
46
  if (!flashMessage) {
48
47
  return;
@@ -53,32 +52,49 @@ export function App({ baseUrl, initialPrNumber }) {
53
52
  useEffect(() => {
54
53
  let cancelled = false;
55
54
  const refresh = async () => {
56
- try {
57
- const nextSnapshot = await fetchSnapshot(baseUrl);
58
- if (cancelled) {
59
- return;
55
+ const results = await Promise.all(repos.map(async (repo) => {
56
+ try {
57
+ const snapshot = await fetchSnapshot(gatewayBaseUrl, repo.repoId);
58
+ return { repoId: repo.repoId, snapshot, receivedAt: Date.now(), error: null };
60
59
  }
61
- setSnapshot(nextSnapshot);
62
- setLastSnapshotReceivedAt(Date.now());
63
- setConnected(true);
64
- setSelectedEntryId((current) => {
65
- if (current && nextSnapshot.entries.some((entry) => entry.id === current)) {
66
- return current;
67
- }
68
- if (initialPrNumber !== undefined) {
69
- const match = nextSnapshot.entries.find((entry) => entry.prNumber === initialPrNumber);
70
- if (match) {
71
- return match.id;
72
- }
73
- }
74
- return nextSnapshot.summary.headEntryId ?? nextSnapshot.entries[0]?.id ?? null;
75
- });
60
+ catch (error) {
61
+ return {
62
+ repoId: repo.repoId,
63
+ snapshot: null,
64
+ receivedAt: null,
65
+ error: error instanceof Error ? error.message : String(error),
66
+ };
67
+ }
68
+ }));
69
+ if (cancelled) {
70
+ return;
76
71
  }
77
- catch (error) {
78
- if (!cancelled) {
79
- setConnected(false);
80
- setFlashMessage(error instanceof Error ? error.message : String(error));
72
+ setRepoStates((current) => current.map((repo) => {
73
+ const result = results.find((candidate) => candidate.repoId === repo.repoId);
74
+ if (!result) {
75
+ return repo;
76
+ }
77
+ if (result.snapshot) {
78
+ return {
79
+ ...repo,
80
+ snapshot: result.snapshot,
81
+ error: null,
82
+ lastSnapshotReceivedAt: result.receivedAt,
83
+ };
84
+ }
85
+ return {
86
+ ...repo,
87
+ error: result.error,
88
+ };
89
+ }));
90
+ const latest = results.reduce((max, result) => {
91
+ if (result.receivedAt === null) {
92
+ return max;
81
93
  }
94
+ return max === null ? result.receivedAt : Math.max(max, result.receivedAt);
95
+ }, null);
96
+ if (latest !== null) {
97
+ setLastSnapshotReceivedAt(latest);
82
98
  }
83
99
  };
84
100
  void refresh();
@@ -89,16 +105,54 @@ export function App({ baseUrl, initialPrNumber }) {
89
105
  cancelled = true;
90
106
  clearInterval(interval);
91
107
  };
92
- }, [baseUrl, initialPrNumber]);
108
+ }, [gatewayBaseUrl, repos]);
93
109
  useEffect(() => {
94
- if (view !== "detail" || !selectedEntryId) {
110
+ if (pendingInitialPrNumber === null) {
111
+ return;
112
+ }
113
+ for (const repo of repoStates) {
114
+ const match = repo.snapshot?.entries.find((entry) => entry.prNumber === pendingInitialPrNumber);
115
+ if (!match) {
116
+ continue;
117
+ }
118
+ setSelectedRepoId(repo.repoId);
119
+ setSelectedEntryIds((current) => ({
120
+ ...current,
121
+ [repo.repoId]: match.id,
122
+ }));
123
+ setView("project");
124
+ setPendingInitialPrNumber(null);
125
+ return;
126
+ }
127
+ }, [pendingInitialPrNumber, repoStates]);
128
+ useEffect(() => {
129
+ if (!currentRepo) {
130
+ return;
131
+ }
132
+ const nextDefault = getDefaultEntryId(currentRepo.snapshot, filter, pendingInitialPrNumber);
133
+ setSelectedEntryIds((current) => {
134
+ const existing = current[currentRepo.repoId];
135
+ if (existing && currentVisibleEntries.some((entry) => entry.id === existing)) {
136
+ return current;
137
+ }
138
+ if (existing === nextDefault) {
139
+ return current;
140
+ }
141
+ return {
142
+ ...current,
143
+ [currentRepo.repoId]: nextDefault,
144
+ };
145
+ });
146
+ }, [currentRepo, currentVisibleEntries, filter, pendingInitialPrNumber]);
147
+ useEffect(() => {
148
+ if (view !== "project" || !currentRepo?.repoId || !selectedEntryId) {
95
149
  setDetail(null);
96
150
  return;
97
151
  }
98
152
  let cancelled = false;
99
153
  const refresh = async () => {
100
154
  try {
101
- const nextDetail = await fetchEntryDetail(baseUrl, selectedEntryId);
155
+ const nextDetail = await fetchEntryDetail(gatewayBaseUrl, currentRepo.repoId, selectedEntryId);
102
156
  if (!cancelled) {
103
157
  setDetail(nextDetail);
104
158
  }
@@ -117,31 +171,37 @@ export function App({ baseUrl, initialPrNumber }) {
117
171
  cancelled = true;
118
172
  clearInterval(interval);
119
173
  };
120
- }, [baseUrl, selectedEntryId, view]);
121
- async function runReconcile() {
174
+ }, [gatewayBaseUrl, currentRepo?.repoId, selectedEntryId, view]);
175
+ async function refreshRepo(repoId) {
176
+ const nextSnapshot = await fetchSnapshot(gatewayBaseUrl, repoId);
177
+ const receivedAt = Date.now();
178
+ setRepoStates((current) => current.map((repo) => repo.repoId === repoId
179
+ ? { ...repo, snapshot: nextSnapshot, error: null, lastSnapshotReceivedAt: receivedAt }
180
+ : repo));
181
+ setLastSnapshotReceivedAt(receivedAt);
182
+ }
183
+ async function runReconcile(repoId) {
184
+ if (!repoId) {
185
+ return;
186
+ }
122
187
  try {
123
- const result = await triggerReconcile(baseUrl);
124
- setFlashMessage(result.started ? "reconcile tick completed" : "reconcile already running");
125
- const nextSnapshot = await fetchSnapshot(baseUrl);
126
- setSnapshot(nextSnapshot);
127
- setLastSnapshotReceivedAt(Date.now());
128
- setConnected(true);
188
+ const result = await triggerReconcile(gatewayBaseUrl, repoId);
189
+ setFlashMessage(result.started ? `reconcile tick completed for ${repoId}` : `reconcile already running for ${repoId}`);
190
+ await refreshRepo(repoId);
129
191
  }
130
192
  catch (error) {
131
193
  setFlashMessage(error instanceof Error ? error.message : String(error));
132
194
  }
133
195
  }
134
196
  async function runDequeue() {
135
- if (!selectedEntryId) {
197
+ if (!currentRepo?.repoId || !selectedEntryId) {
136
198
  return;
137
199
  }
138
200
  try {
139
- await dequeueEntry(baseUrl, selectedEntryId);
140
- setFlashMessage(`dequeued ${selectedEntry ? `#${selectedEntry.prNumber}` : "entry"}`);
141
- const nextSnapshot = await fetchSnapshot(baseUrl);
142
- setSnapshot(nextSnapshot);
143
- setLastSnapshotReceivedAt(Date.now());
144
- setConnected(true);
201
+ const selectedEntry = currentRepo.snapshot?.entries.find((entry) => entry.id === selectedEntryId) ?? null;
202
+ await dequeueEntry(gatewayBaseUrl, currentRepo.repoId, selectedEntryId);
203
+ setFlashMessage(`dequeued ${selectedEntry ? `#${selectedEntry.prNumber}` : "entry"} from ${currentRepo.repoId}`);
204
+ await refreshRepo(currentRepo.repoId);
145
205
  }
146
206
  catch (error) {
147
207
  setFlashMessage(error instanceof Error ? error.message : String(error));
@@ -153,38 +213,48 @@ export function App({ baseUrl, initialPrNumber }) {
153
213
  return;
154
214
  }
155
215
  if (input === "r") {
156
- void runReconcile();
157
- return;
158
- }
159
- if (input === "d") {
160
- void runDequeue();
216
+ void runReconcile(selectedRepoId);
161
217
  return;
162
218
  }
163
- if (view === "list") {
164
- if (input === "a") {
165
- setFilter((current) => (current === "active" ? "all" : "active"));
166
- }
167
- else if (input === "j" || key.downArrow) {
168
- setSelectedEntryId(nextSelection(visibleEntries, selectedEntryId, "next"));
219
+ if (view === "overview") {
220
+ if (input === "j" || key.downArrow) {
221
+ setSelectedRepoId(nextSelection(repoSelectionItems, selectedRepoId, "next"));
169
222
  }
170
223
  else if (input === "k" || key.upArrow) {
171
- setSelectedEntryId(nextSelection(visibleEntries, selectedEntryId, "prev"));
224
+ setSelectedRepoId(nextSelection(repoSelectionItems, selectedRepoId, "prev"));
172
225
  }
173
- else if (key.return && selectedEntryId) {
174
- setView("detail");
226
+ else if (key.return && selectedRepoId) {
227
+ setView("project");
175
228
  }
176
229
  return;
177
230
  }
231
+ if (input === "d") {
232
+ void runDequeue();
233
+ return;
234
+ }
178
235
  if (key.escape || key.backspace || key.delete) {
179
- setView("list");
236
+ setView("overview");
180
237
  return;
181
238
  }
182
- if (input === "j" || key.downArrow) {
183
- setSelectedEntryId(nextSelection(visibleEntries, selectedEntryId, "next"));
239
+ if (input === "a") {
240
+ setFilter((current) => current === "active" ? "all" : "active");
241
+ }
242
+ else if (input === "j" || key.downArrow) {
243
+ if (currentRepo?.repoId) {
244
+ setSelectedEntryIds((current) => ({
245
+ ...current,
246
+ [currentRepo.repoId]: nextSelection(currentVisibleEntries, current[currentRepo.repoId] ?? null, "next"),
247
+ }));
248
+ }
184
249
  }
185
250
  else if (input === "k" || key.upArrow) {
186
- setSelectedEntryId(nextSelection(visibleEntries, selectedEntryId, "prev"));
251
+ if (currentRepo?.repoId) {
252
+ setSelectedEntryIds((current) => ({
253
+ ...current,
254
+ [currentRepo.repoId]: nextSelection(currentVisibleEntries, current[currentRepo.repoId] ?? null, "prev"),
255
+ }));
256
+ }
187
257
  }
188
258
  });
189
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { snapshot: snapshot, connected: connected, filter: filter, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: REFRESH_INTERVAL_MS * 2 }), view === "detail" && selectedEntry && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Queue" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsxs(Text, { bold: true, children: ["#", selectedEntry.prNumber] }), _jsxs(Text, { dimColor: true, children: [" (", selectedEntry.status, ")"] })] })), view === "list" ? (_jsx(QueueListView, { entries: visibleEntries, allEntries: snapshot?.entries ?? [], selectedEntryId: selectedEntryId, recentEvents: snapshot?.recentEvents ?? [], headEntryId: snapshot?.summary.headEntryId ?? null, queueBlock: snapshot?.queueBlock ?? null })) : (_jsx(DetailView, { detail: detail, isHead: isHeadSelected, activeIndex: selectedActiveIndex, activeCount: activeEntries.length, headPrNumber: snapshot?.summary.headPrNumber ?? null, queueBlock: snapshot?.queueBlock ?? null })), flashMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })), _jsx(HelpBar, { view: view })] }));
259
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { repos: repoStates, currentRepo: currentRepo, view: view, filter: filter, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: REFRESH_INTERVAL_MS * 2 }), view === "overview" ? (_jsx(OverviewView, { repos: repoStates, selectedRepoId: selectedRepoId })) : (_jsx(ProjectDetailView, { repo: currentRepo, selectedEntryId: selectedEntryId, detail: detail, filter: filter })), flashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, _jsx(HelpBar, { view: view })] }));
190
260
  }
@@ -1,5 +1,5 @@
1
1
  interface HelpBarProps {
2
- view: "list" | "detail";
2
+ view: "overview" | "project";
3
3
  }
4
4
  export declare function HelpBar({ view }: HelpBarProps): React.JSX.Element;
5
5
  export {};
@@ -1,8 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  export function HelpBar({ view }) {
4
- const text = view === "list"
5
- ? "j/k: navigate Enter: detail a: filter r: reconcile d: dequeue q: quit"
6
- : "j/k: prev/next Esc: list r: reconcile d: dequeue q: quit";
4
+ const text = view === "overview"
5
+ ? "j/k: select project Enter: open project r: reconcile selected project q: quit"
6
+ : "j/k: select PR Esc: overview a: filter r: reconcile project d: dequeue PR q: quit";
7
7
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }));
8
8
  }
@@ -0,0 +1,7 @@
1
+ import type { DashboardRepoState } from "./dashboard-model.ts";
2
+ interface OverviewViewProps {
3
+ repos: DashboardRepoState[];
4
+ selectedRepoId: string | null;
5
+ }
6
+ export declare function OverviewView({ repos, selectedRepoId }: OverviewViewProps): React.JSX.Element;
7
+ export {};
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useStdout } from "ink";
3
+ import { ciStatusIcon, summarizeQueueBlock, truncate } from "./format.js";
4
+ import { getChainEntries, getClusterSummary, getRepoHealth, projectStatsSummary } from "./dashboard-model.js";
5
+ function clampWindowStart(selectedIndex, itemCount, maxItems) {
6
+ if (itemCount <= maxItems) {
7
+ return 0;
8
+ }
9
+ const half = Math.floor(maxItems / 2);
10
+ return Math.max(0, Math.min(itemCount - maxItems, selectedIndex - half));
11
+ }
12
+ export function OverviewView({ repos, selectedRepoId }) {
13
+ const { stdout } = useStdout();
14
+ const rows = stdout?.rows ?? 24;
15
+ const cluster = getClusterSummary(repos);
16
+ const selectedIndex = Math.max(0, repos.findIndex((repo) => repo.repoId === selectedRepoId));
17
+ const maxItems = Math.max(1, Math.floor((rows - 8) / 2));
18
+ const startIndex = clampWindowStart(selectedIndex, repos.length, maxItems);
19
+ const visibleRepos = repos.slice(startIndex, startIndex + maxItems);
20
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Queue Overview" }), _jsxs(Text, { dimColor: true, children: [cluster.total, " projects \u00B7 ", cluster.connected, " connected \u00B7 ", cluster.active, " active \u00B7 ", cluster.blocked, " blocked \u00B7 ", cluster.stuck, " stuck \u00B7 ", cluster.attention, " need attention"] }), repos.length === 0 ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["No repositories are attached yet. Run ", "`merge-steward repo attach <owner/repo>`", " first."] }) })) : (_jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleRepos.map((repo) => {
21
+ const health = getRepoHealth(repo);
22
+ const queueBlockSummary = summarizeQueueBlock(repo.snapshot?.queueBlock);
23
+ const chainEntries = getChainEntries(repo.snapshot);
24
+ const chainText = repo.snapshot
25
+ ? chainEntries.length > 0
26
+ ? chainEntries.map((entry) => {
27
+ const ci = ciStatusIcon(entry);
28
+ return `#${entry.prNumber} ${ci.icon}`;
29
+ }).join(" ")
30
+ : "queue is empty"
31
+ : "queue data unavailable";
32
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: repo.repoId === selectedRepoId ? "cyan" : "gray", children: repo.repoId === selectedRepoId ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { dimColor: true, children: ` ${truncate(repo.repoFullName, 28)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: health.label }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(repo.snapshot)}` })] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `Queue: ${truncate(chainText, 100)}` }) }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: health.color, children: truncate(queueBlockSummary ?? health.detail, 110) }) })] }, repo.repoId));
33
+ }) }))] }));
34
+ }
@@ -0,0 +1,10 @@
1
+ import type { QueueEntryDetail } from "../types.ts";
2
+ import type { DashboardRepoState } from "./dashboard-model.ts";
3
+ interface ProjectDetailViewProps {
4
+ repo: DashboardRepoState | null;
5
+ selectedEntryId: string | null;
6
+ detail: QueueEntryDetail | null;
7
+ filter: "active" | "all";
8
+ }
9
+ export declare function ProjectDetailView({ repo, selectedEntryId, detail, filter, }: ProjectDetailViewProps): React.JSX.Element;
10
+ export {};
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useStdout } from "ink";
3
+ import { buildDisplayEntries } from "./display-filter.js";
4
+ import { describeEntry, getChainEntries, getRepoHealth, projectStatsSummary, runtimeSummary } from "./dashboard-model.js";
5
+ import { ciStatusIcon, formatEventNarrative, humanStatus, relativeTime, shortSha, specChainLabel, summarizeQueueBlock, truncate } from "./format.js";
6
+ function clampWindowStart(selectedIndex, itemCount, maxItems) {
7
+ if (itemCount <= maxItems) {
8
+ return 0;
9
+ }
10
+ const half = Math.floor(maxItems / 2);
11
+ return Math.max(0, Math.min(itemCount - maxItems, selectedIndex - half));
12
+ }
13
+ function selectedEntryIndex(snapshot, filter, entryId) {
14
+ if (!snapshot || !entryId) {
15
+ return 0;
16
+ }
17
+ const entries = buildDisplayEntries(snapshot.entries, filter);
18
+ const index = entries.findIndex((entry) => entry.id === entryId);
19
+ return index >= 0 ? index : 0;
20
+ }
21
+ export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
22
+ const { stdout } = useStdout();
23
+ const rows = stdout?.rows ?? 24;
24
+ if (!repo) {
25
+ return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No project selected." }) }));
26
+ }
27
+ if (!repo.snapshot) {
28
+ return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "red", children: ["Queue data is unavailable for ", repo.repoId, "."] }), repo.error ? _jsx(Text, { dimColor: true, children: repo.error }) : null] }));
29
+ }
30
+ const snapshot = repo.snapshot;
31
+ const health = getRepoHealth(repo);
32
+ const queueBlockSummary = summarizeQueueBlock(snapshot.queueBlock);
33
+ const entries = buildDisplayEntries(snapshot.entries, filter);
34
+ const currentSelectedIndex = selectedEntryIndex(snapshot, filter, selectedEntryId);
35
+ const maxEntryRows = Math.max(3, Math.floor((rows - 16) / 2));
36
+ const entryWindowStart = clampWindowStart(currentSelectedIndex, entries.length, maxEntryRows);
37
+ const visibleEntries = entries.slice(entryWindowStart, entryWindowStart + maxEntryRows);
38
+ const selectedEntry = entries.find((entry) => entry.id === selectedEntryId)
39
+ ?? snapshot.entries.find((entry) => entry.id === selectedEntryId)
40
+ ?? null;
41
+ const visibleEvents = detail
42
+ ? detail.events.slice(-6)
43
+ : snapshot.recentEvents.slice(-6);
44
+ const chainEntries = getChainEntries(snapshot);
45
+ const chainText = chainEntries.length > 0
46
+ ? chainEntries.map((entry) => `#${entry.prNumber}`).join(" -> ")
47
+ : "empty";
48
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { dimColor: true, children: ` ${repo.repoFullName}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: health.label })] }), _jsx(Text, { dimColor: true, children: projectStatsSummary(snapshot) }), _jsx(Text, { dimColor: true, children: runtimeSummary(snapshot) }), _jsx(Text, { dimColor: true, children: `Queue: main -> ${truncate(chainText, 96)}` }), queueBlockSummary ? _jsx(Text, { color: "yellow", children: queueBlockSummary }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pull Requests" }), visibleEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (visibleEntries.map((entry) => {
49
+ const ci = ciStatusIcon(entry);
50
+ const isHead = snapshot.summary.headEntryId === entry.id;
51
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: entry.id === selectedEntryId ? "cyan" : "gray", children: entry.id === selectedEntryId ? "\u25b8" : " " }), _jsx(Text, { bold: true, children: `#${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { children: ` ` }), _jsx(Text, { color: ci.color, children: ci.icon }), _jsx(Text, { children: ` ` }), _jsx(Text, { ...(entry.status === "evicted" ? { color: "red" } : {}), children: humanStatus(entry.status, entry) }), _jsx(Text, { dimColor: true, children: ` updated ${relativeTime(entry.updatedAt)} ago` })] }), (entry.id === selectedEntryId || entry.status === "evicted" || (isHead && queueBlockSummary)) && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: truncate(describeEntry(entry, { isHead, queueBlockSummary }), 110) }) }))] }, entry.id));
52
+ }))] }), selectedEntry && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Selected PR #${selectedEntry.prNumber}` }), _jsxs(Text, { dimColor: true, children: [selectedEntry.branch, " \u00B7 head ", shortSha(selectedEntry.headSha), " \u00B7 base ", shortSha(selectedEntry.baseSha)] }), _jsx(Text, { dimColor: true, children: `Test branch: ${specChainLabel(selectedEntry, snapshot.entries)}` }), detail?.incidents.length
53
+ ? detail.incidents.slice(-2).map((incident) => (_jsxs(Text, { color: "red", children: [relativeTime(incident.at), " ago \u00B7 ", incident.failureClass, " \u00B7 ", incident.outcome] }, incident.id)))
54
+ : null] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: detail ? "Selected PR Activity" : "Recent Project Activity" }), visibleEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (visibleEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: truncate(formatEventNarrative(event), 106) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
55
+ }
@@ -1,10 +1,11 @@
1
- import type { QueueWatchSnapshot } from "../types.ts";
1
+ import type { DashboardRepoState } from "./dashboard-model.ts";
2
2
  interface StatusBarProps {
3
- snapshot: QueueWatchSnapshot | null;
4
- connected: boolean;
3
+ repos: DashboardRepoState[];
4
+ currentRepo: DashboardRepoState | null;
5
+ view: "overview" | "project";
5
6
  filter: "active" | "all";
6
7
  lastSnapshotReceivedAt: number | null;
7
8
  expectedFreshMs: number;
8
9
  }
9
- export declare function StatusBar({ snapshot, connected, filter, lastSnapshotReceivedAt, expectedFreshMs, }: StatusBarProps): React.JSX.Element;
10
+ export declare function StatusBar({ repos, currentRepo, view, filter, lastSnapshotReceivedAt, expectedFreshMs, }: StatusBarProps): React.JSX.Element;
10
11
  export {};
@@ -1,37 +1,32 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
- import { formatDuration, relativeTime, runtimeLabel, summarizeQueueBlock, truncate } from "./format.js";
3
+ import { getClusterSummary, getRepoHealth } from "./dashboard-model.js";
4
+ import { truncate } from "./format.js";
4
5
  import { FreshnessBadge } from "./FreshnessBadge.js";
5
- const TERMINAL = new Set(["merged", "evicted", "dequeued"]);
6
- export function StatusBar({ snapshot, connected, filter, lastSnapshotReceivedAt, expectedFreshMs, }) {
6
+ export function StatusBar({ repos, currentRepo, view, filter, lastSnapshotReceivedAt, expectedFreshMs, }) {
7
7
  const { stdout } = useStdout();
8
8
  const width = stdout?.columns ?? 80;
9
- if (!snapshot) {
10
- const left = "merge-steward";
11
- const availableLeft = Math.max(1, width - 28);
12
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: truncate(left, availableLeft) }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: expectedFreshMs })] }));
13
- }
14
- const { summary, runtime } = snapshot;
15
- const queueBlockLabel = summarizeQueueBlock(snapshot.queueBlock);
16
- const activeEntries = snapshot.entries.filter((e) => !TERMINAL.has(e.status));
17
- const avgWaitMs = activeEntries.length > 0
18
- ? activeEntries.reduce((sum, e) => sum + (Date.now() - new Date(e.enqueuedAt).getTime()), 0) / activeEntries.length
19
- : 0;
20
- const queueHealth = queueBlockLabel
21
- ? `waiting on main`
22
- : summary.headPrNumber !== null
23
- ? `head #${summary.headPrNumber} active`
24
- : "queue idle";
25
- const leftParts = [
26
- snapshot.repoFullName,
27
- `base:${snapshot.baseBranch}`,
28
- `${summary.total} entries ${summary.active} active`,
29
- queueHealth,
30
- queueBlockLabel ?? null,
31
- avgWaitMs > 0 ? `wait ~${formatDuration(avgWaitMs)}` : null,
32
- `last tick ${runtimeLabel(runtime)} ${relativeTime(runtime.lastTickCompletedAt ?? runtime.lastTickStartedAt)}`,
33
- filter,
34
- ].filter(Boolean).join(" | ");
9
+ const cluster = getClusterSummary(repos);
10
+ const connected = cluster.connected > 0;
11
+ const repoHealth = currentRepo ? getRepoHealth(currentRepo) : null;
12
+ const leftParts = view === "project" && currentRepo
13
+ ? [
14
+ "merge-steward dashboard",
15
+ currentRepo.repoId,
16
+ repoHealth?.label ?? null,
17
+ currentRepo.snapshot ? `${currentRepo.snapshot.summary.active} active` : null,
18
+ currentRepo.snapshot?.summary.headPrNumber ? `head #${currentRepo.snapshot.summary.headPrNumber}` : "no head PR",
19
+ `filter:${filter}`,
20
+ ].filter(Boolean).join(" | ")
21
+ : [
22
+ "merge-steward dashboard",
23
+ `${cluster.total} projects`,
24
+ `${cluster.connected} connected`,
25
+ `${cluster.active} active`,
26
+ `${cluster.blocked} blocked`,
27
+ `${cluster.stuck} stuck`,
28
+ `${cluster.attention} need attention`,
29
+ ].join(" | ");
35
30
  const availableLeft = Math.max(1, width - 28);
36
31
  return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: truncate(leftParts, availableLeft) }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: expectedFreshMs })] }));
37
32
  }
@@ -1,8 +1,8 @@
1
1
  import type { QueueEntryDetail, QueueWatchSnapshot } from "../types.ts";
2
- export declare function fetchSnapshot(baseUrl: string): Promise<QueueWatchSnapshot>;
3
- export declare function fetchEntryDetail(baseUrl: string, entryId: string): Promise<QueueEntryDetail>;
4
- export declare function triggerReconcile(baseUrl: string): Promise<{
2
+ export declare function fetchSnapshot(gatewayBaseUrl: string, repoId: string): Promise<QueueWatchSnapshot>;
3
+ export declare function fetchEntryDetail(gatewayBaseUrl: string, repoId: string, entryId: string): Promise<QueueEntryDetail>;
4
+ export declare function triggerReconcile(gatewayBaseUrl: string, repoId: string): Promise<{
5
5
  ok: true;
6
6
  started: boolean;
7
7
  }>;
8
- export declare function dequeueEntry(baseUrl: string, entryId: string): Promise<void>;
8
+ export declare function dequeueEntry(gatewayBaseUrl: string, repoId: string, entryId: string): Promise<void>;
package/dist/watch/api.js CHANGED
@@ -33,15 +33,19 @@ function buildUrl(baseUrl, path, params) {
33
33
  }
34
34
  return url.toString();
35
35
  }
36
- export async function fetchSnapshot(baseUrl) {
37
- return await requestJson(buildUrl(baseUrl, "/queue/watch", { eventLimit: "40" }));
36
+ function repoBaseUrl(gatewayBaseUrl, repoId) {
37
+ const base = gatewayBaseUrl.replace(/\/$/, "");
38
+ return `${base}/repos/${encodeURIComponent(repoId)}`;
38
39
  }
39
- export async function fetchEntryDetail(baseUrl, entryId) {
40
- return await requestJson(buildUrl(baseUrl, `/queue/entries/${encodeURIComponent(entryId)}/detail`, { eventLimit: "120" }));
40
+ export async function fetchSnapshot(gatewayBaseUrl, repoId) {
41
+ return await requestJson(buildUrl(repoBaseUrl(gatewayBaseUrl, repoId), "/queue/watch", { eventLimit: "40" }));
41
42
  }
42
- export async function triggerReconcile(baseUrl) {
43
- return await requestJson(buildUrl(baseUrl, "/queue/reconcile"), { method: "POST" });
43
+ export async function fetchEntryDetail(gatewayBaseUrl, repoId, entryId) {
44
+ return await requestJson(buildUrl(repoBaseUrl(gatewayBaseUrl, repoId), `/queue/entries/${encodeURIComponent(entryId)}/detail`, { eventLimit: "120" }));
44
45
  }
45
- export async function dequeueEntry(baseUrl, entryId) {
46
- await requestJson(buildUrl(baseUrl, `/queue/entries/${encodeURIComponent(entryId)}/dequeue`), { method: "POST" });
46
+ export async function triggerReconcile(gatewayBaseUrl, repoId) {
47
+ return await requestJson(buildUrl(repoBaseUrl(gatewayBaseUrl, repoId), "/queue/reconcile"), { method: "POST" });
48
+ }
49
+ export async function dequeueEntry(gatewayBaseUrl, repoId, entryId) {
50
+ await requestJson(buildUrl(repoBaseUrl(gatewayBaseUrl, repoId), `/queue/entries/${encodeURIComponent(entryId)}/dequeue`), { method: "POST" });
47
51
  }
@@ -0,0 +1,38 @@
1
+ import type { QueueEntry, QueueWatchSnapshot } from "../types.ts";
2
+ export interface DashboardRepoConfig {
3
+ repoId: string;
4
+ repoFullName: string;
5
+ baseBranch: string;
6
+ }
7
+ export interface DashboardRepoState extends DashboardRepoConfig {
8
+ snapshot: QueueWatchSnapshot | null;
9
+ error: string | null;
10
+ lastSnapshotReceivedAt: number | null;
11
+ }
12
+ export interface RepoHealthSummary {
13
+ kind: "offline" | "blocked" | "stuck" | "attention" | "active" | "idle";
14
+ label: string;
15
+ color: "red" | "yellow" | "green" | "gray" | "cyan";
16
+ detail: string;
17
+ }
18
+ export interface ClusterSummary {
19
+ total: number;
20
+ connected: number;
21
+ blocked: number;
22
+ stuck: number;
23
+ attention: number;
24
+ active: number;
25
+ }
26
+ export declare function matchRepoRef(repo: DashboardRepoConfig, repoRef: string | undefined): boolean;
27
+ export declare function getVisibleEntries(snapshot: QueueWatchSnapshot | null, filter: "active" | "all"): QueueEntry[];
28
+ export declare function getChainEntries(snapshot: QueueWatchSnapshot | null): QueueEntry[];
29
+ export declare function getActiveEntries(snapshot: QueueWatchSnapshot | null): QueueEntry[];
30
+ export declare function getDefaultEntryId(snapshot: QueueWatchSnapshot | null, filter: "active" | "all", preferredPrNumber?: number | null): string | null;
31
+ export declare function getRepoHealth(repo: DashboardRepoState, now?: number): RepoHealthSummary;
32
+ export declare function getClusterSummary(repos: DashboardRepoState[], now?: number): ClusterSummary;
33
+ export declare function projectStatsSummary(snapshot: QueueWatchSnapshot | null): string;
34
+ export declare function runtimeSummary(snapshot: QueueWatchSnapshot | null): string;
35
+ export declare function describeEntry(entry: QueueEntry, options: {
36
+ isHead: boolean;
37
+ queueBlockSummary: string | null;
38
+ }): string;
@@ -0,0 +1,192 @@
1
+ import { buildChainEntries, buildDisplayEntries } from "./display-filter.js";
2
+ import { formatDuration, humanStatus, nextStepLabel, relativeTime, summarizeQueueBlock } from "./format.js";
3
+ const RECENT_EVICTION_MS = 60 * 60 * 1_000;
4
+ const STUCK_ENTRY_MS = 25 * 60 * 1_000;
5
+ const OFFLINE_STALE_MS = 10 * 1_000;
6
+ export function matchRepoRef(repo, repoRef) {
7
+ if (!repoRef) {
8
+ return false;
9
+ }
10
+ return repo.repoId === repoRef || repo.repoFullName === repoRef;
11
+ }
12
+ export function getVisibleEntries(snapshot, filter) {
13
+ if (!snapshot) {
14
+ return [];
15
+ }
16
+ return buildDisplayEntries(snapshot.entries, filter);
17
+ }
18
+ export function getChainEntries(snapshot) {
19
+ if (!snapshot) {
20
+ return [];
21
+ }
22
+ return buildChainEntries(snapshot.entries);
23
+ }
24
+ export function getActiveEntries(snapshot) {
25
+ if (!snapshot) {
26
+ return [];
27
+ }
28
+ return snapshot.entries.filter((entry) => (entry.status !== "merged" && entry.status !== "evicted" && entry.status !== "dequeued"));
29
+ }
30
+ export function getDefaultEntryId(snapshot, filter, preferredPrNumber) {
31
+ if (!snapshot) {
32
+ return null;
33
+ }
34
+ const visibleEntries = getVisibleEntries(snapshot, filter);
35
+ if (preferredPrNumber !== undefined && preferredPrNumber !== null) {
36
+ const match = visibleEntries.find((entry) => entry.prNumber === preferredPrNumber)
37
+ ?? snapshot.entries.find((entry) => entry.prNumber === preferredPrNumber);
38
+ if (match) {
39
+ return match.id;
40
+ }
41
+ }
42
+ return snapshot.summary.headEntryId ?? visibleEntries[0]?.id ?? snapshot.entries[0]?.id ?? null;
43
+ }
44
+ export function getRepoHealth(repo, now = Date.now()) {
45
+ const snapshot = repo.snapshot;
46
+ const staleForMs = repo.lastSnapshotReceivedAt === null ? Infinity : now - repo.lastSnapshotReceivedAt;
47
+ if (!snapshot || (repo.error && staleForMs > OFFLINE_STALE_MS)) {
48
+ return {
49
+ kind: "offline",
50
+ label: "Offline",
51
+ color: "red",
52
+ detail: repo.error ?? "No queue data from merge-steward yet.",
53
+ };
54
+ }
55
+ if (snapshot.queueBlock) {
56
+ return {
57
+ kind: "blocked",
58
+ label: "Blocked",
59
+ color: "yellow",
60
+ detail: summarizeQueueBlock(snapshot.queueBlock) ?? `Waiting for ${snapshot.baseBranch} to recover.`,
61
+ };
62
+ }
63
+ if (snapshot.runtime.lastTickOutcome === "failed") {
64
+ return {
65
+ kind: "stuck",
66
+ label: "Stuck",
67
+ color: "red",
68
+ detail: snapshot.runtime.lastTickError?.split(/\r?\n/, 1)[0] ?? "The last reconcile tick failed.",
69
+ };
70
+ }
71
+ const activeEntries = getActiveEntries(snapshot);
72
+ const headEntry = activeEntries[0];
73
+ if (headEntry) {
74
+ const ageMs = now - new Date(headEntry.updatedAt).getTime();
75
+ if (ageMs >= STUCK_ENTRY_MS) {
76
+ return {
77
+ kind: "stuck",
78
+ label: "Stuck",
79
+ color: "red",
80
+ detail: `Head PR #${headEntry.prNumber} has not moved for ${relativeTime(headEntry.updatedAt)}.`,
81
+ };
82
+ }
83
+ }
84
+ const recentEvicted = snapshot.entries.find((entry) => (entry.status === "evicted"
85
+ && now - new Date(entry.updatedAt).getTime() <= RECENT_EVICTION_MS));
86
+ if (recentEvicted) {
87
+ return {
88
+ kind: "attention",
89
+ label: "Needs attention",
90
+ color: "yellow",
91
+ detail: `PR #${recentEvicted.prNumber} was evicted and needs repair before it can rejoin the queue.`,
92
+ };
93
+ }
94
+ if (activeEntries.length > 0) {
95
+ return {
96
+ kind: "active",
97
+ label: "Running",
98
+ color: "cyan",
99
+ detail: headEntry
100
+ ? `Head PR #${headEntry.prNumber} is ${humanStatus(headEntry.status, headEntry)}.`
101
+ : `${activeEntries.length} PRs are active in the queue.`,
102
+ };
103
+ }
104
+ return {
105
+ kind: "idle",
106
+ label: "Idle",
107
+ color: "green",
108
+ detail: snapshot.summary.total > 0
109
+ ? `No active queue work right now. Last activity was ${relativeTime(snapshot.entries[0]?.updatedAt)} ago.`
110
+ : "No pull requests are queued right now.",
111
+ };
112
+ }
113
+ export function getClusterSummary(repos, now = Date.now()) {
114
+ const initial = {
115
+ total: repos.length,
116
+ connected: 0,
117
+ blocked: 0,
118
+ stuck: 0,
119
+ attention: 0,
120
+ active: 0,
121
+ };
122
+ return repos.reduce((summary, repo) => {
123
+ const health = getRepoHealth(repo, now);
124
+ if (health.kind !== "offline") {
125
+ summary.connected += 1;
126
+ }
127
+ if (health.kind === "blocked") {
128
+ summary.blocked += 1;
129
+ }
130
+ if (health.kind === "stuck") {
131
+ summary.stuck += 1;
132
+ }
133
+ if (health.kind === "attention") {
134
+ summary.attention += 1;
135
+ }
136
+ if (repo.snapshot && getActiveEntries(repo.snapshot).length > 0) {
137
+ summary.active += 1;
138
+ }
139
+ return summary;
140
+ }, initial);
141
+ }
142
+ export function projectStatsSummary(snapshot) {
143
+ if (!snapshot) {
144
+ return "No queue data yet.";
145
+ }
146
+ const { summary } = snapshot;
147
+ const activeEntries = getActiveEntries(snapshot);
148
+ const avgWaitMs = activeEntries.length > 0
149
+ ? activeEntries.reduce((sum, entry) => sum + (Date.now() - new Date(entry.enqueuedAt).getTime()), 0) / activeEntries.length
150
+ : 0;
151
+ const parts = [
152
+ `${summary.active} active`,
153
+ `${summary.queued} waiting`,
154
+ `${summary.validating} testing`,
155
+ `${summary.merging} merging`,
156
+ ];
157
+ if (summary.evicted > 0) {
158
+ parts.push(`${summary.evicted} need repair`);
159
+ }
160
+ if (avgWaitMs > 0) {
161
+ parts.push(`avg wait ${formatDuration(avgWaitMs)}`);
162
+ }
163
+ return parts.join(" · ");
164
+ }
165
+ export function runtimeSummary(snapshot) {
166
+ if (!snapshot) {
167
+ return "No runtime data yet.";
168
+ }
169
+ if (snapshot.runtime.tickInProgress) {
170
+ return `Reconcile tick is running now. Last completed ${relativeTime(snapshot.runtime.lastTickCompletedAt)} ago.`;
171
+ }
172
+ if (snapshot.runtime.lastTickOutcome === "failed") {
173
+ return `Last reconcile tick failed ${relativeTime(snapshot.runtime.lastTickCompletedAt ?? snapshot.runtime.lastTickStartedAt)} ago.`;
174
+ }
175
+ if (snapshot.runtime.lastTickCompletedAt) {
176
+ return `Last reconcile tick ${snapshot.runtime.lastTickOutcome} ${relativeTime(snapshot.runtime.lastTickCompletedAt)} ago.`;
177
+ }
178
+ return "No reconcile tick has completed yet.";
179
+ }
180
+ export function describeEntry(entry, options) {
181
+ if (options.isHead && options.queueBlockSummary) {
182
+ return options.queueBlockSummary;
183
+ }
184
+ if (entry.status === "queued") {
185
+ if (entry.position > 1) {
186
+ return `Waiting behind ${entry.position - 1} earlier PRs.`;
187
+ }
188
+ return "First in line and waiting for the next reconcile tick.";
189
+ }
190
+ const nextStep = nextStepLabel(entry.status, entry);
191
+ return `${humanStatus(entry.status, entry)}: ${nextStep}.`;
192
+ }
@@ -28,6 +28,7 @@ export declare function specChainLabel(entry: {
28
28
  export declare function runtimeLabel(runtime: QueueRuntimeStatus): string;
29
29
  export declare function formatEventSummary(event: QueueEventSummary): string;
30
30
  export declare function formatEntryEvent(event: QueueEventRecord): string;
31
+ export declare function formatEventNarrative(event: QueueEventRecord | QueueEventSummary): string;
31
32
  export declare function formatDuration(ms: number): string;
32
33
  export declare function progressBar(current: number, total: number, width: number): string;
33
34
  export declare function truncate(value: string, width: number): string;
@@ -149,6 +149,40 @@ export function formatEntryEvent(event) {
149
149
  const detail = event.detail ? ` (${shortenHashes(event.detail)})` : "";
150
150
  return `${transition}${detail}`;
151
151
  }
152
+ function withDetail(text, detail) {
153
+ if (!detail) {
154
+ return text;
155
+ }
156
+ return `${text} ${shortenHashes(detail)}`;
157
+ }
158
+ export function formatEventNarrative(event) {
159
+ const prPrefix = "prNumber" in event ? `PR #${event.prNumber} ` : "This PR ";
160
+ if (!event.fromStatus && event.toStatus === "queued") {
161
+ return withDetail(`${prPrefix}entered the merge queue.`, event.detail);
162
+ }
163
+ if (event.toStatus === "preparing_head") {
164
+ return withDetail(`${prPrefix}started preparing its combined test branch.`, event.detail);
165
+ }
166
+ if (event.toStatus === "validating") {
167
+ return withDetail(`${prPrefix}started running CI.`, event.detail);
168
+ }
169
+ if (event.toStatus === "merging") {
170
+ return withDetail(`${prPrefix}passed CI and started merging to main.`, event.detail);
171
+ }
172
+ if (event.toStatus === "merged") {
173
+ return withDetail(`${prPrefix}landed on main.`, event.detail);
174
+ }
175
+ if (event.toStatus === "evicted") {
176
+ return withDetail(`${prPrefix}was evicted and needs repair before it can rejoin the queue.`, event.detail);
177
+ }
178
+ if (event.toStatus === "dequeued") {
179
+ return withDetail(`${prPrefix}was removed from the queue.`, event.detail);
180
+ }
181
+ if (event.toStatus === "queued" && event.fromStatus) {
182
+ return withDetail(`${prPrefix}was re-queued for another attempt.`, event.detail);
183
+ }
184
+ return withDetail(`${prPrefix}moved from ${displayStatus(event.fromStatus ?? "start")} to ${displayStatus(event.toStatus)}.`, event.detail);
185
+ }
152
186
  export function formatDuration(ms) {
153
187
  const seconds = Math.floor(ms / 1000);
154
188
  if (seconds < 60)
@@ -1 +1,4 @@
1
- export declare function startWatch(configPath?: string, initialPrNumber?: number): Promise<void>;
1
+ export declare function startDashboard(options?: {
2
+ initialRepoRef?: string;
3
+ initialPrNumber?: number;
4
+ }): Promise<void>;
@@ -1,10 +1,10 @@
1
1
  import { createElement } from "react";
2
2
  import { render } from "ink";
3
- import { loadConfig } from "../config.js";
4
3
  import { App } from "./App.js";
5
4
  import { parseHomeConfigObject } from "../steward-home.js";
6
5
  import { getDefaultConfigPath } from "../runtime-paths.js";
7
6
  import { readFileSync, existsSync } from "node:fs";
7
+ import { loadAllRepoConfigs } from "../install.js";
8
8
  function resolveGatewayBaseUrl() {
9
9
  const configPath = getDefaultConfigPath();
10
10
  if (!existsSync(configPath)) {
@@ -17,13 +17,21 @@ function resolveGatewayBaseUrl() {
17
17
  const port = homeConfig.server.gateway_port ?? (homeConfig.server.port_base - 1);
18
18
  return `http://${bind}:${port}`;
19
19
  }
20
- export async function startWatch(configPath, initialPrNumber) {
21
- const config = loadConfig(configPath);
20
+ export async function startDashboard(options) {
21
+ const configs = await loadAllRepoConfigs();
22
22
  const gatewayBase = resolveGatewayBaseUrl();
23
- const baseUrl = `${gatewayBase}/repos/${config.repoId}`;
24
23
  process.stderr.write("\u001b[?1049h\u001b[2J\u001b[H");
25
24
  try {
26
- const instance = render(createElement(App, { baseUrl, ...(initialPrNumber !== undefined ? { initialPrNumber } : {}) }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
25
+ const instance = render(createElement(App, {
26
+ gatewayBaseUrl: gatewayBase,
27
+ repos: configs.map((config) => ({
28
+ repoId: config.repoId,
29
+ repoFullName: config.repoFullName,
30
+ baseBranch: config.baseBranch,
31
+ })),
32
+ initialRepoRef: options?.initialRepoRef,
33
+ ...(options?.initialPrNumber !== undefined ? { initialPrNumber: options.initialPrNumber } : {}),
34
+ }), { stdout: process.stderr, stdin: process.stdin, patchConsole: false });
27
35
  await instance.waitUntilExit();
28
36
  }
29
37
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.9.7",
3
+ "version": "0.10.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": {