merge-steward 0.10.0 → 0.10.2
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/README.md +1 -0
- package/dist/cli/commands/service.js +8 -2
- package/dist/cli/help.js +2 -2
- package/dist/cli/system.d.ts +8 -0
- package/dist/cli/system.js +29 -1
- package/dist/watch/dashboard-model.js +47 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -258,6 +258,7 @@ These are the first commands to reach for after setup or when a queue looks wron
|
|
|
258
258
|
```bash
|
|
259
259
|
merge-steward doctor --repo app
|
|
260
260
|
merge-steward service status
|
|
261
|
+
merge-steward service restart
|
|
261
262
|
merge-steward dashboard
|
|
262
263
|
merge-steward queue status --repo app
|
|
263
264
|
merge-steward queue show --repo app --pr 123
|
|
@@ -2,7 +2,7 @@ import { installServiceUnit } from "../../install.js";
|
|
|
2
2
|
import { UsageError } from "../types.js";
|
|
3
3
|
import { parseIntegerFlag } from "../args.js";
|
|
4
4
|
import { formatJson, writeOutput } from "../output.js";
|
|
5
|
-
import { formatCommandFailure, parseSystemctlShowOutput, runSystemctl } from "../system.js";
|
|
5
|
+
import { fetchServiceHealthStatus, formatCommandFailure, parseSystemctlShowOutput, runSystemctl } from "../system.js";
|
|
6
6
|
const UNIT_NAME = "merge-steward.service";
|
|
7
7
|
export async function handleService(parsed, stdout, runCommand) {
|
|
8
8
|
const subcommand = parsed.positionals[1];
|
|
@@ -31,6 +31,8 @@ export async function handleService(parsed, stdout, runCommand) {
|
|
|
31
31
|
const restart = await runSystemctl(runCommand, ["reload-or-restart", UNIT_NAME]);
|
|
32
32
|
if (parsed.flags.get("json") === true) {
|
|
33
33
|
writeOutput(stdout, formatJson({
|
|
34
|
+
service: "merge-steward",
|
|
35
|
+
unit: UNIT_NAME,
|
|
34
36
|
daemonReloaded: daemonReload.ok,
|
|
35
37
|
restarted: restart.ok,
|
|
36
38
|
errors: [
|
|
@@ -55,8 +57,9 @@ export async function handleService(parsed, stdout, runCommand) {
|
|
|
55
57
|
throw new Error(status.error);
|
|
56
58
|
}
|
|
57
59
|
const properties = parseSystemctlShowOutput(status.result.stdout);
|
|
60
|
+
const health = await fetchServiceHealthStatus();
|
|
58
61
|
if (parsed.flags.get("json") === true) {
|
|
59
|
-
writeOutput(stdout, formatJson({ unit: UNIT_NAME, systemd: properties }));
|
|
62
|
+
writeOutput(stdout, formatJson({ service: "merge-steward", unit: UNIT_NAME, systemd: properties, health }));
|
|
60
63
|
return 0;
|
|
61
64
|
}
|
|
62
65
|
writeOutput(stdout, [
|
|
@@ -65,6 +68,9 @@ export async function handleService(parsed, stdout, runCommand) {
|
|
|
65
68
|
`Enabled: ${properties.UnitFileState ?? "unknown"}`,
|
|
66
69
|
`Active: ${properties.ActiveState ?? "unknown"}${properties.SubState ? ` (${properties.SubState})` : ""}`,
|
|
67
70
|
properties.ExecMainPID ? `Main PID: ${properties.ExecMainPID}` : undefined,
|
|
71
|
+
health.reachable
|
|
72
|
+
? `Health: ${health.ok ? "ok" : "unhealthy"} (HTTP ${health.status})`
|
|
73
|
+
: `Health: not reachable (${health.error})`,
|
|
68
74
|
]
|
|
69
75
|
.filter(Boolean)
|
|
70
76
|
.join("\n") + "\n");
|
package/dist/cli/help.js
CHANGED
|
@@ -20,7 +20,7 @@ function rootHelpText() {
|
|
|
20
20
|
" repo list [--json] List attached repositories",
|
|
21
21
|
" repo show <id> [--json] Show one repo config",
|
|
22
22
|
" doctor [--repo <id>] [--json] Validate config, secrets, auth, and required binaries",
|
|
23
|
-
" service status [--json] Show systemd state",
|
|
23
|
+
" service status [--json] Show systemd state and local health",
|
|
24
24
|
" service logs [--lines <count>] [--json] Show recent journal logs",
|
|
25
25
|
" queue status --repo <id> [--json] Show queue summary and current entries",
|
|
26
26
|
" queue show --repo <id> (--entry <id> | --pr <num>) [--events <count>] [--json]",
|
|
@@ -83,7 +83,7 @@ function serviceHelpText() {
|
|
|
83
83
|
"Commands:",
|
|
84
84
|
" install [--force] [--json] Reinstall the systemd unit",
|
|
85
85
|
" restart [--json] Reload-or-restart the service",
|
|
86
|
-
" status [--json] Show systemd state",
|
|
86
|
+
" status [--json] Show systemd state and local health",
|
|
87
87
|
" logs [--lines <count>] [--json]",
|
|
88
88
|
" Show recent journal logs",
|
|
89
89
|
].join("\n");
|
package/dist/cli/system.d.ts
CHANGED
|
@@ -40,6 +40,14 @@ export declare function fetchGatewayJson<T>(relativePath: string, options?: {
|
|
|
40
40
|
method?: string;
|
|
41
41
|
body?: unknown;
|
|
42
42
|
}): Promise<T>;
|
|
43
|
+
export declare function fetchServiceHealthStatus(): Promise<{
|
|
44
|
+
reachable: true;
|
|
45
|
+
ok: boolean;
|
|
46
|
+
status: number;
|
|
47
|
+
} | {
|
|
48
|
+
reachable: false;
|
|
49
|
+
error: string;
|
|
50
|
+
}>;
|
|
43
51
|
export declare function fetchLocalJson<T>(repoId: string, relativePath: string, options?: {
|
|
44
52
|
method?: string;
|
|
45
53
|
}): Promise<T>;
|
package/dist/cli/system.js
CHANGED
|
@@ -161,7 +161,7 @@ export function getGatewayBaseUrl() {
|
|
|
161
161
|
throw new Error("merge-steward home not initialized.");
|
|
162
162
|
}
|
|
163
163
|
const homeConfig = parseHomeConfigObject(readFileSync(homeConfigPath, "utf8"), homeConfigPath);
|
|
164
|
-
const bind = homeConfig.server.bind;
|
|
164
|
+
const bind = homeConfig.server.bind === "0.0.0.0" ? "127.0.0.1" : homeConfig.server.bind;
|
|
165
165
|
const port = homeConfig.server.gateway_port ?? (homeConfig.server.port_base - 1);
|
|
166
166
|
return `http://${bind}:${port}`;
|
|
167
167
|
}
|
|
@@ -193,6 +193,34 @@ export async function fetchGatewayJson(relativePath, options) {
|
|
|
193
193
|
const base = getGatewayBaseUrl();
|
|
194
194
|
return await requestGatewayJson(`${base}${relativePath}`, options);
|
|
195
195
|
}
|
|
196
|
+
export async function fetchServiceHealthStatus() {
|
|
197
|
+
try {
|
|
198
|
+
const response = await fetch(`${getGatewayBaseUrl()}/health`, {
|
|
199
|
+
signal: AbortSignal.timeout(2_000),
|
|
200
|
+
});
|
|
201
|
+
let ok = response.ok;
|
|
202
|
+
try {
|
|
203
|
+
const body = await response.json();
|
|
204
|
+
if (typeof body.ok === "boolean") {
|
|
205
|
+
ok = response.ok && body.ok;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
ok = response.ok;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
reachable: true,
|
|
213
|
+
ok,
|
|
214
|
+
status: response.status,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
return {
|
|
219
|
+
reachable: false,
|
|
220
|
+
error: error instanceof Error ? error.message : String(error),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
196
224
|
export async function fetchLocalJson(repoId, relativePath, options) {
|
|
197
225
|
return await fetchGatewayJson(`/repos/${repoId}${relativePath}`, options);
|
|
198
226
|
}
|
|
@@ -3,6 +3,39 @@ import { formatDuration, humanStatus, nextStepLabel, relativeTime, summarizeQueu
|
|
|
3
3
|
const RECENT_EVICTION_MS = 60 * 60 * 1_000;
|
|
4
4
|
const STUCK_ENTRY_MS = 25 * 60 * 1_000;
|
|
5
5
|
const OFFLINE_STALE_MS = 10 * 1_000;
|
|
6
|
+
function isLaterEntry(left, right) {
|
|
7
|
+
if (left.position !== right.position) {
|
|
8
|
+
return left.position > right.position;
|
|
9
|
+
}
|
|
10
|
+
return new Date(left.updatedAt).getTime() >= new Date(right.updatedAt).getTime();
|
|
11
|
+
}
|
|
12
|
+
function getLatestEntries(snapshot) {
|
|
13
|
+
if (!snapshot) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const byPR = new Map();
|
|
17
|
+
for (const entry of snapshot.entries) {
|
|
18
|
+
const existing = byPR.get(entry.prNumber);
|
|
19
|
+
if (!existing || isLaterEntry(entry, existing)) {
|
|
20
|
+
byPR.set(entry.prNumber, entry);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return [...byPR.values()].sort((a, b) => a.position - b.position);
|
|
24
|
+
}
|
|
25
|
+
function getMostRecentActivityAt(snapshot) {
|
|
26
|
+
const latestEntries = getLatestEntries(snapshot);
|
|
27
|
+
if (latestEntries.length === 0) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
return latestEntries.reduce((latest, entry) => {
|
|
31
|
+
if (!latest) {
|
|
32
|
+
return entry.updatedAt;
|
|
33
|
+
}
|
|
34
|
+
return new Date(entry.updatedAt).getTime() > new Date(latest).getTime()
|
|
35
|
+
? entry.updatedAt
|
|
36
|
+
: latest;
|
|
37
|
+
}, undefined);
|
|
38
|
+
}
|
|
6
39
|
export function matchRepoRef(repo, repoRef) {
|
|
7
40
|
if (!repoRef) {
|
|
8
41
|
return false;
|
|
@@ -22,10 +55,7 @@ export function getChainEntries(snapshot) {
|
|
|
22
55
|
return buildChainEntries(snapshot.entries);
|
|
23
56
|
}
|
|
24
57
|
export function getActiveEntries(snapshot) {
|
|
25
|
-
|
|
26
|
-
return [];
|
|
27
|
-
}
|
|
28
|
-
return snapshot.entries.filter((entry) => (entry.status !== "merged" && entry.status !== "evicted" && entry.status !== "dequeued"));
|
|
58
|
+
return getLatestEntries(snapshot).filter((entry) => (entry.status !== "merged" && entry.status !== "evicted" && entry.status !== "dequeued"));
|
|
29
59
|
}
|
|
30
60
|
export function getDefaultEntryId(snapshot, filter, preferredPrNumber) {
|
|
31
61
|
if (!snapshot) {
|
|
@@ -81,7 +111,7 @@ export function getRepoHealth(repo, now = Date.now()) {
|
|
|
81
111
|
};
|
|
82
112
|
}
|
|
83
113
|
}
|
|
84
|
-
const recentEvicted = snapshot.
|
|
114
|
+
const recentEvicted = getLatestEntries(snapshot).find((entry) => (entry.status === "evicted"
|
|
85
115
|
&& now - new Date(entry.updatedAt).getTime() <= RECENT_EVICTION_MS));
|
|
86
116
|
if (recentEvicted) {
|
|
87
117
|
return {
|
|
@@ -106,7 +136,7 @@ export function getRepoHealth(repo, now = Date.now()) {
|
|
|
106
136
|
label: "Idle",
|
|
107
137
|
color: "green",
|
|
108
138
|
detail: snapshot.summary.total > 0
|
|
109
|
-
? `No active queue work right now. Last activity was ${relativeTime(snapshot
|
|
139
|
+
? `No active queue work right now. Last activity was ${relativeTime(getMostRecentActivityAt(snapshot))} ago.`
|
|
110
140
|
: "No pull requests are queued right now.",
|
|
111
141
|
};
|
|
112
142
|
}
|
|
@@ -143,19 +173,23 @@ export function projectStatsSummary(snapshot) {
|
|
|
143
173
|
if (!snapshot) {
|
|
144
174
|
return "No queue data yet.";
|
|
145
175
|
}
|
|
146
|
-
const
|
|
176
|
+
const latestEntries = getLatestEntries(snapshot);
|
|
147
177
|
const activeEntries = getActiveEntries(snapshot);
|
|
148
178
|
const avgWaitMs = activeEntries.length > 0
|
|
149
179
|
? activeEntries.reduce((sum, entry) => sum + (Date.now() - new Date(entry.enqueuedAt).getTime()), 0) / activeEntries.length
|
|
150
180
|
: 0;
|
|
181
|
+
const queued = latestEntries.filter((entry) => entry.status === "queued").length;
|
|
182
|
+
const validating = latestEntries.filter((entry) => entry.status === "validating" || entry.status === "preparing_head").length;
|
|
183
|
+
const merging = latestEntries.filter((entry) => entry.status === "merging").length;
|
|
184
|
+
const evicted = latestEntries.filter((entry) => entry.status === "evicted").length;
|
|
151
185
|
const parts = [
|
|
152
|
-
`${
|
|
153
|
-
`${
|
|
154
|
-
`${
|
|
155
|
-
`${
|
|
186
|
+
`${activeEntries.length} active`,
|
|
187
|
+
`${queued} waiting`,
|
|
188
|
+
`${validating} testing`,
|
|
189
|
+
`${merging} merging`,
|
|
156
190
|
];
|
|
157
|
-
if (
|
|
158
|
-
parts.push(`${
|
|
191
|
+
if (evicted > 0) {
|
|
192
|
+
parts.push(`${evicted} need repair`);
|
|
159
193
|
}
|
|
160
194
|
if (avgWaitMs > 0) {
|
|
161
195
|
parts.push(`avg wait ${formatDuration(avgWaitMs)}`);
|