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 +62 -27
- package/dist/cli/args.js +8 -3
- package/dist/cli/commands/dashboard.d.ts +2 -0
- package/dist/cli/commands/dashboard.js +24 -0
- package/dist/cli/commands/queue.js +5 -5
- package/dist/cli/help.js +2 -2
- package/dist/cli.js +6 -1
- package/dist/watch/App.d.ts +5 -2
- package/dist/watch/App.js +158 -88
- package/dist/watch/HelpBar.d.ts +1 -1
- package/dist/watch/HelpBar.js +3 -3
- package/dist/watch/OverviewView.d.ts +7 -0
- package/dist/watch/OverviewView.js +34 -0
- package/dist/watch/ProjectDetailView.d.ts +10 -0
- package/dist/watch/ProjectDetailView.js +55 -0
- package/dist/watch/StatusBar.d.ts +5 -4
- package/dist/watch/StatusBar.js +24 -29
- package/dist/watch/api.d.ts +4 -4
- package/dist/watch/api.js +12 -8
- package/dist/watch/dashboard-model.d.ts +38 -0
- package/dist/watch/dashboard-model.js +192 -0
- package/dist/watch/format.d.ts +1 -0
- package/dist/watch/format.js +34 -0
- package/dist/watch/index.d.ts +4 -1
- package/dist/watch/index.js +13 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# merge-steward
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
#
|
|
217
|
-
merge-steward
|
|
219
|
+
# Open one project directly in the dashboard
|
|
220
|
+
merge-steward dashboard --repo app
|
|
218
221
|
```
|
|
219
222
|
|
|
220
|
-
###
|
|
223
|
+
### Dashboard
|
|
221
224
|
|
|
222
|
-
`merge-steward
|
|
225
|
+
`merge-steward dashboard` is the operator surface for day-to-day queue work.
|
|
223
226
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
-
|
|
227
|
-
-
|
|
228
|
-
-
|
|
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
|
|
234
|
-
- `Esc` — return to
|
|
235
|
-
- `a` — toggle `active` vs `all`
|
|
236
|
-
- `r` — run a reconcile tick
|
|
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
|
|
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,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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
await
|
|
124
|
-
return
|
|
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
|
-
"
|
|
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
|
|
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":
|
package/dist/watch/App.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import type { DashboardRepoConfig } from "./dashboard-model.ts";
|
|
1
2
|
interface AppProps {
|
|
2
|
-
|
|
3
|
+
gatewayBaseUrl: string;
|
|
4
|
+
repos: DashboardRepoConfig[];
|
|
5
|
+
initialRepoRef?: string | undefined;
|
|
3
6
|
initialPrNumber?: number | undefined;
|
|
4
7
|
}
|
|
5
|
-
export declare function App({
|
|
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 {
|
|
5
|
+
import { getDefaultEntryId, getVisibleEntries, matchRepoRef } from "./dashboard-model.js";
|
|
7
6
|
import { HelpBar } from "./HelpBar.js";
|
|
8
|
-
import {
|
|
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
|
|
12
|
-
|
|
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 =
|
|
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) %
|
|
22
|
-
: (currentIndex - 1 +
|
|
23
|
-
return
|
|
18
|
+
? (currentIndex + 1) % items.length
|
|
19
|
+
: (currentIndex - 1 + items.length) % items.length;
|
|
20
|
+
return items[nextIndex]?.id ?? null;
|
|
24
21
|
}
|
|
25
|
-
export function App({
|
|
22
|
+
export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }) {
|
|
26
23
|
const { exit } = useApp();
|
|
27
|
-
const [
|
|
28
|
-
|
|
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 [
|
|
32
|
-
|
|
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
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
}, [
|
|
108
|
+
}, [gatewayBaseUrl, repos]);
|
|
93
109
|
useEffect(() => {
|
|
94
|
-
if (
|
|
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(
|
|
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
|
-
}, [
|
|
121
|
-
async function
|
|
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(
|
|
124
|
-
setFlashMessage(result.started ?
|
|
125
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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 === "
|
|
164
|
-
if (input === "
|
|
165
|
-
|
|
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
|
-
|
|
224
|
+
setSelectedRepoId(nextSelection(repoSelectionItems, selectedRepoId, "prev"));
|
|
172
225
|
}
|
|
173
|
-
else if (key.return &&
|
|
174
|
-
setView("
|
|
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("
|
|
236
|
+
setView("overview");
|
|
180
237
|
return;
|
|
181
238
|
}
|
|
182
|
-
if (input === "
|
|
183
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
}
|
package/dist/watch/HelpBar.d.ts
CHANGED
package/dist/watch/HelpBar.js
CHANGED
|
@@ -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 === "
|
|
5
|
-
? "j/k:
|
|
6
|
-
: "j/k:
|
|
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 {
|
|
1
|
+
import type { DashboardRepoState } from "./dashboard-model.ts";
|
|
2
2
|
interface StatusBarProps {
|
|
3
|
-
|
|
4
|
-
|
|
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({
|
|
10
|
+
export declare function StatusBar({ repos, currentRepo, view, filter, lastSnapshotReceivedAt, expectedFreshMs, }: StatusBarProps): React.JSX.Element;
|
|
10
11
|
export {};
|
package/dist/watch/StatusBar.js
CHANGED
|
@@ -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 {
|
|
3
|
+
import { getClusterSummary, getRepoHealth } from "./dashboard-model.js";
|
|
4
|
+
import { truncate } from "./format.js";
|
|
4
5
|
import { FreshnessBadge } from "./FreshnessBadge.js";
|
|
5
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
}
|
package/dist/watch/api.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { QueueEntryDetail, QueueWatchSnapshot } from "../types.ts";
|
|
2
|
-
export declare function fetchSnapshot(
|
|
3
|
-
export declare function fetchEntryDetail(
|
|
4
|
-
export declare function triggerReconcile(
|
|
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(
|
|
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
|
-
|
|
37
|
-
|
|
36
|
+
function repoBaseUrl(gatewayBaseUrl, repoId) {
|
|
37
|
+
const base = gatewayBaseUrl.replace(/\/$/, "");
|
|
38
|
+
return `${base}/repos/${encodeURIComponent(repoId)}`;
|
|
38
39
|
}
|
|
39
|
-
export async function
|
|
40
|
-
return await requestJson(buildUrl(
|
|
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
|
|
43
|
-
return await requestJson(buildUrl(
|
|
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
|
|
46
|
-
await requestJson(buildUrl(
|
|
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
|
+
}
|
package/dist/watch/format.d.ts
CHANGED
|
@@ -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;
|
package/dist/watch/format.js
CHANGED
|
@@ -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)
|
package/dist/watch/index.d.ts
CHANGED
package/dist/watch/index.js
CHANGED
|
@@ -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
|
|
21
|
-
const
|
|
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, {
|
|
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 {
|