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.
Files changed (71) hide show
  1. package/README.md +14 -8
  2. package/dist/admin-types.d.ts +17 -0
  3. package/dist/cli/args.d.ts +0 -1
  4. package/dist/cli/args.js +2 -7
  5. package/dist/cli/commands/attach.js +1 -9
  6. package/dist/cli/commands/doctor.js +2 -17
  7. package/dist/cli/commands/queue.js +48 -6
  8. package/dist/cli/commands/repos.js +0 -2
  9. package/dist/cli/help.js +2 -3
  10. package/dist/cli/system.d.ts +7 -2
  11. package/dist/cli/system.js +14 -2
  12. package/dist/config.d.ts +0 -1
  13. package/dist/config.js +0 -1
  14. package/dist/github/actions-runner.d.ts +2 -2
  15. package/dist/github/actions-runner.js +6 -5
  16. package/dist/github-policy.d.ts +39 -0
  17. package/dist/github-policy.js +78 -0
  18. package/dist/github-repo-discovery.js +2 -2
  19. package/dist/http-multi.d.ts +2 -8
  20. package/dist/http-multi.js +89 -42
  21. package/dist/install.d.ts +0 -2
  22. package/dist/install.js +0 -3
  23. package/dist/reconciler-core.d.ts +35 -0
  24. package/dist/reconciler-core.js +19 -0
  25. package/dist/reconciler-evict.d.ts +12 -0
  26. package/dist/reconciler-evict.js +70 -0
  27. package/dist/reconciler-merge.d.ts +3 -0
  28. package/dist/reconciler-merge.js +110 -0
  29. package/dist/reconciler-prepare.d.ts +3 -0
  30. package/dist/reconciler-prepare.js +166 -0
  31. package/dist/reconciler-sanitize.d.ts +3 -0
  32. package/dist/reconciler-sanitize.js +36 -0
  33. package/dist/reconciler-validate.d.ts +3 -0
  34. package/dist/reconciler-validate.js +58 -0
  35. package/dist/reconciler.d.ts +2 -17
  36. package/dist/reconciler.js +8 -402
  37. package/dist/repo-runtime.d.ts +18 -0
  38. package/dist/repo-runtime.js +1 -0
  39. package/dist/server.d.ts +0 -8
  40. package/dist/server.js +74 -13
  41. package/dist/service-queue.d.ts +29 -0
  42. package/dist/service-queue.js +183 -0
  43. package/dist/service-runtime.d.ts +38 -0
  44. package/dist/service-runtime.js +169 -0
  45. package/dist/service-watch.d.ts +20 -0
  46. package/dist/service-watch.js +48 -0
  47. package/dist/service.d.ts +19 -39
  48. package/dist/service.js +47 -391
  49. package/dist/types.d.ts +10 -2
  50. package/dist/watch/App.js +85 -40
  51. package/dist/watch/DetailView.d.ts +3 -2
  52. package/dist/watch/DetailView.js +4 -2
  53. package/dist/watch/FreshnessBadge.d.ts +2 -1
  54. package/dist/watch/FreshnessBadge.js +2 -2
  55. package/dist/watch/OverviewView.d.ts +2 -1
  56. package/dist/watch/OverviewView.js +2 -2
  57. package/dist/watch/ProjectDetailView.js +8 -2
  58. package/dist/watch/QueueListView.js +3 -1
  59. package/dist/watch/StatusBar.d.ts +2 -1
  60. package/dist/watch/StatusBar.js +2 -2
  61. package/dist/watch/api.d.ts +12 -0
  62. package/dist/watch/api.js +21 -3
  63. package/dist/watch/dashboard-model.d.ts +5 -1
  64. package/dist/watch/dashboard-model.js +63 -0
  65. package/dist/watch/format.js +19 -1
  66. package/dist/watch/freshness.d.ts +1 -1
  67. package/dist/watch/freshness.js +7 -1
  68. package/dist/watch/state-visualization.js +7 -3
  69. package/dist/webhook-handler.d.ts +5 -0
  70. package/dist/webhook-handler.js +45 -0
  71. 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, discovers the default branch and required status checks from GitHub, and stores the discovered values into local config.
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 and required checks for an existing repo config. `merge-steward doctor --repo <id>` compares the stored config to GitHub and warns on drift.
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
- Even without a webhook, startup and periodic reconcile will still scan open PRs
202
- and admit anything that already satisfies the gate.
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
@@ -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
  }
@@ -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", "required-check", "label", "merge-queue-check-name", "refresh", "json"]);
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", "required-check", "label", "merge-queue-check-name", "refresh", "json"]);
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 shouldDiscoverRequiredChecks = explicitRequiredChecks.length === 0 && (!existing || refresh || !!explicitBaseBranch);
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: checksMatch || localSuperset ? "pass" : "warn",
192
+ status: "pass",
202
193
  scope: `repo:${repoId}:github-required-checks`,
203
- message: checksMatch
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
- summary: buildSummary(entries),
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 { source, snapshot } = await readQueueSnapshot(config, eventLimit);
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>] [--required-check <checks>] [--label <label>] [--refresh] [--json]",
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 and required checks from GitHub",
62
+ " --refresh Re-discover the base branch from GitHub",
64
63
  " --json Emit structured JSON",
65
64
  "",
66
65
  "Compatibility aliases:",
@@ -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;
@@ -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 Error(payload.error);
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 requiredChecks;
12
- constructor(repoFullName: string, requiredChecks?: 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
- requiredChecks;
14
- constructor(repoFullName, requiredChecks = []) {
13
+ getRequiredChecks;
14
+ constructor(repoFullName, getRequiredChecks = () => []) {
15
15
  this.repoFullName = repoFullName;
16
- this.requiredChecks = requiredChecks;
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 normalizedRequired = this.requiredChecks.map(normalizeCheckName);
36
- const relevant = this.requiredChecks.length > 0
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
+ }