diff --git a/src/api/routes.ts b/src/api/routes.ts index e80bf1ef74..31bb834609 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -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(), diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 6776329817..f4e34167e3 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -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, @@ -436,7 +436,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial 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, @@ -288,17 +318,19 @@ export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPol 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)), }; @@ -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.", }; } @@ -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; } diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 3bbd99c9fc..f0ddd5050c 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -3892,7 +3892,7 @@ export function buildPublicPrIntelligenceComment(args: { const fallbackGateConclusion = !gateEnabled ? "success" : !args.repo - ? "action_required" + ? "neutral" : hardLinkedIssueBlock || hardDuplicateBlock ? "failure" : "success"; @@ -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."; } diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 396bb2bc7b..412d825c34 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -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); @@ -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 () => { diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 92dde1c237..2bcffdf675 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -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, @@ -708,6 +708,7 @@ describe("queue processors", () => { autoLabelEnabled: false, checkRunMode: "off", gateCheckMode: "enabled", + linkedIssueGateMode: "block", requireLinkedIssue: true, }); const calls = { minerList: 0, gateChecks: 0 }; @@ -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 }); } @@ -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 }); } @@ -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 }); } @@ -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( diff --git a/test/unit/rules.test.ts b/test/unit/rules.test.ts index 9914eed121..e8097ddb3d 100644 --- a/test/unit/rules.test.ts +++ b/test/unit/rules.test.ts @@ -149,16 +149,19 @@ describe("advisory rules", () => { expect(output.text).toContain("No configured hard blocker"); }); - it("maps broken evaluation state to action_required gate output", () => { + it("never blocks on app/infra state — keeps an unsynced repo/PR neutral", () => { const advisory = buildPullRequestAdvisory(null, null); const gate = evaluateGateCheck(advisory); const output = formatGateCheckOutput(gate); - expect(gate.conclusion).toBe("action_required"); - expect(gate.blockers.map((finding) => finding.code)).toEqual(["repo_not_registered", "pr_not_cached"]); - expect(output.title).toBe("Gittensory Gate needs app attention"); - expect(output.text).toContain("Repository registration is unknown"); - expect(output.text).toContain("Action: Refresh the Gittensor registry snapshot."); + // App-state findings (repo not synced, PR not cached) must NOT block a contributor on the app's + // own state — the gate is neutral and re-evaluates automatically. + expect(advisory.findings.map((finding) => finding.code)).toEqual(expect.arrayContaining(["repo_not_registered", "pr_not_cached"])); + expect(gate.conclusion).toBe("neutral"); + expect(gate.blockers).toEqual([]); + expect(output.title).toBe("Gittensory Gate — not evaluated yet"); + expect(output.summary).toContain("re-evaluates automatically"); + expect(output.text).toBe("Gittensory did not create a contributor-facing failure for this event."); }); it("formats and sanitizes gate blockers without leaking private scoring terms", () => { @@ -184,7 +187,7 @@ describe("advisory rules", () => { expect(output.text).not.toMatch(/reward|wallet|trust score|score estimate/i); }); - it("keeps legacy Gate blockers by default while honoring explicit advisory or off modes", () => { + it("keeps missing-issue advisory by default, blocks duplicates by default, honoring explicit modes", () => { const pr: PullRequestRecord = { repoFullName: repo.fullName, number: 21, @@ -198,7 +201,9 @@ describe("advisory rules", () => { }; const missingIssueAdvisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); - expect(evaluateGateCheck(missingIssueAdvisory).conclusion).toBe("failure"); + // Missing linked issue defaults to ADVISORY — issues aren't always available, so it only blocks + // when a repo explicitly opts in. + expect(evaluateGateCheck(missingIssueAdvisory).conclusion).toBe("success"); expect(evaluateGateCheck(missingIssueAdvisory, { linkedIssueGateMode: "advisory" }).conclusion).toBe("success"); expect(evaluateGateCheck(missingIssueAdvisory, { linkedIssueGateMode: "off" }).conclusion).toBe("success"); expect(evaluateGateCheck(missingIssueAdvisory, { linkedIssueGateMode: "block" }).conclusion).toBe("failure"); @@ -261,11 +266,40 @@ describe("advisory rules", () => { ); expect(gate.conclusion).toBe("failure"); - expect(gate.summary).toBe("3 configured hard blockers found."); + // Title names the blocker count; summary enumerates every active blocker with its fix. + expect(gate.title).toBe("Gittensory Gate: 3 blockers"); + expect(gate.summary).toContain("No linked issue detected"); + expect(gate.summary).toContain("Linked issue overlaps another open PR"); + expect(gate.summary).toContain("Readiness score is below the configured threshold — Address the short explicit PR panel actions"); expect(gate.blockers.map((finding) => finding.code)).toEqual(["missing_linked_issue", "duplicate_pr_risk", "readiness_score_below_threshold"]); 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", () => { + 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. + 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([]); + + // Confirmed author with the same blocker: the gate blocks and names the blocker in the title. + 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). + const cleanNonConfirmed = evaluateGateCheck({ ...buildPullRequestAdvisory(repo, null), findings: [] }, { confirmedContributor: false }); + expect(cleanNonConfirmed.conclusion).toBe("success"); + }); + it("formats skipped and neutral Gate outputs as non-failures", () => { for (const conclusion of ["neutral", "skipped"] as const) { const output = formatGateCheckOutput({ diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 854bedbd86..6342c0cadd 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -743,10 +743,10 @@ describe("signal coverage edge cases", () => { settings: gateSettings, }); - expect(repoBlockedComment).toContain("> [!IMPORTANT]"); - expect(repoBlockedComment).toContain("cannot evaluate the repo state"); + // App/infra state (repo not synced) never blocks a contributor — the gate stays neutral/advisory. expect(repoBlockedComment).toContain("Public profile only"); - expect(repoBlockedComment).toContain("> | Gate result | ⚠️ App action required | Install/config needs attention. | Fix app config. |"); + expect(repoBlockedComment).toContain("> | Gate result | ⚠️ Not blocking | Advisory; not blocking this PR. | No action. |"); + expect(repoBlockedComment).not.toContain("App action required"); const missingIssueComment = buildPublicPrIntelligenceComment({ repo: directRepo, @@ -1104,7 +1104,7 @@ describe("signal coverage edge cases", () => { expect(comment).toContain("> | Review load | ❌ 8/20 |"); expect(comment).toContain("> | Validation evidence | ❌ 5/25 | Cached preflight status is hold. | Fix blocker. |"); expect(comment).toContain("> | Open PR queue | ❌ 3/10 | 16 open PR(s), 0 likely reviewable, 16 unlinked. | Expect slower review. |"); - expect(comment).toContain("> | Gate result | ⚠️ Skipped | PR closed before full evaluation. | No action. |"); + expect(comment).toContain("> | Gate result | ⚠️ Not blocking | Advisory; not blocking this PR. | No action. |"); expect(comment).toContain("[JSONbored](https://github.com/JSONbored)"); expect(comment).toContain("[Gittensor profile](https://gittensor.io/miners/details?githubId=49853598)"); expect(comment).toContain("Official Gittensor activity: 29 PR(s), 6 issue(s).");