merge-steward 0.0.1

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/dist/classify.d.ts +9 -0
  4. package/dist/classify.js +29 -0
  5. package/dist/cli/args.d.ts +7 -0
  6. package/dist/cli/args.js +118 -0
  7. package/dist/cli/commands/attach.d.ts +2 -0
  8. package/dist/cli/commands/attach.js +61 -0
  9. package/dist/cli/commands/doctor.d.ts +2 -0
  10. package/dist/cli/commands/doctor.js +122 -0
  11. package/dist/cli/commands/init.d.ts +2 -0
  12. package/dist/cli/commands/init.js +55 -0
  13. package/dist/cli/commands/queue.d.ts +2 -0
  14. package/dist/cli/commands/queue.js +171 -0
  15. package/dist/cli/commands/repos.d.ts +2 -0
  16. package/dist/cli/commands/repos.js +53 -0
  17. package/dist/cli/commands/service.d.ts +2 -0
  18. package/dist/cli/commands/service.js +115 -0
  19. package/dist/cli/help.d.ts +2 -0
  20. package/dist/cli/help.js +102 -0
  21. package/dist/cli/output.d.ts +6 -0
  22. package/dist/cli/output.js +15 -0
  23. package/dist/cli/system.d.ts +40 -0
  24. package/dist/cli/system.js +165 -0
  25. package/dist/cli/types.d.ts +23 -0
  26. package/dist/cli/types.js +8 -0
  27. package/dist/cli.d.ts +3 -0
  28. package/dist/cli.js +62 -0
  29. package/dist/config.d.ts +40 -0
  30. package/dist/config.js +100 -0
  31. package/dist/db/schema.d.ts +6 -0
  32. package/dist/db/schema.js +74 -0
  33. package/dist/db/shared.d.ts +27 -0
  34. package/dist/db/shared.js +98 -0
  35. package/dist/db/sqlite-store.d.ts +28 -0
  36. package/dist/db/sqlite-store.js +242 -0
  37. package/dist/exec.d.ts +17 -0
  38. package/dist/exec.js +44 -0
  39. package/dist/github/actions-runner.d.ts +17 -0
  40. package/dist/github/actions-runner.js +63 -0
  41. package/dist/github/check-run-reporter.d.ts +16 -0
  42. package/dist/github/check-run-reporter.js +108 -0
  43. package/dist/github/clone-manager.d.ts +18 -0
  44. package/dist/github/clone-manager.js +47 -0
  45. package/dist/github/pr-client.d.ts +21 -0
  46. package/dist/github/pr-client.js +139 -0
  47. package/dist/github/shell-git.d.ts +14 -0
  48. package/dist/github/shell-git.js +68 -0
  49. package/dist/http.d.ts +5 -0
  50. package/dist/http.js +120 -0
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.js +9 -0
  53. package/dist/install.d.ts +43 -0
  54. package/dist/install.js +206 -0
  55. package/dist/interfaces.d.ts +53 -0
  56. package/dist/interfaces.js +1 -0
  57. package/dist/reconciler.d.ts +18 -0
  58. package/dist/reconciler.js +357 -0
  59. package/dist/resolve-secret.d.ts +7 -0
  60. package/dist/resolve-secret.js +33 -0
  61. package/dist/runtime-paths.d.ts +25 -0
  62. package/dist/runtime-paths.js +73 -0
  63. package/dist/server.d.ts +1 -0
  64. package/dist/server.js +42 -0
  65. package/dist/service.d.ts +73 -0
  66. package/dist/service.js +343 -0
  67. package/dist/steward-home.d.ts +20 -0
  68. package/dist/steward-home.js +38 -0
  69. package/dist/store.d.ts +26 -0
  70. package/dist/store.js +1 -0
  71. package/dist/types.d.ts +170 -0
  72. package/dist/types.js +1 -0
  73. package/dist/watch/App.d.ts +6 -0
  74. package/dist/watch/App.js +192 -0
  75. package/dist/watch/DetailView.d.ts +10 -0
  76. package/dist/watch/DetailView.js +20 -0
  77. package/dist/watch/EntryStateGraph.d.ts +7 -0
  78. package/dist/watch/EntryStateGraph.js +31 -0
  79. package/dist/watch/ExternalRepairObservation.d.ts +6 -0
  80. package/dist/watch/ExternalRepairObservation.js +15 -0
  81. package/dist/watch/FreshnessBadge.d.ts +7 -0
  82. package/dist/watch/FreshnessBadge.js +13 -0
  83. package/dist/watch/HelpBar.d.ts +5 -0
  84. package/dist/watch/HelpBar.js +8 -0
  85. package/dist/watch/QueueListView.d.ts +9 -0
  86. package/dist/watch/QueueListView.js +19 -0
  87. package/dist/watch/StatusBar.d.ts +10 -0
  88. package/dist/watch/StatusBar.js +30 -0
  89. package/dist/watch/api.d.ts +8 -0
  90. package/dist/watch/api.js +43 -0
  91. package/dist/watch/format.d.ts +10 -0
  92. package/dist/watch/format.js +82 -0
  93. package/dist/watch/freshness.d.ts +5 -0
  94. package/dist/watch/freshness.js +28 -0
  95. package/dist/watch/index.d.ts +1 -0
  96. package/dist/watch/index.js +22 -0
  97. package/dist/watch/state-visualization.d.ts +21 -0
  98. package/dist/watch/state-visualization.js +114 -0
  99. package/dist/webhook-handler.d.ts +61 -0
  100. package/dist/webhook-handler.js +150 -0
  101. package/infra/merge-steward@.service +34 -0
  102. package/package.json +63 -0
  103. package/runtime.env.example +10 -0
  104. package/service.env.example +7 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PatchRelay contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # merge-steward
