merge-steward 0.7.0 → 0.8.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/dist/watch/App.js CHANGED
@@ -35,6 +35,14 @@ export function App({ baseUrl, initialPrNumber }) {
35
35
  const entries = snapshot?.entries ?? [];
36
36
  return filter === "active" ? entries.filter(isActiveEntry) : entries;
37
37
  }, [filter, snapshot?.entries]);
38
+ // Recently completed entries: terminal entries within the last 60 seconds.
39
+ const recentlyCompleted = useMemo(() => {
40
+ if (filter !== "active")
41
+ return [];
42
+ const entries = snapshot?.entries ?? [];
43
+ const cutoff = Date.now() - 60_000;
44
+ return entries.filter((e) => !isActiveEntry(e) && new Date(e.updatedAt).getTime() > cutoff);
45
+ }, [filter, snapshot?.entries]);
38
46
  const selectedEntry = useMemo(() => visibleEntries.find((entry) => entry.id === selectedEntryId) ?? null, [selectedEntryId, visibleEntries]);
39
47
  const activeEntries = useMemo(() => (snapshot?.entries ?? []).filter(isActiveEntry), [snapshot?.entries]);
40
48
  const selectedActiveIndex = useMemo(() => {
@@ -188,5 +196,5 @@ export function App({ baseUrl, initialPrNumber }) {
188
196
  setSelectedEntryId(nextSelection(visibleEntries, selectedEntryId, "prev"));
189
197
  }
190
198
  });
191
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { snapshot: snapshot, connected: connected, filter: filter, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: REFRESH_INTERVAL_MS * 2 }), view === "detail" && selectedEntry && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Queue" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsxs(Text, { bold: true, children: ["#", selectedEntry.prNumber] }), _jsxs(Text, { dimColor: true, children: [" (", selectedEntry.status, ")"] })] })), view === "list" ? (_jsx(QueueListView, { entries: visibleEntries, selectedEntryId: selectedEntryId, recentEvents: snapshot?.recentEvents ?? [], headEntryId: snapshot?.summary.headEntryId ?? null, queueBlock: snapshot?.queueBlock ?? null })) : (_jsx(DetailView, { detail: detail, isHead: isHeadSelected, activeIndex: selectedActiveIndex, activeCount: activeEntries.length, headPrNumber: snapshot?.summary.headPrNumber ?? null, queueBlock: snapshot?.queueBlock ?? null })), flashMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })), _jsx(HelpBar, { view: view })] }));
199
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { snapshot: snapshot, connected: connected, filter: filter, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: REFRESH_INTERVAL_MS * 2 }), view === "detail" && selectedEntry && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Queue" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsxs(Text, { bold: true, children: ["#", selectedEntry.prNumber] }), _jsxs(Text, { dimColor: true, children: [" (", selectedEntry.status, ")"] })] })), view === "list" ? (_jsx(QueueListView, { entries: visibleEntries, recentlyCompleted: recentlyCompleted, selectedEntryId: selectedEntryId, recentEvents: snapshot?.recentEvents ?? [], headEntryId: snapshot?.summary.headEntryId ?? null, queueBlock: snapshot?.queueBlock ?? null })) : (_jsx(DetailView, { detail: detail, isHead: isHeadSelected, activeIndex: selectedActiveIndex, activeCount: activeEntries.length, headPrNumber: snapshot?.summary.headPrNumber ?? null, queueBlock: snapshot?.queueBlock ?? null })), flashMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })), _jsx(HelpBar, { view: view })] }));
192
200
  }
@@ -1,10 +1,11 @@
1
1
  import type { QueueBlockState, QueueEntry, QueueEventSummary } from "../types.ts";
2
2
  interface QueueListViewProps {
3
3
  entries: QueueEntry[];
4
+ recentlyCompleted: QueueEntry[];
4
5
  selectedEntryId: string | null;
5
6
  recentEvents: QueueEventSummary[];
6
7
  headEntryId: string | null;
7
8
  queueBlock: QueueBlockState | null;
8
9
  }
9
- export declare function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, queueBlock, }: QueueListViewProps): React.JSX.Element;
10
+ export declare function QueueListView({ entries, recentlyCompleted, selectedEntryId, recentEvents, headEntryId, queueBlock, }: QueueListViewProps): React.JSX.Element;
10
11
  export {};
@@ -1,9 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
- import { formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, specChainLabel, statusColor, summarizeQueueBlock, truncate } from "./format.js";
4
+ import { ciStatusIcon, formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, specChainLabel, statusColor, summarizeQueueBlock, truncate } from "./format.js";
5
+ import { TERMINAL_STATUSES } from "../types.js";
5
6
  const ENTRY_ROW_HEIGHT = 2;
6
7
  const CHROME_ROWS = 13;
