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/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ const repositorySettingsSchema = z.object({
checkRunMode: z.enum(["off", "enabled"]).default("off"),
checkRunDetailLevel: z.enum(["minimal", "standard", "deep"]).default("standard"),
gateCheckMode: z.enum(["off", "enabled"]).default("off"),
linkedIssueGateMode: z.enum(["off", "advisory", "block"]).default("block"),
linkedIssueGateMode: z.enum(["off", "advisory", "block"]).default("advisory"),
duplicatePrGateMode: z.enum(["off", "advisory", "block"]).default("block"),
qualityGateMode: z.enum(["off", "advisory", "block"]).default("advisory"),
qualityGateMinScore: z.number().int().min(0).max(100).nullable().optional(),
Expand Down
4 changes: 2 additions & 2 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
checkRunMode: "off",
checkRunDetailLevel: "minimal",
gateCheckMode: "off",
linkedIssueGateMode: "block",
linkedIssueGateMode: "advisory",
duplicatePrGateMode: "block",
qualityGateMode: "advisory",
qualityGateMinScore: null,
Expand Down Expand Up @@ -436,7 +436,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
checkRunMode: settings.checkRunMode ?? "off",
checkRunDetailLevel: settings.checkRunDetailLevel ?? "minimal",
gateCheckMode: settings.gateCheckMode ?? "off",
linkedIssueGateMode: settings.linkedIssueGateMode ?? "block",
linkedIssueGateMode: settings.linkedIssueGateMode ?? "advisory",
duplicatePrGateMode: settings.duplicatePrGateMode ?? "block",
qualityGateMode: settings.qualityGateMode ?? "advisory",
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
Expand Down
2 changes: 1 addition & 1 deletion src/github/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ export async function createOrUpdatePendingGateCheckRun(
output: {
title: "Gittensory Gate is evaluating",
summary: "Gittensory is running deterministic public PR hygiene checks.",
text: "The Gate preserves legacy linked-issue and duplicate-PR blockers unless this repository explicitly sets those rules to advisory or off.",
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.",
},
});
}
Expand Down
22 changes: 17 additions & 5 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,13 +780,14 @@ function shouldProcessPullRequestPublicSurface(action: string | undefined): bool
return PR_PUBLIC_SURFACE_ACTIONS.has(action ?? "") || PR_GATE_CLOSED_ACTIONS.has(action ?? "");
}

function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null) {
function gateCheckPolicy(settings: RepositorySettings, readinessScore?: number | null, confirmedContributor?: boolean) {
return {
linkedIssueGateMode: settings.linkedIssueGateMode,
duplicatePrGateMode: settings.duplicatePrGateMode,
qualityGateMode: settings.qualityGateMode,
qualityGateMinScore: settings.qualityGateMinScore ?? null,
readinessScore: readinessScore ?? null,
confirmedContributor,
};
}

Expand Down Expand Up @@ -955,11 +956,22 @@ async function maybePublishPrPublicSurface(
scopedOverlapCount: unionScopedOverlapClusters(collisions, pr, preflight.collisions).length,
});

const gateEvaluation = settings.gateCheckMode === "enabled" ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total)) : undefined;
// Only CONFIRMED gittensor contributors can be hard-blocked; everyone else (or an unavailable
// detection) gets a neutral, non-blocking gate. `official` may be null if no public output ran.
const confirmedContributor = official?.status === "confirmed";
const gateEvaluation =
settings.gateCheckMode === "enabled" ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total, confirmedContributor)) : undefined;
if (gateEnabled) {
const gateCheckResult = await createOrUpdateGateCheckRun(env, installationId, repoFullName, advisory, gateCheckPolicy(settings, readiness.total), {
checkRunId: pendingGateCheckRunId,
});
const gateCheckResult = await createOrUpdateGateCheckRun(
env,
installationId,
repoFullName,
advisory,
gateCheckPolicy(settings, readiness.total, confirmedContributor),
{
checkRunId: pendingGateCheckRunId,
},
);
if (gateCheckResult?.kind === "permission_missing") {
await auditGateCheckPermissionMissing(env, author, repoFullName, pr.number, webhook.deliveryId, gateCheckResult.warning);
}
Expand Down
59 changes: 45 additions & 14 deletions src/rules/advisory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export type GateCheckPolicy = {
qualityGateMode?: GateRuleMode | undefined;
qualityGateMinScore?: number | null | undefined;
readinessScore?: number | null | 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. */
confirmedContributor?: boolean | undefined;
};

export type GateCheckEvaluation = {
Expand Down Expand Up @@ -277,28 +281,56 @@ export function formatCheckRunOutput(
}

export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPolicy = {}): GateCheckEvaluation {
const evaluationBlockers = advisoryResult.findings.filter((finding) => isEvaluationBlocker(finding.code));
const warnings = advisoryResult.findings.filter((finding) => finding.severity === "warning");
// App/infra state (repo not synced yet, PR not cached): gittensory cannot evaluate this PR yet, so the
// gate is NEUTRAL (non-blocking) and re-evaluates automatically on the next sync/webhook. Never block a
// contributor on the app's OWN state.
if (advisoryResult.findings.some((finding) => isEvaluationBlocker(finding.code))) {
return {
enabled: true,
conclusion: "neutral",
title: "Gittensory Gate — not evaluated yet",
summary: "Gittensory has not finished syncing this repo/PR. The gate stays advisory and re-evaluates automatically; no action is needed.",
blockers: [],
warnings,
};
}
const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding.code, policy));
const qualityBlocker = buildQualityGateBlocker(policy);
const blockers = [...evaluationBlockers, ...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : [])];
const blockers = [...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : [])];
// 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 (policy.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,
};
}
if (blockers.length === 0) {
return {
enabled: true,
conclusion: "success",
title: "Gittensory Gate passed",
summary: "No configured hard blocker was found. Advisory findings, if any, stay advisory.",
blockers,
warnings: advisoryResult.findings.filter((finding) => finding.severity === "warning"),
warnings,
};
}
// Name the exact blocker(s) + fix in the title so the contributor sees WHY at a glance.
const firstBlocker = blockers[0];
const titleDetail = blockers.length === 1 && firstBlocker ? sanitizeForCheckRun(firstBlocker.title) : `${blockers.length} blockers`;
return {
enabled: true,
conclusion: evaluationBlockers.length > 0 ? "action_required" : "failure",
title: evaluationBlockers.length > 0 ? "Gittensory Gate needs app attention" : "Gittensory Gate is blocking merge",
summary:
evaluationBlockers.length > 0
? "Gittensory cannot evaluate this PR until app or repo state is repaired."
: `${blockers.length} configured hard blocker${blockers.length === 1 ? "" : "s"} found.`,
conclusion: "failure",
title: `Gittensory Gate: ${titleDetail}`,
summary: blockers
.map((finding) => `${sanitizeForCheckRun(finding.title)}${finding.action ? ` — ${sanitizeForCheckRun(finding.action)}` : ""}`)
.join("; "),
blockers,
warnings: advisoryResult.findings.filter((finding) => finding.severity === "warning" && !blockers.includes(finding)),
};
Expand All @@ -325,10 +357,7 @@ export function formatGateCheckOutput(gate: GateCheckEvaluation): { title: strin
});
return {
title: gate.title,
summary:
gate.conclusion === "action_required"
? "Gittensory Gate could not evaluate this PR because app or repo state needs attention."
: "Gittensory Gate found a repo-configured hard blocker.",
summary: "Gittensory Gate found a repo-configured hard blocker.",
text: blockerLines.length > 0 ? blockerLines.join("\n") : "A configured hard blocker was found.",
};
}
Expand Down Expand Up @@ -525,7 +554,9 @@ function isEvaluationBlocker(code: string): boolean {
}

function isConfiguredGateBlocker(code: string, policy: GateCheckPolicy): boolean {
if (code === "missing_linked_issue") return gateMode(policy.linkedIssueGateMode ?? "block") === "block";
// Missing linked issue defaults to ADVISORY — issues aren't always available, so it only blocks when a
// repo explicitly opts in with linkedIssueGateMode: "block". Duplicates still default to blocking.
if (code === "missing_linked_issue") return gateMode(policy.linkedIssueGateMode ?? "advisory") === "block";
if (code === "duplicate_pr_risk") return gateMode(policy.duplicatePrGateMode ?? "block") === "block";
return false;
}
Expand Down
6 changes: 3 additions & 3 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3892,7 +3892,7 @@ export function buildPublicPrIntelligenceComment(args: {
const fallbackGateConclusion = !gateEnabled
? "success"
: !args.repo
? "action_required"
? "neutral"
: hardLinkedIssueBlock || hardDuplicateBlock
? "failure"
: "success";
Expand Down Expand Up @@ -4235,14 +4235,14 @@ function gateStatus(gateEnabled: boolean, conclusion: PublicPrPanelGateEvaluatio
if (!gateEnabled) return "⚠️ Advisory only";
if (conclusion === "success") return "✅ Passing";
if (conclusion === "action_required") return "⚠️ App action required";
if (conclusion === "neutral" || conclusion === "skipped") return "⚠️ Skipped";
if (conclusion === "neutral" || conclusion === "skipped") return "⚠️ Not blocking";
return "❌ Blocking";
}

function gateAction(conclusion: PublicPrPanelGateEvaluation["conclusion"]): string {
if (conclusion === "success") return "No configured blocker found.";
if (conclusion === "action_required") return "Install/config needs attention.";
if (conclusion === "neutral" || conclusion === "skipped") return "PR closed before full evaluation.";
if (conclusion === "neutral" || conclusion === "skipped") return "Advisory; not blocking this PR.";
return "Repo-configured hard blocker found.";
}

Expand Down
4 changes: 2 additions & 2 deletions test/unit/github-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,7 @@ describe("GitHub check runs", () => {
expect(capturedBody).toMatchObject({
name: "Gittensory Gate",
conclusion: "failure",
output: { title: "Gittensory Gate is blocking merge" },
output: { title: "Gittensory Gate: No linked issue detected" },
});
expect(capturedBody.output?.text).toContain("Link the issue before merge.");
expect(capturedBody.output?.text).not.toMatch(/reward|wallet|hotkey|trust score|reviewability|farming/i);
Expand Down Expand Up @@ -262,7 +262,7 @@ describe("GitHub check runs", () => {
output: { title: "Gittensory Gate is evaluating" },
});
expect(capturedBody).not.toHaveProperty("conclusion");
expect(capturedBody.output?.text).toContain("preserves legacy linked-issue and duplicate-PR blockers");
expect(capturedBody.output?.text).toContain("only blocks confirmed Gittensor contributors");
});

it("finalizes a known pending Gate check by id without listing check runs first", async () => {
Expand Down
84 changes: 80 additions & 4 deletions test/unit/queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ describe("queue processors", () => {
);
});

it("publishes an opt-in gate check without requiring comment output while preserving linked-issue blockers", async () => {
it("publishes an opt-in gate without comment output but keeps it advisory for a non-confirmed author", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await persistRegistrySnapshot(
env,
Expand All @@ -708,6 +708,7 @@ describe("queue processors", () => {
autoLabelEnabled: false,
checkRunMode: "off",
gateCheckMode: "enabled",
linkedIssueGateMode: "block",
requireLinkedIssue: true,
});
const calls = { minerList: 0, gateChecks: 0 };
Expand All @@ -728,7 +729,7 @@ 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: "failure", output: { title: "Gittensory Gate is blocking merge" } });
expect(body).toMatchObject({ name: "Gittensory Gate", status: "completed", conclusion: "neutral", output: { title: "Gittensory Gate — advisory only" } });
calls.gateChecks += 1;
return Response.json({ id: 900 });
}
Expand Down Expand Up @@ -793,7 +794,7 @@ 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: "failure", output: { title: "Gittensory Gate is blocking merge" } });
expect(body).toMatchObject({ status: "completed", conclusion: "neutral", output: { title: "Gittensory Gate — advisory only" } });
calls.gateChecks += 1;
return Response.json({ id: 910 });
}
Expand Down Expand Up @@ -863,7 +864,7 @@ 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: "failure", output: { title: "Gittensory Gate is blocking merge" } });
expect(body).toMatchObject({ status: "completed", conclusion: "neutral", output: { title: "Gittensory Gate — advisory only" } });
calls.gateChecks += 1;
return Response.json({ id: 920 });
}
Expand All @@ -889,6 +890,81 @@ describe("queue processors", () => {
expect(audit?.detail).toBe("not_official_gittensor_miner");
});

it("hard-blocks a confirmed Gittensor contributor when a configured blocker fires", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await persistRegistrySnapshot(
env,
normalizeRegistryPayload(
{ "JSONbored/gittensory": { emission_share: 0.01, issue_discovery_share: 0 } },
{ kind: "raw-github", url: "https://example.test" },
"2026-05-23T00:00:00.000Z",
),
);
await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123);
await upsertRepositorySettings(env, {
repoFullName: "JSONbored/gittensory",
commentMode: "all_prs",
publicAudienceMode: "oss_maintainer",
publicSurface: "comment_only",
autoLabelEnabled: false,
checkRunMode: "off",
gateCheckMode: "enabled",
linkedIssueGateMode: "block",
});
const calls = { minerList: 0, gateChecks: 0 };
let gatePatchBody: { conclusion?: string; output?: { title?: string; text?: string } } = {};
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
const method = init?.method ?? "GET";
if (url === "https://api.gittensor.io/miners") {
calls.minerList += 1;
return Response.json([
{ uid: 7, githubUsername: "confirmed-dev", githubId: "123", totalPrs: 4, totalMergedPrs: 3, totalOpenPrs: 1, totalClosedPrs: 0, totalOpenIssues: 0, totalClosedIssues: 0, totalSolvedIssues: 0, totalValidSolvedIssues: 0, isEligible: true, credibility: 1, eligibleRepoCount: 1 },
]);
}
if (url === "https://api.gittensor.io/miners/123") {
return Response.json({ repositories: [{ repositoryFullName: "JSONbored/gittensory", totalPrs: "4", totalMergedPrs: "3", totalOpenPrs: "1", totalClosedPrs: "0", totalOpenIssues: "0", totalClosedIssues: "0", isEligible: true, credibility: "1.000000" }] });
}
if (url === "https://api.gittensor.io/miners/123/prs") return Response.json([]);
if (url === "https://mirror.gittensor.io/api/v1/miners/123/issues") return Response.json({ issues: [] });
if (url.endsWith("/users/confirmed-dev")) return Response.json({ login: "confirmed-dev", public_repos: 2, followers: 1 });
if (url.includes("/users/confirmed-dev/repos")) return Response.json([]);
if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" });
if (url.includes("/commits/confirmed123/check-runs")) return Response.json({ total_count: 0, check_runs: [] });
if (url.includes("/issues/61/comments") && method === "GET") return Response.json([]);
if (url.includes("/issues/61/comments") && method === "POST") return Response.json({ id: 611 }, { status: 201 });
if (url.includes("/check-runs/940") && method === "PATCH") {
gatePatchBody = JSON.parse(String(init?.body ?? "{}")) as typeof gatePatchBody;
calls.gateChecks += 1;
return Response.json({ id: 940 });
}
if (url.includes("/check-runs") && method === "POST") {
calls.gateChecks += 1;
return Response.json({ id: 940 }, { status: 201 });
}
return new Response("not found", { status: 404 });
});

await processJob(env, {
type: "github-webhook",
deliveryId: "gate-confirmed-block",
eventName: "pull_request",
payload: {
action: "opened",
installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } },
repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } },
pull_request: { number: 61, title: "Add helper", state: "open", user: { login: "confirmed-dev" }, head: { sha: "confirmed123" }, labels: [], body: "Adds a helper." },
},
});

// A confirmed contributor with a configured hard blocker (linked-issue gate set to block, no issue
// linked) IS blocked, and the Gate names the exact blocker so the fix is obvious.
expect(calls.minerList).toBe(1);
expect(calls.gateChecks).toBe(2);
expect(gatePatchBody.conclusion).toBe("failure");
expect(gatePatchBody.output?.title).toBe("Gittensory Gate: No linked issue detected");
});

it("audits opt-in gate check permission failures without blocking webhook processing", async () => {
const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() });
await persistRegistrySnapshot(
Expand Down
Loading