merge-steward 0.15.0 → 0.16.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.
@@ -1,7 +1,7 @@
1
1
  import { SqliteStore } from "../../db/sqlite-store.js";
2
2
  import { formatJson, writeOutput } from "../output.js";
3
3
  import { ServiceApiError, fetchLocalJson, loadRepoConfigById } from "../system.js";
4
- import { resolvePrNumber, resolveRepo } from "../resolve.js";
4
+ import { defaultResolveRunner, resolvePrNumber, resolveRepo } from "../resolve.js";
5
5
  import { parseIntegerFlag } from "../args.js";
6
6
  import { fetchPrGitHubOverview } from "./pr-github.js";
7
7
  export function classifyQueueEntry(entry) {
@@ -223,11 +223,7 @@ export async function handlePrStatus(options) {
223
223
  const resolvedPr = await resolvePrNumber({ parsed, helpTopic: "root", runCommand: resolveCommand });
224
224
  const { config } = loadRepoConfigById(resolvedRepo.repoId);
225
225
  const fetchGh = options.fetchGitHub
226
- ?? ((repoFullName, prNumber) => fetchPrGitHubOverview(repoFullName, prNumber, resolveCommand ?? (async () => ({
227
- exitCode: 127,
228
- stdout: "",
229
- stderr: "no resolveCommand provided",
230
- }))));
226
+ ?? ((repoFullName, prNumber) => fetchPrGitHubOverview(repoFullName, prNumber, resolveCommand ?? defaultResolveRunner));
231
227
  const buildReport = async () => {
232
228
  const queueResult = await loadQueueEntry(config, resolvedPr.prNumber);
233
229
  if (queueResult.kind === "found") {
package/dist/watch/App.js CHANGED
@@ -1,24 +1,13 @@
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 { dequeueEntry, fetchEntryDetail, fetchGatewayHealth, fetchSnapshot, triggerReconcile } from "./api.js";
5
- import { getDefaultEntryId, getVisibleEntries, matchRepoRef } from "./dashboard-model.js";
4
+ import { fetchGatewayHealth, fetchSnapshot, triggerReconcile } from "./api.js";
5
+ import { buildDashboard, matchRepoRef, repoSelector, stepRepo } from "./dashboard-model.js";
6
6
  import { HelpBar } from "./HelpBar.js";
7
7
  import { OverviewView } from "./OverviewView.js";
8
8
  import { ProjectDetailView } from "./ProjectDetailView.js";
9
9
  import { StatusBar } from "./StatusBar.js";
10
10
  const REFRESH_INTERVAL_MS = 1_500;
11
- function nextSelection(items, selectedId, direction) {
12
- if (items.length === 0) {
13
- return null;
14
- }
15
- const index = items.findIndex((item) => item.id === selectedId);
16
- const currentIndex = index === -1 ? 0 : index;
17
- const nextIndex = direction === "next"
18
- ? (currentIndex + 1) % items.length
19
- : (currentIndex - 1 + items.length) % items.length;
20
- return items[nextIndex]?.id ?? null;
21
- }
22
11
  export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }) {
23
12
  const { exit } = useApp();
24
13
  const [repoStates, setRepoStates] = useState(() => repos.map((repo) => ({
@@ -30,25 +19,34 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
30
19
  lastSnapshotReceivedAt: null,
31
20
  })));
32
21
  const [lastSnapshotReceivedAt, setLastSnapshotReceivedAt] = useState(null);
33
- const [detail, setDetail] = useState(null);
34
22
  const [gatewayError, setGatewayError] = useState(null);
35
23
  const [selectedRepoId, setSelectedRepoId] = useState(() => {
36
24
  const initialRepo = repos.find((repo) => matchRepoRef(repo, initialRepoRef));
37
25
  return initialRepo?.repoId ?? repos[0]?.repoId ?? null;
38
26
  });
39
- const [selectedEntryIds, setSelectedEntryIds] = useState({});
40
- const [view, setView] = useState(initialRepoRef || initialPrNumber !== undefined ? "project" : "overview");
41
- const [filter, setFilter] = useState("active");
27
+ const [view, setView] = useState(initialRepoRef || initialPrNumber !== undefined ? "detail" : "list");
42
28
  const [flashMessage, setFlashMessage] = useState(null);
43
29
  const [pendingInitialPrNumber, setPendingInitialPrNumber] = useState(initialPrNumber ?? null);
44
- const repoSelectionItems = useMemo(() => repoStates.map((repo) => ({ id: repo.repoId })), [repoStates]);
45
- const currentRepo = useMemo(() => repoStates.find((repo) => repo.repoId === selectedRepoId) ?? null, [repoStates, selectedRepoId]);
46
- const currentVisibleEntries = useMemo(() => getVisibleEntries(currentRepo?.snapshot ?? null, filter), [currentRepo?.snapshot, filter]);
47
- const selectedEntryId = currentRepo ? (selectedEntryIds[currentRepo.repoId] ?? null) : null;
30
+ const model = useMemo(() => buildDashboard(repoStates), [repoStates]);
31
+ useEffect(() => {
32
+ setSelectedRepoId((current) => repoSelector(model.repos, current));
33
+ }, [model]);
48
34
  useEffect(() => {
49
- if (!flashMessage) {
35
+ if (pendingInitialPrNumber === null)
50
36
  return;
37
+ for (const repo of repoStates) {
38
+ const match = repo.snapshot?.entries.find((entry) => entry.prNumber === pendingInitialPrNumber);
39
+ if (match) {
40
+ setSelectedRepoId(repo.repoId);
41
+ setView("detail");
42
+ setPendingInitialPrNumber(null);
43
+ return;
44
+ }
51
45
  }
46
+ }, [pendingInitialPrNumber, repoStates]);
47
+ useEffect(() => {
48
+ if (!flashMessage)
49
+ return;
52
50
  const timeout = setTimeout(() => setFlashMessage(null), 2500);
53
51
  return () => clearTimeout(timeout);
54
52
  }, [flashMessage]);
@@ -57,9 +55,8 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
57
55
  const refresh = async () => {
58
56
  try {
59
57
  const health = await fetchGatewayHealth(gatewayBaseUrl);
60
- if (!cancelled) {
58
+ if (!cancelled)
61
59
  setGatewayError(null);
62
- }
63
60
  setRepoStates((current) => current.map((repo) => {
64
61
  const runtime = health.repos.find((candidate) => candidate.repoId === repo.repoId);
65
62
  if (!runtime) {
@@ -96,14 +93,12 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
96
93
  };
97
94
  }
98
95
  }));
99
- if (cancelled) {
96
+ if (cancelled)
100
97
  return;
101
- }
102
98
  setRepoStates((current) => current.map((repo) => {
103
99
  const result = results.find((candidate) => candidate.repoId === repo.repoId);
104
- if (!result) {
100
+ if (!result)
105
101
  return repo;
106
- }
107
102
  if (result.snapshot) {
108
103
  return {
109
104
  ...repo,
@@ -112,25 +107,19 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
112
107
  lastSnapshotReceivedAt: result.receivedAt,
113
108
  };
114
109
  }
115
- return {
116
- ...repo,
117
- error: result.error,
118
- };
110
+ return { ...repo, error: result.error };
119
111
  }));
120
112
  const latest = results.reduce((max, result) => {
121
- if (result.receivedAt === null) {
113
+ if (result.receivedAt === null)
122
114
  return max;
123
- }
124
115
  return max === null ? result.receivedAt : Math.max(max, result.receivedAt);
125
116
  }, null);
126
- if (latest !== null) {
117
+ if (latest !== null)
127
118
  setLastSnapshotReceivedAt(latest);
128
- }
129
119
  }
130
120
  catch (error) {
131
- if (cancelled) {
121
+ if (cancelled)
132
122
  return;
133
- }
134
123
  const message = error instanceof Error ? error.message : String(error);
135
124
  setGatewayError(message);
136
125
  setRepoStates((current) => current.map((repo) => ({
@@ -139,7 +128,6 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
139
128
  serviceMessage: message,
140
129
  error: message,
141
130
  })));
142
- return;
143
131
  }
144
132
  };
145
133
  void refresh();
@@ -151,102 +139,12 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
151
139
  clearInterval(interval);
152
140
  };
153
141
  }, [gatewayBaseUrl, repos]);
154
- useEffect(() => {
155
- if (pendingInitialPrNumber === null) {
156
- return;
157
- }
158
- for (const repo of repoStates) {
159
- const match = repo.snapshot?.entries.find((entry) => entry.prNumber === pendingInitialPrNumber);
160
- if (!match) {
161
- continue;
162
- }
163
- setSelectedRepoId(repo.repoId);
164
- setSelectedEntryIds((current) => ({
165
- ...current,
166
- [repo.repoId]: match.id,
167
- }));
168
- setView("project");
169
- setPendingInitialPrNumber(null);
170
- return;
171
- }
172
- }, [pendingInitialPrNumber, repoStates]);
173
- useEffect(() => {
174
- if (!currentRepo) {
175
- return;
176
- }
177
- const nextDefault = getDefaultEntryId(currentRepo.snapshot, filter, pendingInitialPrNumber);
178
- setSelectedEntryIds((current) => {
179
- const existing = current[currentRepo.repoId];
180
- if (existing && currentVisibleEntries.some((entry) => entry.id === existing)) {
181
- return current;
182
- }
183
- if (existing === nextDefault) {
184
- return current;
185
- }
186
- return {
187
- ...current,
188
- [currentRepo.repoId]: nextDefault,
189
- };
190
- });
191
- }, [currentRepo, currentVisibleEntries, filter, pendingInitialPrNumber]);
192
- useEffect(() => {
193
- if (view !== "project" || !currentRepo?.repoId || !selectedEntryId || currentRepo.serviceState !== "ready") {
194
- setDetail(null);
195
- return;
196
- }
197
- let cancelled = false;
198
- const refresh = async () => {
199
- try {
200
- const nextDetail = await fetchEntryDetail(gatewayBaseUrl, currentRepo.repoId, selectedEntryId);
201
- if (!cancelled) {
202
- setDetail(nextDetail);
203
- }
204
- }
205
- catch (error) {
206
- if (!cancelled) {
207
- setFlashMessage(error instanceof Error ? error.message : String(error));
208
- }
209
- }
210
- };
211
- void refresh();
212
- const interval = setInterval(() => {
213
- void refresh();
214
- }, REFRESH_INTERVAL_MS);
215
- return () => {
216
- cancelled = true;
217
- clearInterval(interval);
218
- };
219
- }, [gatewayBaseUrl, currentRepo?.repoId, currentRepo?.serviceState, selectedEntryId, view]);
220
- async function refreshRepo(repoId) {
221
- const nextSnapshot = await fetchSnapshot(gatewayBaseUrl, repoId);
222
- const receivedAt = Date.now();
223
- setRepoStates((current) => current.map((repo) => repo.repoId === repoId
224
- ? { ...repo, serviceState: "ready", serviceMessage: null, snapshot: nextSnapshot, error: null, lastSnapshotReceivedAt: receivedAt }
225
- : repo));
226
- setLastSnapshotReceivedAt(receivedAt);
227
- }
228
142
  async function runReconcile(repoId) {
229
- if (!repoId) {
143
+ if (!repoId)
230
144
  return;
231
- }
232
145
  try {
233
146
  const result = await triggerReconcile(gatewayBaseUrl, repoId);
234
147
  setFlashMessage(result.started ? `reconcile tick completed for ${repoId}` : `reconcile already running for ${repoId}`);
235
- await refreshRepo(repoId);
236
- }
237
- catch (error) {
238
- setFlashMessage(error instanceof Error ? error.message : String(error));
239
- }
240
- }
241
- async function runDequeue() {
242
- if (!currentRepo?.repoId || !selectedEntryId || currentRepo.serviceState !== "ready") {
243
- return;
244
- }
245
- try {
246
- const selectedEntry = currentRepo.snapshot?.entries.find((entry) => entry.id === selectedEntryId) ?? null;
247
- await dequeueEntry(gatewayBaseUrl, currentRepo.repoId, selectedEntryId);
248
- setFlashMessage(`dequeued ${selectedEntry ? `#${selectedEntry.prNumber}` : "entry"} from ${currentRepo.repoId}`);
249
- await refreshRepo(currentRepo.repoId);
250
148
  }
251
149
  catch (error) {
252
150
  setFlashMessage(error instanceof Error ? error.message : String(error));
@@ -261,45 +159,22 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
261
159
  void runReconcile(selectedRepoId);
262
160
  return;
263
161
  }
264
- if (view === "overview") {
265
- if (input === "j" || key.downArrow) {
266
- setSelectedRepoId(nextSelection(repoSelectionItems, selectedRepoId, "next"));
267
- }
268
- else if (input === "k" || key.upArrow) {
269
- setSelectedRepoId(nextSelection(repoSelectionItems, selectedRepoId, "prev"));
270
- }
271
- else if (key.return && selectedRepoId) {
272
- setView("project");
273
- }
162
+ if (input === "j" || key.downArrow) {
163
+ setSelectedRepoId((current) => stepRepo(model.repos, current, 1));
274
164
  return;
275
165
  }
276
- if (input === "d") {
277
- void runDequeue();
166
+ if (input === "k" || key.upArrow) {
167
+ setSelectedRepoId((current) => stepRepo(model.repos, current, -1));
278
168
  return;
279
169
  }
280
- if (key.escape || key.backspace || key.delete) {
281
- setView("overview");
170
+ if (view === "list") {
171
+ if (key.return)
172
+ setView("detail");
282
173
  return;
283
174
  }
284
- if (input === "a") {
285
- setFilter((current) => current === "active" ? "all" : "active");
286
- }
287
- else if (input === "j" || key.downArrow) {
288
- if (currentRepo?.repoId) {
289
- setSelectedEntryIds((current) => ({
290
- ...current,
291
- [currentRepo.repoId]: nextSelection(currentVisibleEntries, current[currentRepo.repoId] ?? null, "next"),
292
- }));
293
- }
294
- }
295
- else if (input === "k" || key.upArrow) {
296
- if (currentRepo?.repoId) {
297
- setSelectedEntryIds((current) => ({
298
- ...current,
299
- [currentRepo.repoId]: nextSelection(currentVisibleEntries, current[currentRepo.repoId] ?? null, "prev"),
300
- }));
301
- }
175
+ if (key.escape || key.backspace || key.delete) {
176
+ setView("list");
302
177
  }
303
178
  });
304
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { repos: repoStates, currentRepo: currentRepo, view: view, filter: filter, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: REFRESH_INTERVAL_MS * 2, gatewayError: gatewayError }), view === "overview" ? (_jsx(OverviewView, { repos: repoStates, selectedRepoId: selectedRepoId, gatewayError: gatewayError })) : (_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 })] }));
179
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { connected: model.repos.some((repo) => !repo.offlineMessage), lastSnapshotReceivedAt: lastSnapshotReceivedAt, gatewayError: gatewayError }), view === "detail" ? (_jsx(ProjectDetailView, { model: model, selectedRepoId: selectedRepoId })) : (_jsx(OverviewView, { model: model, selectedRepoId: selectedRepoId, showCursor: true })), flashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, _jsx(HelpBar, { view: view })] }));
305
180
  }
@@ -1,5 +1,5 @@
1
1
  interface HelpBarProps {
2
- view: "overview" | "project";
2
+ view: "list" | "detail";
3
3
  }
4
4
  export declare function HelpBar({ view }: HelpBarProps): React.JSX.Element;
5
5
  export {};
