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