patchrelay 0.10.6 → 0.11.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.6",
4
- "commit": "39ced0e75b38",
5
- "builtAt": "2026-03-22T22:02:35.390Z"
3
+ "version": "0.11.0",
4
+ "commit": "d4b6131b08e4",
5
+ "builtAt": "2026-03-23T23:29:46.267Z"
6
6
  }
package/dist/config.js CHANGED
@@ -32,6 +32,7 @@ const projectSchema = z.object({
32
32
  github: z.object({
33
33
  webhook_secret: z.string().min(1).optional(),
34
34
  repo_full_name: z.string().min(1).optional(),
35
+ base_branch: z.string().min(1).optional(),
35
36
  }).optional(),
36
37
  });
37
38
  const configSchema = z.object({
@@ -393,6 +394,7 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
393
394
  github: {
394
395
  ...(project.github.webhook_secret ? { webhookSecret: project.github.webhook_secret } : {}),
395
396
  ...(project.github.repo_full_name ? { repoFullName: project.github.repo_full_name } : {}),
397
+ ...(project.github.base_branch ? { baseBranch: project.github.base_branch } : {}),
396
398
  },
397
399
  } : {}),
398
400
  };
@@ -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":
@@ -1,18 +1,52 @@
1
1
  import { resolveFactoryStateFromGitHub } from "./factory-state.js";
2
2
  import { normalizeGitHubWebhook, verifyGitHubWebhookSignature } from "./github-webhooks.js";
3
3
  import { safeJsonParse } from "./utils.js";