8
+ const RECENTLY_COMPLETED_MAX_AGE_MS = 60_000;
7
9
  function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, allEntries, }) {
8
10
  const retryText = `${entry.retryAttempts}/${entry.maxRetries}`;
9
11
  const ciText = entry.ciRetries > 0 ? `CI retries ${entry.ciRetries}` : null;
@@ -22,13 +24,22 @@ function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, allEntries,
22
24
  : nextStepLabel(entry.status, entry);
23
25
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "›" : " " }), _jsx(Text, { color: blockedOnMain ? "red" : isHead ? "green" : "gray", children: blockedOnMain ? "!" : isHead ? "#" : " " }), _jsx(Text, { bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` pos ${entry.position}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: renderedColor, children: renderedStatus })] }), _jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: progressBar(progress.current, progress.total, 8) }), _jsx(Text, { dimColor: true, children: specLabel }), _jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: nextStep }), _jsx(Text, { dimColor: true, children: ` | retry ${retryText}` }), ciText ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: ciText })] })) : null] })] }));
24
26
  }
25
- export function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
27
+ export function QueueListView({ entries, recentlyCompleted, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
26
28
  const { stdout } = useStdout();
27
29
  const rows = stdout?.rows ?? 24;
28
30
  const cols = stdout?.columns ?? 100;
29
31
  const infoWidth = Math.max(32, cols - 4);
30
- const eventRows = Math.min(8, Math.max(4, rows - (entries.length * ENTRY_ROW_HEIGHT) - CHROME_ROWS));
32
+ const totalRows = entries.length + recentlyCompleted.length;
33
+ const eventRows = Math.min(8, Math.max(4, rows - (totalRows * ENTRY_ROW_HEIGHT) - CHROME_ROWS));
31
34
  const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
32
35
  const queueBlockLabel = summarizeQueueBlock(queueBlock);
33
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, infoWidth: infoWidth, isHead: entry.id === headEntryId, queueBlock: queueBlock, allEntries: entries }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
36
+ // Spec chain: main #A #B #C
37
+ const chainEntries = useMemo(() => {
38
+ const active = entries.filter((e) => !TERMINAL_STATUSES.includes(e.status));
39
+ return active.sort((a, b) => a.position - b.position);
40
+ }, [entries]);
41
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [chainEntries.length > 0 && (_jsxs(Box, { marginBottom: 1, gap: 0, children: [_jsx(Text, { dimColor: true, children: "main" }), chainEntries.map((entry) => {
42
+ const ci = ciStatusIcon(entry);
43
+ return (_jsxs(Box, { gap: 0, children: [_jsx(Text, { dimColor: true, children: " \u2500 " }), _jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), _jsx(Text, { color: ci.color, children: ` ${ci.icon}` })] }, entry.id));
44
+ })] })), queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), entries.length === 0 && recentlyCompleted.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (_jsxs(_Fragment, { children: [entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, infoWidth: infoWidth, isHead: entry.id === headEntryId, queueBlock: queueBlock, allEntries: entries }, entry.id))), recentlyCompleted.length > 0 && (_jsx(_Fragment, { children: recentlyCompleted.map((entry) => (_jsx(Box, { flexDirection: "column", children: _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { color: entry.status === "merged" ? "green" : "red", children: entry.status === "merged" ? "\u2713" : "\u2717" }), _jsx(Text, { dimColor: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { dimColor: true, children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${humanStatus(entry.status, entry)} ${relativeTime(entry.updatedAt)}` })] }) }, entry.id))) }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
34
45
  }
@@ -31,4 +31,12 @@ export declare function formatDuration(ms: number): string;
31
31
  export declare function progressBar(current: number, total: number, width: number): string;
32
32
  export declare function truncate(value: string, width: number): string;
33
33
  export declare function summarizeCheckNames(checks: CheckResult[], limit?: number): string;
34
+ /** CI status icon for the spec chain summary line. */
35
+ export declare function ciStatusIcon(entry: {
36
+ status: QueueEntryStatus;
37
+ ciRunId: string | null;
38
+ }): {
39
+ icon: string;
40
+ color: string;
41
+ };
34
42
  export declare function summarizeQueueBlock(block: QueueBlockState | null | undefined): string | null;
@@ -155,6 +155,22 @@ export function summarizeCheckNames(checks, limit = 3) {
155
155
  return names.join(", ");
156
156
  return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
157
157
  }
158
+ /** CI status icon for the spec chain summary line. */
159
+ export function ciStatusIcon(entry) {
160
+ switch (entry.status) {
161
+ case "merged": return { icon: "\u2713", color: "green" }; // ✓
162
+ case "merging": return { icon: "\u2713", color: "cyan" }; // ✓
163
+ case "evicted": return { icon: "\u2717", color: "red" }; // ✗
164
+ case "dequeued": return { icon: "\u2012", color: "gray" }; // ‒
165
+ case "validating":
166
+ return entry.ciRunId ? { icon: "\u25cf", color: "cyan" } : { icon: "\u25cb", color: "gray" }; // ● or ○
167
+ case "preparing_head":
168
+ return { icon: "\u25cb", color: "yellow" }; // ○
169
+ case "queued":
170
+ default:
171
+ return { icon: "\u25cb", color: "gray" }; // ○
172
+ }
173
+ }
158
174
  export function summarizeQueueBlock(block) {
159
175
  if (!block)
160
176
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": {