merge-steward 0.10.2 → 0.10.4
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 +14 -8
- package/dist/admin-types.d.ts +17 -0
- package/dist/cli/args.d.ts +0 -1
- package/dist/cli/args.js +2 -7
- package/dist/cli/commands/attach.js +1 -9
- package/dist/cli/commands/doctor.js +2 -17
- package/dist/cli/commands/queue.js +48 -6
- package/dist/cli/commands/repos.js +0 -2
- package/dist/cli/help.js +2 -3
- package/dist/cli/system.d.ts +7 -2
- package/dist/cli/system.js +14 -2
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/github/actions-runner.d.ts +2 -2
- package/dist/github/actions-runner.js +6 -5
- package/dist/github-policy.d.ts +39 -0
- package/dist/github-policy.js +78 -0
- package/dist/github-repo-discovery.js +2 -2
- package/dist/http-multi.d.ts +2 -8
- package/dist/http-multi.js +89 -42
- package/dist/install.d.ts +0 -2
- package/dist/install.js +0 -3
- package/dist/reconciler-core.d.ts +35 -0
- package/dist/reconciler-core.js +19 -0
- package/dist/reconciler-evict.d.ts +12 -0
- package/dist/reconciler-evict.js +70 -0
- package/dist/reconciler-merge.d.ts +3 -0
- package/dist/reconciler-merge.js +110 -0
- package/dist/reconciler-prepare.d.ts +3 -0
- package/dist/reconciler-prepare.js +166 -0
- package/dist/reconciler-sanitize.d.ts +3 -0
- package/dist/reconciler-sanitize.js +36 -0
- package/dist/reconciler-validate.d.ts +3 -0
- package/dist/reconciler-validate.js +58 -0
- package/dist/reconciler.d.ts +2 -17
- package/dist/reconciler.js +8 -402
- package/dist/repo-runtime.d.ts +18 -0
- package/dist/repo-runtime.js +1 -0
- package/dist/server.d.ts +0 -8
- package/dist/server.js +74 -13
- package/dist/service-queue.d.ts +29 -0
- package/dist/service-queue.js +183 -0
- package/dist/service-runtime.d.ts +38 -0
- package/dist/service-runtime.js +169 -0
- package/dist/service-watch.d.ts +20 -0
- package/dist/service-watch.js +48 -0
- package/dist/service.d.ts +19 -39
- package/dist/service.js +47 -391
- package/dist/types.d.ts +10 -2
- package/dist/watch/App.js +85 -40
- package/dist/watch/DetailView.d.ts +3 -2
- package/dist/watch/DetailView.js +4 -2
- package/dist/watch/FreshnessBadge.d.ts +2 -1
- package/dist/watch/FreshnessBadge.js +2 -2
- package/dist/watch/OverviewView.d.ts +2 -1
- package/dist/watch/OverviewView.js +2 -2
- package/dist/watch/ProjectDetailView.js +8 -2
- package/dist/watch/QueueListView.js +3 -1
- package/dist/watch/StatusBar.d.ts +2 -1
- package/dist/watch/StatusBar.js +2 -2
- package/dist/watch/api.d.ts +12 -0
- package/dist/watch/api.js +21 -3
- package/dist/watch/dashboard-model.d.ts +5 -1
- package/dist/watch/dashboard-model.js +63 -0
- package/dist/watch/format.js +19 -1
- package/dist/watch/freshness.d.ts +1 -1
- package/dist/watch/freshness.js +7 -1
- package/dist/watch/state-visualization.js +7 -3
- package/dist/webhook-handler.d.ts +5 -0
- package/dist/webhook-handler.js +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,7 +58,7 @@ Add one repo-scoped steward instance:
|
|
|
58
58
|
merge-steward attach owner/repo
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
That writes `~/.config/merge-steward/repos/<derived-id>.json`. By default, `attach` derives the repo id from the GitHub repo name
|
|
61
|
+
That writes `~/.config/merge-steward/repos/<derived-id>.json`. By default, `attach` derives the repo id from the GitHub repo name and discovers the default branch from GitHub. Required checks are not stored locally; the running steward reads GitHub branch protection as the source of truth.
|
|
62
62
|
|
|
63
63
|
If you also use `review-quill`, its `review-quill/verdict` check can be added to
|
|
64
64
|
the repository's required checks if you want machine review to be part of the
|
|
@@ -145,7 +145,6 @@ The CLI is a thin local client and does not need direct access to secret credent
|
|
|
145
145
|
"clonePath": "~/.local/state/merge-steward/repos/app",
|
|
146
146
|
"maxRetries": 2,
|
|
147
147
|
"flakyRetries": 1,
|
|
148
|
-
"requiredChecks": ["test", "lint"],
|
|
149
148
|
"pollIntervalMs": 30000,
|
|
150
149
|
"admissionLabel": "queue",
|
|
151
150
|
"mergeQueueCheckName": "merge-steward/queue",
|
|
@@ -167,7 +166,6 @@ The CLI is a thin local client and does not need direct access to secret credent
|
|
|
167
166
|
| `clonePath` | Local clone directory (created on first run) |
|
|
168
167
|
| `maxRetries` | Rebase/CI retry attempts before eviction |
|
|
169
168
|
| `flakyRetries` | CI-only retries before counting toward maxRetries |
|
|
170
|
-
| `requiredChecks` | Check names that must pass for admission (empty = any green) |
|
|
171
169
|
| `pollIntervalMs` | Reconciliation loop interval |
|
|
172
170
|
| `admissionLabel` | Optional GitHub label used as a manual/operator admission nudge |
|
|
173
171
|
| `mergeQueueCheckName` | GitHub check run name emitted on eviction |
|
|
@@ -176,9 +174,8 @@ The CLI is a thin local client and does not need direct access to secret credent
|
|
|
176
174
|
|
|
177
175
|
- `repoId` defaults to the repo name portion of `owner/repo`
|
|
178
176
|
- `baseBranch` defaults to the GitHub default branch
|
|
179
|
-
- `requiredChecks` default to the active `required_status_checks` branch rules for the configured base branch
|
|
180
177
|
|
|
181
|
-
Pass `--refresh` to re-discover base branch
|
|
178
|
+
Pass `--refresh` to re-discover the base branch for an existing repo config. `merge-steward doctor --repo <id>` reports the GitHub-required checks currently enforced for that branch.
|
|
182
179
|
|
|
183
180
|
### GitHub Webhook
|
|
184
181
|
|
|
@@ -187,7 +184,7 @@ Configure one webhook on the repository pointing to the steward:
|
|
|
187
184
|
- **Payload URL:** `https://queue.example.com/webhooks/github`
|
|
188
185
|
- **Content type:** `application/json`
|
|
189
186
|
- **Secret:** same as `MERGE_STEWARD_WEBHOOK_SECRET` or the `merge-steward-webhook-secret` systemd credential
|
|
190
|
-
- **Events:** Pull requests, Pull request reviews, Check suites, Pushes
|
|
187
|
+
- **Events:** Pull requests, Pull request reviews, Check suites, Pushes, Branch protection rules, Repository rulesets
|
|
191
188
|
|
|
192
189
|
The steward uses a single multi-repo webhook endpoint and routes events by `repository.full_name`.
|
|
193
190
|
|
|
@@ -197,9 +194,14 @@ It can wake up on:
|
|
|
197
194
|
- review approvals
|
|
198
195
|
- successful check-suite completion
|
|
199
196
|
- pushes to the base branch
|
|
197
|
+
- branch protection edits
|
|
198
|
+
- repository ruleset edits
|
|
200
199
|
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
On startup, the steward reconciles GitHub protection for every attached repo. Policy changes are normally learned from GitHub policy webhooks. If a merge is rejected unexpectedly, the steward performs a guarded one-shot policy refresh to recover from a missed webhook without polling GitHub continuously.
|
|
201
|
+
|
|
202
|
+
The dashboard project view and `merge-steward queue status --repo <id>` also show the live GitHub-required checks and the last policy refresh, so an operator can tell whether a queue pause came from a policy change or from the branch state itself.
|
|
203
|
+
|
|
204
|
+
Current policy decision: Merge Steward uses the same GitHub-required check names for both PR/spec admission and current-`main` drift detection. For example, if branch protection requires `Tests`, the steward treats `Tests` as both the merge gate for candidate commits and the signal that current `main` is still in-policy. This is an explicit simplification for now and may be split into separate merge-gate and main-health signals later.
|
|
203
205
|
|
|
204
206
|
### Running
|
|
205
207
|
|
|
@@ -230,6 +232,7 @@ The first screen shows all configured projects with:
|
|
|
230
232
|
- readable queue stats
|
|
231
233
|
- a compact queue chain like `#123 ● #124 ○`
|
|
232
234
|
- clear bad states such as blocked, stuck, or needs attention
|
|
235
|
+
- explicit startup states such as `Initializing` and `Init failed` for repo-local boot problems
|
|
233
236
|
|
|
234
237
|
Press `Enter` on a project to open the second screen. That project detail view shows:
|
|
235
238
|
|
|
@@ -238,6 +241,7 @@ Press `Enter` on a project to open the second screen. That project detail view s
|
|
|
238
241
|
- recent queue activity in plain language
|
|
239
242
|
- incidents for evicted PRs
|
|
240
243
|
- direct actions like reconcile and dequeue
|
|
244
|
+
- live GitHub-required checks plus the last policy refresh
|
|
241
245
|
|
|
242
246
|
Use `merge-steward dashboard --repo <id>` to open the project detail screen directly. Use `--pr <number>` to preselect a PR when you already know what you need to inspect.
|
|
243
247
|
|
|
@@ -253,6 +257,8 @@ Controls:
|
|
|
253
257
|
|
|
254
258
|
### Validation, Visibility, And Troubleshooting
|
|
255
259
|
|
|
260
|
+
The gateway binds its HTTP port before repo initialization finishes. Each repo then initializes independently in the background, so a bad clone or GitHub discovery problem stays local to that repo instead of taking down the whole dashboard.
|
|
261
|
+
|
|
256
262
|
These are the first commands to reach for after setup or when a queue looks wrong:
|
|
257
263
|
|
|
258
264
|
```bash
|
package/dist/admin-types.d.ts
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
import type { DiscoveredRepoSettings } from "./github-repo-discovery.ts";
|
|
2
|
+
export type RepoRuntimeState = "initializing" | "ready" | "failed";
|
|
3
|
+
export interface RepoRuntimeStatus {
|
|
4
|
+
repoId: string;
|
|
5
|
+
repoFullName: string;
|
|
6
|
+
baseBranch: string;
|
|
7
|
+
state: RepoRuntimeState;
|
|
8
|
+
startedAt: string;
|
|
9
|
+
readyAt?: string | undefined;
|
|
10
|
+
failedAt?: string | undefined;
|
|
11
|
+
lastError?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
export interface ServiceHealthResponse {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
startupComplete: boolean;
|
|
16
|
+
repos: RepoRuntimeStatus[];
|
|
17
|
+
}
|
|
2
18
|
export interface ServiceGitHubAuthStatus {
|
|
3
19
|
mode: "none" | "app";
|
|
4
20
|
configured: boolean;
|
|
@@ -27,4 +43,5 @@ export interface ServiceGitHubRepoAccessResponse {
|
|
|
27
43
|
export interface ServiceErrorResponse {
|
|
28
44
|
ok: false;
|
|
29
45
|
error: string;
|
|
46
|
+
code?: string;
|
|
30
47
|
}
|
package/dist/cli/args.d.ts
CHANGED
|
@@ -3,5 +3,4 @@ export declare function parseArgs(argv: string[]): ParsedArgs;
|
|
|
3
3
|
export declare function hasHelpFlag(parsed: ParsedArgs): boolean;
|
|
4
4
|
export declare function assertKnownFlags(parsed: ParsedArgs, helpTopic: HelpTopic, allowedFlags: string[]): void;
|
|
5
5
|
export declare function validateFlags(parsed: ParsedArgs): void;
|
|
6
|
-
export declare function parseCsvFlag(value: string | boolean | undefined): string[];
|
|
7
6
|
export declare function parseIntegerFlag(value: string | boolean | undefined, label: string): number | undefined;
|
package/dist/cli/args.js
CHANGED
|
@@ -63,7 +63,7 @@ export function validateFlags(parsed) {
|
|
|
63
63
|
assertKnownFlags(parsed, "root", ["repo", "pr"]);
|
|
64
64
|
return;
|
|
65
65
|
case "attach":
|
|
66
|
-
assertKnownFlags(parsed, "repos", ["base-branch", "
|
|
66
|
+
assertKnownFlags(parsed, "repos", ["base-branch", "label", "merge-queue-check-name", "refresh", "json"]);
|
|
67
67
|
return;
|
|
68
68
|
case "repos":
|
|
69
69
|
assertKnownFlags(parsed, "repos", ["json"]);
|
|
@@ -76,7 +76,7 @@ export function validateFlags(parsed) {
|
|
|
76
76
|
assertKnownFlags(parsed, "repo", ["json"]);
|
|
77
77
|
return;
|
|
78
78
|
case "attach":
|
|
79
|
-
assertKnownFlags(parsed, "repo", ["base-branch", "
|
|
79
|
+
assertKnownFlags(parsed, "repo", ["base-branch", "label", "merge-queue-check-name", "refresh", "json"]);
|
|
80
80
|
return;
|
|
81
81
|
default:
|
|
82
82
|
assertKnownFlags(parsed, "repo", []);
|
|
@@ -122,11 +122,6 @@ export function validateFlags(parsed) {
|
|
|
122
122
|
assertKnownFlags(parsed, "root", []);
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
-
export function parseCsvFlag(value) {
|
|
126
|
-
if (typeof value !== "string")
|
|
127
|
-
return [];
|
|
128
|
-
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
129
|
-
}
|
|
130
125
|
export function parseIntegerFlag(value, label) {
|
|
131
126
|
if (typeof value !== "string")
|
|
132
127
|
return undefined;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { upsertRepoConfig } from "../../install.js";
|
|
2
2
|
import { UsageError } from "../types.js";
|
|
3
|
-
import { parseCsvFlag } from "../args.js";
|
|
4
3
|
import { formatJson, writeOutput } from "../output.js";
|
|
5
4
|
import { runSystemctl } from "../system.js";
|
|
6
5
|
import { fetchServiceRepoDiscovery, listRepoConfigs } from "../system.js";
|
|
@@ -42,15 +41,13 @@ export async function handleAttach(parsed, stdout, runCommand) {
|
|
|
42
41
|
const { repoId, repoFullName } = parseAttachTarget(parsed);
|
|
43
42
|
const existing = listRepoConfigs().find((repo) => repo.repoId === repoId || repo.repoFullName === repoFullName);
|
|
44
43
|
const explicitBaseBranch = typeof parsed.flags.get("base-branch") === "string" ? String(parsed.flags.get("base-branch")) : undefined;
|
|
45
|
-
const explicitRequiredChecks = parseCsvFlag(parsed.flags.get("required-check"));
|
|
46
44
|
const admissionLabel = typeof parsed.flags.get("label") === "string" ? String(parsed.flags.get("label")) : undefined;
|
|
47
45
|
const mergeQueueCheckName = typeof parsed.flags.get("merge-queue-check-name") === "string"
|
|
48
46
|
? String(parsed.flags.get("merge-queue-check-name"))
|
|
49
47
|
: undefined;
|
|
50
48
|
const refresh = parsed.flags.get("refresh") === true;
|
|
51
49
|
const shouldDiscoverBaseBranch = !explicitBaseBranch && (!existing || refresh);
|
|
52
|
-
const
|
|
53
|
-
const needsDiscovery = shouldDiscoverBaseBranch || shouldDiscoverRequiredChecks;
|
|
50
|
+
const needsDiscovery = shouldDiscoverBaseBranch || refresh;
|
|
54
51
|
let discovered;
|
|
55
52
|
const warnings = [];
|
|
56
53
|
if (needsDiscovery) {
|
|
@@ -70,14 +67,10 @@ export async function handleAttach(parsed, stdout, runCommand) {
|
|
|
70
67
|
}
|
|
71
68
|
const baseBranch = explicitBaseBranch
|
|
72
69
|
?? (shouldDiscoverBaseBranch ? discovered?.branch : undefined);
|
|
73
|
-
const requiredChecks = explicitRequiredChecks.length > 0
|
|
74
|
-
? explicitRequiredChecks
|
|
75
|
-
: (shouldDiscoverRequiredChecks ? discovered?.requiredChecks : undefined);
|
|
76
70
|
const result = await upsertRepoConfig({
|
|
77
71
|
id: repoId,
|
|
78
72
|
repoFullName,
|
|
79
73
|
...(baseBranch ? { baseBranch } : {}),
|
|
80
|
-
...(requiredChecks ? { requiredChecks } : {}),
|
|
81
74
|
...(admissionLabel ? { admissionLabel } : {}),
|
|
82
75
|
...(mergeQueueCheckName ? { mergeQueueCheckName } : {}),
|
|
83
76
|
});
|
|
@@ -97,7 +90,6 @@ export async function handleAttach(parsed, stdout, runCommand) {
|
|
|
97
90
|
`Base branch: ${result.repo.baseBranch}`,
|
|
98
91
|
`Admission label: ${result.repo.admissionLabel}`,
|
|
99
92
|
`Queue eviction check: ${result.repo.mergeQueueCheckName}`,
|
|
100
|
-
`Required checks: ${result.repo.requiredChecks.length > 0 ? result.repo.requiredChecks.join(", ") : "(any green check)"}`,
|
|
101
93
|
...warnings.map((warning) => `Warning: ${warning}`),
|
|
102
94
|
...(discovered?.warnings.map((warning) => `Warning: ${warning}`) ?? []),
|
|
103
95
|
restartState.ok
|
|
@@ -2,7 +2,6 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { loadConfig } from "../../config.js";
|
|
5
|
-
import { normalizeCheckList } from "../../github-repo-discovery.js";
|
|
6
5
|
import { getDefaultConfigPath, getDefaultRepoConfigDir, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultStateDir, getSystemdUnitPath, } from "../../runtime-paths.js";
|
|
7
6
|
import { formatJson, writeOutput } from "../output.js";
|
|
8
7
|
import { fetchServiceGitHubAuthStatus, fetchServiceRepoAccess, fetchServiceRepoDiscovery, getHomeEnv, loadRepoConfigById, } from "../system.js";
|
|
@@ -189,24 +188,10 @@ export async function handleDoctor(parsed, stdout) {
|
|
|
189
188
|
? `Local base branch matches GitHub default branch (${discovered.defaultBranch})`
|
|
190
189
|
: `Local base branch is ${config.baseBranch}, but GitHub default branch is ${discovered.defaultBranch}`,
|
|
191
190
|
});
|
|
192
|
-
const configuredChecks = normalizeCheckList(config.requiredChecks);
|
|
193
|
-
const discoveredChecks = normalizeCheckList(discovered.requiredChecks);
|
|
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;
|
|
200
191
|
checks.push({
|
|
201
|
-
status:
|
|
192
|
+
status: "pass",
|
|
202
193
|
scope: `repo:${repoId}:github-required-checks`,
|
|
203
|
-
message:
|
|
204
|
-
? (configuredChecks.length > 0
|
|
205
|
-
? `Local required checks match GitHub for ${config.baseBranch}`
|
|
206
|
-
: `No required checks configured locally and GitHub does not require status checks 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)" : ""}`,
|
|
194
|
+
message: `GitHub requires [${discovered.requiredChecks.join(", ") || "(none)"}] for ${config.baseBranch}${discoveredSource === "gh" ? " (via gh fallback)" : ""}`,
|
|
210
195
|
});
|
|
211
196
|
for (const warning of discovered.warnings ?? []) {
|
|
212
197
|
checks.push({
|
|
@@ -1,27 +1,40 @@
|
|
|
1
1
|
import { SqliteStore } from "../../db/sqlite-store.js";
|
|
2
|
-
import { buildSummary } from "../../service.js";
|
|
3
2
|
import { TERMINAL_STATUSES } from "../../types.js";
|
|
4
3
|
import { UsageError } from "../types.js";
|
|
5
4
|
import { parseIntegerFlag } from "../args.js";
|
|
6
5
|
import { formatJson, writeOutput } from "../output.js";
|
|
7
|
-
import { loadRepoConfigById, resolveRepoId, fetchLocalJson } from "../system.js";
|
|
6
|
+
import { ServiceApiError, loadRepoConfigById, resolveRepoId, fetchLocalJson } from "../system.js";
|
|
7
|
+
import { buildQueueSummary } from "../../watch/dashboard-model.js";
|
|
8
8
|
async function readQueueSnapshot(config, eventLimit) {
|
|
9
9
|
try {
|
|
10
10
|
const query = new URLSearchParams({ eventLimit: String(eventLimit) });
|
|
11
11
|
const snapshot = await fetchLocalJson(config.repoId, `/queue/watch?${query.toString()}`);
|
|
12
|
-
return { source: "service", snapshot };
|
|
12
|
+
return { kind: "snapshot", source: "service", snapshot };
|
|
13
13
|
}
|
|
14
|
-
catch {
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof ServiceApiError && error.code === "repo_initializing") {
|
|
16
|
+
return { kind: "repo_unavailable", state: "initializing", message: error.message };
|
|
17
|
+
}
|
|
18
|
+
if (error instanceof ServiceApiError && error.code === "repo_init_failed") {
|
|
19
|
+
return { kind: "repo_unavailable", state: "failed", message: error.message };
|
|
20
|
+
}
|
|
15
21
|
const store = new SqliteStore(config.database.path);
|
|
16
22
|
try {
|
|
17
23
|
const entries = store.listAll(config.repoId);
|
|
18
24
|
return {
|
|
25
|
+
kind: "snapshot",
|
|
19
26
|
source: "database",
|
|
20
27
|
snapshot: {
|
|
21
28
|
repoId: config.repoId,
|
|
22
29
|
repoFullName: config.repoFullName,
|
|
23
30
|
baseBranch: config.baseBranch,
|
|
24
|
-
|
|
31
|
+
githubPolicy: {
|
|
32
|
+
requiredChecks: [],
|
|
33
|
+
fetchedAt: null,
|
|
34
|
+
lastRefreshReason: null,
|
|
35
|
+
lastRefreshChanged: null,
|
|
36
|
+
},
|
|
37
|
+
summary: buildQueueSummary(entries),
|
|
25
38
|
runtime: {
|
|
26
39
|
tickInProgress: false,
|
|
27
40
|
lastTickStartedAt: null,
|
|
@@ -47,10 +60,18 @@ function firstLine(text) {
|
|
|
47
60
|
return line ? line : null;
|
|
48
61
|
}
|
|
49
62
|
export function formatQueueStatusText(source, snapshot) {
|
|
63
|
+
const githubPolicy = snapshot.githubPolicy ?? {
|
|
64
|
+
requiredChecks: [],
|
|
65
|
+
fetchedAt: null,
|
|
66
|
+
lastRefreshReason: null,
|
|
67
|
+
lastRefreshChanged: null,
|
|
68
|
+
};
|
|
50
69
|
return [
|
|
51
70
|
`Repo: ${snapshot.repoId} (${snapshot.repoFullName})`,
|
|
52
71
|
`Source: ${source}`,
|
|
53
72
|
`Base branch: ${snapshot.baseBranch}`,
|
|
73
|
+
`GitHub required checks: ${githubPolicy.requiredChecks.length > 0 ? githubPolicy.requiredChecks.join(", ") : "(none)"}`,
|
|
74
|
+
...(githubPolicy.fetchedAt ? [`GitHub policy fetched: ${githubPolicy.fetchedAt}`] : []),
|
|
54
75
|
`Active entries: ${snapshot.summary.active}`,
|
|
55
76
|
`Queued: ${snapshot.summary.queued} preparing: ${snapshot.summary.preparingHead} validating: ${snapshot.summary.validating} merging: ${snapshot.summary.merging}`,
|
|
56
77
|
`Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued}`,
|
|
@@ -64,6 +85,7 @@ export function formatQueueStatusText(source, snapshot) {
|
|
|
64
85
|
...(snapshot.queueBlock
|
|
65
86
|
? [
|
|
66
87
|
`Queue blocked: ${snapshot.queueBlock.reason} on ${snapshot.queueBlock.baseBranch}${snapshot.queueBlock.baseSha ? ` @ ${snapshot.queueBlock.baseSha.slice(0, 8)}` : ""}`,
|
|
88
|
+
`Missing required: ${snapshot.queueBlock.missingRequiredChecks.length > 0 ? snapshot.queueBlock.missingRequiredChecks.join(", ") : "(none)"}`,
|
|
67
89
|
`Base failures: ${snapshot.queueBlock.failingChecks.length > 0 ? snapshot.queueBlock.failingChecks.map((check) => check.name).join(", ") : "(none)"}`,
|
|
68
90
|
...(snapshot.queueBlock.pendingChecks.length > 0
|
|
69
91
|
? [`Base pending: ${snapshot.queueBlock.pendingChecks.map((check) => check.name).join(", ")}`]
|
|
@@ -127,7 +149,27 @@ export async function handleQueue(parsed, stdout) {
|
|
|
127
149
|
const { config } = loadRepoConfigById(repoId);
|
|
128
150
|
if (subcommand === "status") {
|
|
129
151
|
const eventLimit = parseIntegerFlag(parsed.flags.get("events"), "--events") ?? 20;
|
|
130
|
-
const
|
|
152
|
+
const result = await readQueueSnapshot(config, eventLimit);
|
|
153
|
+
if (result.kind === "repo_unavailable") {
|
|
154
|
+
if (parsed.flags.get("json") === true) {
|
|
155
|
+
writeOutput(stdout, formatJson({
|
|
156
|
+
repoId: config.repoId,
|
|
157
|
+
repoFullName: config.repoFullName,
|
|
158
|
+
source: "service",
|
|
159
|
+
state: result.state,
|
|
160
|
+
error: result.message,
|
|
161
|
+
}));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
writeOutput(stdout, [
|
|
165
|
+
`Repo: ${config.repoId} (${config.repoFullName})`,
|
|
166
|
+
"Source: service",
|
|
167
|
+
`State: ${result.state}`,
|
|
168
|
+
result.message,
|
|
169
|
+
].join("\n") + "\n");
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
const { source, snapshot } = result;
|
|
131
173
|
const payload = { source, ...snapshot };
|
|
132
174
|
if (parsed.flags.get("json") === true) {
|
|
133
175
|
writeOutput(stdout, formatJson(payload));
|
|
@@ -23,7 +23,6 @@ export async function handleRepos(parsed, stdout) {
|
|
|
23
23
|
repoId: config.repoId,
|
|
24
24
|
repoFullName: config.repoFullName,
|
|
25
25
|
baseBranch: config.baseBranch,
|
|
26
|
-
requiredChecks: config.requiredChecks,
|
|
27
26
|
admissionLabel: config.admissionLabel,
|
|
28
27
|
mergeQueueCheckName: config.mergeQueueCheckName,
|
|
29
28
|
configPath,
|
|
@@ -40,7 +39,6 @@ export async function handleRepos(parsed, stdout) {
|
|
|
40
39
|
`GitHub repo: ${config.repoFullName}`,
|
|
41
40
|
`Config path: ${configPath}`,
|
|
42
41
|
`Base branch: ${config.baseBranch}`,
|
|
43
|
-
`Required checks: ${config.requiredChecks.length > 0 ? config.requiredChecks.join(", ") : "(any green check)"}`,
|
|
44
42
|
`Admission label: ${config.admissionLabel}`,
|
|
45
43
|
`Queue eviction check: ${config.mergeQueueCheckName}`,
|
|
46
44
|
webhookUrl ? `Webhook URL: ${webhookUrl}` : undefined,
|
package/dist/cli/help.js
CHANGED
|
@@ -15,7 +15,7 @@ function rootHelpText() {
|
|
|
15
15
|
" 5. merge-steward queue status --repo <id>",
|
|
16
16
|
"",
|
|
17
17
|
"Everyday commands:",
|
|
18
|
-
" repo attach <owner/repo> [--base-branch <branch>] [--
|
|
18
|
+
" repo attach <owner/repo> [--base-branch <branch>] [--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
21
|
" repo show <id> [--json] Show one repo config",
|
|
@@ -56,11 +56,10 @@ function repoHelpText() {
|
|
|
56
56
|
"",
|
|
57
57
|
"Options for `repo attach`:",
|
|
58
58
|
" --base-branch <branch> Base branch to land into (default: main)",
|
|
59
|
-
" --required-check <checks> Comma-separated required check names",
|
|
60
59
|
" --label <label> Admission label (default: queue)",
|
|
61
60
|
" --merge-queue-check-name <name>",
|
|
62
61
|
" Eviction check run name (default: merge-steward/queue)",
|
|
63
|
-
" --refresh Re-discover base branch
|
|
62
|
+
" --refresh Re-discover the base branch from GitHub",
|
|
64
63
|
" --json Emit structured JSON",
|
|
65
64
|
"",
|
|
66
65
|
"Compatibility aliases:",
|
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, ServiceGitHubRepoAccessResponse } from "../admin-types.ts";
|
|
3
|
+
import type { ServiceHealthResponse, 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>;
|
|
@@ -12,7 +12,6 @@ export declare function listRepoConfigs(): Array<{
|
|
|
12
12
|
repoId: string;
|
|
13
13
|
repoFullName: string;
|
|
14
14
|
baseBranch: string;
|
|
15
|
-
requiredChecks: string[];
|
|
16
15
|
admissionLabel: string;
|
|
17
16
|
mergeQueueCheckName: string;
|
|
18
17
|
port: number;
|
|
@@ -36,6 +35,11 @@ export declare function runSystemctl(runCommand: CommandRunner, args: string[]):
|
|
|
36
35
|
result: CommandResult;
|
|
37
36
|
}>;
|
|
38
37
|
export declare function getGatewayBaseUrl(): string;
|
|
38
|
+
export declare class ServiceApiError extends Error {
|
|
39
|
+
readonly status?: number | undefined;
|
|
40
|
+
readonly code?: string | undefined;
|
|
41
|
+
constructor(message: string, status?: number | undefined, code?: string | undefined);
|
|
42
|
+
}
|
|
39
43
|
export declare function fetchGatewayJson<T>(relativePath: string, options?: {
|
|
40
44
|
method?: string;
|
|
41
45
|
body?: unknown;
|
|
@@ -44,6 +48,7 @@ export declare function fetchServiceHealthStatus(): Promise<{
|
|
|
44
48
|
reachable: true;
|
|
45
49
|
ok: boolean;
|
|
46
50
|
status: number;
|
|
51
|
+
health?: ServiceHealthResponse;
|
|
47
52
|
} | {
|
|
48
53
|
reachable: false;
|
|
49
54
|
error: string;
|
package/dist/cli/system.js
CHANGED
|
@@ -62,7 +62,6 @@ export function listRepoConfigs() {
|
|
|
62
62
|
repoId: config.repoId,
|
|
63
63
|
repoFullName: config.repoFullName,
|
|
64
64
|
baseBranch: config.baseBranch,
|
|
65
|
-
requiredChecks: config.requiredChecks,
|
|
66
65
|
admissionLabel: config.admissionLabel,
|
|
67
66
|
mergeQueueCheckName: config.mergeQueueCheckName,
|
|
68
67
|
port: config.server.port,
|
|
@@ -165,6 +164,16 @@ export function getGatewayBaseUrl() {
|
|
|
165
164
|
const port = homeConfig.server.gateway_port ?? (homeConfig.server.port_base - 1);
|
|
166
165
|
return `http://${bind}:${port}`;
|
|
167
166
|
}
|
|
167
|
+
export class ServiceApiError extends Error {
|
|
168
|
+
status;
|
|
169
|
+
code;
|
|
170
|
+
constructor(message, status, code) {
|
|
171
|
+
super(message);
|
|
172
|
+
this.status = status;
|
|
173
|
+
this.code = code;
|
|
174
|
+
this.name = "ServiceApiError";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
168
177
|
async function requestGatewayJson(url, options) {
|
|
169
178
|
const response = await fetch(url, {
|
|
170
179
|
method: options?.method ?? "GET",
|
|
@@ -177,7 +186,7 @@ async function requestGatewayJson(url, options) {
|
|
|
177
186
|
try {
|
|
178
187
|
const payload = JSON.parse(text);
|
|
179
188
|
if (typeof payload.error === "string" && payload.error) {
|
|
180
|
-
throw new
|
|
189
|
+
throw new ServiceApiError(payload.error, response.status, payload.code);
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
192
|
catch (error) {
|
|
@@ -199,8 +208,10 @@ export async function fetchServiceHealthStatus() {
|
|
|
199
208
|
signal: AbortSignal.timeout(2_000),
|
|
200
209
|
});
|
|
201
210
|
let ok = response.ok;
|
|
211
|
+
let health;
|
|
202
212
|
try {
|
|
203
213
|
const body = await response.json();
|
|
214
|
+
health = body;
|
|
204
215
|
if (typeof body.ok === "boolean") {
|
|
205
216
|
ok = response.ok && body.ok;
|
|
206
217
|
}
|
|
@@ -212,6 +223,7 @@ export async function fetchServiceHealthStatus() {
|
|
|
212
223
|
reachable: true,
|
|
213
224
|
ok,
|
|
214
225
|
status: response.status,
|
|
226
|
+
...(health ? { health } : {}),
|
|
215
227
|
};
|
|
216
228
|
}
|
|
217
229
|
catch (error) {
|
package/dist/config.d.ts
CHANGED
|
@@ -9,7 +9,6 @@ export declare const stewardConfigSchema: z.ZodObject<{
|
|
|
9
9
|
maxRetries: z.ZodDefault<z.ZodNumber>;
|
|
10
10
|
flakyRetries: z.ZodDefault<z.ZodNumber>;
|
|
11
11
|
speculativeDepth: z.ZodDefault<z.ZodNumber>;
|
|
12
|
-
requiredChecks: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
13
12
|
pollIntervalMs: z.ZodDefault<z.ZodNumber>;
|
|
14
13
|
server: z.ZodDefault<z.ZodObject<{
|
|
15
14
|
bind: z.ZodDefault<z.ZodString>;
|
package/dist/config.js
CHANGED
|
@@ -15,7 +15,6 @@ export const stewardConfigSchema = z.object({
|
|
|
15
15
|
flakyRetries: z.number().int().min(0).default(1),
|
|
16
16
|
/** Max speculative branches to maintain in parallel. 1 = serial mode. */
|
|
17
17
|
speculativeDepth: z.number().int().min(1).default(10),
|
|
18
|
-
requiredChecks: z.array(z.string()).default([]),
|
|
19
18
|
pollIntervalMs: z.number().int().min(1000).default(30_000),
|
|
20
19
|
server: z.object({
|
|
21
20
|
bind: z.string().default("127.0.0.1"),
|
|
@@ -8,8 +8,8 @@ import type { CIStatus } from "../types.ts";
|
|
|
8
8
|
*/
|
|
9
9
|
export declare class GitHubActionsRunner implements CIRunner {
|
|
10
10
|
private readonly repoFullName;
|
|
11
|
-
private readonly
|
|
12
|
-
constructor(repoFullName: string,
|
|
11
|
+
private readonly getRequiredChecks;
|
|
12
|
+
constructor(repoFullName: string, getRequiredChecks?: () => string[]);
|
|
13
13
|
triggerRun(_branch: string, sha: string): Promise<string>;
|
|
14
14
|
getStatus(runId: string): Promise<CIStatus>;
|
|
15
15
|
cancelRun(_runId: string): Promise<void>;
|
|
@@ -10,10 +10,10 @@ function normalizeCheckName(name) {
|
|
|
10
10
|
*/
|
|
11
11
|
export class GitHubActionsRunner {
|
|
12
12
|
repoFullName;
|
|
13
|
-
|
|
14
|
-
constructor(repoFullName,
|
|
13
|
+
getRequiredChecks;
|
|
14
|
+
constructor(repoFullName, getRequiredChecks = () => []) {
|
|
15
15
|
this.repoFullName = repoFullName;
|
|
16
|
-
this.
|
|
16
|
+
this.getRequiredChecks = getRequiredChecks;
|
|
17
17
|
}
|
|
18
18
|
async triggerRun(_branch, sha) {
|
|
19
19
|
// CI is triggered by the push. Return the SHA as the poll key.
|
|
@@ -32,8 +32,9 @@ export class GitHubActionsRunner {
|
|
|
32
32
|
const checkRuns = JSON.parse(result.stdout);
|
|
33
33
|
if (checkRuns.length === 0)
|
|
34
34
|
return "pending";
|
|
35
|
-
const
|
|
36
|
-
const
|
|
35
|
+
const requiredChecks = this.getRequiredChecks();
|
|
36
|
+
const normalizedRequired = requiredChecks.map(normalizeCheckName);
|
|
37
|
+
const relevant = requiredChecks.length > 0
|
|
37
38
|
? checkRuns.filter((c) => normalizedRequired.includes(normalizeCheckName(c.name)))
|
|
38
39
|
: checkRuns;
|
|
39
40
|
if (relevant.length === 0)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
import type { DiscoveredRepoSettings } from "./github-repo-discovery.ts";
|
|
3
|
+
export interface GitHubPolicySnapshot {
|
|
4
|
+
requiredChecks: string[];
|
|
5
|
+
fetchedAt: string | null;
|
|
6
|
+
lastRefreshReason: string | null;
|
|
7
|
+
lastRefreshChanged: boolean | null;
|
|
8
|
+
}
|
|
9
|
+
export interface GitHubPolicyRefreshResult {
|
|
10
|
+
attempted: boolean;
|
|
11
|
+
changed: boolean;
|
|
12
|
+
previousRequiredChecks: string[];
|
|
13
|
+
requiredChecks: string[];
|
|
14
|
+
fetchedAt: string | null;
|
|
15
|
+
skippedReason?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
interface GitHubPolicyCacheOptions {
|
|
18
|
+
repoFullName: string;
|
|
19
|
+
initialRequiredChecks: string[];
|
|
20
|
+
logger: Logger;
|
|
21
|
+
refreshPolicy(): Promise<DiscoveredRepoSettings>;
|
|
22
|
+
issueRefreshCooldownMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class GitHubPolicyCache {
|
|
25
|
+
private readonly options;
|
|
26
|
+
private requiredChecks;
|
|
27
|
+
private fetchedAt;
|
|
28
|
+
private lastIssueRefreshAt;
|
|
29
|
+
private readonly issueRefreshCooldownMs;
|
|
30
|
+
private lastRefreshReason;
|
|
31
|
+
private lastRefreshChanged;
|
|
32
|
+
constructor(options: GitHubPolicyCacheOptions);
|
|
33
|
+
getSnapshot(): GitHubPolicySnapshot;
|
|
34
|
+
getRequiredChecks(): string[];
|
|
35
|
+
refreshFromWebhook(reason: string): Promise<GitHubPolicyRefreshResult>;
|
|
36
|
+
refreshOnIssue(reason: string): Promise<GitHubPolicyRefreshResult>;
|
|
37
|
+
private refresh;
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function normalizeChecks(values) {
|
|
2
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))]
|
|
3
|
+
.sort((left, right) => left.localeCompare(right));
|
|
4
|
+
}
|
|
5
|
+
function equalChecks(left, right) {
|
|
6
|
+
if (left.length !== right.length) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
return left.every((value, index) => value === right[index]);
|
|
10
|
+
}
|
|
11
|
+
export class GitHubPolicyCache {
|
|
12
|
+
options;
|
|
13
|
+
requiredChecks;
|
|
14
|
+
fetchedAt;
|
|
15
|
+
lastIssueRefreshAt = 0;
|
|
16
|
+
issueRefreshCooldownMs;
|
|
17
|
+
lastRefreshReason = null;
|
|
18
|
+
lastRefreshChanged = null;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.requiredChecks = normalizeChecks(options.initialRequiredChecks);
|
|
22
|
+
this.fetchedAt = new Date().toISOString();
|
|
23
|
+
this.issueRefreshCooldownMs = options.issueRefreshCooldownMs ?? 5 * 60_000;
|
|
24
|
+
}
|
|
25
|
+
getSnapshot() {
|
|
26
|
+
return {
|
|
27
|
+
requiredChecks: [...this.requiredChecks],
|
|
28
|
+
fetchedAt: this.fetchedAt,
|
|
29
|
+
lastRefreshReason: this.lastRefreshReason,
|
|
30
|
+
lastRefreshChanged: this.lastRefreshChanged,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
getRequiredChecks() {
|
|
34
|
+
return [...this.requiredChecks];
|
|
35
|
+
}
|
|
36
|
+
async refreshFromWebhook(reason) {
|
|
37
|
+
return await this.refresh(reason, { force: true, issueTriggered: false });
|
|
38
|
+
}
|
|
39
|
+
async refreshOnIssue(reason) {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
if (now - this.lastIssueRefreshAt < this.issueRefreshCooldownMs) {
|
|
42
|
+
return {
|
|
43
|
+
attempted: false,
|
|
44
|
+
changed: false,
|
|
45
|
+
previousRequiredChecks: [...this.requiredChecks],
|
|
46
|
+
requiredChecks: [...this.requiredChecks],
|
|
47
|
+
fetchedAt: this.fetchedAt,
|
|
48
|
+
skippedReason: "cooldown",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
this.lastIssueRefreshAt = now;
|
|
52
|
+
return await this.refresh(reason, { force: false, issueTriggered: true });
|
|
53
|
+
}
|
|
54
|
+
async refresh(reason, options) {
|
|
55
|
+
const previousRequiredChecks = [...this.requiredChecks];
|
|
56
|
+
const discovered = await this.options.refreshPolicy();
|
|
57
|
+
const nextRequiredChecks = normalizeChecks(discovered.requiredChecks);
|
|
58
|
+
const changed = !equalChecks(previousRequiredChecks, nextRequiredChecks);
|
|
59
|
+
this.requiredChecks = nextRequiredChecks;
|
|
60
|
+
this.fetchedAt = new Date().toISOString();
|
|
61
|
+
this.lastRefreshReason = reason;
|
|
62
|
+
this.lastRefreshChanged = changed;
|
|
63
|
+
this.options.logger.info({
|
|
64
|
+
repoFullName: this.options.repoFullName,
|
|
65
|
+
reason,
|
|
66
|
+
changed,
|
|
67
|
+
requiredChecks: this.requiredChecks,
|
|
68
|
+
policyRefreshSource: options.issueTriggered ? "issue" : (options.force ? "webhook" : "manual"),
|
|
69
|
+
}, "Refreshed GitHub protection policy");
|
|
70
|
+
return {
|
|
71
|
+
attempted: true,
|
|
72
|
+
changed,
|
|
73
|
+
previousRequiredChecks,
|
|
74
|
+
requiredChecks: [...this.requiredChecks],
|
|
75
|
+
fetchedAt: this.fetchedAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|