merge-steward 0.9.4 → 0.9.6

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.
Files changed (42) hide show
  1. package/README.md +69 -11
  2. package/dist/admin-types.d.ts +12 -0
  3. package/dist/cli/args.js +14 -0
  4. package/dist/cli/commands/doctor.js +90 -13
  5. package/dist/cli/commands/queue.d.ts +2 -0
  6. package/dist/cli/commands/queue.js +50 -26
  7. package/dist/cli/help.js +22 -14
  8. package/dist/cli/system.d.ts +4 -1
  9. package/dist/cli/system.js +26 -2
  10. package/dist/cli/types.d.ts +1 -1
  11. package/dist/cli.js +32 -16
  12. package/dist/config.js +1 -1
  13. package/dist/exec.js +63 -23
  14. package/dist/github/actions-runner.js +5 -1
  15. package/dist/github/pr-client.d.ts +5 -0
  16. package/dist/github/pr-client.js +18 -0
  17. package/dist/github/shell-git.js +1 -0
  18. package/dist/github-auth.d.ts +1 -0
  19. package/dist/github-auth.js +1 -1
  20. package/dist/github-repo-discovery.js +36 -1
  21. package/dist/http-multi.d.ts +5 -1
  22. package/dist/http-multi.js +19 -0
  23. package/dist/install.js +1 -1
  24. package/dist/interfaces.d.ts +6 -0
  25. package/dist/invalidation.d.ts +13 -0
  26. package/dist/invalidation.js +15 -0
  27. package/dist/reconciler.js +21 -10
  28. package/dist/server.js +54 -1
  29. package/dist/service.d.ts +1 -2
  30. package/dist/service.js +31 -32
  31. package/dist/types.d.ts +1 -0
  32. package/dist/watch/DetailView.js +1 -1
  33. package/dist/watch/EntryStateGraph.js +1 -1
  34. package/dist/watch/QueueListView.js +8 -5
  35. package/dist/watch/StatusBar.js +1 -1
  36. package/dist/watch/display-filter.js +35 -24
  37. package/dist/watch/format.d.ts +1 -0
  38. package/dist/watch/format.js +14 -3
  39. package/dist/watch/state-visualization.js +8 -1
  40. package/dist/webhook-handler.js +1 -1
  41. package/package.json +1 -1
  42. package/service.env.example +11 -0
package/README.md CHANGED
@@ -1,18 +1,30 @@
1
1
  # merge-steward
2
2
 
3
- Serial merge queue service. Rebases PRs onto main one at a time, waits for CI, and merges when green. Evicts on failure and reports incidents via GitHub check runs.
3
+ Speculative merge queue service. It admits approved PRs whose required checks
4
+ are green, builds cumulative speculative branches, waits for CI on those exact
5
+ integrated SHAs, and then fast-forwards `main` to the tested result. On
6
+ failure, it evicts with a durable incident record and GitHub check run.
4
7
 
5
- Fully independent of PatchRelay. Communicates through GitHub — PRs, labels, check runs, branches.
8
+ Fully independent of PatchRelay. Communicates through GitHub — PRs, reviews,
9
+ checks, labels, branches.
10
+
11
+ Shared protocol:
12
+
13
+ - [../../docs/design-docs/pr-automation-loop.md](../../docs/design-docs/pr-automation-loop.md)
6
14
 
7
15
  ## How it works
8
16
 
9
- 1. A PR gets the `queue` label (manually, by PatchRelay, or by any automation)
10
- 2. The steward sees the label via GitHub webhook
11
- 3. If the PR is approved and CI is green, it enters the queue
12
- 4. The steward processes the queue head: fetch → rebase onto main → push → wait for CI → merge
13
- 5. On failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run
14
- 6. PatchRelay (or any agent) sees the check run failure and can fix the branch
15
- 7. When the branch is fixed and CI passes again, adding the `queue` label re-admits it
17
+ 1. A PR becomes eligible when GitHub says it is approved and its required checks are green
18
+ 2. The steward notices that through webhook wakeups or startup/reconcile scans
19
+ 3. It admits the PR into the queue; the optional `queue` label is just an operator-friendly nudge, not the sole trigger
20
+ 4. The steward builds a speculative branch:
21
+ - head of queue: `main + PR`
22
+ - downstream entries: cumulative specs like `main + A + B`
23
+ 5. CI runs on that speculative SHA
24
+ 6. If the queue head's speculative SHA is still a fast-forward from current `main`, the steward pushes that exact SHA to `main`
25
+ 7. On failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run
26
+ 8. PatchRelay (or any agent) sees the check run failure and can fix the branch
27
+ 9. When the branch is fixed and CI passes again, the PR can be re-admitted
16
28
 
17
29
  ## Setup
18
30
 
@@ -46,6 +58,12 @@ merge-steward attach owner/repo
46
58
 
47
59
  That writes `~/.config/merge-steward/repos/<derived-id>.json`. By default, `attach` derives the repo id from the GitHub repo name, discovers the default branch and required status checks from GitHub, and stores the discovered values into local config.
48
60
 
61
+ If you also use `review-quill`, its `review-quill/verdict` check can be added to
62
+ the repository's required checks if you want machine review to be part of the
63
+ merge gate. Steward will naturally follow whatever required checks the repo
64
+ configuration uses, but its primary admission gate is still GitHub's formal
65
+ review state plus those required checks.
66
+
49
67
  Validate the setup:
50
68
 
51
69
  ```bash
@@ -90,6 +108,20 @@ In practice, use:
90
108
 
91
109
  When GitHub App auth is configured, Merge Steward mints short-lived installation tokens and uses them for both `gh` API calls and `git clone/fetch/push` over HTTPS. In multi-repo setups it resolves the installation per repository, so repos in different GitHub App installations can still coexist.
92
110
 
111
+ Recommended GitHub App repository permissions:
112
+
113
+ - `Contents: Read and write`
114
+ - `Pull requests: Read and write`
115
+ - `Checks: Read and write`
116
+ - `Metadata: Read-only`
117
+ - `Administration: Read-only`
118
+
119
+ `Contents: Read and write` is the important merge-path permission because the
120
+ steward lands tested speculative SHAs by fast-forward pushing `main`.
121
+ `Administration: Read-only` is not required for merging itself, but it lets the
122
+ doctor and attach/refresh flows discover branch rules and required checks
123
+ without falling back to a local `gh` user token.
124
+
93
125
  The machine-level env files created by `merge-steward init` are:
94
126
 
95
127
  - `~/.config/merge-steward/runtime.env`
@@ -135,7 +167,7 @@ The CLI is a thin local client and does not need direct access to secret credent
135
167
  | `flakyRetries` | CI-only retries before counting toward maxRetries |
136
168
  | `requiredChecks` | Check names that must pass for admission (empty = any green) |
137
169
  | `pollIntervalMs` | Reconciliation loop interval |
138
- | `admissionLabel` | GitHub label that triggers queue admission |
170
+ | `admissionLabel` | Optional GitHub label used as a manual/operator admission nudge |
139
171
  | `mergeQueueCheckName` | GitHub check run name emitted on eviction |
140
172
 
141
173
  `attach` discovers these values from GitHub when possible:
@@ -157,6 +189,16 @@ Configure one webhook on the repository pointing to the steward:
157
189
 
158
190
  The steward uses a single multi-repo webhook endpoint and routes events by `repository.full_name`.
159
191
 
192
+ It can wake up on:
193
+
194
+ - PR label changes
195
+ - review approvals
196
+ - successful check-suite completion
197
+ - pushes to the base branch
198
+
199
+ Even without a webhook, startup and periodic reconcile will still scan open PRs
200
+ and admit anything that already satisfies the gate.
201
+
160
202
  ### Running
161
203
 
162
204
  ```bash
@@ -248,6 +290,22 @@ queued → preparing_head → validating → merging → merged
248
290
  - **evicted**: failed after retry budget, incident created
249
291
  - **dequeued**: manually removed
250
292
 
293
+ ## Merge Gate
294
+
295
+ For the steward path, the real gate is:
296
+
297
+ - GitHub says the PR review state is approved
298
+ - the configured required checks are green
299
+ - the steward's speculative integrated branch also passes CI
300
+
301
+ `review-quill/verdict` only matters if you choose to include it in the repo's
302
+ required checks.
303
+
304
+ GitHub branch protection is still useful as defense in depth, but steward does
305
+ not merge by pressing GitHub's merge button. It fast-forwards `main` to the
306
+ already-tested speculative SHA, so successful queue merges also depend on the
307
+ steward App being allowed to push that result to the protected branch.
308
+
251
309
  ## Interaction with PatchRelay
252
310
 
253
311
  The steward and PatchRelay are independent services that communicate through GitHub:
@@ -263,7 +321,7 @@ Neither service calls the other's API. GitHub is the shared bus.
263
321
  ## Current scope
264
322
 
265
323
  What's implemented:
266
- - **Speculative execution**: cumulative branches (`main+A`, `main+A+B`, `main+A+B+C`) tested in parallel. Configurable depth (default 3, set `speculativeDepth: 1` for serial mode).
324
+ - **Speculative execution**: cumulative branches (`main+A`, `main+A+B`, `main+A+B+C`) tested in parallel. Configurable depth (default 10, set `speculativeDepth: 1` for serial mode).
267
325
  - **Speculative consistency**: when head merges, downstream entries that already passed don't re-test.
268
326
  - **Cascade invalidation**: when mid-chain entry fails, downstream speculative branches are rebuilt without it.
269
327
  - Non-spinning conflict retry: gated on base SHA change
@@ -12,6 +12,18 @@ export interface ServiceGitHubDiscoverResponse {
12
12
  ok: true;
13
13
  discovery: DiscoveredRepoSettings;
14
14
  }
15
+ export interface ServiceGitHubRepoAccessResponse {
16
+ ok: true;
17
+ repoFullName: string;
18
+ baseBranch: string;
19
+ permissions: {
20
+ contents: "none" | "read" | "write";
21
+ pull: boolean;
22
+ push: boolean;
23
+ admin: boolean;
24
+ };
25
+ branchProtected: boolean;
26
+ }
15
27
  export interface ServiceErrorResponse {
16
28
  ok: false;
17
29
  error: string;
package/dist/cli/args.js CHANGED
@@ -63,6 +63,20 @@ export function validateFlags(parsed) {
63
63
  case "repos":
64
64
  assertKnownFlags(parsed, "repos", ["json"]);
65
65
  return;
66
+ case "repo":
67
+ switch (subcommand) {
68
+ case undefined:
69
+ case "list":
70
+ case "show":
71
+ assertKnownFlags(parsed, "repo", ["json"]);
72
+ return;
73
+ case "attach":
74
+ assertKnownFlags(parsed, "repo", ["base-branch", "required-check", "label", "merge-queue-check-name", "refresh", "json"]);
75
+ return;
76
+ default:
77
+ assertKnownFlags(parsed, "repo", []);
78
+ return;
79
+ }
66
80
  case "service":
67
81
  switch (subcommand) {
68
82
  case "install":
@@ -3,9 +3,9 @@ import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs"
3
3
  import path from "node:path";
4
4
  import { loadConfig } from "../../config.js";
5
5
  import { normalizeCheckList } from "../../github-repo-discovery.js";
6
- import { getDefaultConfigPath, getDefaultRepoConfigDir, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultStateDir, getRepoConfigPath, getSystemdUnitPath, } from "../../runtime-paths.js";
6
+ import { getDefaultConfigPath, getDefaultRepoConfigDir, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultStateDir, getSystemdUnitPath, } from "../../runtime-paths.js";
7
7
  import { formatJson, writeOutput } from "../output.js";
8
- import { fetchServiceGitHubAuthStatus, fetchServiceRepoDiscovery, getHomeEnv } from "../system.js";
8
+ import { fetchServiceGitHubAuthStatus, fetchServiceRepoAccess, fetchServiceRepoDiscovery, getHomeEnv, loadRepoConfigById, } from "../system.js";
9
9
  function checkPath(scope, targetPath, writable = false) {
10
10
  if (!existsSync(targetPath)) {
11
11
  return { status: "fail", scope, message: `Missing path: ${targetPath}` };
@@ -35,6 +35,33 @@ async function checkExecutable(scope, command) {
35
35
  }
36
36
  return { status: "fail", scope, message: `${command} is not available in PATH` };
37
37
  }
38
+ function runGhApiJson(pathArg) {
39
+ const result = spawnSync("gh", ["api", pathArg], { encoding: "utf8" });
40
+ if (result.status !== 0) {
41
+ throw new Error(result.stderr.trim() || result.stdout.trim() || `gh api ${pathArg} failed`);
42
+ }
43
+ return JSON.parse(result.stdout);
44
+ }
45
+ function discoverRepoSettingsViaGhCli(repoFullName, branch) {
46
+ const repo = runGhApiJson(`repos/${repoFullName}`);
47
+ const protection = runGhApiJson(`repos/${repoFullName}/branches/${branch}/protection`);
48
+ const requiredChecks = new Set();
49
+ for (const context of protection.required_status_checks?.contexts ?? []) {
50
+ const trimmed = context?.trim();
51
+ if (trimmed)
52
+ requiredChecks.add(trimmed);
53
+ }
54
+ for (const check of protection.required_status_checks?.checks ?? []) {
55
+ const trimmed = check.context?.trim();
56
+ if (trimmed)
57
+ requiredChecks.add(trimmed);
58
+ }
59
+ return {
60
+ defaultBranch: repo.default_branch?.trim() || branch,
61
+ branch,
62
+ requiredChecks: [...requiredChecks].sort((left, right) => left.localeCompare(right)),
63
+ };
64
+ }
38
65
  export async function handleDoctor(parsed, stdout) {
39
66
  const repoId = typeof parsed.flags.get("repo") === "string" ? String(parsed.flags.get("repo")) : undefined;
40
67
  const checks = [];
@@ -109,13 +136,24 @@ export async function handleDoctor(parsed, stdout) {
109
136
  }
110
137
  let repoConfigPath;
111
138
  if (repoId) {
112
- repoConfigPath = getRepoConfigPath(repoId);
113
- if (!existsSync(repoConfigPath)) {
114
- checks.push({ status: "fail", scope: `repo:${repoId}`, message: `Repo config not found: ${repoConfigPath}` });
139
+ let loaded;
140
+ try {
141
+ loaded = loadRepoConfigById(repoId);
142
+ repoConfigPath = loaded.configPath;
143
+ }
144
+ catch (error) {
145
+ checks.push({
146
+ status: "fail",
147
+ scope: `repo:${repoId}`,
148
+ message: error instanceof Error ? error.message : String(error),
149
+ });
150
+ }
151
+ if (!loaded) {
152
+ // already reported above
115
153
  }
116
154
  else {
117
155
  try {
118
- const config = loadConfig(repoConfigPath);
156
+ const config = loaded.config;
119
157
  mkdirSync(path.dirname(config.database.path), { recursive: true });
120
158
  mkdirSync(path.dirname(config.clonePath), { recursive: true });
121
159
  checks.push({ status: "pass", scope: `repo:${repoId}`, message: `Repo config is valid for ${config.repoFullName}` });
@@ -128,8 +166,22 @@ export async function handleDoctor(parsed, stdout) {
128
166
  checks.push(checkPath(`repo:${repoId}:clone-parent`, path.dirname(config.clonePath), true));
129
167
  if (serviceGitHubStatus) {
130
168
  try {
131
- const response = await fetchServiceRepoDiscovery(config.repoFullName, { baseBranch: config.baseBranch });
132
- const discovered = response.discovery;
169
+ let discoveredSource;
170
+ let discovered;
171
+ try {
172
+ const response = await fetchServiceRepoDiscovery(config.repoFullName, { baseBranch: config.baseBranch });
173
+ discovered = response.discovery;
174
+ discoveredSource = "service";
175
+ }
176
+ catch (error) {
177
+ discovered = discoverRepoSettingsViaGhCli(config.repoFullName, config.baseBranch);
178
+ discoveredSource = "gh";
179
+ checks.push({
180
+ status: "warn",
181
+ scope: `repo:${repoId}:github-discovery`,
182
+ message: `Service discovery failed, used local gh fallback: ${error instanceof Error ? error.message : String(error)}`,
183
+ });
184
+ }
133
185
  checks.push({
134
186
  status: discovered.defaultBranch === config.baseBranch ? "pass" : "warn",
135
187
  scope: `repo:${repoId}:github-default-branch`,
@@ -139,24 +191,49 @@ export async function handleDoctor(parsed, stdout) {
139
191
  });
140
192
  const configuredChecks = normalizeCheckList(config.requiredChecks);
141
193
  const discoveredChecks = normalizeCheckList(discovered.requiredChecks);
142
- const checksMatch = configuredChecks.length === discoveredChecks.length
143
- && configuredChecks.every((value, index) => value === discoveredChecks[index]);
194
+ const configuredSet = new Set(configuredChecks);
195
+ const discoveredSet = new Set(discoveredChecks);
196
+ const missingFromLocal = discoveredChecks.filter((value) => !configuredSet.has(value));
197
+ const extraLocal = configuredChecks.filter((value) => !discoveredSet.has(value));
198
+ const checksMatch = missingFromLocal.length === 0 && extraLocal.length === 0;
199
+ const localSuperset = missingFromLocal.length === 0 && extraLocal.length > 0;
144
200
  checks.push({
145
- status: checksMatch ? "pass" : "warn",
201
+ status: checksMatch || localSuperset ? "pass" : "warn",
146
202
  scope: `repo:${repoId}:github-required-checks`,
147
203
  message: checksMatch
148
204
  ? (configuredChecks.length > 0
149
205
  ? `Local required checks match GitHub for ${config.baseBranch}`
150
206
  : `No required checks configured locally and GitHub does not require status checks for ${config.baseBranch}`)
151
- : `Local required checks [${configuredChecks.join(", ") || "(none)"}] differ from GitHub [${discoveredChecks.join(", ") || "(none)"}] for ${config.baseBranch}`,
207
+ : localSuperset
208
+ ? `Local required checks extend GitHub for ${config.baseBranch} with additional gates [${extraLocal.join(", ")}]`
209
+ : `Local required checks [${configuredChecks.join(", ") || "(none)"}] differ from GitHub [${discoveredChecks.join(", ") || "(none)"}] for ${config.baseBranch}${discoveredSource === "gh" ? " (via gh fallback)" : ""}`,
152
210
  });
153
- for (const warning of discovered.warnings) {
211
+ for (const warning of discovered.warnings ?? []) {
154
212
  checks.push({
155
213
  status: "warn",
156
214
  scope: `repo:${repoId}:github-discovery`,
157
215
  message: warning,
158
216
  });
159
217
  }
218
+ try {
219
+ const access = await fetchServiceRepoAccess(config.repoFullName, { baseBranch: config.baseBranch });
220
+ checks.push({
221
+ status: access.permissions.push ? "pass" : "fail",
222
+ scope: `repo:${repoId}:github-push-access`,
223
+ message: access.permissions.push
224
+ ? access.branchProtected
225
+ ? `Steward App has Contents:${access.permissions.contents} for ${config.repoFullName}; ${config.baseBranch} is protected, so final fast-forward pushes still depend on branch rules allowing the App`
226
+ : `Steward App has Contents:${access.permissions.contents} for ${config.repoFullName}`
227
+ : `Steward App does not have Contents:write for ${config.repoFullName}; queue merges to ${config.baseBranch} will fail`,
228
+ });
229
+ }
230
+ catch (error) {
231
+ checks.push({
232
+ status: "warn",
233
+ scope: `repo:${repoId}:github-push-access`,
234
+ message: `Could not verify steward push access: ${error instanceof Error ? error.message : String(error)}`,
235
+ });
236
+ }
160
237
  }
161
238
  catch (error) {
162
239
  checks.push({
@@ -1,2 +1,4 @@
1
+ import { type QueueWatchSnapshot } from "../../types.ts";
1
2
  import type { ParsedArgs, Output } from "../types.ts";
3
+ export declare function formatQueueStatusText(source: "service" | "database", snapshot: QueueWatchSnapshot): string;
2
4
  export declare function handleQueue(parsed: ParsedArgs, stdout: Output): Promise<number>;
@@ -1,6 +1,6 @@
1
1
  import { SqliteStore } from "../../db/sqlite-store.js";
2
- import { getRepoConfigPath } from "../../runtime-paths.js";
3
2
  import { buildSummary } from "../../service.js";
3
+ import { TERMINAL_STATUSES } from "../../types.js";
4
4
  import { UsageError } from "../types.js";
5
5
  import { parseIntegerFlag } from "../args.js";
6
6
  import { formatJson, writeOutput } from "../output.js";
@@ -40,12 +40,57 @@ async function readQueueSnapshot(config, eventLimit) {
40
40
  }
41
41
  }
42
42
  }
43
+ function firstLine(text) {
44
+ if (!text)
45
+ return null;
46
+ const line = text.split(/\r?\n/, 1)[0]?.trim();
47
+ return line ? line : null;
48
+ }
49
+ export function formatQueueStatusText(source, snapshot) {
50
+ return [
51
+ `Repo: ${snapshot.repoId} (${snapshot.repoFullName})`,
52
+ `Source: ${source}`,
53
+ `Base branch: ${snapshot.baseBranch}`,
54
+ `Active entries: ${snapshot.summary.active}`,
55
+ `Queued: ${snapshot.summary.queued} preparing: ${snapshot.summary.preparingHead} validating: ${snapshot.summary.validating} merging: ${snapshot.summary.merging}`,
56
+ `Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued}`,
57
+ snapshot.summary.headPrNumber ? `Head PR: #${snapshot.summary.headPrNumber}` : "Head PR: none",
58
+ ...(snapshot.runtime.lastTickOutcome === "failed"
59
+ ? [
60
+ "Last tick: failed",
61
+ ...(firstLine(snapshot.runtime.lastTickError) ? [`Last error: ${firstLine(snapshot.runtime.lastTickError)}`] : []),
62
+ ]
63
+ : []),
64
+ ...(snapshot.queueBlock
65
+ ? [
66
+ `Queue blocked: ${snapshot.queueBlock.reason} on ${snapshot.queueBlock.baseBranch}${snapshot.queueBlock.baseSha ? ` @ ${snapshot.queueBlock.baseSha.slice(0, 8)}` : ""}`,
67
+ `Base failures: ${snapshot.queueBlock.failingChecks.length > 0 ? snapshot.queueBlock.failingChecks.map((check) => check.name).join(", ") : "(none)"}`,
68
+ ...(snapshot.queueBlock.pendingChecks.length > 0
69
+ ? [`Base pending: ${snapshot.queueBlock.pendingChecks.map((check) => check.name).join(", ")}`]
70
+ : []),
71
+ ]
72
+ : []),
73
+ "",
74
+ "Entries:",
75
+ ...(snapshot.entries.length > 0
76
+ ? snapshot.entries.map((entry) => `- #${entry.prNumber} ${entry.status} pos=${entry.position} branch=${entry.branch}`)
77
+ : ["- (none)"]),
78
+ ].join("\n") + "\n";
79
+ }
43
80
  function findEntryForInspect(store, repoId, options) {
44
81
  if (options.entryId) {
45
82
  return store.getEntry(options.entryId);
46
83
  }
47
84
  if (options.prNumber !== undefined) {
48
- return store.listAll(repoId).find((entry) => entry.prNumber === options.prNumber);
85
+ const matches = store.listAll(repoId).filter((entry) => entry.prNumber === options.prNumber);
86
+ matches.sort((left, right) => {
87
+ const leftActive = TERMINAL_STATUSES.includes(left.status) ? 0 : 1;
88
+ const rightActive = TERMINAL_STATUSES.includes(right.status) ? 0 : 1;
89
+ if (leftActive !== rightActive)
90
+ return rightActive - leftActive;
91
+ return right.position - left.position;
92
+ });
93
+ return matches[0];
49
94
  }
50
95
  return undefined;
51
96
  }
@@ -73,8 +118,9 @@ export async function handleQueue(parsed, stdout) {
73
118
  }
74
119
  if (subcommand === "watch") {
75
120
  const repoId = resolveRepoId(parsed, 2, "queue");
121
+ const { configPath } = loadRepoConfigById(repoId);
76
122
  const { startWatch } = await import("../../watch/index.js");
77
- await startWatch(getRepoConfigPath(repoId), parseIntegerFlag(parsed.flags.get("pr"), "--pr"));
123
+ await startWatch(configPath, parseIntegerFlag(parsed.flags.get("pr"), "--pr"));
78
124
  return 0;
79
125
  }
80
126
  const repoId = resolveRepoId(parsed, 2, "queue");
@@ -87,29 +133,7 @@ export async function handleQueue(parsed, stdout) {
87
133
  writeOutput(stdout, formatJson(payload));
88
134
  return 0;
89
135
  }
90
- writeOutput(stdout, [
91
- `Repo: ${snapshot.repoId} (${snapshot.repoFullName})`,
92
- `Source: ${source}`,
93
- `Base branch: ${snapshot.baseBranch}`,
94
- `Active entries: ${snapshot.summary.active}`,
95
- `Queued: ${snapshot.summary.queued} preparing: ${snapshot.summary.preparingHead} validating: ${snapshot.summary.validating} merging: ${snapshot.summary.merging}`,
96
- `Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued}`,
97
- snapshot.summary.headPrNumber ? `Head PR: #${snapshot.summary.headPrNumber}` : "Head PR: none",
98
- ...(snapshot.queueBlock
99
- ? [
100
- `Queue blocked: ${snapshot.queueBlock.reason} on ${snapshot.queueBlock.baseBranch}${snapshot.queueBlock.baseSha ? ` @ ${snapshot.queueBlock.baseSha.slice(0, 8)}` : ""}`,
101
- `Base failures: ${snapshot.queueBlock.failingChecks.length > 0 ? snapshot.queueBlock.failingChecks.map((check) => check.name).join(", ") : "(none)"}`,
102
- ...(snapshot.queueBlock.pendingChecks.length > 0
103
- ? [`Base pending: ${snapshot.queueBlock.pendingChecks.map((check) => check.name).join(", ")}`]
104
- : []),
105
- ]
106
- : []),
107
- "",
108
- "Entries:",
109
- ...(snapshot.entries.length > 0
110
- ? snapshot.entries.map((entry) => `- #${entry.prNumber} ${entry.status} pos=${entry.position} branch=${entry.branch}`)
111
- : ["- (none)"]),
112
- ].join("\n") + "\n");
136
+ writeOutput(stdout, formatQueueStatusText(source, snapshot));
113
137
  return 0;
114
138
  }
115
139
  if (subcommand === "show") {
package/dist/cli/help.js CHANGED
@@ -9,15 +9,16 @@ function rootHelpText() {
9
9
  "",
10
10
  "Happy path:",
11
11
  " 1. merge-steward init <public-base-url>",
12
- " 2. merge-steward attach <owner/repo>",
12
+ " 2. merge-steward repo attach <owner/repo>",
13
13
  " 3. merge-steward doctor --repo <id>",
14
14
  " 4. merge-steward service status",
15
15
  " 5. merge-steward queue status --repo <id>",
16
16
  "",
17
17
  "Everyday commands:",
18
- " attach <owner/repo> [--base-branch <branch>] [--required-check <checks>] [--label <label>] [--refresh] [--json]",
18
+ " repo attach <owner/repo> [--base-branch <branch>] [--required-check <checks>] [--label <label>] [--refresh] [--json]",
19
19
  " Create or update a repo config, auto-discover GitHub defaults, and restart the service",
20
- " repos [<id>] [--json] List attached repositories or show one repo config",
20
+ " repo list [--json] List attached repositories",
21
+ " repo show <id> [--json] Show one repo config",
21
22
  " doctor [--repo <id>] [--json] Validate config, secrets, auth, and required binaries",
22
23
  " service status [--json] Show systemd state",
23
24
  " service logs [--lines <count>] [--json] Show recent journal logs",
@@ -40,18 +41,20 @@ function rootHelpText() {
40
41
  "",
41
42
  "Command help:",
42
43
  " merge-steward help",
43
- " merge-steward help repos",
44
+ " merge-steward help repo",
44
45
  " merge-steward help service",
45
46
  " merge-steward help queue",
46
47
  ].join("\n");
47
48
  }
48
- function reposHelpText() {
49
+ function repoHelpText() {
49
50
  return [
50
51
  "Usage:",
51
- " merge-steward attach <owner/repo> [options]",
52
- " merge-steward repos [<id>] [--json]",
52
+ " merge-steward repo attach <owner/repo> [options]",
53
+ " merge-steward repo attach <id> <owner/repo> [options]",
54
+ " merge-steward repo list [--json]",
55
+ " merge-steward repo show <id> [--json]",
53
56
  "",
54
- "Options for `attach`:",
57
+ "Options for `repo attach`:",
55
58
  " --base-branch <branch> Base branch to land into (default: main)",
56
59
  " --required-check <checks> Comma-separated required check names",
57
60
  " --label <label> Admission label (default: queue)",
@@ -60,12 +63,16 @@ function reposHelpText() {
60
63
  " --refresh Re-discover base branch and required checks from GitHub",
61
64
  " --json Emit structured JSON",
62
65
  "",
66
+ "Compatibility aliases:",
67
+ " merge-steward attach ... Alias for `merge-steward repo attach ...`",
68
+ " merge-steward repos ... Alias for `merge-steward repo list/show ...`",
69
+ "",
63
70
  "Examples:",
64
- " merge-steward attach owner/repo",
65
- " merge-steward attach owner/repo --refresh",
66
- " merge-steward attach owner/repo --label queue",
67
- " merge-steward repos",
68
- " merge-steward repos app",
71
+ " merge-steward repo attach owner/repo",
72
+ " merge-steward repo attach owner/repo --refresh",
73
+ " merge-steward repo attach owner/repo --label queue",
74
+ " merge-steward repo list",
75
+ " merge-steward repo show app",
69
76
  ].join("\n");
70
77
  }
71
78
  function serviceHelpText() {
@@ -95,8 +102,9 @@ function queueHelpText() {
95
102
  }
96
103
  export function helpTextFor(topic) {
97
104
  switch (topic) {
105
+ case "repo":
98
106
  case "repos":
99
- return reposHelpText();
107
+ return repoHelpText();
100
108
  case "service":
101
109
  return serviceHelpText();
102
110
  case "queue":
@@ -1,6 +1,6 @@
1
1
  import { type StewardConfig } from "../config.ts";
2
2
  import { parseHomeConfigObject } from "../steward-home.ts";
3
- import type { ServiceGitHubAuthStatus, ServiceGitHubDiscoverResponse } from "../admin-types.ts";
3
+ import type { ServiceGitHubAuthStatus, ServiceGitHubDiscoverResponse, ServiceGitHubRepoAccessResponse } from "../admin-types.ts";
4
4
  import type { ParsedArgs, HelpTopic, 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>;
@@ -47,3 +47,6 @@ export declare function fetchServiceGitHubAuthStatus(): Promise<ServiceGitHubAut
47
47
  export declare function fetchServiceRepoDiscovery(repoFullName: string, options?: {
48
48
  baseBranch?: string;
49
49
  }): Promise<ServiceGitHubDiscoverResponse>;
50
+ export declare function fetchServiceRepoAccess(repoFullName: string, options: {
51
+ baseBranch: string;
52
+ }): Promise<ServiceGitHubRepoAccessResponse>;
@@ -70,10 +70,25 @@ export function listRepoConfigs() {
70
70
  };
71
71
  });
72
72
  }
73
+ function findConfiguredRepoConfig(repoRef) {
74
+ const normalized = repoRef.trim();
75
+ if (!normalized)
76
+ return undefined;
77
+ return listRepoConfigs().find((repo) => (repo.repoId === normalized
78
+ || repo.repoFullName === normalized));
79
+ }
73
80
  export function loadRepoConfigById(repoId) {
74
- const configPath = getRepoConfigPath(repoId);
81
+ const exactPath = getRepoConfigPath(repoId);
82
+ const resolved = existsSync(exactPath)
83
+ ? { configPath: exactPath, repoId }
84
+ : findConfiguredRepoConfig(repoId);
85
+ const configPath = resolved?.configPath ?? exactPath;
75
86
  if (!existsSync(configPath)) {
76
- throw new UsageError(`Repo config not found: ${configPath}. Run \`merge-steward attach ${repoId} <owner/repo>\` first.`, "repos");
87
+ const configured = listRepoConfigs();
88
+ const configuredHint = configured.length > 0
89
+ ? ` Configured repos: ${configured.map((repo) => repo.repoId).join(", ")}.`
90
+ : "";
91
+ throw new UsageError(`Repo config not found for ${repoId}: ${configPath}. Run \`merge-steward attach ${repoId} <owner/repo>\` first.${configuredHint}`, "repos");
77
92
  }
78
93
  return {
79
94
  configPath,
@@ -193,3 +208,12 @@ export async function fetchServiceRepoDiscovery(repoFullName, options) {
193
208
  },
194
209
  });
195
210
  }
211
+ export async function fetchServiceRepoAccess(repoFullName, options) {
212
+ return await fetchGatewayJson("/admin/github/repo-access", {
213
+ method: "POST",
214
+ body: {
215
+ repoFullName,
216
+ baseBranch: options.baseBranch,
217
+ },
218
+ });
219
+ }
@@ -2,7 +2,7 @@ export interface ParsedArgs {
2
2
  positionals: string[];
3
3
  flags: Map<string, string | boolean>;
4
4
  }
5
- export type HelpTopic = "root" | "repos" | "service" | "queue";
5
+ export type HelpTopic = "root" | "repo" | "repos" | "service" | "queue";
6
6
  export interface Output {
7
7
  write(chunk: string): boolean;
8
8
  }