patchrelay 0.1.0
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 +271 -0
- package/config/patchrelay.example.json +5 -0
- package/dist/build-info.js +29 -0
- package/dist/build-info.json +6 -0
- package/dist/cli/data.js +461 -0
- package/dist/cli/formatters/json.js +3 -0
- package/dist/cli/formatters/text.js +119 -0
- package/dist/cli/index.js +761 -0
- package/dist/codex-app-server.js +353 -0
- package/dist/codex-types.js +1 -0
- package/dist/config-types.js +1 -0
- package/dist/config.js +494 -0
- package/dist/db/authoritative-ledger-store.js +437 -0
- package/dist/db/issue-workflow-store.js +690 -0
- package/dist/db/linear-installation-store.js +184 -0
- package/dist/db/migrations.js +183 -0
- package/dist/db/shared.js +101 -0
- package/dist/db/stage-event-store.js +33 -0
- package/dist/db/webhook-event-store.js +46 -0
- package/dist/db-ports.js +5 -0
- package/dist/db-types.js +1 -0
- package/dist/db.js +40 -0
- package/dist/file-permissions.js +40 -0
- package/dist/http.js +321 -0
- package/dist/index.js +69 -0
- package/dist/install.js +302 -0
- package/dist/installation-ports.js +1 -0
- package/dist/issue-query-service.js +68 -0
- package/dist/ledger-ports.js +1 -0
- package/dist/linear-client.js +338 -0
- package/dist/linear-oauth-service.js +131 -0
- package/dist/linear-oauth.js +154 -0
- package/dist/linear-types.js +1 -0
- package/dist/linear-workflow.js +78 -0
- package/dist/logging.js +62 -0
- package/dist/preflight.js +227 -0
- package/dist/project-resolution.js +51 -0
- package/dist/reconciliation-action-applier.js +55 -0
- package/dist/reconciliation-actions.js +1 -0
- package/dist/reconciliation-engine.js +312 -0
- package/dist/reconciliation-snapshot-builder.js +96 -0
- package/dist/reconciliation-types.js +1 -0
- package/dist/runtime-paths.js +89 -0
- package/dist/service-queue.js +49 -0
- package/dist/service-runtime.js +96 -0
- package/dist/service-stage-finalizer.js +348 -0
- package/dist/service-stage-runner.js +233 -0
- package/dist/service-webhook-processor.js +181 -0
- package/dist/service-webhooks.js +148 -0
- package/dist/service.js +139 -0
- package/dist/stage-agent-activity-publisher.js +33 -0
- package/dist/stage-event-ports.js +1 -0
- package/dist/stage-failure.js +92 -0
- package/dist/stage-launch.js +54 -0
- package/dist/stage-lifecycle-publisher.js +213 -0
- package/dist/stage-reporting.js +153 -0
- package/dist/stage-turn-input-dispatcher.js +102 -0
- package/dist/token-crypto.js +21 -0
- package/dist/types.js +5 -0
- package/dist/utils.js +163 -0
- package/dist/webhook-agent-session-handler.js +157 -0
- package/dist/webhook-archive.js +24 -0
- package/dist/webhook-comment-handler.js +89 -0
- package/dist/webhook-desired-stage-recorder.js +150 -0
- package/dist/webhook-event-ports.js +1 -0
- package/dist/webhook-installation-handler.js +57 -0
- package/dist/webhooks.js +301 -0
- package/dist/workflow-policy.js +42 -0
- package/dist/workflow-ports.js +1 -0
- package/dist/workflow-types.js +1 -0
- package/dist/worktree-manager.js +66 -0
- package/infra/patchrelay-reload.service +6 -0
- package/infra/patchrelay.path +11 -0
- package/infra/patchrelay.service +28 -0
- package/package.json +55 -0
- package/runtime.env.example +8 -0
- package/service.env.example +7 -0
package/dist/http.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import fastify from "fastify";
|
|
2
|
+
import rawBody from "fastify-raw-body";
|
|
3
|
+
import { getBuildInfo } from "./build-info.js";
|
|
4
|
+
export async function buildHttpServer(config, service, logger) {
|
|
5
|
+
const buildInfo = getBuildInfo();
|
|
6
|
+
const loopbackBind = isLoopbackBind(config.server.bind);
|
|
7
|
+
const managementRoutesEnabled = loopbackBind || config.operatorApi.enabled;
|
|
8
|
+
const app = fastify({
|
|
9
|
+
loggerInstance: logger,
|
|
10
|
+
bodyLimit: config.ingress.maxBodyBytes,
|
|
11
|
+
disableRequestLogging: true,
|
|
12
|
+
});
|
|
13
|
+
await app.register(rawBody, {
|
|
14
|
+
field: "rawBody",
|
|
15
|
+
global: false,
|
|
16
|
+
encoding: false,
|
|
17
|
+
runFirst: true,
|
|
18
|
+
});
|
|
19
|
+
app.get("/", async (_request, reply) => {
|
|
20
|
+
return reply
|
|
21
|
+
.type("text/html; charset=utf-8")
|
|
22
|
+
.send(`<!doctype html>
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charset="utf-8">
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
27
|
+
<title>PatchRelay</title>
|
|
28
|
+
<style>
|
|
29
|
+
:root {
|
|
30
|
+
color-scheme: light;
|
|
31
|
+
--bg: #f6f3ee;
|
|
32
|
+
--panel: rgba(255, 255, 255, 0.76);
|
|
33
|
+
--ink: #1f1d1a;
|
|
34
|
+
--muted: #6c665d;
|
|
35
|
+
--accent: #1e6a52;
|
|
36
|
+
--accent-2: #d2a24c;
|
|
37
|
+
--border: rgba(31, 29, 26, 0.12);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
* {
|
|
41
|
+
box-sizing: border-box;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
body {
|
|
45
|
+
margin: 0;
|
|
46
|
+
min-height: 100vh;
|
|
47
|
+
font-family: Georgia, "Times New Roman", serif;
|
|
48
|
+
color: var(--ink);
|
|
49
|
+
background:
|
|
50
|
+
radial-gradient(circle at top left, rgba(210, 162, 76, 0.24), transparent 34%),
|
|
51
|
+
radial-gradient(circle at bottom right, rgba(30, 106, 82, 0.16), transparent 28%),
|
|
52
|
+
linear-gradient(135deg, #efe7db 0%, var(--bg) 48%, #f7f5f1 100%);
|
|
53
|
+
display: grid;
|
|
54
|
+
place-items: center;
|
|
55
|
+
padding: 24px;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
main {
|
|
59
|
+
width: min(760px, 100%);
|
|
60
|
+
background: var(--panel);
|
|
61
|
+
border: 1px solid var(--border);
|
|
62
|
+
border-radius: 24px;
|
|
63
|
+
padding: 40px 32px;
|
|
64
|
+
box-shadow: 0 30px 80px rgba(49, 42, 30, 0.12);
|
|
65
|
+
backdrop-filter: blur(14px);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.eyebrow {
|
|
69
|
+
margin: 0 0 12px;
|
|
70
|
+
font-size: 12px;
|
|
71
|
+
letter-spacing: 0.14em;
|
|
72
|
+
text-transform: uppercase;
|
|
73
|
+
color: var(--accent);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
h1 {
|
|
77
|
+
margin: 0;
|
|
78
|
+
font-size: clamp(40px, 8vw, 72px);
|
|
79
|
+
line-height: 0.95;
|
|
80
|
+
font-weight: 700;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
p {
|
|
84
|
+
margin: 18px 0 0;
|
|
85
|
+
font-size: 18px;
|
|
86
|
+
line-height: 1.6;
|
|
87
|
+
color: var(--muted);
|
|
88
|
+
max-width: 42rem;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.meta {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-wrap: wrap;
|
|
94
|
+
gap: 12px;
|
|
95
|
+
margin-top: 28px;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.chip {
|
|
99
|
+
display: inline-flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
border: 1px solid var(--border);
|
|
102
|
+
border-radius: 999px;
|
|
103
|
+
padding: 10px 14px;
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
color: var(--ink);
|
|
106
|
+
background: rgba(255, 255, 255, 0.7);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
code {
|
|
110
|
+
font-family: "SFMono-Regular", "Cascadia Code", "Fira Code", monospace;
|
|
111
|
+
font-size: 0.95em;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
a {
|
|
115
|
+
color: var(--accent);
|
|
116
|
+
text-decoration: none;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
a:hover {
|
|
120
|
+
text-decoration: underline;
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
123
|
+
</head>
|
|
124
|
+
<body>
|
|
125
|
+
<main>
|
|
126
|
+
<p class="eyebrow">PatchRelay</p>
|
|
127
|
+
<h1>Webhook in, worktree out.</h1>
|
|
128
|
+
<p>
|
|
129
|
+
PatchRelay listens for signed Linear webhooks, prepares an issue-specific worktree, and orchestrates
|
|
130
|
+
staged Codex runs through <code>codex app-server</code> with durable thread history and read-only reports.
|
|
131
|
+
</p>
|
|
132
|
+
<div class="meta">
|
|
133
|
+
<span class="chip">Health: <a href="${config.server.healthPath}">${config.server.healthPath}</a></span>
|
|
134
|
+
<span class="chip">Webhook: <code>${config.ingress.linearWebhookPath}</code></span>
|
|
135
|
+
<span class="chip">Version: <code>${buildInfo.version}</code></span>
|
|
136
|
+
<span class="chip">Commit: <code>${buildInfo.commit}</code></span>
|
|
137
|
+
<span class="chip">Logs: <code>${config.logging.filePath}</code></span>
|
|
138
|
+
</div>
|
|
139
|
+
</main>
|
|
140
|
+
</body>
|
|
141
|
+
</html>`);
|
|
142
|
+
});
|
|
143
|
+
app.get(config.server.healthPath, async () => ({
|
|
144
|
+
ok: true,
|
|
145
|
+
service: buildInfo.service,
|
|
146
|
+
version: buildInfo.version,
|
|
147
|
+
commit: buildInfo.commit,
|
|
148
|
+
builtAt: buildInfo.builtAt,
|
|
149
|
+
}));
|
|
150
|
+
app.get(config.server.readinessPath, async (_request, reply) => {
|
|
151
|
+
const readiness = service.getReadiness();
|
|
152
|
+
return reply.code(readiness.ready ? 200 : 503).send({
|
|
153
|
+
ok: readiness.ready,
|
|
154
|
+
...readiness,
|
|
155
|
+
service: buildInfo.service,
|
|
156
|
+
version: buildInfo.version,
|
|
157
|
+
commit: buildInfo.commit,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
app.post(config.ingress.linearWebhookPath, {
|
|
161
|
+
config: {
|
|
162
|
+
rawBody: true,
|
|
163
|
+
},
|
|
164
|
+
}, async (request, reply) => {
|
|
165
|
+
const rawBody = typeof request.rawBody === "string" ? Buffer.from(request.rawBody) : request.rawBody;
|
|
166
|
+
if (!rawBody) {
|
|
167
|
+
return reply.code(400).send({ ok: false, reason: "missing_raw_body" });
|
|
168
|
+
}
|
|
169
|
+
const webhookId = getHeader(request, "linear-delivery");
|
|
170
|
+
if (!webhookId) {
|
|
171
|
+
return reply.code(400).send({ ok: false, reason: "missing_delivery_header" });
|
|
172
|
+
}
|
|
173
|
+
const result = await service.acceptWebhook({
|
|
174
|
+
webhookId,
|
|
175
|
+
headers: request.headers,
|
|
176
|
+
rawBody,
|
|
177
|
+
});
|
|
178
|
+
return reply.code(result.status).send(result.body);
|
|
179
|
+
});
|
|
180
|
+
if (config.operatorApi.enabled) {
|
|
181
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
182
|
+
if (!request.url.startsWith("/api/")) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (!isAuthorizedOperatorRequest(request, config)) {
|
|
186
|
+
return reply.code(401).send({ ok: false, reason: "operator_auth_required" });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
app.get("/api/issues/:issueKey", async (request, reply) => {
|
|
190
|
+
const issueKey = request.params.issueKey;
|
|
191
|
+
const result = await service.getIssueOverview(issueKey);
|
|
192
|
+
if (!result) {
|
|
193
|
+
return reply.code(404).send({ ok: false, reason: "issue_not_found" });
|
|
194
|
+
}
|
|
195
|
+
return reply.send({ ok: true, ...result });
|
|
196
|
+
});
|
|
197
|
+
app.get("/api/issues/:issueKey/report", async (request, reply) => {
|
|
198
|
+
const issueKey = request.params.issueKey;
|
|
199
|
+
const result = await service.getIssueReport(issueKey);
|
|
200
|
+
if (!result) {
|
|
201
|
+
return reply.code(404).send({ ok: false, reason: "issue_not_found" });
|
|
202
|
+
}
|
|
203
|
+
return reply.send({ ok: true, ...result });
|
|
204
|
+
});
|
|
205
|
+
app.get("/api/issues/:issueKey/live", async (request, reply) => {
|
|
206
|
+
const issueKey = request.params.issueKey;
|
|
207
|
+
const result = await service.getActiveStageStatus(issueKey);
|
|
208
|
+
if (!result) {
|
|
209
|
+
return reply.code(404).send({ ok: false, reason: "active_stage_not_found" });
|
|
210
|
+
}
|
|
211
|
+
return reply.send({ ok: true, ...result });
|
|
212
|
+
});
|
|
213
|
+
app.get("/api/issues/:issueKey/stages/:stageRunId/events", async (request, reply) => {
|
|
214
|
+
const { issueKey, stageRunId } = request.params;
|
|
215
|
+
const result = await service.getStageEvents(issueKey, Number(stageRunId));
|
|
216
|
+
if (!result) {
|
|
217
|
+
return reply.code(404).send({ ok: false, reason: "stage_run_not_found" });
|
|
218
|
+
}
|
|
219
|
+
return reply.send({ ok: true, ...result });
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (managementRoutesEnabled) {
|
|
223
|
+
app.get("/api/installations", async (_request, reply) => {
|
|
224
|
+
return reply.send({ ok: true, installations: service.listLinearInstallations() });
|
|
225
|
+
});
|
|
226
|
+
app.get("/api/oauth/linear/start", async (request, reply) => {
|
|
227
|
+
const projectId = getQueryParam(request, "projectId");
|
|
228
|
+
const result = service.createLinearOAuthStart(projectId ? { projectId } : undefined);
|
|
229
|
+
return reply.send({ ok: true, ...result });
|
|
230
|
+
});
|
|
231
|
+
app.get("/api/oauth/linear/state/:state", async (request, reply) => {
|
|
232
|
+
const { state } = request.params;
|
|
233
|
+
const result = service.getLinearOAuthStateStatus(state);
|
|
234
|
+
if (!result) {
|
|
235
|
+
return reply.code(404).send({ ok: false, reason: "oauth_state_not_found" });
|
|
236
|
+
}
|
|
237
|
+
return reply.send({ ok: true, ...result });
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
app.get("/oauth/linear/callback", async (request, reply) => {
|
|
241
|
+
const code = getQueryParam(request, "code");
|
|
242
|
+
const state = getQueryParam(request, "state");
|
|
243
|
+
if (!code || !state) {
|
|
244
|
+
return reply.code(400).type("text/html; charset=utf-8").send(renderOAuthResult("Missing code or state."));
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const installation = await service.completeLinearOAuth({ code, state });
|
|
248
|
+
return reply
|
|
249
|
+
.type("text/html; charset=utf-8")
|
|
250
|
+
.send(renderOAuthResult(`Connected Linear installation #${installation.id}. You can close this window.`));
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
return reply
|
|
254
|
+
.code(400)
|
|
255
|
+
.type("text/html; charset=utf-8")
|
|
256
|
+
.send(renderOAuthResult(error instanceof Error ? error.message : String(error)));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
app.addHook("onResponse", async (request, reply) => {
|
|
260
|
+
if (reply.statusCode < 500) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
request.log.error({
|
|
264
|
+
reqId: request.id,
|
|
265
|
+
method: request.method,
|
|
266
|
+
url: request.url,
|
|
267
|
+
statusCode: reply.statusCode,
|
|
268
|
+
responseTime: reply.elapsedTime,
|
|
269
|
+
}, "request failed");
|
|
270
|
+
});
|
|
271
|
+
return app;
|
|
272
|
+
}
|
|
273
|
+
function getHeader(request, name) {
|
|
274
|
+
const value = request.headers[name];
|
|
275
|
+
return typeof value === "string" ? value : undefined;
|
|
276
|
+
}
|
|
277
|
+
function isAuthorizedOperatorRequest(request, config) {
|
|
278
|
+
if (isLoopbackBind(config.server.bind)) {
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
if (!config.operatorApi.bearerToken) {
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
const auth = getHeader(request, "authorization");
|
|
285
|
+
return auth === `Bearer ${config.operatorApi.bearerToken}`;
|
|
286
|
+
}
|
|
287
|
+
function isLoopbackBind(bind) {
|
|
288
|
+
return bind === "127.0.0.1" || bind === "::1" || bind === "localhost";
|
|
289
|
+
}
|
|
290
|
+
function escapeHtml(value) {
|
|
291
|
+
return value
|
|
292
|
+
.replaceAll("&", "&")
|
|
293
|
+
.replaceAll("<", "<")
|
|
294
|
+
.replaceAll(">", ">")
|
|
295
|
+
.replaceAll("\"", """);
|
|
296
|
+
}
|
|
297
|
+
function getQueryParam(request, key) {
|
|
298
|
+
const value = request.query?.[key];
|
|
299
|
+
return typeof value === "string" ? value : undefined;
|
|
300
|
+
}
|
|
301
|
+
function renderOAuthResult(message) {
|
|
302
|
+
return `<!doctype html>
|
|
303
|
+
<html lang="en">
|
|
304
|
+
<head>
|
|
305
|
+
<meta charset="utf-8">
|
|
306
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
307
|
+
<title>PatchRelay OAuth</title>
|
|
308
|
+
<style>
|
|
309
|
+
body { font-family: Georgia, "Times New Roman", serif; background: #f6f3ee; color: #1f1d1a; margin: 0; padding: 32px; }
|
|
310
|
+
main { max-width: 640px; margin: 10vh auto; background: rgba(255,255,255,0.82); border: 1px solid rgba(31,29,26,0.12); border-radius: 20px; padding: 32px; }
|
|
311
|
+
p { font-size: 18px; line-height: 1.6; }
|
|
312
|
+
</style>
|
|
313
|
+
</head>
|
|
314
|
+
<body>
|
|
315
|
+
<main>
|
|
316
|
+
<h1>PatchRelay</h1>
|
|
317
|
+
<p>${escapeHtml(message)}</p>
|
|
318
|
+
</main>
|
|
319
|
+
</body>
|
|
320
|
+
</html>`;
|
|
321
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { runCli } from "./cli/index.js";
|
|
4
|
+
import { CodexAppServerClient } from "./codex-app-server.js";
|
|
5
|
+
import { getAdjacentEnvFilePaths, loadConfig } from "./config.js";
|
|
6
|
+
import { PatchRelayDatabase } from "./db.js";
|
|
7
|
+
import { enforceRuntimeFilePermissions, enforceServiceEnvPermissions } from "./file-permissions.js";
|
|
8
|
+
import { buildHttpServer } from "./http.js";
|
|
9
|
+
import { DatabaseBackedLinearClientProvider } from "./linear-client.js";
|
|
10
|
+
import { createLogger } from "./logging.js";
|
|
11
|
+
import { runPreflight } from "./preflight.js";
|
|
12
|
+
import { PatchRelayService } from "./service.js";
|
|
13
|
+
import { ensureDir } from "./utils.js";
|
|
14
|
+
async function main() {
|
|
15
|
+
const cliExitCode = await runCli(process.argv.slice(2));
|
|
16
|
+
if (cliExitCode !== -1) {
|
|
17
|
+
process.exitCode = cliExitCode;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const configPath = process.env.PATCHRELAY_CONFIG;
|
|
21
|
+
const config = loadConfig(configPath);
|
|
22
|
+
await enforceServiceEnvPermissions(getAdjacentEnvFilePaths(configPath).serviceEnvPath);
|
|
23
|
+
await ensureDir(dirname(config.database.path));
|
|
24
|
+
await ensureDir(dirname(config.logging.filePath));
|
|
25
|
+
if (config.logging.webhookArchiveDir) {
|
|
26
|
+
await ensureDir(config.logging.webhookArchiveDir);
|
|
27
|
+
}
|
|
28
|
+
for (const project of config.projects) {
|
|
29
|
+
await ensureDir(project.worktreeRoot);
|
|
30
|
+
}
|
|
31
|
+
await enforceRuntimeFilePermissions(config);
|
|
32
|
+
const preflight = await runPreflight(config);
|
|
33
|
+
const failedChecks = preflight.checks.filter((check) => check.status === "fail");
|
|
34
|
+
if (failedChecks.length > 0) {
|
|
35
|
+
throw new Error(["PatchRelay startup preflight failed:", ...failedChecks.map((check) => `- [${check.scope}] ${check.message}`)].join("\n"));
|
|
36
|
+
}
|
|
37
|
+
const logger = createLogger(config);
|
|
38
|
+
const db = new PatchRelayDatabase(config.database.path, config.database.wal);
|
|
39
|
+
db.runMigrations();
|
|
40
|
+
const codex = new CodexAppServerClient(config.runner.codex, logger);
|
|
41
|
+
const linearProvider = new DatabaseBackedLinearClientProvider(config, db, logger);
|
|
42
|
+
const service = new PatchRelayService(config, db, codex, linearProvider, logger);
|
|
43
|
+
await service.start();
|
|
44
|
+
const app = await buildHttpServer(config, service, logger);
|
|
45
|
+
await app.listen({
|
|
46
|
+
host: config.server.bind,
|
|
47
|
+
port: config.server.port,
|
|
48
|
+
});
|
|
49
|
+
logger.info({
|
|
50
|
+
bind: config.server.bind,
|
|
51
|
+
port: config.server.port,
|
|
52
|
+
webhookPath: config.ingress.linearWebhookPath,
|
|
53
|
+
configPath: process.env.PATCHRELAY_CONFIG,
|
|
54
|
+
}, "PatchRelay started");
|
|
55
|
+
const shutdown = async () => {
|
|
56
|
+
service.stop();
|
|
57
|
+
await app.close();
|
|
58
|
+
};
|
|
59
|
+
process.once("SIGINT", () => {
|
|
60
|
+
void shutdown();
|
|
61
|
+
});
|
|
62
|
+
process.once("SIGTERM", () => {
|
|
63
|
+
void shutdown();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
main().catch((error) => {
|
|
67
|
+
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
});
|
package/dist/install.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getDefaultLogPath, getDefaultWebhookArchiveDir, getPatchRelayConfigDir, getPatchRelayDataDir, getPatchRelayStateDir, getSystemdUserPathUnitPath, getSystemdUserReloadUnitPath, getSystemdUserUnitPath, readBundledAsset, } from "./runtime-paths.js";
|
|
7
|
+
import { loadConfig } from "./config.js";
|
|
8
|
+
import { enforceArbitraryFilePermissions } from "./file-permissions.js";
|
|
9
|
+
import { ensureAbsolutePath } from "./utils.js";
|
|
10
|
+
function defaultProjectWorkflows() {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
id: "development",
|
|
14
|
+
when_state: "Start",
|
|
15
|
+
active_state: "Implementing",
|
|
16
|
+
workflow_file: "IMPLEMENTATION_WORKFLOW.md",
|
|
17
|
+
fallback_state: "Human Needed",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "review",
|
|
21
|
+
when_state: "Review",
|
|
22
|
+
active_state: "Reviewing",
|
|
23
|
+
workflow_file: "REVIEW_WORKFLOW.md",
|
|
24
|
+
fallback_state: "Human Needed",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "deploy",
|
|
28
|
+
when_state: "Deploy",
|
|
29
|
+
active_state: "Deploying",
|
|
30
|
+
workflow_file: "DEPLOY_WORKFLOW.md",
|
|
31
|
+
fallback_state: "Human Needed",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "cleanup",
|
|
35
|
+
when_state: "Cleanup",
|
|
36
|
+
active_state: "Cleaning Up",
|
|
37
|
+
workflow_file: "CLEANUP_WORKFLOW.md",
|
|
38
|
+
fallback_state: "Human Needed",
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
function renderTemplate(template, replacements) {
|
|
43
|
+
const home = homedir();
|
|
44
|
+
const user = basename(home);
|
|
45
|
+
const rendered = template
|
|
46
|
+
.replaceAll("${PATCHRELAY_CONFIG:-/home/your-user/.config/patchrelay/patchrelay.json}", getDefaultConfigPath())
|
|
47
|
+
.replaceAll("${PATCHRELAY_DB_PATH:-/home/your-user/.local/state/patchrelay/patchrelay.sqlite}", getDefaultDatabasePath())
|
|
48
|
+
.replaceAll("${PATCHRELAY_LOG_FILE:-/home/your-user/.local/state/patchrelay/patchrelay.log}", getDefaultLogPath())
|
|
49
|
+
.replaceAll("/home/your-user/.config/patchrelay/runtime.env", getDefaultRuntimeEnvPath())
|
|
50
|
+
.replaceAll("/home/your-user/.config/patchrelay/service.env", getDefaultServiceEnvPath())
|
|
51
|
+
.replaceAll("/home/your-user/.config/patchrelay/patchrelay.json", getDefaultConfigPath())
|
|
52
|
+
.replaceAll("/home/your-user/.config/patchrelay", getPatchRelayConfigDir())
|
|
53
|
+
.replaceAll("/home/your-user/.local/state/patchrelay/webhooks", getDefaultWebhookArchiveDir())
|
|
54
|
+
.replaceAll("/home/your-user/.local/state/patchrelay", getPatchRelayStateDir())
|
|
55
|
+
.replaceAll("/home/your-user/.local/share/patchrelay", getPatchRelayDataDir())
|
|
56
|
+
.replaceAll("/home/your-user", home)
|
|
57
|
+
.replaceAll("your-user", user);
|
|
58
|
+
if (replacements?.publicBaseUrl) {
|
|
59
|
+
return rendered.replaceAll("https://patchrelay.example.com", replacements.publicBaseUrl);
|
|
60
|
+
}
|
|
61
|
+
return rendered;
|
|
62
|
+
}
|
|
63
|
+
function parseConfigObject(raw, configPath) {
|
|
64
|
+
const source = raw.trim() ? raw : "{}";
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(source);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
throw new Error(`Invalid JSON config file: ${configPath}: ${message}`, {
|
|
72
|
+
cause: error,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
76
|
+
throw new Error(`Config file must contain a JSON object at the top level: ${configPath}`);
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
|
80
|
+
function stringifyConfig(config) {
|
|
81
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
82
|
+
}
|
|
83
|
+
function generateSecret(bytes = 32) {
|
|
84
|
+
return crypto.randomBytes(bytes).toString("hex");
|
|
85
|
+
}
|
|
86
|
+
function renderServiceEnvTemplate(template) {
|
|
87
|
+
return template
|
|
88
|
+
.replace("LINEAR_WEBHOOK_SECRET=replace-with-linear-webhook-secret", `LINEAR_WEBHOOK_SECRET=${generateSecret()}`)
|
|
89
|
+
.replace("PATCHRELAY_TOKEN_ENCRYPTION_KEY=replace-with-long-random-secret", `PATCHRELAY_TOKEN_ENCRYPTION_KEY=${generateSecret()}`);
|
|
90
|
+
}
|
|
91
|
+
async function writeTemplateFile(targetPath, content, force, options) {
|
|
92
|
+
if (!force && existsSync(targetPath)) {
|
|
93
|
+
if (options?.mode !== undefined) {
|
|
94
|
+
await enforceArbitraryFilePermissions(targetPath, options.mode);
|
|
95
|
+
}
|
|
96
|
+
return "skipped";
|
|
97
|
+
}
|
|
98
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
99
|
+
await writeFile(targetPath, content, "utf8");
|
|
100
|
+
if (options?.mode !== undefined) {
|
|
101
|
+
await enforceArbitraryFilePermissions(targetPath, options.mode);
|
|
102
|
+
}
|
|
103
|
+
return "created";
|
|
104
|
+
}
|
|
105
|
+
async function applyPublicBaseUrlToConfig(configPath, publicBaseUrl) {
|
|
106
|
+
if (!publicBaseUrl || !existsSync(configPath)) {
|
|
107
|
+
return "skipped";
|
|
108
|
+
}
|
|
109
|
+
const original = await readFile(configPath, "utf8");
|
|
110
|
+
const document = parseConfigObject(original, configPath);
|
|
111
|
+
const server = document.server && typeof document.server === "object" && !Array.isArray(document.server)
|
|
112
|
+
? { ...document.server }
|
|
113
|
+
: {};
|
|
114
|
+
server.public_base_url = publicBaseUrl;
|
|
115
|
+
document.server = server;
|
|
116
|
+
const next = stringifyConfig(document);
|
|
117
|
+
if (next === original) {
|
|
118
|
+
return "skipped";
|
|
119
|
+
}
|
|
120
|
+
await writeFile(configPath, next, "utf8");
|
|
121
|
+
return "updated";
|
|
122
|
+
}
|
|
123
|
+
export async function initializePatchRelayHome(options) {
|
|
124
|
+
const force = options?.force ?? false;
|
|
125
|
+
const publicBaseUrl = options?.publicBaseUrl;
|
|
126
|
+
const configDir = getPatchRelayConfigDir();
|
|
127
|
+
const runtimeEnvPath = getDefaultRuntimeEnvPath();
|
|
128
|
+
const serviceEnvPath = getDefaultServiceEnvPath();
|
|
129
|
+
const configPath = getDefaultConfigPath();
|
|
130
|
+
const stateDir = getPatchRelayStateDir();
|
|
131
|
+
const dataDir = getPatchRelayDataDir();
|
|
132
|
+
await mkdir(configDir, { recursive: true });
|
|
133
|
+
await mkdir(stateDir, { recursive: true });
|
|
134
|
+
await mkdir(dataDir, { recursive: true });
|
|
135
|
+
const runtimeEnvTemplate = renderTemplate(readBundledAsset("runtime.env.example"));
|
|
136
|
+
const serviceEnvTemplate = renderServiceEnvTemplate(readBundledAsset("service.env.example"));
|
|
137
|
+
const configTemplate = renderTemplate(readBundledAsset("config/patchrelay.example.json"), publicBaseUrl ? { publicBaseUrl } : undefined);
|
|
138
|
+
const runtimeEnvStatus = await writeTemplateFile(runtimeEnvPath, runtimeEnvTemplate, force);
|
|
139
|
+
const serviceEnvStatus = await writeTemplateFile(serviceEnvPath, serviceEnvTemplate, force, { mode: 0o600 });
|
|
140
|
+
const initialConfigStatus = await writeTemplateFile(configPath, configTemplate, force);
|
|
141
|
+
const configStatus = initialConfigStatus === "created" ? initialConfigStatus : await applyPublicBaseUrlToConfig(configPath, publicBaseUrl);
|
|
142
|
+
return {
|
|
143
|
+
configDir,
|
|
144
|
+
runtimeEnvPath,
|
|
145
|
+
serviceEnvPath,
|
|
146
|
+
configPath,
|
|
147
|
+
stateDir,
|
|
148
|
+
dataDir,
|
|
149
|
+
runtimeEnvStatus,
|
|
150
|
+
serviceEnvStatus,
|
|
151
|
+
configStatus,
|
|
152
|
+
...(publicBaseUrl
|
|
153
|
+
? {
|
|
154
|
+
publicBaseUrl,
|
|
155
|
+
webhookUrl: new URL("/webhooks/linear", publicBaseUrl).toString(),
|
|
156
|
+
oauthCallbackUrl: new URL("/oauth/linear/callback", publicBaseUrl).toString(),
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export async function installUserServiceUnits(options) {
|
|
162
|
+
const force = options?.force ?? false;
|
|
163
|
+
const unitPath = getSystemdUserUnitPath();
|
|
164
|
+
const reloadUnitPath = getSystemdUserReloadUnitPath();
|
|
165
|
+
const pathUnitPath = getSystemdUserPathUnitPath();
|
|
166
|
+
const serviceStatus = await writeTemplateFile(unitPath, renderTemplate(readBundledAsset("infra/patchrelay.service")), force);
|
|
167
|
+
const reloadStatus = await writeTemplateFile(reloadUnitPath, renderTemplate(readBundledAsset("infra/patchrelay-reload.service")), force);
|
|
168
|
+
const pathStatus = await writeTemplateFile(pathUnitPath, renderTemplate(readBundledAsset("infra/patchrelay.path")), force);
|
|
169
|
+
return {
|
|
170
|
+
unitPath,
|
|
171
|
+
reloadUnitPath,
|
|
172
|
+
pathUnitPath,
|
|
173
|
+
runtimeEnvPath: getDefaultRuntimeEnvPath(),
|
|
174
|
+
serviceEnvPath: getDefaultServiceEnvPath(),
|
|
175
|
+
configPath: getDefaultConfigPath(),
|
|
176
|
+
serviceStatus,
|
|
177
|
+
reloadStatus,
|
|
178
|
+
pathStatus,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export async function upsertProjectInConfig(options) {
|
|
182
|
+
const configPath = options.configPath ?? getDefaultConfigPath();
|
|
183
|
+
if (!existsSync(configPath)) {
|
|
184
|
+
throw new Error(`Config file not found: ${configPath}. Run "patchrelay init <public-base-url>" first so PatchRelay knows the public HTTPS origin for Linear.`);
|
|
185
|
+
}
|
|
186
|
+
const projectId = options.id.trim();
|
|
187
|
+
if (!projectId) {
|
|
188
|
+
throw new Error("Project id is required.");
|
|
189
|
+
}
|
|
190
|
+
const repoPath = ensureAbsolutePath(options.repoPath);
|
|
191
|
+
const issueKeyPrefixes = [...new Set((options.issueKeyPrefixes ?? []).map((value) => value.trim()).filter(Boolean))];
|
|
192
|
+
const linearTeamIds = [...new Set((options.linearTeamIds ?? []).map((value) => value.trim()).filter(Boolean))];
|
|
193
|
+
const original = await readFile(configPath, "utf8");
|
|
194
|
+
const parsed = parseConfigObject(original, configPath);
|
|
195
|
+
const existingProjects = Array.isArray(parsed.projects) ? parsed.projects : [];
|
|
196
|
+
const existingIndex = existingProjects.findIndex((project) => String(project.id ?? "") === projectId);
|
|
197
|
+
const existingProject = existingIndex >= 0 ? existingProjects[existingIndex] : undefined;
|
|
198
|
+
const nextProject = {
|
|
199
|
+
...(existingProject ?? {}),
|
|
200
|
+
id: projectId,
|
|
201
|
+
repo_path: repoPath,
|
|
202
|
+
workflows: Array.isArray(existingProject?.workflows) && existingProject.workflows.length > 0
|
|
203
|
+
? existingProject.workflows
|
|
204
|
+
: defaultProjectWorkflows(),
|
|
205
|
+
};
|
|
206
|
+
if (issueKeyPrefixes.length > 0) {
|
|
207
|
+
nextProject.issue_key_prefixes = issueKeyPrefixes;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
delete nextProject.issue_key_prefixes;
|
|
211
|
+
}
|
|
212
|
+
if (linearTeamIds.length > 0) {
|
|
213
|
+
nextProject.linear_team_ids = linearTeamIds;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
delete nextProject.linear_team_ids;
|
|
217
|
+
}
|
|
218
|
+
if (existingProjects.length - (existingProject ? 1 : 0) > 0 && issueKeyPrefixes.length === 0 && linearTeamIds.length === 0) {
|
|
219
|
+
throw new Error("Adding or updating a project in a multi-project config requires routing. Use --issue-prefix or --team-id.");
|
|
220
|
+
}
|
|
221
|
+
if (existingProjects.length - (existingProject ? 1 : 0) > 0) {
|
|
222
|
+
const unscoped = existingProjects.find((project, index) => {
|
|
223
|
+
if (index === existingIndex) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const prefixes = Array.isArray(project.issue_key_prefixes) ? project.issue_key_prefixes : [];
|
|
227
|
+
const teamIds = Array.isArray(project.linear_team_ids) ? project.linear_team_ids : [];
|
|
228
|
+
const labels = Array.isArray(project.allow_labels) ? project.allow_labels : [];
|
|
229
|
+
return prefixes.length === 0 && teamIds.length === 0 && labels.length === 0;
|
|
230
|
+
});
|
|
231
|
+
if (unscoped) {
|
|
232
|
+
throw new Error(`Existing project ${String(unscoped.id ?? "unknown")} has no routing configured. Add routing before configuring multiple projects.`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (const prefix of issueKeyPrefixes) {
|
|
236
|
+
const owner = existingProjects.find((project, index) => index !== existingIndex &&
|
|
237
|
+
Array.isArray(project.issue_key_prefixes) &&
|
|
238
|
+
project.issue_key_prefixes.map(String).includes(prefix));
|
|
239
|
+
if (owner) {
|
|
240
|
+
throw new Error(`Issue key prefix "${prefix}" is already configured for project ${String(owner.id ?? "unknown")}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
for (const teamId of linearTeamIds) {
|
|
244
|
+
const owner = existingProjects.find((project, index) => index !== existingIndex &&
|
|
245
|
+
Array.isArray(project.linear_team_ids) &&
|
|
246
|
+
project.linear_team_ids.map(String).includes(teamId));
|
|
247
|
+
if (owner) {
|
|
248
|
+
throw new Error(`Linear team id "${teamId}" is already configured for project ${String(owner.id ?? "unknown")}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const normalizedExistingProject = existingProject &&
|
|
252
|
+
JSON.stringify({
|
|
253
|
+
id: String(existingProject.id ?? ""),
|
|
254
|
+
repo_path: String(existingProject.repo_path ?? ""),
|
|
255
|
+
issue_key_prefixes: Array.isArray(existingProject.issue_key_prefixes)
|
|
256
|
+
? existingProject.issue_key_prefixes.map(String)
|
|
257
|
+
: [],
|
|
258
|
+
linear_team_ids: Array.isArray(existingProject.linear_team_ids) ? existingProject.linear_team_ids.map(String) : [],
|
|
259
|
+
});
|
|
260
|
+
const normalizedNextProject = JSON.stringify({
|
|
261
|
+
id: String(nextProject.id ?? ""),
|
|
262
|
+
repo_path: String(nextProject.repo_path ?? ""),
|
|
263
|
+
issue_key_prefixes: issueKeyPrefixes,
|
|
264
|
+
linear_team_ids: linearTeamIds,
|
|
265
|
+
});
|
|
266
|
+
const status = existingProject === undefined ? "created" : normalizedExistingProject === normalizedNextProject ? "unchanged" : "updated";
|
|
267
|
+
const document = parseConfigObject(original, configPath);
|
|
268
|
+
if ("projects" in document && document.projects !== undefined && !Array.isArray(document.projects)) {
|
|
269
|
+
throw new Error(`Config file field "projects" must be a JSON array: ${configPath}`);
|
|
270
|
+
}
|
|
271
|
+
if (status !== "unchanged") {
|
|
272
|
+
const nextProjects = [...existingProjects];
|
|
273
|
+
if (existingIndex >= 0) {
|
|
274
|
+
nextProjects[existingIndex] = nextProject;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
nextProjects.push(nextProject);
|
|
278
|
+
}
|
|
279
|
+
document.projects = nextProjects;
|
|
280
|
+
const next = stringifyConfig(document);
|
|
281
|
+
await writeFile(configPath, next, "utf8");
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
loadConfig(configPath, { profile: "write_config" });
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (status !== "unchanged") {
|
|
288
|
+
await writeFile(configPath, original, "utf8");
|
|
289
|
+
}
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
configPath,
|
|
294
|
+
status,
|
|
295
|
+
project: {
|
|
296
|
+
id: projectId,
|
|
297
|
+
repoPath,
|
|
298
|
+
issueKeyPrefixes,
|
|
299
|
+
linearTeamIds,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|