@@ -1,14 +1,8 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text, useStdout } from "ink";
2
+ import { Box, Text } from "ink";
3
3
  export function HelpBar({ view }) {
4
- const { stdout } = useStdout();
5
- const compact = (stdout?.columns ?? 80) < 80;
6
- const text = compact
7
- ? (view === "overview"
8
- ? "j/k select Enter open r refresh q quit"
9
- : "j/k select Esc overview a filter r refresh d dequeue q quit")
10
- : view === "overview"
11
- ? "j/k: select project Enter: open project r: reconcile selected project q: quit"
12
- : "j/k: select PR Esc: overview a: filter r: reconcile project d: dequeue PR q: quit";
4
+ const text = view === "list"
5
+ ? "↑↓ switch repo enter detail r reconcile q quit"
6
+ : "↑↓ switch repo esc back r reconcile q quit";
13
7
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }));
14
8
  }
@@ -1,8 +1,14 @@
1
- import type { DashboardRepoState } from "./dashboard-model.ts";
2
- interface OverviewViewProps {
3
- repos: DashboardRepoState[];
1
+ import type { DashboardModel, DashboardRepo } from "./dashboard-model.ts";
2
+ interface ListViewProps {
3
+ model: DashboardModel;
4
4
  selectedRepoId: string | null;
5
- gatewayError: string | null;
5
+ showCursor: boolean;
6
6
  }
7
- export declare function OverviewView({ repos, selectedRepoId, gatewayError }: OverviewViewProps): React.JSX.Element;
7
+ export declare function RepoRow({ repo, selected, showCursor, width, }: {
8
+ repo: DashboardRepo;
9
+ selected: boolean;
10
+ showCursor: boolean;
11
+ width: number;
12
+ }): React.JSX.Element;
13
+ export declare function OverviewView({ model, selectedRepoId, showCursor }: ListViewProps): React.JSX.Element;
8
14
  export {};
@@ -1,44 +1,48 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
- import { ciStatusIcon, summarizeQueueBlock, truncate } from "./format.js";
4
- import { getClusterSummary, getRepoHealth, projectStatsSummary } from "./dashboard-model.js";
5
- import { getChainEntries } from "./dashboard-model.js";
6
- function clampWindowStart(selectedIndex, itemCount, maxItems) {
7
- if (itemCount <= maxItems) {
8
- return 0;
3
+ function RepoTokens({ tokens, width }) {
4
+ if (tokens.length === 0 || width < 6)
5
+ return null;
6
+ const parts = [];
7
+ let used = 0;
8
+ for (const token of tokens) {
9
+ const text = `#${token.prNumber} ${token.glyph}`;
10
+ const separatorWidth = parts.length === 0 ? 0 : 2;
11
+ if (used + separatorWidth + text.length > width)
12
+ break;
13
+ used += separatorWidth + text.length;
14
+ parts.push({ token, text });
9
15
  }
10
- const half = Math.floor(maxItems / 2);
11
- return Math.max(0, Math.min(itemCount - maxItems, selectedIndex - half));
16
+ return (_jsx(Text, { children: parts.map((part, index) => (_jsx(Text, { color: part.token.color, children: index === 0 ? part.text : ` ${part.text}` }, `${part.token.prNumber}-${index}`))) }));
12
17
  }
13
- export function OverviewView({ repos, selectedRepoId, gatewayError }) {
18
+ export function RepoRow({ repo, selected, showCursor, width, }) {
19
+ const cursorChar = showCursor && selected ? ">" : " ";
20
+ const repoLabelWidth = Math.min(28, Math.max(12, Math.floor(width * 0.35)));
21
+ const tokenWidth = Math.max(6, width - repoLabelWidth - 3);
22
+ const repoLabel = repo.repoFullName.length > repoLabelWidth
23
+ ? repo.repoFullName.slice(0, repoLabelWidth)
24
+ : repo.repoFullName.padEnd(repoLabelWidth, " ");
25
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: cursorChar }), _jsx(Text, { bold: selected, children: ` ${repoLabel} ` }), repo.offlineMessage ? (_jsx(Text, { color: "red", children: repo.offlineMessage })) : (_jsx(RepoTokens, { tokens: repo.tokens, width: tokenWidth }))] }));
26
+ }
27
+ export function OverviewView({ model, selectedRepoId, showCursor }) {
14
28
  const { stdout } = useStdout();
15
- const rows = stdout?.rows ?? 24;
16
- const width = stdout?.columns ?? 80;
17
- const compact = width < 90;
18
- const cluster = getClusterSummary(repos);
19
- const selectedIndex = Math.max(0, repos.findIndex((repo) => repo.repoId === selectedRepoId));
20
- const maxItems = Math.max(1, Math.floor((rows - 8) / 2));
21
- const startIndex = clampWindowStart(selectedIndex, repos.length, maxItems);
22
- const visibleRepos = repos.slice(startIndex, startIndex + maxItems);
23
- const summaryText = compact
24
- ? `${cluster.total}p ${cluster.connected}c ${cluster.active}a ${cluster.blocked}b ${cluster.stuck}s`
25
- : `${cluster.total} projects · ${cluster.connected} connected · ${cluster.active} active · ${cluster.blocked} blocked · ${cluster.stuck} stuck · ${cluster.attention} need attention`;
26
- const idWidth = compact ? Math.max(14, Math.min(24, Math.floor(width * 0.44))) : 18;
27
- const chainWidth = compact ? Math.max(12, width - 10) : 100;
28
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Queue Overview" }), _jsx(Text, { dimColor: true, children: summaryText }), gatewayError ? (_jsx(Text, { color: "red", children: `Gateway offline: ${truncate(gatewayError, 88)}. Run \`merge-steward service restart\`.` })) : null, 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) => {
29
- const health = getRepoHealth(repo);
30
- const queueBlockSummary = summarizeQueueBlock(repo.snapshot?.queueBlock);
31
- const chainEntries = getChainEntries(repo.snapshot);
32
- const chainText = repo.snapshot
33
- ? chainEntries.length > 0
34
- ? chainEntries.map((entry) => {
35
- const ci = ciStatusIcon(entry);
36
- return compact
37
- ? `#${entry.prNumber}${ci.icon}`
38
- : `#${entry.prNumber} ${ci.icon}`;
39
- }).join(" ")
40
- : compact ? "empty" : "queue is empty"
41
- : "queue data unavailable";
42
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: repo.repoId === selectedRepoId ? "cyan" : "gray", children: repo.repoId === selectedRepoId ? ">" : " " }), _jsx(Text, { bold: true, children: truncate(repo.repoId, idWidth) }), compact ? null : _jsx(Text, { dimColor: true, children: ` ${truncate(repo.repoFullName, 28)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: compact ? health.label.slice(0, 5) : health.label }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(repo.snapshot, compact)}` })] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `Queue: ${truncate(chainText, chainWidth)}` }) }), compact ? null : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: health.color, children: truncate(queueBlockSummary ?? health.detail, 110) }) }))] }, repo.repoId));
43
- }) }))] }));
29
+ const rows = Math.max(3, stdout?.rows ?? 24);
30
+ const width = Math.max(40, stdout?.columns ?? 80);
31
+ const availableRows = Math.max(1, rows - 3);
32
+ const selectedIndex = model.repos.findIndex((repo) => repo.repoId === selectedRepoId);
33
+ const selected = selectedIndex >= 0 ? model.repos[selectedIndex] : model.repos[0];
34
+ const others = model.repos.filter((repo) => repo !== selected);
35
+ const ordered = [];
36
+ if (selected)
37
+ ordered.push(selected);
38
+ ordered.push(...others);
39
+ const quietLine = model.quietCount > 0 ? 1 : 0;
40
+ const maxRepoLines = Math.max(1, availableRows - quietLine);
41
+ const visible = ordered.slice(0, maxRepoLines);
42
+ const hiddenActive = ordered.length - visible.length;
43
+ const quietFooter = model.quietCount + hiddenActive;
44
+ if (visible.length === 0) {
45
+ return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
46
+ }
47
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [visible.map((repo) => (_jsx(RepoRow, { repo: repo, selected: repo === selected, showCursor: showCursor, width: width - 2 }, repo.repoId))), quietFooter > 0 ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `+${quietFooter} quiet` }) })) : null] }));
44
48
  }
