diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 88368e0c23..834713baf2 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -558,7 +558,13 @@ 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 }); + const linkedIssueAuthorLogins = await resolveLinkedIssueAuthorLogins(env, repoFullName, pr.linkedIssues); + 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); @@ -3056,17 +3062,22 @@ 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)]); + const [repo, otherOpenPullRequests, linkedIssueAuthorLogins] = await Promise.all([ + getRepository(env, repoFullName), + listOtherOpenPullRequests(env, repoFullName, pr.number), + resolveLinkedIssueAuthorLogins(env, repoFullName, pr.linkedIssues), + ]); 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 5e0b3d4bb0..28307518fc 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -141,7 +141,19 @@ 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 }); + const issueAuthorsByNumber = new Map( + issues + .filter((issue) => issue.repoFullName === input.repoFullName) + .map((issue) => [issue.number, issue.authorLogin]), + ); + const linkedIssueAuthorLogins = syntheticPr.linkedIssues.map( + (issueNumber) => issueAuthorsByNumber.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 6f36fd6acc..71a9b58159 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; -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 { @@ -400,4 +400,18 @@ describe("resolveLinkedIssueAuthorLogins", () => { const result = await resolveLinkedIssueAuthorLogins(brokenEnv, "owner/repo", [1]); expect(result).toEqual([null]); }); + + it("threads linked issue authors into authorized PR action advisories", 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" }); + const pr: PullRequestRecord = { repoFullName: "owner/repo", number: 99, title: "Fix self-authored bug", state: "open", authorLogin: "Miner1", body: "Closes #12", labels: [], linkedIssues: [12] }; + + const { advisory } = await buildAuthorizedPrActionAdvisory(env, "owner/repo", pr, settings({ selfAuthoredLinkedIssueGateMode: "block" })); + const gate = evaluateGateCheck(advisory, gateCheckPolicy(settings({ selfAuthoredLinkedIssueGateMode: "block" }), 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); + }); }); diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index c9cc26b3f6..32fd4044a0 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): IssueRecord { + return { repoFullName: "acme/widgets", number, title, state: "open", labels: [], linkedPrs: [], authorAssociation: null, authorLogin } as IssueRecord; } const BASE_INPUT: PredictedGateInput = { @@ -72,6 +72,18 @@ describe("buildPredictedGateVerdict", () => { expect(result.blockers.some((b) => b.code === "missing_linked_issue")).toBe(false); }); + it("predicts a BLOCK when the public self-authored linked-issue gate has issue-author evidence", () => { + const result = verdict({ gate: { selfAuthoredLinkedIssue: "block" }, issues: [openIssue(7, "Uploads should retry on 5xx", "Miner1")] }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "self_authored_linked_issue")).toBe(true); + }); + + it("does not predict the self-authored linked-issue finding when issue-author evidence is absent", () => { + const result = verdict({ gate: { selfAuthoredLinkedIssue: "block" }, issues: [openIssue(7, "Uploads should retry on 5xx")] }); + expect(result.conclusion).toBe("success"); + expect(result.blockers.some((b) => b.code === "self_authored_linked_issue")).toBe(false); + }); + it("honors public gate.mergeReadiness when predicting blockers", () => { const result = verdict({ gate: { duplicates: "off", mergeReadiness: "block" },