Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/settings/agent-execution.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, string> | null | undefined }): AgentPermissionReadiness {
if (!agentRequiresPrWrite(input.autonomy)) return "not_required";
return input.installationPermissions?.pull_requests === "write" ? "ready" : "reconsent_required";
}
28 changes: 28 additions & 0 deletions test/unit/agent-execution.test.ts
Original file line number Diff line number Diff line change
@@ -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)", () => {
Expand Down Expand Up @@ -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");
});
});
Loading