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-auto-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-auto-merge.tsBlame221 lines · 1 contributor
21ff9beClaude1/**
2 * Block J16 — PR auto-merge.
3 *
4 * Owners opt a PR into auto-merge. Whenever a commit status lands against the
5 * head SHA we re-evaluate combined state and — if green — post a readiness
6 * comment + notification to the PR author. The actual merge click remains
7 * manual (the full merge path has many guardrails the owner should see);
8 * auto-merge is about surfacing "ready now" without manual polling.
9 *
10 * Pure helper `computeAutoMergeAction` is exposed so unit tests can drive
11 * every state-machine path without touching the DB.
12 */
13
14import { and, eq } from "drizzle-orm";
15import { db } from "../db";
16import { prAutoMerge, pullRequests } from "../db/schema";
17import type { StatusState } from "./commit-statuses";
18
19export const MERGE_METHODS = ["merge", "squash", "rebase"] as const;
20export type MergeMethod = (typeof MERGE_METHODS)[number];
21
22export function isValidMergeMethod(m: unknown): m is MergeMethod {
23 return typeof m === "string" && (MERGE_METHODS as readonly string[]).includes(m);
24}
25
26export type AutoMergeAction =
27 | { action: "wait"; reason: "checks_pending" | "no_checks" }
28 | { action: "merge"; reason: "checks_passed" }
29 | {
30 action: "skip";
31 reason: "not_enabled" | "pr_closed" | "pr_draft" | "checks_failed";
32 };
33
34/**
35 * Pure: what should happen for a given PR / combined-status snapshot?
36 *
37 * - If auto-merge isn't enabled → skip (not_enabled)
38 * - PR draft or not open → skip
39 * - No status reports yet → wait (no_checks)
40 * - Any pending → wait (checks_pending)
41 * - Any failure/error → skip (checks_failed) — owner needs to intervene
42 * - All success → merge (checks_passed)
43 */
44export function computeAutoMergeAction(opts: {
45 autoMergeEnabled: boolean;
46 prState: string;
47 isDraft: boolean;
48 combinedState: StatusState | "success" | null;
49 totalChecks: number;
50}): AutoMergeAction {
51 if (!opts.autoMergeEnabled) return { action: "skip", reason: "not_enabled" };
52 if (opts.prState !== "open") return { action: "skip", reason: "pr_closed" };
53 if (opts.isDraft) return { action: "skip", reason: "pr_draft" };
54 if (!opts.combinedState || opts.totalChecks === 0) {
55 return { action: "wait", reason: "no_checks" };
56 }
57 if (opts.combinedState === "pending") {
58 return { action: "wait", reason: "checks_pending" };
59 }
60 if (opts.combinedState === "failure" || opts.combinedState === "error") {
61 return { action: "skip", reason: "checks_failed" };
62 }
63 return { action: "merge", reason: "checks_passed" };
64}
65
66/** Enable auto-merge for a PR — idempotent (delete-then-insert). */
67export async function enableAutoMerge(opts: {
68 pullRequestId: string;
69 enabledBy: string;
70 mergeMethod?: MergeMethod;
71 commitTitle?: string | null;
72 commitMessage?: string | null;
73}): Promise<boolean> {
74 const method: MergeMethod = isValidMergeMethod(opts.mergeMethod)
75 ? opts.mergeMethod
76 : "merge";
77 try {
78 await db
79 .delete(prAutoMerge)
80 .where(eq(prAutoMerge.pullRequestId, opts.pullRequestId));
81 await db.insert(prAutoMerge).values({
82 pullRequestId: opts.pullRequestId,
83 enabledBy: opts.enabledBy,
84 mergeMethod: method,
85 commitTitle: opts.commitTitle || null,
86 commitMessage: opts.commitMessage || null,
87 });
88 return true;
89 } catch (err) {
90 console.error("[pr-auto-merge] enableAutoMerge failed:", err);
91 return false;
92 }
93}
94
95export async function disableAutoMerge(pullRequestId: string): Promise<boolean> {
96 try {
97 const rows = await db
98 .delete(prAutoMerge)
99 .where(eq(prAutoMerge.pullRequestId, pullRequestId))
100 .returning({ id: prAutoMerge.id });
101 return rows.length > 0;
102 } catch (err) {
103 console.error("[pr-auto-merge] disableAutoMerge failed:", err);
104 return false;
105 }
106}
107
108export async function getAutoMergeForPr(
109 pullRequestId: string
110): Promise<{
111 enabled: boolean;
112 mergeMethod: MergeMethod;
113 enabledBy: string | null;
114 lastStatus: string | null;
115 notifiedReady: boolean;
116} | null> {
117 try {
118 const [row] = await db
119 .select()
120 .from(prAutoMerge)
121 .where(eq(prAutoMerge.pullRequestId, pullRequestId))
122 .limit(1);
123 if (!row) return { enabled: false, mergeMethod: "merge", enabledBy: null, lastStatus: null, notifiedReady: false };
124 return {
125 enabled: true,
126 mergeMethod: (isValidMergeMethod(row.mergeMethod)
127 ? row.mergeMethod
128 : "merge") as MergeMethod,
129 enabledBy: row.enabledBy,
130 lastStatus: row.lastStatus,
131 notifiedReady: row.notifiedReady,
132 };
133 } catch (err) {
134 console.error("[pr-auto-merge] getAutoMergeForPr failed:", err);
135 return { enabled: false, mergeMethod: "merge", enabledBy: null, lastStatus: null, notifiedReady: false };
136 }
137}
138
139/**
140 * Record the latest evaluation (status + timestamp) against a PR's auto-merge
141 * row. `notifiedReady=true` is set when action transitions to "merge" for the
142 * first time so we don't spam the PR with duplicate ready comments.
143 */
144export async function recordEvaluation(
145 pullRequestId: string,
146 action: AutoMergeAction,
147 combinedState: StatusState | "success" | null
148): Promise<{ wasAlreadyReady: boolean }> {
149 try {
150 const [existing] = await db
151 .select()
152 .from(prAutoMerge)
153 .where(eq(prAutoMerge.pullRequestId, pullRequestId))
154 .limit(1);
155 if (!existing) return { wasAlreadyReady: false };
156 const wasAlreadyReady = existing.notifiedReady;
157 await db
158 .update(prAutoMerge)
159 .set({
160 lastStatus: combinedState || null,
161 lastCheckedAt: new Date(),
162 notifiedReady:
163 action.action === "merge" ? true : existing.notifiedReady,
164 })
165 .where(eq(prAutoMerge.pullRequestId, pullRequestId));
166 return { wasAlreadyReady };
167 } catch (err) {
168 console.error("[pr-auto-merge] recordEvaluation failed:", err);
169 return { wasAlreadyReady: false };
170 }
171}
172
173/**
174 * Find all auto-merge rows for open PRs in a given repo. The caller resolves
175 * each PR's head branch SHA and dispatches evaluation where the SHA matches
176 * the one that just received a new status.
177 */
178export async function listAutoMergePrsForRepo(
179 repositoryId: string
180): Promise<
181 Array<{
182 pullRequestId: string;
183 number: number;
184 headBranch: string;
185 baseBranch: string;
186 state: string;
187 isDraft: boolean;
188 authorId: string;
189 }>
190> {
191 try {
192 const rows = await db
193 .select({
194 pullRequestId: pullRequests.id,
195 number: pullRequests.number,
196 headBranch: pullRequests.headBranch,
197 baseBranch: pullRequests.baseBranch,
198 state: pullRequests.state,
199 isDraft: pullRequests.isDraft,
200 authorId: pullRequests.authorId,
201 })
202 .from(prAutoMerge)
203 .innerJoin(pullRequests, eq(pullRequests.id, prAutoMerge.pullRequestId))
204 .where(
205 and(
206 eq(pullRequests.repositoryId, repositoryId),
207 eq(pullRequests.state, "open")
208 )
209 );
210 return rows;
211 } catch (err) {
212 console.error("[pr-auto-merge] listAutoMergePrsForRepo failed:", err);
213 return [];
214 }
215}
216
217export const __internal = {
218 computeAutoMergeAction,
219 isValidMergeMethod,
220 MERGE_METHODS,
221};