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
18 changes: 16 additions & 2 deletions src/rules/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const OSS_ANTI_SLOP_FUNNEL = {
registerUrl: GITTENSOR_HOME_URL,
} as const;
import { buildPullRequestAdvisory, evaluateGateCheck, type GateCheckConclusion } from "./advisory";
import { evaluatePreMergeChecks } from "../review/pre-merge-checks";

/**
* Pre-submission "will my PR pass the gate?" prediction for a MINER, computed BEFORE a PR exists.
Expand Down Expand Up @@ -55,8 +56,11 @@ export type PredictedGateVerdict = {
const PREDICTED_GATE_NOTE =
"Predicted from the repo's public .gittensory.yml gate config + safe defaults. The maintainer may have " +
"private dashboard overrides not reflected here, and the dual-model AI-consensus blocker is only " +
"evaluated on a real PR. Every author is gated the same: a configured hard blocker fails the gate " +
"regardless of confirmed-contributor status (which affects only on-chain scoring).";
"evaluated on a real PR. Diff-dependent checks are NOT evaluated pre-submission and may still fail the real " +
"gate: the slop score, the focus-manifest path policy, and any pre-merge check scoped to changed paths " +
"(path-independent title/description/label pre-merge checks ARE predicted). Every author is gated the same: " +
"a configured hard blocker fails the gate regardless of confirmed-contributor status (which affects only " +
"on-chain scoring).";

export type PredictedGateInput = {
repoFullName: string;
Expand Down Expand Up @@ -149,6 +153,16 @@ export function buildPredictedGateVerdict(args: {
const linkedIssueAuthorLogins = syntheticPr.linkedIssues.map((issueNumber) => issueAuthorByNumber.get(issueNumber) ?? null);
const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: pullRequests, requireLinkedIssue, linkedIssueAuthorLogins });

// Deterministic pre-merge checks parity (#11/#18), partial: the LIVE gate enforces the repo's
// `review.pre_merge_checks` (from the SAME public .gittensory.yml the predictor already reads), but the
// predictor ignored them — so a PR the gate would auto-close on an enforced title/description rule predicted
// "success". Evaluate the PATH-INDEPENDENT checks (empty `whenPaths` — title/description/label assertions) here:
// their inputs (title/body/labels) are exactly the real PR's, so the result is identical to live. Path-gated
// checks, manifest-policy, and slop need the PR's changed files/diff (not available pre-submission) and are
// disclaimed in the note below; they reach parity once the predictor accepts changed paths.
const alwaysApplyPreMergeChecks = manifest.review.preMergeChecks.filter((check) => check.whenPaths.length === 0);
advisory.findings.push(...evaluatePreMergeChecks(alwaysApplyPreMergeChecks, { title: syntheticPr.title, body: syntheticPr.body, labels: syntheticPr.labels, changedPaths: [] }));

// 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.
const pack: GatePolicyPack = gate.pack ?? "gittensor";
Expand Down
46 changes: 44 additions & 2 deletions test/unit/predicted-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ const BASE_INPUT: PredictedGateInput = {
linkedIssues: [7],
};

function verdict(args: { gate: Record<string, unknown>; input?: Partial<PredictedGateInput>; issues?: IssueRecord[]; pullRequests?: PullRequestRecord[] }) {
function verdict(args: { gate: Record<string, unknown>; review?: Record<string, unknown>; input?: Partial<PredictedGateInput>; issues?: IssueRecord[]; pullRequests?: PullRequestRecord[] }) {
return buildPredictedGateVerdict({
input: { ...BASE_INPUT, ...args.input },
manifest: parseFocusManifest({ gate: args.gate }),
manifest: parseFocusManifest({ gate: args.gate, ...(args.review ? { review: args.review } : {}) }),
repo: REPO,
issues: args.issues ?? [openIssue(7, "Uploads should retry on 5xx")],
pullRequests: args.pullRequests ?? [],
Expand Down Expand Up @@ -174,6 +174,48 @@ describe("buildPredictedGateVerdict", () => {
expect(result.conclusion).toBe("failure");
expect(result.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true);
});

it("predicts a BLOCK for an enforced path-INDEPENDENT pre-merge check the title fails (#11/#18)", () => {
// The repo's public .gittensory.yml enforces a conventional-style title; the PR title lacks "[FEAT]".
const result = verdict({
gate: {},
review: { pre_merge_checks: [{ name: "Conventional title", title_contains: "[FEAT]", enforce: true }] },
});
expect(result.conclusion).toBe("failure");
expect(result.blockers.some((b) => b.code === "pre_merge_check_required")).toBe(true);
});

it("predicts a PASS once the path-independent pre-merge check is satisfied", () => {
const result = verdict({
gate: {},
input: { title: "[FEAT] Add retry to the upload client" },
review: { pre_merge_checks: [{ name: "Conventional title", title_contains: "[FEAT]", enforce: true }] },
});
expect(result.conclusion).not.toBe("failure");
expect(result.blockers.some((b) => b.code === "pre_merge_check_required")).toBe(false);
});

it("surfaces a non-enforced path-independent pre-merge check as a WARNING, not a blocker", () => {
const result = verdict({
gate: {},
review: { pre_merge_checks: [{ name: "Mention testing", description_contains: "tested", enforce: false }] },
});
expect(result.conclusion).not.toBe("failure");
expect(result.warnings.some((w) => w.code === "pre_merge_check_failed")).toBe(true);
});

it("does NOT predict a path-GATED pre-merge check pre-submission (no diff) and discloses the gap in the note (#11/#18)", () => {
// A path-gated check whose title assertion the PR fails — but it is scoped to changed paths, which are
// unknown pre-submission, so it must be skipped (not falsely block) and called out in the note.
const result = verdict({
gate: {},
review: { pre_merge_checks: [{ name: "Tests for src", title_contains: "ZZZ-never", when_paths: ["src/**"], enforce: true }] },
});
expect(result.blockers.some((b) => b.code === "pre_merge_check_required")).toBe(false);
expect(result.warnings.some((w) => w.code === "pre_merge_check_unresolved")).toBe(false);
expect(result.note).toContain("scoped to changed paths");
expect(result.note.toLowerCase()).toContain("slop");
});
});

describe("pack-aware prediction (#693)", () => {
Expand Down
Loading