2
+
3
+ Serial merge queue service. Rebases PRs onto main one at a time, waits for CI, and merges when green. Evicts on failure and reports incidents via GitHub check runs.
4
+
5
+ Fully independent of PatchRelay. Communicates through GitHub — PRs, labels, check runs, branches.
6
+
7
+ ## How it works
8
+
9
+ 1. A PR gets the `queue` label (manually, by PatchRelay, or by any automation)
10
+ 2. The steward sees the label via GitHub webhook
11
+ 3. If the PR is approved and CI is green, it enters the queue
12
+ 4. The steward processes the queue head: fetch → rebase onto main → push → wait for CI → merge
13
+ 5. On failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run
14
+ 6. PatchRelay (or any agent) sees the check run failure and can fix the branch
15
+ 7. When the branch is fixed and CI passes again, adding the `queue` label re-admits it
16
+
17
+ ## Setup
18
+
19
+ ### Prerequisites
20
+
21
+ - Node.js 24+
22
+ - `gh` CLI available in `PATH`
23
+ - `git` binary
24
+
25
+ ### Bootstrap
26
+
27
+ Initialize the machine-level steward home once:
28
+
29
+ ```bash
30
+ merge-steward init https://queue.example.com
31
+ ```
32
+
33
+ That creates:
34
+
35
+ - `~/.config/merge-steward/runtime.env`
36
+ - `~/.config/merge-steward/service.env`
37
+ - `~/.config/merge-steward/merge-steward.json`
38
+ - `~/.config/merge-steward/repos/`
39
+ - `/etc/systemd/system/merge-steward@.service`
40
+
41
+ Add one repo-scoped steward instance:
42
+
43
+ ```bash
44
+ merge-steward attach app owner/repo --base-branch main --required-check test,lint
45
+ ```
46
+
47
+ That writes `~/.config/merge-steward/repos/app.json`, enables `merge-steward@app.service`, and prints the repo-specific webhook URL.
48
+
49
+ Validate the setup:
50
+
51
+ ```bash
52
+ merge-steward doctor --repo app
53
+ merge-steward service status app
54
+ merge-steward queue status --repo app
55
+ ```
56
+
57
+ ### Secrets
58
+
59
+ For dev, `service.env` can contain:
60
+
61
+ ```bash
62
+ MERGE_STEWARD_WEBHOOK_SECRET=replace-with-webhook-secret
63
+ MERGE_STEWARD_GITHUB_TOKEN=replace-with-github-token
64
+ ```
65
+
66
+ For production, prefer `systemd-creds` with:
67
+
68
+ - `LoadCredentialEncrypted=merge-steward-webhook-secret`
69
+ - `LoadCredentialEncrypted=merge-steward-github-token`
70
+
71
+ The steward resolves secrets in this order:
72
+
73
+ 1. `$CREDENTIALS_DIRECTORY/<name>`
74
+ 2. `${ENV_KEY}_FILE`
75
+ 3. `${ENV_KEY}`
76
+
77
+ ### Repo Config
78
+
79
+ `merge-steward attach` writes a repo-scoped config like:
80
+
81
+ ```json
82
+ {
83
+ "repoId": "app",
84
+ "repoFullName": "owner/repo",
85
+ "baseBranch": "main",
86
+ "clonePath": "~/.local/state/merge-steward/repos/app",
87
+ "maxRetries": 2,
88
+ "flakyRetries": 1,
89
+ "requiredChecks": ["test", "lint"],
90
+ "pollIntervalMs": 30000,
91
+ "admissionLabel": "queue",
92
+ "webhookPath": "/webhooks/github/queue/app",
93
+ "server": {
94
+ "bind": "127.0.0.1",
95
+ "port": 8790
96
+ },
97
+ "database": {
98
+ "path": "~/.local/state/merge-steward/app.sqlite"
99
+ }
100
+ }
101
+ ```
102
+
103
+ | Field | Description |
104
+ |-|-|
105
+ | `repoId` | Internal ID for this repo (used in DB keys) |
106
+ | `repoFullName` | GitHub `owner/repo` |
107
+ | `baseBranch` | Target branch for merges (usually `main`) |
108
+ | `clonePath` | Local clone directory (created on first run) |
109
+ | `maxRetries` | Rebase/CI retry attempts before eviction |
110
+ | `flakyRetries` | CI-only retries before counting toward maxRetries |
111
+ | `requiredChecks` | Check names that must pass for admission (empty = any green) |
112
+ | `pollIntervalMs` | Reconciliation loop interval |
113
+ | `admissionLabel` | GitHub label that triggers queue admission |
114
+ | `webhookPath` | Repo-specific webhook endpoint path |
115
+
116
+ ### GitHub Webhook
117
+
118
+ Configure a webhook on the repository:
119
+
120
+ - **Payload URL:** the repo-specific URL printed by `merge-steward attach`, for example `https://queue.example.com/webhooks/github/queue/app`
121
+ - **Content type:** `application/json`
122
+ - **Secret:** same as `MERGE_STEWARD_WEBHOOK_SECRET`
123
+ - **Events:** Pull requests, Pull request reviews, Check suites, Pushes
124
+
125
+ ### Running
126
+
127
+ ```bash
128
+ # Happy path
129
+ merge-steward init https://queue.example.com
130
+ merge-steward attach app owner/repo --base-branch main --required-check test,lint
131
+ merge-steward doctor --repo app
132
+ merge-steward service status app
133
+ merge-steward queue status --repo app
134
+ merge-steward queue show --repo app --pr 123
135
+
136
+ # Manual foreground start
137
+ merge-steward serve --repo app
138
+
139
+ # Live queue watch TUI
140
+ merge-steward queue watch --repo app
141
+ ```
142
+
143
+ ### Watch TUI
144
+
145
+ `merge-steward queue watch --repo <id>` gives you a terminal view of the queue:
146
+
147
+ - which PRs are currently queued
148
+ - which PR is head-of-line
149
+ - current steward tick state
150
+ - recent queue transitions
151
+ - per-PR detail with incidents and event history
152
+
153
+ Controls:
154
+
155
+ - `j` / `k` or arrows — move selection
156
+ - `Enter` — open selected PR detail
157
+ - `Esc` — return to queue view
158
+ - `a` — toggle `active` vs `all`
159
+ - `r` — run a reconcile tick now
160
+ - `d` — dequeue the selected PR
161
+ - `q` — quit
162
+
163
+ ### systemd
164
+
165
+ ```ini
166
+ [Unit]
167
+ Description=merge-steward (%i)
168
+ After=network-online.target
169
+ Wants=network-online.target
170
+
171
+ [Service]
172
+ Type=simple
173
+ EnvironmentFile=-/home/your-user/.config/merge-steward/runtime.env
174
+ EnvironmentFile=-/home/your-user/.config/merge-steward/service.env
175
+ LoadCredentialEncrypted=merge-steward-webhook-secret
176
+ LoadCredentialEncrypted=merge-steward-github-token
177
+ ExecStart=/usr/bin/env merge-steward serve --repo %i
178
+ Restart=on-failure
179
+ RestartSec=5s
180
+
181
+ [Install]
182
+ WantedBy=multi-user.target
183
+ ```
184
+
185
+ ## API
186
+
187
+ | Endpoint | Method | Description |
188
+ |-|-|-|
189
+ | `/health` | GET | Liveness check |
190
+ | `/queue/status` | GET | All queue entries |
191
+ | `/queue/watch` | GET | Queue snapshot for the operator TUI |
192
+ | `/queue/enqueue` | POST | Manually enqueue a PR |
193
+ | `/queue/reconcile` | POST | Trigger one reconcile tick immediately |
194
+ | `/queue/entries/:id/detail` | GET | Entry detail with recent events and incidents |
195
+ | `/queue/entries/:id/dequeue` | POST | Remove from queue (non-destructive) |
196
+ | `/queue/entries/:id/update-head` | POST | Update head SHA (force-push) |
197
+ | `/queue/incidents/:id` | GET | Get incident details |
198
+ | `/queue/entries/:id/incidents` | GET | List incidents for an entry |
199
+ | `/webhooks/github/queue` | POST | GitHub webhook receiver (configurable via `webhookPath`) |
200
+
201
+ ## Queue state machine
202
+
203
+ ```
204
+ queued → preparing_head → validating → merging → merged
205
+ → evicted (on failure after retries)
206
+ ```
207
+
208
+ - **queued**: waiting in line
209
+ - **preparing_head**: fetching + rebasing onto base branch
210
+ - **validating**: CI running
211
+ - **merging**: revalidation + merge
212
+ - **merged**: done
213
+ - **evicted**: failed after retry budget, incident created
214
+ - **dequeued**: manually removed
215
+
216
+ ## Interaction with PatchRelay
217
+
218
+ The steward and PatchRelay are independent services that communicate through GitHub:
219
+
220
+ - PatchRelay adds the `queue` label when an issue reaches `awaiting_queue`
221
+ - The steward merges the PR or evicts it (creating a `merge-steward/queue` check run)
222
+ - PatchRelay watches for that check run failure and triggers `queue_repair`
223
+ - After repair, PatchRelay re-adds the `queue` label
224
+ - The steward re-admits the PR
225
+
226
+ Neither service calls the other's API. GitHub is the shared bus.
227
+
228
+ ## Current scope
229
+
230
+ What's implemented:
231
+ - **Speculative execution**: cumulative branches (`main+A`, `main+A+B`, `main+A+B+C`) tested in parallel. Configurable depth (default 3, set `speculativeDepth: 1` for serial mode).
232
+ - **Speculative consistency**: when head merges, downstream entries that already passed don't re-test.
233
+ - **Cascade invalidation**: when mid-chain entry fails, downstream speculative branches are rebuilt without it.
234
+ - Non-spinning conflict retry: gated on base SHA change
235
+ - Flaky CI retry budget (separate from retry budget)
236
+ - Revalidation before merge (approval, SHA, external merge)
237
+ - Durable incident records on eviction
238
+ - GitHub check run as eviction signal
239
+ - Label-based admission and re-admission
240
+ - Structured reconciler event stream for observability
241
+
242
+ What's not built yet (see [design doc](https://github.com/krasnoperov/patchrelay/blob/main/docs/design-docs/merge-steward.md)):
243
+ - Binary bisection on batch failure
244
+ - File-path conflict detection for parallel lanes
245
+ - Flaky test learning (only retry budget, no historical analysis)
246
+ - Priority reordering after enqueue
@@ -0,0 +1,9 @@
1
+ import type { CheckResult, FailureClass } from "./types.ts";
2
+ /**
3
+ * Classify a CI failure by comparing branch checks against main baseline.
4
+ *
5
+ * - main_broken: the same checks that fail on the branch also fail on main
6
+ * - branch_local: checks fail on the branch but pass on main (PR's own fault)
7
+ * - integration_conflict: default when no baseline is available
8
+ */
9
+ export declare function classifyFailure(branchChecks: CheckResult[], mainChecks: CheckResult[]): FailureClass;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Classify a CI failure by comparing branch checks against main baseline.
3
+ *
4
+ * - main_broken: the same checks that fail on the branch also fail on main
5
+ * - branch_local: checks fail on the branch but pass on main (PR's own fault)
6
+ * - integration_conflict: default when no baseline is available
7
+ */
8
+ export function classifyFailure(branchChecks, mainChecks) {
9
+ const failedOnBranch = branchChecks.filter((c) => c.conclusion === "failure");
10
+ if (failedOnBranch.length === 0)
11
+ return "integration_conflict";
12
+ if (mainChecks.length === 0) {
13
+ // No baseline — can't distinguish. Default to integration_conflict
14
+ // since we only classify after rebase.
15
+ return "integration_conflict";
16
+ }
17
+ const failedOnMain = mainChecks.filter((c) => c.conclusion === "failure");
18
+ const mainFailedNames = new Set(failedOnMain.map((c) => c.name));
19
+ // If every branch failure also fails on main, main is broken.
20
+ const allOnMain = failedOnBranch.every((c) => mainFailedNames.has(c.name));
21
+ if (allOnMain && failedOnMain.length > 0)
22
+ return "main_broken";
23
+ // If none of the branch failures appear on main, it's the branch's fault.
24
+ const noneOnMain = failedOnBranch.every((c) => !mainFailedNames.has(c.name));
25
+ if (noneOnMain)
26
+ return "branch_local";
27
+ // Mixed: some fail on main, some don't. Treat as integration_conflict.
28
+ return "integration_conflict";
29
+ }
@@ -0,0 +1,7 @@
1
+ import type { ParsedArgs, HelpTopic } from "./types.ts";
2
+ export declare function parseArgs(argv: string[]): ParsedArgs;
3
+ export declare function hasHelpFlag(parsed: ParsedArgs): boolean;
4
+ export declare function assertKnownFlags(parsed: ParsedArgs, helpTopic: HelpTopic, allowedFlags: string[]): void;
5
+ export declare function validateFlags(parsed: ParsedArgs): void;
6
+ export declare function parseCsvFlag(value: string | boolean | undefined): string[];
7
+ export declare function parseIntegerFlag(value: string | boolean | undefined, label: string): number | undefined;
@@ -0,0 +1,118 @@
1
+ import { UsageError } from "./types.js";
2
+ export function parseArgs(argv) {
3
+ const positionals = [];
4
+ const flags = new Map();
5
+ for (let index = 0; index < argv.length; index += 1) {
6
+ const value = argv[index];
7
+ if (value === "-h" || value === "--help") {
8
+ flags.set("help", true);
9
+ continue;
10
+ }
11
+ if (!value.startsWith("--")) {
12
+ positionals.push(value);
13
+ continue;
14
+ }
15
+ const trimmed = value.slice(2);
16
+ const [name, inline] = trimmed.split("=", 2);
17
+ if (!name)
18
+ continue;
19
+ if (inline !== undefined) {
20
+ flags.set(name, inline);
21
+ continue;
22
+ }
23
+ const next = argv[index + 1];
24
+ if (next && !next.startsWith("--")) {
25
+ flags.set(name, next);
26
+ index += 1;
27
+ continue;
28
+ }
29
+ flags.set(name, true);
30
+ }
31
+ return { positionals, flags };
32
+ }
33
+ export function hasHelpFlag(parsed) {
34
+ return parsed.flags.get("help") === true;
35
+ }
36
+ export function assertKnownFlags(parsed, helpTopic, allowedFlags) {
37
+ const allowed = new Set(["help", ...allowedFlags]);
38
+ const unknownFlags = [...parsed.flags.keys()].filter((flag) => !allowed.has(flag)).sort();
39
+ if (unknownFlags.length === 0) {
40
+ return;
41
+ }
42
+ throw new UsageError(`Unknown flag${unknownFlags.length === 1 ? "" : "s"}: ${unknownFlags.map((flag) => `--${flag}`).join(", ")}`, helpTopic);
43
+ }
44
+ export function validateFlags(parsed) {
45
+ const command = parsed.positionals[0] ?? "help";
46
+ const subcommand = parsed.positionals[1];
47
+ switch (command) {
48
+ case "help":
49
+ assertKnownFlags(parsed, "root", []);
50
+ return;
51
+ case "init":
52
+ assertKnownFlags(parsed, "root", ["force", "json"]);
53
+ return;
54
+ case "doctor":
55
+ assertKnownFlags(parsed, "root", ["repo", "json"]);
56
+ return;
57
+ case "serve":
58
+ assertKnownFlags(parsed, "root", ["config", "repo"]);
59
+ return;
60
+ case "attach":
61
+ assertKnownFlags(parsed, "repos", ["base-branch", "required-check", "label", "json"]);
62
+ return;
63
+ case "repos":
64
+ assertKnownFlags(parsed, "repos", ["json"]);
65
+ return;
66
+ case "service":
67
+ switch (subcommand) {
68
+ case "install":
69
+ assertKnownFlags(parsed, "service", ["force", "json"]);
70
+ return;
71
+ case "restart":
72
+ assertKnownFlags(parsed, "service", ["json"]);
73
+ return;
74
+ case "status":
75
+ assertKnownFlags(parsed, "service", ["json"]);
76
+ return;
77
+ case "logs":
78
+ assertKnownFlags(parsed, "service", ["lines", "json"]);
79
+ return;
80
+ default:
81
+ assertKnownFlags(parsed, "service", []);
82
+ return;
83
+ }
84
+ case "queue":
85
+ switch (subcommand) {
86
+ case "status":
87
+ assertKnownFlags(parsed, "queue", ["repo", "events", "json"]);
88
+ return;
89
+ case "show":
90
+ assertKnownFlags(parsed, "queue", ["repo", "entry", "pr", "events", "json"]);
91
+ return;
92
+ case "watch":
93
+ assertKnownFlags(parsed, "queue", ["repo", "pr"]);
94
+ return;
95
+ case "reconcile":
96
+ assertKnownFlags(parsed, "queue", ["repo", "json"]);
97
+ return;
98
+ default:
99
+ assertKnownFlags(parsed, "queue", []);
100
+ return;
101
+ }
102
+ default:
103
+ assertKnownFlags(parsed, "root", []);
104
+ }
105
+ }
106
+ export function parseCsvFlag(value) {
107
+ if (typeof value !== "string")
108
+ return [];
109
+ return value.split(",").map((entry) => entry.trim()).filter(Boolean);
110
+ }
111
+ export function parseIntegerFlag(value, label) {
112
+ if (typeof value !== "string")
113
+ return undefined;
114
+ if (!/^\d+$/.test(value.trim())) {
115
+ throw new UsageError(`${label} must be a positive integer.`);
116
+ }
117
+ return Number(value.trim());
118
+ }
@@ -0,0 +1,2 @@
1
+ import type { ParsedArgs, Output, CommandRunner } from "../types.ts";
2
+ export declare function handleAttach(parsed: ParsedArgs, stdout: Output, runCommand: CommandRunner): Promise<number>;
@@ -0,0 +1,61 @@
1
+ import { installServiceUnit, upsertRepoConfig } from "../../install.js";
2
+ import { UsageError } from "../types.js";
3
+ import { parseCsvFlag } from "../args.js";
4
+ import { formatJson, writeOutput } from "../output.js";
5
+ import { readHomeConfig, runSystemctl } from "../system.js";
6
+ export async function handleAttach(parsed, stdout, runCommand) {
7
+ const repoId = parsed.positionals[1];
8
+ const repoFullName = parsed.positionals[2];
9
+ if (!repoId || !repoFullName) {
10
+ throw new UsageError("merge-steward attach requires <id> and <owner/repo>.", "repos");
11
+ }
12
+ const baseBranch = typeof parsed.flags.get("base-branch") === "string" ? String(parsed.flags.get("base-branch")) : undefined;
13
+ const admissionLabel = typeof parsed.flags.get("label") === "string" ? String(parsed.flags.get("label")) : undefined;
14
+ const result = await upsertRepoConfig({
15
+ id: repoId,
16
+ repoFullName,
17
+ ...(baseBranch ? { baseBranch } : {}),
18
+ ...(parseCsvFlag(parsed.flags.get("required-check")).length > 0
19
+ ? { requiredChecks: parseCsvFlag(parsed.flags.get("required-check")) }
20
+ : {}),
21
+ ...(admissionLabel ? { admissionLabel } : {}),
22
+ });
23
+ const unitInstall = await installServiceUnit();
24
+ const daemonReload = await runSystemctl(runCommand, ["daemon-reload"]);
25
+ const enableState = await runSystemctl(runCommand, ["enable", `merge-steward@${repoId}.service`]);
26
+ const restartState = await runSystemctl(runCommand, ["reload-or-restart", `merge-steward@${repoId}.service`]);
27
+ const { config: homeConfig } = readHomeConfig();
28
+ const publicBaseUrl = homeConfig.server.public_base_url;
29
+ const webhookUrl = publicBaseUrl ? new URL(result.repo.webhookPath, publicBaseUrl).toString() : undefined;
30
+ const payload = {
31
+ ...result,
32
+ unitTemplatePath: unitInstall.unitTemplatePath,
33
+ daemonReloaded: daemonReload.ok,
34
+ serviceEnabled: enableState.ok,
35
+ serviceRestarted: restartState.ok,
36
+ ...(webhookUrl ? { webhookUrl } : {}),
37
+ errors: [
38
+ ...(daemonReload.ok ? [] : [daemonReload.error]),
39
+ ...(enableState.ok ? [] : [enableState.error]),
40
+ ...(restartState.ok ? [] : [restartState.error]),
41
+ ],
42
+ };
43
+ if (parsed.flags.get("json") === true) {
44
+ writeOutput(stdout, formatJson(payload));
45
+ return daemonReload.ok && enableState.ok && restartState.ok ? 0 : 1;
46
+ }
47
+ writeOutput(stdout, [
48
+ `Repo config: ${result.configPath}`,
49
+ `${result.status === "created" ? "Attached" : result.status === "updated" ? "Updated" : "Verified"} repo ${result.repo.id} for ${result.repo.repoFullName}`,
50
+ `Base branch: ${result.repo.baseBranch}`,
51
+ `Admission label: ${result.repo.admissionLabel}`,
52
+ `Required checks: ${result.repo.requiredChecks.length > 0 ? result.repo.requiredChecks.join(", ") : "(any green check)"}`,
53
+ `Local port: ${result.repo.port}`,
54
+ webhookUrl ? `Webhook URL: ${webhookUrl}` : "Webhook URL: set MERGE_STEWARD_PUBLIC_BASE_URL in runtime.env or merge-steward.json to print this",
55
+ daemonReload.ok ? "systemd daemon-reload completed." : `systemd daemon-reload failed: ${daemonReload.error}`,
56
+ enableState.ok ? `Enabled merge-steward@${repoId}.service` : `Enable failed: ${enableState.error}`,
57
+ restartState.ok ? `Restarted merge-steward@${repoId}.service` : `Restart failed: ${restartState.error}`,
58
+ "Next: merge-steward service status " + repoId,
59
+ ].join("\n") + "\n");
60
+ return daemonReload.ok && enableState.ok && restartState.ok ? 0 : 1;
61
+ }
@@ -0,0 +1,2 @@
1
+ import type { ParsedArgs, Output } from "../types.ts";
2
+ export declare function handleDoctor(parsed: ParsedArgs, stdout: Output): Promise<number>;
@@ -0,0 +1,122 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { loadConfig } from "../../config.js";
5
+ import { exec } from "../../exec.js";
6
+ import { resolveSecretWithSource } from "../../resolve-secret.js";
7
+ import { getDefaultConfigPath, getDefaultRepoConfigDir, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultStateDir, getRepoConfigPath, getSystemdUnitTemplatePath, } from "../../runtime-paths.js";
8
+ import { formatJson, writeOutput } from "../output.js";
9
+ import { getHomeEnv } from "../system.js";
10
+ function checkPath(scope, targetPath, writable = false) {
11
+ if (!existsSync(targetPath)) {
12
+ return { status: "fail", scope, message: `Missing path: ${targetPath}` };
13
+ }
14
+ try {
15
+ const stats = statSync(targetPath);
16
+ if (!stats.isDirectory() && !stats.isFile()) {
17
+ return { status: "fail", scope, message: `Unexpected path type: ${targetPath}` };
18
+ }
19
+ if (writable) {
20
+ accessSync(stats.isDirectory() ? targetPath : path.dirname(targetPath), constants.W_OK);
21
+ }
22
+ return { status: "pass", scope, message: writable ? `${targetPath} is writable` : `${targetPath} exists` };
23
+ }
24
+ catch (error) {
25
+ return {
26
+ status: "fail",
27
+ scope,
28
+ message: error instanceof Error ? error.message : String(error),
29
+ };
30
+ }
31
+ }
32
+ async function checkExecutable(scope, command) {
33
+ const result = spawnSync(command, ["--version"], { stdio: "ignore" });
34
+ if (result.status === 0) {
35
+ return { status: "pass", scope, message: `${command} is available` };
36
+ }
37
+ return { status: "fail", scope, message: `${command} is not available in PATH` };
38
+ }
39
+ export async function handleDoctor(parsed, stdout) {
40
+ const repoId = typeof parsed.flags.get("repo") === "string" ? String(parsed.flags.get("repo")) : undefined;
41
+ const checks = [];
42
+ const env = getHomeEnv();
43
+ checks.push(checkPath("home-config", getDefaultConfigPath()));
44
+ checks.push(checkPath("runtime-env", getDefaultRuntimeEnvPath()));
45
+ checks.push(checkPath("service-env", getDefaultServiceEnvPath()));
46
+ checks.push(checkPath("repo-config-dir", getDefaultRepoConfigDir(), true));
47
+ checks.push(checkPath("state-dir", getDefaultStateDir(), true));
48
+ checks.push(checkPath("systemd-unit", getSystemdUnitTemplatePath()));
49
+ checks.push(await checkExecutable("git", "git"));
50
+ checks.push(await checkExecutable("gh", "gh"));
51
+ const webhookSecret = resolveSecretWithSource("merge-steward-webhook-secret", "MERGE_STEWARD_WEBHOOK_SECRET", env);
52
+ checks.push({
53
+ status: webhookSecret.value ? "pass" : "warn",
54
+ scope: "webhook-secret",
55
+ message: webhookSecret.value
56
+ ? `Webhook secret resolved from ${webhookSecret.source}`
57
+ : "Webhook secret is missing; signed webhook verification will be disabled",
58
+ });
59
+ const githubToken = resolveSecretWithSource("merge-steward-github-token", "MERGE_STEWARD_GITHUB_TOKEN", env);
60
+ checks.push({
61
+ status: githubToken.value ? "pass" : "fail",
62
+ scope: "github-token",
63
+ message: githubToken.value
64
+ ? `GitHub token resolved from ${githubToken.source}`
65
+ : "GitHub token is missing; steward cannot call gh for merge/check operations",
66
+ });
67
+ let repoConfigPath;
68
+ if (repoId) {
69
+ repoConfigPath = getRepoConfigPath(repoId);
70
+ if (!existsSync(repoConfigPath)) {
71
+ checks.push({ status: "fail", scope: `repo:${repoId}`, message: `Repo config not found: ${repoConfigPath}` });
72
+ }
73
+ else {
74
+ try {
75
+ const config = loadConfig(repoConfigPath);
76
+ mkdirSync(path.dirname(config.database.path), { recursive: true });
77
+ mkdirSync(path.dirname(config.clonePath), { recursive: true });
78
+ checks.push({ status: "pass", scope: `repo:${repoId}`, message: `Repo config is valid for ${config.repoFullName}` });
79
+ checks.push(checkPath(`repo:${repoId}:database-dir`, path.dirname(config.database.path), true));
80
+ checks.push(checkPath(`repo:${repoId}:clone-parent`, path.dirname(config.clonePath), true));
81
+ if (githubToken.value) {
82
+ try {
83
+ const auth = await exec("gh", ["api", "user", "--jq", ".login"], {
84
+ allowNonZero: true,
85
+ env: {
86
+ ...process.env,
87
+ ...Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined)),
88
+ },
89
+ });
90
+ if (auth.exitCode === 0 && auth.stdout.trim()) {
91
+ checks.push({ status: "pass", scope: "github-auth", message: `gh authenticated as ${auth.stdout.trim()}` });
92
+ }
93
+ else {
94
+ checks.push({ status: "warn", scope: "github-auth", message: "gh did not confirm the current auth identity" });
95
+ }
96
+ }
97
+ catch (error) {
98
+ checks.push({
99
+ status: "warn",
100
+ scope: "github-auth",
101
+ message: error instanceof Error ? error.message : String(error),
102
+ });
103
+ }
104
+ }
105
+ }
106
+ catch (error) {
107
+ checks.push({
108
+ status: "fail",
109
+ scope: `repo:${repoId}`,
110
+ message: error instanceof Error ? error.message : String(error),
111
+ });
112
+ }
113
+ }
114
+ }
115
+ const ok = checks.every((check) => check.status !== "fail");
116
+ if (parsed.flags.get("json") === true) {
117
+ writeOutput(stdout, formatJson({ ok, checks, ...(repoConfigPath ? { repoConfigPath } : {}) }));
118
+ return ok ? 0 : 1;
119
+ }
120
+ writeOutput(stdout, `${checks.map((check) => `[${check.status}] ${check.scope}: ${check.message}`).join("\n")}\n`);
121
+ return ok ? 0 : 1;
122
+ }
@@ -0,0 +1,2 @@
1
+ import type { ParsedArgs, Output, CommandRunner } from "../types.ts";
2
+ export declare function handleInit(parsed: ParsedArgs, stdout: Output, runCommand: CommandRunner): Promise<number>;