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.
- package/dist/github/shell-git.js +1 -1
- package/dist/interfaces.d.ts +0 -1
- package/dist/reconciler.js +16 -14
- package/dist/service.js +2 -2
- package/dist/types.d.ts +1 -1
- package/dist/watch/App.js +3 -13
- package/dist/watch/DetailView.js +2 -3
- package/dist/watch/EntryStateGraph.js +9 -28
- package/dist/watch/QueueListView.d.ts +2 -2
- package/dist/watch/QueueListView.js +21 -44
- package/dist/watch/display-filter.d.ts +14 -0
- package/dist/watch/display-filter.js +45 -0
- package/dist/watch/format.js +34 -16
- package/dist/watch/state-visualization.js +18 -42
- package/package.json +1 -1
package/dist/github/shell-git.js
CHANGED
package/dist/interfaces.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/reconciler.js
CHANGED
|
@@ -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, "
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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 === "
|
|
285
|
-
|| event.action === "
|
|
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" | "
|
|
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,
|
|
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
|
}
|
package/dist/watch/DetailView.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
14
|
-
const
|
|
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
|
-
|
|
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,
|
|
27
|
+
export function QueueListView({ entries, allEntries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
|
|
28
28
|
const { stdout } = useStdout();
|
|
29
29
|
const rows = stdout?.rows ?? 24;
|
|
30
|
-
|
|
31
|
-
const
|
|
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
|
-
//
|
|
37
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/watch/format.js
CHANGED
|
@@ -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 "
|
|
44
|
+
return "waiting in queue";
|
|
45
45
|
case "preparing_head":
|
|
46
46
|
if (entry?.lastFailedBaseSha)
|
|
47
|
-
return "
|
|
48
|
-
return "
|
|
47
|
+
return "has conflicts";
|
|
48
|
+
return "preparing";
|
|
49
49
|
case "validating":
|
|
50
|
-
return "
|
|
50
|
+
return "testing";
|
|
51
51
|
case "merging":
|
|
52
|
-
return "merging
|
|
52
|
+
return "merging";
|
|
53
53
|
case "merged":
|
|
54
54
|
return "merged";
|
|
55
55
|
case "evicted":
|
|
56
|
-
return "
|
|
56
|
+
return "needs repair";
|
|
57
57
|
case "dequeued":
|
|
58
|
-
return "
|
|
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 "
|
|
79
|
+
return "starting shortly";
|
|
80
80
|
case "preparing_head":
|
|
81
81
|
if (entry?.lastFailedBaseSha)
|
|
82
|
-
return "
|
|
83
|
-
return "building
|
|
82
|
+
return "conflicts with main, will retry when queue advances";
|
|
83
|
+
return "building test branch with PRs ahead";
|
|
84
84
|
case "validating":
|
|
85
|
-
return
|
|
85
|
+
return entry?.specBasedOn
|
|
86
|
+
? "CI running, tested together with PRs ahead"
|
|
87
|
+
: "CI running on combined changes";
|
|
86
88
|
case "merging":
|
|
87
|
-
return "
|
|
89
|
+
return "landing on main";
|
|
88
90
|
case "merged":
|
|
89
91
|
return "landed on main";
|
|
90
92
|
case "evicted":
|
|
91
|
-
return "needs
|
|
93
|
+
return "needs branch repair before re-admission";
|
|
92
94
|
case "dequeued":
|
|
93
|
-
return "removed
|
|
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
|
|
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
|
|
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: "
|
|
6
|
-
validating: "
|
|
5
|
+
preparing_head: "preparing",
|
|
6
|
+
validating: "testing",
|
|
7
7
|
merging: "merging",
|
|
8
8
|
merged: "merged",
|
|
9
9
|
evicted: "evicted",
|
|
10
|
-
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: `
|
|
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: `
|
|
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: `
|
|
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:
|
|
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
|
-
? "
|
|
92
|
+
? " Tests pass → merges automatically when PRs ahead finish."
|
|
108
93
|
: "";
|
|
109
94
|
observations.push({
|
|
110
95
|
tone: "info",
|
|
111
|
-
text:
|
|
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
|
}
|