Skip to content
Closed
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
8 changes: 7 additions & 1 deletion src/services/agent-action-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "../settings/moderation-rules";
import { incr } from "../selfhost/metrics";
import { shouldWaitForOlderSiblings } from "../review/merge-train";
import { resolveDispositionReason } from "../review/outcomes-wire";
import { captureError } from "../selfhost/sentry";

// The agent actor name on every audit record — the App acts on the maintainer's behalf per their configured
Expand Down Expand Up @@ -555,7 +556,12 @@ export async function executeAgentMaintenanceActions(env: Env, ctx: AgentActionE
const notifyOutcome: NotifyOutcome | null =
action.actionClass === "merge" ? "merged" : action.actionClass === "close" ? "closed" : action.actionClass === "request_changes" ? "manual" : null;
if (notifyOutcome) {
const notifyParams = { repoFullName: ctx.repoFullName, pullNumber: ctx.pullNumber, outcome: notifyOutcome, summary: action.reason, submitter: ctx.authorLogin };
// #6636: surface the AI's actual gate reasoning — the reasonCode summary on the most recent gate_decision
// row for this PR — as the notification reason, falling back to the plain disposition reason when no
// verdict is on record or the read fails. This is the live consumer resolveDispositionReason was built for
// (its own doc comment promises the "enriched, verdict-aware reason the user actually sees").
const dispositionReason = await resolveDispositionReason(env, targetKey, action.reason);
const notifyParams = { repoFullName: ctx.repoFullName, pullNumber: ctx.pullNumber, outcome: notifyOutcome, summary: dispositionReason, submitter: ctx.authorLogin };
await notifyActionToDiscord(env, notifyParams).catch(() => undefined);
await notifyActionToSlack(env, notifyParams).catch(() => undefined);
}
Expand Down
31 changes: 31 additions & 0 deletions test/unit/agent-action-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,19 @@ vi.mock("../../src/github/backfill", async (importOriginal) => ({
fetchLivePullRequestState: vi.fn(async () => "open" as const),
refreshInstallationHealthForInstallation: vi.fn(async () => null),
}));
vi.mock("../../src/services/notify-discord", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/services/notify-discord")>()),
notifyActionToDiscord: vi.fn(async () => undefined),
notifyActionToSlack: vi.fn(async () => undefined),
}));

import { closeIssue, closePullRequest, createIssueComment, createPullRequestReview, dismissLatestBotApproval, mergePullRequest, updatePullRequestBranch } from "../../src/github/pr-actions";
import { ensurePullRequestLabel, removePullRequestLabel } from "../../src/github/labels";
import { ensurePullRequestAssignee } from "../../src/github/assignees";
import { fetchPullRequestFreshness } from "../../src/github/pr-freshness";
import { createInstallationToken } from "../../src/github/app";
import { fetchLiveCiAggregate, fetchLivePullRequestMergeState, fetchLivePullRequestState, fetchLiveReviewThreadBlockers, refreshInstallationHealthForInstallation } from "../../src/github/backfill";
import { notifyActionToDiscord, notifyActionToSlack } from "../../src/services/notify-discord";
import {
actionParams,
applyModerationEscalationForRule,
Expand Down Expand Up @@ -417,6 +423,31 @@ describe("executeAgentMaintenanceActions (#778 gate stack)", () => {
expect(mergePullRequest).toHaveBeenCalled();
});

it("#6636: a terminal disposition notification uses the recorded gate verdict as its reason when one is on record", async () => {
const env = createTestEnv({});
// The latest gate_decision row for this PR carries the AI's reasoning — the enriched reason resolveDispositionReason surfaces.
await env.DB.prepare(
"INSERT INTO review_audit (id, project, target_id, event_type, decision, source, head_sha, summary, created_at) VALUES (?,?,?,?,?,?,?,?,?)",
)
.bind("gate:owner/repo#7", "owner/repo", "owner/repo#7", "gate_decision", "close", "gittensory-native", "sha7", "An AI reviewer flagged a likely blocking defect", "2026-06-21T00:00:00.000Z")
.run();

const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]);
expect(outcomes[0]?.outcome).toBe("completed");
expect(mergePullRequest).toHaveBeenCalled();
// The rendered notification shows the gate verdict, NOT the plain disposition reason ("clean").
expect(notifyActionToDiscord).toHaveBeenCalledWith(env, expect.objectContaining({ outcome: "merged", summary: "An AI reviewer flagged a likely blocking defect" }));
expect(notifyActionToSlack).toHaveBeenCalledWith(env, expect.objectContaining({ outcome: "merged", summary: "An AI reviewer flagged a likely blocking defect" }));
});

it("#6636: the disposition notification falls back to the plain disposition reason when no gate verdict is on record", async () => {
const env = createTestEnv({});
const outcomes = await executeAgentMaintenanceActions(env, ctx(), [merge]);
expect(outcomes[0]?.outcome).toBe("completed");
expect(notifyActionToDiscord).toHaveBeenCalledWith(env, expect.objectContaining({ outcome: "merged", summary: merge.reason }));
expect(notifyActionToSlack).toHaveBeenCalledWith(env, expect.objectContaining({ outcome: "merged", summary: merge.reason }));
});

it("honors a CUSTOM configured manualReviewLabel name (case-insensitive) instead of only the literal default", async () => {
const env = createTestEnv({});
vi.mocked(fetchPullRequestFreshness).mockResolvedValueOnce({ status: "current", liveHeadSha: "sha7", liveState: "open", liveLabels: ["Needs-Human"] });
Expand Down