From 9d0dea4d46a3e04508862ca402860dbee1634ab1 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:05:10 -0700 Subject: [PATCH] fix(gate): gate non-confirmed contributors normally (eliminate the manual-review backlog) Remove the blanket "non-confirmed contributor never blocks" rule across every surface. Non-confirmed contributors are now gated identically to confirmed authors: a real blocker fails the gate (one-shot close), a clean PR passes (auto-merge). Confirmed-Gittensor status now affects only on-chain scoring, never the merge/close verdict. - predicted-gate.ts: docstrings (PREDICTED_GATE_NOTE + the confirmedContributor field) updated to describe the new rule; the predictor already delegated the verdict to evaluateGateCheck, so it mirrors the real gate exactly. - app.ts: the pending Gate check-run text no longer claims the gate "only blocks confirmed Gittensor contributors". - processors.ts / agent-actions.ts / types.ts / mcp/server.ts: comment + JSDoc consistency for the new policy; the agent acts purely off the gate verdict, so the auto-merge/close behavior follows automatically. - Tests: gate-check-policy, predicted-gate, mcp-predict-gate, parity-wire, queue, github-app, rules, safety updated to assert the new gate-normally behavior (failure on a blocker, success when clean) instead of the old forced-neutral; the first-time-contributor grace assertions are unchanged. First-time-newcomer grace and the eval-not-ready neutral remain intact. --- src/github/app.ts | 2 +- src/mcp/server.ts | 10 +++++----- src/queue/processors.ts | 19 ++++++++++--------- src/rules/advisory.ts | 26 ++++++++++---------------- src/rules/predicted-gate.ts | 8 +++++--- src/settings/agent-actions.ts | 10 +++++----- src/types.ts | 15 ++++++++------- test/unit/gate-check-policy.test.ts | 29 ++++++++++++++++------------- test/unit/github-app.test.ts | 3 ++- test/unit/mcp-predict-gate.test.ts | 29 +++++++++++++++++------------ test/unit/parity-wire.test.ts | 21 +++++++++++++-------- test/unit/predicted-gate.test.ts | 15 +++++++++------ test/unit/queue.test.ts | 25 +++++++++++++++---------- test/unit/rules.test.ts | 18 ++++++++---------- test/unit/safety.test.ts | 5 +++-- 15 files changed, 127 insertions(+), 108 deletions(-) diff --git a/src/github/app.ts b/src/github/app.ts index 15265cdc27..5865ffb76d 100644 --- a/src/github/app.ts +++ b/src/github/app.ts @@ -169,7 +169,7 @@ export async function createOrUpdatePendingGateCheckRun( output: { title: "Gittensory Gate is evaluating", summary: "Gittensory is running deterministic public PR hygiene checks.", - text: "The Gate only blocks confirmed Gittensor contributors, and only on configured blockers (duplicate PRs by default). For everyone else, and while state is still syncing, it stays advisory.", + text: "The Gate blocks every author on the repo's configured hard blockers (duplicate PRs by default); on everything else, and while state is still syncing, it stays advisory.", }, }); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e4221f88f9..b57b117e83 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1964,11 +1964,11 @@ export class GittensoryMcp { loadOrComputeIssueQualityResponse(this.env, repoFullName), loadRepoFocusManifest(this.env, repoFullName), ]); - // Parity with the maintainer gate: only CONFIRMED Gittensor contributors are ever hard-blocked, so the - // prediction must know the caller's own confirmed status — otherwise it over-reports `failure` for a - // non-confirmed contributor whose synthetic PR trips a blocker. Resolve it the same way the pipeline - // does (official Gittensor API → confirmed). The oss-anti-slop pack drops the contributor gate entirely, - // so skip the lookup there (keeps the prediction account-free for non-Gittensor adopters). + // Resolve the caller's own confirmed-Gittensor status the same way the maintainer pipeline does (official + // Gittensor API → confirmed). It is surfaced in the verdict for transparency but no longer changes the + // predicted conclusion — every author is gated identically, so a blocker predicts `failure` regardless of + // confirmed status (parity with the new real gate). The oss-anti-slop pack carries no contributor field at + // all, so skip the lookup there (keeps the prediction account-free for non-Gittensor adopters). const pack = manifest.gate.pack ?? "gittensor"; const confirmedContributor = pack === "oss-anti-slop" ? undefined : (await fetchGittensorContributorSnapshot(input.login)) !== null; const verdict = buildPredictedGateVerdict({ diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 3181f9adbb..de7596b4c9 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -594,8 +594,9 @@ export function changedPathsForGuardrail(files: Awaited DB > defaults), resolved upstream by // resolveRepositorySettings, so the blocker modes here reflect the repo's config file directly. - // The `oss-anti-slop` pack (#692) is repo-agnostic: it blocks ANY author whose PR trips an opted-in - // deterministic rule, so it drops the confirmed-contributor gate entirely (no Gittensor coupling). The - // `gittensor` pack keeps the contributor gate — only confirmed contributors are hard-blocked. + // The `oss-anti-slop` pack (#692) is repo-agnostic and carries no confirmed-contributor field at all (no + // Gittensor coupling). The `gittensor` pack still threads confirmedContributor for context/telemetry, but + // it no longer changes the verdict — every author is gated identically. (#gate-nonconfirmed) const confirmedContributorForPack = settings.gatePack === "oss-anti-slop" ? undefined : confirmedContributor; return { linkedIssueGateMode: settings.linkedIssueGateMode, @@ -1963,7 +1964,7 @@ async function maybePublishPrPublicSurface( const unifiedCommentAllowed = isUnifiedReviewCommentEnabled(env) && isConvergenceRepoAllowed(env, repoFullName); // `settings` is the EFFECTIVE config (`.gittensory.yml` > DB > defaults), resolved by the caller via // resolveRepositorySettings — so gate on/off and every blocker mode already reflect the repo's config - // file. The gate only chooses what to do; confirmedContributor governs WHO can be blocked. + // file. The gate verdict is the same for every author; confirmedContributor feeds only on-chain scoring. const gateEnabled = settings.gateCheckMode === "enabled" && Boolean(advisory.headSha); // Cheap, network-free skip checks (also avoids the miner lookup when it would be wasted). const prelim = decidePublicSurface({ @@ -2097,9 +2098,9 @@ async function maybePublishPrPublicSurface( }); } - // Only CONFIRMED gittensor contributors can be hard-blocked; everyone else (or an unavailable - // detection) gets a neutral, non-blocking gate. Gate-only runs still verify confirmation before - // evaluating blockers so confirmed contributors cannot bypass a required Gate check. + // Resolve the author's confirmed-Gittensor status. It feeds on-chain SCORING and the public surface, but + // it no longer gates the verdict — every author is hard-blocked the same way on a configured blocker, and + // a clean PR passes the same way. (#gate-nonconfirmed) const confirmedContributor = official?.status === "confirmed"; // Anti-slop (#530/#532): only when opted in (slopGateMode !== "off"). Surface the deterministic slop diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 86023f830c..d2acca7af1 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -45,9 +45,10 @@ export type GateCheckPolicy = { authorMergedPrCount?: number | undefined; /** The PR author's closed-unmerged PR count in THIS repo (repeat offender = >= 3). Used only by grace. */ authorClosedUnmergedPrCount?: number | undefined; - /** ONLY confirmed gittensor contributors can be hard-blocked. When explicitly `false`, the gate is - * forced to a neutral (non-blocking) conclusion regardless of blockers — gittensory must never block - * a non-confirmed contributor. `undefined` = the caller did not gate on contributor status. */ + /** The PR author's confirmed-Gittensor status. Carried for context/telemetry only — it no longer + * changes the gate verdict (every author is gated identically; a configured blocker fails the gate + * regardless of confirmed status, which now affects only on-chain scoring). `undefined` = unresolved. + * (#gate-nonconfirmed) */ confirmedContributor?: boolean | undefined; }; @@ -348,19 +349,12 @@ export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPol const qualityBlocker = buildQualityGateBlocker(effective); const slopBlocker = buildSlopGateBlocker(effective); const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : []), ...(slopBlocker ? [slopBlocker] : [])]; - // Contributor-gated: ONLY confirmed Gittensor contributors can be hard-blocked. For everyone else the - // gate is neutral (non-blocking) + the minimal advisory comment — gittensory must never block a - // non-confirmed contributor, regardless of what blockers fired. - if (effective.confirmedContributor === false && blockers.length > 0) { - return { - enabled: true, - conclusion: "neutral", - title: "Gittensory Gate — advisory only", - summary: "The PR author is not a confirmed Gittensor contributor, so gittensory does not block this PR. Findings stay advisory.", - blockers: [], - warnings, - }; - } + // Non-confirmed contributors are gated NORMALLY (real blockers → failure → one-shot close; clean → success → + // merge), the SAME as confirmed contributors: the review + CI + guardrail vet every PR, and confirmed-status + // affects only on-chain SCORING, never the merge/close decision. (#gate-nonconfirmed) The old blanket + // "never block a non-confirmed contributor" forced every non-confirmed PR with a blocker to a neutral → HELD + // state, burying the maintainer in manual review. Genuine newcomers stay protected by the opt-in first-time- + // contributor grace immediately below; everyone else is auto-merged/closed so the queue stays automated. // First-time-contributor grace (#552): when the maintainer opted in, a genuine newcomer (0 merged PRs in // this repo) who is NOT a repeat offender (< 3 closed-unmerged PRs) gets a neutral, non-blocking gate even // when blockers fired — they keep the advisory findings without the hard block. Repeat offenders, authors diff --git a/src/rules/predicted-gate.ts b/src/rules/predicted-gate.ts index 215043c973..fdf8c64c91 100644 --- a/src/rules/predicted-gate.ts +++ b/src/rules/predicted-gate.ts @@ -55,7 +55,8 @@ 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. Only confirmed Gittensor contributors are ever hard-blocked."; + "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)."; export type PredictedGateInput = { repoFullName: string; @@ -84,8 +85,9 @@ export function buildPredictedGateVerdict(args: { 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. */ + /** The contributor's OWN confirmed-Gittensor status (self-data). Carried through for transparency only — + * 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; }): PredictedGateVerdict { const { input, manifest, repo, issues, pullRequests } = args; diff --git a/src/settings/agent-actions.ts b/src/settings/agent-actions.ts index 5fb755d5a9..65b465119c 100644 --- a/src/settings/agent-actions.ts +++ b/src/settings/agent-actions.ts @@ -170,9 +170,9 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne const acting = (actionClass: AgentActionClass) => isActingAutonomyLevel(level(actionClass)); const approval = (actionClass: AgentActionClass) => autonomyRequiresApproval(level(actionClass)); - // Only a SKIPPED gate (genuinely not evaluated) drives no action. A NEUTRAL gate (advisory-only blockers on a - // non-confirmed contributor, or eval-not-ready) is gate-NON-BLOCKING: it flows to the disposition so the PR is - // merged (clean+green) or HELD with a label — never left silently undecided. (#harm-stop neutral-silent-stuck) + // Only a SKIPPED gate (genuinely not evaluated) drives no action. A NEUTRAL gate (first-time-contributor + // grace, or eval-not-ready while state is still syncing) is gate-NON-BLOCKING: it flows to the disposition so + // the PR is merged (clean+green) or HELD with a label — never left silently undecided. (#harm-stop neutral-silent-stuck) if (input.conclusion === "skipped") return actions; // CI state over ALL of the PR's checks (required OR not — codecov/patch included) — reviewbot's ci_red @@ -184,8 +184,8 @@ export function planAgentMaintenanceActions(input: AgentActionPlanInput): Planne if (input.ciState === "pending") return actions; // Only SUCCESS earns the review-good auto-merge. A NEUTRAL gate flows (no longer silently returns []) but is - // NOT auto-merged — it falls through to a HELD + labeled state for review. (Auto-merging neutral / non-confirmed - // contributor PRs is a separate trust/policy decision, deliberately NOT bundled into the harm-stop.) (#harm-stop) + // NOT auto-merged — it falls through to a HELD + labeled state for review. (Auto-merging a neutral / grace + // PR is a separate trust/policy decision, deliberately NOT bundled into the harm-stop.) (#harm-stop) const gatePassing = input.conclusion === "success"; // A changed path matching a hard guardrail forces manual review (suppresses auto-MERGE / auto-approve / auto-close). // Fail SAFE on UNKNOWN paths (#1062): when guardrails are configured but the changed-file set is empty (cache diff --git a/src/types.ts b/src/types.ts index b324207655..9f2cd0bbc8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -454,10 +454,11 @@ export type BountyRecord = { export type GateRuleMode = "off" | "advisory" | "block"; -/** Which policy pack the gate runs under (#692). `gittensor` = the full Gittensor policy: only confirmed - * Gittensor contributors are hard-blocked (registry/emissions-aware). `oss-anti-slop` = a general, repo- - * agnostic pack: the same deterministic rules (slop/duplicate/linked-issue/readiness/AI-consensus) block - * ANY author, with no emissions/registry/confirmed-contributor coupling — so the gate runs on any repo. */ +/** Which policy pack the gate runs under (#692). `gittensor` = the full Gittensor policy: registry/emissions- + * aware, and it threads the author's confirmed status for on-chain scoring (the gate verdict itself blocks + * every author the same — confirmed status no longer changes it, #gate-nonconfirmed). `oss-anti-slop` = a + * general, repo-agnostic pack: the same deterministic rules (slop/duplicate/linked-issue/readiness/AI- + * consensus) block ANY author, with no emissions/registry/Gittensor coupling — so the gate runs on any repo. */ export type GatePolicyPack = "gittensor" | "oss-anti-slop"; export type RepositorySettings = { @@ -468,8 +469,8 @@ export type RepositorySettings = { checkRunMode: "off" | "enabled"; checkRunDetailLevel: "minimal" | "standard" | "deep"; gateCheckMode: "off" | "enabled"; - /** Policy pack the gate evaluates under (#692). Default `gittensor` (confirmed-contributor-gated, - * registry-aware). `oss-anti-slop` runs the deterministic rules against any author on any repo. */ + /** Policy pack the gate evaluates under (#692). Default `gittensor` (registry-aware; threads confirmed + * status for scoring only). `oss-anti-slop` runs the deterministic rules against any author on any repo. */ gatePack: GatePolicyPack; linkedIssueGateMode: GateRuleMode; duplicatePrGateMode: GateRuleMode; @@ -477,7 +478,7 @@ export type RepositorySettings = { qualityGateMinScore?: number | null | undefined; /** Deterministic anti-slop signal (#530/#532). `off` = no slop score; `advisory` = surface the slop * score + warnings in context; `block` = ALSO hard-block when slopRisk >= slopGateMinScore (deterministic - * only, confirmed-contributor-gated like every blocker). Default `off` — opt-in via .gittensory.yml. */ + * only, applies to every author like every blocker). Default `off` — opt-in via .gittensory.yml. */ slopGateMode: GateRuleMode; /** Merge-readiness gate (#merge-readiness). `off`/`advisory`/`block`. No min-score. Default `off`. */ mergeReadinessGateMode: GateRuleMode; diff --git a/test/unit/gate-check-policy.test.ts b/test/unit/gate-check-policy.test.ts index 6cd95e7a1d..c1447b2a39 100644 --- a/test/unit/gate-check-policy.test.ts +++ b/test/unit/gate-check-policy.test.ts @@ -89,18 +89,19 @@ describe(".gittensory.yml settings override (resolveEffectiveSettings)", () => { expect(evaluateGateCheck(missingIssueAdvisory(), newcomerPolicy).conclusion).toBe("neutral"); }); - it("still only blocks confirmed contributors regardless of the config", () => { + it("blocks a non-confirmed contributor identically to a confirmed one (#gate-nonconfirmed)", () => { const eff = resolveEffectiveSettings(settings({ linkedIssueGateMode: "advisory" }), parseFocusManifest({ gate: { linkedIssue: "block" } })); + // Non-confirmed now gates NORMALLY: a configured blocker → failure, the same verdict a confirmed author gets. const nonConfirmed = evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(eff, null, false)); - expect(nonConfirmed.conclusion).toBe("neutral"); - expect(nonConfirmed.blockers).toEqual([]); + expect(nonConfirmed.conclusion).toBe("failure"); + expect(nonConfirmed.blockers.map((finding) => finding.code)).toEqual(["missing_linked_issue"]); }); }); describe("policy pack (#692)", () => { - it("gittensor pack hard-blocks only confirmed contributors", () => { + it("gittensor pack hard-blocks every author the same — confirmed status no longer changes the verdict (#gate-nonconfirmed)", () => { const gittensor = settings({ gatePack: "gittensor", linkedIssueGateMode: "block" }); - expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(gittensor, null, false)).conclusion).toBe("neutral"); + expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(gittensor, null, false)).conclusion).toBe("failure"); expect(evaluateGateCheck(missingIssueAdvisory(), gateCheckPolicy(gittensor, null, true)).conclusion).toBe("failure"); }); @@ -145,10 +146,10 @@ describe("AI consensus defect gate blocker", () => { expect(evaluateGateCheck(aiDefectAdvisory(), gateCheckPolicy(eff, null, true)).conclusion).toBe("failure"); }); - it("never blocks a non-confirmed contributor even with aiReview: block", () => { + it("blocks a non-confirmed contributor under aiReview: block, the same as a confirmed one (#gate-nonconfirmed)", () => { const result = evaluateGateCheck(aiDefectAdvisory(), gateCheckPolicy(settings({ aiReviewMode: "block" }), null, false)); - expect(result.conclusion).toBe("neutral"); - expect(result.blockers).toEqual([]); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.map((f) => f.code)).toEqual(["ai_consensus_defect"]); }); }); @@ -177,8 +178,10 @@ describe("slop gate (#530/#532)", () => { expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopRisk: 59, confirmedContributor: true }).conclusion).toBe("success"); }); - it("respects the confirmed-contributor gate (never blocks a non-confirmed author)", () => { - expect(evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopGateMinScore: 60, slopRisk: 90, confirmedContributor: false }).conclusion).toBe("neutral"); + it("blocks a non-confirmed author on slop the same as a confirmed one (#gate-nonconfirmed)", () => { + const result = evaluateGateCheck(cleanAdvisory(), { slopGateMode: "block", slopGateMinScore: 60, slopRisk: 90, confirmedContributor: false }); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.map((finding) => finding.code)).toContain("slop_risk_above_threshold"); }); it("gateCheckPolicy threads slop settings + the live slopRisk into the policy (incl. .gittensory.yml)", () => { @@ -327,10 +330,10 @@ describe("focus-manifest policy gate (#555)", () => { expect(evaluateGateCheck(manifestAdvisory(code), { manifestPolicyGateMode: "advisory", confirmedContributor: true }).conclusion).toBe("success"); }); - it("never blocks a non-confirmed contributor even with manifestPolicy: block", () => { + it("blocks a non-confirmed contributor under manifestPolicy: block, the same as a confirmed one (#gate-nonconfirmed)", () => { const result = evaluateGateCheck(manifestAdvisory(code), { manifestPolicyGateMode: "block", confirmedContributor: false }); - expect(result.conclusion).toBe("neutral"); - expect(result.blockers).toEqual([]); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.map((finding) => finding.code)).toContain(code); }); }); } diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 2933a96c3a..58c4a1e348 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -309,7 +309,8 @@ describe("GitHub check runs", () => { output: { title: "Gittensory Gate is evaluating" }, }); expect(capturedBody).not.toHaveProperty("conclusion"); - expect(capturedBody.output?.text).toContain("only blocks confirmed Gittensor contributors"); + // The Gate blocks every author the same on a configured blocker (confirmed status no longer gates the verdict). + expect(capturedBody.output?.text).toContain("blocks every author"); }); it("finalizes a known pending Gate check by id without listing check runs first", async () => { diff --git a/test/unit/mcp-predict-gate.test.ts b/test/unit/mcp-predict-gate.test.ts index ed17058e34..342dc617ad 100644 --- a/test/unit/mcp-predict-gate.test.ts +++ b/test/unit/mcp-predict-gate.test.ts @@ -46,10 +46,10 @@ describe("MCP gittensory_predict_gate", () => { expect((minimal.structuredContent as { pack: string }).pack).toBe("oss-anti-slop"); }); - // Parity regression (#627-class): under the default `gittensor` pack, only CONFIRMED Gittensor - // contributors are ever hard-blocked. The prediction must resolve the caller's confirmed status — if it - // doesn't (the bug), a non-confirmed contributor whose synthetic PR trips a blocker is wrongly told - // `failure` when the real maintainer gate would return `neutral`. + // 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 + // longer changes the verdict. describe("contributor-confirmation parity under the gittensor pack", () => { afterEach(() => vi.unstubAllGlobals()); @@ -64,12 +64,12 @@ describe("MCP gittensory_predict_gate", () => { }); } - it("stays NEUTRAL for a non-confirmed contributor even when a blocker fires", async () => { + it("predicts FAILURE for a non-confirmed contributor when a blocker fires (#gate-nonconfirmed)", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); // gittensor pack, linked-issue blocks; the contributor supplies no linked issue → blocker fires. await upsertRepoFocusManifest(env, "acme/widgets", { gate: { pack: "gittensor", linkedIssue: "block" } }); - stubGittensorMiners([]); // miner1 is NOT a confirmed Gittensor contributor + stubGittensorMiners([]); // miner1 is NOT a confirmed Gittensor contributor — gated the same regardless const client = await connect(env); const result = await client.callTool({ @@ -77,9 +77,11 @@ describe("MCP gittensory_predict_gate", () => { arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client", linkedIssues: [] }, }); expect(result.isError).toBeFalsy(); - const data = result.structuredContent as { pack: string; conclusion: string; confirmedContributor: boolean | undefined }; + const data = result.structuredContent as { pack: string; conclusion: string; confirmedContributor: boolean | undefined; blockers: Array<{ code: string }> }; expect(data.pack).toBe("gittensor"); - expect(data.conclusion).toBe("neutral"); + expect(data.conclusion).toBe("failure"); + expect(data.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); + // Confirmed status is still surfaced (transparency / scoring) — it just no longer changes the verdict. expect(data.confirmedContributor).toBe(false); }); @@ -101,7 +103,7 @@ describe("MCP gittensory_predict_gate", () => { expect(data.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); }); - it("treats a Gittensor API failure as non-confirmed (fail-safe NEUTRAL, never a false FAILURE)", async () => { + it("treats a Gittensor API failure as non-confirmed, but the gate verdict is unaffected by it (#gate-nonconfirmed)", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: "acme/widgets" }); // No explicit pack → defaults to the gittensor pack, which still resolves confirmed status via the API. @@ -109,7 +111,9 @@ describe("MCP gittensory_predict_gate", () => { // The confirmation lookup is the only network call on the prediction path (the URL is a fixed constant // base; the login is never interpolated into it — it is filtered client-side — so there is no SSRF // surface). When that call fails/times out, fetchGittensorContributorSnapshot resolves to null, so the - // contributor is treated as non-confirmed → the gate stays neutral rather than wrongly blocking them. + // contributor is surfaced as non-confirmed. Confirmed status no longer changes the verdict, so the gate + // is computed purely from the public config and still predicts FAILURE on the configured blocker — an API + // outage can neither falsely block nor falsely un-block a contributor. vi.stubGlobal("fetch", async () => { throw new Error("network down"); }); @@ -120,9 +124,10 @@ describe("MCP gittensory_predict_gate", () => { arguments: { login: "miner1", owner: "acme", repo: "widgets", title: "Add retry to upload client", linkedIssues: [] }, }); expect(result.isError).toBeFalsy(); - const data = result.structuredContent as { conclusion: string; confirmedContributor: boolean | undefined }; + const data = result.structuredContent as { conclusion: string; confirmedContributor: boolean | undefined; blockers: Array<{ code: string }> }; expect(data.confirmedContributor).toBe(false); - expect(data.conclusion).toBe("neutral"); + expect(data.conclusion).toBe("failure"); + expect(data.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); }); }); diff --git a/test/unit/parity-wire.test.ts b/test/unit/parity-wire.test.ts index 97eadd34f7..9291d1b285 100644 --- a/test/unit/parity-wire.test.ts +++ b/test/unit/parity-wire.test.ts @@ -246,7 +246,8 @@ async function generatePrivateKeyPem(): Promise { return `-----BEGIN PRIVATE KEY-----\n${b64}\n-----END PRIVATE KEY-----\n`; } -// A confirmed-miner snapshot so the gate can hard-BLOCK (a non-confirmed author always gets a neutral gate). +// A confirmed-miner snapshot (confirmed status now feeds only on-chain scoring; the gate blocks every author +// the same on a configured blocker — #gate-nonconfirmed). function parityMinerSnapshot(login: string) { return { source: "gittensor_api" as const, @@ -293,7 +294,8 @@ async function seedGateEnabledRepo(env: Env): Promise { } // The miner-list/token/check-run endpoints the gate finalize touches; `confirmedAuthor` toggles whether the -// gittensor miner list confirms the PR author (a confirmed author can be hard-blocked → a `failure` gate). +// gittensor miner list confirms the PR author. Confirmed status no longer changes the gate verdict (it feeds +// scoring); the configured blocker fails the gate for either author (#gate-nonconfirmed). function stubFinalizeFetch(confirmedAuthor: string | null): void { vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { const url = input.toString(); @@ -341,18 +343,21 @@ describe("recordNativeGateDecision wired into the review FINALIZE path (GITTENSO expect(rows[0]!.summary).toBe("missing_linked_issue"); }); - it("FLAG-ON, NON-confirmed author → NEUTRAL gate: the call site still runs (non-failure reasonCode side) but a neutral conclusion is non-comparable → NO native row", async () => { + it("FLAG-ON, NON-confirmed author + linked-issue block: gated NORMALLY → FAILURE → a comparable 'hold' native row (#gate-nonconfirmed)", async () => { const env = createTestEnv({ GITTENSORY_REVIEW_PARITY_AUDIT: "true", GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await seedGateEnabledRepo(env); - stubFinalizeFetch(null); // miner list empty → author unconfirmed → gate cannot hard-block → neutral + stubFinalizeFetch(null); // miner list empty → author unconfirmed, but confirmed status no longer changes the verdict try { - await processJob(env, prWebhook("parity-finalize-neutral", "contributor")); + await processJob(env, prWebhook("parity-finalize-nonconfirmed", "contributor")); } finally { vi.unstubAllGlobals(); } - // reasonCode took the non-"failure" branch (= the neutral conclusion); recordNativeGateDecision no-ops on a - // non-comparable (neutral) conclusion, so nothing is written — the call site ran, the recorder declined. - expect(await nativeRows(env)).toEqual([]); + // The blocker (missing linked issue) fails the gate regardless of confirmed status → failure → native action + // "hold", which IS comparable → recordNativeGateDecision writes one row with the blocker code as the reason. + const rows = await nativeRows(env); + expect(rows.length).toBe(1); + expect(rows[0]).toMatchObject({ decision: "hold", source: GITTENSORY_NATIVE_SOURCE }); + expect(rows[0]!.summary).toBe("missing_linked_issue"); }); it("FLAG-OFF (default): the finalize path records NOTHING — byte-identical review path, no native row", async () => { diff --git a/test/unit/predicted-gate.test.ts b/test/unit/predicted-gate.test.ts index 32d34a2ce3..c9cc26b3f6 100644 --- a/test/unit/predicted-gate.test.ts +++ b/test/unit/predicted-gate.test.ts @@ -134,17 +134,17 @@ describe("buildPredictedGateVerdict", () => { expect(result.blockers.some((b) => b.code === "duplicate_pr_risk")).toBe(true); }); - it("forces a neutral prediction for a self-declared non-confirmed contributor", () => { + it("predicts a non-confirmed contributor NORMALLY — a blocker → failure, matching the real gate (#gate-nonconfirmed)", () => { 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 + confirmedContributor: false, // confirmed status no longer changes the verdict — every author is gated the same }); - expect(result.conclusion).toBe("neutral"); - expect(result.blockers).toHaveLength(0); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); }); }); @@ -177,7 +177,7 @@ describe("pack-aware prediction (#693)", () => { expect(result.confirmedContributor).toBeUndefined(); }); - it("under gittensor, the same non-confirmed contributor stays neutral (matches the real gate)", () => { + it("under gittensor, a non-confirmed contributor is predicted FAILURE on a blocker (matches the real gate, #gate-nonconfirmed)", () => { const result = buildPredictedGateVerdict({ input: { ...BASE_INPUT, body: "no issue", linkedIssues: [] }, manifest: parseFocusManifest({ gate: { pack: "gittensor", linkedIssue: "block" } }), @@ -187,7 +187,10 @@ describe("pack-aware prediction (#693)", () => { confirmedContributor: false, }); expect(result.pack).toBe("gittensor"); - expect(result.conclusion).toBe("neutral"); + expect(result.conclusion).toBe("failure"); + expect(result.blockers.some((b) => b.code === "missing_linked_issue")).toBe(true); + // Confirmed status is still surfaced for transparency — it just no longer changes the verdict. + expect(result.confirmedContributor).toBe(false); }); it("runs on a non-Gittensor (app-installed, unregistered) repo under oss-anti-slop with no Gittensor account", () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 0abd081fd8..f2807dd1c0 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -836,7 +836,7 @@ describe("queue processors", () => { ); }); - it("publishes an opt-in gate without comment output but keeps it advisory for a non-confirmed author", async () => { + it("publishes an opt-in gate without comment output, blocking a non-confirmed author normally (#gate-nonconfirmed)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( env, @@ -875,7 +875,8 @@ describe("queue processors", () => { } if (url.includes("/check-runs/900") && (init?.method ?? "GET") === "PATCH") { const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string; status?: string; conclusion?: string; output?: { title?: string } }; - expect(body).toMatchObject({ name: "Gittensory Gate", status: "completed", conclusion: "neutral", output: { title: "Gittensory Gate — advisory only" } }); + // Non-confirmed author + linked-issue block + no issue → gated normally → failure (#gate-nonconfirmed). + expect(body).toMatchObject({ name: "Gittensory Gate", status: "completed", conclusion: "failure", output: { title: "Gittensory Gate: No linked issue detected" } }); calls.gateChecks += 1; return Response.json({ id: 900 }); } @@ -1182,7 +1183,7 @@ describe("queue processors", () => { expect(count?.n).toBe(0); }); - it("auto-maintain (#778): never acts on a non-confirmed contributor's PR (gate stays advisory)", async () => { + it("auto-maintain (#778): takes no terminal action when merge/close/approve autonomy is not granted (gate now fails normally for a non-confirmed author)", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( env, @@ -1198,8 +1199,10 @@ describe("queue processors", () => { gateCheckMode: "enabled", autonomy: { label: "auto", request_changes: "auto" }, }); - // No confirmed-miner seed → author is unconfirmed; with a blocker the gate neutralizes (never blocks one). - // requireLinkedIssue is unset here, so the manifest's linkedIssue:block is what makes the blocker fire. + // No confirmed-miner seed → author is unconfirmed; the manifest's linkedIssue:block + no issue fires a + // blocker, so the gate now FAILS the author normally (#gate-nonconfirmed — confirmed status no longer + // neutralizes the verdict). But this repo grants only label/request_changes autonomy — NOT merge/close/ + // approve — so the failing gate yields a request-changes/label action at most, never a terminal action. await upsertRepoFocusManifest(env, "JSONbored/gittensory", { gate: { linkedIssue: "block" } }); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); @@ -1222,9 +1225,9 @@ describe("queue processors", () => { }, }); - // A non-confirmed contributor (neutral/advisory gate) is no longer left SILENT — the bot may surface it with a - // label so it's visible, but it takes NO TERMINAL action (never auto-merge/close/approve a non-confirmed or - // not-review-good PR). (#harm-stop: neutral flows to held+labeled instead of an empty plan.) + // The failing gate is surfaced (request-changes/label), but with no merge/close/approve autonomy granted the + // bot takes NO TERMINAL action — proving terminal actions require their own autonomy grant, independent of the + // gate verdict. (Auto-close on a failing gate is exercised by the #778 close-autonomy tests below.) const terminal = await env.DB.prepare("select count(*) as n from audit_events where event_type in ('agent.action.merge','agent.action.close','agent.action.approve')").first<{ n: number }>(); expect(terminal?.n).toBe(0); }); @@ -1358,7 +1361,8 @@ describe("queue processors", () => { } if (url.includes("/check-runs/910") && method === "PATCH") { const body = JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string; output?: { title?: string } }; - expect(body).toMatchObject({ status: "completed", conclusion: "neutral", output: { title: "Gittensory Gate — advisory only" } }); + // The bot author is gated normally now (no confirmation gate); linked-issue block + no issue → failure (#gate-nonconfirmed). + expect(body).toMatchObject({ status: "completed", conclusion: "failure", output: { title: "Gittensory Gate: No linked issue detected" } }); calls.gateChecks += 1; return Response.json({ id: 910 }); } @@ -1429,7 +1433,8 @@ describe("queue processors", () => { } if (url.includes("/check-runs/920") && method === "PATCH") { const body = JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string; output?: { title?: string } }; - expect(body).toMatchObject({ status: "completed", conclusion: "neutral", output: { title: "Gittensory Gate — advisory only" } }); + // The unconfirmed miner is gated normally now; linked-issue block + no issue → failure (#gate-nonconfirmed). + expect(body).toMatchObject({ status: "completed", conclusion: "failure", output: { title: "Gittensory Gate: No linked issue detected" } }); calls.gateChecks += 1; return Response.json({ id: 920 }); } diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index eb64b8b723..d404cfe27c 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -276,27 +276,25 @@ describe("advisory rules", () => { expect(gate.warnings.map((finding) => finding.code)).toEqual(["busy_pr_queue"]); }); - it("only hard-blocks confirmed Gittensor contributors — non-confirmed authors stay neutral regardless of blockers", () => { + it("gates NON-confirmed contributors normally — a real blocker closes them like a confirmed author (#gate-nonconfirmed)", () => { const blockingAdvisory = { ...buildPullRequestAdvisory(repo, null), findings: [{ code: "duplicate_pr_risk", title: "Linked issue overlaps another open PR", severity: "warning" as const, detail: "Duplicate." }], }; - // Non-confirmed author: the gate is forced neutral (non-blocking) even though a real blocker fired. + // Non-confirmed author: gated NORMALLY now — a real blocker → failure (one-shot close), no longer forced to a + // neutral/held state. Confirmed-status affects only on-chain scoring, never the gate verdict. (#gate-nonconfirmed) const nonConfirmed = evaluateGateCheck(blockingAdvisory, { duplicatePrGateMode: "block", confirmedContributor: false }); - expect(nonConfirmed.conclusion).toBe("neutral"); - expect(nonConfirmed.title).toBe("Gittensory Gate — advisory only"); - expect(nonConfirmed.summary).toContain("not a confirmed Gittensor contributor"); - expect(nonConfirmed.blockers).toEqual([]); + expect(nonConfirmed.conclusion).toBe("failure"); + expect(nonConfirmed.title).toBe("Gittensory Gate: Linked issue overlaps another open PR"); + expect(nonConfirmed.blockers.map((finding) => finding.code)).toEqual(["duplicate_pr_risk"]); - // Confirmed author with the same blocker: the gate blocks and names the blocker in the title. + // Confirmed author with the same blocker: identical verdict. const confirmed = evaluateGateCheck(blockingAdvisory, { duplicatePrGateMode: "block", confirmedContributor: true }); expect(confirmed.conclusion).toBe("failure"); - expect(confirmed.title).toBe("Gittensory Gate: Linked issue overlaps another open PR"); expect(confirmed.blockers.map((finding) => finding.code)).toEqual(["duplicate_pr_risk"]); - // A clean PR from a non-confirmed author stays a normal success (the neutral override only kicks in - // when there is actually a blocker to suppress). + // A clean PR from a non-confirmed author is a normal success → auto-merges. const cleanNonConfirmed = evaluateGateCheck({ ...buildPullRequestAdvisory(repo, null), findings: [] }, { confirmedContributor: false }); expect(cleanNonConfirmed.conclusion).toBe("success"); }); diff --git a/test/unit/safety.test.ts b/test/unit/safety.test.ts index 209d1c533c..720c834384 100644 --- a/test/unit/safety.test.ts +++ b/test/unit/safety.test.ts @@ -27,8 +27,9 @@ async function generatePrivateKeyPem(): Promise { return `-----BEGIN PRIVATE KEY-----\n${b64}\n-----END PRIVATE KEY-----\n`; } -// A confirmed-miner snapshot — only a confirmed contributor can be hard-blocked (the safety blocker must be -// able to FAIL the gate for the flag-ON assertion to be observable through the finalized conclusion). +// A confirmed-miner snapshot. The author confirmation no longer changes whether the gate can block +// (#gate-nonconfirmed); this just stands up a representative confirmed author so the flag-ON safety blocker's +// FAILURE is observable through the finalized conclusion. function safetyMinerSnapshot(login: string) { return { source: "gittensor_api" as const,