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.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/dist/classify.d.ts +9 -0
- package/dist/classify.js +29 -0
- package/dist/cli/args.d.ts +7 -0
- package/dist/cli/args.js +118 -0
- package/dist/cli/commands/attach.d.ts +2 -0
- package/dist/cli/commands/attach.js +61 -0
- package/dist/cli/commands/doctor.d.ts +2 -0
- package/dist/cli/commands/doctor.js +122 -0
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +55 -0
- package/dist/cli/commands/queue.d.ts +2 -0
- package/dist/cli/commands/queue.js +171 -0
- package/dist/cli/commands/repos.d.ts +2 -0
- package/dist/cli/commands/repos.js +53 -0
- package/dist/cli/commands/service.d.ts +2 -0
- package/dist/cli/commands/service.js +115 -0
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +102 -0
- package/dist/cli/output.d.ts +6 -0
- package/dist/cli/output.js +15 -0
- package/dist/cli/system.d.ts +40 -0
- package/dist/cli/system.js +165 -0
- package/dist/cli/types.d.ts +23 -0
- package/dist/cli/types.js +8 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +62 -0
- package/dist/config.d.ts +40 -0
- package/dist/config.js +100 -0
- package/dist/db/schema.d.ts +6 -0
- package/dist/db/schema.js +74 -0
- package/dist/db/shared.d.ts +27 -0
- package/dist/db/shared.js +98 -0
- package/dist/db/sqlite-store.d.ts +28 -0
- package/dist/db/sqlite-store.js +242 -0
- package/dist/exec.d.ts +17 -0
- package/dist/exec.js +44 -0
- package/dist/github/actions-runner.d.ts +17 -0
- package/dist/github/actions-runner.js +63 -0
- package/dist/github/check-run-reporter.d.ts +16 -0
- package/dist/github/check-run-reporter.js +108 -0
- package/dist/github/clone-manager.d.ts +18 -0
- package/dist/github/clone-manager.js +47 -0
- package/dist/github/pr-client.d.ts +21 -0
- package/dist/github/pr-client.js +139 -0
- package/dist/github/shell-git.d.ts +14 -0
- package/dist/github/shell-git.js +68 -0
- package/dist/http.d.ts +5 -0
- package/dist/http.js +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -0
- package/dist/install.d.ts +43 -0
- package/dist/install.js +206 -0
- package/dist/interfaces.d.ts +53 -0
- package/dist/interfaces.js +1 -0
- package/dist/reconciler.d.ts +18 -0
- package/dist/reconciler.js +357 -0
- package/dist/resolve-secret.d.ts +7 -0
- package/dist/resolve-secret.js +33 -0
- package/dist/runtime-paths.d.ts +25 -0
- package/dist/runtime-paths.js +73 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +42 -0
- package/dist/service.d.ts +73 -0
- package/dist/service.js +343 -0
- package/dist/steward-home.d.ts +20 -0
- package/dist/steward-home.js +38 -0
- package/dist/store.d.ts +26 -0
- package/dist/store.js +1 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.js +1 -0
- package/dist/watch/App.d.ts +6 -0
- package/dist/watch/App.js +192 -0
- package/dist/watch/DetailView.d.ts +10 -0
- package/dist/watch/DetailView.js +20 -0
- package/dist/watch/EntryStateGraph.d.ts +7 -0
- package/dist/watch/EntryStateGraph.js +31 -0
- package/dist/watch/ExternalRepairObservation.d.ts +6 -0
- package/dist/watch/ExternalRepairObservation.js +15 -0
- package/dist/watch/FreshnessBadge.d.ts +7 -0
- package/dist/watch/FreshnessBadge.js +13 -0
- package/dist/watch/HelpBar.d.ts +5 -0
- package/dist/watch/HelpBar.js +8 -0
- package/dist/watch/QueueListView.d.ts +9 -0
- package/dist/watch/QueueListView.js +19 -0
- package/dist/watch/StatusBar.d.ts +10 -0
- package/dist/watch/StatusBar.js +30 -0
- package/dist/watch/api.d.ts +8 -0
- package/dist/watch/api.js +43 -0
- package/dist/watch/format.d.ts +10 -0
- package/dist/watch/format.js +82 -0
- package/dist/watch/freshness.d.ts +5 -0
- package/dist/watch/freshness.js +28 -0
- package/dist/watch/index.d.ts +1 -0
- package/dist/watch/index.js +22 -0
- package/dist/watch/state-visualization.d.ts +21 -0
- package/dist/watch/state-visualization.js +114 -0
- package/dist/webhook-handler.d.ts +61 -0
- package/dist/webhook-handler.js +150 -0
- package/infra/merge-steward@.service +34 -0
- package/package.json +63 -0
- package/runtime.env.example +10 -0
- 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;
|
package/dist/classify.js
ADDED
|
@@ -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;
|
package/dist/cli/args.js
ADDED
|
@@ -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,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,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
|
+
}
|