merge-steward 0.14.2 → 0.15.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/README.md CHANGED
@@ -266,6 +266,7 @@ merge-steward doctor --repo app
266
266
  merge-steward service status
267
267
  merge-steward service restart
268
268
  merge-steward dashboard
269
+ merge-steward pr status # from a git checkout; resolves repo + PR automatically
269
270
  merge-steward queue status --repo app
270
271
  merge-steward queue show --repo app --pr 123
271
272
  merge-steward service logs --lines 100
@@ -275,10 +276,25 @@ Use them this way:
275
276
 
276
277
  - `doctor` checks config, GitHub auth, branch rules, and required checks.
277
278
  - `dashboard` is the best live operator view across all configured projects.
279
+ - `pr status` gives a single agent-friendly verdict on one PR (queue entry when it exists, GitHub state otherwise) with a stable exit code so scripts can chain with `&&`. Supports `--wait` to poll until a terminal state is reached.
278
280
  - `queue status` is the fastest text snapshot when you need one repo in a shell script or over SSH.
279
281
  - `queue show --pr <number>` is the most direct way to inspect one PR's queue events and incidents.
280
282
  - `service logs` helps when the queue is not reacting to webhooks, GitHub auth is failing, or reconcile ticks are erroring.
281
283
 
284
+ ### Resolving --repo and --pr from the current checkout
285
+
286
+ `pr status`, `queue status`, `queue show`, and `queue reconcile` accept `--repo` and `--pr` but you can omit them when running from inside a git checkout. `merge-steward` reads `origin`'s remote URL, matches it to an attached repoId, and uses `gh pr view` to find the PR for the current branch. Pass `--cwd <path>` to resolve from a different directory.
287
+
288
+ ### Exit codes (pr status)
289
+
290
+ | code | meaning |
291
+ |-|-|
292
+ | 0 | merged / approved with green required checks |
293
+ | 2 | changes_requested / failing required checks / evicted / closed |
294
+ | 3 | still in flight (queued, preparing, validating, merging, pending) |
295
+ | 4 | `--wait` timed out before a terminal state was reached |
296
+ | 1 | usage or configuration error |
297
+
282
298
  ### systemd
283
299
 
284
300
  ```ini
package/dist/cli/args.js CHANGED
@@ -100,19 +100,28 @@ export function validateFlags(parsed) {
100
100
  assertKnownFlags(parsed, "service", []);
101
101
  return;
102
102
  }
103
+ case "pr":
104
+ switch (subcommand) {
105
+ case "status":
106
+ assertKnownFlags(parsed, "root", ["repo", "pr", "cwd", "wait", "timeout", "poll", "json"]);
107
+ return;
108
+ default:
109
+ assertKnownFlags(parsed, "root", []);
110
+ return;
111
+ }
103
112
  case "queue":
104
113
  switch (subcommand) {
105
114
  case "dashboard":
106
115
  assertKnownFlags(parsed, "queue", ["repo", "pr"]);
107
116
  return;
108
117
  case "status":
109
- assertKnownFlags(parsed, "queue", ["repo", "events", "json"]);
118
+ assertKnownFlags(parsed, "queue", ["repo", "pr", "cwd", "events", "json"]);
110
119
  return;
111
120
  case "show":
112
- assertKnownFlags(parsed, "queue", ["repo", "entry", "pr", "events", "json"]);
121
+ assertKnownFlags(parsed, "queue", ["repo", "entry", "pr", "cwd", "events", "json"]);
113
122
  return;
114
123
  case "reconcile":
115
- assertKnownFlags(parsed, "queue", ["repo", "json"]);
124
+ assertKnownFlags(parsed, "queue", ["repo", "cwd", "json"]);
116
125
  return;
117
126
  default:
118
127
  assertKnownFlags(parsed, "queue", []);
@@ -0,0 +1,22 @@
1
+ import type { ResolveCommandRunner } from "../resolve.ts";
2
+ export interface PrGitHubCheck {
3
+ name: string;
4
+ status: "success" | "failure" | "pending";
5
+ required: boolean;
6
+ detailsUrl?: string;
7
+ }
8
+ export type PrGitHubState = "OPEN" | "CLOSED" | "MERGED";
9
+ export type PrGitHubReviewDecision = "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "COMMENTED" | "";
10
+ export interface PrGitHubOverview {
11
+ number: number;
12
+ branch: string;
13
+ headSha: string;
14
+ state: PrGitHubState;
15
+ merged: boolean;
16
+ reviewDecision: PrGitHubReviewDecision;
17
+ mergeStateStatus?: string;
18
+ labels: string[];
19
+ checks: PrGitHubCheck[];
20
+ }
21
+ export declare function parsePrView(raw: string): PrGitHubOverview;
22
+ export declare function fetchPrGitHubOverview(repoFullName: string, prNumber: number, runCommand: ResolveCommandRunner): Promise<PrGitHubOverview>;
@@ -0,0 +1,81 @@
1
+ function classifyCheck(node) {
2
+ const typename = node.__typename ?? "";
3
+ if (typename === "StatusContext") {
4
+ const state = (node.state ?? "").toUpperCase();
5
+ if (state === "SUCCESS" || state === "EXPECTED")
6
+ return "success";
7
+ if (state === "FAILURE" || state === "ERROR")
8
+ return "failure";
9
+ return "pending";
10
+ }
11
+ const status = (node.status ?? "").toUpperCase();
12
+ if (status !== "COMPLETED")
13
+ return "pending";
14
+ const conclusion = (node.conclusion ?? "").toUpperCase();
15
+ switch (conclusion) {
16
+ case "SUCCESS":
17
+ case "NEUTRAL":
18
+ case "SKIPPED":
19
+ return "success";
20
+ case "FAILURE":
21
+ case "CANCELLED":
22
+ case "TIMED_OUT":
23
+ case "STALE":
24
+ case "ACTION_REQUIRED":
25
+ return "failure";
26
+ default:
27
+ return "pending";
28
+ }
29
+ }
30
+ function deriveCheckName(node) {
31
+ if (typeof node.name === "string" && node.name)
32
+ return node.name;
33
+ if (typeof node.context === "string" && node.context)
34
+ return node.context;
35
+ return undefined;
36
+ }
37
+ export function parsePrView(raw) {
38
+ const data = JSON.parse(raw);
39
+ const state = (data.state ?? "OPEN").toUpperCase();
40
+ const reviewDecision = (data.reviewDecision ?? "").toUpperCase();
41
+ const labels = Array.isArray(data.labels) ? data.labels.map((label) => label.name).filter(Boolean) : [];
42
+ const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
43
+ const checks = rollup
44
+ .map((node) => {
45
+ const name = deriveCheckName(node);
46
+ if (!name)
47
+ return undefined;
48
+ const detailsUrl = node.detailsUrl ?? node.targetUrl;
49
+ return {
50
+ name,
51
+ status: classifyCheck(node),
52
+ required: node.isRequired === true,
53
+ ...(detailsUrl ? { detailsUrl } : {}),
54
+ };
55
+ })
56
+ .filter((c) => c !== undefined);
57
+ return {
58
+ number: data.number,
59
+ branch: data.headRefName,
60
+ headSha: data.headRefOid,
61
+ state,
62
+ merged: state === "MERGED",
63
+ reviewDecision,
64
+ ...(data.mergeStateStatus ? { mergeStateStatus: data.mergeStateStatus } : {}),
65
+ labels,
66
+ checks,
67
+ };
68
+ }
69
+ export async function fetchPrGitHubOverview(repoFullName, prNumber, runCommand) {
70
+ const result = await runCommand("gh", [
71
+ "pr", "view", String(prNumber),
72
+ "--repo", repoFullName,
73
+ "--json",
74
+ "number,headRefName,headRefOid,state,reviewDecision,mergeStateStatus,labels,statusCheckRollup",
75
+ ]);
76
+ if (result.exitCode !== 0) {
77
+ const message = result.stderr.trim() || result.stdout.trim() || `gh pr view exited with ${result.exitCode}`;
78
+ throw new Error(`Unable to fetch PR #${prNumber} from GitHub: ${message}`);
79
+ }
80
+ return parsePrView(result.stdout);
81
+ }
@@ -0,0 +1,55 @@
1
+ import type { QueueEntry } from "../../types.ts";
2
+ import type { Output, ParsedArgs } from "../types.ts";
3
+ import { type ResolveCommandRunner } from "../resolve.ts";
4
+ import { type PrGitHubOverview } from "./pr-github.ts";
5
+ export type PrStatusKind = "merged" | "merged_outside_queue" | "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "dequeued" | "closed" | "changes_requested" | "checks_failing" | "checks_pending" | "approved_clean" | "not_queued";
6
+ export type PrStatusSource = "queue" | "github";
7
+ export interface PrStatusReport {
8
+ repoId: string;
9
+ repoFullName: string;
10
+ prNumber: number;
11
+ source: PrStatusSource;
12
+ kind: PrStatusKind;
13
+ terminal: boolean;
14
+ exitCode: number;
15
+ reason?: string;
16
+ queueEntry?: QueueEntry;
17
+ github?: PrGitHubOverview;
18
+ checkedAt: string;
19
+ }
20
+ export declare function classifyQueueEntry(entry: QueueEntry): PrStatusReport["kind"];
21
+ export declare function classifyGitHubOverview(overview: PrGitHubOverview): {
22
+ kind: PrStatusReport["kind"];
23
+ reason?: string;
24
+ };
25
+ export interface BuildReportOptions {
26
+ repoId: string;
27
+ repoFullName: string;
28
+ prNumber: number;
29
+ queueEntry?: QueueEntry | undefined;
30
+ github?: PrGitHubOverview | undefined;
31
+ }
32
+ export declare function buildPrStatusReport(options: BuildReportOptions): PrStatusReport;
33
+ export interface HandlePrStatusOptions {
34
+ parsed: ParsedArgs;
35
+ stdout: Output;
36
+ resolveCommand?: ResolveCommandRunner | undefined;
37
+ /** For tests: inject a custom fetcher instead of shelling to `gh`. */
38
+ fetchGitHub?: ((repoFullName: string, prNumber: number) => Promise<PrGitHubOverview>) | undefined;
39
+ /** For tests: inject a clock. Defaults to Date.now. */
40
+ now?: (() => number) | undefined;
41
+ /** For tests: inject a sleep. Defaults to setTimeout. */
42
+ sleep?: ((ms: number) => Promise<void>) | undefined;
43
+ }
44
+ export interface WaitOptions {
45
+ timeoutMs: number;
46
+ pollMs: number;
47
+ now: () => number;
48
+ sleep: (ms: number) => Promise<void>;
49
+ }
50
+ export declare function resolveWaitOptions(parsed: ParsedArgs, overrides?: Partial<Pick<WaitOptions, "now" | "sleep">>): WaitOptions;
51
+ export declare function waitForTerminalReport(build: () => Promise<PrStatusReport>, options: WaitOptions): Promise<{
52
+ report: PrStatusReport;
53
+ timedOut: boolean;
54
+ }>;
55
+ export declare function handlePrStatus(options: HandlePrStatusOptions): Promise<number>;
@@ -0,0 +1,272 @@
1
+ import { SqliteStore } from "../../db/sqlite-store.js";
2
+ import { formatJson, writeOutput } from "../output.js";
3
+ import { ServiceApiError, fetchLocalJson, loadRepoConfigById } from "../system.js";
4
+ import { resolvePrNumber, resolveRepo } from "../resolve.js";
5
+ import { parseIntegerFlag } from "../args.js";
6
+ import { fetchPrGitHubOverview } from "./pr-github.js";
7
+ export function classifyQueueEntry(entry) {
8
+ switch (entry.status) {
9
+ case "merged": return "merged";
10
+ case "queued": return "queued";
11
+ case "preparing_head": return "preparing_head";
12
+ case "validating": return "validating";
13
+ case "merging": return "merging";
14
+ case "evicted": return "evicted";
15
+ case "dequeued": return "dequeued";
16
+ }
17
+ }
18
+ function exitCodeForKind(kind) {
19
+ switch (kind) {
20
+ case "merged":
21
+ case "merged_outside_queue":
22
+ case "approved_clean":
23
+ return 0;
24
+ case "closed":
25
+ case "changes_requested":
26
+ case "checks_failing":
27
+ case "evicted":
28
+ case "dequeued":
29
+ return 2;
30
+ case "queued":
31
+ case "preparing_head":
32
+ case "validating":
33
+ case "merging":
34
+ case "checks_pending":
35
+ case "not_queued":
36
+ return 3;
37
+ }
38
+ }
39
+ function isTerminalKind(kind) {
40
+ return exitCodeForKind(kind) !== 3;
41
+ }
42
+ export function classifyGitHubOverview(overview) {
43
+ if (overview.state === "MERGED" || overview.merged) {
44
+ return { kind: "merged_outside_queue" };
45
+ }
46
+ if (overview.state === "CLOSED") {
47
+ return { kind: "closed", reason: "PR is closed without merge" };
48
+ }
49
+ if (overview.reviewDecision === "CHANGES_REQUESTED") {
50
+ return { kind: "changes_requested", reason: "A reviewer requested changes" };
51
+ }
52
+ const requiredChecks = overview.checks.filter((c) => c.required);
53
+ const checkPool = requiredChecks.length > 0 ? requiredChecks : overview.checks;
54
+ const failing = checkPool.filter((c) => c.status === "failure").map((c) => c.name);
55
+ if (failing.length > 0) {
56
+ return { kind: "checks_failing", reason: `Failing checks: ${failing.join(", ")}` };
57
+ }
58
+ const pending = checkPool.filter((c) => c.status === "pending").map((c) => c.name);
59
+ if (pending.length > 0) {
60
+ return { kind: "checks_pending", reason: `Pending checks: ${pending.join(", ")}` };
61
+ }
62
+ if (overview.reviewDecision === "APPROVED") {
63
+ return { kind: "approved_clean", reason: "Approved with green checks; not admitted to queue" };
64
+ }
65
+ return { kind: "not_queued", reason: "PR is not admitted to the merge queue" };
66
+ }
67
+ function findEntryForPr(snapshot, prNumber) {
68
+ const matches = snapshot.entries.filter((entry) => entry.prNumber === prNumber);
69
+ if (matches.length === 0)
70
+ return undefined;
71
+ matches.sort((left, right) => {
72
+ const leftTerminal = ["merged", "evicted", "dequeued"].includes(left.status) ? 1 : 0;
73
+ const rightTerminal = ["merged", "evicted", "dequeued"].includes(right.status) ? 1 : 0;
74
+ if (leftTerminal !== rightTerminal)
75
+ return leftTerminal - rightTerminal;
76
+ return right.position - left.position;
77
+ });
78
+ return matches[0];
79
+ }
80
+ async function loadQueueEntry(config, prNumber) {
81
+ try {
82
+ const snapshot = await fetchLocalJson(config.repoId, "/queue/watch?eventLimit=1");
83
+ const entry = findEntryForPr(snapshot, prNumber);
84
+ return entry ? { kind: "found", entry, source: "service" } : { kind: "not_found" };
85
+ }
86
+ catch (error) {
87
+ if (error instanceof ServiceApiError) {
88
+ return { kind: "unavailable" };
89
+ }
90
+ const store = new SqliteStore(config.database.path);
91
+ try {
92
+ const entries = store.listAll(config.repoId).filter((entry) => entry.prNumber === prNumber);
93
+ if (entries.length === 0)
94
+ return { kind: "not_found" };
95
+ entries.sort((left, right) => {
96
+ const leftTerminal = ["merged", "evicted", "dequeued"].includes(left.status) ? 1 : 0;
97
+ const rightTerminal = ["merged", "evicted", "dequeued"].includes(right.status) ? 1 : 0;
98
+ if (leftTerminal !== rightTerminal)
99
+ return leftTerminal - rightTerminal;
100
+ return right.position - left.position;
101
+ });
102
+ return { kind: "found", entry: entries[0], source: "database" };
103
+ }
104
+ finally {
105
+ store.close();
106
+ }
107
+ }
108
+ }
109
+ export function buildPrStatusReport(options) {
110
+ const checkedAt = new Date().toISOString();
111
+ if (options.queueEntry) {
112
+ const kind = classifyQueueEntry(options.queueEntry);
113
+ return {
114
+ repoId: options.repoId,
115
+ repoFullName: options.repoFullName,
116
+ prNumber: options.prNumber,
117
+ source: "queue",
118
+ kind,
119
+ terminal: isTerminalKind(kind),
120
+ exitCode: exitCodeForKind(kind),
121
+ queueEntry: options.queueEntry,
122
+ checkedAt,
123
+ };
124
+ }
125
+ if (options.github) {
126
+ const { kind, reason } = classifyGitHubOverview(options.github);
127
+ return {
128
+ repoId: options.repoId,
129
+ repoFullName: options.repoFullName,
130
+ prNumber: options.prNumber,
131
+ source: "github",
132
+ kind,
133
+ terminal: isTerminalKind(kind),
134
+ exitCode: exitCodeForKind(kind),
135
+ ...(reason ? { reason } : {}),
136
+ github: options.github,
137
+ checkedAt,
138
+ };
139
+ }
140
+ throw new Error("buildPrStatusReport requires either queueEntry or github overview");
141
+ }
142
+ function formatReportText(report) {
143
+ const lines = [
144
+ `Repo: ${report.repoFullName} (${report.repoId})`,
145
+ `PR: #${report.prNumber}`,
146
+ `Source: ${report.source}`,
147
+ `State: ${report.kind}`,
148
+ `Terminal: ${report.terminal ? "yes" : "no"}`,
149
+ ];
150
+ if (report.reason)
151
+ lines.push(`Reason: ${report.reason}`);
152
+ if (report.queueEntry) {
153
+ const entry = report.queueEntry;
154
+ lines.push(`Queue position: ${entry.position}`);
155
+ lines.push(`Branch: ${entry.branch}`);
156
+ lines.push(`Head SHA: ${entry.headSha}`);
157
+ if (entry.waitDetail)
158
+ lines.push(`Wait detail: ${entry.waitDetail}`);
159
+ }
160
+ if (report.github) {
161
+ const gh = report.github;
162
+ lines.push(`Review decision: ${gh.reviewDecision || "(none)"}`);
163
+ if (gh.mergeStateStatus)
164
+ lines.push(`Merge state: ${gh.mergeStateStatus}`);
165
+ if (gh.labels.length > 0)
166
+ lines.push(`Labels: ${gh.labels.join(", ")}`);
167
+ if (gh.checks.length > 0) {
168
+ const required = gh.checks.filter((c) => c.required);
169
+ const summary = required.length > 0 ? required : gh.checks;
170
+ const bySt = {
171
+ success: summary.filter((c) => c.status === "success").length,
172
+ failure: summary.filter((c) => c.status === "failure").length,
173
+ pending: summary.filter((c) => c.status === "pending").length,
174
+ };
175
+ lines.push(`Checks (${required.length > 0 ? "required" : "all"}): ${bySt.success} ok, ${bySt.failure} failing, ${bySt.pending} pending`);
176
+ for (const check of summary) {
177
+ lines.push(` - [${check.status}] ${check.name}${check.required ? " (required)" : ""}`);
178
+ }
179
+ }
180
+ }
181
+ return lines.join("\n") + "\n";
182
+ }
183
+ const DEFAULT_WAIT_TIMEOUT_MS = 30 * 60_000;
184
+ const MAX_WAIT_TIMEOUT_MS = 2 * 60 * 60_000;
185
+ const MIN_WAIT_TIMEOUT_MS = 1_000;
186
+ const DEFAULT_POLL_MS = 5_000;
187
+ const MIN_POLL_MS = 1_000;
188
+ const MAX_POLL_MS = 5 * 60_000;
189
+ export function resolveWaitOptions(parsed, overrides) {
190
+ const timeoutSeconds = parseIntegerFlag(parsed.flags.get("timeout"), "--timeout");
191
+ const pollSeconds = parseIntegerFlag(parsed.flags.get("poll"), "--poll");
192
+ const timeoutMs = clamp(timeoutSeconds !== undefined ? timeoutSeconds * 1000 : DEFAULT_WAIT_TIMEOUT_MS, MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS);
193
+ const pollMs = clamp(pollSeconds !== undefined ? pollSeconds * 1000 : DEFAULT_POLL_MS, MIN_POLL_MS, MAX_POLL_MS);
194
+ return {
195
+ timeoutMs,
196
+ pollMs,
197
+ now: overrides?.now ?? (() => Date.now()),
198
+ sleep: overrides?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
199
+ };
200
+ }
201
+ function clamp(value, min, max) {
202
+ return Math.max(min, Math.min(max, value));
203
+ }
204
+ export async function waitForTerminalReport(build, options) {
205
+ const deadline = options.now() + options.timeoutMs;
206
+ let report = await build();
207
+ while (!report.terminal) {
208
+ const remaining = deadline - options.now();
209
+ if (remaining <= 0) {
210
+ return { report, timedOut: true };
211
+ }
212
+ const waitMs = Math.min(options.pollMs, remaining);
213
+ if (waitMs > 0)
214
+ await options.sleep(waitMs);
215
+ report = await build();
216
+ }
217
+ return { report, timedOut: false };
218
+ }
219
+ export async function handlePrStatus(options) {
220
+ const { parsed, stdout } = options;
221
+ const resolveCommand = options.resolveCommand;
222
+ const resolvedRepo = await resolveRepo({ parsed, helpTopic: "root", runCommand: resolveCommand });
223
+ const resolvedPr = await resolvePrNumber({ parsed, helpTopic: "root", runCommand: resolveCommand });
224
+ const { config } = loadRepoConfigById(resolvedRepo.repoId);
225
+ const fetchGh = options.fetchGitHub
226
+ ?? ((repoFullName, prNumber) => fetchPrGitHubOverview(repoFullName, prNumber, resolveCommand ?? (async () => ({
227
+ exitCode: 127,
228
+ stdout: "",
229
+ stderr: "no resolveCommand provided",
230
+ }))));
231
+ const buildReport = async () => {
232
+ const queueResult = await loadQueueEntry(config, resolvedPr.prNumber);
233
+ if (queueResult.kind === "found") {
234
+ return buildPrStatusReport({
235
+ repoId: resolvedRepo.repoId,
236
+ repoFullName: resolvedRepo.repoFullName,
237
+ prNumber: resolvedPr.prNumber,
238
+ queueEntry: queueResult.entry,
239
+ });
240
+ }
241
+ const overview = await fetchGh(resolvedRepo.repoFullName, resolvedPr.prNumber);
242
+ return buildPrStatusReport({
243
+ repoId: resolvedRepo.repoId,
244
+ repoFullName: resolvedRepo.repoFullName,
245
+ prNumber: resolvedPr.prNumber,
246
+ github: overview,
247
+ });
248
+ };
249
+ const shouldWait = parsed.flags.get("wait") === true || typeof parsed.flags.get("wait") === "string";
250
+ let report;
251
+ let timedOut = false;
252
+ if (shouldWait) {
253
+ const waitOptions = resolveWaitOptions(parsed, {
254
+ ...(options.now ? { now: options.now } : {}),
255
+ ...(options.sleep ? { sleep: options.sleep } : {}),
256
+ });
257
+ const result = await waitForTerminalReport(buildReport, waitOptions);
258
+ report = result.report;
259
+ timedOut = result.timedOut;
260
+ }
261
+ else {
262
+ report = await buildReport();
263
+ }
264
+ const payload = timedOut ? { ...report, timedOut: true } : report;
265
+ if (parsed.flags.get("json") === true) {
266
+ writeOutput(stdout, formatJson(payload));
267
+ }
268
+ else {
269
+ writeOutput(stdout, formatReportText(report) + (timedOut ? "Timed out waiting for terminal state.\n" : ""));
270
+ }
271
+ return timedOut ? 4 : report.exitCode;
272
+ }
@@ -1,4 +1,5 @@
1
1
  import { type QueueWatchSnapshot } from "../../types.ts";
2
2
  import type { ParsedArgs, Output } from "../types.ts";
3
+ import { type ResolveCommandRunner } from "../resolve.ts";
3
4
  export declare function formatQueueStatusText(source: "service" | "database", snapshot: QueueWatchSnapshot): string;
4
- export declare function handleQueue(parsed: ParsedArgs, stdout: Output): Promise<number>;
5
+ export declare function handleQueue(parsed: ParsedArgs, stdout: Output, resolveCommand?: ResolveCommandRunner): Promise<number>;
@@ -3,8 +3,15 @@ import { TERMINAL_STATUSES } from "../../types.js";
3
3
  import { UsageError } from "../types.js";
4
4
  import { parseIntegerFlag } from "../args.js";
5
5
  import { formatJson, writeOutput } from "../output.js";
6
- import { ServiceApiError, loadRepoConfigById, resolveRepoId, fetchLocalJson } from "../system.js";
6
+ import { ServiceApiError, loadRepoConfigById, fetchLocalJson } from "../system.js";
7
+ import { resolveRepo } from "../resolve.js";
7
8
  import { buildQueueSummary } from "../../watch/dashboard-model.js";
9
+ async function resolveRepoIdWithCwdFallback(parsed, positionalIndex, resolveCommand) {
10
+ const positional = parsed.positionals[positionalIndex];
11
+ if (positional)
12
+ return positional;
13
+ return (await resolveRepo({ parsed, helpTopic: "queue", runCommand: resolveCommand })).repoId;
14
+ }
8
15
  async function readQueueSnapshot(config, eventLimit) {
9
16
  try {
10
17
  const query = new URLSearchParams({ eventLimit: String(eventLimit) });
@@ -133,7 +140,7 @@ function readQueueEntryDetail(config, options) {
133
140
  store.close();
134
141
  }
135
142
  }
136
- export async function handleQueue(parsed, stdout) {
143
+ export async function handleQueue(parsed, stdout, resolveCommand) {
137
144
  const subcommand = parsed.positionals[1];
138
145
  if (!subcommand) {
139
146
  throw new UsageError("merge-steward queue requires a subcommand.", "queue");
@@ -145,7 +152,7 @@ export async function handleQueue(parsed, stdout) {
145
152
  const { handleDashboard } = await import("./dashboard.js");
146
153
  return await handleDashboard(parsed);
147
154
  }
148
- const repoId = resolveRepoId(parsed, 2, "queue");
155
+ const repoId = await resolveRepoIdWithCwdFallback(parsed, 2, resolveCommand);
149
156
  const { config } = loadRepoConfigById(repoId);
150
157
  if (subcommand === "status") {
151
158
  const eventLimit = parseIntegerFlag(parsed.flags.get("events"), "--events") ?? 20;
package/dist/cli/help.js CHANGED
@@ -22,11 +22,27 @@ function rootHelpText() {
22
22
  " doctor [--repo <id>] [--json] Validate config, secrets, auth, and required binaries",
23
23
  " service status [--json] Show systemd state and local health",
24
24
  " service logs [--lines <count>] [--json] Show recent journal logs",
25
- " queue status --repo <id> [--json] Show queue summary and current entries",
26
- " queue show --repo <id> (--entry <id> | --pr <num>) [--events <count>] [--json]",
25
+ " pr status [--repo <id>] [--pr <num>] [--wait] [--timeout <s>] [--poll <s>] [--json]",
26
+ " Classify a single PR; exit 0 terminal ok, 2 terminal failure, 3 still in flight, 4 --wait timeout",
27
+ " queue status [--repo <id>] [--cwd <path>] [--json] Show queue summary and current entries",
28
+ " queue show [--repo <id>] (--entry <id> | --pr <num>) [--events <count>] [--json]",
27
29
  " Show one queue entry with events and incidents",
28
30
  " dashboard [--repo <id>] [--pr <number>] Open the multi-repo merge queue dashboard",
29
31
  "",
32
+ "Resolution:",
33
+ " --repo and --pr are optional when you run the command inside a git",
34
+ " checkout. Without them, merge-steward reads `origin`'s remote URL,",
35
+ " matches it to an attached repoId, and uses `gh pr view` to find the",
36
+ " PR for the current branch. Pass --cwd <path> to resolve from a",
37
+ " specific directory instead of process.cwd().",
38
+ "",
39
+ "Exit codes (pr status):",
40
+ " 0 merged / approved with green required checks",
41
+ " 2 changes_requested / failing required checks / evicted / closed",
42
+ " 3 still in flight (queued, preparing, validating, merging, pending)",
43
+ " 4 --wait timed out before a terminal state was reached",
44
+ " 1 usage or configuration error",
45
+ "",
30
46
  "Service management:",
31
47
  " service install [--force] [--json] Reinstall the systemd unit",
32
48
  " service restart [--json] Reload-or-restart the service",
@@ -94,9 +110,11 @@ function queueHelpText() {
94
110
  "",
95
111
  "Commands:",
96
112
  " dashboard [--repo <id>] [--pr <number>] Open the multi-repo merge queue dashboard",
97
- " status --repo <id> Show queue summary and entries",
98
- " show --repo <id> (--entry <id> | --pr <num>) Show one queue entry with events and incidents",
99
- " reconcile --repo <id> [--json] Ask the service to reconcile immediately",
113
+ " status [--repo <id>] [--cwd <path>] Show queue summary and entries",
114
+ " show [--repo <id>] (--entry <id> | --pr <num>) Show one queue entry with events and incidents",
115
+ " reconcile [--repo <id>] [--cwd <path>] [--json] Ask the service to reconcile immediately",
116
+ "",
117
+ "--repo is inferred from `origin`'s remote in the current git checkout.",
100
118
  ].join("\n");
101
119
  }
102
120
  export function helpTextFor(topic) {
@@ -0,0 +1,31 @@
1
+ import type { ParsedArgs, HelpTopic } from "./types.ts";
2
+ export interface ResolveCommandResult {
3
+ exitCode: number;
4
+ stdout: string;
5
+ stderr: string;
6
+ }
7
+ export type ResolveCommandRunner = (command: string, args: string[], options?: {
8
+ cwd?: string;
9
+ }) => Promise<ResolveCommandResult>;
10
+ export declare function defaultResolveRunner(command: string, args: string[], options?: {
11
+ cwd?: string;
12
+ }): Promise<ResolveCommandResult>;
13
+ export type RepoRefSource = "flag" | "cwd";
14
+ export interface ResolvedRepo {
15
+ repoId: string;
16
+ repoFullName: string;
17
+ source: RepoRefSource;
18
+ }
19
+ export interface ResolveOptions {
20
+ parsed: ParsedArgs;
21
+ runCommand?: ResolveCommandRunner | undefined;
22
+ cwd?: string | undefined;
23
+ helpTopic?: HelpTopic | undefined;
24
+ }
25
+ export declare function resolveRepo(options: ResolveOptions): Promise<ResolvedRepo>;
26
+ export type PrRefSource = "flag" | "cwd";
27
+ export interface ResolvedPr {
28
+ prNumber: number;
29
+ source: PrRefSource;
30
+ }
31
+ export declare function resolvePrNumber(options: ResolveOptions): Promise<ResolvedPr>;
@@ -0,0 +1,100 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { listRepoConfigs } from "./system.js";
3
+ import { UsageError } from "./types.js";
4
+ export function defaultResolveRunner(command, args, options) {
5
+ const result = spawnSync(command, args, {
6
+ encoding: "utf8",
7
+ ...(options?.cwd ? { cwd: options.cwd } : {}),
8
+ });
9
+ return Promise.resolve({
10
+ exitCode: result.status ?? 1,
11
+ stdout: result.stdout ?? "",
12
+ stderr: result.stderr ?? "",
13
+ });
14
+ }
15
+ function parseOwnerRepoFromRemote(remoteUrl) {
16
+ const trimmed = remoteUrl.trim();
17
+ if (!trimmed)
18
+ return undefined;
19
+ const sshMatch = trimmed.match(/^git@[^:]+:([^/]+\/[^.]+?)(?:\.git)?$/);
20
+ if (sshMatch?.[1])
21
+ return sshMatch[1];
22
+ const httpsMatch = trimmed.match(/^https?:\/\/[^/]+\/([^/]+\/[^/]+?)(?:\.git)?\/?$/);
23
+ if (httpsMatch?.[1])
24
+ return httpsMatch[1];
25
+ const sshProto = trimmed.match(/^ssh:\/\/[^/]+\/([^/]+\/[^/]+?)(?:\.git)?$/);
26
+ if (sshProto?.[1])
27
+ return sshProto[1];
28
+ return undefined;
29
+ }
30
+ async function resolveGitRemote(runCommand, cwd) {
31
+ const result = await runCommand("git", ["remote", "get-url", "origin"], { cwd });
32
+ if (result.exitCode !== 0)
33
+ return undefined;
34
+ return parseOwnerRepoFromRemote(result.stdout);
35
+ }
36
+ export async function resolveRepo(options) {
37
+ const parsed = options.parsed;
38
+ const helpTopic = options.helpTopic ?? "root";
39
+ const runCommand = options.runCommand ?? defaultResolveRunner;
40
+ const cwd = options.cwd ?? (typeof parsed.flags.get("cwd") === "string" ? String(parsed.flags.get("cwd")) : process.cwd());
41
+ const flagValue = parsed.flags.get("repo");
42
+ const flagString = typeof flagValue === "string" ? flagValue.trim() : "";
43
+ if (flagString) {
44
+ const match = findAttachedRepo(flagString);
45
+ if (!match) {
46
+ throw new UsageError(attachHint(flagString), helpTopic);
47
+ }
48
+ return { repoId: match.repoId, repoFullName: match.repoFullName, source: "flag" };
49
+ }
50
+ const ownerRepo = await resolveGitRemote(runCommand, cwd);
51
+ if (!ownerRepo) {
52
+ throw new UsageError("Unable to resolve a repo: pass --repo <id> or run from a git checkout with an origin remote.", helpTopic);
53
+ }
54
+ const match = findAttachedRepo(ownerRepo);
55
+ if (!match) {
56
+ throw new UsageError(attachHint(ownerRepo), helpTopic);
57
+ }
58
+ return { repoId: match.repoId, repoFullName: match.repoFullName, source: "cwd" };
59
+ }
60
+ function findAttachedRepo(ref) {
61
+ const normalized = ref.trim();
62
+ if (!normalized)
63
+ return undefined;
64
+ const lower = normalized.toLowerCase();
65
+ return listRepoConfigs().find((repo) => repo.repoId === normalized
66
+ || repo.repoFullName === normalized
67
+ || repo.repoFullName.toLowerCase() === lower);
68
+ }
69
+ function attachHint(ref) {
70
+ const attached = listRepoConfigs();
71
+ const attachedHint = attached.length > 0
72
+ ? ` Attached repos: ${attached.map((r) => r.repoId).join(", ")}.`
73
+ : "";
74
+ return `Repo ${ref} is not attached to merge-steward. Run \`merge-steward repo attach ${ref}\` first.${attachedHint}`;
75
+ }
76
+ export async function resolvePrNumber(options) {
77
+ const parsed = options.parsed;
78
+ const helpTopic = options.helpTopic ?? "root";
79
+ const runCommand = options.runCommand ?? defaultResolveRunner;
80
+ const cwd = options.cwd ?? (typeof parsed.flags.get("cwd") === "string" ? String(parsed.flags.get("cwd")) : process.cwd());
81
+ const flagValue = parsed.flags.get("pr");
82
+ if (typeof flagValue === "string" && flagValue.trim()) {
83
+ const flagNumber = parseInt(flagValue.trim(), 10);
84
+ if (!Number.isFinite(flagNumber) || flagNumber <= 0) {
85
+ throw new UsageError("--pr must be a positive integer.", helpTopic);
86
+ }
87
+ return { prNumber: flagNumber, source: "flag" };
88
+ }
89
+ const result = await runCommand("gh", ["pr", "view", "--json", "number", "-q", ".number"], { cwd });
90
+ if (result.exitCode !== 0) {
91
+ const hint = result.stderr.trim() || "no PR for the current branch";
92
+ throw new UsageError(`Unable to resolve PR from cwd (${hint}). Pass --pr <number>.`, helpTopic);
93
+ }
94
+ const raw = result.stdout.trim();
95
+ const prNumber = parseInt(raw, 10);
96
+ if (!Number.isFinite(prNumber) || prNumber <= 0) {
97
+ throw new UsageError(`\`gh pr view\` returned no PR number (got: ${JSON.stringify(raw)}). Pass --pr <number>.`, helpTopic);
98
+ }
99
+ return { prNumber, source: "cwd" };
100
+ }
@@ -1,7 +1,7 @@
1
1
  import { type StewardConfig } from "../config.ts";
2
2
  import { parseHomeConfigObject } from "../steward-home.ts";
3
3
  import type { ServiceHealthResponse, ServiceGitHubAuthStatus, ServiceGitHubDiscoverResponse, ServiceGitHubRepoAccessResponse } from "../admin-types.ts";
4
- import type { ParsedArgs, HelpTopic, CommandResult, CommandRunner } from "./types.ts";
4
+ import type { CommandResult, CommandRunner } from "./types.ts";
5
5
  export declare function readEnvFile(filePath: string): Record<string, string>;
6
6
  export declare function getHomeEnv(): Record<string, string | undefined>;
7
7
  export declare function readHomeConfig(): {
@@ -22,7 +22,6 @@ export declare function loadRepoConfigById(repoId: string): {
22
22
  config: StewardConfig;
23
23
  };
24
24
  export declare function buildWebhookUrl(): string | undefined;
25
- export declare function resolveRepoId(parsed: ParsedArgs, positionalIndex?: number, helpTopic?: HelpTopic): string;
26
25
  export declare function defaultRunCommand(command: string, args: string[]): Promise<CommandResult>;
27
26
  export declare function formatCommandFailure(result: CommandResult, fallback: string): string;
28
27
  export declare function parseSystemctlShowOutput(raw: string): Record<string, string>;
@@ -103,17 +103,6 @@ export function buildWebhookUrl() {
103
103
  const publicBaseUrl = homeConfig.server.public_base_url;
104
104
  return publicBaseUrl ? `${publicBaseUrl.replace(/\/$/, "")}/webhooks/github` : undefined;
105
105
  }
106
- export function resolveRepoId(parsed, positionalIndex = 2, helpTopic = "root") {
107
- const positional = parsed.positionals[positionalIndex];
108
- if (positional) {
109
- return positional;
110
- }
111
- const flagged = parsed.flags.get("repo");
112
- if (typeof flagged === "string" && flagged.trim()) {
113
- return flagged.trim();
114
- }
115
- throw new UsageError("Repo id is required.", helpTopic);
116
- }
117
106
  export function defaultRunCommand(command, args) {
118
107
  const result = spawnSync(command, args, {
119
108
  encoding: "utf8",
@@ -16,6 +16,9 @@ export interface RunCliOptions {
16
16
  stdout?: Output;
17
17
  stderr?: Output;
18
18
  runCommand?: CommandRunner;
19
+ resolveCommand?: (command: string, args: string[], options?: {
20
+ cwd?: string;
21
+ }) => Promise<CommandResult>;
19
22
  }
20
23
  export declare class UsageError extends Error {
21
24
  readonly helpTopic: HelpTopic;
package/dist/cli.js CHANGED
@@ -71,8 +71,16 @@ export async function runCli(argv, options) {
71
71
  return await (await import("./cli/commands/dashboard.js")).handleDashboard(parsed);
72
72
  case "service":
73
73
  return await (await import("./cli/commands/service.js")).handleService(parsed, stdout, runCommand);
74
+ case "pr": {
75
+ const subcommand = parsed.positionals[1];
76
+ if (subcommand !== "status") {
77
+ throw new UsageError(`Unknown pr command: ${subcommand ?? "(none)"}. Try \`merge-steward pr status\`.`);
78
+ }
79
+ const { handlePrStatus } = await import("./cli/commands/pr-status.js");
80
+ return await handlePrStatus({ parsed, stdout, resolveCommand: options?.resolveCommand });
81
+ }
74
82
  case "queue":
75
- return await (await import("./cli/commands/queue.js")).handleQueue(parsed, stdout);
83
+ return await (await import("./cli/commands/queue.js")).handleQueue(parsed, stdout, options?.resolveCommand);
76
84
  default:
77
85
  throw new UsageError(`Unknown command: ${command}`);
78
86
  }
@@ -22,6 +22,8 @@ export declare class MergeStewardRuntime {
22
22
  private lastTickOutcome;
23
23
  private lastTickError;
24
24
  private currentQueueBlock;
25
+ private readonly waitingMainInfoAt;
26
+ private static readonly WAITING_MAIN_INFO_INTERVAL_MS;
25
27
  constructor(config: StewardConfig, policy: GitHubPolicyCache, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: SpeculativeBranchBuilder, logger: Logger, beforeTick?: (() => Promise<void>) | undefined);
26
28
  start(): Promise<void>;
27
29
  stop(): Promise<void>;
@@ -17,6 +17,12 @@ export class MergeStewardRuntime {
17
17
  lastTickOutcome = "idle";
18
18
  lastTickError = null;
19
19
  currentQueueBlock = null;
20
+ // Per-entry timestamp of the last info-level `merge_waiting_main` log.
21
+ // The event fires every tick while the gate waits, so subsequent ticks
22
+ // log at debug to avoid spamming; a re-emission of info every ~5 min keeps
23
+ // long waits visible in `service logs` without drowning it out.
24
+ waitingMainInfoAt = new Map();
25
+ static WAITING_MAIN_INFO_INTERVAL_MS = 5 * 60 * 1000;
20
26
  constructor(config, policy, store, git, ci, github, eviction, specBuilder, logger, beforeTick) {
21
27
  this.config = config;
22
28
  this.policy = policy;
@@ -101,8 +107,23 @@ export class MergeStewardRuntime {
101
107
  || event.action === "ci_failed"
102
108
  || event.action === "merge_rejected" || event.action === "budget_exhausted";
103
109
  const isDebug = event.action === "ci_pending" || event.action === "retry_gated"
104
- || event.action === "fetch_started" || event.action === "merge_waiting_main";
105
- const level = isWarn ? "warn" : isDebug ? "debug" : "info";
110
+ || event.action === "fetch_started";
111
+ let level = isWarn ? "warn" : isDebug ? "debug" : "info";
112
+ if (event.action === "merge_waiting_main") {
113
+ // The event fires every ~tick while the gate waits on main's own
114
+ // CI. Log at info the first time (so an operator can see *why*
115
+ // the head entry is parked in merging) and once every interval
116
+ // after, debug on the intermediate ticks to avoid spamming.
117
+ const last = this.waitingMainInfoAt.get(event.entryId);
118
+ const now = Date.now();
119
+ if (last === undefined || now - last >= MergeStewardRuntime.WAITING_MAIN_INFO_INTERVAL_MS) {
120
+ level = "info";
121
+ this.waitingMainInfoAt.set(event.entryId, now);
122
+ }
123
+ else {
124
+ level = "debug";
125
+ }
126
+ }
106
127
  this.logger[level]({ ...event }, `Queue: ${event.action} PR #${event.prNumber}`);
107
128
  if (event.action === "main_broken" && tickQueueBlockEvent === null) {
108
129
  tickQueueBlockEvent = event;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
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": {