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.
- package/README.md +69 -11
- package/dist/admin-types.d.ts +12 -0
- package/dist/cli/args.js +14 -0
- package/dist/cli/commands/doctor.js +90 -13
- package/dist/cli/commands/queue.d.ts +2 -0
- package/dist/cli/commands/queue.js +50 -26
- package/dist/cli/help.js +22 -14
- package/dist/cli/system.d.ts +4 -1
- package/dist/cli/system.js +26 -2
- package/dist/cli/types.d.ts +1 -1
- package/dist/cli.js +32 -16
- package/dist/config.js +1 -1
- package/dist/exec.js +63 -23
- package/dist/github/actions-runner.js +5 -1
- package/dist/github/pr-client.d.ts +5 -0
- package/dist/github/pr-client.js +18 -0
- package/dist/github/shell-git.js +1 -0
- package/dist/github-auth.d.ts +1 -0
- package/dist/github-auth.js +1 -1
- package/dist/github-repo-discovery.js +36 -1
- package/dist/http-multi.d.ts +5 -1
- package/dist/http-multi.js +19 -0
- package/dist/install.js +1 -1
- package/dist/interfaces.d.ts +6 -0
- package/dist/invalidation.d.ts +13 -0
- package/dist/invalidation.js +15 -0
- package/dist/reconciler.js +21 -10
- package/dist/server.js +54 -1
- package/dist/service.d.ts +1 -2
- package/dist/service.js +31 -32
- package/dist/types.d.ts +1 -0
- package/dist/watch/DetailView.js +1 -1
- package/dist/watch/EntryStateGraph.js +1 -1
- package/dist/watch/QueueListView.js +8 -5
- package/dist/watch/StatusBar.js +1 -1
- package/dist/watch/display-filter.js +35 -24
- package/dist/watch/format.d.ts +1 -0
- package/dist/watch/format.js +14 -3
- package/dist/watch/state-visualization.js +8 -1
- package/dist/webhook-handler.js +1 -1
- package/package.json +1 -1
- package/service.env.example +11 -0
package/README.md
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
# merge-steward
|
|
2
2
|
|
|
3
|
-
|
|
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,
|
|
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
|
|
10
|
-
2. The steward
|
|
11
|
-
3.
|
|
12
|
-
4. The steward
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
package/dist/admin-types.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 =
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
143
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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(
|
|
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
|
-
"
|
|
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
|
|
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
|
|
49
|
+
function repoHelpText() {
|
|
49
50
|
return [
|
|
50
51
|
"Usage:",
|
|
51
|
-
" merge-steward attach <owner/repo> [options]",
|
|
52
|
-
" merge-steward
|
|
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
|
|
68
|
-
" merge-steward
|
|
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
|
|
107
|
+
return repoHelpText();
|
|
100
108
|
case "service":
|
|
101
109
|
return serviceHelpText();
|
|
102
110
|
case "queue":
|
package/dist/cli/system.d.ts
CHANGED
|
@@ -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>;
|
package/dist/cli/system.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/cli/types.d.ts
CHANGED
|
@@ -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
|
}
|