merge-steward 0.15.0 → 0.16.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.
- package/dist/cli/commands/pr-status.js +2 -6
- package/dist/watch/App.js +38 -163
- package/dist/watch/HelpBar.d.ts +1 -1
- package/dist/watch/HelpBar.js +4 -10
- package/dist/watch/OverviewView.d.ts +11 -5
- package/dist/watch/OverviewView.js +42 -38
- package/dist/watch/ProjectDetailView.d.ts +4 -7
- package/dist/watch/ProjectDetailView.js +51 -86
- package/dist/watch/StatusBar.d.ts +2 -7
- package/dist/watch/StatusBar.js +4 -34
- package/dist/watch/dashboard-model.d.ts +36 -25
- package/dist/watch/dashboard-model.js +277 -239
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SqliteStore } from "../../db/sqlite-store.js";
|
|
2
2
|
import { formatJson, writeOutput } from "../output.js";
|
|
3
3
|
import { ServiceApiError, fetchLocalJson, loadRepoConfigById } from "../system.js";
|
|
4
|
-
import { resolvePrNumber, resolveRepo } from "../resolve.js";
|
|
4
|
+
import { defaultResolveRunner, resolvePrNumber, resolveRepo } from "../resolve.js";
|
|
5
5
|
import { parseIntegerFlag } from "../args.js";
|
|
6
6
|
import { fetchPrGitHubOverview } from "./pr-github.js";
|
|
7
7
|
export function classifyQueueEntry(entry) {
|
|
@@ -223,11 +223,7 @@ export async function handlePrStatus(options) {
|
|
|
223
223
|
const resolvedPr = await resolvePrNumber({ parsed, helpTopic: "root", runCommand: resolveCommand });
|
|
224
224
|
const { config } = loadRepoConfigById(resolvedRepo.repoId);
|
|
225
225
|
const fetchGh = options.fetchGitHub
|
|
226
|
-
?? ((repoFullName, prNumber) => fetchPrGitHubOverview(repoFullName, prNumber, resolveCommand ??
|
|
227
|
-
exitCode: 127,
|
|
228
|
-
stdout: "",
|
|
229
|
-
stderr: "no resolveCommand provided",
|
|
230
|
-
}))));
|
|
226
|
+
?? ((repoFullName, prNumber) => fetchPrGitHubOverview(repoFullName, prNumber, resolveCommand ?? defaultResolveRunner));
|
|
231
227
|
const buildReport = async () => {
|
|
232
228
|
const queueResult = await loadQueueEntry(config, resolvedPr.prNumber);
|
|
233
229
|
if (queueResult.kind === "found") {
|
package/dist/watch/App.js
CHANGED
|
@@ -1,24 +1,13 @@
|
|
|
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 {
|
|
5
|
-
import {
|
|
4
|
+
import { fetchGatewayHealth, fetchSnapshot, triggerReconcile } from "./api.js";
|
|
5
|
+
import { buildDashboard, matchRepoRef, repoSelector, stepRepo } from "./dashboard-model.js";
|
|
6
6
|
import { HelpBar } from "./HelpBar.js";
|
|
7
7
|
import { OverviewView } from "./OverviewView.js";
|
|
8
8
|
import { ProjectDetailView } from "./ProjectDetailView.js";
|
|
9
9
|
import { StatusBar } from "./StatusBar.js";
|
|
10
10
|
const REFRESH_INTERVAL_MS = 1_500;
|
|
11
|
-
function nextSelection(items, selectedId, direction) {
|
|
12
|
-
if (items.length === 0) {
|
|
13
|
-
return null;
|
|
14
|
-
}
|
|
15
|
-
const index = items.findIndex((item) => item.id === selectedId);
|
|
16
|
-
const currentIndex = index === -1 ? 0 : index;
|
|
17
|
-
const nextIndex = direction === "next"
|
|
18
|
-
? (currentIndex + 1) % items.length
|
|
19
|
-
: (currentIndex - 1 + items.length) % items.length;
|
|
20
|
-
return items[nextIndex]?.id ?? null;
|
|
21
|
-
}
|
|
22
11
|
export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber }) {
|
|
23
12
|
const { exit } = useApp();
|
|
24
13
|
const [repoStates, setRepoStates] = useState(() => repos.map((repo) => ({
|
|
@@ -30,25 +19,34 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
30
19
|
lastSnapshotReceivedAt: null,
|
|
31
20
|
})));
|
|
32
21
|
const [lastSnapshotReceivedAt, setLastSnapshotReceivedAt] = useState(null);
|
|
33
|
-
const [detail, setDetail] = useState(null);
|
|
34
22
|
const [gatewayError, setGatewayError] = useState(null);
|
|
35
23
|
const [selectedRepoId, setSelectedRepoId] = useState(() => {
|
|
36
24
|
const initialRepo = repos.find((repo) => matchRepoRef(repo, initialRepoRef));
|
|
37
25
|
return initialRepo?.repoId ?? repos[0]?.repoId ?? null;
|
|
38
26
|
});
|
|
39
|
-
const [
|
|
40
|
-
const [view, setView] = useState(initialRepoRef || initialPrNumber !== undefined ? "project" : "overview");
|
|
41
|
-
const [filter, setFilter] = useState("active");
|
|
27
|
+
const [view, setView] = useState(initialRepoRef || initialPrNumber !== undefined ? "detail" : "list");
|
|
42
28
|
const [flashMessage, setFlashMessage] = useState(null);
|
|
43
29
|
const [pendingInitialPrNumber, setPendingInitialPrNumber] = useState(initialPrNumber ?? null);
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
30
|
+
const model = useMemo(() => buildDashboard(repoStates), [repoStates]);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setSelectedRepoId((current) => repoSelector(model.repos, current));
|
|
33
|
+
}, [model]);
|
|
48
34
|
useEffect(() => {
|
|
49
|
-
if (
|
|
35
|
+
if (pendingInitialPrNumber === null)
|
|
50
36
|
return;
|
|
37
|
+
for (const repo of repoStates) {
|
|
38
|
+
const match = repo.snapshot?.entries.find((entry) => entry.prNumber === pendingInitialPrNumber);
|
|
39
|
+
if (match) {
|
|
40
|
+
setSelectedRepoId(repo.repoId);
|
|
41
|
+
setView("detail");
|
|
42
|
+
setPendingInitialPrNumber(null);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
51
45
|
}
|
|
46
|
+
}, [pendingInitialPrNumber, repoStates]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!flashMessage)
|
|
49
|
+
return;
|
|
52
50
|
const timeout = setTimeout(() => setFlashMessage(null), 2500);
|
|
53
51
|
return () => clearTimeout(timeout);
|
|
54
52
|
}, [flashMessage]);
|
|
@@ -57,9 +55,8 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
57
55
|
const refresh = async () => {
|
|
58
56
|
try {
|
|
59
57
|
const health = await fetchGatewayHealth(gatewayBaseUrl);
|
|
60
|
-
if (!cancelled)
|
|
58
|
+
if (!cancelled)
|
|
61
59
|
setGatewayError(null);
|
|
62
|
-
}
|
|
63
60
|
setRepoStates((current) => current.map((repo) => {
|
|
64
61
|
const runtime = health.repos.find((candidate) => candidate.repoId === repo.repoId);
|
|
65
62
|
if (!runtime) {
|
|
@@ -96,14 +93,12 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
96
93
|
};
|
|
97
94
|
}
|
|
98
95
|
}));
|
|
99
|
-
if (cancelled)
|
|
96
|
+
if (cancelled)
|
|
100
97
|
return;
|
|
101
|
-
}
|
|
102
98
|
setRepoStates((current) => current.map((repo) => {
|
|
103
99
|
const result = results.find((candidate) => candidate.repoId === repo.repoId);
|
|
104
|
-
if (!result)
|
|
100
|
+
if (!result)
|
|
105
101
|
return repo;
|
|
106
|
-
}
|
|
107
102
|
if (result.snapshot) {
|
|
108
103
|
return {
|
|
109
104
|
...repo,
|
|
@@ -112,25 +107,19 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
112
107
|
lastSnapshotReceivedAt: result.receivedAt,
|
|
113
108
|
};
|
|
114
109
|
}
|
|
115
|
-
return {
|
|
116
|
-
...repo,
|
|
117
|
-
error: result.error,
|
|
118
|
-
};
|
|
110
|
+
return { ...repo, error: result.error };
|
|
119
111
|
}));
|
|
120
112
|
const latest = results.reduce((max, result) => {
|
|
121
|
-
if (result.receivedAt === null)
|
|
113
|
+
if (result.receivedAt === null)
|
|
122
114
|
return max;
|
|
123
|
-
}
|
|
124
115
|
return max === null ? result.receivedAt : Math.max(max, result.receivedAt);
|
|
125
116
|
}, null);
|
|
126
|
-
if (latest !== null)
|
|
117
|
+
if (latest !== null)
|
|
127
118
|
setLastSnapshotReceivedAt(latest);
|
|
128
|
-
}
|
|
129
119
|
}
|
|
130
120
|
catch (error) {
|
|
131
|
-
if (cancelled)
|
|
121
|
+
if (cancelled)
|
|
132
122
|
return;
|
|
133
|
-
}
|
|
134
123
|
const message = error instanceof Error ? error.message : String(error);
|
|
135
124
|
setGatewayError(message);
|
|
136
125
|
setRepoStates((current) => current.map((repo) => ({
|
|
@@ -139,7 +128,6 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
139
128
|
serviceMessage: message,
|
|
140
129
|
error: message,
|
|
141
130
|
})));
|
|
142
|
-
return;
|
|
143
131
|
}
|
|
144
132
|
};
|
|
145
133
|
void refresh();
|
|
@@ -151,102 +139,12 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
151
139
|
clearInterval(interval);
|
|
152
140
|
};
|
|
153
141
|
}, [gatewayBaseUrl, repos]);
|
|
154
|
-
useEffect(() => {
|
|
155
|
-
if (pendingInitialPrNumber === null) {
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
for (const repo of repoStates) {
|
|
159
|
-
const match = repo.snapshot?.entries.find((entry) => entry.prNumber === pendingInitialPrNumber);
|
|
160
|
-
if (!match) {
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
setSelectedRepoId(repo.repoId);
|
|
164
|
-
setSelectedEntryIds((current) => ({
|
|
165
|
-
...current,
|
|
166
|
-
[repo.repoId]: match.id,
|
|
167
|
-
}));
|
|
168
|
-
setView("project");
|
|
169
|
-
setPendingInitialPrNumber(null);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
}, [pendingInitialPrNumber, repoStates]);
|
|
173
|
-
useEffect(() => {
|
|
174
|
-
if (!currentRepo) {
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
const nextDefault = getDefaultEntryId(currentRepo.snapshot, filter, pendingInitialPrNumber);
|
|
178
|
-
setSelectedEntryIds((current) => {
|
|
179
|
-
const existing = current[currentRepo.repoId];
|
|
180
|
-
if (existing && currentVisibleEntries.some((entry) => entry.id === existing)) {
|
|
181
|
-
return current;
|
|
182
|
-
}
|
|
183
|
-
if (existing === nextDefault) {
|
|
184
|
-
return current;
|
|
185
|
-
}
|
|
186
|
-
return {
|
|
187
|
-
...current,
|
|
188
|
-
[currentRepo.repoId]: nextDefault,
|
|
189
|
-
};
|
|
190
|
-
});
|
|
191
|
-
}, [currentRepo, currentVisibleEntries, filter, pendingInitialPrNumber]);
|
|
192
|
-
useEffect(() => {
|
|
193
|
-
if (view !== "project" || !currentRepo?.repoId || !selectedEntryId || currentRepo.serviceState !== "ready") {
|
|
194
|
-
setDetail(null);
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
let cancelled = false;
|
|
198
|
-
const refresh = async () => {
|
|
199
|
-
try {
|
|
200
|
-
const nextDetail = await fetchEntryDetail(gatewayBaseUrl, currentRepo.repoId, selectedEntryId);
|
|
201
|
-
if (!cancelled) {
|
|
202
|
-
setDetail(nextDetail);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
catch (error) {
|
|
206
|
-
if (!cancelled) {
|
|
207
|
-
setFlashMessage(error instanceof Error ? error.message : String(error));
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
void refresh();
|
|
212
|
-
const interval = setInterval(() => {
|
|
213
|
-
void refresh();
|
|
214
|
-
}, REFRESH_INTERVAL_MS);
|
|
215
|
-
return () => {
|
|
216
|
-
cancelled = true;
|
|
217
|
-
clearInterval(interval);
|
|
218
|
-
};
|
|
219
|
-
}, [gatewayBaseUrl, currentRepo?.repoId, currentRepo?.serviceState, selectedEntryId, view]);
|
|
220
|
-
async function refreshRepo(repoId) {
|
|
221
|
-
const nextSnapshot = await fetchSnapshot(gatewayBaseUrl, repoId);
|
|
222
|
-
const receivedAt = Date.now();
|
|
223
|
-
setRepoStates((current) => current.map((repo) => repo.repoId === repoId
|
|
224
|
-
? { ...repo, serviceState: "ready", serviceMessage: null, snapshot: nextSnapshot, error: null, lastSnapshotReceivedAt: receivedAt }
|
|
225
|
-
: repo));
|
|
226
|
-
setLastSnapshotReceivedAt(receivedAt);
|
|
227
|
-
}
|
|
228
142
|
async function runReconcile(repoId) {
|
|
229
|
-
if (!repoId)
|
|
143
|
+
if (!repoId)
|
|
230
144
|
return;
|
|
231
|
-
}
|
|
232
145
|
try {
|
|
233
146
|
const result = await triggerReconcile(gatewayBaseUrl, repoId);
|
|
234
147
|
setFlashMessage(result.started ? `reconcile tick completed for ${repoId}` : `reconcile already running for ${repoId}`);
|
|
235
|
-
await refreshRepo(repoId);
|
|
236
|
-
}
|
|
237
|
-
catch (error) {
|
|
238
|
-
setFlashMessage(error instanceof Error ? error.message : String(error));
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
async function runDequeue() {
|
|
242
|
-
if (!currentRepo?.repoId || !selectedEntryId || currentRepo.serviceState !== "ready") {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
try {
|
|
246
|
-
const selectedEntry = currentRepo.snapshot?.entries.find((entry) => entry.id === selectedEntryId) ?? null;
|
|
247
|
-
await dequeueEntry(gatewayBaseUrl, currentRepo.repoId, selectedEntryId);
|
|
248
|
-
setFlashMessage(`dequeued ${selectedEntry ? `#${selectedEntry.prNumber}` : "entry"} from ${currentRepo.repoId}`);
|
|
249
|
-
await refreshRepo(currentRepo.repoId);
|
|
250
148
|
}
|
|
251
149
|
catch (error) {
|
|
252
150
|
setFlashMessage(error instanceof Error ? error.message : String(error));
|
|
@@ -261,45 +159,22 @@ export function App({ gatewayBaseUrl, repos, initialRepoRef, initialPrNumber })
|
|
|
261
159
|
void runReconcile(selectedRepoId);
|
|
262
160
|
return;
|
|
263
161
|
}
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
setSelectedRepoId(nextSelection(repoSelectionItems, selectedRepoId, "next"));
|
|
267
|
-
}
|
|
268
|
-
else if (input === "k" || key.upArrow) {
|
|
269
|
-
setSelectedRepoId(nextSelection(repoSelectionItems, selectedRepoId, "prev"));
|
|
270
|
-
}
|
|
271
|
-
else if (key.return && selectedRepoId) {
|
|
272
|
-
setView("project");
|
|
273
|
-
}
|
|
162
|
+
if (input === "j" || key.downArrow) {
|
|
163
|
+
setSelectedRepoId((current) => stepRepo(model.repos, current, 1));
|
|
274
164
|
return;
|
|
275
165
|
}
|
|
276
|
-
if (input === "
|
|
277
|
-
|
|
166
|
+
if (input === "k" || key.upArrow) {
|
|
167
|
+
setSelectedRepoId((current) => stepRepo(model.repos, current, -1));
|
|
278
168
|
return;
|
|
279
169
|
}
|
|
280
|
-
if (
|
|
281
|
-
|
|
170
|
+
if (view === "list") {
|
|
171
|
+
if (key.return)
|
|
172
|
+
setView("detail");
|
|
282
173
|
return;
|
|
283
174
|
}
|
|
284
|
-
if (
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
else if (input === "j" || key.downArrow) {
|
|
288
|
-
if (currentRepo?.repoId) {
|
|
289
|
-
setSelectedEntryIds((current) => ({
|
|
290
|
-
...current,
|
|
291
|
-
[currentRepo.repoId]: nextSelection(currentVisibleEntries, current[currentRepo.repoId] ?? null, "next"),
|
|
292
|
-
}));
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
else if (input === "k" || key.upArrow) {
|
|
296
|
-
if (currentRepo?.repoId) {
|
|
297
|
-
setSelectedEntryIds((current) => ({
|
|
298
|
-
...current,
|
|
299
|
-
[currentRepo.repoId]: nextSelection(currentVisibleEntries, current[currentRepo.repoId] ?? null, "prev"),
|
|
300
|
-
}));
|
|
301
|
-
}
|
|
175
|
+
if (key.escape || key.backspace || key.delete) {
|
|
176
|
+
setView("list");
|
|
302
177
|
}
|
|
303
178
|
});
|
|
304
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, {
|
|
179
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(StatusBar, { connected: model.repos.some((repo) => !repo.offlineMessage), lastSnapshotReceivedAt: lastSnapshotReceivedAt, gatewayError: gatewayError }), view === "detail" ? (_jsx(ProjectDetailView, { model: model, selectedRepoId: selectedRepoId })) : (_jsx(OverviewView, { model: model, selectedRepoId: selectedRepoId, showCursor: true })), flashMessage ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: flashMessage }) })) : null, _jsx(HelpBar, { view: view })] }));
|
|
305
180
|
}
|
package/dist/watch/HelpBar.d.ts
CHANGED
package/dist/watch/HelpBar.js
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
3
|
export function HelpBar({ view }) {
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
? (view === "overview"
|
|
8
|
-
? "j/k select Enter open r refresh q quit"
|
|
9
|
-
: "j/k select Esc overview a filter r refresh d dequeue q quit")
|
|
10
|
-
: view === "overview"
|
|
11
|
-
? "j/k: select project Enter: open project r: reconcile selected project q: quit"
|
|
12
|
-
: "j/k: select PR Esc: overview a: filter r: reconcile project d: dequeue PR q: quit";
|
|
4
|
+
const text = view === "list"
|
|
5
|
+
? "↑↓ switch repo enter detail r reconcile q quit"
|
|
6
|
+
: "↑↓ switch repo esc back r reconcile q quit";
|
|
13
7
|
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: text }) }));
|
|
14
8
|
}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
interface
|
|
3
|
-
|
|
1
|
+
import type { DashboardModel, DashboardRepo } from "./dashboard-model.ts";
|
|
2
|
+
interface ListViewProps {
|
|
3
|
+
model: DashboardModel;
|
|
4
4
|
selectedRepoId: string | null;
|
|
5
|
-
|
|
5
|
+
showCursor: boolean;
|
|
6
6
|
}
|
|
7
|
-
export declare function
|
|
7
|
+
export declare function RepoRow({ repo, selected, showCursor, width, }: {
|
|
8
|
+
repo: DashboardRepo;
|
|
9
|
+
selected: boolean;
|
|
10
|
+
showCursor: boolean;
|
|
11
|
+
width: number;
|
|
12
|
+
}): React.JSX.Element;
|
|
13
|
+
export declare function OverviewView({ model, selectedRepoId, showCursor }: ListViewProps): React.JSX.Element;
|
|
8
14
|
export {};
|
|
@@ -1,44 +1,48 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useStdout } from "ink";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
function RepoTokens({ tokens, width }) {
|
|
4
|
+
if (tokens.length === 0 || width < 6)
|
|
5
|
+
return null;
|
|
6
|
+
const parts = [];
|
|
7
|
+
let used = 0;
|
|
8
|
+
for (const token of tokens) {
|
|
9
|
+
const text = `#${token.prNumber} ${token.glyph}`;
|
|
10
|
+
const separatorWidth = parts.length === 0 ? 0 : 2;
|
|
11
|
+
if (used + separatorWidth + text.length > width)
|
|
12
|
+
break;
|
|
13
|
+
used += separatorWidth + text.length;
|
|
14
|
+
parts.push({ token, text });
|
|
9
15
|
}
|
|
10
|
-
|
|
11
|
-
return Math.max(0, Math.min(itemCount - maxItems, selectedIndex - half));
|
|
16
|
+
return (_jsx(Text, { children: parts.map((part, index) => (_jsx(Text, { color: part.token.color, children: index === 0 ? part.text : ` ${part.text}` }, `${part.token.prNumber}-${index}`))) }));
|
|
12
17
|
}
|
|
13
|
-
export function
|
|
18
|
+
export function RepoRow({ repo, selected, showCursor, width, }) {
|
|
19
|
+
const cursorChar = showCursor && selected ? ">" : " ";
|
|
20
|
+
const repoLabelWidth = Math.min(28, Math.max(12, Math.floor(width * 0.35)));
|
|
21
|
+
const tokenWidth = Math.max(6, width - repoLabelWidth - 3);
|
|
22
|
+
const repoLabel = repo.repoFullName.length > repoLabelWidth
|
|
23
|
+
? repo.repoFullName.slice(0, repoLabelWidth)
|
|
24
|
+
: repo.repoFullName.padEnd(repoLabelWidth, " ");
|
|
25
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: cursorChar }), _jsx(Text, { bold: selected, children: ` ${repoLabel} ` }), repo.offlineMessage ? (_jsx(Text, { color: "red", children: repo.offlineMessage })) : (_jsx(RepoTokens, { tokens: repo.tokens, width: tokenWidth }))] }));
|
|
26
|
+
}
|
|
27
|
+
export function OverviewView({ model, selectedRepoId, showCursor }) {
|
|
14
28
|
const { stdout } = useStdout();
|
|
15
|
-
const rows = stdout?.rows ?? 24;
|
|
16
|
-
const width = stdout?.columns ?? 80;
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
? chainEntries.map((entry) => {
|
|
35
|
-
const ci = ciStatusIcon(entry);
|
|
36
|
-
return compact
|
|
37
|
-
? `#${entry.prNumber}${ci.icon}`
|
|
38
|
-
: `#${entry.prNumber} ${ci.icon}`;
|
|
39
|
-
}).join(" ")
|
|
40
|
-
: compact ? "empty" : "queue is empty"
|
|
41
|
-
: "queue data unavailable";
|
|
42
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: repo.repoId === selectedRepoId ? "cyan" : "gray", children: repo.repoId === selectedRepoId ? ">" : " " }), _jsx(Text, { bold: true, children: truncate(repo.repoId, idWidth) }), compact ? null : _jsx(Text, { dimColor: true, children: ` ${truncate(repo.repoFullName, 28)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: compact ? health.label.slice(0, 5) : health.label }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(repo.snapshot, compact)}` })] }), _jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `Queue: ${truncate(chainText, chainWidth)}` }) }), compact ? null : (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { color: health.color, children: truncate(queueBlockSummary ?? health.detail, 110) }) }))] }, repo.repoId));
|
|
43
|
-
}) }))] }));
|
|
29
|
+
const rows = Math.max(3, stdout?.rows ?? 24);
|
|
30
|
+
const width = Math.max(40, stdout?.columns ?? 80);
|
|
31
|
+
const availableRows = Math.max(1, rows - 3);
|
|
32
|
+
const selectedIndex = model.repos.findIndex((repo) => repo.repoId === selectedRepoId);
|
|
33
|
+
const selected = selectedIndex >= 0 ? model.repos[selectedIndex] : model.repos[0];
|
|
34
|
+
const others = model.repos.filter((repo) => repo !== selected);
|
|
35
|
+
const ordered = [];
|
|
36
|
+
if (selected)
|
|
37
|
+
ordered.push(selected);
|
|
38
|
+
ordered.push(...others);
|
|
39
|
+
const quietLine = model.quietCount > 0 ? 1 : 0;
|
|
40
|
+
const maxRepoLines = Math.max(1, availableRows - quietLine);
|
|
41
|
+
const visible = ordered.slice(0, maxRepoLines);
|
|
42
|
+
const hiddenActive = ordered.length - visible.length;
|
|
43
|
+
const quietFooter = model.quietCount + hiddenActive;
|
|
44
|
+
if (visible.length === 0) {
|
|
45
|
+
return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
|
|
46
|
+
}
|
|
47
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [visible.map((repo) => (_jsx(RepoRow, { repo: repo, selected: repo === selected, showCursor: showCursor, width: width - 2 }, repo.repoId))), quietFooter > 0 ? (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: `+${quietFooter} quiet` }) })) : null] }));
|
|
44
48
|
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { DashboardRepoState } from "./dashboard-model.ts";
|
|
1
|
+
import { type DashboardModel } from "./dashboard-model.ts";
|
|
3
2
|
interface ProjectDetailViewProps {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
detail: QueueEntryDetail | null;
|
|
7
|
-
filter: "active" | "all";
|
|
3
|
+
model: DashboardModel;
|
|
4
|
+
selectedRepoId: string | null;
|
|
8
5
|
}
|
|
9
|
-
export declare function ProjectDetailView({
|
|
6
|
+
export declare function ProjectDetailView({ model, selectedRepoId }: ProjectDetailViewProps): React.JSX.Element;
|
|
10
7
|
export {};
|
|
@@ -1,93 +1,58 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text, useStdout } from "ink";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
function selectedEntryIndex(snapshot, filter, entryId) {
|
|
14
|
-
if (!snapshot || !entryId) {
|
|
15
|
-
return 0;
|
|
16
|
-
}
|
|
17
|
-
const entries = buildDisplayEntries(snapshot.entries, filter);
|
|
18
|
-
const index = entries.findIndex((entry) => entry.id === entryId);
|
|
19
|
-
return index >= 0 ? index : 0;
|
|
3
|
+
import { RepoRow } from "./OverviewView.js";
|
|
4
|
+
import { clipSummary } from "./dashboard-model.js";
|
|
5
|
+
const PR_ID_WIDTH = 7;
|
|
6
|
+
const SUMMARY_INDENT = 9;
|
|
7
|
+
function PrEntryRow({ entry, width, includeSummary, }) {
|
|
8
|
+
const idText = `#${entry.prNumber}`.padEnd(PR_ID_WIDTH, " ");
|
|
9
|
+
const summaryText = includeSummary
|
|
10
|
+
? clipSummary(entry.summary, { maxLines: 3, width: Math.max(20, width - SUMMARY_INDENT) })
|
|
11
|
+
: "";
|
|
12
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: entry.color, children: idText }), _jsx(Text, { color: entry.color, children: entry.glyph }), _jsx(Text, { children: ` ${entry.phrase}` })] }), summaryText ? (_jsxs(Box, { children: [_jsx(Text, { children: " ".repeat(SUMMARY_INDENT) }), _jsx(Text, { dimColor: true, children: summaryText })] })) : null] }));
|
|
20
13
|
}
|
|
21
|
-
function
|
|
22
|
-
switch (entryStatus) {
|
|
23
|
-
case "queued":
|
|
24
|
-
return "waiting";
|
|
25
|
-
case "preparing_head":
|
|
26
|
-
return "prep";
|
|
27
|
-
case "validating":
|
|
28
|
-
return "testing";
|
|
29
|
-
case "merging":
|
|
30
|
-
return "merging";
|
|
31
|
-
case "merged":
|
|
32
|
-
return "merged";
|
|
33
|
-
case "evicted":
|
|
34
|
-
return "repair";
|
|
35
|
-
case "dequeued":
|
|
36
|
-
return "removed";
|
|
37
|
-
default:
|
|
38
|
-
return entryStatus;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
export function ProjectDetailView({ repo, selectedEntryId, detail, filter, }) {
|
|
14
|
+
export function ProjectDetailView({ model, selectedRepoId }) {
|
|
42
15
|
const { stdout } = useStdout();
|
|
43
|
-
const rows = stdout?.rows ?? 24;
|
|
44
|
-
const width = stdout?.columns ?? 80;
|
|
45
|
-
const
|
|
16
|
+
const rows = Math.max(3, stdout?.rows ?? 24);
|
|
17
|
+
const width = Math.max(40, stdout?.columns ?? 80);
|
|
18
|
+
const repo = selectedRepoId
|
|
19
|
+
? model.repos.find((candidate) => candidate.repoId === selectedRepoId) ?? model.repos[0] ?? null
|
|
20
|
+
: model.repos[0] ?? null;
|
|
46
21
|
if (!repo) {
|
|
47
|
-
return
|
|
22
|
+
return _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: " " }) });
|
|
23
|
+
}
|
|
24
|
+
const availableRows = Math.max(1, rows - 3);
|
|
25
|
+
const afterRepoRow = Math.max(0, availableRows - 2);
|
|
26
|
+
const entries = repo.entries;
|
|
27
|
+
const planned = [];
|
|
28
|
+
let used = 0;
|
|
29
|
+
const maxSummaryLines = 3;
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
if (used >= afterRepoRow)
|
|
32
|
+
break;
|
|
33
|
+
const phraseLine = 1;
|
|
34
|
+
if (used + phraseLine > afterRepoRow)
|
|
35
|
+
break;
|
|
36
|
+
planned.push({ entry, includeSummary: false, lines: phraseLine });
|
|
37
|
+
used += phraseLine;
|
|
48
38
|
}
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
39
|
+
if (afterRepoRow - used >= 2) {
|
|
40
|
+
for (const item of planned) {
|
|
41
|
+
if (!item.entry.summary)
|
|
42
|
+
continue;
|
|
43
|
+
const summaryText = clipSummary(item.entry.summary, {
|
|
44
|
+
maxLines: maxSummaryLines,
|
|
45
|
+
width: Math.max(20, width - SUMMARY_INDENT),
|
|
46
|
+
});
|
|
47
|
+
if (!summaryText)
|
|
48
|
+
continue;
|
|
49
|
+
const summaryLines = summaryText.split("\n").length;
|
|
50
|
+
if (used + summaryLines > afterRepoRow)
|
|
51
|
+
break;
|
|
52
|
+
item.includeSummary = true;
|
|
53
|
+
item.lines += summaryLines;
|
|
54
|
+
used += summaryLines;
|
|
55
|
+
}
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
const health = getRepoHealth(repo);
|
|
58
|
-
const queueBlockSummary = summarizeQueueBlock(snapshot.queueBlock);
|
|
59
|
-
const entries = buildDisplayEntries(snapshot.entries, filter);
|
|
60
|
-
const currentSelectedIndex = selectedEntryIndex(snapshot, filter, selectedEntryId);
|
|
61
|
-
const maxEntryRows = Math.max(3, Math.floor((rows - 16) / 2));
|
|
62
|
-
const entryWindowStart = clampWindowStart(currentSelectedIndex, entries.length, maxEntryRows);
|
|
63
|
-
const visibleEntries = entries.slice(entryWindowStart, entryWindowStart + maxEntryRows);
|
|
64
|
-
const selectedEntry = entries.find((entry) => entry.id === selectedEntryId)
|
|
65
|
-
?? snapshot.entries.find((entry) => entry.id === selectedEntryId)
|
|
66
|
-
?? null;
|
|
67
|
-
const eventRows = compact ? 4 : 6;
|
|
68
|
-
const visibleEvents = detail
|
|
69
|
-
? detail.events.slice(-eventRows)
|
|
70
|
-
: snapshot.recentEvents.slice(-eventRows);
|
|
71
|
-
const chainEntries = getChainEntries(snapshot);
|
|
72
|
-
const chainText = chainEntries.length > 0
|
|
73
|
-
? chainEntries.map((entry) => {
|
|
74
|
-
const ci = ciStatusIcon(entry);
|
|
75
|
-
return `#${entry.prNumber}${ci.icon}`;
|
|
76
|
-
}).join(" ")
|
|
77
|
-
: "empty";
|
|
78
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: repo.repoId }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: health.color, children: health.label })] }), _jsx(Text, { dimColor: true, children: ` ${projectStatsSummary(snapshot, compact)}` }), compact ? null : _jsx(Text, { dimColor: true, children: runtimeSummary(snapshot) }), _jsx(Text, { dimColor: true, children: compact
|
|
79
|
-
? `Queue: ${truncate(chainText, width - 10)}`
|
|
80
|
-
: `Queue: main -> ${truncate(chainText, width - 18)}` }), compact ? null : queueBlockSummary ? _jsx(Text, { color: "yellow", children: queueBlockSummary }) : null, compact ? null : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["GitHub required checks: ", snapshot.githubPolicy.requiredChecks.length > 0 ? snapshot.githubPolicy.requiredChecks.join(", ") : "(none)"] }), snapshot.githubPolicy.fetchedAt ? (_jsxs(Text, { dimColor: true, children: ["Policy fetched ", relativeTime(snapshot.githubPolicy.fetchedAt), " ago", snapshot.githubPolicy.lastRefreshReason
|
|
81
|
-
? ` via ${snapshot.githubPolicy.lastRefreshReason}${snapshot.githubPolicy.lastRefreshChanged === null ? "" : snapshot.githubPolicy.lastRefreshChanged ? " (changed)" : " (unchanged)"}`
|
|
82
|
-
: ""] })) : null] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Pull Requests" }), visibleEntries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (visibleEntries.map((entry) => {
|
|
83
|
-
const ci = ciStatusIcon(entry);
|
|
84
|
-
const isHead = snapshot.summary.headEntryId === entry.id;
|
|
85
|
-
const isSelected = entry.id === selectedEntryId;
|
|
86
|
-
const statusColor = entry.status === "evicted" ? "red" : ci.color;
|
|
87
|
-
const baseStatusText = compact ? compactEntryStatus(entry.status) : humanStatus(entry.status, entry);
|
|
88
|
-
const statusText = entry.status === "merged" ? `${baseStatusText}: ${postMergeStatusLine(entry)}` : baseStatusText;
|
|
89
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: isSelected ? "cyan" : "gray", children: isSelected ? ">" : " " }), _jsx(Text, { bold: true, children: `#${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { children: ` ` }), _jsx(Text, { color: ci.color, children: ci.icon }), _jsx(Text, { color: statusColor, children: ` ${statusText}` }), compact ? null : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: ` updated ${relativeTime(entry.updatedAt)} ago` }), isHead ? _jsx(Text, { dimColor: true, children: ` · ${humanStatus(entry.status, entry)} head` }) : null] }))] }), (isSelected || entry.status === "evicted" || (isHead && queueBlockSummary)) && (_jsx(Box, { paddingLeft: 2, children: _jsx(Text, { dimColor: true, children: describeEntry(entry, { isHead, queueBlockSummary }) }) }))] }, entry.id));
|
|
90
|
-
}))] }), selectedEntry ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: `Selected PR #${selectedEntry.prNumber}` }), compact ? (_jsx(Text, { dimColor: true, children: `head ${shortSha(selectedEntry.headSha)} · base ${shortSha(selectedEntry.baseSha)}` })) : (_jsxs(Text, { dimColor: true, children: [selectedEntry.branch, " \u00B7 head ", shortSha(selectedEntry.headSha), " \u00B7 base ", shortSha(selectedEntry.baseSha)] })), compact ? null : _jsx(Text, { dimColor: true, children: `Test branch: ${specChainLabel(selectedEntry, snapshot.entries)}` }), selectedEntry.status === "merged" ? (_jsx(Text, { dimColor: true, children: `Post-merge: ${postMergeStatusLine(selectedEntry)}${selectedEntry.postMergeCheckedAt ? ` · ${relativeTime(selectedEntry.postMergeCheckedAt)} ago` : ""}` })) : null, detail?.incidents.length
|
|
91
|
-
? detail.incidents.slice(-2).map((incident) => (_jsxs(Text, { color: "red", children: [relativeTime(incident.at), " ago \u00B7 ", incident.failureClass, " \u00B7 ", incident.outcome] }, incident.id)))
|
|
92
|
-
: null] })) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: detail ? "Selected PR Activity" : "Recent Project Activity" }), visibleEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (visibleEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventNarrative(event, selectedEntry ? { prNumber: selectedEntry.prNumber } : {}) })] }, "id" in event && event.id ? String(event.id) : `${event.entryId}-${event.at}`))))] })] }));
|
|
57
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(RepoRow, { repo: repo, selected: false, showCursor: false, width: width - 2 }), planned.length > 0 ? _jsx(Box, { children: _jsx(Text, { children: " " }) }) : null, planned.map(({ entry, includeSummary }) => (_jsx(PrEntryRow, { entry: entry, width: width, includeSummary: includeSummary }, entry.prNumber)))] }));
|
|
93
58
|
}
|
|
@@ -1,12 +1,7 @@
|
|
|
1
|
-
import type { DashboardRepoState } from "./dashboard-model.ts";
|
|
2
1
|
interface StatusBarProps {
|
|
3
|
-
|
|
4
|
-
currentRepo: DashboardRepoState | null;
|
|
5
|
-
view: "overview" | "project";
|
|
6
|
-
filter: "active" | "all";
|
|
2
|
+
connected: boolean;
|
|
7
3
|
lastSnapshotReceivedAt: number | null;
|
|
8
|
-
expectedFreshMs: number;
|
|
9
4
|
gatewayError: string | null;
|
|
10
5
|
}
|
|
11
|
-
export declare function StatusBar({
|
|
6
|
+
export declare function StatusBar({ connected, lastSnapshotReceivedAt, gatewayError }: StatusBarProps): React.JSX.Element;
|
|
12
7
|
export {};
|
package/dist/watch/StatusBar.js
CHANGED
|
@@ -1,37 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text
|
|
3
|
-
import { getClusterSummary, getRepoHealth } from "./dashboard-model.js";
|
|
4
|
-
import { truncate } from "./format.js";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
5
3
|
import { FreshnessBadge } from "./FreshnessBadge.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const compact = width < 88;
|
|
10
|
-
const cluster = getClusterSummary(repos);
|
|
11
|
-
const connected = cluster.connected > 0;
|
|
12
|
-
const repoHealth = currentRepo ? getRepoHealth(currentRepo) : null;
|
|
13
|
-
const leftParts = view === "project" && currentRepo
|
|
14
|
-
? compact && currentRepo.repoId
|
|
15
|
-
? `ms ${currentRepo.repoId} ${repoHealth?.label ?? ""} a:${currentRepo.snapshot?.summary.active ?? 0} h:${currentRepo.snapshot?.summary.headPrNumber ?? "?"}`
|
|
16
|
-
: [
|
|
17
|
-
"merge-steward dashboard",
|
|
18
|
-
currentRepo.repoId,
|
|
19
|
-
repoHealth?.label ?? null,
|
|
20
|
-
currentRepo.snapshot ? `${currentRepo.snapshot.summary.active} active` : null,
|
|
21
|
-
currentRepo.snapshot?.summary.headPrNumber ? `head #${currentRepo.snapshot.summary.headPrNumber}` : "no head PR",
|
|
22
|
-
`filter:${filter}`,
|
|
23
|
-
].filter(Boolean).join(" | ")
|
|
24
|
-
: compact
|
|
25
|
-
? `${cluster.total}p ${cluster.connected}c ${cluster.active}a ${cluster.blocked}b`
|
|
26
|
-
: [
|
|
27
|
-
"merge-steward dashboard",
|
|
28
|
-
`${cluster.total} projects`,
|
|
29
|
-
`${cluster.connected} connected`,
|
|
30
|
-
`${cluster.active} active`,
|
|
31
|
-
`${cluster.blocked} blocked`,
|
|
32
|
-
`${cluster.stuck} stuck`,
|
|
33
|
-
`${cluster.attention} need attention`,
|
|
34
|
-
].join(" | ");
|
|
35
|
-
const availableLeft = compact ? Math.max(1, width - 14) : Math.max(1, width - 28);
|
|
36
|
-
return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: truncate(leftParts, availableLeft) }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: expectedFreshMs, gatewayError: gatewayError })] }));
|
|
4
|
+
const EXPECTED_FRESH_MS = 3_000;
|
|
5
|
+
export function StatusBar({ connected, lastSnapshotReceivedAt, gatewayError }) {
|
|
6
|
+
return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { bold: true, children: "merge-steward" }), _jsx(FreshnessBadge, { connected: connected, lastSnapshotReceivedAt: lastSnapshotReceivedAt, expectedFreshMs: EXPECTED_FRESH_MS, gatewayError: gatewayError })] }));
|
|
37
7
|
}
|
|
@@ -12,31 +12,42 @@ export interface DashboardRepoState extends DashboardRepoConfig {
|
|
|
12
12
|
error: string | null;
|
|
13
13
|
lastSnapshotReceivedAt: number | null;
|
|
14
14
|
}
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
export type DashboardTokenColor = "red" | "yellow" | "green" | "gray" | "white";
|
|
16
|
+
export type DashboardTokenKind = "running" | "queued" | "approved" | "declined" | "error" | "cancelled" | "superseded";
|
|
17
|
+
export interface DashboardToken {
|
|
18
|
+
prNumber: number;
|
|
19
|
+
glyph: string;
|
|
20
|
+
color: DashboardTokenColor;
|
|
21
|
+
kind: DashboardTokenKind;
|
|
20
22
|
}
|
|
21
|
-
export interface
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
stuck: number;
|
|
26
|
-
attention: number;
|
|
27
|
-
active: number;
|
|
23
|
+
export interface DashboardPrEntry extends DashboardToken {
|
|
24
|
+
phrase: string;
|
|
25
|
+
summary?: string;
|
|
26
|
+
eventAt: number;
|
|
28
27
|
}
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
28
|
+
export interface DashboardRepo {
|
|
29
|
+
repoId: string;
|
|
30
|
+
repoFullName: string;
|
|
31
|
+
tokens: DashboardToken[];
|
|
32
|
+
entries: DashboardPrEntry[];
|
|
33
|
+
latestActivityAt: number;
|
|
34
|
+
hasActivity: boolean;
|
|
35
|
+
offlineMessage: string | null;
|
|
36
|
+
}
|
|
37
|
+
export interface DashboardModel {
|
|
38
|
+
repos: DashboardRepo[];
|
|
39
|
+
quietCount: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function buildDashboard(repos: DashboardRepoState[], opts?: {
|
|
42
|
+
windowMs?: number;
|
|
43
|
+
now?: number;
|
|
44
|
+
}): DashboardModel;
|
|
45
|
+
export declare function clipSummary(summary: string | undefined, opts?: {
|
|
46
|
+
maxLines?: number;
|
|
47
|
+
width?: number;
|
|
42
48
|
}): string;
|
|
49
|
+
export declare function repoSelector(repos: DashboardRepo[], selected: string | null): string | null;
|
|
50
|
+
export declare function stepRepo(repos: DashboardRepo[], selected: string | null, direction: 1 | -1): string | null;
|
|
51
|
+
export declare function findRepo(repos: DashboardRepo[], selected: string | null): DashboardRepo | null;
|
|
52
|
+
export declare function matchRepoRef(repo: DashboardRepoConfig, repoRef: string | undefined): boolean;
|
|
53
|
+
export declare function buildQueueSummary(entries: QueueEntry[]): QueueWatchSnapshot["summary"];
|
|
@@ -1,40 +1,293 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
const
|
|
4
|
-
const
|
|
5
|
-
|
|
1
|
+
import { TERMINAL_STATUSES } from "../types.js";
|
|
2
|
+
import { mergeWaitState } from "./format.js";
|
|
3
|
+
const DECIDED_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const GLYPH = {
|
|
5
|
+
running: "\u25cf",
|
|
6
|
+
queued: "\u25cb",
|
|
7
|
+
approved: "\u2713",
|
|
8
|
+
declined: "\u2717",
|
|
9
|
+
error: "\u26a0",
|
|
10
|
+
cancelled: "\u2013",
|
|
11
|
+
superseded: "\u21bb",
|
|
12
|
+
};
|
|
13
|
+
const COLOR = {
|
|
14
|
+
running: "yellow",
|
|
15
|
+
queued: "gray",
|
|
16
|
+
approved: "green",
|
|
17
|
+
declined: "red",
|
|
18
|
+
error: "red",
|
|
19
|
+
cancelled: "gray",
|
|
20
|
+
superseded: "gray",
|
|
21
|
+
};
|
|
22
|
+
function timestamp(value) {
|
|
23
|
+
if (!value)
|
|
24
|
+
return 0;
|
|
25
|
+
const ms = new Date(value).getTime();
|
|
26
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
27
|
+
}
|
|
6
28
|
function isLaterEntry(left, right) {
|
|
7
29
|
if (left.position !== right.position) {
|
|
8
30
|
return left.position > right.position;
|
|
9
31
|
}
|
|
10
|
-
return
|
|
32
|
+
return timestamp(left.updatedAt) >= timestamp(right.updatedAt);
|
|
11
33
|
}
|
|
12
|
-
function
|
|
13
|
-
if (!snapshot) {
|
|
14
|
-
return [];
|
|
15
|
-
}
|
|
34
|
+
function pickLatestPerPR(entries) {
|
|
16
35
|
const byPR = new Map();
|
|
17
|
-
for (const entry of
|
|
36
|
+
for (const entry of entries) {
|
|
18
37
|
const existing = byPR.get(entry.prNumber);
|
|
19
38
|
if (!existing || isLaterEntry(entry, existing)) {
|
|
20
39
|
byPR.set(entry.prNumber, entry);
|
|
21
40
|
}
|
|
22
41
|
}
|
|
23
|
-
return [...byPR.values()]
|
|
42
|
+
return [...byPR.values()];
|
|
24
43
|
}
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
function entryKind(entry) {
|
|
45
|
+
switch (entry.status) {
|
|
46
|
+
case "queued":
|
|
47
|
+
return "queued";
|
|
48
|
+
case "preparing_head":
|
|
49
|
+
case "validating":
|
|
50
|
+
case "merging":
|
|
51
|
+
return "running";
|
|
52
|
+
case "merged":
|
|
53
|
+
if (entry.postMergeStatus === "fail")
|
|
54
|
+
return "declined";
|
|
55
|
+
if (entry.postMergeStatus === "pending")
|
|
56
|
+
return "running";
|
|
57
|
+
return "approved";
|
|
58
|
+
case "evicted":
|
|
59
|
+
return "error";
|
|
60
|
+
case "dequeued":
|
|
61
|
+
return "cancelled";
|
|
29
62
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
63
|
+
}
|
|
64
|
+
function entryPhrase(entry, opts) {
|
|
65
|
+
if (opts.isHead && opts.queueBlocked) {
|
|
66
|
+
return "main broken";
|
|
67
|
+
}
|
|
68
|
+
switch (entry.status) {
|
|
69
|
+
case "queued":
|
|
70
|
+
return opts.isHead ? "queued" : "behind head";
|
|
71
|
+
case "preparing_head":
|
|
72
|
+
return entry.lastFailedBaseSha ? "has conflicts" : "preparing";
|
|
73
|
+
case "validating":
|
|
74
|
+
return "testing";
|
|
75
|
+
case "merging": {
|
|
76
|
+
const wait = mergeWaitState({ status: entry.status, waitDetail: entry.waitDetail });
|
|
77
|
+
if (wait === "approval")
|
|
78
|
+
return "waiting for approval";
|
|
79
|
+
if (wait === "main")
|
|
80
|
+
return "waiting for main";
|
|
81
|
+
return "merging";
|
|
33
82
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
83
|
+
case "merged":
|
|
84
|
+
if (entry.postMergeStatus === "fail")
|
|
85
|
+
return "post-merge failed";
|
|
86
|
+
if (entry.postMergeStatus === "pending")
|
|
87
|
+
return "merged, post-merge pending";
|
|
88
|
+
return "merged";
|
|
89
|
+
case "evicted":
|
|
90
|
+
return "evicted";
|
|
91
|
+
case "dequeued":
|
|
92
|
+
return "dequeued";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function entrySummary(entry) {
|
|
96
|
+
if (entry.status === "merged" && entry.postMergeStatus === "fail" && entry.postMergeSummary) {
|
|
97
|
+
return entry.postMergeSummary;
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
function isActive(status) {
|
|
102
|
+
return !TERMINAL_STATUSES.includes(status);
|
|
103
|
+
}
|
|
104
|
+
function tokenSortOrder(kind) {
|
|
105
|
+
switch (kind) {
|
|
106
|
+
case "running":
|
|
107
|
+
return 0;
|
|
108
|
+
case "queued":
|
|
109
|
+
return 1;
|
|
110
|
+
case "approved":
|
|
111
|
+
case "declined":
|
|
112
|
+
case "error":
|
|
113
|
+
return 2;
|
|
114
|
+
case "cancelled":
|
|
115
|
+
case "superseded":
|
|
116
|
+
return 3;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function overrideKind(kind, override) {
|
|
120
|
+
if (override === "main_broken")
|
|
121
|
+
return "error";
|
|
122
|
+
return kind;
|
|
123
|
+
}
|
|
124
|
+
function repoEntriesFromSnapshot(snapshot, cutoff) {
|
|
125
|
+
const latest = pickLatestPerPR(snapshot.entries);
|
|
126
|
+
const head = latest
|
|
127
|
+
.filter((entry) => isActive(entry.status))
|
|
128
|
+
.sort((a, b) => a.position - b.position)[0] ?? null;
|
|
129
|
+
const queueBlocked = Boolean(snapshot.queueBlock);
|
|
130
|
+
const byPr = new Map();
|
|
131
|
+
for (const entry of latest) {
|
|
132
|
+
const active = isActive(entry.status);
|
|
133
|
+
if (!active && timestamp(entry.updatedAt) < cutoff)
|
|
134
|
+
continue;
|
|
135
|
+
if (entry.status === "dequeued")
|
|
136
|
+
continue;
|
|
137
|
+
const isHead = head !== null && entry.id === head.id;
|
|
138
|
+
const rawKind = entryKind(entry);
|
|
139
|
+
const kind = overrideKind(rawKind, isHead && queueBlocked ? "main_broken" : null);
|
|
140
|
+
const glyph = GLYPH[kind];
|
|
141
|
+
const color = COLOR[kind];
|
|
142
|
+
const phrase = entryPhrase(entry, { isHead, queueBlocked });
|
|
143
|
+
const item = {
|
|
144
|
+
prNumber: entry.prNumber,
|
|
145
|
+
glyph,
|
|
146
|
+
color,
|
|
147
|
+
kind,
|
|
148
|
+
phrase,
|
|
149
|
+
eventAt: timestamp(entry.updatedAt),
|
|
150
|
+
};
|
|
151
|
+
const summary = entrySummary(entry);
|
|
152
|
+
if (summary)
|
|
153
|
+
item.summary = summary;
|
|
154
|
+
byPr.set(entry.prNumber, item);
|
|
155
|
+
}
|
|
156
|
+
return [...byPr.values()].sort((left, right) => {
|
|
157
|
+
const leftOrder = tokenSortOrder(left.kind);
|
|
158
|
+
const rightOrder = tokenSortOrder(right.kind);
|
|
159
|
+
if (leftOrder !== rightOrder)
|
|
160
|
+
return leftOrder - rightOrder;
|
|
161
|
+
if (left.eventAt !== right.eventAt)
|
|
162
|
+
return right.eventAt - left.eventAt;
|
|
163
|
+
return right.prNumber - left.prNumber;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
export function buildDashboard(repos, opts = {}) {
|
|
167
|
+
const windowMs = opts.windowMs ?? DECIDED_WINDOW_MS;
|
|
168
|
+
const now = opts.now ?? Date.now();
|
|
169
|
+
const cutoff = now - windowMs;
|
|
170
|
+
const mapped = repos.map((repo) => {
|
|
171
|
+
const snapshot = repo.snapshot;
|
|
172
|
+
if (!snapshot) {
|
|
173
|
+
const message = repo.serviceState === "initializing"
|
|
174
|
+
? repo.serviceMessage ?? "initializing"
|
|
175
|
+
: repo.serviceState === "failed"
|
|
176
|
+
? repo.serviceMessage ?? "init failed"
|
|
177
|
+
: repo.error ?? "offline";
|
|
178
|
+
return {
|
|
179
|
+
repoId: repo.repoId,
|
|
180
|
+
repoFullName: repo.repoFullName,
|
|
181
|
+
tokens: [],
|
|
182
|
+
entries: [],
|
|
183
|
+
latestActivityAt: 0,
|
|
184
|
+
hasActivity: false,
|
|
185
|
+
offlineMessage: message,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const entries = repoEntriesFromSnapshot(snapshot, cutoff);
|
|
189
|
+
const latestActivityAt = entries.reduce((max, entry) => Math.max(max, entry.eventAt), 0);
|
|
190
|
+
return {
|
|
191
|
+
repoId: repo.repoId,
|
|
192
|
+
repoFullName: repo.repoFullName,
|
|
193
|
+
tokens: entries.map(({ prNumber, glyph, color, kind }) => ({ prNumber, glyph, color, kind })),
|
|
194
|
+
entries,
|
|
195
|
+
latestActivityAt,
|
|
196
|
+
hasActivity: entries.length > 0,
|
|
197
|
+
offlineMessage: null,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
const active = mapped.filter((repo) => repo.hasActivity || repo.offlineMessage);
|
|
201
|
+
active.sort((left, right) => {
|
|
202
|
+
if (left.offlineMessage !== null && right.offlineMessage === null)
|
|
203
|
+
return 1;
|
|
204
|
+
if (right.offlineMessage !== null && left.offlineMessage === null)
|
|
205
|
+
return -1;
|
|
206
|
+
const leftHasActive = left.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
|
|
207
|
+
const rightHasActive = right.entries.some((entry) => entry.kind === "running" || entry.kind === "queued");
|
|
208
|
+
if (leftHasActive !== rightHasActive)
|
|
209
|
+
return leftHasActive ? -1 : 1;
|
|
210
|
+
if (left.latestActivityAt !== right.latestActivityAt)
|
|
211
|
+
return right.latestActivityAt - left.latestActivityAt;
|
|
212
|
+
return left.repoFullName.localeCompare(right.repoFullName);
|
|
213
|
+
});
|
|
214
|
+
const quietCount = mapped.length - active.length;
|
|
215
|
+
return { repos: active, quietCount };
|
|
216
|
+
}
|
|
217
|
+
export function clipSummary(summary, opts = {}) {
|
|
218
|
+
if (!summary)
|
|
219
|
+
return "";
|
|
220
|
+
const maxLines = opts.maxLines ?? 3;
|
|
221
|
+
const width = Math.max(20, opts.width ?? 80);
|
|
222
|
+
const collapsed = summary.replace(/\s+/g, " ").trim();
|
|
223
|
+
if (collapsed.length === 0)
|
|
224
|
+
return "";
|
|
225
|
+
const sentenceRe = /[^.!?]+[.!?]+(?:\s|$)|[^.!?]+$/g;
|
|
226
|
+
const sentences = (collapsed.match(sentenceRe) ?? [collapsed])
|
|
227
|
+
.map((value) => value.trim())
|
|
228
|
+
.filter((value) => value.length > 0);
|
|
229
|
+
let buffer = "";
|
|
230
|
+
for (const sentence of sentences) {
|
|
231
|
+
const candidate = buffer.length === 0 ? sentence : `${buffer} ${sentence}`;
|
|
232
|
+
if (wrapLines(candidate, width).length <= maxLines) {
|
|
233
|
+
buffer = candidate;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (buffer.length === 0) {
|
|
240
|
+
const first = sentences[0] ?? collapsed;
|
|
241
|
+
return wrapLines(first, width).slice(0, maxLines).join("\n");
|
|
242
|
+
}
|
|
243
|
+
return wrapLines(buffer, width).slice(0, maxLines).join("\n");
|
|
244
|
+
}
|
|
245
|
+
function wrapLines(text, width) {
|
|
246
|
+
const words = text.split(/\s+/).filter((word) => word.length > 0);
|
|
247
|
+
const lines = [];
|
|
248
|
+
let current = "";
|
|
249
|
+
for (const word of words) {
|
|
250
|
+
if (current.length === 0) {
|
|
251
|
+
current = word;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (current.length + 1 + word.length <= width) {
|
|
255
|
+
current = `${current} ${word}`;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
lines.push(current);
|
|
259
|
+
current = word;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (current.length > 0)
|
|
263
|
+
lines.push(current);
|
|
264
|
+
return lines.length === 0 ? [""] : lines;
|
|
265
|
+
}
|
|
266
|
+
export function repoSelector(repos, selected) {
|
|
267
|
+
if (repos.length === 0)
|
|
268
|
+
return null;
|
|
269
|
+
if (selected && repos.some((repo) => repo.repoId === selected)) {
|
|
270
|
+
return selected;
|
|
271
|
+
}
|
|
272
|
+
return repos[0]?.repoId ?? null;
|
|
273
|
+
}
|
|
274
|
+
export function stepRepo(repos, selected, direction) {
|
|
275
|
+
if (repos.length === 0)
|
|
276
|
+
return null;
|
|
277
|
+
const index = repos.findIndex((repo) => repo.repoId === selected);
|
|
278
|
+
const currentIndex = index === -1 ? 0 : index;
|
|
279
|
+
const nextIndex = (currentIndex + direction + repos.length) % repos.length;
|
|
280
|
+
return repos[nextIndex]?.repoId ?? null;
|
|
281
|
+
}
|
|
282
|
+
export function findRepo(repos, selected) {
|
|
283
|
+
if (!selected)
|
|
284
|
+
return repos[0] ?? null;
|
|
285
|
+
return repos.find((repo) => repo.repoId === selected) ?? repos[0] ?? null;
|
|
286
|
+
}
|
|
287
|
+
export function matchRepoRef(repo, repoRef) {
|
|
288
|
+
if (!repoRef)
|
|
289
|
+
return false;
|
|
290
|
+
return repo.repoId === repoRef || repo.repoFullName === repoRef;
|
|
38
291
|
}
|
|
39
292
|
export function buildQueueSummary(entries) {
|
|
40
293
|
const summary = {
|
|
@@ -50,7 +303,7 @@ export function buildQueueSummary(entries) {
|
|
|
50
303
|
headEntryId: null,
|
|
51
304
|
headPrNumber: null,
|
|
52
305
|
};
|
|
53
|
-
const activeEntries = entries.filter((entry) => !
|
|
306
|
+
const activeEntries = entries.filter((entry) => !TERMINAL_STATUSES.includes(entry.status));
|
|
54
307
|
if (activeEntries.length > 0) {
|
|
55
308
|
summary.headEntryId = activeEntries[0].id;
|
|
56
309
|
summary.headPrNumber = activeEntries[0].prNumber;
|
|
@@ -83,218 +336,3 @@ export function buildQueueSummary(entries) {
|
|
|
83
336
|
}
|
|
84
337
|
return summary;
|
|
85
338
|
}
|
|
86
|
-
export function matchRepoRef(repo, repoRef) {
|
|
87
|
-
if (!repoRef) {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
return repo.repoId === repoRef || repo.repoFullName === repoRef;
|
|
91
|
-
}
|
|
92
|
-
export function getVisibleEntries(snapshot, filter) {
|
|
93
|
-
if (!snapshot) {
|
|
94
|
-
return [];
|
|
95
|
-
}
|
|
96
|
-
return buildDisplayEntries(snapshot.entries, filter);
|
|
97
|
-
}
|
|
98
|
-
export function getChainEntries(snapshot) {
|
|
99
|
-
if (!snapshot) {
|
|
100
|
-
return [];
|
|
101
|
-
}
|
|
102
|
-
return buildChainEntries(snapshot.entries);
|
|
103
|
-
}
|
|
104
|
-
export function getActiveEntries(snapshot) {
|
|
105
|
-
return getLatestEntries(snapshot).filter((entry) => (entry.status !== "merged" && entry.status !== "evicted" && entry.status !== "dequeued"));
|
|
106
|
-
}
|
|
107
|
-
export function getDefaultEntryId(snapshot, filter, preferredPrNumber) {
|
|
108
|
-
if (!snapshot) {
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
const visibleEntries = getVisibleEntries(snapshot, filter);
|
|
112
|
-
if (preferredPrNumber !== undefined && preferredPrNumber !== null) {
|
|
113
|
-
const match = visibleEntries.find((entry) => entry.prNumber === preferredPrNumber)
|
|
114
|
-
?? snapshot.entries.find((entry) => entry.prNumber === preferredPrNumber);
|
|
115
|
-
if (match) {
|
|
116
|
-
return match.id;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return snapshot.summary.headEntryId ?? visibleEntries[0]?.id ?? null;
|
|
120
|
-
}
|
|
121
|
-
export function getRepoHealth(repo, now = Date.now()) {
|
|
122
|
-
const snapshot = repo.snapshot;
|
|
123
|
-
const staleForMs = repo.lastSnapshotReceivedAt === null ? Infinity : now - repo.lastSnapshotReceivedAt;
|
|
124
|
-
if (repo.serviceState === "initializing") {
|
|
125
|
-
return {
|
|
126
|
-
kind: "initializing",
|
|
127
|
-
label: "Initializing",
|
|
128
|
-
color: "cyan",
|
|
129
|
-
detail: repo.serviceMessage ?? "Loading GitHub policy and local clone state.",
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
if (repo.serviceState === "failed") {
|
|
133
|
-
return {
|
|
134
|
-
kind: "failed",
|
|
135
|
-
label: "Init failed",
|
|
136
|
-
color: "red",
|
|
137
|
-
detail: repo.serviceMessage ?? "Repo initialization failed in merge-steward.",
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
if (!snapshot || (repo.error && staleForMs > OFFLINE_STALE_MS)) {
|
|
141
|
-
return {
|
|
142
|
-
kind: "offline",
|
|
143
|
-
label: "Offline",
|
|
144
|
-
color: "red",
|
|
145
|
-
detail: repo.error ?? "No queue data from merge-steward yet.",
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
if (snapshot.queueBlock) {
|
|
149
|
-
return {
|
|
150
|
-
kind: "blocked",
|
|
151
|
-
label: "Blocked",
|
|
152
|
-
color: "yellow",
|
|
153
|
-
detail: summarizeQueueBlock(snapshot.queueBlock) ?? `Waiting for ${snapshot.baseBranch} to recover.`,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
if (snapshot.runtime.lastTickOutcome === "failed") {
|
|
157
|
-
return {
|
|
158
|
-
kind: "stuck",
|
|
159
|
-
label: "Stuck",
|
|
160
|
-
color: "red",
|
|
161
|
-
detail: snapshot.runtime.lastTickError?.split(/\r?\n/, 1)[0] ?? "The last reconcile tick failed.",
|
|
162
|
-
};
|
|
163
|
-
}
|
|
164
|
-
const activeEntries = getActiveEntries(snapshot);
|
|
165
|
-
const headEntry = activeEntries[0];
|
|
166
|
-
if (headEntry) {
|
|
167
|
-
const ageMs = now - new Date(headEntry.updatedAt).getTime();
|
|
168
|
-
if (ageMs >= STUCK_ENTRY_MS) {
|
|
169
|
-
return {
|
|
170
|
-
kind: "stuck",
|
|
171
|
-
label: "Stuck",
|
|
172
|
-
color: "red",
|
|
173
|
-
detail: `Head PR #${headEntry.prNumber} has not moved for ${relativeTime(headEntry.updatedAt)}.`,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
const recentEvicted = getLatestEntries(snapshot).find((entry) => (entry.status === "evicted"
|
|
178
|
-
&& now - new Date(entry.updatedAt).getTime() <= RECENT_EVICTION_MS));
|
|
179
|
-
if (recentEvicted) {
|
|
180
|
-
return {
|
|
181
|
-
kind: "attention",
|
|
182
|
-
label: "Needs attention",
|
|
183
|
-
color: "yellow",
|
|
184
|
-
detail: `PR #${recentEvicted.prNumber} was evicted and needs repair before it can rejoin the queue.`,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
if (activeEntries.length > 0) {
|
|
188
|
-
return {
|
|
189
|
-
kind: "active",
|
|
190
|
-
label: "Running",
|
|
191
|
-
color: "cyan",
|
|
192
|
-
detail: headEntry
|
|
193
|
-
? `Head PR #${headEntry.prNumber} is ${humanStatus(headEntry.status, headEntry)}.`
|
|
194
|
-
: `${activeEntries.length} PRs are active in the queue.`,
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
return {
|
|
198
|
-
kind: "idle",
|
|
199
|
-
label: "Idle",
|
|
200
|
-
color: "green",
|
|
201
|
-
detail: snapshot.summary.total > 0
|
|
202
|
-
? `No active queue work right now. Last activity was ${relativeTime(getMostRecentActivityAt(snapshot))} ago.`
|
|
203
|
-
: "No pull requests are queued right now.",
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
export function getClusterSummary(repos, now = Date.now()) {
|
|
207
|
-
const initial = {
|
|
208
|
-
total: repos.length,
|
|
209
|
-
connected: 0,
|
|
210
|
-
blocked: 0,
|
|
211
|
-
stuck: 0,
|
|
212
|
-
attention: 0,
|
|
213
|
-
active: 0,
|
|
214
|
-
};
|
|
215
|
-
return repos.reduce((summary, repo) => {
|
|
216
|
-
const health = getRepoHealth(repo, now);
|
|
217
|
-
if (health.kind !== "offline") {
|
|
218
|
-
summary.connected += 1;
|
|
219
|
-
}
|
|
220
|
-
if (health.kind === "blocked") {
|
|
221
|
-
summary.blocked += 1;
|
|
222
|
-
}
|
|
223
|
-
if (health.kind === "stuck") {
|
|
224
|
-
summary.stuck += 1;
|
|
225
|
-
}
|
|
226
|
-
if (health.kind === "attention") {
|
|
227
|
-
summary.attention += 1;
|
|
228
|
-
}
|
|
229
|
-
if (repo.snapshot && getActiveEntries(repo.snapshot).length > 0) {
|
|
230
|
-
summary.active += 1;
|
|
231
|
-
}
|
|
232
|
-
return summary;
|
|
233
|
-
}, initial);
|
|
234
|
-
}
|
|
235
|
-
export function projectStatsSummary(snapshot, compact = false) {
|
|
236
|
-
if (!snapshot) {
|
|
237
|
-
return "No queue data yet.";
|
|
238
|
-
}
|
|
239
|
-
const latestEntries = getLatestEntries(snapshot);
|
|
240
|
-
const activeEntries = getActiveEntries(snapshot);
|
|
241
|
-
const avgWaitMs = activeEntries.length > 0
|
|
242
|
-
? activeEntries.reduce((sum, entry) => sum + (Date.now() - new Date(entry.enqueuedAt).getTime()), 0) / activeEntries.length
|
|
243
|
-
: 0;
|
|
244
|
-
const queued = latestEntries.filter((entry) => entry.status === "queued").length;
|
|
245
|
-
const validating = latestEntries.filter((entry) => entry.status === "validating" || entry.status === "preparing_head").length;
|
|
246
|
-
const merging = latestEntries.filter((entry) => entry.status === "merging").length;
|
|
247
|
-
const evicted = latestEntries.filter((entry) => entry.status === "evicted").length;
|
|
248
|
-
const parts = compact
|
|
249
|
-
? [
|
|
250
|
-
`${activeEntries.length}a`,
|
|
251
|
-
`${queued}w`,
|
|
252
|
-
`${validating}t`,
|
|
253
|
-
`${merging}m`,
|
|
254
|
-
]
|
|
255
|
-
: [
|
|
256
|
-
`${activeEntries.length} active`,
|
|
257
|
-
`${queued} waiting`,
|
|
258
|
-
`${validating} testing`,
|
|
259
|
-
`${merging} merging`,
|
|
260
|
-
];
|
|
261
|
-
if (evicted > 0) {
|
|
262
|
-
parts.push(compact ? `${evicted}x` : `${evicted} need repair`);
|
|
263
|
-
}
|
|
264
|
-
if (avgWaitMs > 0) {
|
|
265
|
-
parts.push(compact ? `w ${formatDuration(avgWaitMs)}` : `avg wait ${formatDuration(avgWaitMs)}`);
|
|
266
|
-
}
|
|
267
|
-
return parts.join(" · ");
|
|
268
|
-
}
|
|
269
|
-
export function runtimeSummary(snapshot) {
|
|
270
|
-
if (!snapshot) {
|
|
271
|
-
return "No runtime data yet.";
|
|
272
|
-
}
|
|
273
|
-
if (snapshot.runtime.tickInProgress) {
|
|
274
|
-
return `Reconcile tick is running now. Last completed ${relativeTime(snapshot.runtime.lastTickCompletedAt)} ago.`;
|
|
275
|
-
}
|
|
276
|
-
if (snapshot.runtime.lastTickOutcome === "failed") {
|
|
277
|
-
return `Last reconcile tick failed ${relativeTime(snapshot.runtime.lastTickCompletedAt ?? snapshot.runtime.lastTickStartedAt)} ago.`;
|
|
278
|
-
}
|
|
279
|
-
if (snapshot.runtime.lastTickCompletedAt) {
|
|
280
|
-
return `Last reconcile tick ${snapshot.runtime.lastTickOutcome} ${relativeTime(snapshot.runtime.lastTickCompletedAt)} ago.`;
|
|
281
|
-
}
|
|
282
|
-
return "No reconcile tick has completed yet.";
|
|
283
|
-
}
|
|
284
|
-
export function describeEntry(entry, options) {
|
|
285
|
-
if (options.isHead && options.queueBlockSummary) {
|
|
286
|
-
return options.queueBlockSummary;
|
|
287
|
-
}
|
|
288
|
-
if (entry.status === "merged") {
|
|
289
|
-
return `Landed on main. ${postMergeStatusLine(entry)}`
|
|
290
|
-
+ (entry.postMergeCheckedAt ? ` (${relativeTime(entry.postMergeCheckedAt)} ago).` : ".");
|
|
291
|
-
}
|
|
292
|
-
if (entry.status === "queued") {
|
|
293
|
-
if (entry.position > 1) {
|
|
294
|
-
return `Waiting behind ${entry.position - 1} earlier PRs.`;
|
|
295
|
-
}
|
|
296
|
-
return "First in line and waiting for the next reconcile tick.";
|
|
297
|
-
}
|
|
298
|
-
const nextStep = nextStepLabel(entry.status, entry);
|
|
299
|
-
return `${humanStatus(entry.status, entry)}: ${nextStep}.`;
|
|
300
|
-
}
|