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