merge-steward 0.4.1 → 0.5.1

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.
@@ -29,6 +29,7 @@ async function readQueueSnapshot(config, eventLimit) {
29
29
  lastTickOutcome: "idle",
30
30
  lastTickError: null,
31
31
  },
32
+ queueBlock: null,
32
33
  entries,
33
34
  recentEvents: store.listRecentEvents(config.repoId, { limit: eventLimit }),
34
35
  },
@@ -94,6 +95,15 @@ export async function handleQueue(parsed, stdout) {
94
95
  `Queued: ${snapshot.summary.queued} preparing: ${snapshot.summary.preparingHead} validating: ${snapshot.summary.validating} merging: ${snapshot.summary.merging}`,
95
96
  `Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued}`,
96
97
  snapshot.summary.headPrNumber ? `Head PR: #${snapshot.summary.headPrNumber}` : "Head PR: none",
98
+ ...(snapshot.queueBlock
99
+ ? [
100
+ `Queue blocked: ${snapshot.queueBlock.reason} on ${snapshot.queueBlock.baseBranch}${snapshot.queueBlock.baseSha ? ` @ ${snapshot.queueBlock.baseSha.slice(0, 8)}` : ""}`,
101
+ `Base failures: ${snapshot.queueBlock.failingChecks.length > 0 ? snapshot.queueBlock.failingChecks.map((check) => check.name).join(", ") : "(none)"}`,
102
+ ...(snapshot.queueBlock.pendingChecks.length > 0
103
+ ? [`Base pending: ${snapshot.queueBlock.pendingChecks.map((check) => check.name).join(", ")}`]
104
+ : []),
105
+ ]
106
+ : []),
97
107
  "",
98
108
  "Entries:",
99
109
  ...(snapshot.entries.length > 0
@@ -53,10 +53,9 @@ export class GitHubPRClient {
53
53
  try {
54
54
  const checks = JSON.parse(result.stdout);
55
55
  return checks
56
- .filter((c) => c.conclusion !== null)
57
56
  .map((c) => ({
58
57
  name: c.name,
59
- conclusion: mapRestConclusion(c.conclusion),
58
+ conclusion: mapRestConclusion(c.status, c.conclusion),
60
59
  ...(c.html_url ? { url: c.html_url } : {}),
61
60
  }));
62
61
  }
@@ -100,8 +99,10 @@ export class GitHubPRClient {
100
99
  }
101
100
  }
102
101
  }
103
- /** Map GitHub REST API check-run conclusion (lowercase) to our union. */
104
- function mapRestConclusion(conclusion) {
102
+ /** Map GitHub REST API check-run status/conclusion to our union. */
103
+ function mapRestConclusion(status, conclusion) {
104
+ if (status !== "completed" || conclusion === null)
105
+ return "pending";
105
106
  switch (conclusion) {
106
107
  case "success":
107
108
  case "neutral":
@@ -75,11 +75,26 @@ export async function reconcile(ctx) {
75
75
  async function prepareHead(ctx, entry) {
76
76
  emit(ctx, entry, "fetch_started");
77
77
  await ctx.git.fetch();
78
+ const baseSha = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
78
79
  // Gate: main CI must be green.
79
80
  if (ctx.ci.getMainStatus) {
80
81
  const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
81
82
  if (mainStatus === "fail") {
82
- emit(ctx, entry, "main_broken");
83
+ let mainChecks = [];
84
+ try {
85
+ mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
86
+ }
87
+ catch {
88
+ mainChecks = [];
89
+ }
90
+ const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
91
+ const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
92
+ emit(ctx, entry, "main_broken", {
93
+ baseSha,
94
+ failingChecks,
95
+ pendingChecks,
96
+ detail: describeMainBroken(failingChecks, pendingChecks),
97
+ });
83
98
  return;
84
99
  }
85
100
  }
@@ -90,7 +105,6 @@ async function prepareHead(ctx, entry) {
90
105
  ctx.store.updateHead(entry.id, currentRef);
91
106
  return;
92
107
  }
93
- const baseSha = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
94
108
  // Gate: budget exhausted after previous conflict.
95
109
  if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
96
110
  emit(ctx, entry, "budget_exhausted", { baseSha });
@@ -104,6 +118,23 @@ async function prepareHead(ctx, entry) {
104
118
  }
105
119
  await performRebase(ctx, entry, baseSha);
106
120
  }
121
+ function describeMainBroken(failingChecks, pendingChecks) {
122
+ const parts = [];
123
+ if (failingChecks.length > 0) {
124
+ parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
125
+ }
126
+ if (pendingChecks.length > 0) {
127
+ parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
128
+ }
129
+ return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
130
+ }
131
+ function summarizeCheckNames(checks, limit = 3) {
132
+ const names = [...new Set(checks.map((check) => check.name))];
133
+ if (names.length <= limit) {
134
+ return names.join(", ");
135
+ }
136
+ return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
137
+ }
107
138
  async function performRebase(ctx, entry, baseSha) {
108
139
  emit(ctx, entry, "rebase_started", { baseSha });
109
140
  const result = await ctx.git.rebase(entry.branch, ref(ctx, ctx.baseBranch));
package/dist/service.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare class MergeStewardService {
23
23
  private lastTickCompletedAt;
24
24
  private lastTickOutcome;
25
25
  private lastTickError;
26
- private mainBrokenReported;
26
+ private currentQueueBlock;
27
27
  constructor(config: StewardConfig, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: import("./interfaces.ts").SpeculativeBranchBuilder | null, logger: Logger);
28
28
  /** Expose the GitHub client for webhook handler branch→PR lookups. */
29
29
  get githubApi(): GitHubPRApi;
@@ -69,5 +69,6 @@ export declare class MergeStewardService {
69
69
  acknowledgeExternalMerge(prNumber: number): void;
70
70
  private runTick;
71
71
  private scheduleNextTick;
72
+ private describeMainBroken;
72
73
  }
73
74
  export declare function buildSummary(entries: QueueEntry[]): QueueStatusSummary;
package/dist/service.js CHANGED
@@ -21,7 +21,7 @@ export class MergeStewardService {
21
21
  lastTickCompletedAt = null;
22
22
  lastTickOutcome = "idle";
23
23
  lastTickError = null;
24
- mainBrokenReported = false;
24
+ currentQueueBlock = null;
25
25
  constructor(config, store, git, ci, github, eviction, specBuilder, logger) {
26
26
  this.config = config;
27
27
  this.store = store;
@@ -127,6 +127,7 @@ export class MergeStewardService {
127
127
  baseBranch: this.config.baseBranch,
128
128
  summary: buildSummary(entries),
129
129
  runtime: this.getRuntimeStatus(),
130
+ queueBlock: this.currentQueueBlock,
130
131
  entries,
131
132
  recentEvents: this.store.listRecentEvents(this.config.repoId, { limit: options?.eventLimit ?? 40 }),
132
133
  };
@@ -241,6 +242,7 @@ export class MergeStewardService {
241
242
  this.lastTickOutcome = "running";
242
243
  this.lastTickError = null;
243
244
  try {
245
+ let tickQueueBlockEvent = null;
244
246
  await reconcile({
245
247
  store: this.store,
246
248
  repoId: this.config.repoId,
@@ -261,12 +263,12 @@ export class MergeStewardService {
261
263
  || event.action === "fetch_started";
262
264
  const level = isWarn ? "warn" : isDebug ? "debug" : "info";
263
265
  this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
264
- if (event.action === "main_broken" && !this.mainBrokenReported) {
265
- this.mainBrokenReported = true;
266
+ if (event.action === "main_broken" && tickQueueBlockEvent === null) {
267
+ tickQueueBlockEvent = event;
266
268
  }
267
269
  },
268
270
  });
269
- this.mainBrokenReported = false; // Reset after successful tick.
271
+ this.currentQueueBlock = tickQueueBlockEvent ? await this.describeMainBroken(tickQueueBlockEvent) : null;
270
272
  this.lastTickOutcome = "succeeded";
271
273
  }
272
274
  catch (error) {
@@ -288,6 +290,39 @@ export class MergeStewardService {
288
290
  this.tickTimer = setTimeout(() => void this.runTick(), this.config.pollIntervalMs);
289
291
  this.tickTimer.unref?.();
290
292
  }
293
+ async describeMainBroken(event) {
294
+ const baseRef = `origin/${this.config.baseBranch}`;
295
+ let baseSha = null;
296
+ try {
297
+ baseSha = await this.git.headSha(baseRef);
298
+ }
299
+ catch {
300
+ try {
301
+ baseSha = await this.git.headSha(this.config.baseBranch);
302
+ }
303
+ catch {
304
+ baseSha = event.baseSha ?? null;
305
+ }
306
+ }
307
+ const checkRef = event.baseSha ?? baseSha ?? baseRef;
308
+ let checks = [];
309
+ try {
310
+ checks = await this.github.listChecksForRef(checkRef);
311
+ }
312
+ catch {
313
+ checks = [];
314
+ }
315
+ return {
316
+ reason: "main_broken",
317
+ entryId: event.entryId,
318
+ headPrNumber: event.prNumber,
319
+ baseBranch: this.config.baseBranch,
320
+ baseSha,
321
+ observedAt: event.at,
322
+ failingChecks: event.failingChecks ?? checks.filter((check) => check.conclusion === "failure"),
323
+ pendingChecks: event.pendingChecks ?? checks.filter((check) => check.conclusion === "pending"),
324
+ };
325
+ }
291
326
  }
292
327
  /** Simple glob match: supports * as wildcard. */
293
328
  function matchGlob(pattern, value) {
package/dist/types.d.ts CHANGED
@@ -117,12 +117,23 @@ export interface QueueStatusSummary {
117
117
  headEntryId: string | null;
118
118
  headPrNumber: number | null;
119
119
  }
120
+ export interface QueueBlockState {
121
+ reason: "main_broken";
122
+ entryId: string;
123
+ headPrNumber: number | null;
124
+ baseBranch: string;
125
+ baseSha: string | null;
126
+ observedAt: string;
127
+ failingChecks: CheckResult[];
128
+ pendingChecks: CheckResult[];
129
+ }
120
130
  export interface QueueWatchSnapshot {
121
131
  repoId: string;
122
132
  repoFullName: string;
123
133
  baseBranch: string;
124
134
  summary: QueueStatusSummary;
125
135
  runtime: QueueRuntimeStatus;
136
+ queueBlock: QueueBlockState | null;
126
137
  entries: QueueEntry[];
127
138
  recentEvents: QueueEventSummary[];
128
139
  }
@@ -169,6 +180,8 @@ export interface ReconcileEvent {
169
180
  baseSha?: string | undefined;
170
181
  ciRunId?: string | undefined;
171
182
  conflictFiles?: string[] | undefined;
183
+ failingChecks?: CheckResult[] | undefined;
184
+ pendingChecks?: CheckResult[] | undefined;
172
185
  failureClass?: string | undefined;
173
186
  dependsOn?: string | undefined;
174
187
  }
package/dist/watch/App.js CHANGED
@@ -188,5 +188,5 @@ export function App({ baseUrl, initialPrNumber }) {
188
188
  setSelectedEntryId(nextSelection(visibleEntries, selectedEntryId, "prev"));
189
189
  }
190
190
  });
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 })) : (_jsx(DetailView, { detail: detail, isHead: isHeadSelected, activeIndex: selectedActiveIndex, activeCount: activeEntries.length, headPrNumber: snapshot?.summary.headPrNumber ?? null })), flashMessage && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })), _jsx(HelpBar, { view: view })] }));
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 })] }));
192
192
  }
