From 9d8d3a233fbc4a4f5aac023e5390966e974b9d78 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:26:50 -0700 Subject: [PATCH] feat(miner): pre-submission gate prediction on the local branch (self-gate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the biggest miner gap: a contributor can now see "will my PR pass repo X's gate, and what would block it?" BEFORE opening the PR — the score-moving feedback that was previously only computed in the maintainer PR pipeline. - New src/rules/predicted-gate.ts: buildPredictedGateVerdict builds a SYNTHETIC PR from the local branch metadata and runs the EXACT same engine the maintainer PR pipeline runs — buildPullRequestAdvisory + buildPublicReadinessScore + evaluateGateCheck — so the verdict a miner sees pre-submission is the verdict the gate computes post-submission (true parity, one engine). - Boundary-safe by construction: 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 verdict is labelled predicted/public_config and notes that private overrides and the dual-model AI-consensus blocker are not evaluated pre-submission. - Wired into POST /v1/local/branch-analysis (already requireContributorAccess + self-scoped) as a `predictedGate` block, so every existing miner branch-analysis tool inherits it. Output runs through sanitizePublicComment (public-safe). - A non-confirmed contributor's prediction is forced neutral, matching the real gate (only confirmed Gittensor contributors are ever hard-blocked). Tests: clean pass; duplicate→block (and off→no block); missing-linked-issue blocks only on linkedIssue:block; non-confirmed→neutral. Branch coverage 97%. Part of #525. --- src/api/routes.ts | 22 ++++- src/rules/predicted-gate.ts | 144 +++++++++++++++++++++++++++++++ test/unit/predicted-gate.test.ts | 81 +++++++++++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 src/rules/predicted-gate.ts create mode 100644 test/unit/predicted-gate.test.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index aeb8617a2c..2fb245501c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -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"; @@ -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, analysis.generatedAt); await recordRouteProductUsage(c, { surface: "api", diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts new file mode 100644 index 0000000000..d31864b787 --- /dev/null +++ b/src/rules/predicted-gate.ts @@ -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, + }; +} diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts new file mode 100644 index 0000000000..e190e7462b --- /dev/null +++ b/test/unit/predicted-gate.test.ts @@ -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; input?: Partial; 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); + }); +});