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
22 changes: 21 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, bu
import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor";
import { buildPullRequestReviewability, type PullRequestReviewability } from "../signals/reward-risk";
import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch";
import { buildPredictedGateVerdict } from "../rules/predicted-gate";
import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../signals/local-scorer-diagnostics";
import { compileFocusManifestPolicy } from "../signals/focus-manifest";
import { loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader";
Expand Down Expand Up @@ -2081,7 +2082,26 @@ export function createApp() {
issueQuality: issueQuality?.report,
gittensorSnapshot: context.gittensorSnapshot,
});
const response = { ...analysis, dataQuality: await loadRepoDataQuality(c.env, parsed.data.repoFullName) };
// Pre-submission gate prediction: the SAME advisory + evaluateGateCheck the maintainer PR pipeline
// runs, over a synthetic PR from this local branch, using ONLY the repo's PUBLIC .gittensory.yml gate
// policy (never the maintainer's private DB settings). Self-scoped (requireContributorAccess above).
const predictedGate = buildPredictedGateVerdict({
input: {
repoFullName: parsed.data.repoFullName,
contributorLogin: parsed.data.login,
title: parsed.data.title ?? analysis.prPacket.titleSuggestion,
body: parsed.data.body,
labels: parsed.data.labels,
linkedIssues: parsed.data.linkedIssues,
},
manifest: repoManifest,
repo,
issues,
pullRequests,
bounties,
issueQuality: issueQuality?.report,
});
const response = { ...analysis, predictedGate, dataQuality: await loadRepoDataQuality(c.env, parsed.data.repoFullName) };
await persistSignal(c.env, "local-branch-analysis", `${parsed.data.login}:${parsed.data.repoFullName}:${parsed.data.branchName ?? parsed.data.headRef ?? "local"}`, parsed.data.repoFullName, response as unknown as Record<string, JsonValue>, analysis.generatedAt);
await recordRouteProductUsage(c, {
surface: "api",
Expand Down
144 changes: 144 additions & 0 deletions src/rules/predicted-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
buildCollisionReport,
buildPreflightResult,
buildPublicReadinessScore,
buildQueueHealth,
unionScopedOverlapClusters,
type IssueQualityReport,
} from "../signals/engine";
import type { FocusManifest } from "../signals/focus-manifest";
import { sanitizePublicComment } from "../github/commands";
import type { BountyRecord, IssueRecord, PullRequestRecord, RepositoryRecord } from "../types";
import { buildPullRequestAdvisory, evaluateGateCheck, type GateCheckConclusion } from "./advisory";

/**
* Pre-submission "will my PR pass the gate?" prediction for a MINER, computed BEFORE a PR exists.
*
* Parity: it runs the EXACT same engine the maintainer PR pipeline runs — buildPullRequestAdvisory +
* evaluateGateCheck over a synthetic PR built from the contributor's local branch metadata. The verdict a
* miner sees pre-submission is therefore the same verdict the gate would compute post-submission.
*
* Boundary: the gate POLICY is sourced ONLY from the repo's PUBLIC `.gittensory.yml` (`manifest.gate`) +
* safe defaults — never the maintainer's private dashboard/DB settings. The `.gittensory.yml` is in the
* repo and publicly viewable, so this leaks nothing a contributor could not already read. The result is
* explicitly labelled "predicted" and notes that private overrides and AI-consensus blockers are not
* evaluated pre-submission.
*/
export type PredictedGateVerdict = {
predicted: true;
basis: "public_config";
conclusion: GateCheckConclusion;
title: string;
summary: string;
readinessScore: number | null;
confirmedContributor: boolean | undefined;
blockers: Array<{ code: string; title: string; detail: string; action?: string | undefined }>;
warnings: Array<{ code: string; title: string; detail: string; action?: string | undefined }>;
note: string;
};

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. Only confirmed Gittensor contributors are ever hard-blocked.";

export type PredictedGateInput = {
repoFullName: string;
contributorLogin: string;
title: string;
body?: string | undefined;
labels?: string[] | undefined;
linkedIssues?: number[] | undefined;
authorAssociation?: string | undefined;
};

function publicSafeFinding(finding: { code: string; title: string; detail: string; action?: string | undefined }) {
return {
code: finding.code,
title: sanitizePublicComment(finding.title),
detail: sanitizePublicComment(finding.detail),
action: finding.action ? sanitizePublicComment(finding.action) : undefined,
};
}

export function buildPredictedGateVerdict(args: {
input: PredictedGateInput;
manifest: FocusManifest;
repo: RepositoryRecord | null;
issues: IssueRecord[];
pullRequests: PullRequestRecord[];
bounties?: BountyRecord[] | undefined;
issueQuality?: IssueQualityReport | null | undefined;
/** The contributor's OWN confirmed-Gittensor status (self-data). `false` → the real gate would stay
* neutral for them; `undefined` → not gated on contributor status. */
confirmedContributor?: boolean | undefined;
}): PredictedGateVerdict {
const { input, manifest, repo, issues, pullRequests } = args;
const gate = manifest.gate;

// A synthetic open PR from the local branch metadata — fed to the SAME advisory builder as a real PR.
const syntheticPr: PullRequestRecord = {
repoFullName: input.repoFullName,
number: 0,
title: input.title,
state: "open",
authorLogin: input.contributorLogin,
authorAssociation: input.authorAssociation ?? null,
body: input.body ?? null,
labels: input.labels ?? [],
linkedIssues: input.linkedIssues ?? [],
};

const preflight = buildPreflightResult(
{
repoFullName: input.repoFullName,
contributorLogin: input.contributorLogin,
title: input.title,
body: input.body,
labels: input.labels,
linkedIssues: input.linkedIssues,
authorAssociation: input.authorAssociation,
},
repo,
issues,
pullRequests,
args.bounties ?? [],
args.issueQuality,
);
const collisions = buildCollisionReport(input.repoFullName, issues, pullRequests);
const queueHealth = buildQueueHealth(repo, issues, pullRequests, collisions);
const readiness = buildPublicReadinessScore({
pr: syntheticPr,
preflight,
queueHealth,
scopedOverlapCount: unionScopedOverlapClusters(collisions, syntheticPr, preflight.collisions).length,
});

// Linked-issue finding is surfaced when the repo's public policy treats it as anything but `off`, so the
// gate can evaluate it; evaluateGateCheck decides whether it actually blocks (block) or stays advisory.
const requireLinkedIssue = gate.linkedIssue !== null && gate.linkedIssue !== "off";
const advisory = buildPullRequestAdvisory(repo, syntheticPr, { otherOpenPullRequests: pullRequests, requireLinkedIssue });

const evaluation = evaluateGateCheck(advisory, {
linkedIssueGateMode: gate.linkedIssue ?? undefined,
duplicatePrGateMode: gate.duplicates ?? undefined,
qualityGateMode: gate.readinessMode ?? undefined,
qualityGateMinScore: gate.readinessMinScore ?? null,
aiReviewGateMode: gate.aiReviewMode ?? undefined,
readinessScore: readiness.total,
confirmedContributor: args.confirmedContributor,
});

return {
predicted: true,
basis: "public_config",
conclusion: evaluation.conclusion,
title: sanitizePublicComment(evaluation.title),
summary: sanitizePublicComment(evaluation.summary),
readinessScore: readiness.total,
confirmedContributor: args.confirmedContributor,
blockers: evaluation.blockers.map(publicSafeFinding),
warnings: evaluation.warnings.map(publicSafeFinding),
note: PREDICTED_GATE_NOTE,
};
}
81 changes: 81 additions & 0 deletions test/unit/predicted-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildPredictedGateVerdict, type PredictedGateInput } from "../../src/rules/predicted-gate";
import { parseFocusManifest } from "../../src/signals/focus-manifest";
import type { IssueRecord, PullRequestRecord, RepositoryRecord } from "../../src/types";

const REPO: RepositoryRecord = { fullName: "acme/widgets", owner: "acme", name: "widgets", isInstalled: true, isRegistered: true, isPrivate: false };

function openPr(number: number, title: string, linkedIssues: number[] = [], authorLogin = "someone"): PullRequestRecord {
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;
}

const BASE_INPUT: PredictedGateInput = {
repoFullName: "acme/widgets",
contributorLogin: "miner1",
title: "Add retry to the upload client",
body: "Closes #7",
linkedIssues: [7],
};

function verdict(args: { gate: Record<string, unknown>; input?: Partial<PredictedGateInput>; issues?: IssueRecord[]; pullRequests?: PullRequestRecord[] }) {
return buildPredictedGateVerdict({
input: { ...BASE_INPUT, ...args.input },
manifest: parseFocusManifest({ gate: args.gate }),
repo: REPO,
issues: args.issues ?? [openIssue(7, "Uploads should retry on 5xx")],
pullRequests: args.pullRequests ?? [],
});
}

describe("buildPredictedGateVerdict", () => {
it("predicts a pass for a clean diff with a linked issue and no duplicate", () => {
const result = verdict({ gate: { duplicates: "block", linkedIssue: "advisory" } });
expect(result.predicted).toBe(true);
expect(result.basis).toBe("public_config");
expect(result.conclusion).toBe("success");
expect(result.blockers).toHaveLength(0);
expect(result.note).toContain("public .gittensory.yml");
});

it("predicts a BLOCK when a duplicate PR exists and duplicates:block (the default)", () => {
// Another open PR already targets the same linked issue → duplicate_pr_risk.
const result = verdict({ gate: { duplicates: "block" }, pullRequests: [openPr(42, "Retry uploads on 5xx responses", [7])] });
expect(result.conclusion).toBe("failure");
expect(result.blockers.some((b) => b.code === "duplicate_pr_risk")).toBe(true);
// Public-safe: blocker text carries a fix and no raw internal markers.
expect(result.title.toLowerCase()).toContain("gittensory gate");
});

it("does NOT block on a duplicate when duplicates:off", () => {
const result = verdict({ gate: { duplicates: "off" }, pullRequests: [openPr(42, "Retry uploads on 5xx responses", [7])] });
expect(result.conclusion).not.toBe("failure");
expect(result.blockers.some((b) => b.code === "duplicate_pr_risk")).toBe(false);
});

it("predicts a BLOCK for a missing linked issue only when linkedIssue:block", () => {
const blocked = verdict({ gate: { linkedIssue: "block" }, input: { body: "no issue here", linkedIssues: [] }, issues: [] });
expect(blocked.conclusion).toBe("failure");
expect(blocked.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true);

// Default (advisory) → not a hard blocker.
const advisory = verdict({ gate: { linkedIssue: "advisory" }, input: { body: "no issue here", linkedIssues: [] }, issues: [] });
expect(advisory.blockers.some((b) => b.code === "missing_linked_issue")).toBe(false);
});

it("forces a neutral prediction for a self-declared non-confirmed contributor", () => {
const result = buildPredictedGateVerdict({
input: { ...BASE_INPUT, body: "no issue", linkedIssues: [] },
manifest: parseFocusManifest({ gate: { linkedIssue: "block" } }),
repo: REPO,
issues: [],
pullRequests: [],
confirmedContributor: false, // a non-confirmed contributor is never hard-blocked by the real gate
});
expect(result.conclusion).toBe("neutral");
expect(result.blockers).toHaveLength(0);
});
});