Skip to content

Commit 22ec2e4

Browse files
authored
fix(agent-actions): re-check the linked-issue hard rule on merge accept (#2388)
* fix(agent-actions): re-check the linked-issue hard rule on merge accept decidePendingAgentAction re-validated only the head SHA before replaying a staged auto_with_approval merge. The linked-issue hard rule (owner-assigned / missing-point-label / maintainer-only) is evaluated fresh on every planning pass and takes precedence over merge, but a staged merge only replayed the plan-time snapshot: a maintainer relabeling or reassigning the linked issue between staging and accept (head SHA unchanged) would still merge a now-ineligible PR. Re-run resolveLinkedIssueHardRule for a staged merge before executing it, superseding the same way the head-moved check already does. Skips the check for an owner/automation-authored PR (unless closeOwnerAuthors is on), mirroring the planner's own closeEligible exemption so a trusted PR the rule never blocks in the first place isn't wrongly denied here. Live CI re-verification for this same accept path is covered separately by the already-open PR for #2128, which executeAgentMaintenanceActions applies to every merge regardless of caller — no changes needed here for that part. * test(agent-actions): cover the linked-issue recheck's token-mint failure path The rebase onto main's later head-pinning fixes shifted this diff's covered range; close the resulting branch-coverage gap on the createInstallationToken(...).catch(() => undefined) fallback added for the #2132 linked-issue hard-rule recheck. * fix(agent-actions): gate the linked-issue recheck on the post-downgrade plan The linked-issue hard-rule recheck gated on pending.actionClass (the ORIGINAL staged class) rather than the plan's actual contents after the #2127 precision-breaker downgrade. A merge already downgraded to a needs-human-review label by downgradeMergeToHold would still get its whole row rejected on a stale linked-issue violation, silently swallowing the hold label the breaker was supposed to guarantee -- since nothing is about to merge, the linked-issue state is irrelevant to what plan is actually going to execute. Also documents why the recheck's best-effort token mint is intentionally fail-open, consistent with the sibling #2126 CI/mergeable/review re-check: resolveLinkedIssueHardRule already degrades to env.GITHUB_PUBLIC_TOKEN before ever returning "not violated," and this is the same shared resolver + fail-open contract the live planning path already relies on for the primary hard-rule decision.
1 parent 6556a0f commit 22ec2e4

2 files changed

Lines changed: 191 additions & 2 deletions

File tree

src/services/agent-approval-queue.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { getInstallation, getPullRequest, getRepositorySettings, getPendingAgentAction, recordAuditEvent, setPendingAgentActionStatus } from "../db/repositories";
2+
import { createInstallationToken } from "../github/app";
3+
import { loadLinkedIssueHardRules, resolveLinkedIssueHardRule } from "../review/linked-issue-hard-rules";
24
import { executeAgentMaintenanceActions, pendingActionToPlanned } from "./agent-action-executor";
3-
import { downgradeCloseToHold, downgradeMergeToHold, type PlannedAgentAction } from "../settings/agent-actions";
5+
import { downgradeCloseToHold, downgradeMergeToHold, isProtectedAutomationAuthor, type PlannedAgentAction } from "../settings/agent-actions";
46
import { isCloseHoldOnly, isHoldOnly } from "../review/outcomes-wire";
5-
import { createInstallationToken } from "../github/app";
67
import { fetchLiveCiAggregate, fetchLivePullRequestMergeState, fetchLivePullRequestReviewDecision } from "../github/backfill";
78
import { githubRateLimitAdmissionKeyForToken } from "../github/client";
89
import type { AgentPendingActionParams, AgentPendingActionRecord } from "../types";
@@ -148,6 +149,58 @@ export async function decidePendingAgentAction(env: Env, input: { id: string; de
148149
if (holdOnly) plan = downgradeMergeToHold(plan, true);
149150
if (closeHoldOnly) plan = downgradeCloseToHold(plan, true);
150151

152+
// Re-validate a staged MERGE against the CURRENT linked-issue hard-rule state (#2132). The hard rule is
153+
// evaluated fresh on every planning pass and takes precedence over merge (see planAgentMaintenanceActions),
154+
// but a staged merge only replays the PLAN-TIME snapshot — a maintainer relabeling/reassigning the linked
155+
// issue between staging and accept (head SHA unchanged, so the check above doesn't catch it) would otherwise
156+
// still merge a now-ineligible PR. Mirrors the planner's own owner/automation exemption (closeEligible) so an
157+
// owner's staged merge, which the hard rule never blocks in the first place, is not wrongly denied here.
158+
// Gated on the POST-downgrade `plan`, not `pending.actionClass`: the precision-breaker downgrade immediately
159+
// above can already have replaced a staged merge with a needs-human-review label (downgradeMergeToHold) — that
160+
// downgraded plan isn't going to merge anything, so a stale linked-issue violation must not reject the whole
161+
// row and suppress the hold label; it only matters while a merge is still the thing about to execute.
162+
if (plan.some((action) => action.actionClass === "merge") && pr) {
163+
const repoOwner = pending.repoFullName.includes("/") ? pending.repoFullName.slice(0, pending.repoFullName.indexOf("/")) : "";
164+
const authorLogin = pr.authorLogin ?? "";
165+
const authorIsOwner = authorLogin.length > 0 && authorLogin.toLowerCase() === repoOwner.toLowerCase();
166+
const authorIsAutomationBot = isProtectedAutomationAuthor(pr.authorLogin);
167+
const closeEligible = (!authorIsOwner && !authorIsAutomationBot) || (authorIsOwner && settings.closeOwnerAuthors === true);
168+
if (closeEligible) {
169+
const linkedIssueRulesConfig = await loadLinkedIssueHardRules(env, pending.repoFullName);
170+
// Best-effort mint, same as the #2126 CI/mergeable/review re-check above: a failed mint here does NOT
171+
// silently skip the recheck -- resolveLinkedIssueHardRule falls back to env.GITHUB_PUBLIC_TOKEN when
172+
// ciToken is undefined and still attempts the fetch, only returning "not violated" if that ALSO can't
173+
// gather issue facts. This is the same shared resolver + same fail-open contract the LIVE planning path
174+
// (processors.ts) already relies on for the PRIMARY hard-rule decision; holding this SECONDARY, narrow-
175+
// race-window recheck to a stricter fail-closed standard would deny otherwise-legitimate merges on every
176+
// transient token-mint hiccup without closing a real gap (the executor mints its OWN token independently
177+
// for the actual merge mutation, so a suspended/broken installation still fails there regardless).
178+
const ciToken = await createInstallationToken(env, pending.installationId).catch(() => undefined);
179+
const linkedIssueHardRule = await resolveLinkedIssueHardRule({
180+
env,
181+
repoFullName: pending.repoFullName,
182+
repoOwner,
183+
config: linkedIssueRulesConfig,
184+
body: pr.body,
185+
linkedIssues: pr.linkedIssues,
186+
ciToken,
187+
installationId: pending.installationId,
188+
});
189+
if (linkedIssueHardRule?.violated) {
190+
await setPendingAgentActionStatus(env, pending.id, { status: "rejected", decidedBy: input.decidedBy });
191+
await recordAuditEvent(env, {
192+
eventType: "agent.pending_action.superseded",
193+
actor: input.decidedBy,
194+
targetKey,
195+
outcome: "denied",
196+
detail: `superseded merge: linked-issue hard rule now violated — ${linkedIssueHardRule.reason ?? "ineligible linked issue"}`,
197+
metadata: { ...baseMetadata, linkedIssueReason: linkedIssueHardRule.reason },
198+
});
199+
return { status: "rejected", action: { ...pending, status: "rejected", decidedBy: input.decidedBy }, executionOutcome: "linked_issue_hard_rule" };
200+
}
201+
}
202+
}
203+
151204
const outcomes = await executeAgentMaintenanceActions(
152205
env,
153206
{

test/unit/agent-approval-queue.test.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,22 @@ vi.mock("../../src/github/backfill", async (importOriginal) => ({
3434
fetchLivePullRequestMergeState: vi.fn(async () => "clean"),
3535
fetchLivePullRequestReviewDecision: vi.fn(async () => undefined),
3636
}));
37+
// resolveLinkedIssueHardRule defaults to the REAL implementation, which is a safe no-op here: loadLinkedIssueHardRules
38+
// (also real, unmocked) always returns the all-off default config, so the real resolver returns undefined (not
39+
// violated) without any GitHub fetch. Individual tests override it to exercise the accept-time recheck (#2132).
40+
vi.mock("../../src/review/linked-issue-hard-rules", async (importOriginal) => {
41+
const actual = await importOriginal<typeof import("../../src/review/linked-issue-hard-rules")>();
42+
return {
43+
...actual,
44+
resolveLinkedIssueHardRule: vi.fn(actual.resolveLinkedIssueHardRule),
45+
};
46+
});
3747

3848
import { createPullRequestReview, mergePullRequest } from "../../src/github/pr-actions";
3949
import { ensurePullRequestLabel } from "../../src/github/labels";
4050
import { createInstallationToken } from "../../src/github/app";
4151
import { fetchLiveCiAggregate, fetchLivePullRequestMergeState, fetchLivePullRequestReviewDecision } from "../../src/github/backfill";
52+
import { resolveLinkedIssueHardRule } from "../../src/review/linked-issue-hard-rules";
4253
import { actionParams, executeAgentMaintenanceActions, pendingActionToPlanned, type AgentActionExecutionContext } from "../../src/services/agent-action-executor";
4354
import { decidePendingAgentAction } from "../../src/services/agent-approval-queue";
4455
import {
@@ -357,6 +368,29 @@ describe("agent approval queue (#779)", () => {
357368
expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 5, "owner/repo", 7, "gittensory:needs-human-review", { createMissingLabel: true });
358369
});
359370

371+
it("REGRESSION: a precision-breaker-downgraded merge still executes the hold/label plan even when the linked issue would now violate the hard rule", async () => {
372+
// Before the fix, the linked-issue recheck gated on pending.actionClass (the ORIGINAL staged class), not the
373+
// post-downgrade plan -- so a merge already downgraded to a needs-human-review label by the #2127 precision
374+
// breaker above would still get its whole row rejected on a stale linked-issue violation, silently swallowing
375+
// the hold label the breaker was supposed to guarantee.
376+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
377+
await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval", label: "auto" } });
378+
await seedInstallation(env);
379+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "contributor" }, head: { sha: "h7" }, labels: [], body: "Closes #9" });
380+
vi.mocked(resolveLinkedIssueHardRule).mockResolvedValueOnce({ violated: true, reason: "Linked issue #9 is labeled `maintainer-only` — it is not open for community PRs." });
381+
const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash", expectedHeadSha: "h7" }, reason: "clean" });
382+
// The merge-precision breaker engages fleet-wide AFTER this merge was staged — same as the #2127 test above.
383+
await env.DB.prepare("INSERT INTO system_flags (key, value) VALUES (?, ?)").bind("holdonly:owner/repo", "true").run();
384+
385+
const result = await decidePendingAgentAction(env, { id: action.id, decision: "accept", decidedBy: "owner" });
386+
expect(result.status).toBe("accepted");
387+
expect(result.executionOutcome).toBe("completed");
388+
expect(mergePullRequest).not.toHaveBeenCalled();
389+
expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 5, "owner/repo", 7, "gittensory:needs-human-review", { createMissingLabel: true });
390+
// The recheck must not even run once the plan no longer contains a merge -- there's nothing left to validate.
391+
expect(resolveLinkedIssueHardRule).not.toHaveBeenCalled();
392+
});
393+
360394
it("accept executes a staged merge normally when the precision breaker is off", async () => {
361395
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
362396
await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } });
@@ -467,6 +501,108 @@ describe("agent approval queue (#779)", () => {
467501
expect(ensurePullRequestLabel).toHaveBeenCalledWith(env, 5, "owner/repo", 8, "gittensory:needs-human-review", { createMissingLabel: true });
468502
});
469503

504+
it("accept supersedes a staged merge when the linked issue trips a hard rule after staging (#2132)", async () => {
505+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
506+
await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } });
507+
await seedInstallation(env);
508+
// Staged against a CONTRIBUTOR PR whose linked issue was eligible at plan time; between staging and accept
509+
// another maintainer relabeled the linked issue (head SHA unchanged, so the freshness check above misses it).
510+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "contributor" }, head: { sha: "h7" }, labels: [], body: "Closes #9" });
511+
vi.mocked(resolveLinkedIssueHardRule).mockResolvedValueOnce({ violated: true, reason: "Linked issue #9 is labeled `maintainer-only` — it is not open for community PRs." });
512+
const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash", expectedHeadSha: "h7" }, reason: "clean" });
513+
514+
const result = await decidePendingAgentAction(env, { id: action.id, decision: "accept", decidedBy: "owner" });
515+
expect(result.status).toBe("rejected");
516+
expect(result.executionOutcome).toBe("linked_issue_hard_rule");
517+
expect(mergePullRequest).not.toHaveBeenCalled();
518+
expect((await getPendingAgentAction(env, action.id))?.status).toBe("rejected");
519+
const audit = await env.DB.prepare("select outcome, detail from audit_events where event_type = ?").bind("agent.pending_action.superseded").first<{ outcome: string; detail: string }>();
520+
expect(audit?.outcome).toBe("denied");
521+
expect(audit?.detail).toContain("maintainer-only");
522+
});
523+
524+
it("accept executes a staged merge when the linked issue remains eligible", async () => {
525+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
526+
await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } });
527+
await seedInstallation(env);
528+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "contributor" }, head: { sha: "h7" }, labels: [], body: "Closes #9" });
529+
vi.mocked(resolveLinkedIssueHardRule).mockResolvedValueOnce({ violated: false, reason: null });
530+
const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash", expectedHeadSha: "h7" }, reason: "clean" });
531+
532+
const result = await decidePendingAgentAction(env, { id: action.id, decision: "accept", decidedBy: "owner" });
533+
expect(result.status).toBe("accepted");
534+
expect(result.executionOutcome).toBe("completed");
535+
expect(mergePullRequest).toHaveBeenCalledWith(env, 5, "owner/repo", 7, { mergeMethod: "squash", sha: "h7" });
536+
});
537+
538+
it("accept still executes when the linked-issue recheck's own token mint fails — fails OPEN, ciToken passed as undefined (#2132)", async () => {
539+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
540+
await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } });
541+
await seedInstallation(env);
542+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "contributor" }, head: { sha: "h7" }, labels: [], body: "Closes #9" });
543+
vi.mocked(resolveLinkedIssueHardRule).mockResolvedValueOnce({ violated: false, reason: null });
544+
// First call is the #2126 merge-live-recheck's own token mint (succeeds); the second is this new linked-issue
545+
// recheck's token mint, which fails here. The executor mints its own token for the actual mutation
546+
// independently, so this transient failure must fail open on THIS check specifically, not block the accept.
547+
vi.mocked(createInstallationToken).mockResolvedValueOnce("test-installation-token").mockRejectedValueOnce(new Error("installation suspended"));
548+
const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash", expectedHeadSha: "h7" }, reason: "clean" });
549+
550+
const result = await decidePendingAgentAction(env, { id: action.id, decision: "accept", decidedBy: "owner" });
551+
expect(result.status).toBe("accepted");
552+
expect(result.executionOutcome).toBe("completed");
553+
expect(mergePullRequest).toHaveBeenCalledWith(env, 5, "owner/repo", 7, { mergeMethod: "squash", sha: "h7" });
554+
expect(vi.mocked(resolveLinkedIssueHardRule)).toHaveBeenCalledWith(expect.objectContaining({ ciToken: undefined }));
555+
});
556+
557+
it("accept supersedes with a fallback reason when the hard-rule result omits one", async () => {
558+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
559+
await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } });
560+
await seedInstallation(env);
561+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "contributor" }, head: { sha: "h7" }, labels: [], body: "Closes #9" });
562+
vi.mocked(resolveLinkedIssueHardRule).mockResolvedValueOnce({ violated: true, reason: null });
563+
const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash", expectedHeadSha: "h7" }, reason: "clean" });
564+
565+
const result = await decidePendingAgentAction(env, { id: action.id, decision: "accept", decidedBy: "owner" });
566+
expect(result.status).toBe("rejected");
567+
expect(result.executionOutcome).toBe("linked_issue_hard_rule");
568+
const audit = await env.DB.prepare("select detail from audit_events where event_type = ?").bind("agent.pending_action.superseded").first<{ detail: string }>();
569+
expect(audit?.detail).toContain("ineligible linked issue");
570+
});
571+
572+
it("accept tolerates a slash-less repoFullName and a missing PR author login (defensive fallbacks)", async () => {
573+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
574+
await upsertRepositorySettings(env, { repoFullName: "solorepo", autonomy: { merge: "auto_with_approval" } });
575+
await upsertInstallation(env, {
576+
installation: { id: 5, account: { login: "owner", id: 1, type: "User" }, repository_selection: "selected", permissions: { metadata: "read", pull_requests: "write", issues: "write" }, events: ["pull_request"] },
577+
repositories: [{ name: "solorepo", full_name: "solorepo", private: false, owner: { login: "owner" } }],
578+
});
579+
// No `user` on the payload → authorLogin stored null; repoFullName has no "/" → repoOwner falls back to "".
580+
await upsertPullRequestFromGitHub(env, "solorepo", { number: 7, title: "PR", state: "open", head: { sha: "h7" }, labels: [], body: "x" });
581+
const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "solorepo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash", expectedHeadSha: "h7" }, reason: "clean" });
582+
583+
const result = await decidePendingAgentAction(env, { id: action.id, decision: "accept", decidedBy: "owner" });
584+
expect(result.status).toBe("accepted");
585+
expect(result.executionOutcome).toBe("completed");
586+
expect(mergePullRequest).toHaveBeenCalledWith(env, 5, "solorepo", 7, { mergeMethod: "squash", sha: "h7" });
587+
});
588+
589+
it("accept does not consult the linked-issue hard rule for an owner-authored staged merge (mirrors the planner's closeEligible exemption)", async () => {
590+
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: "x" });
591+
await upsertRepositorySettings(env, { repoFullName: "owner/repo", autonomy: { merge: "auto_with_approval" } });
592+
await seedInstallation(env);
593+
// Author IS the repo owner ("owner/repo" → owner login "owner"); closeOwnerAuthors defaults false, so the
594+
// hard rule must never even be consulted for this PR, regardless of what it would say.
595+
await upsertPullRequestFromGitHub(env, "owner/repo", { number: 7, title: "PR", state: "open", user: { login: "owner" }, head: { sha: "h7" }, labels: [], body: "Closes #9" });
596+
vi.mocked(resolveLinkedIssueHardRule).mockResolvedValueOnce({ violated: true, reason: "would have violated, but must not even be checked" });
597+
const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: { mergeMethod: "squash", expectedHeadSha: "h7" }, reason: "clean" });
598+
599+
const result = await decidePendingAgentAction(env, { id: action.id, decision: "accept", decidedBy: "owner" });
600+
expect(result.status).toBe("accepted");
601+
expect(result.executionOutcome).toBe("completed");
602+
expect(mergePullRequest).toHaveBeenCalledWith(env, 5, "owner/repo", 7, { mergeMethod: "squash", sha: "h7" });
603+
expect(resolveLinkedIssueHardRule).not.toHaveBeenCalled();
604+
});
605+
470606
it("accept does not supersede when the PR record is absent (no live head to compare) — proceeds to the executor", async () => {
471607
const env = createTestEnv({});
472608
// No PR seeded → getPullRequest returns null → pr?.headSha is undefined, so the staleness guard is skipped

0 commit comments

Comments
 (0)