@@ -1,10 +1,11 @@
1
- import type { QueueEntryDetail } from "../types.ts";
1
+ import type { QueueBlockState, QueueEntryDetail } from "../types.ts";
2
2
  interface DetailViewProps {
3
3
  detail: QueueEntryDetail | null;
4
4
  isHead: boolean;
5
5
  activeIndex: number | null;
6
6
  activeCount: number;
7
7
  headPrNumber: number | null;
8
+ queueBlock: QueueBlockState | null;
8
9
  }
9
- export declare function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, }: DetailViewProps): React.JSX.Element;
10
+ export declare function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, queueBlock, }: DetailViewProps): React.JSX.Element;
10
11
  export {};
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
- import { formatEntryEvent, progressBar, relativeTime, shortSha, statusColor } from "./format.js";
3
+ import { formatEntryEvent, progressBar, 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";
7
- export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, }) {
7
+ export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNumber, queueBlock, }) {
8
8
  if (!detail) {
9
9
  return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Loading entry detail..." }) }));
10
10
  }
@@ -15,6 +15,7 @@ export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNum
15
15
  activeIndex,
16
16
  activeCount,
17
17
  headPrNumber,
18
+ queueBlock,
18
19
  });
19
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), _jsx(Text, { color: statusColor(entry.status), children: entry.status }), _jsxs(Text, { dimColor: true, children: ["pos ", entry.position] }), _jsxs(Text, { dimColor: true, children: ["generation ", entry.generation] }), _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.issueKey && _jsx(Text, { dimColor: true, children: entry.issueKey })] }), 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] }), _jsx(Text, { color: statusColor(entry.status), children: entry.status }), _jsxs(Text, { dimColor: true, children: ["pos ", entry.position] }), _jsxs(Text, { dimColor: true, children: ["generation ", entry.generation] }), _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.issueKey && _jsx(Text, { dimColor: true, children: entry.issueKey })] }), 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
21
  }
@@ -1,9 +1,10 @@
1
- import type { QueueEntry, QueueEventSummary } from "../types.ts";
1
+ import type { QueueBlockState, QueueEntry, QueueEventSummary } from "../types.ts";
2
2
  interface QueueListViewProps {
3
3
  entries: QueueEntry[];
4
4
  selectedEntryId: string | null;
5
5
  recentEvents: QueueEventSummary[];
6
6
  headEntryId: string | null;
7
+ queueBlock: QueueBlockState | null;
7
8
  }
8
- export declare function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, }: QueueListViewProps): React.JSX.Element;
9
+ export declare function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, queueBlock, }: QueueListViewProps): React.JSX.Element;
9
10
  export {};
@@ -1,19 +1,23 @@
1
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 { formatEventSummary, relativeTime, statusColor, truncate } from "./format.js";
4
+ import { formatEventSummary, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
5
5
  const CHROME_ROWS = 13;
6
- function QueueRow({ entry, selected, branchWidth, isHead, }) {
6
+ function QueueRow({ entry, selected, branchWidth, isHead, queueBlock, }) {
7
7
  const retryText = `${entry.retryAttempts}/${entry.maxRetries}`;
8
8
  const ciText = entry.ciRetries > 0 ? ` ci:${entry.ciRetries}` : "";
9
- return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "›" : " " }), _jsx(Text, { color: isHead ? "green" : "gray", children: isHead ? "*" : " " }), _jsxs(Text, { children: [" ", String(entry.position).padStart(2, " "), " "] }), _jsxs(Text, { bold: true, children: ["#", String(entry.prNumber).padStart(4, " ")] }), _jsx(Text, { children: " " }), _jsx(Text, { color: statusColor(entry.status), children: entry.status.padEnd(14, " ") }), _jsx(Text, { children: " " }), _jsx(Text, { children: retryText.padEnd(4, " ") }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: relativeTime(entry.updatedAt).padStart(4, " ") }), _jsx(Text, { children: " " }), _jsx(Text, { children: truncate(entry.branch, branchWidth) }), _jsx(Text, { dimColor: true, children: ciText })] }));
9
+ const blockedOnMain = isHead && queueBlock?.reason === "main_broken" && queueBlock.headPrNumber === entry.prNumber;
10
+ const renderedStatus = blockedOnMain ? "blocked/main" : entry.status;
11
+ const renderedColor = blockedOnMain ? "red" : statusColor(entry.status);
12
+ return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "›" : " " }), _jsx(Text, { color: blockedOnMain ? "red" : isHead ? "green" : "gray", children: blockedOnMain ? "!" : isHead ? "*" : " " }), _jsxs(Text, { children: [" ", String(entry.position).padStart(2, " "), " "] }), _jsxs(Text, { bold: true, children: ["#", String(entry.prNumber).padStart(4, " ")] }), _jsx(Text, { children: " " }), _jsx(Text, { color: renderedColor, children: renderedStatus.padEnd(14, " ") }), _jsx(Text, { children: " " }), _jsx(Text, { children: retryText.padEnd(4, " ") }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: relativeTime(entry.updatedAt).padStart(4, " ") }), _jsx(Text, { children: " " }), _jsx(Text, { children: truncate(entry.branch, branchWidth) }), _jsx(Text, { dimColor: true, children: ciText })] }));
10
13
  }
11
- export function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, }) {
14
+ export function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
12
15
  const { stdout } = useStdout();
13
16
  const rows = stdout?.rows ?? 24;
14
17
  const cols = stdout?.columns ?? 100;
15
18
  const branchWidth = Math.max(12, cols - 36);
16
19
  const eventRows = Math.min(8, Math.max(4, rows - entries.length - CHROME_ROWS));
17
20
  const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
18
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " s h pos pr status rt ago branch" }), 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, branchWidth: branchWidth, isHead: entry.id === headEntryId }, 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}`))))] })] }));
21
+ const queueBlockLabel = summarizeQueueBlock(queueBlock);
22
+ 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."] })] })), _jsx(Text, { dimColor: true, children: " s h pos pr status rt ago branch" }), 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, branchWidth: branchWidth, 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}`))))] })] }));
19
23
  }
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useStdout } from "ink";
3
- import { formatDuration, relativeTime, runtimeLabel, truncate } from "./format.js";
3
+ import { formatDuration, relativeTime, runtimeLabel, summarizeQueueBlock, truncate } from "./format.js";
4
4
  import { FreshnessBadge } from "./FreshnessBadge.js";
5
5
  const TERMINAL = new Set(["merged", "evicted", "dequeued"]);
6
6
  export function StatusBar({ snapshot, connected, filter, lastSnapshotReceivedAt, expectedFreshMs, }) {
@@ -12,6 +12,7 @@ export function StatusBar({ snapshot, connected, filter, lastSnapshotReceivedAt,
12
12
  return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: truncate(left, availableLeft) }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: expectedFreshMs })] }));
