| 1 | import { DatabaseSync } from "node:sqlite"; |
| 2 | import { describe, expect, it } from "vitest"; |
| 3 | import type { Env } from "../../src/env.js"; |
| 4 | import { HttpError } from "../../src/lib/util.js"; |
| 5 | import { AUTO_MERGE_BOT, AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, mergesByAgentPermission } from "../../src/repo/auto-merge.js"; |
| 6 | import type { RepoDO } from "../../src/repo/RepoDO.js"; |
| 7 | import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; |
| 8 | import type { ProtectionRules } from "../../src/repo/types.js"; |
| 9 | |
| 10 | const person = { id: "u1", kind: "user" as const }; |
| 11 | const agent = { id: "a1", kind: "agent" as const }; |
| 12 | const job = { id: "job:j1", kind: "system" as const }; |
| 13 | const agentJob = { id: "job:j2", kind: "agent" as const }; |
| 14 | const refuse = (c: { id: string; kind: "user" | "agent" | "system"; mayMerge?: boolean }, rules: ProtectionRules | null, isDefault = true) => automaticLandingRefusal(c, rules, isDefault, "main"); |
| 15 | |
| 16 | describe("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 | describe("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. */ |
| 89 | function 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 | */ |
| 111 | function 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 | |
| 153 | const QUEUED: ProtectionRules = { requiredChecks: ["ci"], allowAgentAutoMerge: true, mergeQueue: { method: "squash" } }; |
| 154 | |
| 155 | describe("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. */ |
| 218 | function 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 | |
| 253 | describe("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 | */ |
| 315 | function 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 | |
| 364 | const olive = { id: "u1", login: "olive", kind: "user" as const, role: "write" as const }; |
| 365 | const oliveBot = { id: "a1", login: "olive-bot", kind: "agent" as const, role: "write" as const }; |
| 366 | const botAuthor = { id: "a1", login: "olive-bot", kind: "agent" }; |
| 367 | |
| 368 | describe("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 | }); |