Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

pr-merge.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pr-merge.tsBlame285 lines · 2 contributors
2b9055eClaude1/**
2 * Block K3 — Shared PR merge executor.
3 *
4 * Factors the side-effecting merge mechanics out of the
5 * `POST /:owner/:repo/pulls/:number/merge` HTTP handler in `src/routes/pulls.tsx`
6 * so the autopilot's `auto-merge-sweep` task can perform a merge without
7 * replicating route logic. The HTTP handler retains its own gating chain
8 * (gate checks, branch-protection re-evaluation, error redirects); this
9 * module only covers the post-decision mechanics:
10 *
11 * 1. Run the actual ref update (`git update-ref` for ff/clean merges,
12 * delegating to `mergeWithAutoResolve` when the K2-style decision
13 * flagged conflicts and AI conflict-resolution is enabled).
14 * 2. Flip `pull_requests.state` to `merged`, stamp `mergedAt` / `mergedBy`.
15 * 3. Run J7 close-keyword scanning — close any referenced open issues in
16 * the same repo and post the back-link comment.
17 *
18 * Pure error-funnel: every failure is returned as `{ok:false, error}`; we
19 * never throw. Callers decide how to surface the error (HTTP redirect vs.
20 * audit row).
21 *
22 * Intentionally NOT in this file:
23 * - Gate evaluation / branch-protection (use `evaluateAutoMerge` in K2,
24 * or the inline chain in the HTTP handler).
25 * - AI review comment posting (the auto-merge audit/comment is the
26 * autopilot task's responsibility).
27 */
28
29import { and, eq } from "drizzle-orm";
30import { db } from "../db";
31import {
32 issueComments,
33 issues,
34 pullRequests,
35 type PullRequest,
36} from "../db/schema";
37import { getRepoPath } from "../git/repository";
38import { mergeWithAutoResolve } from "./merge-resolver";
39import { isAiReviewEnabled } from "./ai-review";
40import { extractClosingRefsMulti } from "./close-keywords";
b7b5f75ccanty labs41import { logActivity } from "./notify";
a74f4edccanty labs42import { fireWebhooks } from "../routes/webhooks";
2b9055eClaude43
44export interface PerformMergeArgs {
45 /** Full PR row — we need title/body/baseBranch/headBranch/repositoryId. */
46 pr: Pick<
47 PullRequest,
48 | "id"
49 | "number"
50 | "title"
51 | "body"
52 | "baseBranch"
53 | "headBranch"
54 | "repositoryId"
55 | "authorId"
56 | "state"
57 | "isDraft"
58 >;
59 ownerName: string;
60 repoName: string;
61 /** Whose user id to stamp on `merged_by` + close-keyword comments. */
62 actorUserId: string;
63 /**
64 * When true, indicates the caller's gate matrix saw a `Merge check` failure
65 * — we should route through `mergeWithAutoResolve` (Claude-assisted
66 * resolution) instead of a plain ref update. The autopilot sweep currently
67 * passes `false` because `evaluateAutoMerge` already requires green gates.
68 */
69 hasConflicts?: boolean;
70}
71
72export interface PerformMergeResult {
73 ok: boolean;
74 error?: string;
75 /**
76 * Issue numbers that were auto-closed by J7 close-keyword scanning.
77 * Empty array on no matches or on close-keyword failure (never throws).
78 */
79 closedIssueNumbers: number[];
80 /**
81 * Files that the AI conflict resolver touched, when `hasConflicts` routed
82 * through `mergeWithAutoResolve`. Empty when a plain ref update was used.
83 */
84 resolvedFiles: string[];
85}
86
87/**
88 * Internal helper: run the actual git operation (ref update or
89 * Claude-assisted merge). Returns `{ok}` so the caller decides whether to
90 * flip DB state.
91 */
92async function executeGitMerge(args: {
93 ownerName: string;
94 repoName: string;
95 baseBranch: string;
96 headBranch: string;
97 prNumber: number;
98 prTitle: string;
99 hasConflicts: boolean;
100}): Promise<{ ok: true; resolvedFiles: string[] } | { ok: false; error: string }> {
101 const repoDir = getRepoPath(args.ownerName, args.repoName);
102
103 if (args.hasConflicts && isAiReviewEnabled()) {
104 const mergeResult = await mergeWithAutoResolve(
105 args.ownerName,
106 args.repoName,
107 args.baseBranch,
108 args.headBranch,
109 `Merge pull request #${args.prNumber}: ${args.prTitle}`
110 );
111 if (!mergeResult.success) {
112 return {
113 ok: false,
114 error: mergeResult.error || "Auto-merge failed",
115 };
116 }
117 return { ok: true, resolvedFiles: mergeResult.resolvedFiles };
118 }
119
120 try {
121 const proc = Bun.spawn(
122 [
123 "git",
124 "update-ref",
125 `refs/heads/${args.baseBranch}`,
126 `refs/heads/${args.headBranch}`,
127 ],
128 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
129 );
130 const exit = await proc.exited;
131 if (exit !== 0) {
132 const errText = await new Response(proc.stderr).text();
133 return {
134 ok: false,
135 error: `git update-ref failed: ${errText.trim() || `exit ${exit}`}`,
136 };
137 }
138 return { ok: true, resolvedFiles: [] };
139 } catch (err) {
140 return {
141 ok: false,
142 error: `git update-ref threw: ${err instanceof Error ? err.message : String(err)}`,
143 };
144 }
145}
146
147/**
148 * Apply J7 close-keyword scanning. Best-effort — failures swallowed and
149 * surfaced via the returned array (which is empty on any error).
150 */
151async function applyCloseKeywords(args: {
152 pr: PerformMergeArgs["pr"];
153 actorUserId: string;
154}): Promise<number[]> {
155 const closed: number[] = [];
156 try {
157 const refs = extractClosingRefsMulti([args.pr.title, args.pr.body]);
158 for (const n of refs) {
159 const [issue] = await db
160 .select()
161 .from(issues)
162 .where(
163 and(
164 eq(issues.repositoryId, args.pr.repositoryId),
165 eq(issues.number, n)
166 )
167 )
168 .limit(1);
169 if (!issue || issue.state !== "open") continue;
170 await db
171 .update(issues)
172 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
173 .where(eq(issues.id, issue.id));
174 await db.insert(issueComments).values({
175 issueId: issue.id,
176 authorId: args.actorUserId,
177 body: `Closed by pull request #${args.pr.number}.`,
178 });
179 closed.push(n);
180 }
181 } catch {
182 // J7 invariant: close-keyword failures never block the merge.
183 }
184 return closed;
185}
186
187/**
188 * Run a PR merge end-to-end (git + DB + close-keywords). Caller is
189 * responsible for having pre-validated that the merge is allowed.
190 *
191 * Returns:
192 * - ok=true with `closedIssueNumbers` + `resolvedFiles` on full success.
193 * - ok=false with `error` if the git step failed; DB is left untouched.
194 * (DB-update failures are bubbled up the same way.)
195 */
196export async function performMerge(
197 args: PerformMergeArgs
198): Promise<PerformMergeResult> {
199 // Defence-in-depth: refuse to act on PRs that aren't actually open/non-draft.
200 if (args.pr.state !== "open") {
201 return {
202 ok: false,
203 error: `PR is not open (state=${args.pr.state}).`,
204 closedIssueNumbers: [],
205 resolvedFiles: [],
206 };
207 }
208 if (args.pr.isDraft) {
209 return {
210 ok: false,
211 error: "PR is a draft — drafts cannot be merged.",
212 closedIssueNumbers: [],
213 resolvedFiles: [],
214 };
215 }
216
217 const gitResult = await executeGitMerge({
218 ownerName: args.ownerName,
219 repoName: args.repoName,
220 baseBranch: args.pr.baseBranch,
221 headBranch: args.pr.headBranch,
222 prNumber: args.pr.number,
223 prTitle: args.pr.title,
224 hasConflicts: args.hasConflicts === true,
225 });
226 if (!gitResult.ok) {
227 return {
228 ok: false,
229 error: gitResult.error,
230 closedIssueNumbers: [],
231 resolvedFiles: [],
232 };
233 }
234
235 try {
236 await db
237 .update(pullRequests)
238 .set({
239 state: "merged",
240 mergedAt: new Date(),
241 mergedBy: args.actorUserId,
242 updatedAt: new Date(),
243 })
244 .where(eq(pullRequests.id, args.pr.id));
245 } catch (err) {
246 return {
247 ok: false,
248 error: `DB update failed after git merge: ${
249 err instanceof Error ? err.message : String(err)
250 }`,
251 closedIssueNumbers: [],
252 resolvedFiles: gitResult.resolvedFiles,
253 };
254 }
255
256 const closedIssueNumbers = await applyCloseKeywords({
257 pr: args.pr,
258 actorUserId: args.actorUserId,
259 });
260
b7b5f75ccanty labs261 void logActivity({
262 repositoryId: args.pr.repositoryId,
263 userId: args.actorUserId,
264 action: "pr_merge",
265 targetType: "pull_request",
266 targetId: String(args.pr.number),
267 metadata: { baseBranch: args.pr.baseBranch, headBranch: args.pr.headBranch },
268 });
a74f4edccanty labs269 void fireWebhooks(args.pr.repositoryId, "pr", {
270 action: "merged",
271 number: args.pr.number,
272 });
b7b5f75ccanty labs273
2b9055eClaude274 return {
275 ok: true,
276 closedIssueNumbers,
277 resolvedFiles: gitResult.resolvedFiles,
278 };
279}
280
281/** Test-only surface. */
282export const __test = {
283 executeGitMerge,
284 applyCloseKeywords,
285};