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 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");
@@ -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>;
@@ -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
- if (!snapshot) {
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.entries.find((entry) => (entry.status === "evicted"
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.entries[0]?.updatedAt)} ago.`
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 { summary } = snapshot;
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
- `${summary.active} active`,
153
- `${summary.queued} waiting`,
154
- `${summary.validating} testing`,
155
- `${summary.merging} merging`,
186
+ `${activeEntries.length} active`,
187
+ `${queued} waiting`,
188
+ `${validating} testing`,
189
+ `${merging} merging`,
156
190
  ];
157
- if (summary.evicted > 0) {
158
- parts.push(`${summary.evicted} need repair`);
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)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {