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.

187 lines · 9.8 KBRaw History
1import { DatabaseSync } from "node:sqlite";
2import { describe, expect, it } from "vitest";
3import type { Env } from "../../src/env.js";
4import { HttpError } from "../../src/lib/util.js";
5import { AUTO_MERGE_BOT, AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule } from "../../src/repo/auto-merge.js";
6import type { RepoDO } from "../../src/repo/RepoDO.js";
7import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
8import type { ProtectionRules } from "../../src/repo/types.js";
9
10const person = { id: "u1", kind: "user" as const };
11const agent = { id: "a1", kind: "agent" as const };
12const job = { id: "job:j1", kind: "system" as const };
13const agentJob = { id: "job:j2", kind: "agent" as const };
14const refuse = (c: { id: string; kind: "user" | "agent" | "system" }, rules: ProtectionRules | null, isDefault = true) => automaticLandingRefusal(c, rules, isDefault, "main");
15
16describe("who may have Diffinery land a pull request for them", () => {
17 it("tells people from agents and workflow tokens", () => {
18 expect(isPerson(person)).toBe(true);
19 for (const c of [agent, job, agentJob]) expect(isPerson(c)).toBe(false);
20 // A job token is never a person, whatever kind it carries.
21 expect(isPerson({ id: "job:x", kind: "user" })).toBe(false);
22 });
23
24 it("always lets people", () => {
25 expect(refuse(person, null)).toBeNull();
26 expect(refuse(person, { requiredChecks: [] })).toBeNull();
27 });
28
29 it("lets agents and tokens on a branch that is neither default nor protected", () => {
30 for (const c of [agent, job, agentJob]) expect(refuse(c, null, false)).toBeNull();
31 });
32
33 it("refuses agents and tokens on the default branch or a protected one without allowAgentAutoMerge", () => {
34 for (const c of [agent, job, agentJob]) {
35 expect(refuse(c, null)?.status).toBe(403);
36 expect(refuse(c, { requiredChecks: ["ci"] }, false)?.status).toBe(403);
37 expect(refuse(c, { requiredChecks: ["ci"], allowAgentAutoMerge: false })?.message).toMatch(/allowAgentAutoMerge/);
38 }
39 });
40
41 it("needs at least one required check with the setting", () => {
42 for (const c of [agent, job]) {
43 expect(refuse(c, { allowAgentAutoMerge: true })?.status).toBe(422);
44 expect(refuse(c, { allowAgentAutoMerge: true, requiredChecks: [] })?.message).toMatch(/required status checks/);
45 expect(refuse(c, { allowAgentAutoMerge: true, requiredChecks: ["ci"] })).toBeNull();
46 }
47 });
48
49 it("keeps agentsMayMerge for agents only: workflow tokens are never looser than agents", () => {
50 expect(refuse(agent, { agentsMayMerge: true })).toBeNull();
51 expect(refuse(agentJob, { agentsMayMerge: true })).toBeNull();
52 expect(refuse(job, { agentsMayMerge: true })?.status).toBe(403);
53 expect(landsByAgentRule(agent, { agentsMayMerge: true, allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true)).toBe(false);
54 expect(landsByAgentRule(agent, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true)).toBe(true);
55 expect(landsByAgentRule(person, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true)).toBe(false);
56 expect(landsByAgentRule(agent, null, false)).toBe(false);
57 });
58});
59
60// ---------------------------------------------------------------- what auto-merge does when it tries
61
62/** A minimal SqlStorage over node:sqlite with the repository schema. */
63function sqlStorage(): SqlStorage {
64 const db = new DatabaseSync(":memory:");
65 db.exec(REPO_SCHEMA);
66 const sql = {
67 exec(query: string, ...bindings: unknown[]) {
68 const stmt = db.prepare(query);
69 if (/^\s*select/i.test(query)) {
70 const rows = stmt.all(...(bindings as never[]));
71 return { toArray: () => rows, one: () => rows[0] } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
72 }
73 stmt.run(...(bindings as never[]));
74 return { toArray: () => [], one: () => undefined } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
75 },
76 } as unknown as SqlStorage;
77 migrateRepoSchema(sql);
78 return sql;
79}
80
81/**
82 * Auto-merge over a fake repository: pull request #1 from `topic` into `base`, ready to merge, with
83 * auto-merge turned on by a workflow token (whose access needs no directory lookup).
84 */
85function harness(o: { rules?: ProtectionRules | null; archived?: boolean; deleteBranch?: boolean; enqueue?: () => Promise<unknown> } = {}) {
86 const sql = sqlStorage();
87 const at = new Date().toISOString();
88 sql.exec("INSERT INTO issues (number, kind, title, author_id, author_login, created_at, updated_at) VALUES (1, 'pull', 'T', 'u1', 'olive', ?, ?)", at, at);
89 const state = { method: "squash", commitTitle: null, commitMessage: null, enabledBy: { id: "job:j1", login: "github-actions[bot]", kind: "system" }, enabledAt: at, role: "write", permissions: { contents: "write" }, ...(o.deleteBranch ? { deleteBranch: true } : {}) };
90 sql.exec("INSERT INTO pulls (number, head_ref, head_sha, base_ref, auto_merge) VALUES (1, 'topic', 'h1', 'base', ?)", JSON.stringify(state));
91 const comments: Array<{ body: string; by: string }> = [];
92 const events: Array<{ type: string; action?: string; detail?: Record<string, unknown> }> = [];
93 const merges: Array<Record<string, unknown>> = [];
94 const enqueues: Array<Record<string, unknown>> = [];
95 const repo = {
96 sql,
97 meta: { repoId: "r1", ownerId: "u1", owner: "olive", name: "n", defaultBranch: "main", ownerKind: "user", ...(o.archived !== undefined ? { archived: o.archived } : {}) },
98 isArchived: async () => !!o.archived,
99 protectionFor: () => o.rules ?? null,
100 collab: {
101 get: () => ({ issue: { number: 1, state: "open", pull: { merged: false, draft: false, base: "base", head: "topic", headSha: "h1" } } }),
102 mergeability: async () => ({ mergeable: true, blockers: [] }),
103 merge: async (_n: number, input: Record<string, unknown>) => {
104 merges.push(input);
105 sql.exec("UPDATE pulls SET merged = 1, auto_merge = NULL WHERE number = 1");
106 return { merged: true, sha: "m1", method: "squash" };
107 },
108 addComment: (_n: number, body: string, by: { login: string }) => comments.push({ body, by: by.login }),
109 changedWorkflow: async () => null,
110 },
111 queue: {
112 isQueued: () => false,
113 enqueue: async (_n: number, input: Record<string, unknown>) => {
114 enqueues.push(input);
115 return o.enqueue ? o.enqueue() : { state: "queued", entry: null };
116 },
117 },
118 social: { record: () => undefined },
119 emit: (e: { type: string; action?: string; detail?: Record<string, unknown> }) => events.push(e),
120 broadcast: () => undefined,
121 background: () => undefined,
122 };
123 const am = new AutoMerge(repo as unknown as RepoDO, {} as Env);
124 return { am, comments, events, merges, enqueues, on: () => am.state(1) !== null };
125}
126
127const QUEUED: ProtectionRules = { requiredChecks: ["ci"], allowAgentAutoMerge: true, mergeQueue: { method: "squash" } };
128
129describe("auto-merge at work", () => {
130 it("merges a ready pull request", async () => {
131 const h = harness();
132 await h.am.process({ number: 1 });
133 expect(h.merges).toHaveLength(1);
134 expect(h.on()).toBe(false);
135 });
136
137 it("never merges, or queues, in an archived repository: it turns itself off and says why", async () => {
138 for (const rules of [null, QUEUED]) {
139 const h = harness({ archived: true, rules });
140 await h.am.process({ number: 1 });
141 expect(h.merges).toHaveLength(0);
142 expect(h.enqueues).toHaveLength(0);
143 expect(h.on()).toBe(false);
144 expect(h.comments).toEqual([{ body: "Auto-merge was turned off: the repository is archived.", by: AUTO_MERGE_BOT.login }]);
145 }
146 });
147
148 it("can't be turned on in an archived repository", async () => {
149 const h = harness({ archived: true });
150 h.am.clear(1);
151 await expect(h.am.enable(1, {}, { id: "u1", login: "olive", kind: "user", role: "admin" })).rejects.toMatchObject({ status: 403 });
152 expect(h.on()).toBe(false);
153 });
154
155 it("turns itself off, with a comment, when the merge queue refuses the pull request", async () => {
156 const h = harness({ rules: QUEUED, enqueue: () => Promise.reject(new HttpError(405, "pull request cannot be added to the merge queue: required check 'ci' failed")) });
157 await h.am.process({ number: 1 });
158 expect(h.enqueues).toHaveLength(1);
159 expect(h.on()).toBe(false);
160 expect(h.comments).toEqual([{ body: "Auto-merge was turned off: pull request cannot be added to the merge queue: required check 'ci' failed.", by: AUTO_MERGE_BOT.login }]);
161 expect(h.events.find((e) => e.action === "pull_request.auto_merge_disabled")?.detail?.reason).toMatch(/cannot be added to the merge queue/);
162 // Anything else the queue throws turns it off too, rather than retrying on every event in silence.
163 const odd = harness({ rules: QUEUED, enqueue: () => Promise.reject(new Error("boom")) });
164 await odd.am.process({ number: 1 });
165 expect(odd.on()).toBe(false);
166 expect(odd.comments[0]?.body).toBe("Auto-merge was turned off: the pull request could not be added to the merge queue.");
167 });
168
169 it("leaves auto-merge on when the head moved under it (409): the next event looks again", async () => {
170 const h = harness({ rules: QUEUED, enqueue: () => Promise.reject(new HttpError(409, "head branch was modified")) });
171 await h.am.process({ number: 1 });
172 expect(h.on()).toBe(true);
173 expect(h.comments).toHaveLength(0);
174 });
175
176 it("deletes the head branch after it lands when asked to, directly or through the queue", async () => {
177 const direct = harness({ deleteBranch: true });
178 await direct.am.process({ number: 1 });
179 expect(direct.merges[0]).toMatchObject({ deleteBranch: true });
180 const queued = harness({ deleteBranch: true, rules: QUEUED });
181 await queued.am.process({ number: 1 });
182 expect(queued.enqueues[0]).toMatchObject({ deleteBranch: true });
183 const kept = harness({ rules: QUEUED });
184 await kept.am.process({ number: 1 });
185 expect(kept.enqueues[0]).toMatchObject({ deleteBranch: false });
186 });
187});