Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsBlame280 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";
2b9055eClaude42
43export interface PerformMergeArgs {
44 /** Full PR row — we need title/body/baseBranch/headBranch/repositoryId. */
45 pr: Pick<
46 PullRequest,
47 | "id"
48 | "number"
49 | "title"
50 | "body"
51 | "baseBranch"
52 | "headBranch"
53 | "repositoryId"
54 | "authorId"
55 | "state"
56 | "isDraft"
57 >;
58 ownerName: string;
59 repoName: string;
60 /** Whose user id to stamp on `merged_by` + close-keyword comments. */
61 actorUserId: string;
62 /**
63 * When true, indicates the caller's gate matrix saw a `Merge check` failure
64 * — we should route through `mergeWithAutoResolve` (Claude-assisted
65 * resolution) instead of a plain ref update. The autopilot sweep currently
66 * passes `false` because `evaluateAutoMerge` already requires green gates.
67 */
68 hasConflicts?: boolean;
69}
70
71export interface PerformMergeResult {
72 ok: boolean;
73 error?: string;
74 /**
75 * Issue numbers that were auto-closed by J7 close-keyword scanning.
76 * Empty array on no matches or on close-keyword failure (never throws).
77 */
78 closedIssueNumbers: number[];
79 /**
80 * Files that the AI conflict resolver touched, when `hasConflicts` routed
81 * through `mergeWithAutoResolve`. Empty when a plain ref update was used.
82 */
83 resolvedFiles: string[];
84}
85
86/**
87 * Internal helper: run the actual git operation (ref update or
88 * Claude-assisted merge). Returns `{ok}` so the caller decides whether to
89 * flip DB state.
90 */
91async function executeGitMerge(args: {
92 ownerName: string;
93 repoName: string;
94 baseBranch: string;
95 headBranch: string;
96 prNumber: number;
97 prTitle: string;
98 hasConflicts: boolean;
99}): Promise<{ ok: true; resolvedFiles: string[] } | { ok: false; error: string }> {
100 const repoDir = getRepoPath(args.ownerName, args.repoName);
101
102 if (args.hasConflicts && isAiReviewEnabled()) {
103 const mergeResult = await mergeWithAutoResolve(
104 args.ownerName,
105 args.repoName,
106 args.baseBranch,
107 args.headBranch,
108 `Merge pull request #${args.prNumber}: ${args.prTitle}`
109 );
110 if (!mergeResult.success) {
111 return {
112 ok: false,
113 error: mergeResult.error || "Auto-merge failed",
114 };
115 }
116 return { ok: true, resolvedFiles: mergeResult.resolvedFiles };
117 }
118
119 try {
120 const proc = Bun.spawn(
121 [
122 "git",
123 "update-ref",
124 `refs/heads/${args.baseBranch}`,
125 `refs/heads/${args.headBranch}`,
126 ],
127 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
128 );
129 const exit = await proc.exited;
130 if (exit !== 0) {
131 const errText = await new Response(proc.stderr).text();
132 return {
133 ok: false,
134 error: `git update-ref failed: ${errText.trim() || `exit ${exit}`}`,
135 };
136 }
137 return { ok: true, resolvedFiles: [] };
138 } catch (err) {
139 return {
140 ok: false,
141 error: `git update-ref threw: ${err instanceof Error ? err.message : String(err)}`,
142 };
143 }
144}
145
146/**
147 * Apply J7 close-keyword scanning. Best-effort — failures swallowed and
148 * surfaced via the returned array (which is empty on any error).
149 */
150async function applyCloseKeywords(args: {
151 pr: PerformMergeArgs["pr"];
152 actorUserId: string;
153}): Promise<number[]> {
154 const closed: number[] = [];
155 try {
156 const refs = extractClosingRefsMulti([args.pr.title, args.pr.body]);
157 for (const n of refs) {
158 const [issue] = await db
159 .select()
160 .from(issues)
161 .where(
162 and(
163 eq(issues.repositoryId, args.pr.repositoryId),
164 eq(issues.number, n)
165 )
166 )
167 .limit(1);
168 if (!issue || issue.state !== "open") continue;
169 await db
170 .update(issues)
171 .set({ state: "closed", closedAt: new Date(), updatedAt: new Date() })
172 .where(eq(issues.id, issue.id));
173 await db.insert(issueComments).values({
174 issueId: issue.id,
175 authorId: args.actorUserId,
176 body: `Closed by pull request #${args.pr.number}.`,
177 });
178 closed.push(n);
179 }
180 } catch {
181 // J7 invariant: close-keyword failures never block the merge.
182 }
183 return closed;
184}
185
186/**
187 * Run a PR merge end-to-end (git + DB + close-keywords). Caller is
188 * responsible for having pre-validated that the merge is allowed.
189 *
190 * Returns:
191 * - ok=true with `closedIssueNumbers` + `resolvedFiles` on full success.
192 * - ok=false with `error` if the git step failed; DB is left untouched.
193 * (DB-update failures are bubbled up the same way.)
194 */
195export async function performMerge(
196 args: PerformMergeArgs
197): Promise<PerformMergeResult> {
198 // Defence-in-depth: refuse to act on PRs that aren't actually open/non-draft.
199 if (args.pr.state !== "open") {
200 return {
201 ok: false,
202 error: `PR is not open (state=${args.pr.state}).`,
203 closedIssueNumbers: [],
204 resolvedFiles: [],
205 };
206 }
207 if (args.pr.isDraft) {
208 return {
209 ok: false,
210 error: "PR is a draft — drafts cannot be merged.",
211 closedIssueNumbers: [],
212 resolvedFiles: [],
213 };
214 }
215
216 const gitResult = await executeGitMerge({
217 ownerName: args.ownerName,
218 repoName: args.repoName,
219 baseBranch: args.pr.baseBranch,
220 headBranch: args.pr.headBranch,
221 prNumber: args.pr.number,
222 prTitle: args.pr.title,
223 hasConflicts: args.hasConflicts === true,
224 });
225 if (!gitResult.ok) {
226 return {
227 ok: false,
228 error: gitResult.error,
229 closedIssueNumbers: [],
230 resolvedFiles: [],
231 };
232 }
233
234 try {
235 await db
236 .update(pullRequests)
237 .set({
238 state: "merged",
239 mergedAt: new Date(),
240 mergedBy: args.actorUserId,
241 updatedAt: new Date(),
242 })
243 .where(eq(pullRequests.id, args.pr.id));
244 } catch (err) {
245 return {
246 ok: false,
247 error: `DB update failed after git merge: ${
248 err instanceof Error ? err.message : String(err)
249 }`,
250 closedIssueNumbers: [],
251 resolvedFiles: gitResult.resolvedFiles,
252 };
253 }
254
255 const closedIssueNumbers = await applyCloseKeywords({
256 pr: args.pr,
257 actorUserId: args.actorUserId,
258 });
259
b7b5f75ccanty labs260 void logActivity({
261 repositoryId: args.pr.repositoryId,
262 userId: args.actorUserId,
263 action: "pr_merge",
264 targetType: "pull_request",
265 targetId: String(args.pr.number),
266 metadata: { baseBranch: args.pr.baseBranch, headBranch: args.pr.headBranch },
267 });
268
2b9055eClaude269 return {
270 ok: true,
271 closedIssueNumbers,
272 resolvedFiles: gitResult.resolvedFiles,
273 };
274}
275
276/** Test-only surface. */
277export const __test = {
278 executeGitMerge,
279 applyCloseKeywords,
280};