patchrelay 0.11.0 → 0.12.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/dist/build-info.json +3 -3
- package/dist/config.js +6 -5
- package/dist/factory-state.js +5 -3
- package/dist/github-app-token.js +205 -0
- package/dist/github-webhook-handler.js +2 -1
- package/dist/merge-queue.js +13 -8
- package/dist/resolve-secret.js +38 -0
- package/dist/run-orchestrator.js +19 -1
- package/dist/service.js +17 -0
- package/infra/patchrelay.service +31 -8
- package/package.json +1 -1
- package/service.env.example +20 -2
package/dist/build-info.json
CHANGED
package/dist/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { isIP } from "node:net";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { getDefaultConfigPath, getDefaultDatabasePath, getDefaultLogPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getPatchRelayDataDir, } from "./runtime-paths.js";
|
|
6
|
+
import { resolveSecret } from "./resolve-secret.js";
|
|
6
7
|
import { ensureAbsolutePath } from "./utils.js";
|
|
7
8
|
const LINEAR_OAUTH_CALLBACK_PATH = "/oauth/linear/callback";
|
|
8
9
|
const REPO_SETTINGS_DIRNAME = ".patchrelay";
|
|
@@ -285,12 +286,12 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
285
286
|
const parsedFile = parseJsonFile(requestedPath, "config file");
|
|
286
287
|
const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
|
|
287
288
|
const requirements = getLoadProfileRequirements(profile);
|
|
288
|
-
const webhookSecret =
|
|
289
|
-
const tokenEncryptionKey =
|
|
290
|
-
const oauthClientId =
|
|
291
|
-
const oauthClientSecret =
|
|
289
|
+
const webhookSecret = resolveSecret("linear-webhook-secret", parsed.linear.webhook_secret_env, env);
|
|
290
|
+
const tokenEncryptionKey = resolveSecret("token-encryption-key", parsed.linear.token_encryption_key_env, env);
|
|
291
|
+
const oauthClientId = resolveSecret("linear-oauth-client-id", parsed.linear.oauth.client_id_env, env);
|
|
292
|
+
const oauthClientSecret = resolveSecret("linear-oauth-client-secret", parsed.linear.oauth.client_secret_env, env);
|
|
292
293
|
const operatorApiToken = parsed.operator_api.bearer_token_env
|
|
293
|
-
?
|
|
294
|
+
? resolveSecret("operator-api-token", parsed.operator_api.bearer_token_env, env)
|
|
294
295
|
: undefined;
|
|
295
296
|
if (requirements.requireWebhookSecret && !webhookSecret) {
|
|
296
297
|
throw new Error(`Missing env var ${parsed.linear.webhook_secret_env}`);
|
package/dist/factory-state.js
CHANGED
|
@@ -14,11 +14,11 @@ export const ALLOWED_TRANSITIONS = {
|
|
|
14
14
|
delegated: ["preparing", "failed"],
|
|
15
15
|
preparing: ["implementing", "failed"],
|
|
16
16
|
implementing: ["pr_open", "awaiting_input", "failed", "escalated"],
|
|
17
|
-
pr_open: ["awaiting_review", "repairing_ci", "failed"],
|
|
17
|
+
pr_open: ["awaiting_review", "awaiting_queue", "changes_requested", "repairing_ci", "failed"],
|
|
18
18
|
awaiting_review: ["changes_requested", "awaiting_queue", "repairing_ci"],
|
|
19
19
|
changes_requested: ["implementing", "awaiting_input", "escalated"],
|
|
20
20
|
repairing_ci: ["pr_open", "awaiting_review", "escalated", "failed"],
|
|
21
|
-
awaiting_queue: ["done", "repairing_queue", "repairing_ci"],
|
|
21
|
+
awaiting_queue: ["done", "repairing_queue", "repairing_ci", "changes_requested"],
|
|
22
22
|
repairing_queue: ["pr_open", "awaiting_review", "awaiting_queue", "escalated", "failed"],
|
|
23
23
|
awaiting_input: ["implementing", "delegated", "escalated"],
|
|
24
24
|
escalated: [],
|
|
@@ -34,7 +34,9 @@ export function resolveFactoryStateFromGitHub(triggerEvent, current) {
|
|
|
34
34
|
case "review_approved":
|
|
35
35
|
return current === "awaiting_review" || current === "pr_open" ? "awaiting_queue" : undefined;
|
|
36
36
|
case "review_changes_requested":
|
|
37
|
-
return current === "awaiting_review" || current === "pr_open"
|
|
37
|
+
return current === "awaiting_review" || current === "pr_open" || current === "awaiting_queue"
|
|
38
|
+
? "changes_requested"
|
|
39
|
+
: undefined;
|
|
38
40
|
case "review_commented":
|
|
39
41
|
return undefined; // informational only
|
|
40
42
|
case "check_passed":
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { createSign } from "node:crypto";
|
|
2
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveSecret } from "./resolve-secret.js";
|
|
5
|
+
import { getPatchRelayDataDir } from "./runtime-paths.js";
|
|
6
|
+
const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (tokens last 1 hour)
|
|
7
|
+
/**
|
|
8
|
+
* Resolve credentials from environment. Returns undefined if not configured.
|
|
9
|
+
*
|
|
10
|
+
* The private key is resolved via the provider-agnostic `resolveSecret`:
|
|
11
|
+
* 1. `$CREDENTIALS_DIRECTORY/github-app-pem` (systemd-creds)
|
|
12
|
+
* 2. `$PATCHRELAY_GITHUB_APP_PRIVATE_KEY_FILE` (explicit file path)
|
|
13
|
+
* 3. `$PATCHRELAY_GITHUB_APP_PRIVATE_KEY` (direct env var)
|
|
14
|
+
*/
|
|
15
|
+
export function resolveGitHubAppCredentials() {
|
|
16
|
+
const appId = process.env.PATCHRELAY_GITHUB_APP_ID;
|
|
17
|
+
const privateKey = resolveSecret("github-app-pem", "PATCHRELAY_GITHUB_APP_PRIVATE_KEY");
|
|
18
|
+
if (!appId || !privateKey)
|
|
19
|
+
return undefined;
|
|
20
|
+
const installationId = process.env.PATCHRELAY_GITHUB_APP_INSTALLATION_ID;
|
|
21
|
+
return {
|
|
22
|
+
appId,
|
|
23
|
+
privateKey,
|
|
24
|
+
...(installationId ? { installationId } : {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Well-known paths for the token file and the gh wrapper.
|
|
29
|
+
*/
|
|
30
|
+
export function getGitHubAppPaths() {
|
|
31
|
+
const shareDir = getPatchRelayDataDir();
|
|
32
|
+
return {
|
|
33
|
+
tokenFile: path.join(shareDir, "gh-token"),
|
|
34
|
+
binDir: path.join(shareDir, "bin"),
|
|
35
|
+
ghWrapper: path.join(shareDir, "bin", "gh"),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create the gh wrapper script that reads the token file.
|
|
40
|
+
* Idempotent — safe to call on every startup.
|
|
41
|
+
*/
|
|
42
|
+
export async function ensureGhWrapper(logger) {
|
|
43
|
+
const { binDir, ghWrapper, tokenFile } = getGitHubAppPaths();
|
|
44
|
+
await mkdir(binDir, { recursive: true });
|
|
45
|
+
const script = `#!/bin/bash
|
|
46
|
+
# PatchRelay gh wrapper — uses GitHub App token when available.
|
|
47
|
+
# Falls through to the user's own gh auth if the token file is missing.
|
|
48
|
+
TOKEN_FILE="${tokenFile}"
|
|
49
|
+
if [ -f "$TOKEN_FILE" ]; then
|
|
50
|
+
export GH_TOKEN=$(cat "$TOKEN_FILE")
|
|
51
|
+
fi
|
|
52
|
+
exec /usr/bin/gh "$@"
|
|
53
|
+
`;
|
|
54
|
+
await writeFile(ghWrapper, script, { mode: 0o755 });
|
|
55
|
+
logger.debug({ path: ghWrapper }, "Wrote gh wrapper script");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Generate a GitHub App JWT (RS256, 10-minute lifetime).
|
|
59
|
+
*/
|
|
60
|
+
function generateJwt(appId, privateKey) {
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
63
|
+
const payload = Buffer.from(JSON.stringify({
|
|
64
|
+
iat: now - 60, // 60s clock drift allowance
|
|
65
|
+
exp: now + 600, // 10 minutes
|
|
66
|
+
iss: appId,
|
|
67
|
+
})).toString("base64url");
|
|
68
|
+
const signer = createSign("RSA-SHA256");
|
|
69
|
+
signer.update(`${header}.${payload}`);
|
|
70
|
+
const signature = signer.sign(privateKey, "base64url");
|
|
71
|
+
return `${header}.${payload}.${signature}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Exchange a JWT for an installation access token (1-hour lifetime).
|
|
75
|
+
*/
|
|
76
|
+
async function fetchInstallationToken(jwt, installationId) {
|
|
77
|
+
const response = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: {
|
|
80
|
+
Authorization: `Bearer ${jwt}`,
|
|
81
|
+
Accept: "application/vnd.github+json",
|
|
82
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const body = await response.text();
|
|
87
|
+
throw new Error(`Failed to fetch installation token (${response.status}): ${body}`);
|
|
88
|
+
}
|
|
89
|
+
const data = await response.json();
|
|
90
|
+
return data.token;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Find the first installation ID for this app. Called once if installationId
|
|
94
|
+
* is not pre-configured.
|
|
95
|
+
*/
|
|
96
|
+
async function resolveInstallationId(jwt) {
|
|
97
|
+
const response = await fetch("https://api.github.com/app/installations", {
|
|
98
|
+
headers: {
|
|
99
|
+
Authorization: `Bearer ${jwt}`,
|
|
100
|
+
Accept: "application/vnd.github+json",
|
|
101
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const body = await response.text();
|
|
106
|
+
throw new Error(`Failed to list installations (${response.status}): ${body}`);
|
|
107
|
+
}
|
|
108
|
+
const installations = await response.json();
|
|
109
|
+
const first = installations[0];
|
|
110
|
+
if (!first) {
|
|
111
|
+
throw new Error("GitHub App has no installations. Install it on a repository first.");
|
|
112
|
+
}
|
|
113
|
+
return String(first.id);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Creates a token manager that writes a fresh GitHub App installation token
|
|
117
|
+
* to a well-known file every 30 minutes. The gh wrapper script reads this file.
|
|
118
|
+
*
|
|
119
|
+
* Returns undefined if credentials are not configured (optional feature).
|
|
120
|
+
*/
|
|
121
|
+
export function createGitHubAppTokenManager(credentials, logger) {
|
|
122
|
+
const { tokenFile } = getGitHubAppPaths();
|
|
123
|
+
let timer;
|
|
124
|
+
let resolvedInstallationId = credentials.installationId;
|
|
125
|
+
let cachedToken;
|
|
126
|
+
let resolvedBotIdentity;
|
|
127
|
+
async function refresh() {
|
|
128
|
+
try {
|
|
129
|
+
const jwt = generateJwt(credentials.appId, credentials.privateKey);
|
|
130
|
+
if (!resolvedInstallationId) {
|
|
131
|
+
resolvedInstallationId = await resolveInstallationId(jwt);
|
|
132
|
+
logger.info({ installationId: resolvedInstallationId }, "Resolved GitHub App installation ID");
|
|
133
|
+
}
|
|
134
|
+
if (!resolvedBotIdentity) {
|
|
135
|
+
resolvedBotIdentity = await resolveBotIdentity(jwt);
|
|
136
|
+
logger.info({ botName: resolvedBotIdentity.name, botEmail: resolvedBotIdentity.email }, "Resolved GitHub App bot identity");
|
|
137
|
+
}
|
|
138
|
+
const token = await fetchInstallationToken(jwt, resolvedInstallationId);
|
|
139
|
+
await mkdir(path.dirname(tokenFile), { recursive: true });
|
|
140
|
+
await writeFile(tokenFile, token, { mode: 0o600 });
|
|
141
|
+
cachedToken = token;
|
|
142
|
+
logger.debug("Refreshed GitHub App installation token");
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Failed to refresh GitHub App token (will retry in 30 minutes)");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function schedule() {
|
|
149
|
+
timer = setTimeout(() => {
|
|
150
|
+
void refresh().finally(schedule);
|
|
151
|
+
}, TOKEN_REFRESH_MS);
|
|
152
|
+
timer.unref?.();
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
async start() {
|
|
156
|
+
await refresh();
|
|
157
|
+
schedule();
|
|
158
|
+
},
|
|
159
|
+
stop() {
|
|
160
|
+
if (timer) {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
timer = undefined;
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
currentToken() {
|
|
166
|
+
return cachedToken;
|
|
167
|
+
},
|
|
168
|
+
botIdentity() {
|
|
169
|
+
return resolvedBotIdentity;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the bot user identity (name + noreply email) from the GitHub API.
|
|
175
|
+
* The bot user ID (not the App ID) is required for the noreply email —
|
|
176
|
+
* using the App ID causes the [bot] badge to not render on commits.
|
|
177
|
+
*/
|
|
178
|
+
async function resolveBotIdentity(jwt) {
|
|
179
|
+
const response = await fetch("https://api.github.com/app", {
|
|
180
|
+
headers: {
|
|
181
|
+
Authorization: `Bearer ${jwt}`,
|
|
182
|
+
Accept: "application/vnd.github+json",
|
|
183
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
const body = await response.text();
|
|
188
|
+
throw new Error(`Failed to fetch app info (${response.status}): ${body}`);
|
|
189
|
+
}
|
|
190
|
+
const app = await response.json();
|
|
191
|
+
const botLogin = `${app.slug}[bot]`;
|
|
192
|
+
// Fetch the bot user to get the user ID (different from App ID)
|
|
193
|
+
const userResponse = await fetch(`https://api.github.com/users/${encodeURIComponent(botLogin)}`, {
|
|
194
|
+
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
|
195
|
+
});
|
|
196
|
+
if (!userResponse.ok) {
|
|
197
|
+
const body = await userResponse.text();
|
|
198
|
+
throw new Error(`Failed to fetch bot user ${botLogin} (${userResponse.status}): ${body}`);
|
|
199
|
+
}
|
|
200
|
+
const user = await userResponse.json();
|
|
201
|
+
return {
|
|
202
|
+
name: user.login,
|
|
203
|
+
email: `${user.id}+${user.login}@users.noreply.github.com`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveFactoryStateFromGitHub } from "./factory-state.js";
|
|
2
2
|
import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
|
|
3
|
+
import { resolveSecret } from "./resolve-secret.js";
|
|
3
4
|
import { safeJsonParse } from "./utils.js";
|
|
4
5
|
/**
|
|
5
6
|
* GitHub sends both check_run and check_suite completion events.
|
|
@@ -73,7 +74,7 @@ export class GitHubWebhookHandler {
|
|
|
73
74
|
? this.config.projects.find((p) => p.github?.repoFullName === repoName)
|
|
74
75
|
: undefined;
|
|
75
76
|
// Verify signature using global GitHub App webhook secret
|
|
76
|
-
const webhookSecret =
|
|
77
|
+
const webhookSecret = resolveSecret("github-app-webhook-secret", "GITHUB_APP_WEBHOOK_SECRET");
|
|
77
78
|
if (webhookSecret) {
|
|
78
79
|
if (!verifyGitHubWebhookSignature(params.rawBody, webhookSecret, params.signature)) {
|
|
79
80
|
return { status: 401, body: { ok: false, reason: "invalid_signature" } };
|
package/dist/merge-queue.js
CHANGED
|
@@ -97,7 +97,7 @@ export class MergeQueue {
|
|
|
97
97
|
projectId: issue.projectId,
|
|
98
98
|
stage: "awaiting_queue",
|
|
99
99
|
status: "blocked",
|
|
100
|
-
summary: "Branch up to date but auto-merge not enabled —
|
|
100
|
+
summary: "Branch up to date but auto-merge not enabled — check gh auth and repo settings",
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
return;
|
|
@@ -123,9 +123,19 @@ export class MergeQueue {
|
|
|
123
123
|
summary: `Branch updated to latest ${baseBranch} — CI will run`,
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Seed the merge queue on startup: for each project, ensure the front-of-queue
|
|
128
|
+
* issue has pendingMergePrep set. Catches issues that entered awaiting_queue
|
|
129
|
+
* but whose merge prep was never triggered or was lost to a crash/restart.
|
|
130
|
+
*/
|
|
131
|
+
seedOnStartup() {
|
|
132
|
+
for (const project of this.config.projects) {
|
|
133
|
+
this.advanceQueue(project.id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
126
136
|
/**
|
|
127
137
|
* Advance the queue: find the next awaiting_queue issue and prepare it.
|
|
128
|
-
* Called when a PR merges (pr_merged event).
|
|
138
|
+
* Called when a PR merges (pr_merged event) and on startup.
|
|
129
139
|
*/
|
|
130
140
|
advanceQueue(projectId) {
|
|
131
141
|
const queue = this.db.listIssuesByState(projectId, "awaiting_queue");
|
|
@@ -138,14 +148,9 @@ export class MergeQueue {
|
|
|
138
148
|
}
|
|
139
149
|
/** Returns true if auto-merge was successfully enabled (or already enabled). */
|
|
140
150
|
async enableAutoMerge(issue, repoFullName) {
|
|
141
|
-
|
|
142
|
-
if (!token) {
|
|
143
|
-
this.logger.warn({ issueKey: issue.issueKey }, "Merge prep: GITHUB_TOKEN not set — auto-merge cannot be enabled");
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
151
|
+
// Uses the host's existing gh auth — same credentials Codex uses to create PRs.
|
|
146
152
|
const result = await execCommand("gh", ["pr", "merge", String(issue.prNumber), "--repo", repoFullName, "--auto", "--squash"], {
|
|
147
153
|
timeoutMs: 30_000,
|
|
148
|
-
env: { ...process.env, GH_TOKEN: token },
|
|
149
154
|
});
|
|
150
155
|
if (result.exitCode !== 0) {
|
|
151
156
|
this.logger.warn({ issueKey: issue.issueKey, stderr: result.stderr?.slice(0, 200) }, "Merge prep: auto-merge enablement failed");
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Resolve a secret value using a three-level fallback:
|
|
5
|
+
*
|
|
6
|
+
* 1. `$CREDENTIALS_DIRECTORY/<credentialName>` — systemd-creds, Docker secrets,
|
|
7
|
+
* or any provider that mounts secrets as files in a private directory.
|
|
8
|
+
* 2. `${envKey}_FILE` — reads the secret from an arbitrary file path.
|
|
9
|
+
* Works with any file-based provider (age, sops, mounted volumes).
|
|
10
|
+
* 3. `$envKey` — direct environment variable (dev, `op run`, `sops exec-env`,
|
|
11
|
+
* or the legacy `service.env` EnvironmentFile).
|
|
12
|
+
*
|
|
13
|
+
* Returns `undefined` when the secret is not found at any level.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveSecret(credentialName, envKey, env = process.env) {
|
|
16
|
+
// 1. systemd credentials directory (private mount, highest trust)
|
|
17
|
+
const credDir = process.env.CREDENTIALS_DIRECTORY;
|
|
18
|
+
if (credDir) {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(path.join(credDir, credentialName), "utf8").trim();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// credential not in this directory — fall through
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// 2. _FILE convention (works with any file-based provider)
|
|
27
|
+
const filePath = env[`${envKey}_FILE`];
|
|
28
|
+
if (filePath) {
|
|
29
|
+
try {
|
|
30
|
+
return readFileSync(filePath, "utf8").trim();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// file not readable — fall through
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 3. Direct env var
|
|
37
|
+
return env[envKey] || undefined;
|
|
38
|
+
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -5,6 +5,7 @@ import { buildRunningSessionPlan, buildCompletedSessionPlan, buildFailedSessionP
|
|
|
5
5
|
import { buildStageReport, countEventMethods, extractTurnId, resolveRunCompletionStatus, summarizeCurrentThread, } from "./run-reporting.js";
|
|
6
6
|
import { WorktreeManager } from "./worktree-manager.js";
|
|
7
7
|
import { resolveAuthoritativeLinearStopState } from "./linear-workflow.js";
|
|
8
|
+
import { execCommand } from "./utils.js";
|
|
8
9
|
const DEFAULT_CI_REPAIR_BUDGET = 2;
|
|
9
10
|
const DEFAULT_QUEUE_REPAIR_BUDGET = 2;
|
|
10
11
|
function slugify(value) {
|
|
@@ -66,6 +67,7 @@ export class RunOrchestrator {
|
|
|
66
67
|
logger;
|
|
67
68
|
feed;
|
|
68
69
|
worktreeManager;
|
|
70
|
+
botIdentity;
|
|
69
71
|
constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
|
|
70
72
|
this.config = config;
|
|
71
73
|
this.db = db;
|
|
@@ -155,6 +157,12 @@ export class RunOrchestrator {
|
|
|
155
157
|
try {
|
|
156
158
|
// Ensure worktree
|
|
157
159
|
await this.worktreeManager.ensureIssueWorktree(project.repoPath, project.worktreeRoot, worktreePath, branchName, { allowExistingOutsideRoot: issue.branchName !== undefined });
|
|
160
|
+
// Set bot git identity when GitHub App is configured
|
|
161
|
+
if (this.botIdentity) {
|
|
162
|
+
const gitBin = this.config.runner.gitBin;
|
|
163
|
+
await execCommand(gitBin, ["-C", worktreePath, "config", "user.name", this.botIdentity.name], { timeoutMs: 5_000 });
|
|
164
|
+
await execCommand(gitBin, ["-C", worktreePath, "config", "user.email", this.botIdentity.email], { timeoutMs: 5_000 });
|
|
165
|
+
}
|
|
158
166
|
// Run prepare-worktree hook
|
|
159
167
|
const hookEnv = buildHookEnv(issue.issueKey ?? issue.linearIssueId, branchName, runType, worktreePath);
|
|
160
168
|
const prepareResult = await runProjectHook(project.repoPath, "prepare-worktree", { cwd: worktreePath, env: hookEnv });
|
|
@@ -163,8 +171,18 @@ export class RunOrchestrator {
|
|
|
163
171
|
}
|
|
164
172
|
// Reuse the existing thread only for review_fix (reviewer context matters).
|
|
165
173
|
// Implementation, ci_repair, and queue_repair get fresh threads.
|
|
174
|
+
// Fall back to a fresh thread if the stored one is stale (e.g. after app-server restart).
|
|
166
175
|
if (issue.threadId && runType === "review_fix") {
|
|
167
|
-
|
|
176
|
+
try {
|
|
177
|
+
await this.codex.readThread(issue.threadId, false);
|
|
178
|
+
threadId = issue.threadId;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
this.logger.info({ issueKey: issue.issueKey, staleThreadId: issue.threadId }, "Stored thread is stale, starting fresh for review_fix");
|
|
182
|
+
const thread = await this.codex.startThread({ cwd: worktreePath });
|
|
183
|
+
threadId = thread.id;
|
|
184
|
+
this.db.upsertIssue({ projectId: item.projectId, linearIssueId: item.issueId, threadId });
|
|
185
|
+
}
|
|
168
186
|
}
|
|
169
187
|
else {
|
|
170
188
|
const thread = await this.codex.startThread({ cwd: worktreePath });
|
package/dist/service.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveGitHubAppCredentials, createGitHubAppTokenManager, ensureGhWrapper, } from "./github-app-token.js";
|
|
1
2
|
import { GitHubWebhookHandler } from "./github-webhook-handler.js";
|
|
2
3
|
import { IssueQueryService } from "./issue-query-service.js";
|
|
3
4
|
import { LinearOAuthService } from "./linear-oauth-service.js";
|
|
@@ -16,6 +17,7 @@ export class PatchRelayService {
|
|
|
16
17
|
linearProvider;
|
|
17
18
|
orchestrator;
|
|
18
19
|
mergeQueue;
|
|
20
|
+
githubAppTokenManager;
|
|
19
21
|
webhookHandler;
|
|
20
22
|
githubWebhookHandler;
|
|
21
23
|
oauthService;
|
|
@@ -64,14 +66,29 @@ export class PatchRelayService {
|
|
|
64
66
|
this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
|
|
65
67
|
this.queryService = new IssueQueryService(db, codex, this.orchestrator);
|
|
66
68
|
this.runtime = runtime;
|
|
69
|
+
// Optional GitHub App token management for bot identity
|
|
70
|
+
const ghAppCredentials = resolveGitHubAppCredentials();
|
|
71
|
+
if (ghAppCredentials) {
|
|
72
|
+
this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
|
|
73
|
+
}
|
|
67
74
|
this.codex.on("notification", (notification) => {
|
|
68
75
|
void this.orchestrator.handleCodexNotification(notification);
|
|
69
76
|
});
|
|
70
77
|
}
|
|
71
78
|
async start() {
|
|
79
|
+
if (this.githubAppTokenManager) {
|
|
80
|
+
await ensureGhWrapper(this.logger);
|
|
81
|
+
await this.githubAppTokenManager.start();
|
|
82
|
+
const identity = this.githubAppTokenManager.botIdentity();
|
|
83
|
+
if (identity) {
|
|
84
|
+
this.orchestrator.botIdentity = identity;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
72
87
|
await this.runtime.start();
|
|
88
|
+
this.mergeQueue.seedOnStartup();
|
|
73
89
|
}
|
|
74
90
|
stop() {
|
|
91
|
+
this.githubAppTokenManager?.stop();
|
|
75
92
|
this.runtime.stop();
|
|
76
93
|
}
|
|
77
94
|
async createLinearOAuthStart(params) {
|
package/infra/patchrelay.service
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[Unit]
|
|
2
|
-
Description=PatchRelay
|
|
2
|
+
Description=PatchRelay
|
|
3
3
|
After=network-online.target
|
|
4
4
|
Wants=network-online.target
|
|
5
5
|
StartLimitIntervalSec=60
|
|
@@ -7,24 +7,47 @@ StartLimitBurst=8
|
|
|
7
7
|
|
|
8
8
|
[Service]
|
|
9
9
|
Type=simple
|
|
10
|
+
User=your-user
|
|
11
|
+
Group=your-user
|
|
10
12
|
WorkingDirectory=/home/your-user
|
|
13
|
+
|
|
14
|
+
# Non-secret config: runtime overrides (paths, log level) and
|
|
15
|
+
# service-only identifiers (GitHub App IDs, etc.)
|
|
11
16
|
EnvironmentFile=-/home/your-user/.config/patchrelay/runtime.env
|
|
12
|
-
EnvironmentFile
|
|
17
|
+
EnvironmentFile=-/home/your-user/.config/patchrelay/service.env
|
|
13
18
|
Environment=NODE_ENV=production
|
|
14
19
|
Environment=PATCHRELAY_CONFIG=/home/your-user/.config/patchrelay/patchrelay.json
|
|
15
|
-
Environment=PATH=/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
20
|
+
Environment=PATH=/home/your-user/.local/share/patchrelay/bin:/home/your-user/.local/bin:/usr/local/bin:/usr/bin:/bin
|
|
21
|
+
|
|
22
|
+
# Secrets — encrypted at rest via systemd-creds, decrypted into a private
|
|
23
|
+
# ramfs at $CREDENTIALS_DIRECTORY visible only to this service.
|
|
24
|
+
# See docs/secrets.md for setup instructions.
|
|
25
|
+
#
|
|
26
|
+
# When credstore is not configured, PatchRelay falls back to reading secrets
|
|
27
|
+
# from env vars (EnvironmentFile, op run, etc.) via the resolve-secret module.
|
|
28
|
+
LoadCredentialEncrypted=linear-webhook-secret
|
|
29
|
+
LoadCredentialEncrypted=token-encryption-key
|
|
30
|
+
LoadCredentialEncrypted=linear-oauth-client-id
|
|
31
|
+
LoadCredentialEncrypted=linear-oauth-client-secret
|
|
32
|
+
# Uncomment when GitHub App integration is configured:
|
|
33
|
+
# LoadCredentialEncrypted=github-app-pem
|
|
34
|
+
# LoadCredentialEncrypted=github-app-webhook-secret
|
|
35
|
+
|
|
16
36
|
ExecStart=/usr/bin/env patchrelay serve
|
|
17
37
|
Restart=on-failure
|
|
18
38
|
RestartSec=5
|
|
39
|
+
|
|
40
|
+
# Hardening
|
|
19
41
|
NoNewPrivileges=true
|
|
20
42
|
PrivateTmp=true
|
|
21
|
-
|
|
43
|
+
PrivateMounts=true
|
|
44
|
+
ProtectSystem=strict
|
|
22
45
|
ProtectHome=false
|
|
23
46
|
ReadWritePaths=/home/your-user/.config/patchrelay /home/your-user/.local/state/patchrelay /home/your-user/.local/share/patchrelay
|
|
24
47
|
|
|
25
|
-
# PatchRelay
|
|
26
|
-
#
|
|
27
|
-
# Add your managed repository roots to ReadWritePaths
|
|
48
|
+
# PatchRelay runs as your real user so Codex inherits your existing git,
|
|
49
|
+
# SSH, and local tool permissions.
|
|
50
|
+
# Add your managed repository roots to ReadWritePaths.
|
|
28
51
|
|
|
29
52
|
[Install]
|
|
30
|
-
WantedBy=
|
|
53
|
+
WantedBy=multi-user.target
|
package/package.json
CHANGED
package/service.env.example
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
# PatchRelay service
|
|
2
|
-
#
|
|
1
|
+
# PatchRelay service-only configuration.
|
|
2
|
+
#
|
|
3
|
+
# Secrets in this file are generated by `patchrelay init` for initial setup.
|
|
4
|
+
# For production, encrypt them with systemd-creds and remove the plaintext
|
|
5
|
+
# values below. See docs/secrets.md.
|
|
6
|
+
#
|
|
7
|
+
# Non-secret service identifiers (GitHub App IDs) stay here permanently.
|
|
3
8
|
|
|
4
9
|
LINEAR_WEBHOOK_SECRET=replace-with-linear-webhook-secret
|
|
5
10
|
PATCHRELAY_TOKEN_ENCRYPTION_KEY=replace-with-long-random-secret
|
|
6
11
|
LINEAR_OAUTH_CLIENT_ID=replace-with-linear-oauth-client-id
|
|
7
12
|
LINEAR_OAUTH_CLIENT_SECRET=replace-with-linear-oauth-client-secret
|
|
13
|
+
|
|
14
|
+
# Optional: GitHub App for bot identity.
|
|
15
|
+
# When configured, PatchRelay generates short-lived installation tokens
|
|
16
|
+
# and writes a gh wrapper so Codex operates as app-name[bot].
|
|
17
|
+
# Create a GitHub App at Settings > Developer settings > GitHub Apps.
|
|
18
|
+
#
|
|
19
|
+
# The private key resolves through the provider-agnostic fallback:
|
|
20
|
+
# 1. $CREDENTIALS_DIRECTORY/github-app-pem (systemd-creds)
|
|
21
|
+
# 2. PATCHRELAY_GITHUB_APP_PRIVATE_KEY_FILE (path to PEM file)
|
|
22
|
+
# 3. PATCHRELAY_GITHUB_APP_PRIVATE_KEY (inline PEM string)
|
|
23
|
+
#
|
|
24
|
+
# PATCHRELAY_GITHUB_APP_ID=123456
|
|
25
|
+
# PATCHRELAY_GITHUB_APP_INSTALLATION_ID=optional-skip-auto-discovery
|