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.
- package/dist/build-info.json +3 -3
- package/dist/config.js +2 -0
- package/dist/db/migrations.js +8 -0
- package/dist/db.js +12 -1
- package/dist/factory-state.js +6 -2
- package/dist/github-webhook-handler.js +81 -18
- package/dist/merge-queue.js +156 -0
- package/dist/run-orchestrator.js +4 -3
- package/dist/service.js +28 -2
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
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
|
};
|
package/dist/db/migrations.js
CHANGED
|
@@ -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) {
|
package/dist/factory-state.js
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
+
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -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
|
-
//
|
|
165
|
-
|
|
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, {
|
|
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);
|