merge-steward 0.6.0 → 0.7.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.
@@ -18,5 +18,5 @@ export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNum
18
18
  queueBlock,
19
19
  });
20
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) }), _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 })] }), _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) })] }), 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}`))))] })] }));
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}`))))] })] }));
22
22
  }
@@ -1,21 +1,26 @@
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, statusColor, summarizeQueueBlock, truncate } from "./format.js";
4
+ import { formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, specChainLabel, statusColor, summarizeQueueBlock, truncate } from "./format.js";
5
5
  const ENTRY_ROW_HEIGHT = 2;
6
6
  const CHROME_ROWS = 13;
7
- function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, }) {
7
+ function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, allEntries, }) {
8
8
  const retryText = `${entry.retryAttempts}/${entry.maxRetries}`;
9
9
  const ciText = entry.ciRetries > 0 ? `CI retries ${entry.ciRetries}` : null;
10
10
  const blockedOnMain = isHead && queueBlock?.reason === "main_broken" && queueBlock.headPrNumber === entry.prNumber;
11
- const renderedStatus = blockedOnMain ? "blocked by broken main" : humanStatus(entry.status);
12
- const renderedColor = blockedOnMain ? "red" : statusColor(entry.status);
11
+ const renderedStatus = blockedOnMain ? "blocked by broken main" : humanStatus(entry.status, entry);
12
+ const renderedColor = blockedOnMain
13
+ ? "red"
14
+ : entry.status === "preparing_head" && entry.lastFailedBaseSha ? "yellow"
15
+ : statusColor(entry.status);
13
16
  const progress = queueProgress(entry.status);
14
- const branchLabel = truncate(entry.branch, Math.max(12, infoWidth - 34));
17
+ const specLabel = entry.specBranch
18
+ ? specChainLabel(entry, allEntries)
19
+ : truncate(entry.branch, Math.max(12, infoWidth - 34));
15
20
  const nextStep = blockedOnMain
16
21
  ? summarizeQueueBlock(queueBlock) ?? "waiting for main to recover"
17
- : nextStepLabel(entry.status);
18
- 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: branchLabel }), _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] })] }));
22
+ : nextStepLabel(entry.status, entry);
23
+ 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] })] }));
19
24
  }
20
25
  export function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
21
26
  const { stdout } = useStdout();
@@ -25,5 +30,5 @@ export function QueueListView({ entries, selectedEntryId, recentEvents, headEntr
25
30
  const eventRows = Math.min(8, Math.max(4, rows - (entries.length * ENTRY_ROW_HEIGHT) - CHROME_ROWS));
26
31
  const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
27
32
  const queueBlockLabel = summarizeQueueBlock(queueBlock);
28
- 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 }, 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}`))))] })] }));
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}`))))] })] }));
29
34
  }
@@ -2,12 +2,28 @@ import type { CheckResult, QueueBlockState, QueueEntryStatus, QueueEventRecord,
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";
5
- export declare function humanStatus(status: QueueEntryStatus): string;
5
+ export declare function humanStatus(status: QueueEntryStatus, entry?: {
6
+ lastFailedBaseSha: string | null;
7
+ specBranch: string | null;
8
+ }): string;
6
9
  export declare function queueProgress(status: QueueEntryStatus): {
7
10
  current: number;
8
11
  total: number;
9
12
  };
10
- export declare function nextStepLabel(status: QueueEntryStatus): string;
13
+ export declare function nextStepLabel(status: QueueEntryStatus, entry?: {
14
+ lastFailedBaseSha: string | null;
15
+ specBasedOn: string | null;
16
+ }): string;
17
+ /** Describe the spec chain for a queue entry. */
18
+ export declare function specChainLabel(entry: {
19
+ specBranch: string | null;
20
+ specBasedOn: string | null;
21
+ specSha: string | null;
22
+ }, allEntries: Array<{
23
+ id: string;
24
+ prNumber: number;
25
+ specBranch: string | null;
26
+ }>): string;
11
27
  export declare function runtimeLabel(runtime: QueueRuntimeStatus): string;
12
28
  export declare function formatEventSummary(event: QueueEventSummary): string;
13
29
  export declare function formatEntryEvent(event: QueueEventRecord): string;
@@ -38,12 +38,14 @@ export function statusColor(status) {
38
38
  return "gray";
39
39
  }
40
40
  }
41
- export function humanStatus(status) {
41
+ export function humanStatus(status, entry) {
42
42
  switch (status) {
43
43
  case "queued":
44
44
  return "queued";
45
45
  case "preparing_head":
46
- return "refreshing branch";
46
+ if (entry?.lastFailedBaseSha)
47
+ return "retry-gated";
48
+ return "building spec";
47
49
  case "validating":
48
50
  return "running CI";
49
51
  case "merging":
@@ -71,16 +73,18 @@ export function queueProgress(status) {
71
73
  return { current: 4, total: 4 };
72
74
  }
73
75
  }
74
- export function nextStepLabel(status) {
76
+ export function nextStepLabel(status, entry) {
75
77
  switch (status) {
76
78
  case "queued":
77
79
  return "waiting for head-of-line turn";
78
80
  case "preparing_head":
79
- return "refreshing branch with latest main";
81
+ if (entry?.lastFailedBaseSha)
82
+ return "waiting for base to advance";
83
+ return "building cumulative spec branch";
80
84
  case "validating":
81
- return "waiting for CI result";
85
+ return "waiting for CI on spec branch";
82
86
  case "merging":
83
- return "final GitHub merge";
87
+ return "pushing spec to main";
84
88
  case "merged":
85
89
  return "landed on main";
86
90
  case "evicted":
@@ -89,6 +93,18 @@ export function nextStepLabel(status) {
89
93
  return "removed manually";
90
94
  }
91
95
  }
96
+ /** Describe the spec chain for a queue entry. */
97
+ export function specChainLabel(entry, allEntries) {
98
+ if (!entry.specBranch)
99
+ return "no spec yet";
100
+ const parent = entry.specBasedOn
101
+ ? allEntries.find((e) => e.id === entry.specBasedOn)
102
+ : null;
103
+ const base = parent
104
+ ? `#${parent.prNumber}`
105
+ : "main";
106
+ return `${shortSha(entry.specSha)} \u2190 ${base}`;
107
+ }
92
108
  export function runtimeLabel(runtime) {
93
109
  if (runtime.tickInProgress) {
94
110
  return "running";
@@ -96,19 +96,25 @@ export function buildExternalRepairObservations(detail, options) {
96
96
  if (entry.lastFailedBaseSha) {
97
97
  observations.push({
98
98
  tone: "warn",
99
- text: `Waiting for ${entry.lastFailedBaseSha.slice(0, 7)} on base to change before retrying rebase.`,
99
+ text: `Retry-gated: conflict on base ${entry.lastFailedBaseSha.slice(0, 7)}. Waiting for base to advance before rebuilding spec.`,
100
100
  });
101
101
  }
102
102
  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
+ const cascadeNote = entry.specBasedOn
107
+ ? " Will merge automatically when head clears (cascade)."
108
+ : "";
103
109
  observations.push({
104
110
  tone: "info",
105
- text: "Waiting on validation CI for the refreshed head branch.",
111
+ text: `${specNote}${cascadeNote}`,
106
112
  });
107
113
  }
108
114
  else if (entry.status === "merging") {
109
115
  observations.push({
110
116
  tone: "info",
111
- text: "Validation passed; steward is attempting the GitHub merge.",
117
+ text: "CI passed; pushing spec branch to main (fast-forward).",
112
118
  });
113
119
  }
114
120
  if (entry.generation > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {