diff --git a/src/queue/processors.ts b/src/queue/processors.ts index b0a4d8aed9..dded15b399 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -561,7 +561,10 @@ async function sweepRepoRegate(env: Env, repoFullName: string | undefined): Prom const duplicateWinnerEnabled = env.GITTENSORY_DUPLICATE_WINNER === "true"; for (const pr of candidates) { const others = openPullRequests.filter((other) => other.number !== pr.number); - const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: others, requireLinkedIssue, duplicateWinnerEnabled }); + // Thread linked-issue authors so the re-gate sweep applies the self-authored-linked-issue block too — without + // this a self-authored PR re-gated by the sweep escapes a block the main webhook path applies. (#self-authored-parity) + const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins(env, sweepInstallationId, repoFullName, pr.linkedIssues, settings.selfAuthoredLinkedIssueGateMode === "block"); + const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests: others, requireLinkedIssue, duplicateWinnerEnabled, linkedIssueAuthorLogins }); const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings, null, undefined, pr.slopRisk ?? null)); verdicts[String(pr.number)] = gate.conclusion; if (gate.conclusion === "failure" || gate.conclusion === "action_required") flaggedPulls.push(pr.number); @@ -3125,17 +3128,21 @@ async function authorizePrActionActor(args: { // #824 the common "load the PR's repo context + build its advisory" step every authorized action command runs // before its mutation. Identical across gate-override and the PR-panel retrigger. -async function buildAuthorizedPrActionAdvisory( +export async function buildAuthorizedPrActionAdvisory( env: Env, repoFullName: string, pr: PullRequestRecord, settings: RepositorySettings, ): Promise<{ repo: Awaited>; advisory: ReturnType }> { const [repo, otherOpenPullRequests] = await Promise.all([getRepository(env, repoFullName), listOtherOpenPullRequests(env, repoFullName, pr.number)]); + // Mirror the main webhook path: thread linked-issue authors so an authorized PR action (gate-override / panel + // retrigger) honors the self-authored-linked-issue block too. installationId comes from the repo record. (#self-authored-parity) + const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins(env, repo?.installationId ?? null, repoFullName, pr.linkedIssues, settings.selfAuthoredLinkedIssueGateMode === "block"); const advisory = buildPullRequestAdvisory(repo, pr, { otherOpenPullRequests, requireLinkedIssue: shouldCollectLinkedIssueEvidence(settings), duplicateWinnerEnabled: env.GITTENSORY_DUPLICATE_WINNER === "true", + linkedIssueAuthorLogins, }); return { repo, advisory }; } diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index a39abd68c7..40e9284e9e 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -141,7 +141,13 @@ export function buildPredictedGateVerdict(args: { // LOSER, never the winner. So the predictor must keep showing the duplicate finding (the honest pre-submit // answer). Threading the flag here would let isDuplicateClusterWinner(0, …) treat #0 as the winner and // falsely suppress the block — a false-optimism regression. Do NOT add it without modeling #0 as the loser. - const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: pullRequests, requireLinkedIssue }); + // Thread linked-issue authors from the issues snapshot so the predictor surfaces the self-authored-linked-issue + // finding too — evaluateGateCheck below already receives gate.selfAuthoredLinkedIssue, but without this finding it + // had nothing to act on, so a configured self-authored gate never showed in the preview. Offline path: resolved + // from the snapshot, never a live fetch. (#self-authored-parity) + const issueAuthorByNumber = new Map(issues.filter((issue) => issue.repoFullName === input.repoFullName).map((issue) => [issue.number, issue.authorLogin ?? null])); + const linkedIssueAuthorLogins = syntheticPr.linkedIssues.map((issueNumber) => issueAuthorByNumber.get(issueNumber) ?? null); + const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: pullRequests, requireLinkedIssue, linkedIssueAuthorLogins }); // Pack-aware (#693): under `oss-anti-slop` the gate blocks ANY author, so drop the confirmed-contributor // gate entirely (mirrors gateCheckPolicy). `gittensor` keeps it. Pack comes from the PUBLIC .gittensory.yml. diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 202e7c8f08..af8c4e5637 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { generateKeyPairSync } from "node:crypto"; import { clearInstallationTokenCacheForTest } from "../../src/github/app"; -import { gateCheckPolicy, resolveLinkedIssueAuthorLogins, shouldCollectLinkedIssueEvidence, shouldCollectSlopEvidence, shouldRunSlopAiAdvisory } from "../../src/queue/processors"; +import { buildAuthorizedPrActionAdvisory, gateCheckPolicy, resolveLinkedIssueAuthorLogins, shouldCollectLinkedIssueEvidence, shouldCollectSlopEvidence, shouldRunSlopAiAdvisory } from "../../src/queue/processors"; import { createTestEnv } from "../helpers/d1"; import { upsertIssueFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { evaluateGateCheck } from "../../src/rules/advisory"; import { parseFocusManifest, resolveEffectiveSettings } from "../../src/signals/focus-manifest"; -import type { Advisory, RepositorySettings } from "../../src/types"; +import type { Advisory, PullRequestRecord, RepositorySettings } from "../../src/types"; function settings(over: Partial = {}): RepositorySettings { return { @@ -482,3 +482,39 @@ describe("resolveLinkedIssueAuthorLogins", () => { } }); }); + +describe("buildAuthorizedPrActionAdvisory self-authored parity (#self-authored-parity)", () => { + it("threads linked-issue authors so an authorized PR action blocks a self-authored linked issue", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 1); + await upsertIssueFromGitHub(env, "owner/repo", { number: 12, title: "Self-authored bug", body: "", state: "open", user: { login: "miner1" }, labels: [], html_url: "https://github.com/owner/repo/issues/12", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }); + // PR author "Miner1" matches issue author "miner1" case-insensitively → self-authored. + const pr: PullRequestRecord = { repoFullName: "owner/repo", number: 99, title: "Fix self-authored bug", state: "open", authorLogin: "Miner1", body: "Closes #12", labels: [], linkedIssues: [12] }; + + const policy = settings({ selfAuthoredLinkedIssueGateMode: "block" }); + const { advisory } = await buildAuthorizedPrActionAdvisory(env, "owner/repo", pr, policy); + const gate = evaluateGateCheck(advisory, gateCheckPolicy(policy, null)); + + expect(advisory.findings.some((finding) => finding.code === "self_authored_linked_issue")).toBe(true); + expect(gate.conclusion).toBe("failure"); + expect(gate.blockers.some((finding) => finding.code === "self_authored_linked_issue")).toBe(true); + }); + + it("does not flag a linked issue authored by someone else", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 1); + await upsertIssueFromGitHub(env, "owner/repo", { number: 13, title: "Reported by another", body: "", state: "open", user: { login: "reporter" }, labels: [], html_url: "https://github.com/owner/repo/issues/13", created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }); + const pr: PullRequestRecord = { repoFullName: "owner/repo", number: 100, title: "Fix reported bug", state: "open", authorLogin: "fixer", body: "Closes #13", labels: [], linkedIssues: [13] }; + + const { advisory } = await buildAuthorizedPrActionAdvisory(env, "owner/repo", pr, settings({ selfAuthoredLinkedIssueGateMode: "block" })); + expect(advisory.findings.some((finding) => finding.code === "self_authored_linked_issue")).toBe(false); + }); + + it("tolerates a repo absent from the DB and a non-blocking mode (no installation id, no live fetch)", async () => { + const env = createTestEnv(); + // No repository row → getRepository returns null → repo?.installationId ?? null = null; mode !== "block" → no live fetch. + const pr: PullRequestRecord = { repoFullName: "owner/missing", number: 101, title: "Fix something", state: "open", authorLogin: "someone", body: "Closes #14", labels: [], linkedIssues: [14] }; + const { advisory } = await buildAuthorizedPrActionAdvisory(env, "owner/missing", pr, settings({ selfAuthoredLinkedIssueGateMode: "advisory" })); + expect(advisory.findings.some((finding) => finding.code === "self_authored_linked_issue")).toBe(false); + }); +}); diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index 904fa84853..828bf5f0d4 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -9,8 +9,8 @@ function openPr(number: number, title: string, linkedIssues: number[] = [], auth return { repoFullName: "acme/widgets", number, title, state: "open", authorLogin, linkedIssues, labels: [] }; } -function openIssue(number: number, title: string): IssueRecord { - return { repoFullName: "acme/widgets", number, title, state: "open", labels: [], linkedPrs: [], authorAssociation: null } as IssueRecord; +function openIssue(number: number, title: string, authorLogin: string | null = null): IssueRecord { + return { repoFullName: "acme/widgets", number, title, state: "open", labels: [], linkedPrs: [], authorAssociation: null, authorLogin } as IssueRecord; } const BASE_INPUT: PredictedGateInput = { @@ -66,6 +66,21 @@ describe("buildPredictedGateVerdict", () => { expect(advisory.blockers.some((b) => b.code === "missing_linked_issue")).toBe(false); }); + it("predicts a BLOCK for a self-authored linked issue when gate.selfAuthoredLinkedIssue:block (#self-authored-parity)", () => { + // miner1 links issue #7 which miner1 also authored → self_authored_linked_issue (resolved from the snapshot). + const blocked = verdict({ gate: { selfAuthoredLinkedIssue: "block" }, issues: [openIssue(7, "Uploads should retry on 5xx", "miner1")] }); + expect(blocked.conclusion).toBe("failure"); + expect(blocked.blockers.some((b) => b.code === "self_authored_linked_issue")).toBe(true); + + // Authored by someone else → no self-authored finding. + const otherAuthor = verdict({ gate: { selfAuthoredLinkedIssue: "block" }, issues: [openIssue(7, "Uploads should retry on 5xx", "reporter")] }); + expect(otherAuthor.blockers.some((b) => b.code === "self_authored_linked_issue")).toBe(false); + + // A linked issue absent from the snapshot resolves to a null author → no self-authored finding (fail-open). + const notInSnapshot = verdict({ gate: { selfAuthoredLinkedIssue: "block" }, input: { body: "Closes #99", linkedIssues: [99] }, issues: [] }); + expect(notInSnapshot.blockers.some((b) => b.code === "self_authored_linked_issue")).toBe(false); + }); + it("uses linked issues inferred from the body for gate advisory parity", () => { const result = verdict({ gate: { linkedIssue: "block" }, input: { body: "Closes #7", linkedIssues: [] } }); expect(result.conclusion).toBe("success"); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index b91a68cc75..dbe75bbb47 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -581,6 +581,24 @@ describe("queue processors", () => { expect(sent).toEqual([]); }); + it("agent re-gate sweep applies the self-authored-linked-issue block (#self-authored-parity)", async () => { + const env = createTestEnv({ JOBS: { async send() {} } as unknown as Queue }); + await upsertRepositoryFromGitHub(env, { name: "agent-repo", full_name: "owner/agent-repo", private: false, owner: { login: "owner" } }); + await upsertRepositorySettings(env, { repoFullName: "owner/agent-repo", autonomy: { merge: "auto" }, selfAuthoredLinkedIssueGateMode: "block" }); + // Issue #5 is authored by miner1; PR #9 by miner1 links it → self-authored. Without threading the linked-issue + // author into the sweep's advisory, this PR would re-gate as "success" and escape the block. (#self-authored-parity) + await upsertIssueFromGitHub(env, "owner/agent-repo", { number: 5, title: "Self-reported bug", body: "", state: "open", user: { login: "miner1" }, labels: [], html_url: "https://github.com/owner/agent-repo/issues/5", created_at: "2026-05-27T00:00:00Z", updated_at: "2026-05-27T00:00:00Z" }); + await upsertPullRequestFromGitHub(env, "owner/agent-repo", { number: 9, title: "Fix self-reported bug", state: "open", user: { login: "miner1" }, head: { sha: "a9" }, labels: [], body: "Closes #5" }); + vi.setSystemTime(new Date("2026-05-28T02:00:00.000Z")); + + await processJob(env, { type: "agent-regate-sweep", requestedBy: "test", repoFullName: "owner/agent-repo" }); + + const audit = await env.DB.prepare("select metadata_json from audit_events where event_type = ?").bind("agent.sweep.regate").first<{ metadata_json: string }>(); + const meta = JSON.parse(audit?.metadata_json ?? "{}"); + expect(meta.verdicts).toMatchObject({ "9": "failure" }); + expect(meta.flaggedPulls).toContain(9); + }); + it("agent re-gate sweep skips advisory AI review while refreshing the PR surface on a stale AI-enabled PR", async () => { let aiCalls = 0; const env = createTestEnv({