merge-steward 0.8.2 → 0.8.4

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,5 +1,5 @@
1
1
  import { exec } from "../exec.js";
2
- /** Extract conflict file names from git rebase/merge stderr. */
2
+ /** Extract conflict file names from git merge stderr. */
3
3
  function parseConflicts(stderr) {
4
4
  const files = stderr
5
5
  .split("\n")
@@ -7,7 +7,6 @@ export interface GitOperations {
7
7
  fetch(remote?: string): Promise<void>;
8
8
  headSha(branch: string): Promise<string>;
9
9
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
10
- mergeBaseInto(branch: string, base: string): Promise<MergeResult>;
11
10
  push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
12
11
  }
13
12
  /**
@@ -118,6 +118,15 @@ async function prepareEntry(ctx, entry, isHead, prevEntry) {
118
118
  if (!base)
119
119
  return; // Non-head: prev hasn't built its spec yet, wait.
120
120
  const baseSha = await ctx.git.headSha(base);
121
+ // ── Branch mismatch gate (all entries) ─────────────────────────
122
+ // Detect external pushes to the PR branch. If webhooks missed
123
+ // a force-push, catch it here before building a spec from stale content.
124
+ const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
125
+ if (currentRef !== entry.headSha) {
126
+ emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
127
+ ctx.store.updateHead(entry.id, currentRef);
128
+ return;
129
+ }
121
130
  // ── Head-only gates ───────────────────────────────────────────
122
131
  if (isHead) {
123
132
  // Gate: main CI must be green.
@@ -142,13 +151,6 @@ async function prepareEntry(ctx, entry, isHead, prevEntry) {
142
151
  return;
143
152
  }
144
153
  }
145
- // Gate: detect external pushes to the PR branch.
146
- const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
147
- if (currentRef !== entry.headSha) {
148
- emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
149
- ctx.store.updateHead(entry.id, currentRef);
150
- return;
151
- }
152
154
  // Gate: budget exhausted after previous conflict.
153
155
  if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
154
156
  emit(ctx, entry, "budget_exhausted", { baseSha });
@@ -197,7 +199,7 @@ async function prepareEntry(ctx, entry, isHead, prevEntry) {
197
199
  return;
198
200
  }
199
201
  if (!result.success) {
200
- emit(ctx, entry, "rebase_conflict", { baseSha, conflictFiles: result.conflictFiles });
202
+ emit(ctx, entry, "spec_build_conflict", { baseSha, conflictFiles: result.conflictFiles });
201
203
  if (isBudgetExhausted(entry)) {
202
204
  emit(ctx, entry, "budget_exhausted");
203
205
  await evictEntry(ctx, entry, "integration_conflict", result.conflictFiles ? { conflictFiles: result.conflictFiles } : undefined);
@@ -219,7 +221,7 @@ async function prepareEntry(ctx, entry, isHead, prevEntry) {
219
221
  ctx.store.transition(entry.id, "validating", {
220
222
  baseSha, ciRunId: runId, lastFailedBaseSha: null,
221
223
  specBranch: specName, specSha, specBasedOn: isHead ? null : prevEntry.id,
222
- }, `spec ${specName} ready, CI ${runId}`);
224
+ }, `spec ready, CI ${runId.slice(0, 12)}`);
223
225
  }
224
226
  function describeMainBroken(failingChecks, pendingChecks) {
225
227
  const parts = [];
@@ -245,7 +247,7 @@ async function checkValidation(ctx, entry, allActive, index) {
245
247
  const sha = entry.specSha ?? entry.headSha;
246
248
  const runId = await ctx.ci.triggerRun(branch, sha);
247
249
  emit(ctx, entry, "ci_triggered", { ciRunId: runId });
248
- ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId}`);
250
+ ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId.slice(0, 12)}`);
249
251
  return;
250
252
  }
251
253
  const status = await ctx.ci.getStatus(entry.ciRunId);
@@ -302,10 +304,10 @@ async function mergeHead(ctx, entry) {
302
304
  return;
303
305
  }
304
306
  if (!prStatus.reviewApproved) {
305
- emit(ctx, entry, "merge_rejected", { detail: "approval withdrawn" });
306
- const allActive = ctx.store.listActive(ctx.repoId);
307
- await evictEntry(ctx, entry, "policy_blocked");
308
- await invalidateDownstream(ctx, allActive, 0);
307
+ // Don't evict immediately reviewer may re-approve after re-review.
308
+ // Stay in merging and re-check on the next tick. Operator can dequeue
309
+ // manually if the approval never comes back.
310
+ emit(ctx, entry, "merge_waiting_approval", { detail: "approval withdrawn, waiting for re-approval" });
309
311
  return;
310
312
  }
311
313
  if (prStatus.headSha !== entry.headSha) {
package/dist/service.js CHANGED
@@ -281,8 +281,8 @@ export class MergeStewardService {
281
281
  eviction: this.eviction,
282
282
  flakyRetries: this.config.flakyRetries,
283
283
  onEvent: (event) => {
284
- const isWarn = event.action === "evicted" || event.action === "rebase_conflict"
285
- || event.action === "spec_build_conflict" || event.action === "ci_failed"
284
+ const isWarn = event.action === "evicted" || event.action === "spec_build_conflict"
285
+ || event.action === "ci_failed"
286
286
  || event.action === "merge_rejected" || event.action === "budget_exhausted";
287
287
  const isDebug = event.action === "ci_pending" || event.action === "retry_gated"
288
288
  || event.action === "fetch_started";
package/dist/types.d.ts CHANGED
@@ -173,7 +173,7 @@ export interface QueueConfig {
173
173
  pollIntervalMs: number;
174
174
  requiredChecks: string[];
175
175
  }
176
- export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "rebase_started" | "rebase_succeeded" | "rebase_conflict" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "sanitized_closed" | "sanitized_duplicate" | "branch_unreachable";
176
+ export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "merge_waiting_approval" | "sanitized_closed" | "sanitized_duplicate" | "branch_unreachable";
177
177
  export interface ReconcileEvent {
178
178
  at: string;
179
179
  entryId: string;
package/dist/watch/App.js CHANGED
@@ -1,6 +1,7 @@
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";
4
5
  import { dequeueEntry, fetchEntryDetail, fetchSnapshot, triggerReconcile } from "./api.js";
5
6
  import { DetailView } from "./DetailView.js";
6
7
  import { HelpBar } from "./HelpBar.js";
@@ -31,18 +32,7 @@ export function App({ baseUrl, initialPrNumber }) {
31
32
  const [view, setView] = useState("list");
32
33
  const [filter, setFilter] = useState("active");
33
34
  const [flashMessage, setFlashMessage] = useState(null);
34
- const visibleEntries = useMemo(() => {
35
- const entries = snapshot?.entries ?? [];
36
- return filter === "active" ? entries.filter(isActiveEntry) : entries;
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]);
35
+ const visibleEntries = useMemo(() => buildDisplayEntries(snapshot?.entries ?? [], filter), [filter, snapshot?.entries]);
46
36
  const selectedEntry = useMemo(() => visibleEntries.find((entry) => entry.id === selectedEntryId) ?? null, [selectedEntryId, visibleEntries]);
47
37
  const activeEntries = useMemo(() => (snapshot?.entries ?? []).filter(isActiveEntry), [snapshot?.entries]);
48
38
  const selectedActiveIndex = useMemo(() => {
@@ -196,5 +186,5 @@ export function App({ baseUrl, initialPrNumber }) {
196
186
  setSelectedEntryId(nextSelection(visibleEntries, selectedEntryId, "prev"));
197
187
  }
198
188
  });
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 })] }));
189
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { snapshot: snapshot, connected: connected, filter: filter, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: REFRESH_INTERVAL_MS * 2 }), view === "detail" && selectedEntry && (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "Queue" }), _jsx(Text, { dimColor: true, children: " \u203A " }), _jsxs(Text, { bold: true, children: ["#", selectedEntry.prNumber] }), _jsxs(Text, { dimColor: true, children: [" (", selectedEntry.status, ")"] })] })), view === "list" ? (_jsx(QueueListView, { entries: visibleEntries, allEntries: snapshot?.entries ?? [], selectedEntryId: selectedEntryId, recentEvents: snapshot?.recentEvents ?? [], headEntryId: snapshot?.summary.headEntryId ?? null, queueBlock: snapshot?.queueBlock ?? null })) : (_jsx(DetailView, { detail: detail, isHead: isHeadSelected, activeIndex: selectedActiveIndex, activeCount: activeEntries.length, headPrNumber: snapshot?.summary.headPrNumber ?? null, queueBlock: snapshot?.queueBlock ?? null })), flashMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })), _jsx(HelpBar, { view: view })] }));
200
190
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { formatEntryEvent, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, shortSha, statusColor, summarizeQueueBlock } from "./format.js";
3
+ import { formatEntryEvent, humanStatus, nextStepLabel, relativeTime, shortSha, statusColor, summarizeQueueBlock } from "./format.js";
4
4
  import { EntryStateGraph } from "./EntryStateGraph.js";
5
5
  import { ExternalRepairObservation } from "./ExternalRepairObservation.js";
6
6
  import { buildEntryStateGraph, buildExternalRepairObservations } from "./state-visualization.js";
@@ -17,6 +17,5 @@ export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNum
17
17
  headPrNumber,
18
18
  queueBlock,
19
19
  });
20
- const pipeline = queueProgress(entry.status);
21
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status), children: humanStatus(entry.status, entry) }), _jsxs(Text, { dimColor: true, children: ["pos ", entry.position] }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })] }), _jsx(Text, { children: entry.branch }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["head ", shortSha(entry.headSha)] }), _jsxs(Text, { dimColor: true, children: ["base ", shortSha(entry.baseSha)] })] }), entry.specBranch && (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "spec" }), _jsx(Text, { children: entry.specBranch }), _jsx(Text, { dimColor: true, children: shortSha(entry.specSha) }), _jsx(Text, { dimColor: true, children: "\u2190" }), _jsx(Text, { dimColor: true, children: entry.specBasedOn ? `entry ${shortSha(entry.specBasedOn)}` : "main" })] })), _jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "progress" }), _jsx(Text, { children: progressBar(pipeline.current, pipeline.total, 12) }), _jsx(Text, { dimColor: true, children: nextStepLabel(entry.status, entry) })] }), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? "main branch is unhealthy", "."] }), _jsxs(Text, { dimColor: true, children: [queueBlock.baseBranch, queueBlock.baseSha ? ` @ ${shortSha(queueBlock.baseSha)}` : ""] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? entry.prNumber, " will resume automatically once main recovers."] })] })), entry.maxRetries > 0 && (_jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "retry" }), _jsx(Text, { children: progressBar(entry.retryAttempts, entry.maxRetries, 10) }), _jsxs(Text, { dimColor: true, children: [entry.retryAttempts, "/", entry.maxRetries] })] })), _jsx(EntryStateGraph, { main: graph.main, exits: graph.exits }), _jsx(ExternalRepairObservation, { observations: observations }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Incidents" }), incidents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No incidents." })) : (incidents.map((incident) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(incident.at).padStart(4, " ") }), _jsx(Text, { color: "red", children: incident.failureClass }), _jsx(Text, { dimColor: true, children: incident.outcome })] }, incident.id))))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Events" }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet." })) : (events.slice(-16).map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEntryEvent(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
20
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status), children: humanStatus(entry.status, entry) }), _jsxs(Text, { dimColor: true, children: ["\\u00b7 ", nextStepLabel(entry.status, entry)] })] }), _jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: entry.branch }), _jsxs(Text, { dimColor: true, children: ["head ", shortSha(entry.headSha)] }), _jsxs(Text, { dimColor: true, children: ["base ", shortSha(entry.baseSha)] })] }), entry.specBranch && (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "tested as" }), _jsx(Text, { children: entry.specBranch }), _jsxs(Text, { dimColor: true, children: ["(", shortSha(entry.specSha), " \\u2190 ", entry.specBasedOn ? "PR ahead" : "main", ")"] })] })), entry.retryAttempts > 0 && (_jsxs(Text, { color: "yellow", children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? "main CI is unhealthy", "."] }), _jsxs(Text, { dimColor: true, children: ["Will resume automatically once ", queueBlock.baseBranch, " is green."] })] })), _jsx(EntryStateGraph, { main: graph.main, exits: graph.exits }), _jsx(ExternalRepairObservation, { observations: observations }), incidents.length > 0 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Incidents" }), incidents.map((incident) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(incident.at).padStart(4) }), _jsx(Text, { color: "red", children: incident.failureClass }), _jsx(Text, { dimColor: true, children: incident.outcome })] }, incident.id)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Events" }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet." })) : (events.slice(-16).map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4) }), _jsx(Text, { children: formatEntryEvent(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
22
21
  }
@@ -1,31 +1,12 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- function statusColor(status) {
4
- switch (status) {
5
- case "current":
6
- return "cyan";
7
- case "visited":
8
- return "green";
9
- case "upcoming":
10
- return "gray";
11
- }
12
- }
13
- function statusPrefix(status) {
14
- switch (status) {
15
- case "current":
16
- return "*";
17
- case "visited":
18
- return "+";
19
- case "upcoming":
20
- return " ";
21
- }
22
- }
23
- function NodePill({ node }) {
24
- return (_jsxs(Text, { color: statusColor(node.status), bold: node.status === "current", children: ["[", statusPrefix(node.status), " ", node.label, "]"] }));
25
- }
26
- function NodeRow({ label, nodes, connector = " -> ", }) {
27
- return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: label.padEnd(8, " ") }), nodes.map((node, index) => (_jsxs(Box, { children: [index > 0 && _jsx(Text, { dimColor: true, children: connector }), _jsx(NodePill, { node: node })] }, node.state)))] }));
28
- }
29
3
  export function EntryStateGraph({ main, exits }) {
30
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Entry Graph" }), _jsx(NodeRow, { label: "main", nodes: main }), _jsx(NodeRow, { label: "exits", nodes: exits, connector: " " })] }));
4
+ const visibleExits = exits.filter((n) => n.status !== "upcoming");
5
+ return (_jsxs(Box, { marginTop: 1, gap: 0, children: [main.map((node, i) => {
6
+ const dot = node.status === "upcoming" ? "\u25cb" : "\u25cf"; // ○ or ●
7
+ const color = node.status === "current" ? "cyan"
8
+ : node.status === "visited" ? "green"
9
+ : "gray";
10
+ return (_jsxs(Box, { gap: 0, children: [i > 0 && _jsx(Text, { dimColor: true, children: " \\u2192 " }), _jsx(Text, { color: color, bold: node.status === "current", children: dot }), _jsx(Text, { color: color, bold: node.status === "current", children: ` ${node.label}` })] }, node.state));
11
+ }), visibleExits.map((node) => (_jsxs(Box, { gap: 0, children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { color: "red", children: `\u25cf ${node.label}` })] }, node.state)))] }));
31
12
  }
@@ -1,11 +1,11 @@
1
1
  import type { QueueBlockState, QueueEntry, QueueEventSummary } from "../types.ts";
2
2
  interface QueueListViewProps {
3
3
  entries: QueueEntry[];
4
- recentlyCompleted: QueueEntry[];
4
+ allEntries: QueueEntry[];
5
5
  selectedEntryId: string | null;
6
6
  recentEvents: QueueEventSummary[];
7
7
  headEntryId: string | null;
8
8
  queueBlock: QueueBlockState | null;
9
9
  }
10
- export declare function QueueListView({ entries, recentlyCompleted, selectedEntryId, recentEvents, headEntryId, queueBlock, }: QueueListViewProps): React.JSX.Element;
10
+ export declare function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock, }: QueueListViewProps): React.JSX.Element;
11
11
  export {};
@@ -1,63 +1,40 @@
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 { useMemo } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
- import { ciStatusIcon, formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, specChainLabel, statusColor, summarizeQueueBlock, truncate } from "./format.js";
5
4
  import { TERMINAL_STATUSES } from "../types.js";
6
- const ENTRY_ROW_HEIGHT = 2;
5
+ import { buildChainEntries } from "./display-filter.js";
6
+ import { ciStatusIcon, formatEventSummary, humanStatus, nextStepLabel, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
7
7
  const CHROME_ROWS = 13;
8
- const RECENTLY_COMPLETED_MAX_AGE_MS = 60_000;
9
- function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, allEntries, }) {
10
- const retryText = `${entry.retryAttempts}/${entry.maxRetries}`;
11
- const ciText = entry.ciRetries > 0 ? `CI retries ${entry.ciRetries}` : null;
8
+ function QueueRow({ entry, selected, isHead, queueBlock, }) {
9
+ const isTerminal = TERMINAL_STATUSES.includes(entry.status);
10
+ if (isTerminal) {
11
+ const icon = entry.status === "merged" ? "\u2713" : "\u2717";
12
+ const iconColor = entry.status === "merged" ? "green" : "red";
13
+ return (_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsx(Text, { dimColor: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { dimColor: true, children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: iconColor, children: `${icon} ${humanStatus(entry.status)}` })] }));
14
+ }
12
15
  const blockedOnMain = isHead && queueBlock?.reason === "main_broken" && queueBlock.headPrNumber === entry.prNumber;
13
- const renderedStatus = blockedOnMain ? "blocked by broken main" : humanStatus(entry.status, entry);
14
- const renderedColor = blockedOnMain
15
- ? "red"
16
+ const status = blockedOnMain ? "main CI failing" : humanStatus(entry.status, entry);
17
+ const color = blockedOnMain ? "red"
16
18
  : entry.status === "preparing_head" && entry.lastFailedBaseSha ? "yellow"
17
19
  : statusColor(entry.status);
18
- const progress = queueProgress(entry.status);
19
- const specLabel = entry.specBranch
20
- ? specChainLabel(entry, allEntries)
21
- : truncate(entry.branch, Math.max(12, infoWidth - 34));
22
20
  const nextStep = blockedOnMain
23
21
  ? summarizeQueueBlock(queueBlock) ?? "waiting for main to recover"
24
22
  : nextStepLabel(entry.status, entry);
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] })] }));
23
+ // Only show retry counter when retries have actually happened.
24
+ const retryNote = entry.retryAttempts > 0 ? ` \u00b7 retry ${entry.retryAttempts}/${entry.maxRetries}` : "";
25
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "\u25b8" : " " }), _jsx(Text, { ...(isHead ? { color: "green" } : {}), bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt).padStart(4)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: color, children: status }), _jsx(Text, { dimColor: true, children: ` \u00b7 ${nextStep}${retryNote}` })] }));
26
26
  }
27
- export function QueueListView({ entries, recentlyCompleted, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
27
+ export function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
28
28
  const { stdout } = useStdout();
29
29
  const rows = stdout?.rows ?? 24;
30
- const cols = stdout?.columns ?? 100;
31
- const infoWidth = Math.max(32, cols - 4);
32
- const totalRows = entries.length + recentlyCompleted.length;
33
- const eventRows = Math.min(8, Math.max(4, rows - (totalRows * ENTRY_ROW_HEIGHT) - CHROME_ROWS));
30
+ // All entries are 1 row now.
31
+ const eventRows = Math.min(8, Math.max(4, rows - entries.length - CHROME_ROWS));
34
32
  const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
35
33
  const queueBlockLabel = summarizeQueueBlock(queueBlock);
36
- // Spec chain: main #A #B #C ○
37
- // Includes recently completed entries so the cascade stays visible.
38
- // Deduplicates by prNumber (not entry ID) so re-admitted PRs don't
39
- // appear twice — the active entry wins over the terminal one.
40
- const chainEntries = useMemo(() => {
41
- const seenPR = new Set();
42
- const all = [];
43
- // Active entries take priority.
44
- for (const e of entries) {
45
- if (!TERMINAL_STATUSES.includes(e.status) && !seenPR.has(e.prNumber)) {
46
- all.push(e);
47
- seenPR.add(e.prNumber);
48
- }
49
- }
50
- // Recently completed fill in — only if no active entry for that PR.
51
- for (const e of recentlyCompleted) {
52
- if (!seenPR.has(e.prNumber)) {
53
- all.push(e);
54
- seenPR.add(e.prNumber);
55
- }
56
- }
57
- return all.sort((a, b) => a.position - b.position);
58
- }, [entries, recentlyCompleted]);
34
+ // Chain header always shows the live queue, regardless of display filter.
35
+ const chainEntries = useMemo(() => buildChainEntries(allEntries), [allEntries]);
59
36
  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) => {
60
37
  const ci = ciStatusIcon(entry);
61
38
  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));
62
- })] })), 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}`))))] })] }));
39
+ })] })), 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, isHead: entry.id === headEntryId, queueBlock: queueBlock }, 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}`))))] })] }));
63
40
  }
@@ -0,0 +1,14 @@
1
+ import type { QueueEntry } from "../types.ts";
2
+ /**
3
+ * Build the display row list for the queue watch. In "active" mode,
4
+ * includes active entries plus recently-terminal entries (within 60s),
5
+ * deduplicated by prNumber (active wins over terminal for re-admissions).
6
+ * In "all" mode, returns all entries as-is.
7
+ */
8
+ export declare function buildDisplayEntries(entries: QueueEntry[], filter: "active" | "all"): QueueEntry[];
9
+ /**
10
+ * Build the spec chain header entries. Always shows the current live
11
+ * chain: active entries plus recently-merged entries (for cascade
12
+ * visibility), regardless of the display filter. One entry per PR.
13
+ */
14
+ export declare function buildChainEntries(entries: QueueEntry[]): QueueEntry[];
@@ -0,0 +1,45 @@
1
+ import { TERMINAL_STATUSES } from "../types.js";
2
+ const RECENTLY_COMPLETED_MS = 60_000;
3
+ /**
4
+ * Build the display row list for the queue watch. In "active" mode,
5
+ * includes active entries plus recently-terminal entries (within 60s),
6
+ * deduplicated by prNumber (active wins over terminal for re-admissions).
7
+ * In "all" mode, returns all entries as-is.
8
+ */
9
+ export function buildDisplayEntries(entries, filter) {
10
+ if (filter !== "active")
11
+ return entries;
12
+ const cutoff = Date.now() - RECENTLY_COMPLETED_MS;
13
+ const byPR = new Map();
14
+ for (const e of entries) {
15
+ const isActive = !TERMINAL_STATUSES.includes(e.status);
16
+ const isRecent = !isActive && new Date(e.updatedAt).getTime() > cutoff;
17
+ if (!isActive && !isRecent)
18
+ continue;
19
+ const existing = byPR.get(e.prNumber);
20
+ if (!existing || (isActive && TERMINAL_STATUSES.includes(existing.status))) {
21
+ byPR.set(e.prNumber, e);
22
+ }
23
+ }
24
+ return [...byPR.values()].sort((a, b) => a.position - b.position);
25
+ }
26
+ /**
27
+ * Build the spec chain header entries. Always shows the current live
28
+ * chain: active entries plus recently-merged entries (for cascade
29
+ * visibility), regardless of the display filter. One entry per PR.
30
+ */
31
+ export function buildChainEntries(entries) {
32
+ const cutoff = Date.now() - RECENTLY_COMPLETED_MS;
33
+ const byPR = new Map();
34
+ for (const e of entries) {
35
+ const isActive = !TERMINAL_STATUSES.includes(e.status);
36
+ const isRecent = !isActive && new Date(e.updatedAt).getTime() > cutoff;
37
+ if (!isActive && !isRecent)
38
+ continue;
39
+ const existing = byPR.get(e.prNumber);
40
+ if (!existing || (isActive && TERMINAL_STATUSES.includes(existing.status))) {
41
+ byPR.set(e.prNumber, e);
42
+ }
43
+ }
44
+ return [...byPR.values()].sort((a, b) => a.position - b.position);
45
+ }
@@ -41,21 +41,21 @@ export function statusColor(status) {
41
41
  export function humanStatus(status, entry) {
42
42
  switch (status) {
43
43
  case "queued":
44
- return "queued";
44
+ return "waiting in queue";
45
45
  case "preparing_head":
46
46
  if (entry?.lastFailedBaseSha)
47
- return "retry-gated";
48
- return "building spec";
47
+ return "has conflicts";
48
+ return "preparing";
49
49
  case "validating":
50
- return "running CI";
50
+ return "testing";
51
51
  case "merging":
52
- return "merging to main";
52
+ return "merging";
53
53
  case "merged":
54
54
  return "merged";
55
55
  case "evicted":
56
- return "removed from queue";
56
+ return "needs repair";
57
57
  case "dequeued":
58
- return "dequeued";
58
+ return "removed";
59
59
  }
60
60
  }
61
61
  export function queueProgress(status) {
@@ -76,21 +76,23 @@ export function queueProgress(status) {
76
76
  export function nextStepLabel(status, entry) {
77
77
  switch (status) {
78
78
  case "queued":
79
- return "waiting for head-of-line turn";
79
+ return "starting shortly";
80
80
  case "preparing_head":
81
81
  if (entry?.lastFailedBaseSha)
82
- return "waiting for base to advance";
83
- return "building cumulative spec branch";
82
+ return "conflicts with main, will retry when queue advances";
83
+ return "building test branch with PRs ahead";
84
84
  case "validating":
85
- return "waiting for CI on spec branch";
85
+ return entry?.specBasedOn
86
+ ? "CI running, tested together with PRs ahead"
87
+ : "CI running on combined changes";
86
88
  case "merging":
87
- return "pushing spec to main";
89
+ return "landing on main";
88
90
  case "merged":
89
91
  return "landed on main";
90
92
  case "evicted":
91
- return "needs external repair";
93
+ return "needs branch repair before re-admission";
92
94
  case "dequeued":
93
- return "removed manually";
95
+ return "removed from queue";
94
96
  }
95
97
  }
96
98
  /** Describe the spec chain for a queue entry. */
@@ -114,12 +116,28 @@ export function runtimeLabel(runtime) {
114
116
  }
115
117
  return runtime.lastTickOutcome;
116
118
  }
119
+ const STATUS_DISPLAY = {
120
+ queued: "queued",
121
+ preparing_head: "preparing",
122
+ validating: "testing",
123
+ merging: "merging",
124
+ merged: "merged",
125
+ evicted: "evicted",
126
+ dequeued: "removed",
127
+ };
128
+ function displayStatus(status) {
129
+ return STATUS_DISPLAY[status] ?? status;
130
+ }
117
131
  export function formatEventSummary(event) {
118
- const transition = event.fromStatus ? `${event.fromStatus} -> ${event.toStatus}` : `entered ${event.toStatus}`;
132
+ const from = event.fromStatus ? displayStatus(event.fromStatus) : null;
133
+ const to = displayStatus(event.toStatus);
134
+ const transition = from ? `${from} \u2192 ${to}` : to;
119
135
  return `#${event.prNumber} ${transition}${event.detail ? ` (${event.detail})` : ""}`;
120
136
  }
121
137
  export function formatEntryEvent(event) {
122
- const transition = event.fromStatus ? `${event.fromStatus} -> ${event.toStatus}` : `entered ${event.toStatus}`;
138
+ const from = event.fromStatus ? displayStatus(event.fromStatus) : null;
139
+ const to = displayStatus(event.toStatus);
140
+ const transition = from ? `${from} \u2192 ${to}` : to;
123
141
  return `${transition}${event.detail ? ` (${event.detail})` : ""}`;
124
142
  }
125
143
  export function formatDuration(ms) {
@@ -2,12 +2,12 @@ const MAIN_STATES = ["queued", "preparing_head", "validating", "merging", "merge
2
2
  const EXIT_STATES = ["evicted", "dequeued"];
3
3
  const STATE_LABELS = {
4
4
  queued: "queued",
5
- preparing_head: "preparing_head",
6
- validating: "validating",
5
+ preparing_head: "preparing",
6
+ validating: "testing",
7
7
  merging: "merging",
8
8
  merged: "merged",
9
9
  evicted: "evicted",
10
- dequeued: "dequeued",
10
+ dequeued: "removed",
11
11
  };
12
12
  function labelForState(state) {
13
13
  return STATE_LABELS[state] ?? state;
@@ -43,85 +43,61 @@ export function buildEntryStateGraph(detail) {
43
43
  export function buildExternalRepairObservations(detail, options) {
44
44
  const { entry, incidents } = detail;
45
45
  const observations = [];
46
+ // What is this entry doing right now?
46
47
  if (entry.status === "merged") {
47
- observations.push({
48
- tone: "success",
49
- text: "Merged by steward; no further queue action is required for this entry.",
50
- });
48
+ observations.push({ tone: "success", text: "Landed on main." });
51
49
  }
52
50
  else if (entry.status === "dequeued") {
53
- observations.push({
54
- tone: "info",
55
- text: "Removed from the queue without merge; steward will not advance this entry further.",
56
- });
51
+ observations.push({ tone: "info", text: "Removed from queue." });
57
52
  }
58
53
  else if (entry.status === "evicted") {
59
- observations.push({
60
- tone: "warn",
61
- text: "Evicted after steward retries; external branch repair is expected before any later re-admission.",
62
- });
54
+ observations.push({ tone: "warn", text: "Removed after failed retries. Branch needs repair before re-admission." });
63
55
  }
64
56
  else if (options.isHead && options.queueBlock?.reason === "main_broken") {
65
57
  const failingNames = options.queueBlock.failingChecks.map((check) => check.name);
66
58
  observations.push({
67
59
  tone: "warn",
68
- text: `Head-of-line entry is paused because ${options.queueBlock.baseBranch} is unhealthy${failingNames.length > 0 ? ` (${failingNames.join(", ")})` : ""}.`,
60
+ text: `Queue paused: ${options.queueBlock.baseBranch} CI is failing${failingNames.length > 0 ? ` (${failingNames.join(", ")})` : ""}. Will resume when main is green.`,
69
61
  });
70
62
  }
71
63
  else if (options.isHead) {
72
- observations.push({
73
- tone: "info",
74
- text: "Head-of-line entry; steward can advance this PR on the next reconcile tick.",
75
- });
64
+ observations.push({ tone: "info", text: "First in queue. Will advance on the next tick." });
76
65
  }
77
66
  else if (options.activeIndex !== null && options.headPrNumber !== null) {
78
67
  observations.push({
79
68
  tone: "info",
80
- text: `Waiting behind current head #${options.headPrNumber} as active entry ${options.activeIndex} of ${options.activeCount}.`,
69
+ text: `Position ${options.activeIndex} of ${options.activeCount}. Being tested together with PRs ahead.`,
81
70
  });
82
71
  }
83
72
  else {
84
- observations.push({
85
- tone: "info",
86
- text: "Queued for serial processing by the steward.",
87
- });
73
+ observations.push({ tone: "info", text: "Waiting in queue." });
88
74
  }
75
+ // What went wrong last time?
89
76
  const latestIncident = incidents[incidents.length - 1];
90
77
  if (latestIncident) {
91
78
  observations.push({
92
79
  tone: latestIncident.outcome === "open" ? "warn" : "info",
93
- text: `Latest failure class: ${latestIncident.failureClass} (${latestIncident.outcome}).`,
80
+ text: `Last failure: ${latestIncident.failureClass} (${latestIncident.outcome}).`,
94
81
  });
95
82
  }
83
+ // What is blocking progress?
96
84
  if (entry.lastFailedBaseSha) {
97
85
  observations.push({
98
86
  tone: "warn",
99
- text: `Retry-gated: conflict on base ${entry.lastFailedBaseSha.slice(0, 7)}. Waiting for base to advance before rebuilding spec.`,
87
+ text: "Conflicts with main. Will retry automatically when another PR merges and main advances.",
100
88
  });
101
89
  }
102
90
  else if (entry.status === "validating") {
103
- const specNote = entry.specBranch
104
- ? `CI running on spec branch ${entry.specBranch}.`
105
- : "Waiting on CI for the spec branch.";
106
91
  const cascadeNote = entry.specBasedOn
107
- ? " Will merge automatically when head clears (cascade)."
92
+ ? " Tests pass → merges automatically when PRs ahead finish."
108
93
  : "";
109
94
  observations.push({
110
95
  tone: "info",
111
- text: `${specNote}${cascadeNote}`,
96
+ text: `CI running on combined changes.${cascadeNote}`,
112
97
  });
113
98
  }
114
99
  else if (entry.status === "merging") {
115
- observations.push({
116
- tone: "info",
117
- text: "CI passed; pushing spec branch to main (fast-forward).",
118
- });
119
- }
120
- if (entry.generation > 0) {
121
- observations.push({
122
- tone: "info",
123
- text: `Observed ${entry.generation} branch head update${entry.generation === 1 ? "" : "s"} since first admission.`,
124
- });
100
+ observations.push({ tone: "info", text: "CI passed. Landing on main." });
125
101
  }
126
102
  return observations.slice(0, 4);
127
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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": {