| 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 } 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" }, 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 | // ---------------------------------------------------------------- what auto-merge does when it tries |
| 61 | |
| 62 | /** A minimal SqlStorage over node:sqlite with the repository schema. */ |
| 63 | function 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 | */ |
| 85 | function 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 | |
| 127 | const QUEUED: ProtectionRules = { requiredChecks: ["ci"], allowAgentAutoMerge: true, mergeQueue: { method: "squash" } }; |
| 128 | |
| 129 | describe("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 | }); |