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
89 changes: 89 additions & 0 deletions src/github/pr-actions.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
80 changes: 80 additions & 0 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ReturnType<typeof getRepository>>;
pr: PullRequestRecord;
settings: RepositorySettings;
otherOpenPullRequests: PullRequestRecord[];
deliveryId: string;
},
): Promise<void> {
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<void> {
const [repositories, segments, signalSnapshots] = await Promise.all([listRepositories(env), listRepoSyncSegments(env), listLatestSignalSnapshotsByTarget(env)]);
const requiredSegments = new Set(["labels", "open_issues", "open_pull_requests"]);
Expand Down Expand Up @@ -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) }));
});
}
}

Expand Down
109 changes: 109 additions & 0 deletions src/services/agent-action-executor.ts
Original file line number Diff line number Diff line change
@@ -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<AgentActionClass>(["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<string, string> | 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.<class>` audit record (#776). A failed mutation is recorded as `error`, never swallowed.
*/
export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionExecutionContext, planned: PlannedAgentAction[]): Promise<AgentActionOutcome[]> {
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<void> {
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;
}
}
Loading
Loading