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
11 changes: 9 additions & 2 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<ReturnType<typeof getRepository>>; advisory: ReturnType<typeof buildPullRequestAdvisory> }> {
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 };
}
Expand Down
8 changes: 7 additions & 1 deletion src/rules/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 38 additions & 2 deletions test/unit/gate-check-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): RepositorySettings {
return {
Expand Down Expand Up @@ -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);
});
});
19 changes: 17 additions & 2 deletions test/unit/predicted-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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");
Expand Down
18 changes: 18 additions & 0 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading