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.
- package/dist/cli/commands/queue.js +10 -0
- package/dist/github/pr-client.js +5 -4
- package/dist/reconciler.js +33 -2
- package/dist/service.d.ts +2 -1
- package/dist/service.js +39 -4
- package/dist/types.d.ts +13 -0
- package/dist/watch/App.js +1 -1
- package/dist/watch/DetailView.d.ts +3 -2
- package/dist/watch/DetailView.js +4 -3
- package/dist/watch/QueueListView.d.ts +3 -2
- package/dist/watch/QueueListView.js +9 -5
- package/dist/watch/StatusBar.js +3 -1
- package/dist/watch/format.d.ts +3 -1
- package/dist/watch/format.js +16 -0
- package/dist/watch/state-visualization.d.ts +2 -1
- package/dist/watch/state-visualization.js +7 -0
- package/package.json +1 -1
|
@@ -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
|
package/dist/github/pr-client.js
CHANGED
|
@@ -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
|
|
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":
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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" &&
|
|
265
|
-
|
|
266
|
+
if (event.action === "main_broken" && tickQueueBlockEvent === null) {
|
|
267
|
+
tickQueueBlockEvent = event;
|
|
266
268
|
}
|
|
267
269
|
},
|
|
268
270
|
});
|
|
269
|
-
this.
|
|
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 {};
|
package/dist/watch/DetailView.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/watch/StatusBar.js
CHANGED
|
@@ -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,
|
package/dist/watch/format.d.ts
CHANGED
|
@@ -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;
|
package/dist/watch/format.js
CHANGED
|
@@ -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",
|