patchrelay 0.12.0 → 0.12.2
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 +19 -7
- package/dist/factory-state.js +5 -3
- package/dist/github-app-token.js +9 -4
- package/dist/index.js +1 -0
- package/dist/preflight.js +80 -1
- package/dist/resolve-secret.js +16 -3
- package/dist/run-orchestrator.js +19 -2
- package/dist/service.js +1 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
package/dist/config.js
CHANGED
|
@@ -3,7 +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 {
|
|
6
|
+
import { resolveSecretWithSource } from "./resolve-secret.js";
|
|
7
7
|
import { ensureAbsolutePath } from "./utils.js";
|
|
8
8
|
const LINEAR_OAUTH_CALLBACK_PATH = "/oauth/linear/callback";
|
|
9
9
|
const REPO_SETTINGS_DIRNAME = ".patchrelay";
|
|
@@ -286,13 +286,18 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
286
286
|
const parsedFile = parseJsonFile(requestedPath, "config file");
|
|
287
287
|
const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
|
|
288
288
|
const requirements = getLoadProfileRequirements(profile);
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
const
|
|
294
|
-
?
|
|
289
|
+
const rWebhookSecret = resolveSecretWithSource("linear-webhook-secret", parsed.linear.webhook_secret_env, env);
|
|
290
|
+
const rTokenEncryptionKey = resolveSecretWithSource("token-encryption-key", parsed.linear.token_encryption_key_env, env);
|
|
291
|
+
const rOAuthClientId = resolveSecretWithSource("linear-oauth-client-id", parsed.linear.oauth.client_id_env, env);
|
|
292
|
+
const rOAuthClientSecret = resolveSecretWithSource("linear-oauth-client-secret", parsed.linear.oauth.client_secret_env, env);
|
|
293
|
+
const rOperatorApiToken = parsed.operator_api.bearer_token_env
|
|
294
|
+
? resolveSecretWithSource("operator-api-token", parsed.operator_api.bearer_token_env, env)
|
|
295
295
|
: undefined;
|
|
296
|
+
const webhookSecret = rWebhookSecret.value;
|
|
297
|
+
const tokenEncryptionKey = rTokenEncryptionKey.value;
|
|
298
|
+
const oauthClientId = rOAuthClientId.value;
|
|
299
|
+
const oauthClientSecret = rOAuthClientSecret.value;
|
|
300
|
+
const operatorApiToken = rOperatorApiToken?.value;
|
|
296
301
|
if (requirements.requireWebhookSecret && !webhookSecret) {
|
|
297
302
|
throw new Error(`Missing env var ${parsed.linear.webhook_secret_env}`);
|
|
298
303
|
}
|
|
@@ -400,6 +405,13 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
|
|
|
400
405
|
} : {}),
|
|
401
406
|
};
|
|
402
407
|
}),
|
|
408
|
+
secretSources: {
|
|
409
|
+
"linear-webhook-secret": rWebhookSecret.source,
|
|
410
|
+
"token-encryption-key": rTokenEncryptionKey.source,
|
|
411
|
+
"linear-oauth-client-id": rOAuthClientId.source,
|
|
412
|
+
"linear-oauth-client-secret": rOAuthClientSecret.source,
|
|
413
|
+
...(rOperatorApiToken ? { "operator-api-token": rOperatorApiToken.source } : {}),
|
|
414
|
+
},
|
|
403
415
|
};
|
|
404
416
|
validateConfigSemantics(config, {
|
|
405
417
|
allowMissingOperatorApiToken: requirements.allowMissingOperatorApiToken,
|
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":
|
package/dist/github-app-token.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createSign } from "node:crypto";
|
|
2
2
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveSecretWithSource } from "./resolve-secret.js";
|
|
5
5
|
import { getPatchRelayDataDir } from "./runtime-paths.js";
|
|
6
6
|
const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (tokens last 1 hour)
|
|
7
7
|
/**
|
|
@@ -14,14 +14,19 @@ const TOKEN_REFRESH_MS = 30 * 60_000; // 30 minutes (tokens last 1 hour)
|
|
|
14
14
|
*/
|
|
15
15
|
export function resolveGitHubAppCredentials() {
|
|
16
16
|
const appId = process.env.PATCHRELAY_GITHUB_APP_ID;
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const rPrivateKey = resolveSecretWithSource("github-app-pem", "PATCHRELAY_GITHUB_APP_PRIVATE_KEY");
|
|
18
|
+
const rWebhookSecret = resolveSecretWithSource("github-app-webhook-secret", "GITHUB_APP_WEBHOOK_SECRET");
|
|
19
|
+
if (!appId || !rPrivateKey.value)
|
|
19
20
|
return undefined;
|
|
20
21
|
const installationId = process.env.PATCHRELAY_GITHUB_APP_INSTALLATION_ID;
|
|
21
22
|
return {
|
|
22
23
|
appId,
|
|
23
|
-
privateKey,
|
|
24
|
+
privateKey: rPrivateKey.value,
|
|
24
25
|
...(installationId ? { installationId } : {}),
|
|
26
|
+
secretSources: {
|
|
27
|
+
"github-app-pem": rPrivateKey.source,
|
|
28
|
+
...(rWebhookSecret.value ? { "github-app-webhook-secret": rWebhookSecret.source } : {}),
|
|
29
|
+
},
|
|
25
30
|
};
|
|
26
31
|
}
|
|
27
32
|
/**
|
package/dist/index.js
CHANGED
|
@@ -59,6 +59,7 @@ async function main() {
|
|
|
59
59
|
port: config.server.port,
|
|
60
60
|
webhookPath: config.ingress.linearWebhookPath,
|
|
61
61
|
configPath: process.env.PATCHRELAY_CONFIG,
|
|
62
|
+
secrets: config.secretSources,
|
|
62
63
|
}, "PatchRelay started");
|
|
63
64
|
const shutdown = async () => {
|
|
64
65
|
service.stop();
|
package/dist/preflight.js
CHANGED
|
@@ -2,8 +2,10 @@ import { accessSync, constants, existsSync, mkdirSync, statSync } from "node:fs"
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
4
4
|
import { SqliteConnection } from "./db/shared.js";
|
|
5
|
+
import { resolveSecret } from "./resolve-secret.js";
|
|
5
6
|
import { execCommand } from "./utils.js";
|
|
6
|
-
export async function runPreflight(config) {
|
|
7
|
+
export async function runPreflight(config, options) {
|
|
8
|
+
const connectivity = options?.connectivity ?? true;
|
|
7
9
|
const checks = [];
|
|
8
10
|
if (!config.linear.webhookSecret) {
|
|
9
11
|
checks.push(fail("linear", "LINEAR_WEBHOOK_SECRET is missing"));
|
|
@@ -82,11 +84,79 @@ export async function runPreflight(config) {
|
|
|
82
84
|
}
|
|
83
85
|
checks.push(await checkExecutable("git", config.runner.gitBin));
|
|
84
86
|
checks.push(await checkExecutable("codex", config.runner.codex.bin));
|
|
87
|
+
// Connectivity checks — verify secrets actually work against live APIs.
|
|
88
|
+
// Skipped when graphqlUrl uses a non-routable domain (.example, .test, .invalid).
|
|
89
|
+
const skipConnectivity = !connectivity || isNonRoutableDomain(config.linear.graphqlUrl);
|
|
90
|
+
if (!skipConnectivity) {
|
|
91
|
+
if (config.linear.oauth.clientId && config.linear.oauth.clientSecret) {
|
|
92
|
+
checks.push(await checkLinearApi(config.linear.graphqlUrl));
|
|
93
|
+
}
|
|
94
|
+
const ghAppId = process.env.PATCHRELAY_GITHUB_APP_ID;
|
|
95
|
+
const ghAppKey = resolveSecret("github-app-pem", "PATCHRELAY_GITHUB_APP_PRIVATE_KEY");
|
|
96
|
+
if (ghAppId && ghAppKey) {
|
|
97
|
+
checks.push(await checkGitHubApp(ghAppId, ghAppKey));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
85
100
|
return {
|
|
86
101
|
checks,
|
|
87
102
|
ok: checks.every((check) => check.status !== "fail"),
|
|
88
103
|
};
|
|
89
104
|
}
|
|
105
|
+
async function checkLinearApi(graphqlUrl) {
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetch(graphqlUrl, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({ query: "{ __typename }" }),
|
|
111
|
+
signal: AbortSignal.timeout(5000),
|
|
112
|
+
});
|
|
113
|
+
if (response.ok) {
|
|
114
|
+
return pass("linear_api", `Linear GraphQL API is reachable at ${graphqlUrl}`);
|
|
115
|
+
}
|
|
116
|
+
return warn("linear_api", `Linear GraphQL API returned ${response.status} — may be unreachable or rate-limited`);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return fail("linear_api", `Linear GraphQL API is unreachable at ${graphqlUrl}: ${formatError(error)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function checkGitHubApp(appId, privateKey) {
|
|
123
|
+
try {
|
|
124
|
+
const { createSign } = await import("node:crypto");
|
|
125
|
+
const now = Math.floor(Date.now() / 1000);
|
|
126
|
+
const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url");
|
|
127
|
+
const payload = Buffer.from(JSON.stringify({ iat: now - 60, exp: now + 120, iss: appId })).toString("base64url");
|
|
128
|
+
const signer = createSign("RSA-SHA256");
|
|
129
|
+
signer.update(`${header}.${payload}`);
|
|
130
|
+
let signature;
|
|
131
|
+
try {
|
|
132
|
+
signature = signer.sign(privateKey, "base64url");
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return fail("github_app", `GitHub App private key is invalid: ${formatError(error)}`);
|
|
136
|
+
}
|
|
137
|
+
const jwt = `${header}.${payload}.${signature}`;
|
|
138
|
+
const response = await fetch("https://api.github.com/app", {
|
|
139
|
+
headers: {
|
|
140
|
+
Authorization: `Bearer ${jwt}`,
|
|
141
|
+
Accept: "application/vnd.github+json",
|
|
142
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
143
|
+
},
|
|
144
|
+
signal: AbortSignal.timeout(5000),
|
|
145
|
+
});
|
|
146
|
+
if (response.ok) {
|
|
147
|
+
const app = await response.json();
|
|
148
|
+
const label = app.slug ?? app.name ?? appId;
|
|
149
|
+
return pass("github_app", `GitHub App authenticated as "${label}"`);
|
|
150
|
+
}
|
|
151
|
+
if (response.status === 401) {
|
|
152
|
+
return fail("github_app", "GitHub App authentication failed — check APP_ID and private key");
|
|
153
|
+
}
|
|
154
|
+
return warn("github_app", `GitHub App API returned ${response.status}`);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
return fail("github_app", `GitHub API is unreachable: ${formatError(error)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
90
160
|
function checkDatabaseHealth(config) {
|
|
91
161
|
const checks = [];
|
|
92
162
|
let connection;
|
|
@@ -242,6 +312,15 @@ function checkOAuthRedirectUri(config) {
|
|
|
242
312
|
function isLoopbackHost(host) {
|
|
243
313
|
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
244
314
|
}
|
|
315
|
+
function isNonRoutableDomain(url) {
|
|
316
|
+
try {
|
|
317
|
+
const hostname = new URL(url).hostname;
|
|
318
|
+
return /\.(example|test|invalid|localhost)$/i.test(hostname);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
245
324
|
function formatError(error) {
|
|
246
325
|
return error instanceof Error ? error.message : String(error);
|
|
247
326
|
}
|
package/dist/resolve-secret.js
CHANGED
|
@@ -13,11 +13,19 @@ import path from "node:path";
|
|
|
13
13
|
* Returns `undefined` when the secret is not found at any level.
|
|
14
14
|
*/
|
|
15
15
|
export function resolveSecret(credentialName, envKey, env = process.env) {
|
|
16
|
+
return resolveSecretWithSource(credentialName, envKey, env).value;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Same as `resolveSecret` but also returns which layer provided the value.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveSecretWithSource(credentialName, envKey, env = process.env) {
|
|
16
22
|
// 1. systemd credentials directory (private mount, highest trust)
|
|
17
23
|
const credDir = process.env.CREDENTIALS_DIRECTORY;
|
|
18
24
|
if (credDir) {
|
|
19
25
|
try {
|
|
20
|
-
|
|
26
|
+
const value = readFileSync(path.join(credDir, credentialName), "utf8").trim();
|
|
27
|
+
if (value)
|
|
28
|
+
return { value, source: "creddir" };
|
|
21
29
|
}
|
|
22
30
|
catch {
|
|
23
31
|
// credential not in this directory — fall through
|
|
@@ -27,12 +35,17 @@ export function resolveSecret(credentialName, envKey, env = process.env) {
|
|
|
27
35
|
const filePath = env[`${envKey}_FILE`];
|
|
28
36
|
if (filePath) {
|
|
29
37
|
try {
|
|
30
|
-
|
|
38
|
+
const value = readFileSync(filePath, "utf8").trim();
|
|
39
|
+
if (value)
|
|
40
|
+
return { value, source: "file" };
|
|
31
41
|
}
|
|
32
42
|
catch {
|
|
33
43
|
// file not readable — fall through
|
|
34
44
|
}
|
|
35
45
|
}
|
|
36
46
|
// 3. Direct env var
|
|
37
|
-
|
|
47
|
+
const value = env[envKey] || undefined;
|
|
48
|
+
if (value)
|
|
49
|
+
return { value, source: "env" };
|
|
50
|
+
return { value: undefined, source: "missing" };
|
|
38
51
|
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -179,8 +179,25 @@ export class RunOrchestrator {
|
|
|
179
179
|
threadId = thread.id;
|
|
180
180
|
this.db.upsertIssue({ projectId: item.projectId, linearIssueId: item.issueId, threadId });
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
try {
|
|
183
|
+
const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
|
|
184
|
+
turnId = turn.turnId;
|
|
185
|
+
}
|
|
186
|
+
catch (turnError) {
|
|
187
|
+
// If the thread is stale (e.g. after app-server restart), start fresh and retry once.
|
|
188
|
+
const msg = turnError instanceof Error ? turnError.message : String(turnError);
|
|
189
|
+
if (msg.includes("thread not found") || msg.includes("not materialized")) {
|
|
190
|
+
this.logger.info({ issueKey: issue.issueKey, staleThreadId: threadId }, "Thread is stale, retrying with fresh thread");
|
|
191
|
+
const thread = await this.codex.startThread({ cwd: worktreePath });
|
|
192
|
+
threadId = thread.id;
|
|
193
|
+
this.db.upsertIssue({ projectId: item.projectId, linearIssueId: item.issueId, threadId });
|
|
194
|
+
const turn = await this.codex.startTurn({ threadId, cwd: worktreePath, input: prompt });
|
|
195
|
+
turnId = turn.turnId;
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
throw turnError;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
184
201
|
}
|
|
185
202
|
catch (error) {
|
|
186
203
|
const message = error instanceof Error ? error.message : String(error);
|
package/dist/service.js
CHANGED
|
@@ -69,6 +69,7 @@ export class PatchRelayService {
|
|
|
69
69
|
// Optional GitHub App token management for bot identity
|
|
70
70
|
const ghAppCredentials = resolveGitHubAppCredentials();
|
|
71
71
|
if (ghAppCredentials) {
|
|
72
|
+
Object.assign(config.secretSources, ghAppCredentials.secretSources);
|
|
72
73
|
this.githubAppTokenManager = createGitHubAppTokenManager(ghAppCredentials, logger);
|
|
73
74
|
}
|
|
74
75
|
this.codex.on("notification", (notification) => {
|