diff --git a/src/settings/agent-execution.ts b/src/settings/agent-execution.ts index d08a083477..110f441ec5 100644 --- a/src/settings/agent-execution.ts +++ b/src/settings/agent-execution.ts @@ -1,4 +1,9 @@ -import type { AgentActionClass, AuditEventRecord, AutonomyLevel } from "../types"; +import type { AgentActionClass, AuditEventRecord, AutonomyLevel, AutonomyPolicy } from "../types"; +import { isActingAutonomyLevel, resolveAutonomy } from "./autonomy"; + +// The action classes that mutate a PR's review / merge / close state — these need GitHub `pull_requests: write`. +// (`label` mutates via the Issues API, which the App already holds `issues: write` for.) +const PR_WRITE_ACTION_CLASSES: readonly AgentActionClass[] = ["review", "request_changes", "approve", "merge", "close"]; // Whether the agent actually executes an action, only logs what it WOULD do, or is halted entirely (#776). export type AgentActionMode = "paused" | "dry_run" | "live"; @@ -57,3 +62,24 @@ export function buildAgentActionAudit(input: { }, }; } + +/** + * True when the repo's autonomy config has any ACTING level (auto / auto_with_approval) for a PR-write action + * class — i.e. the agent would need GitHub `pull_requests: write` to carry it out (#775). Pure. + */ +export function agentRequiresPrWrite(autonomy: AutonomyPolicy | null | undefined): boolean { + return PR_WRITE_ACTION_CLASSES.some((actionClass) => isActingAutonomyLevel(resolveAutonomy(autonomy, actionClass))); +} + +export type AgentPermissionReadiness = "not_required" | "ready" | "reconsent_required"; + +/** + * Whether the installation grants the write scope the configured auto-maintain actions need (#775). The action + * layer (#778) consults this before executing a PR-write action: `not_required` = no acting PR-write level is + * configured; `ready` = the App holds `pull_requests: write`; `reconsent_required` = the maintainer must + * re-authorize the App with the upgraded permission. Pure. + */ +export function resolveAgentPermissionReadiness(input: { autonomy: AutonomyPolicy | null | undefined; installationPermissions: Record | null | undefined }): AgentPermissionReadiness { + if (!agentRequiresPrWrite(input.autonomy)) return "not_required"; + return input.installationPermissions?.pull_requests === "write" ? "ready" : "reconsent_required"; +} diff --git a/test/unit/agent-execution.test.ts b/test/unit/agent-execution.test.ts index 79f0857d35..3635e561b1 100644 --- a/test/unit/agent-execution.test.ts +++ b/test/unit/agent-execution.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { agentActionModeExecutes, + agentRequiresPrWrite, buildAgentActionAudit, isGlobalAgentPause, resolveAgentActionMode, + resolveAgentPermissionReadiness, } from "../../src/settings/agent-execution"; describe("resolveAgentActionMode (#776 safety gate)", () => { @@ -69,3 +71,29 @@ describe("buildAgentActionAudit", () => { expect(audit.detail).toBeNull(); }); }); + +describe("agent write-permission readiness (#775)", () => { + it("agentRequiresPrWrite is true only for an acting level on a PR-write action class", () => { + expect(agentRequiresPrWrite({ merge: "auto" })).toBe(true); + expect(agentRequiresPrWrite({ request_changes: "auto_with_approval" })).toBe(true); + expect(agentRequiresPrWrite({ close: "auto" })).toBe(true); + // non-acting levels never demand write + expect(agentRequiresPrWrite({ merge: "propose", review: "suggest" })).toBe(false); + expect(agentRequiresPrWrite({ merge: "observe" })).toBe(false); + expect(agentRequiresPrWrite({})).toBe(false); + expect(agentRequiresPrWrite(null)).toBe(false); + // label acts via the Issues API (issues: write, already held), so it does NOT demand pull_requests: write + expect(agentRequiresPrWrite({ label: "auto" })).toBe(false); + }); + + it("resolveAgentPermissionReadiness gates on the granted pull_requests scope", () => { + // no acting PR-write level → permission is irrelevant + expect(resolveAgentPermissionReadiness({ autonomy: { label: "auto" }, installationPermissions: { pull_requests: "read" } })).toBe("not_required"); + // acting level + write granted → ready + expect(resolveAgentPermissionReadiness({ autonomy: { merge: "auto" }, installationPermissions: { pull_requests: "write", issues: "write" } })).toBe("ready"); + // acting level but only read (or missing) → re-consent required + expect(resolveAgentPermissionReadiness({ autonomy: { merge: "auto" }, installationPermissions: { pull_requests: "read" } })).toBe("reconsent_required"); + expect(resolveAgentPermissionReadiness({ autonomy: { merge: "auto" }, installationPermissions: {} })).toBe("reconsent_required"); + expect(resolveAgentPermissionReadiness({ autonomy: { merge: "auto" }, installationPermissions: null })).toBe("reconsent_required"); + }); +});