From cec8ae91c239b8e40e29381d5438eba9c1185377 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:00:06 -0700 Subject: [PATCH] feat(agent): maintainer write-actions layer (#778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 payoff: gittensory acts on a PR's STATE (label / request-changes / approve / merge / close) per the repo's autonomy config — never on source. Built on the Phase-0 gates; deny-toward-safety at every step. Layers: - src/github/pr-actions.ts — the GitHub write primitives (review / merge with a head-sha guard / close / comment). Thin installation-scoped REST wrappers. - src/settings/agent-actions.ts — planAgentMaintenanceActions: the PURE, conser- vative verdict→action mapping. Labels by bucket; requests changes on a blocking verdict / approves a passing one (never both, never re-posts the same state); merges only a clean, approved, passing PR; closes only clear noise (high slop / duplicate) on a non-passing verdict (never both merge and close). Ordered least→most irreversible. - src/services/agent-action-executor.ts — the gate stack each action runs before any GitHub call: pause (#776 kill-switch) → approval (auto_with_approval stages for the #779 queue) → write-permission readiness (#775) → mode (dry_run records the intent, only live mutates). Every path writes one agent.action. audit (#776); a failed mutation is recorded as error, never swallowed. - processors.ts maybeRunAgentMaintenance — the trigger: after the gate runs on a PR webhook, recompute the CANONICAL verdict (confirmed-contributor status + persisted slop score — gittensory never acts on a non-confirmed contributor's PR, same rule the gate uses to never block one), plan, and execute. Best-effort; never blocks the gate or public surface. Reuses the autonomy / autoMaintain / agentPaused / agentDryRun config from #773/#774/#776 — no new setting, migration, or config-as-code surface. The auto_with_approval queue UX + notification land in #779. Tests: planner (coherence rules, idempotency, requiresApproval), executor (live per-class + paused + global kill-switch + approval-staged + permission-denied + dry-run + error), primitives (each REST shape), and webhook-trigger integration (blocking verdict acts in dry-run; non-acting / non-confirmed / closed-PR no-op). New modules 100% covered; full suite green (2067). --- src/github/pr-actions.ts | 89 +++++++++ src/queue/processors.ts | 80 ++++++++ src/services/agent-action-executor.ts | 109 +++++++++++ src/settings/agent-actions.ts | 126 +++++++++++++ test/unit/agent-action-executor.test.ts | 126 +++++++++++++ test/unit/agent-actions.test.ts | 115 ++++++++++++ test/unit/github-pr-actions.test.ts | 94 ++++++++++ test/unit/queue.test.ts | 234 ++++++++++++++++++++++++ 8 files changed, 973 insertions(+) create mode 100644 src/github/pr-actions.ts create mode 100644 src/services/agent-action-executor.ts create mode 100644 src/settings/agent-actions.ts create mode 100644 test/unit/agent-action-executor.test.ts create mode 100644 test/unit/agent-actions.test.ts create mode 100644 test/unit/github-pr-actions.test.ts diff --git a/src/github/pr-actions.ts b/src/github/pr-actions.ts new file mode 100644 index 0000000000..2fe64a8031 --- /dev/null +++ b/src/github/pr-actions.ts @@ -0,0 +1,89 @@ +import { Octokit } from "@octokit/core"; +import { createInstallationToken } from "./app"; +import type { AutoMergeMethod } from "../types"; + +// The GitHub write primitives the maintainer auto-maintain layer (#778) uses to act on a PR's STATE — never +// its source. Thin wrappers over the installation-scoped REST API, mirroring labels.ts / comments.ts. Each +// throws on a non-2xx response; the action executor owns the try/catch + audit so a failed mutation is +// recorded, not swallowed. + +function splitRepo(repoFullName: string): { owner: string; repo: string } { + const [owner, repo] = repoFullName.split("/"); + if (!owner || !repo) throw new Error(`Invalid repository full name: ${repoFullName}`); + return { owner, repo }; +} + +export type PullRequestReviewEvent = "REQUEST_CHANGES" | "APPROVE" | "COMMENT"; + +/** Post a pull-request review (request-changes / approve / comment). `body` is required for REQUEST_CHANGES. */ +export async function createPullRequestReview( + env: Env, + installationId: number, + repoFullName: string, + pullNumber: number, + event: PullRequestReviewEvent, + body: string, +): Promise<{ id: number }> { + const { owner, repo } = splitRepo(repoFullName); + const token = await createInstallationToken(env, installationId); + const octokit = new Octokit({ auth: token }); + const response = await octokit.request("POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews", { + owner, + repo, + pull_number: pullNumber, + event, + body, + }); + return { id: (response.data as { id: number }).id }; +} + +/** Merge a pull request with the configured method. Pass `sha` to make the merge fail (409) if the head moved + * since we evaluated it — a guard against merging a PR that changed under us. */ +export async function mergePullRequest( + env: Env, + installationId: number, + repoFullName: string, + pullNumber: number, + options: { mergeMethod: AutoMergeMethod; sha?: string | undefined }, +): Promise<{ merged: boolean; sha: string | null }> { + const { owner, repo } = splitRepo(repoFullName); + const token = await createInstallationToken(env, installationId); + const octokit = new Octokit({ auth: token }); + const response = await octokit.request("PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge", { + owner, + repo, + pull_number: pullNumber, + merge_method: options.mergeMethod, + ...(options.sha ? { sha: options.sha } : {}), + }); + const data = response.data as { merged?: boolean; sha?: string }; + return { merged: data.merged ?? true, sha: data.sha ?? null }; +} + +/** Post a plain issue/PR comment (used for the templated close message before closing). */ +export async function createIssueComment(env: Env, installationId: number, repoFullName: string, issueNumber: number, body: string): Promise<{ id: number }> { + const { owner, repo } = splitRepo(repoFullName); + const token = await createInstallationToken(env, installationId); + const octokit = new Octokit({ auth: token }); + const response = await octokit.request("POST /repos/{owner}/{repo}/issues/{issue_number}/comments", { + owner, + repo, + issue_number: issueNumber, + body, + }); + return { id: (response.data as { id: number }).id }; +} + +/** Close a pull request (sets state=closed) without merging. */ +export async function closePullRequest(env: Env, installationId: number, repoFullName: string, pullNumber: number): Promise<{ state: string }> { + const { owner, repo } = splitRepo(repoFullName); + const token = await createInstallationToken(env, installationId); + const octokit = new Octokit({ auth: token }); + const response = await octokit.request("PATCH /repos/{owner}/{repo}/pulls/{pull_number}", { + owner, + repo, + pull_number: pullNumber, + state: "closed", + }); + return { state: (response.data as { state: string }).state }; +} diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 6656e9b1c3..657273b14b 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -99,6 +99,8 @@ import { commandAuthorizationAllowedRoles, commandAuthorizationNeedsMinerDetecti import { isAgentConfigured } from "../settings/autonomy"; import { isGlobalAgentPause, resolveAgentActionMode } from "../settings/agent-execution"; import { selectRegateCandidates } from "../settings/agent-sweep"; +import { planAgentMaintenanceActions } from "../settings/agent-actions"; +import { executeAgentMaintenanceActions } from "../services/agent-action-executor"; import { loadIssueQualityReportMap } from "../services/issue-quality"; import { generateWeeklyValueReport } from "../services/weekly-value-report"; import { REPO_OUTCOME_PATTERNS_SIGNAL, computeRepoOutcomePatterns } from "../services/repo-outcome-patterns"; @@ -414,6 +416,77 @@ async function sweepRepoRegate(env: Env, repoFullName: string | undefined): Prom }); } +/** + * #778 maintainer auto-maintain trigger. After the gate runs on a PR webhook, if the repo opted the agent in + * (an acting autonomy level), recompute the CANONICAL verdict (same inputs the gate published — confirmed- + * contributor status + the persisted slop score), plan the GitHub state actions, and run them through the + * executor's deny-toward-safety gate stack (pause → approval → write-permission → mode). Decoupled and + * best-effort: a failure here never affects the gate or the public surface. gittensory never acts on a + * non-confirmed contributor's PR — the same rule the gate uses to never block one. + */ +async function maybeRunAgentMaintenance( + env: Env, + args: { + installationId: number; + repoFullName: string; + repo: Awaited>; + pr: PullRequestRecord; + settings: RepositorySettings; + otherOpenPullRequests: PullRequestRecord[]; + deliveryId: string; + }, +): Promise { + const { installationId, repoFullName, repo, settings, otherOpenPullRequests, deliveryId } = args; + if (!isAgentConfigured(settings.autonomy)) return; + // Re-read the stored PR so we act on the persisted slop score the gate just wrote, not the pre-gate payload. + const pr = await getPullRequest(env, repoFullName, args.pr.number); + /* v8 ignore next -- defensive: the PR was upserted earlier in this same webhook, so it is always present. */ + if (!pr) return; + if (pr.state !== "open") return; + // gittensory never acts on a non-confirmed contributor's PR — the same rule the gate uses to never block one. + const confirmedContributor = pr.authorLogin + ? (await getCachedOfficialMinerDetection(env, pr.authorLogin, { targetKey: `${repoFullName}#${pr.number}`, deliveryId })).status === "confirmed" + : false; + + const requireLinkedIssue = settings.requireLinkedIssue || settings.linkedIssueGateMode !== "off"; + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue }); + const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, confirmedContributor, pr.slopRisk)); + + const planned = planAgentMaintenanceActions({ + conclusion: gate.conclusion, + blockerTitles: gate.blockers.map((blocker) => blocker.title), + autonomy: settings.autonomy, + autoMaintain: settings.autoMaintain, + slopGateMinScore: settings.slopGateMinScore, + pr: { + mergeableState: pr.mergeableState, + reviewDecision: pr.reviewDecision, + slopRisk: pr.slopRisk, + labels: pr.labels, + linkedDuplicateCount: linkedIssueDuplicatePullRequestsForGate(pr, otherOpenPullRequests).length, + }, + }); + if (planned.length === 0) return; + + const installation = await getInstallation(env, installationId); + /* v8 ignore next -- an installed-App PR webhook always carries an installation record; the null is defensive. */ + const installationPermissions = installation?.permissions ?? null; + await executeAgentMaintenanceActions( + env, + { + installationId, + repoFullName, + pullNumber: pr.number, + headSha: pr.headSha, + autonomy: settings.autonomy, + agentPaused: settings.agentPaused, + agentDryRun: settings.agentDryRun, + installationPermissions, + }, + planned, + ); +} + async function repairDataFidelity(env: Env, requestedBy: "schedule" | "api" | "test"): Promise { const [repositories, segments, signalSnapshots] = await Promise.all([listRepositories(env), listRepoSyncSegments(env), listLatestSignalSnapshotsByTarget(env)]); const requiredSegments = new Set(["labels", "open_issues", "open_pull_requests"]); @@ -871,6 +944,13 @@ async function processGitHubWebhook(env: Env, deliveryId: string, eventName: str }), ); }); + // #778 maintainer auto-maintain: act on the PR's state (label/review/merge/close) per the repo's + // autonomy config, after the gate has run. The function self-guards on agent config; best-effort here + // so it never blocks the gate or public surface. + await maybeRunAgentMaintenance(env, { installationId, repoFullName, repo, pr, settings, otherOpenPullRequests, deliveryId }).catch((error) => { + /* v8 ignore next -- best-effort: auto-maintain failures are logged, never surfaced to the gate. */ + console.error(JSON.stringify({ level: "warn", event: "agent_maintenance_failed", deliveryId, repository: repoFullName, pullNumber: pr.number, error: errorMessage(error) })); + }); } } diff --git a/src/services/agent-action-executor.ts b/src/services/agent-action-executor.ts new file mode 100644 index 0000000000..c4af834338 --- /dev/null +++ b/src/services/agent-action-executor.ts @@ -0,0 +1,109 @@ +import { recordAuditEvent } from "../db/repositories"; +import { ensurePullRequestLabel } from "../github/labels"; +import { closePullRequest, createIssueComment, createPullRequestReview, mergePullRequest } from "../github/pr-actions"; +import { resolveAutonomy } from "../settings/autonomy"; +import { buildAgentActionAudit, isGlobalAgentPause, resolveAgentActionMode, resolveAgentPermissionReadiness } from "../settings/agent-execution"; +import type { PlannedAgentAction } from "../settings/agent-actions"; +import type { AgentActionClass, AutonomyPolicy } from "../types"; +import { errorMessage } from "../utils/json"; + +// The agent actor name on every audit record — the App acts on the maintainer's behalf per their configured +// autonomy (the config IS the authorization; there is no human commenter to authorize, unlike #824). +const AGENT_ACTOR = "gittensory"; + +// The PR-state action classes that require GitHub `pull_requests: write`. `label` mutates via the Issues API +// (`issues: write`, always held), so it is exempt from the write-permission readiness gate. +const PR_WRITE_CLASSES = new Set(["request_changes", "approve", "merge", "close"]); + +export type AgentActionExecutionContext = { + installationId: number; + repoFullName: string; + pullNumber: number; + headSha?: string | null | undefined; + autonomy: AutonomyPolicy | null | undefined; + agentPaused?: boolean | undefined; + agentDryRun?: boolean | undefined; + installationPermissions: Record | null | undefined; +}; + +export type AgentActionOutcome = { + actionClass: AgentActionClass; + outcome: "completed" | "queued" | "denied" | "error" | "dry_run"; + detail: string; +}; + +/** + * Execute (or dry-run, or stage for approval) a planned auto-maintain action set on one PR. Each action runs + * through the SAME deny-toward-safety gate stack before any GitHub call: + * pause (#776 kill-switch) → approval (auto_with_approval → #779 queue) → write-permission (#775) → mode. + * Only `live` mode performs a real mutation; `dry_run` records what it WOULD do. Every path writes one + * `agent.action.` audit record (#776). A failed mutation is recorded as `error`, never swallowed. + */ +export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionExecutionContext, planned: PlannedAgentAction[]): Promise { + const outcomes: AgentActionOutcome[] = []; + const targetKey = `${ctx.repoFullName}#${ctx.pullNumber}`; + const mode = resolveAgentActionMode({ globalPaused: isGlobalAgentPause(env), agentPaused: ctx.agentPaused, agentDryRun: ctx.agentDryRun }); + + for (const action of planned) { + const autonomyLevel = resolveAutonomy(ctx.autonomy, action.actionClass); + const audit = (outcome: AgentActionOutcome["outcome"], detail: string) => { + const auditOutcome = outcome === "dry_run" ? "completed" : outcome; + outcomes.push({ actionClass: action.actionClass, outcome, detail }); + return recordAuditEvent( + env, + buildAgentActionAudit({ actionClass: action.actionClass, autonomyLevel, mode, outcome: auditOutcome, repoFullName: ctx.repoFullName, targetKey, actor: AGENT_ACTOR, reason: detail }), + ); + }; + + // 1) Kill-switch (global or per-repo) halts everything. + if (mode === "paused") { + await audit("denied", "agent actions paused"); + continue; + } + // 2) auto_with_approval stages the action for a maintainer instead of executing it (#779 owns the queue). + if (action.requiresApproval) { + await audit("queued", `awaiting maintainer approval — ${action.reason}`); + continue; + } + // 3) Write-permission readiness: a PR-write action needs `pull_requests: write` granted. + if (PR_WRITE_CLASSES.has(action.actionClass) && resolveAgentPermissionReadiness({ autonomy: ctx.autonomy, installationPermissions: ctx.installationPermissions }) !== "ready") { + await audit("denied", "pull_requests: write not granted — maintainer must re-consent"); + continue; + } + // 4) dry-run records the intent without touching GitHub. + if (mode === "dry_run") { + await audit("dry_run", `dry-run: would ${action.actionClass} — ${action.reason}`); + continue; + } + // 5) live — perform the real mutation, recording success or the error. + try { + await performAction(env, ctx, action); + await audit("completed", action.reason); + } catch (error) { + await audit("error", errorMessage(error)); + } + } + + return outcomes; +} + +async function performAction(env: Env, ctx: AgentActionExecutionContext, action: PlannedAgentAction): Promise { + switch (action.actionClass) { + case "label": + await ensurePullRequestLabel(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, action.label ?? "", { createMissingLabel: true }); + return; + case "request_changes": + await createPullRequestReview(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, "REQUEST_CHANGES", action.reviewBody ?? ""); + return; + case "approve": + await createPullRequestReview(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, "APPROVE", action.reviewBody ?? ""); + return; + case "merge": + await mergePullRequest(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, { mergeMethod: action.mergeMethod ?? "squash", ...(ctx.headSha ? { sha: ctx.headSha } : {}) }); + return; + case "close": + if (action.closeComment) await createIssueComment(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber, action.closeComment); + await closePullRequest(env, ctx.installationId, ctx.repoFullName, ctx.pullNumber); + return; + } +} diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts new file mode 100644 index 0000000000..e73575cd90 --- /dev/null +++ b/src/settings/agent-actions.ts @@ -0,0 +1,126 @@ +import type { AgentActionClass, AutoMaintainPolicy, AutoMergeMethod, AutonomyPolicy } from "../types"; +import type { GateCheckConclusion } from "../rules/advisory"; +import { DEFAULT_AUTO_MAINTAIN_POLICY, autonomyRequiresApproval, isActingAutonomyLevel, resolveAutonomy } from "./autonomy"; + +// High-slop threshold default when a repo hasn't set slopGateMinScore (mirrors the gate's `high` band). +const DEFAULT_SLOP_GATE_MIN_SCORE = 60; + +// The maintainer auto-maintain decision layer (#778): given the gate verdict + the PR's current state + the +// repo's autonomy config, decide which GitHub state actions to take. PURE and deterministic — the executor +// owns the gate stack (mode / permission / auth) and the actual GitHub mutation. Conservative by design: +// every action is independently gated by its own autonomy class, and the irreversible ones (merge / close) +// demand strong positive signals. + +// The bucket labels the layer applies to reflect the gate verdict. Namespaced so a maintainer can filter on +// them and they never collide with project labels. +export const AGENT_LABEL_READY = "gittensory:ready-to-merge"; +export const AGENT_LABEL_CHANGES = "gittensory:changes-requested"; + +export type PlannedAgentAction = { + actionClass: AgentActionClass; + // auto_with_approval → the action is staged for a human approval (the #779 queue) instead of executing now. + requiresApproval: boolean; + reason: string; + // Action-specific payload (only the field for this actionClass is set): + label?: string; + reviewBody?: string; + mergeMethod?: AutoMergeMethod; + closeComment?: string; +}; + +export type AgentActionPlanInput = { + conclusion: GateCheckConclusion; + blockerTitles: string[]; + autonomy: AutonomyPolicy | null | undefined; + // Optional so the trigger can pass raw repo settings; both fall back to conservative defaults here. + autoMaintain?: AutoMaintainPolicy | undefined; + slopGateMinScore?: number | null | undefined; + pr: { + mergeableState?: string | null | undefined; + reviewDecision?: string | null | undefined; + slopRisk?: number | null | undefined; + labels: string[]; + linkedDuplicateCount?: number | undefined; + }; +}; + +const isBlocking = (conclusion: GateCheckConclusion): boolean => conclusion === "failure" || conclusion === "action_required"; + +function hasLabel(labels: string[], name: string): boolean { + return labels.some((label) => label.toLowerCase() === name.toLowerCase()); +} + +function closeMessage(reasons: string[]): string { + return `Gittensory is closing this pull request on the maintainer's behalf (${reasons.join("; ")}). This is an automated maintenance action — if you believe it's mistaken, reopen the PR or ping a maintainer and it will be reviewed.`; +} + +/** + * Plan the maintainer auto-maintain actions for one PR. Returns a COHERENT set (never both approve and + * request-changes; never both merge and close), each entry already filtered to an acting autonomy class. + * Ordered least → most irreversible: label, then the review, then the disposition. + */ +export function planAgentMaintenanceActions(input: AgentActionPlanInput): PlannedAgentAction[] { + const actions: PlannedAgentAction[] = []; + const autoMaintain = input.autoMaintain ?? DEFAULT_AUTO_MAINTAIN_POLICY; + const slopGateMinScore = input.slopGateMinScore ?? DEFAULT_SLOP_GATE_MIN_SCORE; + // Branch-protection-aware: required approvals are satisfied when the repo asks for none, or GitHub already + // resolved the PR's reviews to APPROVED. + const approvalsSatisfied = autoMaintain.requireApprovals === 0 || input.pr.reviewDecision === "APPROVED"; + const level = (actionClass: AgentActionClass) => resolveAutonomy(input.autonomy, actionClass); + const acting = (actionClass: AgentActionClass) => isActingAutonomyLevel(level(actionClass)); + const approval = (actionClass: AgentActionClass) => autonomyRequiresApproval(level(actionClass)); + + // App/infra-neutral verdicts (not evaluated yet) never drive an action. + if (input.conclusion === "neutral" || input.conclusion === "skipped") return actions; + + const blocking = isBlocking(input.conclusion); + const passing = input.conclusion === "success"; + + // 1) label — reflect the verdict bucket. After the neutral/skipped return above, a non-blocking verdict is + // necessarily `success`. Idempotent: skip if the PR already carries the label. + if (acting("label")) { + const label = blocking ? AGENT_LABEL_CHANGES : AGENT_LABEL_READY; + if (!hasLabel(input.pr.labels, label)) { + actions.push({ actionClass: "label", requiresApproval: approval("label"), reason: `verdict=${input.conclusion}`, label }); + } + } + + // 2) review — approve XOR request-changes, and never re-post the same state. + if (blocking && acting("request_changes") && input.pr.reviewDecision !== "CHANGES_REQUESTED") { + const summary = input.blockerTitles.length ? input.blockerTitles.map((title) => `- ${title}`).join("\n") : "- The Gittensory Gate is not satisfied."; + actions.push({ + actionClass: "request_changes", + requiresApproval: approval("request_changes"), + reason: `${input.blockerTitles.length || 1} blocker(s)`, + reviewBody: `Gittensory requests changes — the gate is not yet satisfied:\n\n${summary}`, + }); + } else if (passing && acting("approve") && input.pr.reviewDecision !== "APPROVED") { + actions.push({ + actionClass: "approve", + requiresApproval: approval("approve"), + reason: "gate passed", + reviewBody: "Gittensory approves — the gate is satisfied.", + }); + } + + // 3) disposition — merge a clean, approved, passing PR; otherwise close clear noise. Mutually exclusive. + const mergeableClean = input.pr.mergeableState === "clean"; + const canMerge = passing && acting("merge") && mergeableClean && approvalsSatisfied; + if (canMerge) { + actions.push({ + actionClass: "merge", + requiresApproval: approval("merge"), + reason: `gate passed, mergeable, ${autoMaintain.requireApprovals} approval(s) satisfied`, + mergeMethod: autoMaintain.mergeMethod, + }); + } else if (acting("close") && !passing) { + const noiseReasons: string[] = []; + if (input.pr.slopRisk != null && input.pr.slopRisk >= slopGateMinScore) noiseReasons.push(`slop score ${input.pr.slopRisk} ≥ ${slopGateMinScore}`); + if ((input.pr.linkedDuplicateCount ?? 0) > 0) noiseReasons.push("duplicate of another open PR"); + if (noiseReasons.length > 0) { + actions.push({ actionClass: "close", requiresApproval: approval("close"), reason: noiseReasons.join("; "), closeComment: closeMessage(noiseReasons) }); + } + } + + return actions; +} diff --git a/test/unit/agent-action-executor.test.ts b/test/unit/agent-action-executor.test.ts new file mode 100644 index 0000000000..b605821773 --- /dev/null +++ b/test/unit/agent-action-executor.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/github/pr-actions", () => ({ + createPullRequestReview: vi.fn(async () => ({ id: 1 })), + mergePullRequest: vi.fn(async () => ({ merged: true, sha: "merged-sha" })), + closePullRequest: vi.fn(async () => ({ state: "closed" })), + createIssueComment: vi.fn(async () => ({ id: 2 })), +})); +vi.mock("../../src/github/labels", () => ({ + ensurePullRequestLabel: vi.fn(async () => ({ applied: true, created: false })), +})); + +import { closePullRequest, createIssueComment, createPullRequestReview, mergePullRequest } from "../../src/github/pr-actions"; +import { ensurePullRequestLabel } from "../../src/github/labels"; +import { executeAgentMaintenanceActions, type AgentActionExecutionContext } from "../../src/services/agent-action-executor"; +import type { PlannedAgentAction } from "../../src/settings/agent-actions"; +import { createTestEnv } from "../helpers/d1"; + +function ctx(over: Partial = {}): AgentActionExecutionContext { + return { + installationId: 123, + repoFullName: "owner/repo", + pullNumber: 7, + headSha: "sha7", + autonomy: { label: "auto", request_changes: "auto", approve: "auto", merge: "auto", close: "auto" }, + agentPaused: false, + agentDryRun: false, + installationPermissions: { pull_requests: "write", issues: "write" }, + ...over, + }; +} + +const label: PlannedAgentAction = { actionClass: "label", requiresApproval: false, reason: "ready", label: "gittensory:ready-to-merge" }; +const requestChanges: PlannedAgentAction = { actionClass: "request_changes", requiresApproval: false, reason: "1 blocker", reviewBody: "please fix" }; +const approve: PlannedAgentAction = { actionClass: "approve", requiresApproval: false, reason: "passed", reviewBody: "lgtm" }; +const merge: PlannedAgentAction = { actionClass: "merge", requiresApproval: false, reason: "clean", mergeMethod: "squash" }; +const close: PlannedAgentAction = { actionClass: "close", requiresApproval: false, reason: "noise", closeComment: "closing" }; + +async function auditFor(env: Env, actionClass: string): Promise<{ outcome: string; metadata_json: string } | null> { + return env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ? order by created_at desc limit 1").bind(`agent.action.${actionClass}`).first(); +} + +describe("executeAgentMaintenanceActions (#778 gate stack)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("LIVE: executes each action class via its GitHub primitive and audits completed", async () => { + const env = createTestEnv({}); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [label, requestChanges, approve, merge, close]); + expect(outcomes.map((o) => o.outcome)).toEqual(["completed", "completed", "completed", "completed", "completed"]); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "gittensory:ready-to-merge", { createMissingLabel: true }); + expect(createPullRequestReview).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "REQUEST_CHANGES", "please fix"); + expect(createPullRequestReview).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "APPROVE", "lgtm"); + expect(mergePullRequest).toHaveBeenCalledWith(env, 123, "owner/repo", 7, { mergeMethod: "squash", sha: "sha7" }); + expect(createIssueComment).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "closing"); + expect(closePullRequest).toHaveBeenCalledWith(env, 123, "owner/repo", 7); + expect((await auditFor(env, "merge"))?.outcome).toBe("completed"); + }); + + it("PAUSED (per-repo): mutates nothing and audits denied", async () => { + const env = createTestEnv({}); + const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: true }), [label, merge]); + expect(outcomes.every((o) => o.outcome === "denied")).toBe(true); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + expect(mergePullRequest).not.toHaveBeenCalled(); + expect(JSON.parse((await auditFor(env, "label"))?.metadata_json ?? "{}")).toMatchObject({ mode: "paused" }); + }); + + it("GLOBAL kill-switch (AGENT_ACTIONS_PAUSED) halts everything regardless of per-repo config", async () => { + const env = createTestEnv({ AGENT_ACTIONS_PAUSED: "true" }); + const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentPaused: false }), [merge]); + expect(outcomes[0]?.outcome).toBe("denied"); + expect(mergePullRequest).not.toHaveBeenCalled(); + }); + + it("auto_with_approval: stages the action (queued) instead of executing", async () => { + const env = createTestEnv({}); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [{ ...merge, requiresApproval: true }]); + expect(outcomes[0]?.outcome).toBe("queued"); + expect(mergePullRequest).not.toHaveBeenCalled(); + expect((await auditFor(env, "merge"))?.outcome).toBe("queued"); + }); + + it("PR-write without pull_requests:write → denied (re-consent), but label still runs (issues:write)", async () => { + const env = createTestEnv({}); + const outcomes = await executeAgentMaintenanceActions(env, ctx({ installationPermissions: { pull_requests: "read", issues: "write" } }), [label, merge]); + expect(outcomes.find((o) => o.actionClass === "label")?.outcome).toBe("completed"); + expect(outcomes.find((o) => o.actionClass === "merge")?.outcome).toBe("denied"); + expect(ensurePullRequestLabel).toHaveBeenCalledTimes(1); + expect(mergePullRequest).not.toHaveBeenCalled(); + expect((await auditFor(env, "merge"))?.outcome).toBe("denied"); + }); + + it("DRY-RUN: records the intent without any GitHub call, audited with mode=dry_run", async () => { + const env = createTestEnv({}); + const outcomes = await executeAgentMaintenanceActions(env, ctx({ agentDryRun: true }), [label, merge]); + expect(outcomes.map((o) => o.outcome)).toEqual(["dry_run", "dry_run"]); + expect(ensurePullRequestLabel).not.toHaveBeenCalled(); + expect(mergePullRequest).not.toHaveBeenCalled(); + const audit = await auditFor(env, "merge"); + expect(audit?.outcome).toBe("completed"); + expect(JSON.parse(audit?.metadata_json ?? "{}")).toMatchObject({ mode: "dry_run" }); + }); + + it("LIVE with minimal action payloads: applies defensive defaults and omits the sha guard when headSha is absent", async () => { + const env = createTestEnv({}); + const bare = (actionClass: PlannedAgentAction["actionClass"]): PlannedAgentAction => ({ actionClass, requiresApproval: false, reason: "x" }); + await executeAgentMaintenanceActions(env, ctx({ headSha: undefined }), [bare("label"), bare("request_changes"), bare("approve"), bare("merge"), bare("close")]); + expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "", { createMissingLabel: true }); + expect(createPullRequestReview).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "REQUEST_CHANGES", ""); + expect(createPullRequestReview).toHaveBeenCalledWith(env, 123, "owner/repo", 7, "APPROVE", ""); + expect(mergePullRequest).toHaveBeenCalledWith(env, 123, "owner/repo", 7, { mergeMethod: "squash" }); // no sha guard + expect(closePullRequest).toHaveBeenCalledWith(env, 123, "owner/repo", 7); + expect(createIssueComment).not.toHaveBeenCalled(); // no closeComment → no comment posted + }); + + it("records a failed mutation as error rather than swallowing it", async () => { + const env = createTestEnv({}); + vi.mocked(mergePullRequest).mockRejectedValueOnce(new Error("Pull Request is not mergeable")); + const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]); + expect(outcomes[0]?.outcome).toBe("error"); + expect(outcomes[0]?.detail).toMatch(/not mergeable/i); + expect((await auditFor(env, "merge"))?.outcome).toBe("error"); + }); +}); diff --git a/test/unit/agent-actions.test.ts b/test/unit/agent-actions.test.ts new file mode 100644 index 0000000000..d505e4dc2e --- /dev/null +++ b/test/unit/agent-actions.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_LABEL_CHANGES, AGENT_LABEL_READY, planAgentMaintenanceActions, type AgentActionPlanInput } from "../../src/settings/agent-actions"; +import type { GateCheckConclusion } from "../../src/rules/advisory"; + +function input(overrides: Partial & { conclusion: GateCheckConclusion }): AgentActionPlanInput { + return { + blockerTitles: [], + autonomy: {}, + autoMaintain: { requireApprovals: 1, mergeMethod: "squash" }, + slopGateMinScore: 60, + pr: { labels: [] }, + ...overrides, + }; +} + +const classes = (actions: ReturnType) => actions.map((a) => a.actionClass); + +describe("planAgentMaintenanceActions (#778)", () => { + it("plans nothing for a not-yet-evaluated verdict (neutral / skipped)", () => { + expect(planAgentMaintenanceActions(input({ conclusion: "neutral", autonomy: { merge: "auto", label: "auto", close: "auto" } }))).toEqual([]); + expect(planAgentMaintenanceActions(input({ conclusion: "skipped", autonomy: { approve: "auto" } }))).toEqual([]); + }); + + it("plans nothing when every class is at a non-acting level", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { label: "suggest", request_changes: "propose", close: "observe" }, blockerTitles: ["x"] })); + expect(plan).toEqual([]); + }); + + it("labels by verdict bucket and is idempotent when the label already exists", () => { + expect(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { label: "auto" }, blockerTitles: ["x"] }))[0]).toMatchObject({ actionClass: "label", label: AGENT_LABEL_CHANGES }); + expect(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { label: "auto" } }))[0]).toMatchObject({ actionClass: "label", label: AGENT_LABEL_READY }); + // already labeled → not re-planned + expect(classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { label: "auto" }, pr: { labels: [AGENT_LABEL_READY] } })))).not.toContain("label"); + }); + + it("requests changes on a blocking verdict, with the blocker titles in the body, and never double-requests", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { request_changes: "auto" }, blockerTitles: ["Missing linked issue", "Slop risk"] })); + const rc = plan.find((a) => a.actionClass === "request_changes"); + expect(rc?.reviewBody).toContain("Missing linked issue"); + expect(rc?.reviewBody).toContain("Slop risk"); + // already in CHANGES_REQUESTED → not re-requested + expect(classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { request_changes: "auto" }, blockerTitles: ["x"], pr: { labels: [], reviewDecision: "CHANGES_REQUESTED" } })))).not.toContain("request_changes"); + }); + + it("falls back to a generic request-changes body when no blocker titles are supplied", () => { + const rc = planAgentMaintenanceActions(input({ conclusion: "action_required", autonomy: { request_changes: "auto" }, blockerTitles: [] })).find((a) => a.actionClass === "request_changes"); + expect(rc?.reviewBody).toContain("The Gittensory Gate is not satisfied"); + expect(rc?.reason).toBe("1 blocker(s)"); + }); + + it("approves a passing verdict and never re-approves; never approves AND requests changes", () => { + expect(classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { approve: "auto" } })))).toContain("approve"); + expect(classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { approve: "auto" }, pr: { labels: [], reviewDecision: "APPROVED" } })))).not.toContain("approve"); + // a passing verdict never yields request_changes; a failing one never yields approve + const failing = classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { approve: "auto", request_changes: "auto" }, blockerTitles: ["x"] }))); + expect(failing).toContain("request_changes"); + expect(failing).not.toContain("approve"); + }); + + it("merges only a clean, approved, passing PR (reviewDecision drives the approval gate)", () => { + const ok = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED" } })); + expect(ok.find((a) => a.actionClass === "merge")).toMatchObject({ mergeMethod: "squash" }); + // not mergeable-clean → no merge + expect(classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, pr: { labels: [], mergeableState: "blocked", reviewDecision: "APPROVED" } })))).not.toContain("merge"); + // approvals not satisfied (requireApprovals 1, not APPROVED) → no merge + expect(classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, pr: { labels: [], mergeableState: "clean" } })))).not.toContain("merge"); + }); + + it("requireApprovals:0 lets a clean passing PR merge without an explicit approval", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto" }, autoMaintain: { requireApprovals: 0, mergeMethod: "rebase" }, pr: { labels: [], mergeableState: "clean" } })); + expect(plan.find((a) => a.actionClass === "merge")).toMatchObject({ mergeMethod: "rebase" }); + }); + + it("applies conservative defaults when autoMaintain / slopGateMinScore are omitted", () => { + // no autoMaintain → requireApprovals defaults to 1 → a clean passing PR without APPROVED does NOT merge + expect(classes(planAgentMaintenanceActions({ conclusion: "success", blockerTitles: [], autonomy: { merge: "auto" }, pr: { labels: [], mergeableState: "clean" } }))).not.toContain("merge"); + // no slopGateMinScore → defaults to 60 → slopRisk 70 counts as noise and closes + expect(classes(planAgentMaintenanceActions({ conclusion: "failure", blockerTitles: ["x"], autonomy: { close: "auto" }, pr: { labels: [], slopRisk: 70 } }))).toContain("close"); + // ...and slopRisk 50 is below the default → no close + expect(classes(planAgentMaintenanceActions({ conclusion: "failure", blockerTitles: ["x"], autonomy: { close: "auto" }, pr: { labels: [], slopRisk: 50 } }))).not.toContain("close"); + }); + + it("closes clear noise (high slop or duplicate) on a non-passing verdict, and never closes a passing PR", () => { + // high slop + expect(classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], slopGateMinScore: 60, pr: { labels: [], slopRisk: 80 } })))).toContain("close"); + // duplicate + expect(classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], pr: { labels: [], linkedDuplicateCount: 2 } })))).toContain("close"); + // no noise → no close + expect(classes(planAgentMaintenanceActions(input({ conclusion: "failure", autonomy: { close: "auto" }, blockerTitles: ["x"], pr: { labels: [], slopRisk: 10 } })))).not.toContain("close"); + // passing verdict is never closed even with noise present + expect(classes(planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { close: "auto" }, pr: { labels: [], slopRisk: 90 } })))).not.toContain("close"); + }); + + it("never plans both merge and close", () => { + const plan = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { merge: "auto", close: "auto" }, pr: { labels: [], mergeableState: "clean", reviewDecision: "APPROVED", slopRisk: 95 } })); + const cls = classes(plan); + expect(cls).toContain("merge"); + expect(cls).not.toContain("close"); + }); + + it("flags requiresApproval for auto_with_approval and not for auto", () => { + const approval = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { approve: "auto_with_approval" } })); + expect(approval.find((a) => a.actionClass === "approve")?.requiresApproval).toBe(true); + const auto = planAgentMaintenanceActions(input({ conclusion: "success", autonomy: { approve: "auto" } })); + expect(auto.find((a) => a.actionClass === "approve")?.requiresApproval).toBe(false); + }); + + it("orders actions least → most irreversible (label, review, disposition)", () => { + // requireApprovals:0 lets merge fire while reviewDecision is still unset, so approve fires too. + const plan = planAgentMaintenanceActions( + input({ conclusion: "success", autonomy: { label: "auto", approve: "auto", merge: "auto" }, autoMaintain: { requireApprovals: 0, mergeMethod: "squash" }, pr: { labels: [], mergeableState: "clean" } }), + ); + expect(classes(plan)).toEqual(["label", "approve", "merge"]); + }); +}); diff --git a/test/unit/github-pr-actions.test.ts b/test/unit/github-pr-actions.test.ts new file mode 100644 index 0000000000..0c1654ed38 --- /dev/null +++ b/test/unit/github-pr-actions.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { generateKeyPairSync } from "node:crypto"; +import { closePullRequest, createIssueComment, createPullRequestReview, mergePullRequest } from "../../src/github/pr-actions"; +import { createTestEnv } from "../helpers/d1"; + +function envWithKey() { + return createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem() }); +} + +describe("GitHub PR action primitives (#778)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("validates the repo name before any GitHub call", async () => { + await expect(closePullRequest(createTestEnv(), 1, "invalid", 4)).rejects.toThrow(/Invalid repository full name/); + }); + + it("posts a request-changes review with the body", async () => { + const calls: Array<{ method: string; url: string; body: unknown }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : null }); + if (url.endsWith("/pulls/7/reviews")) return Response.json({ id: 99 }); + return new Response("unexpected", { status: 500 }); + }); + const result = await createPullRequestReview(envWithKey(), 123, "owner/repo", 7, "REQUEST_CHANGES", "please fix"); + expect(result).toEqual({ id: 99 }); + expect(calls[0]).toMatchObject({ method: "POST", body: { event: "REQUEST_CHANGES", body: "please fix" } }); + expect(calls[0]?.url).toMatch(/\/repos\/owner\/repo\/pulls\/7\/reviews$/); + }); + + it("merges a PR with the method and head-sha guard", async () => { + const calls: Array<{ method: string; url: string; body: Record }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + if (url.endsWith("/pulls/7/merge")) return Response.json({ merged: true, sha: "abc" }); + return new Response("unexpected", { status: 500 }); + }); + const result = await mergePullRequest(envWithKey(), 123, "owner/repo", 7, { mergeMethod: "squash", sha: "head1" }); + expect(result).toEqual({ merged: true, sha: "abc" }); + expect(calls[0]).toMatchObject({ method: "PUT", body: { merge_method: "squash", sha: "head1" } }); + }); + + it("omits the sha when not provided and defaults a sparse merge response", async () => { + let sent: Record = {}; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + sent = init?.body ? JSON.parse(String(init.body)) : {}; + return Response.json({}); // sparse body → defaults exercised + }); + const result = await mergePullRequest(envWithKey(), 123, "owner/repo", 7, { mergeMethod: "merge" }); + expect(sent).toMatchObject({ merge_method: "merge" }); + expect(sent).not.toHaveProperty("sha"); + expect(result).toEqual({ merged: true, sha: null }); + }); + + it("closes a PR via PATCH state=closed", async () => { + const calls: Array<{ method: string; url: string; body: Record }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + return Response.json({ state: "closed" }); + }); + const result = await closePullRequest(envWithKey(), 123, "owner/repo", 7); + expect(result).toEqual({ state: "closed" }); + expect(calls[0]).toMatchObject({ method: "PATCH", body: { state: "closed" } }); + expect(calls[0]?.url).toMatch(/\/repos\/owner\/repo\/pulls\/7$/); + }); + + it("posts a plain issue comment", async () => { + const calls: Array<{ method: string; url: string; body: Record }> = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "t" }); + calls.push({ method: init?.method ?? "GET", url, body: init?.body ? JSON.parse(String(init.body)) : {} }); + return Response.json({ id: 5 }); + }); + const result = await createIssueComment(envWithKey(), 123, "owner/repo", 7, "hello"); + expect(result).toEqual({ id: 5 }); + expect(calls[0]).toMatchObject({ method: "POST", body: { body: "hello" } }); + expect(calls[0]?.url).toMatch(/\/repos\/owner\/repo\/issues\/7\/comments$/); + }); +}); + +function generateRsaPrivateKeyPem(): string { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); +} diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index cd55faff32..0a8bd6beb1 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -861,6 +861,240 @@ describe("queue processors", () => { expect(calls).toEqual({ minerList: 1, gateChecks: 2 }); }); + it("auto-maintain (#778): a blocking gate on an agent-configured repo records label + request-changes actions (dry-run)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertInstallation(env, { + installation: { + id: 123, + account: { login: "JSONbored", id: 1, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "write", issues: "write" }, + events: ["pull_request"], + }, + repositories: [{ name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }], + }); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + autonomy: { label: "auto", request_changes: "auto" }, + agentDryRun: true, // dry-run → the actions are recorded but make no GitHub mutation + }); + await upsertOfficialMinerDetection(env, "contributor", { status: "confirmed", snapshot: queueMinerSnapshot("contributor") }, 60_000); + // .gittensory.yml authoritatively sets the linked-issue blocker to "block" (config-as-code, as in the gate tests above). + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) return Response.json({ id: 900 }, { status: 201 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "auto-maintain", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 42, title: "No issue", state: "open", user: { login: "contributor" }, head: { sha: "gate123" }, labels: [], body: "No issue link." }, + }, + }); + + const labelAudit = await env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ?").bind("agent.action.label").first<{ outcome: string; metadata_json: string }>(); + expect(labelAudit?.outcome).toBe("completed"); + expect(JSON.parse(labelAudit?.metadata_json ?? "{}")).toMatchObject({ mode: "dry_run", actionClass: "label" }); + const rcAudit = await env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ?").bind("agent.action.request_changes").first<{ outcome: string; metadata_json: string }>(); + expect(rcAudit?.outcome).toBe("completed"); + expect(JSON.parse(rcAudit?.metadata_json ?? "{}")).toMatchObject({ mode: "dry_run" }); + }); + + it("auto-maintain (#778): a repo with no acting autonomy takes no agent action", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "block", + requireLinkedIssue: true, + autonomy: { label: "observe" }, // not acting → agent never runs + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) return Response.json({ id: 900 }, { status: 201 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "no-autonomy", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 43, title: "No issue", state: "open", user: { login: "contributor" }, head: { sha: "gate123" }, labels: [], body: "No issue link." }, + }, + }); + + const count = await env.DB.prepare("select count(*) as n from audit_events where event_type like 'agent.action.%'").first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + + it("auto-maintain (#778): never acts on a non-confirmed contributor's PR (gate stays advisory)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + autonomy: { label: "auto", request_changes: "auto" }, + }); + // No confirmed-miner seed → author is unconfirmed; with a blocker the gate neutralizes (never blocks one). + // requireLinkedIssue is unset here, so the manifest's linkedIssue:block is what makes the blocker fire. + await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/gate123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) return Response.json({ id: 900 }, { status: 201 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "unconfirmed", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 45, title: "No issue", state: "open", user: { login: "stranger" }, head: { sha: "gate123" }, labels: [], body: "No issue link." }, + }, + }); + + const count = await env.DB.prepare("select count(*) as n from audit_events where event_type like 'agent.action.%'").first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + + it("auto-maintain (#778): skips a closed PR even on an agent-configured repo", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + autonomy: { label: "auto" }, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/check-runs")) return Response.json({ id: 900 }, { status: 201 }); + if (url.includes("/comments")) return Response.json({ id: 1 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "closed-pr", + eventName: "pull_request", + payload: { + action: "closed", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 46, title: "Closed", state: "closed", user: { login: "contributor" }, head: { sha: "gate123" }, labels: [], body: "x" }, + }, + }); + + const count = await env.DB.prepare("select count(*) as n from audit_events where event_type like 'agent.action.%'").first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + + it("auto-maintain (#778): labels a clean passing PR even with no author and no installation record (dry-run)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await persistRegistrySnapshot( + env, + normalizeRegistryPayload({ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } }, { kind: "raw-github", url: "https://example.test" }, "2026-05-23T00:00:00.000Z"), + ); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + // No installation record seeded → installation lookup returns null (label needs only issues:write, exempt). + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + autonomy: { label: "auto" }, + agentDryRun: true, + }); + vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url === "https://api.gittensor.io/miners") return Response.json([]); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/clean123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) return Response.json({ id: 900 }, { status: 201 }); + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "no-author-clean", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + // No `user` → authorLogin is absent; default linkedIssue mode is advisory so the verdict is a clean pass. + pull_request: { number: 47, title: "Clean", state: "open", head: { sha: "clean123" }, labels: [], body: "Closes #1" }, + }, + }); + + const labelAudit = await env.DB.prepare("select outcome, metadata_json from audit_events where event_type = ?").bind("agent.action.label").first<{ outcome: string; metadata_json: string }>(); + expect(labelAudit?.outcome).toBe("completed"); + expect(JSON.parse(labelAudit?.metadata_json ?? "{}")).toMatchObject({ mode: "dry_run" }); + }); + it("publishes an enabled gate when bot PR public output is skipped", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot(