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
2 changes: 1 addition & 1 deletion src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
});
}
Expand Down
10 changes: 5 additions & 5 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
19 changes: 10 additions & 9 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,8 +594,9 @@ export function changedPathsForGuardrail(files: Awaited<ReturnType<typeof listPu
* (an acting autonomy level), reuse the CANONICAL verdict produced by the full gate evaluation, plan the
* GitHub state actions, and run them through the
* executor's deny-toward-safety gate stack (pause → approval → write-permission → mode). Decoupled and
* best-effort: a failure here never affects the gate or the public surface. gittensory never acts on a
* non-confirmed contributor's PR — the same rule the gate uses to never block one.
* best-effort: a failure here never affects the gate or the public surface. The agent acts purely off the
* gate verdict + CI state — every author is handled identically (auto-merge on a clean pass, one-shot close
* on a real blocker), since confirmed-status no longer changes the gate. (#gate-nonconfirmed)
*/
async function maybeRunAgentMaintenance(
env: Env,
Expand Down Expand Up @@ -1621,9 +1622,9 @@ export function gateCheckPolicy(
) {
// `settings` is already the EFFECTIVE config (`.gittensory.yml` > 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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
26 changes: 10 additions & 16 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/rules/predicted-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 5 additions & 5 deletions src/settings/agent-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 8 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -468,16 +469,16 @@ 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;
qualityGateMode: GateRuleMode;
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;
Expand Down
29 changes: 16 additions & 13 deletions test/unit/gate-check-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

Expand Down Expand Up @@ -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"]);
});
});

Expand Down Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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);
});
});
}
Expand Down
3 changes: 2 additions & 1 deletion test/unit/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading
Loading