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.

464 lines · 25.9 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, mergesByAgentPermission } 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"; mayMerge?: boolean }, 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
60describe("an agent that may merge (Agent setup's Can merge pull requests)", () => {
61 const setupAgent = { ...agent, mayMerge: true };
62 it("lands on the default branch and protected ones: approvals and checks are left to mergeability", () => {
63 expect(refuse(setupAgent, null)).toBeNull();
64 expect(refuse(setupAgent, { requiredChecks: ["ci"] }, false)).toBeNull();
65 expect(mergesByAgentPermission(setupAgent, null, true, false)).toBe(true);
66 expect(mergesByAgentPermission(setupAgent, { requiredApprovals: 2 }, false, false)).toBe(true);
67 });
68
69 it("is never a workflow token, a person, or an agent without the permission", () => {
70 for (const c of [agent, person, { ...job, mayMerge: true }, { ...agentJob, mayMerge: true }]) expect(mergesByAgentPermission(c, null, true, false)).toBe(false);
71 expect(refuse({ ...agentJob, mayMerge: true }, null)?.status).toBe(403);
72 });
73
74 it("leaves the branch's own agent rules to their own path", () => {
75 // agentsMayMerge keeps its approval waiver; allowAgentAutoMerge with required checks keeps landsByAgentRule.
76 expect(mergesByAgentPermission(setupAgent, { agentsMayMerge: true }, true, false)).toBe(false);
77 expect(mergesByAgentPermission(setupAgent, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true, true)).toBe(false);
78 expect(mergesByAgentPermission(setupAgent, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true, false)).toBe(true);
79 });
80
81 it("has nothing to do on an unprotected branch that isn't the default", () => {
82 expect(mergesByAgentPermission(setupAgent, null, false, false)).toBe(false);
83 });
84});
85
86// ---------------------------------------------------------------- what auto-merge does when it tries
87
88/** A minimal SqlStorage over node:sqlite with the repository schema. */
89function sqlStorage(): SqlStorage {
90 const db = new DatabaseSync(":memory:");
91 db.exec(REPO_SCHEMA);
92 const sql = {
93 exec(query: string, ...bindings: unknown[]) {
94 const stmt = db.prepare(query);
95 if (/^\s*select/i.test(query)) {
96 const rows = stmt.all(...(bindings as never[]));
97 return { toArray: () => rows, one: () => rows[0] } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
98 }
99 stmt.run(...(bindings as never[]));
100 return { toArray: () => [], one: () => undefined } as unknown as SqlStorageCursor<Record<string, SqlStorageValue>>;
101 },
102 } as unknown as SqlStorage;
103 migrateRepoSchema(sql);
104 return sql;
105}
106
107/**
108 * Auto-merge over a fake repository: pull request #1 from `topic` into `base`, ready to merge, with
109 * auto-merge turned on by a workflow token (whose access needs no directory lookup).
110 */
111function harness(o: { rules?: ProtectionRules | null; archived?: boolean; deleteBranch?: boolean; enqueue?: () => Promise<unknown> } = {}) {
112 const sql = sqlStorage();
113 const at = new Date().toISOString();
114 sql.exec("INSERT INTO issues (number, kind, title, author_id, author_login, created_at, updated_at) VALUES (1, 'pull', 'T', 'u1', 'olive', ?, ?)", at, at);
115 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 } : {}) };
116 sql.exec("INSERT INTO pulls (number, head_ref, head_sha, base_ref, auto_merge) VALUES (1, 'topic', 'h1', 'base', ?)", JSON.stringify(state));
117 const comments: Array<{ body: string; by: string }> = [];
118 const events: Array<{ type: string; action?: string; detail?: Record<string, unknown> }> = [];
119 const merges: Array<Record<string, unknown>> = [];
120 const enqueues: Array<Record<string, unknown>> = [];
121 const repo = {
122 sql,
123 meta: { repoId: "r1", ownerId: "u1", owner: "olive", name: "n", defaultBranch: "main", ownerKind: "user", ...(o.archived !== undefined ? { archived: o.archived } : {}) },
124 isArchived: async () => !!o.archived,
125 protectionFor: () => o.rules ?? null,
126 collab: {
127 get: () => ({ issue: { number: 1, state: "open", pull: { merged: false, draft: false, base: "base", head: "topic", headSha: "h1" } } }),
128 mergeability: async () => ({ mergeable: true, blockers: [] }),
129 merge: async (_n: number, input: Record<string, unknown>) => {
130 merges.push(input);
131 sql.exec("UPDATE pulls SET merged = 1, auto_merge = NULL WHERE number = 1");
132 return { merged: true, sha: "m1", method: "squash" };
133 },
134 addComment: (_n: number, body: string, by: { login: string }) => comments.push({ body, by: by.login }),
135 changedWorkflow: async () => null,
136 },
137 queue: {
138 isQueued: () => false,
139 enqueue: async (_n: number, input: Record<string, unknown>) => {
140 enqueues.push(input);
141 return o.enqueue ? o.enqueue() : { state: "queued", entry: null };
142 },
143 },
144 social: { record: () => undefined },
145 emit: (e: { type: string; action?: string; detail?: Record<string, unknown> }) => events.push(e),
146 broadcast: () => undefined,
147 background: () => undefined,
148 };
149 const am = new AutoMerge(repo as unknown as RepoDO, {} as Env);
150 return { am, comments, events, merges, enqueues, on: () => am.state(1) !== null };
151}
152
153const QUEUED: ProtectionRules = { requiredChecks: ["ci"], allowAgentAutoMerge: true, mergeQueue: { method: "squash" } };
154
155describe("auto-merge at work", () => {
156 it("merges a ready pull request", async () => {
157 const h = harness();
158 await h.am.process({ number: 1 });
159 expect(h.merges).toHaveLength(1);
160 expect(h.on()).toBe(false);
161 });
162
163 it("never merges, or queues, in an archived repository: it turns itself off and says why", async () => {
164 for (const rules of [null, QUEUED]) {
165 const h = harness({ archived: true, rules });
166 await h.am.process({ number: 1 });
167 expect(h.merges).toHaveLength(0);
168 expect(h.enqueues).toHaveLength(0);
169 expect(h.on()).toBe(false);
170 expect(h.comments).toEqual([{ body: "Auto-merge was turned off: the repository is archived.", by: AUTO_MERGE_BOT.login }]);
171 }
172 });
173
174 it("can't be turned on in an archived repository", async () => {
175 const h = harness({ archived: true });
176 h.am.clear(1);
177 await expect(h.am.enable(1, {}, { id: "u1", login: "olive", kind: "user", role: "admin" })).rejects.toMatchObject({ status: 403 });
178 expect(h.on()).toBe(false);
179 });
180
181 it("turns itself off, with a comment, when the merge queue refuses the pull request", async () => {
182 const h = harness({ rules: QUEUED, enqueue: () => Promise.reject(new HttpError(405, "pull request cannot be added to the merge queue: required check 'ci' failed")) });
183 await h.am.process({ number: 1 });
184 expect(h.enqueues).toHaveLength(1);
185 expect(h.on()).toBe(false);
186 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 }]);
187 expect(h.events.find((e) => e.action === "pull_request.auto_merge_disabled")?.detail?.reason).toMatch(/cannot be added to the merge queue/);
188 // Anything else the queue throws turns it off too, rather than retrying on every event in silence.
189 const odd = harness({ rules: QUEUED, enqueue: () => Promise.reject(new Error("boom")) });
190 await odd.am.process({ number: 1 });
191 expect(odd.on()).toBe(false);
192 expect(odd.comments[0]?.body).toBe("Auto-merge was turned off: the pull request could not be added to the merge queue.");
193 });
194
195 it("leaves auto-merge on when the head moved under it (409): the next event looks again", async () => {
196 const h = harness({ rules: QUEUED, enqueue: () => Promise.reject(new HttpError(409, "head branch was modified")) });
197 await h.am.process({ number: 1 });
198 expect(h.on()).toBe(true);
199 expect(h.comments).toHaveLength(0);
200 });
201
202 it("deletes the head branch after it lands when asked to, directly or through the queue", async () => {
203 const direct = harness({ deleteBranch: true });
204 await direct.am.process({ number: 1 });
205 expect(direct.merges[0]).toMatchObject({ deleteBranch: true });
206 const queued = harness({ deleteBranch: true, rules: QUEUED });
207 await queued.am.process({ number: 1 });
208 expect(queued.enqueues[0]).toMatchObject({ deleteBranch: true });
209 const kept = harness({ rules: QUEUED });
210 await kept.am.process({ number: 1 });
211 expect(kept.enqueues[0]).toMatchObject({ deleteBranch: false });
212 });
213});
214
215// ---------------------------------------------------------------- a branch rule's autoMergeAgents
216
217/** Pull request #1 by agent a1 into main, with auto-merge off, and a rule for main; the agent has the write role. */
218function ruleHarness(o: { rules?: ProtectionRules; authorKind?: string; draft?: boolean; workflow?: string; role?: "read" | "write" } = {}) {
219 const sql = sqlStorage();
220 const at = new Date().toISOString();
221 sql.exec("INSERT INTO issues (number, kind, title, author_id, author_login, author_kind, created_at, updated_at) VALUES (1, 'pull', 'T', 'a1', 'olive-bot', ?, ?, ?)", o.authorKind ?? "agent", at, at);
222 sql.exec("INSERT INTO pulls (number, head_ref, head_sha, base_ref, draft) VALUES (1, 'topic', 'h1', 'main', ?)", o.draft ? 1 : 0);
223 const rules: ProtectionRules = o.rules ?? { requiredChecks: ["ci"], allowAgentAutoMerge: true, autoMergeAgents: [{ id: "a1", login: "olive-bot" }], autoMergeMethod: "squash" };
224 const comments: Array<{ body: string; by: string }> = [];
225 const events: Array<{ type: string; action?: string; actor?: { login: string }; detail?: Record<string, unknown> }> = [];
226 const repo = {
227 sql,
228 meta: { repoId: "r1", ownerId: "u1", owner: "olive", name: "n", defaultBranch: "main", ownerKind: "user", archived: false },
229 isArchived: async () => false,
230 protectionFor: () => rules,
231 protectionRuleFor: () => ({ pattern: "main", rules }),
232 collab: {
233 get: () => ({ issue: { number: 1, state: "open", pull: { merged: false, draft: !!o.draft, base: "main", head: "topic", headSha: "h1" } } }),
234 mergeability: async () => ({ mergeable: false, blockers: ["required check 'ci' has not run"] }),
235 addComment: (_n: number, body: string, by: { login: string }) => comments.push({ body, by: by.login }),
236 changedWorkflow: async () => o.workflow ?? null,
237 },
238 queue: { isQueued: () => false },
239 social: {
240 record: (n: number, event: string, actor: { id: string; login: string; kind: string }) =>
241 sql.exec("INSERT INTO issue_events (number, event, actor_id, actor_login, actor_kind, detail, created_at) VALUES (?, ?, ?, ?, ?, '{}', ?)", n, event, actor.id, actor.login, actor.kind, at),
242 },
243 emit: (e: (typeof events)[number]) => events.push(e),
244 broadcast: () => undefined,
245 background: () => undefined,
246 };
247 const am = new AutoMerge(repo as unknown as RepoDO, {} as Env);
248 // The agent's access, as the directory would give it.
249 (am as unknown as { accountCaller: (id: string) => Promise<unknown> }).accountCaller = async (id: string) => (id === "a1" ? { id: "a1", login: "olive-bot", kind: "agent", role: o.role ?? "write" } : null);
250 return { am, comments, events, sql };
251}
252
253describe("a branch rule that auto-merges chosen agents' pull requests", () => {
254 it("turns auto-merge on as the agent, recorded as the server's doing", async () => {
255 const h = ruleHarness();
256 await h.am.applyRule(1, true);
257 const s = h.am.state(1);
258 expect(s).toMatchObject({ method: "squash", byRule: "main", enabledBy: { id: "a1", login: "olive-bot", kind: "agent" }, role: "write" });
259 const audit = h.events.find((e) => e.action === "pull_request.auto_merge_enabled");
260 expect(audit?.actor?.login).toBe(AUTO_MERGE_BOT.login);
261 expect(audit?.detail).toMatchObject({ byRule: "main", agent: "olive-bot", viaAgentRule: true });
262 expect(AutoMerge.info(s)?.byRule).toBe("main");
263 });
264
265 it("leaves other agents, people, drafts and rules without allowAgentAutoMerge alone", async () => {
266 for (const h of [
267 ruleHarness({ rules: { requiredChecks: ["ci"], allowAgentAutoMerge: true, autoMergeAgents: [{ id: "a2", login: "other-bot" }] } }),
268 ruleHarness({ authorKind: "user" }),
269 ruleHarness({ draft: true }),
270 ruleHarness({ rules: { requiredChecks: ["ci"], autoMergeAgents: [{ id: "a1", login: "olive-bot" }] } }),
271 ruleHarness({ rules: { requiredChecks: ["ci"], allowAgentAutoMerge: true } }),
272 ]) {
273 await h.am.applyRule(1, true);
274 expect(h.am.state(1)).toBeNull();
275 expect(h.comments).toHaveLength(0);
276 }
277 });
278
279 it("never turns it back on once it was turned off", async () => {
280 const h = ruleHarness();
281 await h.am.applyRule(1, false);
282 h.am.disable(1, { id: "u1", login: "olive", kind: "user", role: "admin" });
283 expect(h.am.state(1)).toBeNull();
284 await h.am.applyRule(1, true);
285 expect(h.am.state(1)).toBeNull();
286 });
287
288 it("keeps every refusal of the agent's own auto-merge, and says why when asked to", async () => {
289 const wf = ruleHarness({ workflow: ".github/workflows/ci.yml" });
290 await wf.am.applyRule(1, true);
291 expect(wf.am.state(1)).toBeNull();
292 expect(wf.comments[0]?.body).toMatch(/^Auto-merge didn't turn on by itself \(branch rule `main` auto-merges olive-bot's pull requests\): the pull request changes workflow file `\.github\/workflows\/ci\.yml`/);
293 const quiet = ruleHarness({ workflow: ".github/workflows/ci.yml" });
294 await quiet.am.applyRule(1, false);
295 expect(quiet.comments).toHaveLength(0);
296 // No required check: allowAgentAutoMerge refuses, so the rule does too.
297 const unchecked = ruleHarness({ rules: { allowAgentAutoMerge: true, autoMergeAgents: [{ id: "a1", login: "olive-bot" }] } });
298 await unchecked.am.applyRule(1, true);
299 expect(unchecked.am.state(1)).toBeNull();
300 expect(unchecked.comments[0]?.body).toMatch(/no required status checks/);
301 // An agent that lost write access.
302 const reader = ruleHarness({ role: "read" });
303 await reader.am.applyRule(1, true);
304 expect(reader.am.state(1)).toBeNull();
305 expect(reader.comments[0]?.body).toMatch(/write access is required/);
306 });
307});
308
309// ---------------------------------------------------------------- an account's own "auto-merge my pull requests"
310
311/**
312 * Pull request #1 into main, opened by `author` (a person, u1, by default), with auto-merge off. `settings` is
313 * each account's accounts.auto_merge, as D1 would give it.
314 */
315function ownHarness(o: { rules?: ProtectionRules | null; author?: { id: string; login: string; kind: string }; draft?: boolean; workflow?: string; settings?: Record<string, string | null> } = {}) {
316 const sql = sqlStorage();
317 const at = new Date().toISOString();
318 const author = o.author ?? { id: "u1", login: "olive", kind: "user" };
319 sql.exec("INSERT INTO issues (number, kind, title, author_id, author_login, author_kind, created_at, updated_at) VALUES (1, 'pull', 'T', ?, ?, ?, ?, ?)", author.id, author.login, author.kind, at, at);
320 sql.exec("INSERT INTO pulls (number, head_ref, head_sha, base_ref, draft) VALUES (1, 'topic', 'h1', 'main', ?)", o.draft ? 1 : 0);
321 const rules = o.rules === undefined ? ({ requiredChecks: ["ci"] } as ProtectionRules) : o.rules;
322 const comments: Array<{ body: string; by: string }> = [];
323 const events: Array<{ type: string; action?: string; actor?: { login: string }; detail?: Record<string, unknown> }> = [];
324 const lookups: string[] = [];
325 const settings = o.settings ?? { u1: "default" };
326 const env = {
327 DB: {
328 prepare: () => ({
329 bind: (id: string) => ({
330 first: async () => {
331 lookups.push(id);
332 return id in settings ? { id, auto_merge: settings[id] } : null;
333 },
334 }),
335 }),
336 },
337 } as unknown as Env;
338 const repo = {
339 sql,
340 meta: { repoId: "r1", ownerId: "u1", owner: "olive", name: "n", defaultBranch: "main", ownerKind: "user", archived: false },
341 isArchived: async () => false,
342 protectionFor: () => rules,
343 protectionRuleFor: () => (rules ? { pattern: "main", rules } : null),
344 collab: {
345 get: () => ({ issue: { number: 1, state: "open", pull: { merged: false, draft: !!o.draft, base: "main", head: "topic", headSha: "h1" } } }),
346 mergeability: async () => ({ mergeable: false, blockers: ["required check 'ci' has not run"] }),
347 addComment: (_n: number, body: string, by: { login: string }) => comments.push({ body, by: by.login }),
348 changedWorkflow: async () => o.workflow ?? null,
349 },
350 queue: { isQueued: () => false },
351 social: {
352 record: (n: number, event: string, actor: { id: string; login: string; kind: string }) =>
353 sql.exec("INSERT INTO issue_events (number, event, actor_id, actor_login, actor_kind, detail, created_at) VALUES (?, ?, ?, ?, ?, '{}', ?)", n, event, actor.id, actor.login, actor.kind, at),
354 },
355 emit: (e: (typeof events)[number]) => events.push(e),
356 broadcast: () => undefined,
357 background: () => undefined,
358 };
359 const am = new AutoMerge(repo as unknown as RepoDO, env);
360 (am as unknown as { accountCaller: (id: string) => Promise<unknown> }).accountCaller = async (id: string) => (id === "a1" ? { id: "a1", login: "olive-bot", kind: "agent", role: "write" } : null);
361 return { am, comments, events, lookups };
362}
363
364const olive = { id: "u1", login: "olive", kind: "user" as const, role: "write" as const };
365const oliveBot = { id: "a1", login: "olive-bot", kind: "agent" as const, role: "write" as const };
366const botAuthor = { id: "a1", login: "olive-bot", kind: "agent" };
367
368describe("auto-merge for the pull requests an account opens (its own setting)", () => {
369 it("turns on as the person who opened it, recorded as their setting, and waits for checks in the background", async () => {
370 const h = ownHarness();
371 expect(await h.am.opened(1, olive)).toBeNull();
372 const s = h.am.state(1);
373 expect(s).toMatchObject({ method: "merge", byDefault: true, enabledBy: { id: "u1", login: "olive", kind: "user" }, role: "write" });
374 expect(AutoMerge.info(s)?.byDefault).toBe(true);
375 const audit = h.events.find((e) => e.action === "pull_request.auto_merge_enabled");
376 expect(audit?.actor?.login).toBe("olive");
377 expect(audit?.detail).toMatchObject({ byDefault: true });
378 });
379
380 it("uses the setting's merge method, or the branch's where it can't", async () => {
381 const squash = ownHarness({ settings: { u1: "squash" } });
382 await squash.am.opened(1, olive);
383 expect(squash.am.state(1)?.method).toBe("squash");
384 const linear = ownHarness({ settings: { u1: "merge" }, rules: { requiredChecks: ["ci"], requireLinearHistory: true } });
385 await linear.am.opened(1, olive);
386 expect(linear.am.state(1)?.method).toBe("squash");
387 });
388
389 it("only waits for CI: never on a branch without a required check, saying why when asked", async () => {
390 for (const rules of [null, { requiredApprovals: 1 }] as const) {
391 const h = ownHarness({ rules });
392 expect(await h.am.opened(1, olive)).toMatch(/main has no required status checks, so auto-merge would not wait for CI/);
393 expect(h.am.state(1)).toBeNull();
394 }
395 const asked = ownHarness({ rules: null, settings: {} });
396 expect(await asked.am.opened(1, olive, true)).toMatch(/no required status checks/);
397 expect(asked.am.state(1)).toBeNull();
398 });
399
400 it("does nothing without the setting, unless it was asked for on this pull request; and false keeps it off", async () => {
401 const none = ownHarness({ settings: { u1: null } });
402 expect(await none.am.opened(1, olive)).toBeNull();
403 expect(none.am.state(1)).toBeNull();
404 const asked = ownHarness({ settings: {} });
405 expect(await asked.am.opened(1, olive, true)).toBeNull();
406 expect(asked.am.state(1)).toMatchObject({ byDefault: true });
407 const declined = ownHarness();
408 expect(await declined.am.opened(1, olive, false)).toBeNull();
409 expect(declined.am.state(1)).toBeNull();
410 expect(declined.lookups).toHaveLength(0);
411 });
412
413 it("never acts for someone else, on a draft, or where auto-merge was turned off before", async () => {
414 const other = ownHarness({ settings: { u2: "default" } });
415 await other.am.applyOwn(1, { id: "u2", login: "pat", kind: "user", role: "admin" });
416 expect(other.am.state(1)).toBeNull();
417 const draft = ownHarness({ draft: true });
418 expect(await draft.am.applyOwn(1, olive)).toBeNull();
419 expect(await draft.am.applyOwn(1, olive, { requested: true })).toMatch(/draft/);
420 expect(draft.am.state(1)).toBeNull();
421 const off = ownHarness();
422 await off.am.applyOwn(1, olive);
423 off.am.disable(1, olive);
424 await off.am.applyOwn(1, olive);
425 expect(off.am.state(1)).toBeNull();
426 });
427
428 it("never looks a workflow token up as an account", async () => {
429 const h = ownHarness({ author: { id: "job:j1", login: "diffinery-actions[bot]", kind: "system" } });
430 await h.am.applyOwn(1, { id: "job:j1", login: "diffinery-actions[bot]", kind: "system", role: "write" });
431 expect(h.lookups).toHaveLength(0);
432 expect(h.am.state(1)).toBeNull();
433 });
434
435 it("holds an agent to its own auto-merge's rules, and tells it why once", async () => {
436 // main allows agents' auto-merge: it turns on.
437 const allowed = ownHarness({ author: botAuthor, settings: { a1: "default" }, rules: { requiredChecks: ["ci"], allowAgentAutoMerge: true } });
438 await allowed.am.opened(1, oliveBot);
439 expect(allowed.am.state(1)).toMatchObject({ byDefault: true, enabledBy: { login: "olive-bot", kind: "agent" } });
440 // It doesn't, and the agent can't merge on its own permission either: refused, with one comment.
441 const refused = ownHarness({ author: botAuthor, settings: { a1: "default" } });
442 expect(await refused.am.opened(1, oliveBot)).toMatch(/allowAgentAutoMerge/);
443 expect(refused.am.state(1)).toBeNull();
444 expect(refused.comments).toHaveLength(1);
445 expect(refused.comments[0]).toMatchObject({ by: AUTO_MERGE_BOT.login });
446 expect(refused.comments[0]!.body).toMatch(/^Auto-merge didn't turn on by itself \(olive-bot turns on auto-merge for its pull requests\): agents may not/);
447 // An Agent setup agent that may merge: it turns on, and lands once a person approved (mergeability's business).
448 const setup = ownHarness({ author: botAuthor, settings: { a1: "default" } });
449 await setup.am.opened(1, { ...oliveBot, mayMerge: true });
450 expect(setup.am.state(1)).not.toBeNull();
451 // A change to a workflow file needs a person, whatever the settings.
452 const wf = ownHarness({ author: botAuthor, settings: { a1: "default" }, workflow: ".github/workflows/ci.yml", rules: { requiredChecks: ["ci"], allowAgentAutoMerge: true, agentAutoMergeWithoutApproval: true } });
453 expect(await wf.am.opened(1, oliveBot)).toMatch(/workflow file/);
454 expect(wf.am.state(1)).toBeNull();
455 expect(wf.comments).toHaveLength(1);
456 });
457
458 it("leaves a branch rule naming the agent first, recorded as the rule's", async () => {
459 const h = ownHarness({ author: botAuthor, settings: { a1: "squash" }, rules: { requiredChecks: ["ci"], allowAgentAutoMerge: true, autoMergeAgents: [{ id: "a1", login: "olive-bot" }], autoMergeMethod: "rebase" } });
460 await h.am.opened(1, oliveBot);
461 expect(h.am.state(1)).toMatchObject({ byRule: "main", method: "rebase" });
462 expect(h.am.state(1)?.byDefault).toBeUndefined();
463 });
464});