Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

383 lines · 21.7 KBRaw History
1import type { Actor } from "@diffinery/shared";
2import type { Env } from "../env.js";
3import { agentMayMerge } from "../lib/agent-setup.js";
4import { Directory } from "../lib/directory.js";
5import { HttpError, now } from "../lib/util.js";
6import type { RepoDO } from "./RepoDO.js";
7import { isJobCaller } from "./token-policy.js";
8import { ROLE_LEVEL, atLeast, type Caller, type ProtectionRules, type Role } from "./types.js";
9
10/**
11 * Auto-merge, like GitHub's: Diffinery merges a pull request by itself as soon as every
12 * rule of its base branch is met (required checks green on the head, approvals from people,
13 * linear history and an up-to-date branch where required). Into a branch that merges
14 * through a merge queue it adds the pull request to the queue instead.
15 *
16 * It is also how agents land changes on protected branches without a bypass: they never
17 * merge there themselves, but a person who administers the repository can let them have
18 * Diffinery do it (`allowAgentAutoMerge`), on a branch whose required checks gate it. With
19 * `autoMergeAgents` on the rule too, Diffinery turns it on by itself for those agents' pull
20 * requests (applyRule), so no agent or person has to at merge time.
21 */
22
23export type MergeMethod = "merge" | "squash" | "rebase";
24
25/** Stored on the pull request (pulls.auto_merge) while auto-merge is on. */
26export type AutoMergeState = {
27 method: MergeMethod;
28 commitTitle: string | null;
29 commitMessage: string | null;
30 enabledBy: Actor;
31 enabledAt: string;
32 /** The role (and a workflow token's permissions) it was enabled with: the merge never acts with more. */
33 role: Role;
34 permissions?: Caller["permissions"];
35 /** Delete the head branch once it lands, as a manual merge's `delete_branch` does (collab.ts completeMerge says which branches). */
36 deleteBranch?: boolean;
37 /** The branch rule pattern whose `autoMergeAgents` turned it on, for the agent in `enabledBy`. */
38 byRule?: string;
39};
40
41/** What the API and the web show: who turned it on, and how it will merge. */
42export type AutoMergeInfo = Pick<AutoMergeState, "method" | "commitTitle" | "commitMessage" | "enabledBy" | "enabledAt" | "byRule"> & { deleteBranch: boolean };
43
44export type AutoMergeResult = { state: "enabled" | "queued" | "merged"; autoMerge: AutoMergeInfo | null; mergeCommit?: string | null };
45
46/** Why auto-merge turns itself off in an archived repository, which is read-only for everyone. */
47export const ARCHIVED_REASON = "the repository is archived";
48
49/** Comments about auto-merge the server wrote on its own (it was turned off because ...). */
50export const AUTO_MERGE_BOT: Caller = { id: "system:auto-merge", login: "diffinery-auto-merge[bot]", kind: "system", role: "admin" };
51
52const METHODS: readonly MergeMethod[] = ["merge", "squash", "rebase"];
53
54/** A person: not an agent, not a workflow's GITHUB_TOKEN, not the system. */
55export const isPerson = (c: Pick<Caller, "id" | "kind">): boolean => c.kind === "user" && !isJobCaller(c);
56
57/**
58 * Whether `caller` may have Diffinery land a pull request into `base` without a person merging
59 * it: by auto-merge, or through the merge queue. Null when it may, else why not.
60 *
61 * - People may (branch protection still decides when the merge happens).
62 * - Agents and workflow tokens may on a branch that is neither the default branch nor protected.
63 * - An agent may where `agentsMayMerge` already lets it merge by itself.
64 * - Otherwise only where a person turned on `allowAgentAutoMerge`, and the branch has at least
65 * one required check, so CI gates what lands. Workflow tokens are held to the same rule even
66 * when the run's code didn't come from an agent.
67 */
68export function automaticLandingRefusal(caller: Pick<Caller, "id" | "kind" | "mayMerge">, rules: ProtectionRules | null, isDefault: boolean, base: string): { status: 403 | 422; message: string } | null {
69 if (isPerson(caller)) return null;
70 if (!rules && !isDefault) return null;
71 if (caller.kind === "agent" && rules?.agentsMayMerge) return null;
72 if (mergesByAgentPermission(caller, rules, isDefault, true)) return null;
73 const who = isJobCaller(caller) ? "workflow tokens" : "agents";
74 if (!rules?.allowAgentAutoMerge) {
75 return { status: 403, message: `${who} may not have pull requests merged into ${base} automatically unless its branch protection allows it (allowAgentAutoMerge, set by a repository admin); a person must merge` };
76 }
77 if (!rules.requiredChecks?.length) {
78 return { status: 422, message: `${base} has no required status checks: allowAgentAutoMerge needs at least one, so that CI gates what ${who} land` };
79 }
80 return null;
81}
82
83/**
84 * Whether an agent merges into a branch, by hand or through auto-merge and the merge queue, on its own
85 * policy's permission (`mayMerge`, from Agent setup: lib/agent-setup.ts) rather than a rule of the
86 * branch. The rules of the branch still all apply, and on top of them a person must have approved the
87 * pull request's latest commit, and a change to a workflow file needs a person to merge it.
88 * Never a workflow token's, whatever kind it carries.
89 */
90export function mergesByAgentPermission(caller: Pick<Caller, "id" | "kind" | "mayMerge">, rules: ProtectionRules | null, isDefault: boolean, automatic: boolean): boolean {
91 if (caller.kind !== "agent" || isJobCaller(caller) || !caller.mayMerge) return false;
92 // Where agents merge anyway: a branch that is neither protected nor the default one, or one whose rules let them.
93 if (!rules && !isDefault) return false;
94 if (rules?.agentsMayMerge) return false;
95 if (automatic && rules?.allowAgentAutoMerge && rules.requiredChecks?.length) return false;
96 return true;
97}
98
99/** Whether an automatic landing by `caller` relies on `allowAgentAutoMerge` (rather than being a person's, or allowed anyway). */
100export function landsByAgentRule(caller: Pick<Caller, "id" | "kind">, rules: ProtectionRules | null, isDefault: boolean): boolean {
101 return !isPerson(caller) && (!!rules || isDefault) && !(caller.kind === "agent" && rules?.agentsMayMerge);
102}
103
104const minRole = (a: Role, b: Role): Role => (ROLE_LEVEL[a] <= ROLE_LEVEL[b] ? a : b);
105
106type Filter = { number?: number; sha?: string; base?: string };
107
108export class AutoMerge {
109 private lock: Promise<unknown> = Promise.resolve();
110
111 constructor(
112 private repo: RepoDO,
113 private env: Env,
114 ) {}
115
116 private get sql() {
117 return this.repo.sql;
118 }
119
120 state(number: number): AutoMergeState | null {
121 const r = this.sql.exec<{ auto_merge: string | null }>("SELECT auto_merge FROM pulls WHERE number = ?", number).toArray()[0];
122 return r?.auto_merge ? (JSON.parse(r.auto_merge) as AutoMergeState) : null;
123 }
124
125 static info(s: AutoMergeState | null): AutoMergeInfo | null {
126 return s ? { method: s.method, commitTitle: s.commitTitle, commitMessage: s.commitMessage, enabledBy: s.enabledBy, enabledAt: s.enabledAt, deleteBranch: !!s.deleteBranch, ...(s.byRule ? { byRule: s.byRule } : {}) } : null;
127 }
128
129 /**
130 * Turn auto-merge on (or change how it merges), then merge or enqueue right away if the pull request is ready.
131 * `opts.rule`: the server turns it on for `caller`, the pull request's agent, because that branch rule names it
132 * (applyRule); it is then recorded as the server's doing.
133 */
134 async enable(number: number, input: { method?: string; commitTitle?: string | null; commitMessage?: string | null; deleteBranch?: boolean; expectedHeadSha?: string }, caller: Caller, opts: { rule?: string } = {}): Promise<AutoMergeResult> {
135 const { issue } = this.repo.collab.get(number);
136 const pull = issue.pull;
137 if (!pull) throw new HttpError(404, "not a pull request");
138 if (await this.repo.isArchived()) throw new HttpError(403, "repository is archived (read-only)");
139 if (pull.merged) throw new HttpError(422, "pull request is already merged");
140 if (issue.state !== "open") throw new HttpError(422, "pull request is closed");
141 if (pull.draft) throw new HttpError(422, "pull request is a draft; mark it ready for review first");
142 if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to enable auto-merge");
143 const rules = this.repo.protectionFor(pull.base);
144 const refusal = automaticLandingRefusal(caller, rules, pull.base === this.repo.meta.defaultBranch, pull.base);
145 if (refusal) throw new HttpError(refusal.status, refusal.message);
146 if (landsByAgentRule(caller, rules, pull.base === this.repo.meta.defaultBranch)) {
147 const path = await this.repo.collab.changedWorkflow(number);
148 if (path) throw new HttpError(422, `the pull request changes workflow file \`${path}\`; CI can't gate a change to itself, so a person must merge it`);
149 }
150 if (input.method !== undefined && !METHODS.includes(input.method as MergeMethod)) throw new HttpError(422, "merge_method must be merge, squash or rebase");
151 const method = (input.method as MergeMethod | undefined) ?? rules?.mergeQueue?.method ?? (rules?.requireLinearHistory ? "squash" : "merge");
152 if (method === "merge" && rules?.requireLinearHistory) throw new HttpError(422, `${pull.base} requires linear history; use squash or rebase`);
153 if (this.repo.queue.isQueued(number)) throw new HttpError(422, "pull request is already in the merge queue");
154 if (input.expectedHeadSha && input.expectedHeadSha !== pull.headSha) throw new HttpError(409, "head branch was modified; review and try again");
155 const state: AutoMergeState = {
156 method,
157 commitTitle: typeof input.commitTitle === "string" && input.commitTitle ? input.commitTitle : null,
158 commitMessage: typeof input.commitMessage === "string" && input.commitMessage ? input.commitMessage : null,
159 enabledBy: { id: caller.id, login: caller.login, kind: caller.kind, ...(caller.onBehalfOf ? { onBehalfOf: caller.onBehalfOf } : {}) },
160 enabledAt: now(),
161 role: caller.role,
162 ...(caller.permissions ? { permissions: caller.permissions } : {}),
163 ...(input.deleteBranch === true ? { deleteBranch: true } : {}),
164 ...(opts.rule ? { byRule: opts.rule } : {}),
165 };
166 // Turned on by a branch rule: the server did it (for the agent it merges as), and the record says so.
167 const by = opts.rule ? AUTO_MERGE_BOT : caller;
168 this.sql.exec("UPDATE pulls SET auto_merge = ? WHERE number = ?", JSON.stringify(state), number);
169 this.repo.social.record(number, "auto_merge_enabled", by, { merge_method: method, ...(opts.rule ? { rule: opts.rule, agent: caller.login } : {}) });
170 this.event(number, "auto_merge_enabled", by);
171 this.audit("pull_request.auto_merge_enabled", by, number, { base: pull.base, method, viaAgentRule: landsByAgentRule(caller, rules, pull.base === this.repo.meta.defaultBranch), ...(opts.rule ? { byRule: opts.rule, agent: caller.login } : {}) });
172 this.repo.broadcast(`pr:${number}`, { action: "auto_merge" });
173 await this.process({ number });
174 return this.result(number);
175 }
176
177 /**
178 * A branch rule's `autoMergeAgents`: when one of those agents opens a pull request into a matching branch, marks
179 * it ready, or pushes to it, Diffinery turns auto-merge on by itself, as that agent. It then lands exactly as the
180 * agent's own auto-merge would: `allowAgentAutoMerge` and a required check, the agent's write access at merge
181 * time, approvals unless the rule waives them, no requested changes, and a person for a workflow change.
182 * Never again on a pull request where anyone turned auto-merge off, or the server did. `announce`: when it
183 * can't turn on, say why in a comment (once, when the pull request is opened or marked ready).
184 */
185 async applyRule(number: number, announce: boolean): Promise<void> {
186 const r = this.sql
187 .exec<{ author_id: string; author_kind: string; state: string; draft: number; merged: number; auto_merge: string | null; base_ref: string }>(
188 "SELECT i.author_id, i.author_kind, i.state, p.draft, p.merged, p.auto_merge, p.base_ref FROM issues i JOIN pulls p ON p.number = i.number WHERE i.number = ?",
189 number,
190 )
191 .toArray()[0];
192 if (!r || r.state !== "open" || r.merged || r.draft || r.auto_merge || r.author_kind !== "agent") return;
193 const rule = this.repo.protectionRuleFor(r.base_ref);
194 if (!rule?.rules.allowAgentAutoMerge || !rule.rules.autoMergeAgents?.some((a) => a.id === r.author_id)) return;
195 if (this.repo.queue.isQueued(number)) return;
196 if (this.sql.exec("SELECT 1 FROM issue_events WHERE number = ? AND event = 'auto_merge_disabled' LIMIT 1", number).toArray().length) return;
197 const caller = await this.accountCaller(r.author_id, "admin");
198 if (!caller || caller.kind !== "agent") return;
199 try {
200 await this.enable(number, { method: rule.rules.autoMergeMethod }, caller, { rule: rule.pattern });
201 } catch (e) {
202 if (!announce) return;
203 const why = e instanceof HttpError ? e.detail : "it could not be turned on";
204 try {
205 this.repo.collab.addComment(number, `Auto-merge didn't turn on by itself (branch rule \`${rule.pattern}\` auto-merges ${caller.login}'s pull requests): ${why}. A person can merge it.`, AUTO_MERGE_BOT);
206 } catch (err) {
207 console.warn("auto-merge rule comment failed", err);
208 }
209 }
210 }
211
212 private result(number: number): AutoMergeResult {
213 const p = this.sql.exec<{ merged: number; merge_commit: string | null }>("SELECT merged, merge_commit FROM pulls WHERE number = ?", number).one();
214 if (p.merged) return { state: "merged", autoMerge: null, mergeCommit: p.merge_commit };
215 if (this.repo.queue.isQueued(number)) return { state: "queued", autoMerge: null };
216 return { state: "enabled", autoMerge: AutoMerge.info(this.state(number)) };
217 }
218
219 /** Turn auto-merge off at someone's request: who turned it on, or anyone with write access. */
220 disable(number: number, caller: Caller): void {
221 const s = this.state(number);
222 if (!s) return;
223 if (s.enabledBy.id !== caller.id && !atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to disable auto-merge");
224 this.cancel(number, caller, null);
225 }
226
227 /** A push to the head branch by anyone but who turned auto-merge on cancels it, as on GitHub. */
228 headPushed(number: number, pusher: Caller): void {
229 const s = this.state(number);
230 if (s && s.enabledBy.id !== pusher.id) this.cancel(number, pusher, `${pusher.login} pushed new commits to the branch`);
231 }
232
233 /** The pull request closed or merged: auto-merge is over, nothing to announce. */
234 clear(number: number): void {
235 this.sql.exec("UPDATE pulls SET auto_merge = NULL WHERE number = ? AND auto_merge IS NOT NULL", number);
236 }
237
238 /** Turn auto-merge off; with a reason, the server did (it is said in a comment). */
239 cancel(number: number, by: Caller, reason: string | null): void {
240 const s = this.state(number);
241 if (!s) return;
242 this.clear(number);
243 this.repo.social.record(number, "auto_merge_disabled", by, reason ? { reason } : {});
244 this.event(number, "auto_merge_disabled", by);
245 this.audit("pull_request.auto_merge_disabled", by, number, { enabledBy: s.enabledBy.login, ...(reason ? { reason } : {}) });
246 if (reason) {
247 try {
248 this.repo.collab.addComment(number, `Auto-merge was turned off: ${reason}.`, AUTO_MERGE_BOT);
249 } catch (e) {
250 console.warn("auto-merge comment failed", e);
251 }
252 }
253 this.repo.broadcast(`pr:${number}`, { action: "auto_merge" });
254 }
255
256 /** The repository was archived: nothing lands by itself any more, so every waiting auto-merge is turned off, saying why. */
257 repositoryArchived(): Promise<void> {
258 return this.exclusive(async () => {
259 for (const n of this.pending({})) this.cancel(n, AUTO_MERGE_BOT, ARCHIVED_REASON);
260 });
261 }
262
263 /** Re-evaluate in the background: after checks complete, reviews, protection changes and pushes. */
264 schedule(filter: Filter = {}): void {
265 if (!this.pending(filter).length) return;
266 this.repo.background(this.process(filter));
267 }
268
269 private pending(filter: Filter): number[] {
270 const where = ["p.auto_merge IS NOT NULL", "i.state = 'open'"];
271 const args: unknown[] = [];
272 if (filter.number !== undefined) {
273 where.push("p.number = ?");
274 args.push(filter.number);
275 }
276 if (filter.sha !== undefined) {
277 where.push("p.head_sha = ?");
278 args.push(filter.sha);
279 }
280 if (filter.base !== undefined) {
281 where.push("p.base_ref = ?");
282 args.push(filter.base);
283 }
284 return this.sql
285 .exec<{ number: number }>(`SELECT p.number FROM pulls p JOIN issues i ON i.number = p.number WHERE ${where.join(" AND ")} ORDER BY p.number`, ...args)
286 .toArray()
287 .map((r) => r.number);
288 }
289
290 process(filter: Filter = {}): Promise<void> {
291 return this.exclusive(async () => {
292 for (const n of this.pending(filter)) {
293 try {
294 await this.step(n);
295 } catch (e) {
296 console.error(`auto-merge of #${n} failed`, e);
297 }
298 }
299 });
300 }
301
302 private exclusive<T>(fn: () => Promise<T>): Promise<T> {
303 const run = this.lock.then(fn, fn);
304 this.lock = run.catch(() => undefined);
305 return run;
306 }
307
308 /** Merge (or enqueue) one pull request if it is ready now; otherwise leave auto-merge on, or turn it off when it can never go through. */
309 private async step(number: number): Promise<void> {
310 const s = this.state(number);
311 if (!s) return;
312 const { issue } = this.repo.collab.get(number);
313 const pull = issue.pull!;
314 if (pull.merged || issue.state !== "open") return this.clear(number);
315 if (this.repo.queue.isQueued(number)) return this.clear(number);
316 // An archived repository is read-only: a check that finishes after the archive must not land anything.
317 if (await this.repo.isArchived()) return this.cancel(number, AUTO_MERGE_BOT, ARCHIVED_REASON);
318 const caller = await this.callerOf(s);
319 if (!caller || !atLeast(caller.role, "write")) return this.cancel(number, AUTO_MERGE_BOT, `${s.enabledBy.login} no longer has write access`);
320 // The rules may have changed since it was turned on: an agent's auto-merge needs them to still allow it.
321 const rules = this.repo.protectionFor(pull.base);
322 const refusal = automaticLandingRefusal(caller, rules, pull.base === this.repo.meta.defaultBranch, pull.base);
323 if (refusal) return this.cancel(number, AUTO_MERGE_BOT, refusal.message);
324 if (s.method === "merge" && rules?.requireLinearHistory) return this.cancel(number, AUTO_MERGE_BOT, `${pull.base} requires linear history, and auto-merge was set to create a merge commit`);
325 const m = await this.repo.collab.mergeability(number, caller, { automatic: true });
326 if (!m.mergeable) return;
327 if (rules?.mergeQueue) {
328 let r: Awaited<ReturnType<RepoDO["queue"]["enqueue"]>>;
329 try {
330 r = await this.repo.queue.enqueue(number, { method: s.method, deleteBranch: !!s.deleteBranch }, caller);
331 } catch (e) {
332 // Like a refused merge: the queue said no, so auto-merge is off and the comment says why.
333 if (e instanceof HttpError && e.status === 409) return;
334 return this.cancel(number, AUTO_MERGE_BOT, e instanceof HttpError ? e.detail : "the pull request could not be added to the merge queue");
335 }
336 this.clear(number);
337 this.audit("pull_request.auto_merge_enqueued", caller, number, { base: pull.base, state: r.state });
338 return;
339 }
340 try {
341 const r = await this.repo.collab.merge(number, { method: s.method, title: s.commitTitle ?? undefined, message: s.commitMessage ?? undefined, expectedHeadSha: pull.headSha, deleteBranch: !!s.deleteBranch }, caller, { automatic: true });
342 this.audit("pull_request.auto_merged", caller, number, { base: pull.base, method: r.method, sha: r.sha });
343 } catch (e) {
344 // The head or base moved under us: the next event looks again. Anything else won't fix itself.
345 if (e instanceof HttpError && e.status === 409) return;
346 this.cancel(number, AUTO_MERGE_BOT, e instanceof HttpError ? e.detail : "the merge failed");
347 }
348 }
349
350 /**
351 * Who merges: the one who turned auto-merge on, with their access as it is now (never more than
352 * when they turned it on). A workflow token's job has ended by then, so it keeps what it had.
353 */
354 private async callerOf(s: AutoMergeState): Promise<Caller | null> {
355 const by = s.enabledBy;
356 if (isJobCaller(by)) return { ...by, role: s.role, permissions: s.permissions };
357 return this.accountCaller(by.id, s.role);
358 }
359
360 /** An account as a caller in this repository, with its access as it is now, never above `cap`. */
361 private async accountCaller(id: string, cap: Role): Promise<Caller | null> {
362 const dir = new Directory(this.env);
363 const [account, repo] = await Promise.all([dir.account(id), dir.repoById(this.repo.meta.repoId)]);
364 if (!account || !repo) return null;
365 const role = await dir.roleFor(account, repo);
366 if (!role) return null;
367 const kind = account.kind === "agent" ? "agent" : "user";
368 const onBehalfOf = kind === "agent" && account.owner_id ? (await dir.account(account.owner_id))?.login : undefined;
369 return { id: account.id, login: account.login, kind, ...(onBehalfOf ? { onBehalfOf } : {}), role: minRole(role, cap), ...(agentMayMerge(account) ? { mayMerge: true } : {}) };
370 }
371
372 private event(number: number, action: "auto_merge_enabled" | "auto_merge_disabled", by: Caller): void {
373 const p = this.sql.exec<{ head_ref: string; head_sha: string; base_ref: string }>("SELECT head_ref, head_sha, base_ref FROM pulls WHERE number = ?", number).toArray()[0];
374 if (!p) return;
375 const m = this.repo.meta;
376 this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: { id: by.id, login: by.login, kind: by.kind, onBehalfOf: by.onBehalfOf }, at: now() });
377 }
378
379 private audit(action: string, by: Caller, number: number, detail: Record<string, string | number | boolean | null>): void {
380 const m = this.repo.meta;
381 this.repo.emit({ type: "audit", action, actor: { id: by.id, login: by.login, kind: by.kind, onBehalfOf: by.onBehalfOf }, target: `${m.owner}/${m.name}#${number}`, detail, at: now() });
382 }
383}