diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 6becd7e0a1..ec0df0e4ca 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -7940,6 +7940,14 @@ "qualityGateMinScore": { "type": "number", "nullable": true + }, + "mergeReadinessGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] } }, "required": [ @@ -7953,6 +7961,7 @@ "linkedIssueGateMode", "duplicatePrGateMode", "qualityGateMode", + "mergeReadinessGateMode", "autoLabelEnabled", "gittensorLabel", "createMissingLabel", diff --git a/migrations/0026_merge_readiness_gate_mode.sql b/migrations/0026_merge_readiness_gate_mode.sql new file mode 100644 index 0000000000..1090b1aab4 --- /dev/null +++ b/migrations/0026_merge_readiness_gate_mode.sql @@ -0,0 +1 @@ +ALTER TABLE repository_settings ADD COLUMN merge_readiness_gate_mode TEXT NOT NULL DEFAULT 'off'; diff --git a/src/api/routes.ts b/src/api/routes.ts index aa8ceff4ab..94bee40c7a 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -508,6 +508,7 @@ const repositorySettingsSchema = z.object({ 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(), + mergeReadinessGateMode: z.enum(["off", "advisory", "block"]).default("off"), autoLabelEnabled: z.boolean().default(true), gittensorLabel: z.string().trim().min(1).max(50).default("gittensor"), createMissingLabel: z.boolean().default(true), @@ -2444,6 +2445,7 @@ export function createApp() { duplicatePrGateMode: parsed.data.duplicatePrGateMode, qualityGateMode: parsed.data.qualityGateMode, qualityGateMinScore: parsed.data.qualityGateMinScore, + mergeReadinessGateMode: parsed.data.mergeReadinessGateMode, autoLabelEnabled: parsed.data.autoLabelEnabled, gittensorLabel: parsed.data.gittensorLabel, createMissingLabel: parsed.data.createMissingLabel, diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 6776329817..f2a4708b68 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -390,6 +390,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise duplicatePrGateMode: "block", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, @@ -413,6 +414,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise duplicatePrGateMode: parseGateRuleMode(row.duplicatePrGateMode), qualityGateMode: parseGateRuleMode(row.qualityGateMode), qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore), + mergeReadinessGateMode: parseGateRuleMode(row.mergeReadinessGateMode), autoLabelEnabled: row.autoLabelEnabled, gittensorLabel: row.gittensorLabel, createMissingLabel: row.createMissingLabel, @@ -440,6 +442,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial ({ + path: file.path, + additions: file.additions, + deletions: file.deletions, + })), + }), + ); +} + function linkedIssueDuplicatePullRequestsForGate(pr: PullRequestRecord, pullRequests: PullRequestRecord[]): number[] { const linkedIssues = new Set(pr.linkedIssues); if (linkedIssues.size === 0) return []; @@ -955,9 +972,11 @@ async function maybePublishPrPublicSurface( scopedOverlapCount: unionScopedOverlapClusters(collisions, pr, preflight.collisions).length, }); - const gateEvaluation = settings.gateCheckMode === "enabled" ? evaluateGateCheck(advisory, gateCheckPolicy(settings, readiness.total)) : undefined; + const slopFindings = settings.mergeReadinessGateMode !== "off" ? await loadSlopAdvisoryFindings(env, repoFullName, pr.number) : []; + const gatePolicy = gateCheckPolicy(settings, readiness.total, slopFindings); + const gateEvaluation = settings.gateCheckMode === "enabled" ? evaluateGateCheck(advisory, gatePolicy) : undefined; if (gateEnabled) { - const gateCheckResult = await createOrUpdateGateCheckRun(env, installationId, repoFullName, advisory, gateCheckPolicy(settings, readiness.total), { + const gateCheckResult = await createOrUpdateGateCheckRun(env, installationId, repoFullName, advisory, gatePolicy, { checkRunId: pendingGateCheckRunId, }); if (gateCheckResult?.kind === "permission_missing") { diff --git a/src/rules/advisory.ts b/src/rules/advisory.ts index 5c560a5831..3372d2425c 100644 --- a/src/rules/advisory.ts +++ b/src/rules/advisory.ts @@ -11,6 +11,7 @@ import type { } from "../types"; import type { CollisionCluster, CollisionReport } from "../signals/engine"; import { nowIso } from "../utils/json"; +import { evaluateMergeReadinessGateCheck, isMergeReadinessCompositeEnabled } from "./merge-readiness-gate"; export type GateCheckConclusion = "success" | "failure" | "action_required" | "neutral" | "skipped"; @@ -20,6 +21,8 @@ export type GateCheckPolicy = { qualityGateMode?: GateRuleMode | undefined; qualityGateMinScore?: number | null | undefined; readinessScore?: number | null | undefined; + mergeReadinessGateMode?: GateRuleMode | undefined; + slopFindings?: AdvisoryFinding[] | undefined; }; export type GateCheckEvaluation = { @@ -277,7 +280,10 @@ export function formatCheckRunOutput( } export function evaluateGateCheck(advisoryResult: Advisory, policy: GateCheckPolicy = {}): GateCheckEvaluation { - const evaluationBlockers = advisoryResult.findings.filter((finding) => isEvaluationBlocker(finding.code)); + if (isMergeReadinessCompositeEnabled(policy)) { + return evaluateMergeReadinessGateCheck(advisoryResult, policy); + } + const evaluationBlockers = advisoryResult.findings.filter((finding) => isEvaluationBlockerFinding(finding.code)); const configuredBlockers = advisoryResult.findings.filter((finding) => isConfiguredGateBlocker(finding.code, policy)); const qualityBlocker = buildQualityGateBlocker(policy); const blockers = [...evaluationBlockers, ...configuredBlockers, ...(qualityBlocker ? [qualityBlocker] : [])]; @@ -520,10 +526,18 @@ function conclusionForSeverity(severity: AdvisorySeverity, findings: AdvisoryFin return "success"; } -function isEvaluationBlocker(code: string): boolean { +export function isEvaluationBlockerFinding(code: string): boolean { return code === "repo_not_registered" || code === "repo_not_seen" || code === "pr_not_cached"; } +export function buildQualityGateFinding(policy: GateCheckPolicy): AdvisoryFinding | null { + return buildQualityGateBlocker(policy); +} + +export function gateMode(value: GateRuleMode | null | undefined): GateRuleMode { + return value === "off" || value === "block" ? value : "advisory"; +} + function isConfiguredGateBlocker(code: string, policy: GateCheckPolicy): boolean { if (code === "missing_linked_issue") return gateMode(policy.linkedIssueGateMode ?? "block") === "block"; if (code === "duplicate_pr_risk") return gateMode(policy.duplicatePrGateMode ?? "block") === "block"; @@ -544,10 +558,6 @@ function buildQualityGateBlocker(policy: GateCheckPolicy): AdvisoryFinding | nul }; } -function gateMode(value: GateRuleMode | null | undefined): GateRuleMode { - return value === "off" || value === "block" ? value : "advisory"; -} - function normalizeScore(value: number | null | undefined): number | null { if (typeof value !== "number" || !Number.isFinite(value)) return null; return Math.max(0, Math.min(100, Math.round(value))); diff --git a/src/rules/merge-readiness-gate.ts b/src/rules/merge-readiness-gate.ts new file mode 100644 index 0000000000..1b5eec3656 --- /dev/null +++ b/src/rules/merge-readiness-gate.ts @@ -0,0 +1,98 @@ +import type { Advisory, AdvisoryFinding, GateRuleMode } from "../types"; +import type { SlopAssessment } from "../signals/slop"; +import type { GateCheckEvaluation, GateCheckPolicy } from "./advisory"; +import { buildQualityGateFinding, gateMode, isEvaluationBlockerFinding } from "./advisory"; + +export function slopFindingsToAdvisoryFindings(assessment: SlopAssessment): AdvisoryFinding[] { + return assessment.findings.map((finding) => ({ + code: finding.code, + severity: finding.severity, + title: finding.title, + detail: finding.detail, + ...(finding.action ? { action: finding.action } : {}), + ...(finding.publicText ? { publicText: finding.publicText } : {}), + })); +} + +export function isMergeReadinessCompositeEnabled(policy: GateCheckPolicy): boolean { + return policy.mergeReadinessGateMode === "block" || policy.mergeReadinessGateMode === "advisory"; +} + +export function collectMergeReadinessUnmetConditions(advisory: Advisory, policy: GateCheckPolicy): AdvisoryFinding[] { + const unmet: AdvisoryFinding[] = []; + + if (subGateEnabled(policy.linkedIssueGateMode)) { + const finding = advisory.findings.find((entry) => entry.code === "missing_linked_issue"); + if (finding) unmet.push(finding); + } + if (subGateEnabled(policy.duplicatePrGateMode)) { + const finding = advisory.findings.find((entry) => entry.code === "duplicate_pr_risk"); + if (finding) unmet.push(finding); + } + if (subGateEnabled(policy.qualityGateMode)) { + const qualityFinding = buildQualityGateFinding({ ...policy, qualityGateMode: "block" }); + if (qualityFinding) unmet.push(qualityFinding); + } + if ((policy.slopFindings ?? []).length > 0) { + unmet.push(...(policy.slopFindings ?? [])); + } + + return unmet; +} + +export function evaluateMergeReadinessGateCheck(advisory: Advisory, policy: GateCheckPolicy): GateCheckEvaluation { + const evaluationBlockers = advisory.findings.filter((finding) => isEvaluationBlockerFinding(finding.code)); + const unmet = collectMergeReadinessUnmetConditions(advisory, policy); + const advisoryWarnings = advisory.findings.filter((finding) => finding.severity === "warning"); + + if (evaluationBlockers.length > 0) { + return { + enabled: true, + conclusion: "action_required", + title: "Gittensory Gate needs app attention", + summary: "Gittensory cannot evaluate this PR until app or repo state is repaired.", + blockers: evaluationBlockers, + warnings: advisoryWarnings.filter((finding) => !evaluationBlockers.includes(finding)), + }; + } + + if (unmet.length === 0) { + return { + enabled: true, + conclusion: "success", + title: "Gittensory Gate passed", + summary: "All enabled merge-readiness conditions passed.", + blockers: [], + warnings: advisoryWarnings, + }; + } + + if (gateMode(policy.mergeReadinessGateMode) === "advisory") { + return { + enabled: true, + conclusion: "success", + title: "Gittensory Gate passed", + summary: `${unmet.length} merge-readiness condition${unmet.length === 1 ? "" : "s"} remain advisory.`, + blockers: [], + warnings: [...advisoryWarnings, ...unmet], + }; + } + + return { + enabled: true, + conclusion: "failure", + title: "Gittensory Gate is blocking merge", + summary: buildMergeReadinessBlockingSummary(unmet), + blockers: unmet, + warnings: advisoryWarnings.filter((finding) => !unmet.includes(finding)), + }; +} + +function subGateEnabled(mode: GateRuleMode | undefined): boolean { + return mode !== "off"; +} + +function buildMergeReadinessBlockingSummary(unmet: AdvisoryFinding[]): string { + const labels = unmet.map((finding) => finding.title).join("; "); + return `${unmet.length} merge-readiness condition${unmet.length === 1 ? "" : "s"} still blocking: ${labels}.`; +} diff --git a/src/types.ts b/src/types.ts index 4f405e81a8..6f69c27c60 100644 --- a/src/types.ts +++ b/src/types.ts @@ -367,6 +367,7 @@ export type RepositorySettings = { duplicatePrGateMode: GateRuleMode; qualityGateMode: GateRuleMode; qualityGateMinScore?: number | null | undefined; + mergeReadinessGateMode: GateRuleMode; autoLabelEnabled: boolean; gittensorLabel: string; createMissingLabel: boolean; diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index 7d30557c4c..1b776296db 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -1007,12 +1007,19 @@ describe("api route guards and error branches", () => { checkRunDetailLevel: "deep", backfillEnabled: false, privateTrustEnabled: false, + mergeReadinessGateMode: "block", }), }, env, ); expect(updated.status).toBe(200); - await expect(updated.json()).resolves.toMatchObject({ commentMode: "all_prs", checkRunDetailLevel: "deep", backfillEnabled: false, privateTrustEnabled: false }); + await expect(updated.json()).resolves.toMatchObject({ + commentMode: "all_prs", + checkRunDetailLevel: "deep", + backfillEnabled: false, + privateTrustEnabled: false, + mergeReadinessGateMode: "block", + }); }); }); diff --git a/test/unit/data-spine.test.ts b/test/unit/data-spine.test.ts index 8ec3758785..ae9684ac59 100644 --- a/test/unit/data-spine.test.ts +++ b/test/unit/data-spine.test.ts @@ -39,6 +39,7 @@ import { upsertRepoLabel, upsertRepoSyncState, upsertRepositoryFromGitHub, + upsertRepositorySettings, } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; @@ -234,6 +235,7 @@ describe("data spine repositories", () => { checkRunMode: "off", checkRunDetailLevel: "minimal", publicSurface: "comment_and_label", + mergeReadinessGateMode: "off", }); expect(await getRepoSyncState(env, "missing/repo")).toBeNull(); expect(await getPullRequest(env, "owner/repo", 404)).toBeNull(); @@ -324,4 +326,16 @@ describe("data spine repositories", () => { expect(await listContributorPullRequests(env, "jsonbored")).toMatchObject([{ repoFullName: "owner/repo", number: 1 }]); expect(await listContributorIssues(env, "JSONBORED")).toEqual(expect.arrayContaining([expect.objectContaining({ repoFullName: "owner/repo", number: 10 }), expect.objectContaining({ repoFullName: "owner/repo", number: 11 })])); }); + + it("persists merge-readiness gate settings", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo" }); + await upsertRepositorySettings(env, { + repoFullName: "owner/repo", + mergeReadinessGateMode: "block", + }); + expect(await getRepositorySettings(env, "owner/repo")).toMatchObject({ + mergeReadinessGateMode: "block", + }); + }); }); diff --git a/test/unit/github-app.test.ts b/test/unit/github-app.test.ts index 396bb2bc7b..bad8928d0c 100644 --- a/test/unit/github-app.test.ts +++ b/test/unit/github-app.test.ts @@ -239,6 +239,61 @@ describe("GitHub check runs", () => { expect(capturedBody.output?.text).not.toMatch(/reward|wallet|hotkey|trust score|reviewability|farming/i); }); + it("formats merge-readiness composite gate blockers in check run output", async () => { + const privateKey = await generatePrivateKeyPem(); + let capturedBody: { conclusion?: string; output?: { title?: string; summary?: string; text?: string } } = {}; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs")) { + capturedBody = JSON.parse(String(init?.body)) as typeof capturedBody; + return Response.json({ id: 90 }, { status: 201 }); + } + return new Response("not found", { status: 404 }); + }); + + const result = await createOrUpdateGateCheckRun( + createTestEnv({ GITHUB_APP_PRIVATE_KEY: privateKey }), + 123, + "JSONbored/gittensory", + { + id: "gate-composite", + targetType: "pull_request", + targetKey: "JSONbored/gittensory#10", + repoFullName: "JSONbored/gittensory", + pullNumber: 10, + headSha: "gate123", + conclusion: "neutral", + severity: "warning", + title: "Gittensory advisory available", + summary: "2 advisory findings generated.", + findings: [ + { code: "missing_linked_issue", title: "No linked issue detected", severity: "warning", detail: "No closing reference.", action: "Link the issue before merge." }, + { code: "missing_test_evidence", title: "Code changes lack test evidence", severity: "warning", detail: "Code changes were detected without accompanying test evidence.", action: "Add focused tests or explain why existing coverage is sufficient." }, + ], + generatedAt: "2026-05-22T00:00:00.000Z", + }, + { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "off", + slopFindings: [ + { code: "missing_test_evidence", title: "Code changes lack test evidence", severity: "warning", detail: "Code changes were detected without accompanying test evidence.", action: "Add focused tests or explain why existing coverage is sufficient." }, + ], + }, + ); + + expect(result).toMatchObject({ kind: "published", id: 90 }); + expect(capturedBody).toMatchObject({ + conclusion: "failure", + output: { title: "Gittensory Gate is blocking merge" }, + }); + expect(capturedBody.output?.text).toContain("No linked issue detected"); + expect(capturedBody.output?.text).toContain("Code changes lack test evidence"); + }); + it("creates an in-progress Gate check without a conclusion", async () => { const privateKey = await generatePrivateKeyPem(); let capturedBody: { name?: string; status?: string; conclusion?: string; output?: { title?: string; text?: string } } = {}; diff --git a/test/unit/merge-readiness-gate.test.ts b/test/unit/merge-readiness-gate.test.ts new file mode 100644 index 0000000000..8d8b46c715 --- /dev/null +++ b/test/unit/merge-readiness-gate.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; +import { evaluateGateCheck, formatGateCheckOutput } from "../../src/rules/advisory"; +import { + evaluateMergeReadinessGateCheck, + collectMergeReadinessUnmetConditions, + isMergeReadinessCompositeEnabled, + slopFindingsToAdvisoryFindings, +} from "../../src/rules/merge-readiness-gate"; +import { buildPullRequestAdvisory } from "../../src/rules/advisory"; +import { buildSlopAssessment } from "../../src/signals/slop"; +import type { PullRequestRecord, RepositoryRecord } from "../../src/types"; + +const repo: RepositoryRecord = { + fullName: "JSONbored/gittensory", + owner: "JSONbored", + name: "gittensory", + isInstalled: true, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: "JSONbored/gittensory", + emissionShare: 0.01, + issueDiscoveryShare: 0, + maintainerCut: 0, + labelMultipliers: {}, + raw: {}, + }, +}; + +describe("merge-readiness aggregate gate", () => { + it("maps slop findings into advisory findings for composite gates", () => { + expect(slopFindingsToAdvisoryFindings(buildSlopAssessment({}))).toEqual([]); + const [finding] = slopFindingsToAdvisoryFindings( + buildSlopAssessment({ changedFiles: [{ path: "src/app.ts", additions: 4 }] }), + ); + expect(finding).toMatchObject({ code: "missing_test_evidence", title: "Code changes lack test evidence" }); + expect( + slopFindingsToAdvisoryFindings({ + slopRisk: 10, + band: "low", + findings: [ + { code: "detail_only", title: "Detail only", severity: "warning", detail: "detail" }, + { code: "action_only", title: "Action only", severity: "warning", detail: "detail", action: "Fix it." }, + ], + }), + ).toEqual([ + { code: "detail_only", title: "Detail only", severity: "warning", detail: "detail" }, + { code: "action_only", title: "Action only", severity: "warning", detail: "detail", action: "Fix it." }, + ]); + }); + + it("detects when composite merge-readiness mode is enabled", () => { + expect(isMergeReadinessCompositeEnabled({ mergeReadinessGateMode: "off" })).toBe(false); + expect(isMergeReadinessCompositeEnabled({ mergeReadinessGateMode: "advisory" })).toBe(true); + expect(isMergeReadinessCompositeEnabled({ mergeReadinessGateMode: "block" })).toBe(true); + expect(isMergeReadinessCompositeEnabled({})).toBe(false); + }); + + it("keeps legacy per-gate behavior when composite mode is off", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 11, + title: "Add panel", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [], + }; + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + + expect(evaluateGateCheck(advisory, { mergeReadinessGateMode: "off", linkedIssueGateMode: "block" }).conclusion).toBe("failure"); + expect(evaluateGateCheck(advisory, { mergeReadinessGateMode: "off", linkedIssueGateMode: "advisory" }).conclusion).toBe("success"); + }); + + it("blocks merge when composite mode is block and any enabled sub-gate is unmet", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 12, + title: "Add panel", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [], + }; + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + const slopFindings = slopFindingsToAdvisoryFindings( + buildSlopAssessment({ changedFiles: [{ path: "src/app.ts", additions: 12 }] }), + ); + + const gate = evaluateGateCheck(advisory, { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "advisory", + duplicatePrGateMode: "off", + qualityGateMode: "block", + qualityGateMinScore: 90, + readinessScore: 40, + slopFindings, + }); + const output = formatGateCheckOutput(gate); + + expect(gate.conclusion).toBe("failure"); + expect(gate.blockers.map((finding) => finding.code)).toEqual( + expect.arrayContaining(["missing_linked_issue", "readiness_score_below_threshold", "missing_test_evidence"]), + ); + expect(gate.summary).toContain("still blocking"); + expect(output.text).toContain("No linked issue detected"); + expect(output.text).toContain("Readiness score is below the configured threshold"); + expect(output.text).toContain("Code changes lack test evidence"); + }); + + it("passes with advisory composite mode while keeping unmet conditions visible as warnings", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 13, + title: "Add panel", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [], + }; + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + + const gate = evaluateMergeReadinessGateCheck(advisory, { + mergeReadinessGateMode: "advisory", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "off", + }); + + expect(gate.conclusion).toBe("success"); + expect(gate.blockers).toEqual([]); + expect(gate.warnings.map((finding) => finding.code)).toContain("missing_linked_issue"); + expect(gate.summary).toContain("remain advisory"); + }); + + it("uses plural advisory summaries when multiple composite conditions remain", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 19, + title: "Add panel", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [], + }; + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + + const gate = evaluateMergeReadinessGateCheck(advisory, { + mergeReadinessGateMode: "advisory", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "block", + qualityGateMinScore: 90, + readinessScore: 40, + }); + + expect(gate.conclusion).toBe("success"); + expect(gate.summary).toBe("2 merge-readiness conditions remain advisory."); + }); + + it("passes composite gate when all enabled sub-gates are satisfied", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 14, + title: "Add panel", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [42], + }; + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + + const gate = evaluateMergeReadinessGateCheck(advisory, { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "block", + qualityGateMinScore: 80, + readinessScore: 90, + }); + + expect(gate.conclusion).toBe("success"); + expect(gate.summary).toContain("passed"); + }); + + it("routes broken evaluation state to action_required in composite mode", () => { + const gate = evaluateMergeReadinessGateCheck(buildPullRequestAdvisory(null, null), { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "off", + }); + + expect(gate.conclusion).toBe("action_required"); + expect(gate.blockers.map((finding) => finding.code)).toEqual(expect.arrayContaining(["repo_not_registered", "pr_not_cached"])); + }); + + it("honors sub-gate off modes and empty slop findings when collecting unmet conditions", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 15, + title: "Add panel", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [], + }; + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + const slopFindings = slopFindingsToAdvisoryFindings( + buildSlopAssessment({ changedFiles: [{ path: "src/app.ts", additions: 12 }] }), + ); + + expect( + collectMergeReadinessUnmetConditions(advisory, { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "off", + duplicatePrGateMode: "off", + qualityGateMode: "off", + slopFindings: [], + }), + ).toEqual([]); + + expect( + collectMergeReadinessUnmetConditions(advisory, { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "off", + slopFindings, + }).map((finding) => finding.code), + ).toEqual(expect.arrayContaining(["missing_linked_issue", "missing_test_evidence"])); + }); + + it("includes duplicate and quality sub-gates in composite unmet conditions", () => { + const linkedPr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 16, + title: "Duplicate work", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [99], + }; + const duplicateAdvisory = buildPullRequestAdvisory(repo, linkedPr, { + otherOpenPullRequests: [{ ...linkedPr, number: 17, linkedIssues: [99] }], + }); + const readyPr: PullRequestRecord = { + ...linkedPr, + number: 18, + linkedIssues: [100], + }; + const qualityAdvisory = buildPullRequestAdvisory(repo, readyPr, { requireLinkedIssue: true }); + + expect( + collectMergeReadinessUnmetConditions(duplicateAdvisory, { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "off", + duplicatePrGateMode: "block", + qualityGateMode: "off", + }).map((finding) => finding.code), + ).toEqual(["duplicate_pr_risk"]); + + const qualityGate = evaluateMergeReadinessGateCheck(qualityAdvisory, { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "off", + duplicatePrGateMode: "off", + qualityGateMode: "block", + qualityGateMinScore: 90, + readinessScore: 55, + }); + + expect(qualityGate.conclusion).toBe("failure"); + expect(qualityGate.summary).toBe("1 merge-readiness condition still blocking: Readiness score is below the configured threshold."); + }); + + it("ignores enabled duplicate sub-gates when no duplicate finding exists and keeps passing quality scores out of unmet", () => { + const pr: PullRequestRecord = { + repoFullName: repo.fullName, + number: 20, + title: "Clean panel", + state: "open", + authorLogin: "contributor", + authorAssociation: "NONE", + headSha: "abc123", + labels: [], + linkedIssues: [42], + }; + const advisory = buildPullRequestAdvisory(repo, pr, { requireLinkedIssue: true }); + + expect( + collectMergeReadinessUnmetConditions(advisory, { + mergeReadinessGateMode: "block", + linkedIssueGateMode: "off", + duplicatePrGateMode: "block", + qualityGateMode: "block", + qualityGateMinScore: 80, + readinessScore: 95, + }), + ).toEqual([]); + }); +}); diff --git a/test/unit/policy-sanitizer.test.ts b/test/unit/policy-sanitizer.test.ts index d06c9a46e7..243168da36 100644 --- a/test/unit/policy-sanitizer.test.ts +++ b/test/unit/policy-sanitizer.test.ts @@ -66,6 +66,7 @@ function settingsFor(repoFullName: string, overrides: Partial { expect(calls).toEqual({ minerList: 0, gateChecks: 2 }); }); + it("publishes a merge-readiness composite gate with linked-issue and slop blockers", 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: "off", + publicSurface: "off", + autoLabelEnabled: false, + checkRunMode: "off", + gateCheckMode: "enabled", + mergeReadinessGateMode: "block", + linkedIssueGateMode: "block", + duplicatePrGateMode: "off", + qualityGateMode: "off", + requireLinkedIssue: true, + }); + await upsertPullRequestFile(env, { + repoFullName: "JSONbored/gittensory", + pullNumber: 44, + path: "src/app.ts", + status: "added", + additions: 12, + deletions: 0, + changes: 12, + payload: {}, + }); + const calls = { gateChecks: 0 }; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/commits/composite123/check-runs")) return Response.json({ total_count: 0, check_runs: [] }); + if (url.includes("/check-runs") && (init?.method ?? "GET") === "POST") { + calls.gateChecks += 1; + return Response.json({ id: 902 }, { status: 201 }); + } + if (url.includes("/check-runs/902") && (init?.method ?? "GET") === "PATCH") { + const body = JSON.parse(String(init?.body ?? "{}")) as { status?: string; conclusion?: string; output?: { title?: string; text?: string } }; + expect(body).toMatchObject({ status: "completed", conclusion: "failure", output: { title: "Gittensory Gate is blocking merge" } }); + expect(body.output?.text).toContain("No linked issue detected"); + expect(body.output?.text).toContain("Code changes lack test evidence"); + calls.gateChecks += 1; + return Response.json({ id: 902 }); + } + return new Response("not found", { status: 404 }); + }); + + await processJob(env, { + type: "github-webhook", + deliveryId: "gate-composite", + 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: 44, title: "Composite gate", state: "open", user: { login: "contributor" }, head: { sha: "composite123" }, labels: [], body: "No issue link." }, + }, + }); + + expect(calls).toEqual({ gateChecks: 2 }); + }); + it("publishes an enabled gate when bot PR public output is skipped", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await persistRegistrySnapshot( diff --git a/test/unit/registration-readiness.test.ts b/test/unit/registration-readiness.test.ts index 65f385721c..2fe122b303 100644 --- a/test/unit/registration-readiness.test.ts +++ b/test/unit/registration-readiness.test.ts @@ -46,6 +46,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin duplicatePrGateMode: "advisory", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, diff --git a/test/unit/self-dogfood-registration-pack.test.ts b/test/unit/self-dogfood-registration-pack.test.ts index f59258b5d9..bd41d00cfe 100644 --- a/test/unit/self-dogfood-registration-pack.test.ts +++ b/test/unit/self-dogfood-registration-pack.test.ts @@ -59,6 +59,7 @@ function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettin duplicatePrGateMode: "advisory", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, diff --git a/test/unit/signals-coverage.test.ts b/test/unit/signals-coverage.test.ts index 854bedbd86..9716c70896 100644 --- a/test/unit/signals-coverage.test.ts +++ b/test/unit/signals-coverage.test.ts @@ -1507,6 +1507,7 @@ function repoSettings(repoFullName: string): RepositorySettings { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, diff --git a/test/unit/signals-v2.test.ts b/test/unit/signals-v2.test.ts index 16ec9a2437..259d06ad36 100644 --- a/test/unit/signals-v2.test.ts +++ b/test/unit/signals-v2.test.ts @@ -1617,6 +1617,7 @@ describe("v2 signal builders", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, diff --git a/test/unit/signals.test.ts b/test/unit/signals.test.ts index 8e84970595..bc11ccedfa 100644 --- a/test/unit/signals.test.ts +++ b/test/unit/signals.test.ts @@ -328,6 +328,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory" as const, qualityGateMode: "advisory" as const, qualityGateMinScore: null, + mergeReadinessGateMode: "off" as const, autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, @@ -377,6 +378,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, @@ -461,6 +463,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true, @@ -520,6 +523,7 @@ describe("world-class backend signals", () => { duplicatePrGateMode: "advisory", qualityGateMode: "advisory", qualityGateMinScore: null, + mergeReadinessGateMode: "off", autoLabelEnabled: true, gittensorLabel: "gittensor", createMissingLabel: true,