4
+ /**
5
+ * GitHub sends both check_run and check_suite completion events.
6
+ * A single CI run generates 10+ individual check_run events as each job finishes,
7
+ * but only 1 check_suite event when the entire suite completes. Reacting to
8
+ * individual check_run events causes the factory state to flicker rapidly
9
+ * between pr_open and repairing_ci. We only drive state transitions and reactive
10
+ * runs from check_suite events. Individual check_run events still update PR
11
+ * metadata (prCheckStatus) for observability.
12
+ */
13
+ function isMetadataOnlyCheckEvent(event) {
14
+ return event.eventSource === "check_run"
15
+ && (event.triggerEvent === "check_passed" || event.triggerEvent === "check_failed");
16
+ }
17
+ /**
18
+ * Codex sometimes closes and immediately reopens a PR (e.g. to change the
19
+ * base branch or fix the title). A pr_closed event during an active run
20
+ * should not transition to "failed" — the reopened event will follow.
21
+ * Without this guard, the state gets stuck at "failed" because
22
+ * failed → pr_open is not an allowed transition.
23
+ */
24
+ function shouldSuppressCloseTransition(newState, event, issue) {
25
+ return newState === "failed" && event.triggerEvent === "pr_closed" && issue.activeRunId !== undefined;
26
+ }
27
+ /**
28
+ * After a CI repair succeeds and CI passes, the resolver returns pr_open.
29
+ * If the PR is already approved, fast-track to awaiting_queue so the merge
30
+ * queue picks it up again. This avoids a dead state where the PR is approved
31
+ * and CI-green but nobody advances the merge queue.
32
+ */
33
+ function shouldFastTrackToQueue(newState, issue) {
34
+ return newState === "pr_open" && issue.prReviewState === "approved";
35
+ }
4
36
  export class GitHubWebhookHandler {
5
37
  config;
6
38
  db;
7
39
  linearProvider;
8
40
  enqueueIssue;
41
+ mergeQueue;
9
42
  logger;
10
43
  feed;
11
- constructor(config, db, linearProvider, enqueueIssue, logger, feed) {
44
+ constructor(config, db, linearProvider, enqueueIssue, mergeQueue, logger, feed) {
12
45
  this.config = config;
13
46
  this.db = db;
14
47
  this.linearProvider = linearProvider;
15
48
  this.enqueueIssue = enqueueIssue;
49
+ this.mergeQueue = mergeQueue;
16
50
  this.logger = logger;
17
51
  this.feed = feed;
18
52
  }
@@ -63,6 +97,24 @@ export class GitHubWebhookHandler {
63
97
  const payload = safeJsonParse(params.rawBody);
64
98
  if (!payload || typeof payload !== "object")
65
99
  return;
100
+ // Push to a base branch advances the merge queue for affected projects.
101
+ // This catches external merges (human PRs, direct pushes) that PatchRelay
102
+ // does not track as issues but that make queued branches stale.
103
+ if (params.eventType === "push") {
104
+ const pushPayload = payload;
105
+ const ref = pushPayload.ref;
106
+ const repoFullName = pushPayload.repository?.full_name;
107
+ if (ref && repoFullName) {
108
+ const branchName = ref.replace("refs/heads/", "");
109
+ for (const project of this.config.projects) {
110
+ const baseBranch = project.github?.baseBranch ?? "main";
111
+ if (project.github?.repoFullName === repoFullName && branchName === baseBranch) {
112
+ this.mergeQueue.advanceQueue(project.id);
113
+ }
114
+ }
115
+ }
116
+ return;
117
+ }
66
118
  const event = normalizeGitHubWebhook({
67
119
  eventType: params.eventType,
68
120
  payload: payload,
@@ -87,28 +139,40 @@ export class GitHubWebhookHandler {
87
139
  ...(event.reviewState !== undefined ? { prReviewState: event.reviewState } : {}),
88
140
  ...(event.checkStatus !== undefined ? { prCheckStatus: event.checkStatus } : {}),
89
141
  });
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) {
142
+ if (!isMetadataOnlyCheckEvent(event)) {
143
+ // Re-read issue after PR metadata upsert so fast-track sees fresh prReviewState
144
+ const afterMetadata = this.db.getIssue(issue.projectId, issue.linearIssueId) ?? issue;
145
+ let newState = resolveFactoryStateFromGitHub(event.triggerEvent, afterMetadata.factoryState);
146
+ if (shouldSuppressCloseTransition(newState, event, afterMetadata)) {
101
147
  newState = undefined;
102
148
  }
103
- if (newState) {
149
+ if (shouldFastTrackToQueue(newState, afterMetadata)) {
150
+ newState = "awaiting_queue";
151
+ }
152
+ // Only transition and notify when the state actually changes.
153
+ // Multiple check_suite events can arrive for the same outcome.
154
+ if (newState && newState !== afterMetadata.factoryState) {
104
155
  this.db.upsertIssue({
105
156
  projectId: issue.projectId,
106
157
  linearIssueId: issue.linearIssueId,
107
158
  factoryState: newState,
108
159
  });
109
- this.logger.info({ issueKey: issue.issueKey, from: issue.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
160
+ this.logger.info({ issueKey: issue.issueKey, from: afterMetadata.factoryState, to: newState, trigger: event.triggerEvent }, "Factory state transition from GitHub event");
110
161
  // Emit Linear activity for significant state changes
111
162
  void this.emitLinearActivity(issue, newState, event);
163
+ // Schedule merge prep when entering awaiting_queue
164
+ if (newState === "awaiting_queue") {
165
+ this.db.upsertIssue({
166
+ projectId: issue.projectId,
167
+ linearIssueId: issue.linearIssueId,
168
+ pendingMergePrep: true,
169
+ });
170
+ this.enqueueIssue(issue.projectId, issue.linearIssueId);
171
+ }
172
+ // Advance the merge queue when a PR merges
173
+ if (newState === "done" && event.triggerEvent === "pr_merged") {
174
+ this.mergeQueue.advanceQueue(issue.projectId);
175
+ }
112
176
  }
113
177
  }
114
178
  // Reset repair counters on new push
@@ -133,8 +197,7 @@ export class GitHubWebhookHandler {
133
197
  summary: `GitHub: ${event.triggerEvent}${event.prNumber ? ` on PR #${event.prNumber}` : ""}`,
134
198
  detail: event.checkName ?? event.reviewBody?.slice(0, 200) ?? undefined,
135
199
  });
136
- // Trigger reactive runs if applicable (skip individual check_run events)
137
- if (!isIndividualCheckRun) {
200
+ if (!isMetadataOnlyCheckEvent(event)) {
138
201
  this.maybeEnqueueReactiveRun(freshIssue, event);
139
202
  }
140
203
  }
@@ -189,10 +252,10 @@ export class GitHubWebhookHandler {
189
252
  return;
190
253
  const messages = {
191
254
  pr_open: `PR #${event.prNumber ?? ""} opened.${event.prUrl ? ` ${event.prUrl}` : ""}`,
192
- awaiting_queue: "PR approved. Awaiting merge queue.",
255
+ awaiting_queue: "PR approved. Preparing merge.",
193
256
  changes_requested: `Review requested changes.${event.reviewerName ? ` Reviewer: ${event.reviewerName}` : ""}`,
194
257
  repairing_ci: `CI check failed${event.checkName ? `: ${event.checkName}` : ""}. Starting repair.`,
195
- repairing_queue: "Merge queue failed. Starting repair.",
258
+ repairing_queue: "Merge conflict with base branch. Starting repair.",
196
259
  done: `PR merged and deployed.${event.prNumber ? ` PR #${event.prNumber}` : ""}`,
197
260
  failed: "PR was closed without merging.",
198
261
  };
@@ -0,0 +1,156 @@
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 — set GITHUB_TOKEN to unblock",
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
+ * Advance the queue: find the next awaiting_queue issue and prepare it.
128
+ * Called when a PR merges (pr_merged event).
129
+ */
130
+ advanceQueue(projectId) {
131
+ const queue = this.db.listIssuesByState(projectId, "awaiting_queue");
132
+ const next = queue.find((i) => i.activeRunId === undefined && i.pendingRunType === undefined && !i.pendingMergePrep);
133
+ if (!next)
134
+ return;
135
+ this.logger.info({ issueKey: next.issueKey, projectId }, "Advancing merge queue");
136
+ this.db.upsertIssue({ projectId: next.projectId, linearIssueId: next.linearIssueId, pendingMergePrep: true });
137
+ this.enqueueIssue(next.projectId, next.linearIssueId);
138
+ }
139
+ /** Returns true if auto-merge was successfully enabled (or already enabled). */
140
+ async enableAutoMerge(issue, repoFullName) {
141
+ const token = process.env.GITHUB_TOKEN;
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
+ }
146
+ const result = await execCommand("gh", ["pr", "merge", String(issue.prNumber), "--repo", repoFullName, "--auto", "--squash"], {
147
+ timeoutMs: 30_000,
148
+ env: { ...process.env, GH_TOKEN: token },
149
+ });
150
+ if (result.exitCode !== 0) {
151
+ this.logger.warn({ issueKey: issue.issueKey, stderr: result.stderr?.slice(0, 200) }, "Merge prep: auto-merge enablement failed");
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+ }
@@ -30,7 +30,7 @@ function buildRunPrompt(issue, runType, repoPath, context) {
30
30
  const lines = [
31
31
  `Issue: ${issue.issueKey ?? issue.linearIssueId}`,
32
32
  issue.title ? `Title: ${issue.title}` : undefined,
33
- `Branch: ${issue.branchName}`,
33
+ issue.branchName ? `Branch: ${issue.branchName}` : undefined,
34
34
  issue.prNumber ? `PR: #${issue.prNumber}` : undefined,
35
35
  "",
36
36
  ].filter(Boolean);
@@ -161,8 +161,9 @@ export class RunOrchestrator {
161
161
  if (prepareResult.ran && prepareResult.exitCode !== 0) {
162
162
  throw new Error(`prepare-worktree hook failed (exit ${prepareResult.exitCode}): ${prepareResult.stderr?.slice(0, 500) ?? ""}`);
163
163
  }
164
- // Start or reuse Codex thread
165
- if (issue.threadId && runType !== "implementation") {
164
+ // Reuse the existing thread only for review_fix (reviewer context matters).
165
+ // Implementation, ci_repair, and queue_repair get fresh threads.
166
+ if (issue.threadId && runType === "review_fix") {
166
167
  threadId = issue.threadId;
167
168
  }
168
169
  else {
package/dist/service.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { GitHubWebhookHandler } from "./github-webhook-handler.js";
2
2
  import { IssueQueryService } from "./issue-query-service.js";
3
3
  import { LinearOAuthService } from "./linear-oauth-service.js";
4
+ import { MergeQueue } from "./merge-queue.js";
4
5
  import { RunOrchestrator } from "./run-orchestrator.js";
5
6
  import { OperatorEventFeed } from "./operator-feed.js";
6
7
  import { buildSessionStatusUrl, createSessionStatusToken, deriveSessionStatusSigningSecret, verifySessionStatusToken, } from "./public-agent-session-status.js";
@@ -14,6 +15,7 @@ export class PatchRelayService {
14
15
  logger;
15
16
  linearProvider;
16
17
  orchestrator;
18
+ mergeQueue;
17
19
  webhookHandler;
18
20
  githubWebhookHandler;
19
21
  oauthService;
@@ -31,9 +33,33 @@ export class PatchRelayService {
31
33
  throw new Error("Service runtime enqueueIssue is not initialized");
32
34
  };
33
35
  this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
36
+ this.mergeQueue = new MergeQueue(config, db, (projectId, issueId) => enqueueIssue(projectId, issueId), logger, this.feed);
34
37
  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) });
38
+ this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), this.mergeQueue, logger, this.feed);
39
+ const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
40
+ processIssue: async (item) => {
41
+ const issue = db.getIssue(item.projectId, item.issueId);
42
+ // Repairs take priority over merge prep — a check_failed or
43
+ // review_changes_requested that arrived while merge prep was
44
+ // queued must not be swallowed.
45
+ if (issue?.pendingRunType) {
46
+ await this.orchestrator.run(item);
47
+ return;
48
+ }
49
+ if (issue?.pendingMergePrep) {
50
+ const project = config.projects.find((p) => p.id === item.projectId);
51
+ if (project)
52
+ await this.mergeQueue.prepareForMerge(issue, project);
53
+ // Re-check: a repair run may have been enqueued during prep
54
+ const after = db.getIssue(item.projectId, item.issueId);
55
+ if (after?.pendingRunType) {
56
+ runtime.enqueueIssue(item.projectId, item.issueId);
57
+ }
58
+ return;
59
+ }
60
+ await this.orchestrator.run(item);
61
+ },
62
+ });
37
63
  enqueueIssue = (projectId, issueId) => runtime.enqueueIssue(projectId, issueId);
38
64
  this.oauthService = new LinearOAuthService(config, { linearInstallations: db.linearInstallations }, logger);
39
65
  this.queryService = new IssueQueryService(db, codex, this.orchestrator);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.10.6",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {