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
3 changes: 3 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2650,6 +2650,9 @@ export function createApp() {
bounties,
issueQuality: issueQuality?.report,
confirmedContributor: Boolean(context.gittensorSnapshot),
// #11-13/#18: thread the local branch's changed PATHS (already in the request) so the predictor also
// evaluates the focus-manifest path policy + path-gated pre-merge checks, matching the live gate.
...(parsed.data.changedFiles ? { changedPaths: parsed.data.changedFiles.map((file) => file.path) } : {}),
});
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);
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,9 @@ const predictGateShape = {
body: z.string().optional(),
labels: z.array(z.string()).optional(),
linkedIssues: z.array(z.number().int().positive()).optional(),
// The PR's changed file PATHS (metadata only — paths, never source content). Supplying them lets the predictor
// also evaluate the focus-manifest path policy + path-gated pre-merge checks, matching the live gate (#11-13/#18).
changedPaths: z.array(z.string().min(1)).max(500).optional(),
};

// Pure local-metadata computation (no repo data, no secrets) — the agent supplies its own diff metadata
Expand Down Expand Up @@ -2089,6 +2092,7 @@ export class GittensoryMcp {
bounties,
issueQuality: issueQuality?.report,
confirmedContributor,
...(input.changedPaths === undefined ? {} : { changedPaths: input.changedPaths }),
});
return {
summary: `Predicted Gittensory gate for ${repoFullName} under the ${verdict.pack} pack: ${verdict.conclusion}.`,
Expand Down
84 changes: 66 additions & 18 deletions src/rules/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
unionScopedOverlapClusters,
type IssueQualityReport,
} from "../signals/engine";
import type { FocusManifest } from "../signals/focus-manifest";
import { buildFocusManifestGuidance, type FocusManifest } from "../signals/focus-manifest";
import { sanitizePublicComment } from "../github/commands";
import { GITTENSOR_HOME_URL } from "../github/footer";
import type { BountyRecord, GatePolicyPack, IssueRecord, PullRequestRecord, RepositoryRecord } from "../types";
Expand All @@ -17,7 +17,7 @@ const OSS_ANTI_SLOP_FUNNEL = {
message: "This repo runs the Gittensor anti-slop gate. Gittensor lets GitHub contributors earn for open-source work like this — register to start earning.",
registerUrl: GITTENSOR_HOME_URL,
} as const;
import { buildPullRequestAdvisory, evaluateGateCheck, type GateCheckConclusion } from "./advisory";
import { buildPullRequestAdvisory, evaluateGateCheck, isTestPath, type GateCheckConclusion } from "./advisory";
import { evaluatePreMergeChecks } from "../review/pre-merge-checks";

/**
Expand Down Expand Up @@ -53,14 +53,25 @@ export type PredictedGateVerdict = {
note: string;
};

const PREDICTED_GATE_NOTE =
const PREDICTED_GATE_NOTE_BASE =
"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. 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).";
"evaluated on a real PR. ";
// The slop score is ALWAYS disclaimed: it needs the diff CONTENT, which the metadata-only oracle never receives.
const PREDICTED_GATE_NOTE_SLOP = "The slop score is NOT evaluated pre-submission (it needs the diff content) and may still fail the real gate. ";
// Disclaimed only when the caller did NOT supply changed paths — then path-dependent gates can't be predicted.
const PREDICTED_GATE_NOTE_NO_PATHS =
"Provide the PR's changed paths to also predict the focus-manifest path policy and any pre-merge check scoped " +
"to changed paths; without them only path-independent title/description/label pre-merge checks are predicted. ";
const PREDICTED_GATE_NOTE_GATE_EQUALITY =
"Every author is gated the same: a configured hard blocker fails the gate regardless of confirmed-contributor " +
"status (which affects only on-chain scoring).";

/** Compose the predicted-gate note. Slop is always disclaimed; the path-policy/path-gated disclaimer drops once
* the caller supplies changed paths (#11-13/#18). */
function predictedGateNote(hasChangedPaths: boolean): string {
return PREDICTED_GATE_NOTE_BASE + PREDICTED_GATE_NOTE_SLOP + (hasChangedPaths ? "" : PREDICTED_GATE_NOTE_NO_PATHS) + PREDICTED_GATE_NOTE_GATE_EQUALITY;
}

export type PredictedGateInput = {
repoFullName: string;
Expand Down Expand Up @@ -93,9 +104,16 @@ export function buildPredictedGateVerdict(args: {
* it no longer changes the predicted verdict (the real gate fails any author on a configured blocker;
* confirmed-status affects only on-chain scoring). `undefined` → not resolved. */
confirmedContributor?: boolean | undefined;
/** The PR's changed file PATHS (metadata only — file paths, never source content, so the predictor stays
* metadata-only). When supplied, the path-dependent gates the live gate enforces are also predicted: the
* focus-manifest path policy and the path-gated pre-merge checks. Absent ⇒ only path-independent pre-merge
* checks are predicted and the note discloses the gap (#11-13/#18). */
changedPaths?: string[] | undefined;
}): PredictedGateVerdict {
const { input, manifest, repo, issues, pullRequests } = args;
const gate = manifest.gate;
const changedPaths = (args.changedPaths ?? []).filter((path) => typeof path === "string" && path.length > 0);
const hasChangedPaths = changedPaths.length > 0;

const preflight = buildPreflightResult(
{
Expand Down Expand Up @@ -153,15 +171,42 @@ 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: [] }));
// Deterministic pre-merge checks parity (#11/#18): the LIVE gate enforces the repo's `review.pre_merge_checks`
// (from the SAME public .gittensory.yml the predictor already reads). With the PR's changed paths supplied,
// evaluate ALL of them exactly as live (path-gated checks now have their `whenPaths` to match against); without
// paths, evaluate only the PATH-INDEPENDENT checks (empty `whenPaths` — title/description/label assertions),
// whose inputs are exactly the real PR's, and disclaim the path-gated ones in the note.
const predictablePreMergeChecks = hasChangedPaths ? manifest.review.preMergeChecks : manifest.review.preMergeChecks.filter((check) => check.whenPaths.length === 0);
advisory.findings.push(
...evaluatePreMergeChecks(predictablePreMergeChecks, { title: syntheticPr.title, body: syntheticPr.body, labels: syntheticPr.labels, changedPaths, filesResolved: hasChangedPaths }),
);

// Focus-manifest path policy parity (#12): the LIVE gate (manifestPolicyGateMode) pushes the three enforceable
// policy findings over the PR's changed paths. Mirror it when the caller supplied paths and the PUBLIC config
// opts in — recompute the guidance and append ONLY the policy codes, then thread manifestPolicyGateMode into
// evaluateGateCheck below so block-mode blocks (advisory stays a warning). Without paths, this is skipped.
if (hasChangedPaths && gate.manifestPolicy !== null && gate.manifestPolicy !== "off") {
const guidance = buildFocusManifestGuidance({
manifest,
changedPaths,
labels: syntheticPr.labels,
linkedIssueCount: syntheticPr.linkedIssues.length,
testFileCount: changedPaths.filter((path) => isTestPath(path)).length,
passedValidationCount: 0,
});
const policyCodes = new Set(["manifest_blocked_path", "manifest_linked_issue_required", "manifest_missing_tests"]);
for (const finding of guidance.findings) {
if (!policyCodes.has(finding.code)) continue;
advisory.findings.push({
code: finding.code,
severity: finding.severity,
title: finding.title,
detail: finding.detail,
/* v8 ignore next -- the three policy findings always carry an action; the no-action arm is unreachable here. */
...(finding.action !== undefined ? { action: finding.action } : {}),
});
}
}

// 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 All @@ -180,6 +225,9 @@ export function buildPredictedGateVerdict(args: {
qualityGateMinScore: gate.readinessMinScore ?? null,
aiReviewGateMode: gate.aiReviewMode ?? undefined,
mergeReadinessGateMode: gate.mergeReadiness ?? undefined,
// #12: only meaningful when changed paths were supplied (the policy findings are pushed above only then);
// absent paths ⇒ no manifest finding exists, so this mode has nothing to act on (byte-identical).
manifestPolicyGateMode: gate.manifestPolicy ?? undefined,
selfAuthoredLinkedIssueGateMode: gate.selfAuthoredLinkedIssue ?? undefined,
readinessScore: readiness.total,
confirmedContributor: effectiveConfirmedContributor,
Expand All @@ -200,6 +248,6 @@ export function buildPredictedGateVerdict(args: {
blockers: evaluation.blockers.map(publicSafeFinding),
warnings: evaluation.warnings.map(publicSafeFinding),
funnel: pack === "oss-anti-slop" ? { ...OSS_ANTI_SLOP_FUNNEL } : null,
note: PREDICTED_GATE_NOTE,
note: predictedGateNote(hasChangedPaths),
};
}
20 changes: 20 additions & 0 deletions test/unit/mcp-predict-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ describe("MCP gittensory_predict_gate", () => {
expect((minimal.structuredContent as { pack: string }).pack).toBe("oss-anti-slop");
});

it("predicts the focus-manifest path policy when changedPaths are supplied (#11-13/#18)", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" });
// Public config: oss-anti-slop (no account needed), manifest path policy in block mode, dist/** blocked.
await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "oss-anti-slop", manifestPolicy: "block" }, blockedPaths: ["dist/**"] });
const client = await connect(env);

const result = await client.callTool({
name: "gittensory_predict_gate",
arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Build output", changedPaths: ["dist/bundle.js"] },
});
expect(result.isError).toBeFalsy();
const data = result.structuredContent as { conclusion: string; blockers: Array<{ code: string }>; note: string };
expect(data.conclusion).toBe("failure");
expect(data.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(true);
// With paths supplied the note drops the "provide changed paths" disclaimer but still disclaims slop.
expect(data.note).not.toContain("Provide the PR's changed paths");
expect(data.note.toLowerCase()).toContain("slop");
});

// Parity (#gate-nonconfirmed): every author is gated identically now — a synthetic PR that trips a blocker
// predicts `failure` regardless of confirmed status, matching the real maintainer gate. The prediction still
// resolves + surfaces the caller's confirmed status (transparency / on-chain scoring context) but it no
Expand Down
79 changes: 77 additions & 2 deletions test/unit/predicted-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,22 @@ const BASE_INPUT: PredictedGateInput = {
linkedIssues: [7],
};

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

Expand Down Expand Up @@ -216,6 +225,72 @@ describe("buildPredictedGateVerdict", () => {
expect(result.note).toContain("scoped to changed paths");
expect(result.note.toLowerCase()).toContain("slop");
});

it("with changedPaths supplied, predicts a path-GATED pre-merge check that now matches (#11/#18)", () => {
const result = verdict({
gate: {},
changedPaths: ["src/upload/client.ts"],
review: { pre_merge_checks: [{ name: "Tests for src", title_contains: "ZZZ-never", when_paths: ["src/**"], enforce: true }] },
});
expect(result.conclusion).toBe("failure");
expect(result.blockers.some((b) => b.code === "pre_merge_check_required")).toBe(true);
});

it("with changedPaths that do NOT match, the path-gated check is N/A (no finding)", () => {
const result = verdict({
gate: {},
changedPaths: ["docs/readme.md"],
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);
});

it("predicts a manifest path-policy BLOCK when a changed path hits a blocked glob and manifestPolicy:block (#12)", () => {
const result = verdict({
gate: { manifestPolicy: "block" },
manifestExtra: { blockedPaths: ["dist/**"] },
changedPaths: ["dist/bundle.js"],
});
expect(result.conclusion).toBe("failure");
expect(result.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(true);
// The note no longer disclaims path-policy once paths are supplied, but slop stays disclaimed.
expect(result.note).not.toContain("Provide the PR's changed paths");
expect(result.note.toLowerCase()).toContain("slop");
});

it("manifestPolicy:advisory does NOT block on a blocked path (parity with the live advisory gate) (#12)", () => {
// The blocked-path finding is critical, so under advisory mode it neither blocks nor surfaces as a warning —
// exactly how the live gate treats it. The meaningful parity is that advisory never fails the prediction.
const result = verdict({
gate: { manifestPolicy: "advisory" },
manifestExtra: { blockedPaths: ["dist/**"] },
changedPaths: ["dist/bundle.js"],
});
expect(result.conclusion).not.toBe("failure");
expect(result.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(false);
});

it("manifestPolicy:off (default) emits NO manifest finding even when a blocked path is touched", () => {
const result = verdict({
gate: { manifestPolicy: "off" },
manifestExtra: { blockedPaths: ["dist/**"] },
changedPaths: ["dist/bundle.js"],
});
expect(result.blockers.some((b) => b.code === "manifest_blocked_path")).toBe(false);
expect(result.warnings.some((w) => w.code === "manifest_blocked_path")).toBe(false);
});

it("ignores non-policy guidance findings (e.g. off-focus) — only the three enforceable policy codes are threaded (#12)", () => {
// The path isn't blocked but it's outside the wanted areas → guidance emits the NON-policy `manifest_off_focus`.
// The predictor must skip it (only manifest_blocked_path / _linked_issue_required / _missing_tests are gateable).
const result = verdict({
gate: { manifestPolicy: "block" },
manifestExtra: { wantedPaths: ["src/**"] },
changedPaths: ["docs/readme.md"],
});
expect(result.conclusion).not.toBe("failure");
expect([...result.blockers, ...result.warnings].some((f) => f.code === "manifest_off_focus")).toBe(false);
});
});

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