@@ -1,10 +1,7 @@
1
- import type { QueueEntryDetail } from "../types.ts";
2
- import type { DashboardRepoState } from "./dashboard-model.ts";
1
+ import { type DashboardModel } from "./dashboard-model.ts";
3
2
  interface ProjectDetailViewProps {
4
- repo: DashboardRepoState | null;
5
- selectedEntryId: string | null;
6
- detail: QueueEntryDetail | null;
7
- filter: "active" | "all";
3
+ model: DashboardModel;
4
+ selectedRepoId: string | null;
8
5
  }
9
- export declare function ProjectDetailView({ repo, selectedEntryId, detail, filter, }: ProjectDetailViewProps): React.JSX.Element;
6
+ export declare function ProjectDetailView({ model, selectedRepoId }: ProjectDetailViewProps): React.JSX.Element;
10
7
  export {};
@@ -1,93 +1,58 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
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, postMergeStatusLine, 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;
3
+ import { RepoRow } from "./OverviewView.js";
4
+ import { clipSummary } from "./dashboard-model.js";
5
+ const PR_ID_WIDTH = 7;
6
+ const SUMMARY_INDENT = 9;
7
+ function PrEntryRow({ entry, width, includeSummary, }) {
8
+ const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
9
+ const summaryText = includeSummary
10
+ ? clipSummary(entry.summary, { maxLines: 3, width: Math.max(20, width - SUMMARY_INDENT) })
11
+ : "";
12
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${entry.phrase}` })] }), summaryText ? (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: summaryText })] })) : null] }));
20
13
  }
21
- function compactEntryStatus(entryStatus) {
22
- switch (entryStatus) {
23
- case "queued":
24
- return "waiting";
25
- case "preparing_head":
26
- return "prep";
27
- case "validating":
28
- return "testing";
29
- case "merging":
30
- return "merging";
31
- case "merged":
32
- return "merged";
33
- case "evicted":
34
- return "repair";
35
- case "dequeued":
36
- return "removed";
37
- default:
38
- return entryStatus;
39
- }
40
- }
41
- export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
14
+ export function ProjectDetailView({ model, selectedRepoId }) {
42
15
  const { stdout } = useStdout();
43
- const rows = stdout?.rows ?? 24;
44
- const width = stdout?.columns ?? 80;
45
- const compact = width < 95;
16
+ const rows = Math.max(3, stdout?.rows ?? 24);
17
+ const width = Math.max(40, stdout?.columns ?? 80);
18
+ const repo = selectedRepoId
19
+ ? model.repos.find((candidate) => candidate.repoId === selectedRepoId) ?? model.repos[0] ?? null
20
+ : model.repos[0] ?? null;
46
21
  if (!repo) {
47
- return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "No project selected." }) }));
22
+ return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
23
+ }
24
+ const availableRows = Math.max(1, rows - 3);
25
+ const afterRepoRow = Math.max(0, availableRows - 2);
26
+ const entries = repo.entries;
27
+ const planned = [];
28
+ let used = 0;
29
+ const maxSummaryLines = 3;
30
+ for (const entry of entries) {
31
+ if (used >= afterRepoRow)
32
+ break;
33
+ const phraseLine = 1;
34
+ if (used + phraseLine > afterRepoRow)
35
+ break;
36
+ planned.push({ entry, includeSummary: false, lines: phraseLine });
37
+ used += phraseLine;
48
38
  }
49
- if (!repo.snapshot) {
50
- return (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: repo.serviceState === "initializing" ? "cyan" : "red", children: repo.serviceState === "initializing"
51
- ? `Merge Steward is still initializing ${repo.repoId}.`
52
- : repo.serviceState === "failed"
53
- ? `Merge Steward failed to initialize ${repo.repoId}.`
54
- : `Queue data is unavailable for ${repo.repoId}.` }), repo.serviceMessage ? _jsx(Text, { dimColor: true, children: repo.serviceMessage }) : null, repo.error && repo.error !== repo.serviceMessage ? _jsx(Text, { dimColor: true, children: repo.error }) : null] }));
39
+ if (afterRepoRow - used >= 2) {
40
+ for (const item of planned) {
41
+ if (!item.entry.summary)
42
+ continue;
43
+ const summaryText = clipSummary(item.entry.summary, {
44
+ maxLines: maxSummaryLines,
45
+ width: Math.max(20, width - SUMMARY_INDENT),
46
+ });
47
+ if (!summaryText)
48
+ continue;
49
+ const summaryLines = summaryText.split("\n").length;
50
+ if (used + summaryLines > afterRepoRow)
51
+ break;
52
+ item.includeSummary = true;
53
+ item.lines += summaryLines;
54
+ used += summaryLines;
55
+ }
55
56
  }
56
- const snapshot = repo.snapshot;
57
- const health = getRepoHealth(repo);
58
- const queueBlockSummary = summarizeQueueBlock(snapshot.queueBlock);
59
- const entries = buildDisplayEntries(snapshot.entries, filter);
60
- const currentSelectedIndex = selectedEntryIndex(snapshot, filter, selectedEntryId);
61
- const maxEntryRows = Math.max(3, Math.floor((rows - 16) / 2));
62
- const entryWindowStart = clampWindowStart(currentSelectedIndex, entries.length, maxEntryRows);
63
- const visibleEntries = entries.slice(entryWindowStart, entryWindowStart + maxEntryRows);
64
- const selectedEntry = entries.find((entry) => entry.id === selectedEntryId)
65
- ?? snapshot.entries.find((entry) => entry.id === selectedEntryId)
66
- ?? null;
67
- const eventRows = compact ? 4 : 6;
68
- const visibleEvents = detail
69
- ? detail.events.slice(-eventRows)
70
- : snapshot.recentEvents.slice(-eventRows);
71
- const chainEntries = getChainEntries(snapshot);
72
- const chainText = chainEntries.length > 0
73
- ? chainEntries.map((entry) => {
74
- const ci = ciStatusIcon(entry);
75
- return `#${entry.prNumber}${ci.icon}`;
76
- }).join(" ")
77
- : "empty";
78
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: health.label })] }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(snapshot, compact)}` }), compact ? null : _jsx(Text, { dimColor: true, children: runtimeSummary(snapshot) }), _jsx(Text, { dimColor: true, children: compact
79
- ? `Queue: ${truncate(chainText, width - 10)}`
80
- : `Queue: main -> ${truncate(chainText, width - 18)}` }), compact ? null : queueBlockSummary ? _jsx(Text, { color: "yellow", children: queueBlockSummary }) : null, compact ? null : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["GitHub required checks: ", snapshot.githubPolicy.requiredChecks.length > 0 ? snapshot.githubPolicy.requiredChecks.join(", ") : "(none)"] }), snapshot.githubPolicy.fetchedAt ? (_jsxs(Text, { dimColor: true, children: ["Policy fetched ", relativeTime(snapshot.githubPolicy.fetchedAt), " ago", snapshot.githubPolicy.lastRefreshReason
81
- ? ` via ${snapshot.githubPolicy.lastRefreshReason}${snapshot.githubPolicy.lastRefreshChanged === null ? "" : snapshot.githubPolicy.lastRefreshChanged ? " (changed)" : " (unchanged)"}`
82
- : ""] })) : 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) => {
83
- const ci = ciStatusIcon(entry);
84
- const isHead = snapshot.summary.headEntryId === entry.id;
85
- const isSelected = entry.id === selectedEntryId;
86
- const statusColor = entry.status === "evicted" ? "red" : ci.color;
87
- const baseStatusText = compact ? compactEntryStatus(entry.status) : humanStatus(entry.status, entry);
88
- const statusText = entry.status === "merged" ? `${baseStatusText}: ${postMergeStatusLine(entry)}` : baseStatusText;
89
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? "cyan" : "gray", children: isSelected ? ">" : " " }), _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, { color: statusColor, children: ` ${statusText}` }), compact ? null : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` updated ${relativeTime(entry.updatedAt)} ago` }), isHead ? _jsx(Text, { dimColor: true, children: ` · ${humanStatus(entry.status, entry)} head` }) : null] }))] }), (isSelected || entry.status === "evicted" || (isHead && queueBlockSummary)) && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: describeEntry(entry, { isHead, queueBlockSummary }) }) }))] }, entry.id));
90
- }))] }), selectedEntry ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Selected PR #${selectedEntry.prNumber}` }), compact ? (_jsx(Text, { dimColor: true, children: `head ${shortSha(selectedEntry.headSha)} · base ${shortSha(selectedEntry.baseSha)}` })) : (_jsxs(Text, { dimColor: true, children: [selectedEntry.branch, " \u00B7 head ", shortSha(selectedEntry.headSha), " \u00B7 base ", shortSha(selectedEntry.baseSha)] })), compact ? null : _jsx(Text, { dimColor: true, children: `Test branch: ${specChainLabel(selectedEntry, snapshot.entries)}` }), selectedEntry.status === "merged" ? (_jsx(Text, { dimColor: true, children: `Post-merge: ${postMergeStatusLine(selectedEntry)}${selectedEntry.postMergeCheckedAt ? ` · ${relativeTime(selectedEntry.postMergeCheckedAt)} ago` : ""}` })) : null, detail?.incidents.length
91
- ? detail.incidents.slice(-2).map((incident) => (_jsxs(Text, { color: "red", children: [relativeTime(incident.at), " ago \u00B7 ", incident.failureClass, " \u00B7 ", incident.outcome] }, incident.id)))
92
- : null] })) : 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: formatEventNarrative(event, selectedEntry ? { prNumber: selectedEntry.prNumber } : {}) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
57
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(RepoRow, { repo: repo, selected: false, showCursor: false, width: width - 2 }), planned.length > 0 ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null, planned.map(({ entry, includeSummary }) => (_jsx(PrEntryRow, { entry: entry, width: width, includeSummary: includeSummary }, entry.prNumber)))] }));
93
58
  }