13
13
  }
14
14
  const { summary, runtime } = snapshot;
15
+ const queueBlockLabel = summarizeQueueBlock(snapshot.queueBlock);
15
16
  const activeEntries = snapshot.entries.filter((e) => !TERMINAL.has(e.status));
16
17
  const avgWaitMs = activeEntries.length > 0
17
18
  ? activeEntries.reduce((sum, e) => sum + (Date.now() - new Date(e.enqueuedAt).getTime()), 0) / activeEntries.length
@@ -21,6 +22,7 @@ export function StatusBar({ snapshot, connected, filter, lastSnapshotReceivedAt,
21
22
  `base:${snapshot.baseBranch}`,
22
23
  `${summary.total} entries ${summary.active} active`,
23
24
  summary.headPrNumber !== null ? `head #${summary.headPrNumber}` : null,
25
+ queueBlockLabel ? `blocked ${queueBlockLabel}` : null,
24
26
  avgWaitMs > 0 ? `wait ~${formatDuration(avgWaitMs)}` : null,
25
27
  `tick ${runtimeLabel(runtime)} ${relativeTime(runtime.lastTickCompletedAt ?? runtime.lastTickStartedAt)}`,
26
28
  filter,
@@ -1,4 +1,4 @@
1
- import type { QueueEntryStatus, QueueEventRecord, QueueEventSummary, QueueRuntimeStatus } from "../types.ts";
1
+ import type { CheckResult, QueueBlockState, QueueEntryStatus, QueueEventRecord, QueueEventSummary, QueueRuntimeStatus } from "../types.ts";
2
2
  export declare function shortSha(value: string | null | undefined): string;
3
3
  export declare function relativeTime(iso: string | null | undefined): string;
4
4
  export declare function statusColor(status: QueueEntryStatus): "yellow" | "cyan" | "green" | "red" | "gray";
@@ -8,3 +8,5 @@ export declare function formatEntryEvent(event: QueueEventRecord): string;
8
8
  export declare function formatDuration(ms: number): string;
9
9
  export declare function progressBar(current: number, total: number, width: number): string;
10
10
  export declare function truncate(value: string, width: number): string;
11
+ export declare function summarizeCheckNames(checks: CheckResult[], limit?: number): string;
12
+ export declare function summarizeQueueBlock(block: QueueBlockState | null | undefined): string | null;
@@ -80,3 +80,19 @@ export function truncate(value, width) {
80
80
  return value.slice(0, width);
81
81
  return `${value.slice(0, width - 1)}…`;
82
82
  }
83
+ export function summarizeCheckNames(checks, limit = 3) {
84
+ const names = [...new Set(checks.map((check) => check.name))];
85
+ if (names.length === 0)
86
+ return "unknown checks";
87
+ if (names.length <= limit)
88
+ return names.join(", ");
89
+ return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
90
+ }
91
+ export function summarizeQueueBlock(block) {
92
+ if (!block)
93
+ return null;
94
+ const failing = block.failingChecks.length > 0 ? `failing ${summarizeCheckNames(block.failingChecks)}` : null;
95
+ const pending = block.pendingChecks.length > 0 ? `pending ${summarizeCheckNames(block.pendingChecks)}` : null;
96
+ const suffix = [failing, pending].filter(Boolean).join("; ");
97
+ return suffix ? `main broken: ${suffix}` : "main broken";
98
+ }
@@ -1,4 +1,4 @@
1
- import type { QueueEntryDetail } from "../types.ts";
1
+ import type { QueueBlockState, QueueEntryDetail } from "../types.ts";
2
2
  export type VisualizationNodeStatus = "current" | "visited" | "upcoming";
3
3
  export interface VisualizationNode {
4
4
  state: string;
@@ -18,4 +18,5 @@ export declare function buildExternalRepairObservations(detail: QueueEntryDetail
18
18
  activeIndex: number | null;
19
19
  activeCount: number;
20
20
  headPrNumber: number | null;
21
+ queueBlock: QueueBlockState | null;
21
22
  }): ObservationLine[];
@@ -61,6 +61,13 @@ export function buildExternalRepairObservations(detail, options) {
61
61
  text: "Evicted after steward retries; external branch repair is expected before any later re-admission.",
62
62
  });
63
63
  }
64
+ else if (options.isHead && options.queueBlock?.reason === "main_broken") {
65
+ const failingNames = options.queueBlock.failingChecks.map((check) => check.name);
66
+ observations.push({
67
+ tone: "warn",
68
+ text: `Head-of-line entry is paused because ${options.queueBlock.baseBranch} is unhealthy${failingNames.length > 0 ? ` (${failingNames.join(", ")})` : ""}.`,
69
+ });
70
+ }
64
71
  else if (options.isHead) {
65
72
  observations.push({
66
73
  tone: "info",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
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": {