patchrelay 0.10.7 → 0.12.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.10.7",
4
- "commit": "6593575e7ee8",
5
- "builtAt": "2026-03-22T22:06:15.644Z"
3
+ "version": "0.12.0",
4
+ "commit": "bcd6dfd26901",
5
+ "builtAt": "2026-03-24T12:39:09.276Z"
6
6
  }
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";
@@ -32,6 +33,7 @@ const projectSchema = z.object({
32
33
  github: z.object({
33
34
  webhook_secret: z.string().min(1).optional(),
34
35
  repo_full_name: z.string().min(1).optional(),
36
+ base_branch: z.string().min(1).optional(),
35
37
  }).optional(),
36
38
  });
37
39
  const configSchema = z.object({
@@ -284,12 +286,12 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
284
286
  const parsedFile = parseJsonFile(requestedPath, "config file");
285
287
  const parsed = configSchema.parse(withSectionDefaults(expandEnv(parsedFile, env)));
286
288
  const requirements = getLoadProfileRequirements(profile);
287
- const webhookSecret = env[parsed.linear.webhook_secret_env];
288
- const tokenEncryptionKey = env[parsed.linear.token_encryption_key_env];
289
- const oauthClientId = env[parsed.linear.oauth.client_id_env];
290
- const oauthClientSecret = env[parsed.linear.oauth.client_secret_env];
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);
291
293
  const operatorApiToken = parsed.operator_api.bearer_token_env
292
- ? env[parsed.operator_api.bearer_token_env]
294
+ ? resolveSecret("operator-api-token", parsed.operator_api.bearer_token_env, env)
293
295
  : undefined;
294
296
  if (requirements.requireWebhookSecret && !webhookSecret) {
295
297
  throw new Error(`Missing env var ${parsed.linear.webhook_secret_env}`);
@@ -393,6 +395,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
393
395
  github: {
394
396
  ...(project.github.webhook_secret ? { webhookSecret: project.github.webhook_secret } : {}),
395
397
  ...(project.github.repo_full_name ? { repoFullName: project.github.repo_full_name } : {}),
398
+ ...(project.github.base_branch ? { baseBranch: project.github.base_branch } : {}),
396
399
  },
397
400
  } : {}),
398
401
  };
@@ -129,4 +129,12 @@ export function runPatchRelayMigrations(connection) {
129
129
  connection.exec(schema);
130
130
  // Clean up stale dedupe-only webhook records (no payload, never processable)
131
131
  connection.prepare("UPDATE webhook_events SET processing_status = 'processed' WHERE processing_status = 'pending' AND payload_json IS NULL").run();
132
+ // Add pending_merge_prep column for merge queue stewardship
133
+ addColumnIfMissing(connection, "issues", "pending_merge_prep", "INTEGER NOT NULL DEFAULT 0");
134
+ }
135
+ function addColumnIfMissing(connection, table, column, definition) {
136
+ const cols = connection.prepare(`PRAGMA table_info(${table})`).all();
137
+ if (cols.some((c) => c.name === column))
138
+ return;
139
+ connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
132
140
  }
package/dist/db.js CHANGED
@@ -149,6 +149,10 @@ export class PatchRelayDatabase {
149
149
  sets.push("queue_repair_attempts = @queueRepairAttempts");
150
150
  values.queueRepairAttempts = params.queueRepairAttempts;
151
151
  }
152
+ if (params.pendingMergePrep !== undefined) {
153
+ sets.push("pending_merge_prep = @pendingMergePrep");
154
+ values.pendingMergePrep = params.pendingMergePrep ? 1 : 0;
155
+ }
152
156
  this.connection.prepare(`UPDATE issues SET ${sets.join(", ")} WHERE project_id = @projectId AND linear_issue_id = @linearIssueId`).run(values);
153
157
  }
154
158
  else {
@@ -206,13 +210,19 @@ export class PatchRelayDatabase {
206
210
  }
207
211
  listIssuesReadyForExecution() {
208
212
  const rows = this.connection
209
- .prepare("SELECT project_id, linear_issue_id FROM issues WHERE pending_run_type IS NOT NULL AND active_run_id IS NULL")
213
+ .prepare("SELECT project_id, linear_issue_id FROM issues WHERE (pending_run_type IS NOT NULL OR pending_merge_prep = 1) AND active_run_id IS NULL")
210
214
  .all();
211
215
  return rows.map((row) => ({
212
216
  projectId: String(row.project_id),
213
217
  linearIssueId: String(row.linear_issue_id),
214
218
  }));
215
219
  }
220
+ listIssuesByState(projectId, state) {
221
+ const rows = this.connection
222
+ .prepare("SELECT * FROM issues WHERE project_id = ? AND factory_state = ? ORDER BY pr_number ASC")
223
+ .all(projectId, state);
224
+ return rows.map(mapIssueRow);
225
+ }
216
226
  // ─── Runs ─────────────────────────────────────────────────────────
217
227
  createRun(params) {
218
228
  const now = isoNow();
@@ -365,6 +375,7 @@ function mapIssueRow(row) {
365
375
  ...(row.pr_check_status !== null && row.pr_check_status !== undefined ? { prCheckStatus: String(row.pr_check_status) } : {}),
366
376
  ciRepairAttempts: Number(row.ci_repair_attempts ?? 0),
367
377
  queueRepairAttempts: Number(row.queue_repair_attempts ?? 0),
378
+ pendingMergePrep: Boolean(row.pending_merge_prep),
368
379
  };
369
380
  }
370
381
  function mapRunRow(row) {
@@ -19,7 +19,7 @@ export const ALLOWED_TRANSITIONS = {
19
19
  changes_requested: ["implementing", "awaiting_input", "escalated"],
20
20
  repairing_ci: ["pr_open", "awaiting_review", "escalated", "failed"],
21
21
  awaiting_queue: ["done", "repairing_queue", "repairing_ci"],
22
- repairing_queue: ["pr_open", "awaiting_review", "escalated", "failed"],
22
+ repairing_queue: ["pr_open", "awaiting_review", "awaiting_queue", "escalated", "failed"],
23
23
  awaiting_input: ["implementing", "delegated", "escalated"],
24
24
  escalated: [],
25
25
  done: [],
@@ -38,9 +38,13 @@ export function resolveFactoryStateFromGitHub(triggerEvent, current) {
38
38
  case "review_commented":
39
39
  return undefined; // informational only
40
40
  case "check_passed":
41
+ if (current === "repairing_queue")
42
+ return "awaiting_queue";
41
43
  return current === "repairing_ci" ? "pr_open" : undefined;
42
44
  case "check_failed":
43
- return current === "pr_open" || current === "awaiting_review" ? "repairing_ci" : undefined;
45
+ return current === "pr_open" || current === "awaiting_review" || current === "awaiting_queue"
46
+ ? "repairing_ci"
47
+ : undefined;
44
48
  case "pr_merged":
45
49
  return "done";
46
50
  case "pr_closed":
@@ -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,18 +1,53 @@
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";
5
+ /**
6
+ * GitHub sends both check_run and check_suite completion events.
7
+ * A single CI run generates 10+ individual check_run events as each job finishes,
8
+ * but only 1 check_suite event when the entire suite completes. Reacting to
9
+ * individual check_run events causes the factory state to flicker rapidly
10
+ * between pr_open and repairing_ci. We only drive state transitions and reactive
11
+ * runs from check_suite events. Individual check_run events still update PR
12
+ * metadata (prCheckStatus) for observability.
13
+ */
14
+ function isMetadataOnlyCheckEvent(event) {
15
+ return event.eventSource === "check_run"
16
+ && (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
17
+ }
18
+ /**
19
+ * Codex sometimes closes and immediately reopens a PR (e.g. to change the
20
+ * base branch or fix the title). A pr_closed event during an active run
21
+ * should not transition to "failed" — the reopened event will follow.
22
+ * Without this guard, the state gets stuck at "failed" because
23
+ * failed → pr_open is not an allowed transition.
24
+ */
25
+ function shouldSuppressCloseTransition(newState, event, issue) {
26
+ return newState === "failed" && event.triggerEvent === "pr_closed" && issue.activeRunId !== undefined;
27
+ }
28
+ /**
29
+ * After a CI repair succeeds and CI passes, the resolver returns pr_open.
30
+ * If the PR is already approved, fast-track to awaiting_queue so the merge
31
+ * queue picks it up again. This avoids a dead state where the PR is approved
32
+ * and CI-green but nobody advances the merge queue.
33
+ */
34
+ function shouldFastTrackToQueue(newState, issue) {
35
+ return newState === "pr_open" && issue.prReviewState === "approved";
36
+ }
4
37
  export class GitHubWebhookHandler {
5
38
  config;
6
39
  db;
7
40
  linearProvider;
8
41
  enqueueIssue;
42
+ mergeQueue;
9
43
  logger;
10
44
  feed;
11
- constructor(config, db, linearProvider, enqueueIssue, logger, feed) {
45
+ constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, feed) {
12
46
  this.config = config;
13
47
  this.db = db;
14
48
  this.linearProvider = linearProvider;
15
49
  this.enqueueIssue = enqueueIssue;
50
+ this.mergeQueue = mergeQueue;
16
51
  this.logger = logger;
17
52
  this.feed = feed;
18
53
  }
@@ -39,7 +74,7 @@ export class GitHubWebhookHandler {
39
74
  ? this.config.projects.find((p) => p.github?.repoFullName === repoName)
40
75
  : undefined;
41
76
  // Verify signature using global GitHub App webhook secret
42
- const webhookSecret = process.env.GITHUB_APP_WEBHOOK_SECRET;
77
+ const webhookSecret = resolveSecret("github-app-webhook-secret", "GITHUB_APP_WEBHOOK_SECRET");
43
78
  if (webhookSecret) {
44
79
  if (!verifyGitHubWebhookSignature(params.rawBody, webhookSecret, params.signature)) {
45
80
  return { status: 401, body: { ok: false, reason: "invalid_signature" } };
@@ -63,6 +98,24 @@ export class GitHubWebhookHandler {
63
98
  const payload = safeJsonParse(params.rawBody);
64
99
  if (!payload || typeof payload !== "object")
65
100
  return;
101
+ // Push to a base branch advances the merge queue for affected projects.
102
+ // This catches external merges (human PRs, direct pushes) that PatchRelay
103
+ // does not track as issues but that make queued branches stale.
104
+ if (params.eventType === "push") {
105
+ const pushPayload = payload;
106
+ const ref = pushPayload.ref;
107
+ const repoFullName = pushPayload.repository?.full_name;
108
+ if (ref && repoFullName) {
109
+ const branchName = ref.replace("refs/heads/", "");
110
+ for (const project of this.config.projects) {
111
+ const baseBranch = project.github?.baseBranch ?? "main";
112
+ if (project.github?.repoFullName === repoFullName && branchName === baseBranch) {
113
+ this.mergeQueue.advanceQueue(project.id);
114
+ }
115
+ }
116
+ }
117
+ return;
118
+ }
66
119
  const event = normalizeGitHubWebhook({
67
120
  eventType: params.eventType,
68
121
  payload: payload,
@@ -87,28 +140,40 @@ export class GitHubWebhookHandler {
87
140
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
88
141
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
89
142
  });
90
- // Individual check_run events only update PR metadata, not factory state.
91
- // State transitions and reactive runs are driven by check_suite completion
92
- // to avoid flickering when multiple checks run in parallel.
93
- const isIndividualCheckRun = event.eventSource === "check_run"
94
- && (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
95
- // Drive factory state transitions from GitHub events
96
- if (!isIndividualCheckRun) {
97
- let newState = resolveFactoryStateFromGitHub(event.triggerEvent, issue.factoryState);
98
- // Don't transition to failed on pr_closed when a run is active —
99
- // Codex sometimes closes and reopens PRs during its workflow.
100
- if (newState === "failed" && event.triggerEvent === "pr_closed" && issue.activeRunId !== undefined) {
143
+ if (!isMetadataOnlyCheckEvent(event)) {
144
+ // Re-read issue after PR metadata upsert so fast-track sees fresh prReviewState
145
+ const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
146
+ let newState = resolveFactoryStateFromGitHub(event.triggerEvent, afterMetadata.factoryState);
147
+ if (shouldSuppressCloseTransition(newState, event, afterMetadata)) {
101
148
  newState = undefined;
102
149
  }
103
- if (newState && newState !== issue.factoryState) {
150
+ if (shouldFastTrackToQueue(newState, afterMetadata)) {
151
+ newState = "awaiting_queue";
152
+ }
153
+ // Only transition and notify when the state actually changes.
154
+ // Multiple check_suite events can arrive for the same outcome.
155
+ if (newState && newState !== afterMetadata.factoryState) {
104
156
  this.db.upsertIssue({
105
157
  projectId: issue.projectId,
106
158
  linearIssueId: issue.linearIssueId,
107
159
  factoryState: newState,
108
160
  });
109
- this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
161
+ this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
110
162
  // Emit Linear activity for significant state changes
111
163
  void this.emitLinearActivity(issue, newState, event);
164
+ // Schedule merge prep when entering awaiting_queue
165
+ if (newState === "awaiting_queue") {
166
+ this.db.upsertIssue({
167
+ projectId: issue.projectId,
168
+ linearIssueId: issue.linearIssueId,
169
+ pendingMergePrep: true,
170
+ });
171
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
172
+ }
173
+ // Advance the merge queue when a PR merges
174
+ if (newState === "done" && event.triggerEvent === "pr_merged") {
175
+ this.mergeQueue.advanceQueue(issue.projectId);
176
+ }
112
177
  }
113
178
  }
114
179
  // Reset repair counters on new push
@@ -133,8 +198,7 @@ export class GitHubWebhookHandler {
133
198
  summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
134
199
  detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
135
200
  });
136
- // Trigger reactive runs if applicable (skip individual check_run events)
137
- if (!isIndividualCheckRun) {
201
+ if (!isMetadataOnlyCheckEvent(event)) {
138
202
  this.maybeEnqueueReactiveRun(freshIssue, event);
139
203
  }
140
204
  }
@@ -189,10 +253,10 @@ export class GitHubWebhookHandler {
189
253
  return;
190
254
  const messages = {
191
255
  pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
192
- awaiting_queue: "PR approved. Awaiting merge queue.",
256
+ awaiting_queue: "PR approved. Preparing merge.",
193
257
  changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
194
258
  repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
195
- repairing_queue: "Merge queue failed. Starting repair.",
259
+ repairing_queue: "Merge conflict with base branch. Starting repair.",
196
260
  done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
197
261
  failed: "PR was closed without merging.",
198
262
  };
@@ -0,0 +1,161 @@
1
+ import { execCommand } from "./utils.js";
2
+ /**
3
+ * Merge queue steward — keeps PatchRelay-managed PR branches up to date
4
+ * with the base branch and enables auto-merge so GitHub merges when CI passes.
5
+ *
6
+ * Serialization: all calls are routed through the issue queue, and
7
+ * prepareForMerge checks front-of-queue before acting. The issue processor
8
+ * in service.ts checks pendingRunType before pendingMergePrep, so repair
9
+ * runs always take priority over merge prep.
10
+ */
11
+ export class MergeQueue {
12
+ config;
13
+ db;
14
+ enqueueIssue;
15
+ logger;
16
+ feed;
17
+ constructor(config, db, enqueueIssue, logger, feed) {
18
+ this.config = config;
19
+ this.db = db;
20
+ this.enqueueIssue = enqueueIssue;
21
+ this.logger = logger;
22
+ this.feed = feed;
23
+ }
24
+ /**
25
+ * Prepare the front-of-queue issue for merge:
26
+ * 1. Enable auto-merge
27
+ * 2. Update the branch to latest base (git merge)
28
+ * 3. Push (triggers CI; auto-merge fires when CI passes)
29
+ *
30
+ * On conflict: abort merge, transition to repairing_queue, enqueue queue_repair.
31
+ * On transient failure: leave pendingMergePrep set so the next event retries.
32
+ */
33
+ async prepareForMerge(issue, project) {
34
+ // Only prepare the front-of-queue issue for this project
35
+ const queue = this.db.listIssuesByState(project.id, "awaiting_queue");
36
+ const front = queue.find((i) => i.activeRunId === undefined && i.pendingRunType === undefined);
37
+ if (!front || front.id !== issue.id) {
38
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
39
+ return;
40
+ }
41
+ if (!issue.worktreePath || !issue.prNumber) {
42
+ this.logger.warn({ issueKey: issue.issueKey }, "Merge prep skipped: missing worktree or PR number");
43
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
44
+ return;
45
+ }
46
+ const repoFullName = project.github?.repoFullName;
47
+ const baseBranch = project.github?.baseBranch ?? "main";
48
+ const gitBin = this.config.runner.gitBin;
49
+ // Enable auto-merge (idempotent)
50
+ const autoMergeOk = repoFullName ? await this.enableAutoMerge(issue, repoFullName) : false;
51
+ // Fetch latest base branch
52
+ const fetchResult = await execCommand(gitBin, ["-C", issue.worktreePath, "fetch", "origin", baseBranch], {
53
+ timeoutMs: 60_000,
54
+ });
55
+ if (fetchResult.exitCode !== 0) {
56
+ // Transient failure — leave pendingMergePrep set so the next event retries.
57
+ this.logger.warn({ issueKey: issue.issueKey, stderr: fetchResult.stderr?.slice(0, 300) }, "Merge prep: fetch failed, will retry on next event");
58
+ return;
59
+ }
60
+ // Merge base branch into the PR branch
61
+ const mergeResult = await execCommand(gitBin, ["-C", issue.worktreePath, "merge", `origin/${baseBranch}`, "--no-edit"], {
62
+ timeoutMs: 60_000,
63
+ });
64
+ if (mergeResult.exitCode !== 0) {
65
+ // Conflict — abort and trigger queue_repair
66
+ await execCommand(gitBin, ["-C", issue.worktreePath, "merge", "--abort"], { timeoutMs: 10_000 });
67
+ this.logger.info({ issueKey: issue.issueKey }, "Merge prep: conflict detected, triggering queue repair");
68
+ this.db.upsertIssue({
69
+ projectId: issue.projectId,
70
+ linearIssueId: issue.linearIssueId,
71
+ factoryState: "repairing_queue",
72
+ pendingRunType: "queue_repair",
73
+ pendingRunContextJson: JSON.stringify({ failureReason: "merge_conflict" }),
74
+ pendingMergePrep: false,
75
+ });
76
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
77
+ this.feed?.publish({
78
+ level: "warn",
79
+ kind: "workflow",
80
+ issueKey: issue.issueKey,
81
+ projectId: issue.projectId,
82
+ stage: "repairing_queue",
83
+ status: "conflict",
84
+ summary: `Merge conflict with ${baseBranch} — queue repair enqueued`,
85
+ });
86
+ return;
87
+ }
88
+ // Check if merge was a no-op (already up to date)
89
+ if (mergeResult.stdout?.includes("Already up to date")) {
90
+ this.logger.debug({ issueKey: issue.issueKey }, "Merge prep: branch already up to date");
91
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
92
+ if (!autoMergeOk) {
93
+ this.feed?.publish({
94
+ level: "warn",
95
+ kind: "workflow",
96
+ issueKey: issue.issueKey,
97
+ projectId: issue.projectId,
98
+ stage: "awaiting_queue",
99
+ status: "blocked",
100
+ summary: "Branch up to date but auto-merge not enabled — check gh auth and repo settings",
101
+ });
102
+ }
103
+ return;
104
+ }
105
+ // Push the merged branch
106
+ const pushResult = await execCommand(gitBin, ["-C", issue.worktreePath, "push"], {
107
+ timeoutMs: 60_000,
108
+ });
109
+ if (pushResult.exitCode !== 0) {
110
+ // Push failed — leave pendingMergePrep set so the next event retries.
111
+ this.logger.warn({ issueKey: issue.issueKey, stderr: pushResult.stderr?.slice(0, 300) }, "Merge prep: push failed, will retry on next event");
112
+ return;
113
+ }
114
+ this.logger.info({ issueKey: issue.issueKey, baseBranch }, "Merge prep: branch updated and pushed");
115
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, pendingMergePrep: false });
116
+ this.feed?.publish({
117
+ level: "info",
118
+ kind: "workflow",
119
+ issueKey: issue.issueKey,
120
+ projectId: issue.projectId,
121
+ stage: "awaiting_queue",
122
+ status: "prepared",
123
+ summary: `Branch updated to latest ${baseBranch} — CI will run`,
124
+ });
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
+ }
136
+ /**
137
+ * Advance the queue: find the next awaiting_queue issue and prepare it.
138
+ * Called when a PR merges (pr_merged event) and on startup.
139
+ */
140
+ advanceQueue(projectId) {
141
+ const queue = this.db.listIssuesByState(projectId, "awaiting_queue");
142
+ const next = queue.find((i) => i.activeRunId === undefined && i.pendingRunType === undefined && !i.pendingMergePrep);
143
+ if (!next)
144
+ return;
145
+ this.logger.info({ issueKey: next.issueKey, projectId }, "Advancing merge queue");
146
+ this.db.upsertIssue({ projectId: next.projectId, linearIssueId: next.linearIssueId, pendingMergePrep: true });
147
+ this.enqueueIssue(next.projectId, next.linearIssueId);
148
+ }
149
+ /** Returns true if auto-merge was successfully enabled (or already enabled). */
150
+ async enableAutoMerge(issue, repoFullName) {
151
+ // Uses the host's existing gh auth — same credentials Codex uses to create PRs.
152
+ const result = await execCommand("gh", ["pr", "merge", String(issue.prNumber), "--repo", repoFullName, "--auto", "--squash"], {
153
+ timeoutMs: 30_000,
154
+ });
155
+ if (result.exitCode !== 0) {
156
+ this.logger.warn({ issueKey: issue.issueKey, stderr: result.stderr?.slice(0, 200) }, "Merge prep: auto-merge enablement failed");
157
+ return false;
158
+ }
159
+ return true;
160
+ }
161
+ }
@@ -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
+ }
@@ -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 });
package/dist/service.js CHANGED
@@ -1,6 +1,8 @@
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";
5
+ import { MergeQueue } from "./merge-queue.js";
4
6
  import { RunOrchestrator } from "./run-orchestrator.js";
5
7
  import { OperatorEventFeed } from "./operator-feed.js";
6
8
  import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
@@ -14,6 +16,8 @@ export class PatchRelayService {
14
16
  logger;
15
17
  linearProvider;
16
18
  orchestrator;
19
+ mergeQueue;
20
+ githubAppTokenManager;
17
21
  webhookHandler;
18
22
  githubWebhookHandler;
19
23
  oauthService;
@@ -31,21 +35,60 @@ export class PatchRelayService {
31
35
  throw new Error("Service runtime enqueueIssue is not initialized");
32
36
  };
33
37
  this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
38
+ this.mergeQueue = new MergeQueue(config, db, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
34
39
  this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
35
- this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
36
- const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, { processIssue: (item) => this.orchestrator.run(item) });
40
+ this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, this.feed);
41
+ const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
42
+ processIssue: async (item) => {
43
+ const issue = db.getIssue(item.projectId, item.issueId);
44
+ // Repairs take priority over merge prep — a check_failed or
45
+ // review_changes_requested that arrived while merge prep was
46
+ // queued must not be swallowed.
47
+ if (issue?.pendingRunType) {
48
+ await this.orchestrator.run(item);
49
+ return;
50
+ }
51
+ if (issue?.pendingMergePrep) {
52
+ const project = config.projects.find((p) => p.id === item.projectId);
53
+ if (project)
54
+ await this.mergeQueue.prepareForMerge(issue, project);
55
+ // Re-check: a repair run may have been enqueued during prep
56
+ const after = db.getIssue(item.projectId, item.issueId);
57
+ if (after?.pendingRunType) {
58
+ runtime.enqueueIssue(item.projectId, item.issueId);
59
+ }
60
+ return;
61
+ }
62
+ await this.orchestrator.run(item);
63
+ },
64
+ });
37
65
  enqueueIssue = (projectId, issueId) => runtime.enqueueIssue(projectId, issueId);
38
66
  this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
39
67
  this.queryService = new IssueQueryService(db, codex, this.orchestrator);
40
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
+ }
41
74
  this.codex.on("notification", (notification) => {
42
75
  void this.orchestrator.handleCodexNotification(notification);
43
76
  });
44
77
  }
45
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
+ }
46
87
  await this.runtime.start();
88
+ this.mergeQueue.seedOnStartup();
47
89
  }
48
90
  stop() {
91
+ this.githubAppTokenManager?.stop();
49
92
  this.runtime.stop();
50
93
  }
51
94
  async createLinearOAuthStart(params) {
@@ -1,5 +1,5 @@
1
1
  [Unit]
2
- Description=PatchRelay (systemd user service)
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=/home/your-user/.config/patchrelay/service.env
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
- ProtectSystem=full
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 is intended to run as your real user so Codex inherits your
26
- # existing git, SSH, and local tool permissions.
27
- # Add your managed repository roots to ReadWritePaths if you keep hardening enabled.
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=default.target
53
+ WantedBy=multi-user.target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.10.7",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,7 +1,25 @@
1
- # PatchRelay service secrets.
2
- # Keep these values at the machine level for this PatchRelay instance.
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