@@ -1,12 +1,7 @@
1
- import type { DashboardRepoState } from "./dashboard-model.ts";
2
1
  interface StatusBarProps {
3
- repos: DashboardRepoState[];
4
- currentRepo: DashboardRepoState | null;
5
- view: "overview" | "project";
6
- filter: "active" | "all";
2
+ connected: boolean;
7
3
  lastSnapshotReceivedAt: number | null;
8
- expectedFreshMs: number;
9
4
  gatewayError: string | null;
10
5
  }
11
- export declare function StatusBar({ repos, currentRepo, view, filter, lastSnapshotReceivedAt, expectedFreshMs, gatewayError, }: StatusBarProps): React.JSX.Element;
6
+ export declare function StatusBar({ connected, lastSnapshotReceivedAt, gatewayError }: StatusBarProps): React.JSX.Element;
12
7
  export {};
@@ -1,37 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text, useStdout } from "ink";
3
- import { getClusterSummary, getRepoHealth } from "./dashboard-model.js";
4
- import { truncate } from "./format.js";
2
+ import { Box, Text } from "ink";
5
3
  import { FreshnessBadge } from "./FreshnessBadge.js";
6
- export function StatusBar({ repos, currentRepo, view, filter, lastSnapshotReceivedAt, expectedFreshMs, gatewayError, }) {
7
- const { stdout } = useStdout();
8
- const width = stdout?.columns ?? 80;
9
- const compact = width < 88;
10
- const cluster = getClusterSummary(repos);
11
- const connected = cluster.connected > 0;
12
- const repoHealth = currentRepo ? getRepoHealth(currentRepo) : null;
13
- const leftParts = view === "project" && currentRepo
14
- ? compact && currentRepo.repoId
15
- ? `ms ${currentRepo.repoId} ${repoHealth?.label ?? ""} a:${currentRepo.snapshot?.summary.active ?? 0} h:${currentRepo.snapshot?.summary.headPrNumber ?? "?"}`
16
- : [
17
- "merge-steward dashboard",
18
- currentRepo.repoId,
19
- repoHealth?.label ?? null,
20
- currentRepo.snapshot ? `${currentRepo.snapshot.summary.active} active` : null,
21
- currentRepo.snapshot?.summary.headPrNumber ? `head #${currentRepo.snapshot.summary.headPrNumber}` : "no head PR",
22
- `filter:${filter}`,
23
- ].filter(Boolean).join(" | ")
24
- : compact
25
- ? `${cluster.total}p ${cluster.connected}c ${cluster.active}a ${cluster.blocked}b`
26
- : [
27
- "merge-steward dashboard",
28
- `${cluster.total} projects`,
29
- `${cluster.connected} connected`,
30
- `${cluster.active} active`,
31
- `${cluster.blocked} blocked`,
32
- `${cluster.stuck} stuck`,
33
- `${cluster.attention} need attention`,
34
- ].join(" | ");
35
- const availableLeft = compact ? Math.max(1, width - 14) : Math.max(1, width - 28);
36
- return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: truncate(leftParts, availableLeft) }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: expectedFreshMs, gatewayError: gatewayError })] }));
4
+ const EXPECTED_FRESH_MS = 3_000;
5
+ export function StatusBar({ connected, lastSnapshotReceivedAt, gatewayError }) {
6
+ return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "merge-steward" }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: EXPECTED_FRESH_MS, gatewayError: gatewayError })] }));
37
7
  }
@@ -12,31 +12,42 @@ export interface DashboardRepoState extends DashboardRepoConfig {
12
12
  error: string | null;
13
13
  lastSnapshotReceivedAt: number | null;
14
14
  }
15
- export interface RepoHealthSummary {
16
- kind: "offline" | "initializing" | "failed" | "blocked" | "stuck" | "attention" | "active" | "idle";
17
- label: string;
18
- color: "red" | "yellow" | "green" | "gray" | "cyan";
19
- detail: string;
15
+ export type DashboardTokenColor = "red" | "yellow" | "green" | "gray" | "white";
16
+ export type DashboardTokenKind = "running" | "queued" | "approved" | "declined" | "error" | "cancelled" | "superseded";
17
+ export interface DashboardToken {
18
+ prNumber: number;
19
+ glyph: string;
20
+ color: DashboardTokenColor;
21
+ kind: DashboardTokenKind;
20
22
  }
21
- export interface ClusterSummary {
22
- total: number;
23
- connected: number;
24
- blocked: number;
25
- stuck: number;
26
- attention: number;
27
- active: number;
23
+ export interface DashboardPrEntry extends DashboardToken {
24
+ phrase: string;
25
+ summary?: string;
26
+ eventAt: number;
28
27
  }
29
- export declare function buildQueueSummary(entries: QueueEntry[]): QueueWatchSnapshot["summary"];
30
- export declare function matchRepoRef(repo: DashboardRepoConfig, repoRef: string | undefined): boolean;
31
- export declare function getVisibleEntries(snapshot: QueueWatchSnapshot | null, filter: "active" | "all"): QueueEntry[];
32
- export declare function getChainEntries(snapshot: QueueWatchSnapshot | null): QueueEntry[];
33
- export declare function getActiveEntries(snapshot: QueueWatchSnapshot | null): QueueEntry[];
34
- export declare function getDefaultEntryId(snapshot: QueueWatchSnapshot | null, filter: "active" | "all", preferredPrNumber?: number | null): string | null;
35
- export declare function getRepoHealth(repo: DashboardRepoState, now?: number): RepoHealthSummary;
36
- export declare function getClusterSummary(repos: DashboardRepoState[], now?: number): ClusterSummary;
37
- export declare function projectStatsSummary(snapshot: QueueWatchSnapshot | null, compact?: boolean): string;
38
- export declare function runtimeSummary(snapshot: QueueWatchSnapshot | null): string;
39
- export declare function describeEntry(entry: QueueEntry, options: {
40
- isHead: boolean;
41
- queueBlockSummary: string | null;
28
+ export interface DashboardRepo {
29
+ repoId: string;
30
+ repoFullName: string;
31
+ tokens: DashboardToken[];
32
+ entries: DashboardPrEntry[];
33
+ latestActivityAt: number;
34
+ hasActivity: boolean;
35
+ offlineMessage: string | null;
36
+ }
37
+ export interface DashboardModel {
38
+ repos: DashboardRepo[];
39
+ quietCount: number;
40
+ }
41
+ export declare function buildDashboard(repos: DashboardRepoState[], opts?: {
42
+ windowMs?: number;
43
+ now?: number;
44
+ }): DashboardModel;
45
+ export declare function clipSummary(summary: string | undefined, opts?: {
46
+ maxLines?: number;
47
+ width?: number;
42
48
  }): string;
49
+ export declare function repoSelector(repos: DashboardRepo[], selected: string | null): string | null;
50
+ export declare function stepRepo(repos: DashboardRepo[], selected: string | null, direction: 1 | -1): string | null;
51
+ export declare function findRepo(repos: DashboardRepo[], selected: string | null): DashboardRepo | null;
52
+ export declare function matchRepoRef(repo: DashboardRepoConfig, repoRef: string | undefined): boolean;
53
+ export declare function buildQueueSummary(entries: QueueEntry[]): QueueWatchSnapshot["summary"];
@@ -1,40 +1,293 @@
1
- import { buildChainEntries, buildDisplayEntries } from "./display-filter.js";
2
- import { formatDuration, humanStatus, nextStepLabel, postMergeStatusLine, 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;
1
+ import { TERMINAL_STATUSES } from "../types.js";
2
+ import { mergeWaitState } from "./format.js";
3
+ const DECIDED_WINDOW_MS = 24 * 60 * 60 * 1000;
4
+ const GLYPH = {
5
+ running: "\u25cf",
6
+ queued: "\u25cb",
7
+ approved: "\u2713",
8
+ declined: "\u2717",
9
+ error: "\u26a0",
10
+ cancelled: "\u2013",
11
+ superseded: "\u21bb",
12
+ };
13
+ const COLOR = {
14
+ running: "yellow",
15
+ queued: "gray",
16
+ approved: "green",
17
+ declined: "red",
18
+ error: "red",
19
+ cancelled: "gray",
20
+ superseded: "gray",
21
+ };
22
+ function timestamp(value) {
23
+ if (!value)
24
+ return 0;
25
+ const ms = new Date(value).getTime();
26
+ return Number.isFinite(ms) ? ms : 0;
27
+ }
6
28
  function isLaterEntry(left, right) {
7
29
  if (left.position !== right.position) {
8
30
  return left.position > right.position;
9
31
  }
10
- return new Date(left.updatedAt).getTime() >= new Date(right.updatedAt).getTime();
32
+ return timestamp(left.updatedAt) >= timestamp(right.updatedAt);
11
33
  }
12
- function getLatestEntries(snapshot) {
13
- if (!snapshot) {
14
- return [];
15
- }
34
+ function pickLatestPerPR(entries) {
16
35
  const byPR = new Map();
17
- for (const entry of snapshot.entries) {
36
+ for (const entry of entries) {
18
37
  const existing = byPR.get(entry.prNumber);
19
38
  if (!existing || isLaterEntry(entry, existing)) {
20
39
  byPR.set(entry.prNumber, entry);
21
40
  }
22
41
  }
23
- return [...byPR.values()].sort((a, b) => a.position - b.position);
42
+ return [...byPR.values()];
24
43
  }
25
- function getMostRecentActivityAt(snapshot) {
26
- const latestEntries = getLatestEntries(snapshot);
27
- if (latestEntries.length === 0) {
28
- return undefined;
44
+ function entryKind(entry) {
45
+ switch (entry.status) {
46
+ case "queued":
47
+ return "queued";
48
+ case "preparing_head":
49
+ case "validating":
50
+ case "merging":
51
+ return "running";
52
+ case "merged":
53
+ if (entry.postMergeStatus === "fail")
54
+ return "declined";
55
+ if (entry.postMergeStatus === "pending")
56
+ return "running";
57
+ return "approved";
58
+ case "evicted":
59
+ return "error";
60
+ case "dequeued":
61
+ return "cancelled";
29
62
  }
30
- return latestEntries.reduce((latest, entry) => {
31
- if (!latest) {
32
- return entry.updatedAt;
63
+ }
64
+ function entryPhrase(entry, opts) {
65
+ if (opts.isHead && opts.queueBlocked) {
66
+ return "main broken";
67
+ }
68
+ switch (entry.status) {
69
+ case "queued":
70
+ return opts.isHead ? "queued" : "behind head";
71
+ case "preparing_head":
72
+ return entry.lastFailedBaseSha ? "has conflicts" : "preparing";
73
+ case "validating":
74
+ return "testing";
75
+ case "merging": {
76
+ const wait = mergeWaitState({ status: entry.status, waitDetail: entry.waitDetail });
77
+ if (wait === "approval")
78
+ return "waiting for approval";
79
+ if (wait === "main")
80
+ return "waiting for main";
81
+ return "merging";
33
82
  }
34
- return new Date(entry.updatedAt).getTime() > new Date(latest).getTime()
35
- ? entry.updatedAt
36
- : latest;
37
- }, undefined);
83
+ case "merged":
84
+ if (entry.postMergeStatus === "fail")
85
+ return "post-merge failed";
86
+ if (entry.postMergeStatus === "pending")
87
+ return "merged, post-merge pending";
88
+ return "merged";
89
+ case "evicted":
90
+ return "evicted";
91
+ case "dequeued":
92
+ return "dequeued";
93
+ }
94
+ }
95
+ function entrySummary(entry) {
96
+ if (entry.status === "merged" && entry.postMergeStatus === "fail" && entry.postMergeSummary) {
97
+ return entry.postMergeSummary;
98
+ }
99
+ return undefined;
100
+ }
101
+ function isActive(status) {
102
+ return !TERMINAL_STATUSES.includes(status);
103
+ }
104
+ function tokenSortOrder(kind) {
105
+ switch (kind) {
106
+ case "running":
107
+ return 0;
108
+ case "queued":
109
+ return 1;
110
+ case "approved":
111
+ case "declined":
112
+ case "error":
113
+ return 2;
114
+ case "cancelled":
115
+ case "superseded":
116
+ return 3;
117
+ }
118
+ }
119
+ function overrideKind(kind, override) {
120
+ if (override === "main_broken")
121
+ return "error";
122
+ return kind;
123
+ }
124
+ function repoEntriesFromSnapshot(snapshot, cutoff) {
125
+ const latest = pickLatestPerPR(snapshot.entries);
126
+ const head = latest
127
+ .filter((entry) => isActive(entry.status))
128
+ .sort((a, b) => a.position - b.position)[0] ?? null;
129
+ const queueBlocked = Boolean(snapshot.queueBlock);
130
+ const byPr = new Map();
131
+ for (const entry of latest) {
132
+ const active = isActive(entry.status);
133
+ if (!active && timestamp(entry.updatedAt) < cutoff)
134
+ continue;
135
+ if (entry.status === "dequeued")
136
+ continue;
137
+ const isHead = head !== null && entry.id === head.id;
138
+ const rawKind = entryKind(entry);
139
+ const kind = overrideKind(rawKind, isHead && queueBlocked ? "main_broken" : null);
140
+ const glyph = GLYPH[kind];
141
+ const color = COLOR[kind];
142
+ const phrase = entryPhrase(entry, { isHead, queueBlocked });
143
+ const item = {
144
+ prNumber: entry.prNumber,
145
+ glyph,
146
+ color,
147
+ kind,
148
+ phrase,
149
+ eventAt: timestamp(entry.updatedAt),
150
+ };
151
+ const summary = entrySummary(entry);
152
+ if (summary)
153
+ item.summary = summary;
154
+ byPr.set(entry.prNumber, item);
155
+ }
156
+ return [...byPr.values()].sort((left, right) => {
157
+ const leftOrder = tokenSortOrder(left.kind);
158
+ const rightOrder = tokenSortOrder(right.kind);
159
+ if (leftOrder !== rightOrder)
160
+ return leftOrder - rightOrder;
161
+ if (left.eventAt !== right.eventAt)
162
+ return right.eventAt - left.eventAt;
163
+ return right.prNumber - left.prNumber;
164
+ });
165
+ }
166
+ export function buildDashboard(repos, opts = {}) {
167
+ const windowMs = opts.windowMs ?? DECIDED_WINDOW_MS;
168
+ const now = opts.now ?? Date.now();
169
+ const cutoff = now - windowMs;
170
+ const mapped = repos.map((repo) => {
171
+ const snapshot = repo.snapshot;
172
+ if (!snapshot) {
173
+ const message = repo.serviceState === "initializing"
174
+ ? repo.serviceMessage ?? "initializing"
175
+ : repo.serviceState === "failed"
176
+ ? repo.serviceMessage ?? "init failed"
177
+ : repo.error ?? "offline";
178
+ return {
179
+ repoId: repo.repoId,
180
+ repoFullName: repo.repoFullName,
181
+ tokens: [],
182
+ entries: [],
183
+ latestActivityAt: 0,
184
+ hasActivity: false,
185
+ offlineMessage: message,
186
+ };
187
+ }
188
+ const entries = repoEntriesFromSnapshot(snapshot, cutoff);
189
+ const latestActivityAt = entries.reduce((max, entry) => Math.max(max, entry.eventAt), 0);
190
+ return {
191
+ repoId: repo.repoId,
192
+ repoFullName: repo.repoFullName,
193
+ tokens: entries.map(({ prNumber, glyph, color, kind }) => ({ prNumber, glyph, color, kind })),
194
+ entries,
195
+ latestActivityAt,
196
+ hasActivity: entries.length > 0,
197
+ offlineMessage: null,
198
+ };
199
+ });
200
+ const active = mapped.filter((repo) => repo.hasActivity || repo.offlineMessage);
201
+ active.sort((left, right) => {
202
+ if (left.offlineMessage !== null && right.offlineMessage === null)
203
+ return 1;
204
+ if (right.offlineMessage !== null && left.offlineMessage === null)
205
+ return -1;
206
+ const leftHasActive = left.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
207
+ const rightHasActive = right.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
208
+ if (leftHasActive !== rightHasActive)
209
+ return leftHasActive ? -1 : 1;
210
+ if (left.latestActivityAt !== right.latestActivityAt)
211
+ return right.latestActivityAt - left.latestActivityAt;
212
+ return left.repoFullName.localeCompare(right.repoFullName);
213
+ });
214
+ const quietCount = mapped.length - active.length;
215
+ return { repos: active, quietCount };
216
+ }
217
+ export function clipSummary(summary, opts = {}) {
218
+ if (!summary)
219
+ return "";
220
+ const maxLines = opts.maxLines ?? 3;
221
+ const width = Math.max(20, opts.width ?? 80);
222
+ const collapsed = summary.replace(/\s+/g, " ").trim();
223
+ if (collapsed.length === 0)
224
+ return "";
225
+ const sentenceRe = /[^.!?]+[.!?]+(?:\s|$)|[^.!?]+$/g;
226
+ const sentences = (collapsed.match(sentenceRe) ?? [collapsed])
227
+ .map((value) => value.trim())
228
+ .filter((value) => value.length > 0);
229
+ let buffer = "";
230
+ for (const sentence of sentences) {
231
+ const candidate = buffer.length === 0 ? sentence : `${buffer} ${sentence}`;
232
+ if (wrapLines(candidate, width).length <= maxLines) {
233
+ buffer = candidate;
234
+ }
235
+ else {
236
+ break;
237
+ }
238
+ }
239
+ if (buffer.length === 0) {
240
+ const first = sentences[0] ?? collapsed;
241
+ return wrapLines(first, width).slice(0, maxLines).join("\n");
242
+ }
243
+ return wrapLines(buffer, width).slice(0, maxLines).join("\n");
244
+ }
245
+ function wrapLines(text, width) {
246
+ const words = text.split(/\s+/).filter((word) => word.length > 0);
247
+ const lines = [];
248
+ let current = "";
249
+ for (const word of words) {
250
+ if (current.length === 0) {
251
+ current = word;
252
+ continue;
253
+ }
254
+ if (current.length + 1 + word.length <= width) {
255
+ current = `${current} ${word}`;
256
+ }
257
+ else {
258
+ lines.push(current);
259
+ current = word;
260
+ }
261
+ }
262
+ if (current.length > 0)
263
+ lines.push(current);
264
+ return lines.length === 0 ? [""] : lines;
265
+ }
266
+ export function repoSelector(repos, selected) {
267
+ if (repos.length === 0)
268
+ return null;
269
+ if (selected && repos.some((repo) => repo.repoId === selected)) {
270
+ return selected;
271
+ }
272
+ return repos[0]?.repoId ?? null;
273
+ }
274
+ export function stepRepo(repos, selected, direction) {
275
+ if (repos.length === 0)
276
+ return null;
277
+ const index = repos.findIndex((repo) => repo.repoId === selected);
278
+ const currentIndex = index === -1 ? 0 : index;
279
+ const nextIndex = (currentIndex + direction + repos.length) % repos.length;
280
+ return repos[nextIndex]?.repoId ?? null;
281
+ }
282
+ export function findRepo(repos, selected) {
283
+ if (!selected)
284
+ return repos[0] ?? null;
285
+ return repos.find((repo) => repo.repoId === selected) ?? repos[0] ?? null;
286
+ }
287
+ export function matchRepoRef(repo, repoRef) {
288
+ if (!repoRef)
289
+ return false;
290
+ return repo.repoId === repoRef || repo.repoFullName === repoRef;
38
291
  }
39
292
  export function buildQueueSummary(entries) {
40
293
  const summary = {
@@ -50,7 +303,7 @@ export function buildQueueSummary(entries) {
50
303
  headEntryId: null,
51
304
  headPrNumber: null,
52
305
  };
53
- const activeEntries = entries.filter((entry) => !["merged", "evicted", "dequeued"].includes(entry.status));
306
+ const activeEntries = entries.filter((entry) => !TERMINAL_STATUSES.includes(entry.status));
54
307
  if (activeEntries.length > 0) {
55
308
  summary.headEntryId = activeEntries[0].id;
56
309
  summary.headPrNumber = activeEntries[0].prNumber;
@@ -83,218 +336,3 @@ export function buildQueueSummary(entries) {
83
336
  }
84
337
  return summary;
85
338
  }
86
- export function matchRepoRef(repo, repoRef) {
87
- if (!repoRef) {
88
- return false;
89
- }
90
- return repo.repoId === repoRef || repo.repoFullName === repoRef;
91
- }
92
- export function getVisibleEntries(snapshot, filter) {
93
- if (!snapshot) {
94
- return [];
95
- }
96
- return buildDisplayEntries(snapshot.entries, filter);
97
- }
98
- export function getChainEntries(snapshot) {
99
- if (!snapshot) {
100
- return [];
101
- }
102
- return buildChainEntries(snapshot.entries);
103
- }
104
- export function getActiveEntries(snapshot) {
105
- return getLatestEntries(snapshot).filter((entry) => (entry.status !== "merged" && entry.status !== "evicted" && entry.status !== "dequeued"));
106
- }
107
- export function getDefaultEntryId(snapshot, filter, preferredPrNumber) {
108
- if (!snapshot) {
109
- return null;
110
- }
111
- const visibleEntries = getVisibleEntries(snapshot, filter);
112
- if (preferredPrNumber !== undefined && preferredPrNumber !== null) {
113
- const match = visibleEntries.find((entry) => entry.prNumber === preferredPrNumber)
114
- ?? snapshot.entries.find((entry) => entry.prNumber === preferredPrNumber);
115
- if (match) {
116
- return match.id;
117
- }
118
- }
119
- return snapshot.summary.headEntryId ?? visibleEntries[0]?.id ?? null;
120
- }
121
- export function getRepoHealth(repo, now = Date.now()) {
122
- const snapshot = repo.snapshot;
123
- const staleForMs = repo.lastSnapshotReceivedAt === null ? Infinity : now - repo.lastSnapshotReceivedAt;
124
- if (repo.serviceState === "initializing") {
125
- return {
126
- kind: "initializing",
127
- label: "Initializing",
128
- color: "cyan",
129
- detail: repo.serviceMessage ?? "Loading GitHub policy and local clone state.",
130
- };
131
- }
132
- if (repo.serviceState === "failed") {
133
- return {
134
- kind: "failed",
135
- label: "Init failed",
136
- color: "red",
137
- detail: repo.serviceMessage ?? "Repo initialization failed in merge-steward.",
138
- };
139
- }
140
- if (!snapshot || (repo.error && staleForMs > OFFLINE_STALE_MS)) {
141
- return {
142
- kind: "offline",
143
- label: "Offline",
144
- color: "red",
145
- detail: repo.error ?? "No queue data from merge-steward yet.",
146
- };
147
- }
148
- if (snapshot.queueBlock) {
149
- return {
150
- kind: "blocked",
151
- label: "Blocked",
152
- color: "yellow",
153
- detail: summarizeQueueBlock(snapshot.queueBlock) ?? `Waiting for ${snapshot.baseBranch} to recover.`,
154
- };
155
- }
156
- if (snapshot.runtime.lastTickOutcome === "failed") {
157
- return {
158
- kind: "stuck",
159
- label: "Stuck",
160
- color: "red",
161
- detail: snapshot.runtime.lastTickError?.split(/\r?\n/, 1)[0] ?? "The last reconcile tick failed.",
162
- };
163
- }
164
- const activeEntries = getActiveEntries(snapshot);
165
- const headEntry = activeEntries[0];
166
- if (headEntry) {
167
- const ageMs = now - new Date(headEntry.updatedAt).getTime();
168
- if (ageMs >= STUCK_ENTRY_MS) {
169
- return {
170
- kind: "stuck",
171
- label: "Stuck",
172
- color: "red",
173
- detail: `Head PR #${headEntry.prNumber} has not moved for ${relativeTime(headEntry.updatedAt)}.`,
174
- };
175
- }
176
- }
177
- const recentEvicted = getLatestEntries(snapshot).find((entry) => (entry.status === "evicted"
178
- && now - new Date(entry.updatedAt).getTime() <= RECENT_EVICTION_MS));
179
- if (recentEvicted) {
180
- return {
181
- kind: "attention",
182
- label: "Needs attention",
183
- color: "yellow",
184
- detail: `PR #${recentEvicted.prNumber} was evicted and needs repair before it can rejoin the queue.`,
185
- };
186
- }
187
- if (activeEntries.length > 0) {
188
- return {
189
- kind: "active",
190
- label: "Running",
191
- color: "cyan",
192
- detail: headEntry
193
- ? `Head PR #${headEntry.prNumber} is ${humanStatus(headEntry.status, headEntry)}.`
194
- : `${activeEntries.length} PRs are active in the queue.`,
195
- };
196
- }
197
- return {
198
- kind: "idle",
199
- label: "Idle",
200
- color: "green",
201
- detail: snapshot.summary.total > 0
202
- ? `No active queue work right now. Last activity was ${relativeTime(getMostRecentActivityAt(snapshot))} ago.`
203
- : "No pull requests are queued right now.",
204
- };
205
- }
206
- export function getClusterSummary(repos, now = Date.now()) {
207
- const initial = {
208
- total: repos.length,
209
- connected: 0,
210
- blocked: 0,
211
- stuck: 0,
212
- attention: 0,
213
- active: 0,
214
- };
215
- return repos.reduce((summary, repo) => {
216
- const health = getRepoHealth(repo, now);
217
- if (health.kind !== "offline") {
218
- summary.connected += 1;
219
- }
220
- if (health.kind === "blocked") {
221
- summary.blocked += 1;
222
- }
223
- if (health.kind === "stuck") {
224
- summary.stuck += 1;
225
- }
226
- if (health.kind === "attention") {
227
- summary.attention += 1;
228
- }
229
- if (repo.snapshot && getActiveEntries(repo.snapshot).length > 0) {
230
- summary.active += 1;
231
- }
232
- return summary;
233
- }, initial);
234
- }
235
- export function projectStatsSummary(snapshot, compact = false) {
236
- if (!snapshot) {
237
- return "No queue data yet.";
238
- }
239
- const latestEntries = getLatestEntries(snapshot);
240
- const activeEntries = getActiveEntries(snapshot);
241
- const avgWaitMs = activeEntries.length > 0
242
- ? activeEntries.reduce((sum, entry) => sum + (Date.now() - new Date(entry.enqueuedAt).getTime()), 0) / activeEntries.length
243
- : 0;
244
- const queued = latestEntries.filter((entry) => entry.status === "queued").length;
245
- const validating = latestEntries.filter((entry) => entry.status === "validating" || entry.status === "preparing_head").length;
246
- const merging = latestEntries.filter((entry) => entry.status === "merging").length;
247
- const evicted = latestEntries.filter((entry) => entry.status === "evicted").length;
248
- const parts = compact
249
- ? [
250
- `${activeEntries.length}a`,
251
- `${queued}w`,
252
- `${validating}t`,
253
- `${merging}m`,
254
- ]
255
- : [
256
- `${activeEntries.length} active`,
257
- `${queued} waiting`,
258
- `${validating} testing`,
259
- `${merging} merging`,
260
- ];
261
- if (evicted > 0) {
262
- parts.push(compact ? `${evicted}x` : `${evicted} need repair`);
263
- }
264
- if (avgWaitMs > 0) {
265
- parts.push(compact ? `w ${formatDuration(avgWaitMs)}` : `avg wait ${formatDuration(avgWaitMs)}`);
266
- }
267
- return parts.join(" · ");
268
- }
269
- export function runtimeSummary(snapshot) {
270
- if (!snapshot) {
271
- return "No runtime data yet.";
272
- }
273
- if (snapshot.runtime.tickInProgress) {
274
- return `Reconcile tick is running now. Last completed ${relativeTime(snapshot.runtime.lastTickCompletedAt)} ago.`;
275
- }
276
- if (snapshot.runtime.lastTickOutcome === "failed") {
277
- return `Last reconcile tick failed ${relativeTime(snapshot.runtime.lastTickCompletedAt ?? snapshot.runtime.lastTickStartedAt)} ago.`;
278
- }
279
- if (snapshot.runtime.lastTickCompletedAt) {
280
- return `Last reconcile tick ${snapshot.runtime.lastTickOutcome} ${relativeTime(snapshot.runtime.lastTickCompletedAt)} ago.`;
281
- }
282
- return "No reconcile tick has completed yet.";
283
- }
284
- export function describeEntry(entry, options) {
285
- if (options.isHead && options.queueBlockSummary) {
286
- return options.queueBlockSummary;
287
- }
288
- if (entry.status === "merged") {
289
- return `Landed on main. ${postMergeStatusLine(entry)}`
290
- + (entry.postMergeCheckedAt ? ` (${relativeTime(entry.postMergeCheckedAt)} ago).` : ".");
291
- }
292
- if (entry.status === "queued") {
293
- if (entry.position > 1) {
294
- return `Waiting behind ${entry.position - 1} earlier PRs.`;
295
- }
296
- return "First in line and waiting for the next reconcile tick.";
297
- }
298
- const nextStep = nextStepLabel(entry.status, entry);
299
- return `${humanStatus(entry.status, entry)}: ${nextStep}.`;
300
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.15.0",
3
+ "version": "0.16.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": {