| 1 | import type { Actor } from "@diffinery/shared"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { agentMayMerge } from "../lib/agent-setup.js"; |
| 4 | import { Directory } from "../lib/directory.js"; |
| 5 | import { HttpError, now } from "../lib/util.js"; |
| 6 | import type { RepoDO } from "./RepoDO.js"; |
| 7 | import { isJobCaller } from "./token-policy.js"; |
| 8 | import { 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 | |
| 23 | export type MergeMethod = "merge" | "squash" | "rebase"; |
| 24 | |
| 25 | /** Stored on the pull request (pulls.auto_merge) while auto-merge is on. */ |
| 26 | export 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. */ |
| 42 | export type AutoMergeInfo = Pick<AutoMergeState, "method" | "commitTitle" | "commitMessage" | "enabledBy" | "enabledAt" | "byRule"> & { deleteBranch: boolean }; |
| 43 | |
| 44 | export 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. */ |
| 47 | export const ARCHIVED_REASON = "the repository is archived"; |
| 48 | |
| 49 | /** Comments about auto-merge the server wrote on its own (it was turned off because ...). */ |
| 50 | export const AUTO_MERGE_BOT: Caller = { id: "system:auto-merge", login: "diffinery-auto-merge[bot]", kind: "system", role: "admin" }; |
| 51 | |
| 52 | const METHODS: readonly MergeMethod[] = ["merge", "squash", "rebase"]; |
| 53 | |
| 54 | /** A person: not an agent, not a workflow's GITHUB_TOKEN, not the system. */ |
| 55 | export 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 | */ |
| 68 | export 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 | */ |
| 90 | export 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). */ |
| 100 | export 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 | |
| 104 | const minRole = (a: Role, b: Role): Role => (ROLE_LEVEL[a] <= ROLE_LEVEL[b] ? a : b); |
| 105 | |
| 106 | type Filter = { number?: number; sha?: string; base?: string }; |
| 107 | |
| 108 | export 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 | } |