diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 3e2a344831..d5bbd3202c 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -2583,6 +2583,51 @@ "direct_pr_no_issue_required" ] }, + "directPrReadiness": { + "type": "object", + "properties": { + "ready": { + "type": "boolean" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "ready", + "reasons" + ] + }, + "issueDiscoveryReadiness": { + "type": "object", + "properties": { + "ready": { + "type": "boolean" + }, + "recommendation": { + "type": "string", + "enum": [ + "enabled", + "recommended", + "not_recommended" + ] + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "ready", + "recommendation", + "reasons" + ] + }, "labelPolicy": { "type": "object", "additionalProperties": { @@ -2595,6 +2640,80 @@ "nullable": true } }, + "testCoverageHealth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "gate_ready", + "gate_unknown" + ] + }, + "trustedLabelPipelineReady": { + "type": "boolean" + }, + "checkRunMode": { + "type": "string", + "enum": [ + "off", + "enabled" + ] + }, + "requiredGate": { + "type": "array", + "items": { + "type": "string" + } + }, + "note": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "status", + "trustedLabelPipelineReady", + "checkRunMode", + "requiredGate", + "note", + "warnings" + ] + }, + "queueHealth": { + "type": "object", + "properties": { + "level": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "burdenScore": { + "type": "number" + }, + "reviewablePullRequests": { + "type": "number" + }, + "summary": { + "type": "string" + } + }, + "required": [ + "level", + "burdenScore", + "reviewablePullRequests", + "summary" + ] + }, "contributorIntakeHealth": { "type": "object", "additionalProperties": { @@ -2607,6 +2726,59 @@ "nullable": true } }, + "githubApp": { + "type": "object", + "properties": { + "installed": { + "type": "boolean" + }, + "publicSurface": { + "type": "string", + "enum": [ + "off", + "comment_and_label", + "comment_only", + "label_only" + ] + }, + "commentMode": { + "type": "string", + "enum": [ + "off", + "detected_contributors_only", + "all_prs" + ] + }, + "checkRunMode": { + "type": "string", + "enum": [ + "off", + "enabled" + ] + }, + "quietByDefault": { + "type": "boolean" + }, + "behavior": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "installed", + "publicSurface", + "commentMode", + "checkRunMode", + "quietByDefault", + "behavior", + "warnings" + ] + }, "blockers": { "type": "array", "items": { @@ -2632,10 +2804,15 @@ "ready", "recommendedRegistrationMode", "issuePolicy", + "directPrReadiness", + "issueDiscoveryReadiness", "labelPolicy", "maintainerCutReadiness", + "testCoverageHealth", + "queueHealth", "contributorIntakeHealth", "docsCompleteness", + "githubApp", "blockers", "warnings", "dataQuality" @@ -2666,6 +2843,12 @@ "nullable": true } }, + "tradeoffs": { + "type": "array", + "items": { + "type": "string" + } + }, "reasons": { "type": "array", "items": { @@ -2691,6 +2874,7 @@ "privateOnly", "current", "recommended", + "tradeoffs", "reasons", "warnings", "dataQuality" diff --git a/src/api/routes.ts b/src/api/routes.ts index a3487bfeb4..4a9e218307 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -138,6 +138,7 @@ import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-mo import { buildPullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildRepoSettingsPreview } from "../signals/settings-preview"; +import { buildGittensorConfigRecommendation, buildRegistrationReadiness, type InstallationHealthSummary } from "../signals/registration-readiness"; import { fileUpstreamDriftIssues, loadUpstreamStatus, refreshUpstreamDrift } from "../upstream/ruleset"; import type { BountyLifecycleEventRecord, ContributorEvidenceRecord, DataQuality, InstallationHealthRecord, JobMessage, JsonValue, RegistrySnapshot, RepoSyncSegmentRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types"; import { errorMessage, nowIso } from "../utils/json"; @@ -1908,102 +1909,54 @@ async function buildIssueQualityResponse(env: Env, fullName: string) { return loadOrComputeIssueQualityResponse(env, fullName); } +async function loadInstallationHealthSummary(env: Env, repo: RepositoryRecord | null): Promise { + /* v8 ignore start -- Installation health loading is route-level glue over covered signal helpers. */ + const installationId = repo?.installationId ?? null; + if (installationId === null) return null; + const healthRecord = await getInstallationHealth(env, installationId); + if (!healthRecord) return null; + const enriched = enrichInstallationHealth(healthRecord); + return { status: enriched.status, missingPermissions: enriched.missingPermissions, missingEvents: enriched.missingEvents }; + /* v8 ignore stop */ +} + async function buildRegistrationReadinessResponse(env: Env, fullName: string) { - /* v8 ignore start -- Registration readiness branches are route-level response shaping over covered signal helpers. */ + /* v8 ignore start -- Registration readiness route-level shaping over covered signal helpers. */ const intelligence = await buildRepoIntelligenceResponse(env, fullName); const settings = await getRepositorySettings(env, fullName); const repo = intelligence.repo; - const configQuality = intelligence.configQuality as ReturnType; - const maintainerCutReadiness = intelligence.maintainerCutReadiness as ReturnType; - const contributorIntakeHealth = intelligence.contributorIntakeHealth as ReturnType; - const lane = buildLaneAdvice(repo, fullName); - const blockers = [ - ...(!repo?.isRegistered ? ["Repository is not registered in the latest Gittensory registry snapshot."] : []), - ...(configQuality.level === "fragile" ? ["Repository config quality is fragile."] : []), - ...(contributorIntakeHealth.level === "blocked" ? ["Contributor intake health is blocked."] : []), - ]; - const warnings = [ - ...(configQuality.level === "needs_attention" ? ["Repository config quality needs attention before registration promotion."] : []), - ...(contributorIntakeHealth.level === "strained" ? ["Contributor intake is strained; expect more maintainer triage."] : []), - ...(settings.publicSurface === "off" ? ["GitHub App public surface is disabled; maintainers will not get comment/label assistance."] : []), - ]; - const issuePolicy = - lane.lane === "issue_discovery" - ? "issue_discovery_enabled" - : lane.lane === "split" - ? "split_pr_and_issue_discovery_enabled" - : settings.requireLinkedIssue - ? "direct_pr_requires_linked_issue" - : "direct_pr_no_issue_required"; - const ready = blockers.length === 0 && !["fragile", "needs_attention"].includes(configQuality.level); - return { + const installation = await loadInstallationHealthSummary(env, repo); + const report = buildRegistrationReadiness({ repoFullName: fullName, - generatedAt: nowIso(), - ready, - recommendedRegistrationMode: lane.lane === "issue_discovery" ? "issue_discovery" : lane.lane === "split" ? "split" : "direct_pr", - issuePolicy, - labelPolicy: { - autoLabelEnabled: settings.autoLabelEnabled, - label: settings.gittensorLabel, - createMissingLabel: settings.createMissingLabel, - configuredRegistryLabels: configQuality.configuredLabels, - missingOrUnusedRegistryLabels: configQuality.notObservedConfiguredLabels, - }, - maintainerCutReadiness, - contributorIntakeHealth, - docsCompleteness: { - status: "repo_docs_not_crawled", - requiredDocs: ["README", "CONTRIBUTING", "SECURITY", "SUPPORT"], - note: "Gittensory validates public repo docs from the local project during CI; remote repo-doc crawling is not enabled in this signal yet.", - }, - blockers, - warnings, - dataQuality: intelligence.dataQuality, - }; + repo, + settings, + lane: buildLaneAdvice(repo, fullName), + configQuality: intelligence.configQuality as ReturnType, + labelAudit: intelligence.labelAudit as ReturnType, + queueHealth: intelligence.queueHealth as ReturnType, + maintainerCutReadiness: intelligence.maintainerCutReadiness as ReturnType, + contributorIntakeHealth: intelligence.contributorIntakeHealth as ReturnType, + installation, + }); + return { ...report, dataQuality: intelligence.dataQuality }; /* v8 ignore stop */ } async function buildGittensorConfigRecommendationResponse(env: Env, fullName: string) { - /* v8 ignore start -- Config recommendation branches shape advisory output over covered signal helpers. */ + /* v8 ignore start -- Config recommendation route-level shaping over covered signal helpers. */ const intelligence = await buildRepoIntelligenceResponse(env, fullName); const settings = await getRepositorySettings(env, fullName); const repo = intelligence.repo; - const lane = buildLaneAdvice(repo, fullName); - const configQuality = intelligence.configQuality as ReturnType; - const contributorIntakeHealth = intelligence.contributorIntakeHealth as ReturnType; - const maintainerCutReadiness = intelligence.maintainerCutReadiness as ReturnType; - const current = repo?.registryConfig ?? null; - const shouldEnableIssueDiscovery = contributorIntakeHealth.level === "healthy" && configQuality.level === "excellent"; - const recommendedIssueDiscoveryShare = shouldEnableIssueDiscovery ? 0.1 : 0; - const currentAllocation = current?.emissionShare ?? 0; - const directPrShare = Math.max(0, currentAllocation - recommendedIssueDiscoveryShare); - const recommendedMaintainerCut = maintainerCutReadiness.ready ? Math.max(current?.maintainerCut ?? 0, 0.02) : current?.maintainerCut ?? 0; - return { + const recommendation = buildGittensorConfigRecommendation({ repoFullName: fullName, - generatedAt: nowIso(), - privateOnly: true, - current, - recommended: { - participationMode: recommendedIssueDiscoveryShare > 0 ? "split" : "direct_pr", - issueDiscoveryShare: recommendedIssueDiscoveryShare, - directPrShare, - maintainerCut: recommendedMaintainerCut, - requireLinkedIssue: settings.requireLinkedIssue, - labelMultipliers: configQuality.configuredLabels.length > 0 ? "keep_current_and_prune_unused" : "start_without_trusted_label_multipliers", - publicSurface: settings.publicSurface, - confirmedMinerLabel: settings.gittensorLabel, - }, - reasons: [ - lane.lane === "issue_discovery" ? "The current registry lane already routes meaningful work through issue discovery." : "Direct-PR mode is the safest default until issue-discovery intake is intentionally staffed.", - shouldEnableIssueDiscovery ? "Config and intake signals are strong enough to consider a small issue-discovery slice." : "Issue discovery should stay disabled until config quality and intake health are excellent.", - maintainerCutReadiness.ready ? "Maintainer cut can be considered because config and queue signals are clean." : "Maintainer cut should stay unchanged until readiness blockers are cleared.", - ], - warnings: [ - ...(configQuality.notObservedConfiguredLabels.length > 0 ? [`${configQuality.notObservedConfiguredLabels.length} configured label(s) have not been observed in cached repo activity.`] : []), - ...(contributorIntakeHealth.level === "strained" || contributorIntakeHealth.level === "blocked" ? [`Contributor intake is ${contributorIntakeHealth.level}; avoid increasing noisy lanes yet.`] : []), - ], - dataQuality: intelligence.dataQuality, - }; + repo, + settings, + lane: buildLaneAdvice(repo, fullName), + configQuality: intelligence.configQuality as ReturnType, + contributorIntakeHealth: intelligence.contributorIntakeHealth as ReturnType, + maintainerCutReadiness: intelligence.maintainerCutReadiness as ReturnType, + }); + return { ...recommendation, dataQuality: intelligence.dataQuality }; /* v8 ignore stop */ } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index debdeb98cf..92944dcd29 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1332,10 +1332,30 @@ export const RegistrationReadinessSchema = z ready: z.boolean(), recommendedRegistrationMode: z.enum(["direct_pr", "issue_discovery", "split"]), issuePolicy: z.enum(["issue_discovery_enabled", "split_pr_and_issue_discovery_enabled", "direct_pr_requires_linked_issue", "direct_pr_no_issue_required"]), + directPrReadiness: z.object({ ready: z.boolean(), reasons: z.array(z.string()) }), + issueDiscoveryReadiness: z.object({ ready: z.boolean(), recommendation: z.enum(["enabled", "recommended", "not_recommended"]), reasons: z.array(z.string()) }), labelPolicy: z.record(z.unknown()), maintainerCutReadiness: z.record(z.unknown()), + testCoverageHealth: z.object({ + status: z.enum(["gate_ready", "gate_unknown"]), + trustedLabelPipelineReady: z.boolean(), + checkRunMode: z.enum(["off", "enabled"]), + requiredGate: z.array(z.string()), + note: z.string(), + warnings: z.array(z.string()), + }), + queueHealth: z.object({ level: z.enum(["low", "medium", "high", "critical"]), burdenScore: z.number(), reviewablePullRequests: z.number(), summary: z.string() }), contributorIntakeHealth: z.record(z.unknown()), docsCompleteness: z.record(z.unknown()), + githubApp: z.object({ + installed: z.boolean(), + publicSurface: z.enum(["off", "comment_and_label", "comment_only", "label_only"]), + commentMode: z.enum(["off", "detected_contributors_only", "all_prs"]), + checkRunMode: z.enum(["off", "enabled"]), + quietByDefault: z.boolean(), + behavior: z.string(), + warnings: z.array(z.string()), + }), blockers: z.array(z.string()), warnings: z.array(z.string()), dataQuality: z.record(z.unknown()), @@ -1349,6 +1369,7 @@ export const GittensorConfigRecommendationSchema = z privateOnly: z.boolean(), current: z.record(z.unknown()).nullable(), recommended: z.record(z.unknown()), + tradeoffs: z.array(z.string()), reasons: z.array(z.string()), warnings: z.array(z.string()), dataQuality: z.record(z.unknown()), diff --git a/src/signals/registration-readiness.ts b/src/signals/registration-readiness.ts new file mode 100644 index 0000000000..b1e023b48b --- /dev/null +++ b/src/signals/registration-readiness.ts @@ -0,0 +1,298 @@ +import type { RegistryRepoConfig, RepositoryRecord, RepositorySettings } from "../types"; +import { nowIso } from "../utils/json"; +import type { ConfigQuality, ContributorIntakeHealth, LabelAudit, LaneAdvice, MaintainerCutReadiness, QueueHealth } from "./engine"; + +export type RegistrationMode = "direct_pr" | "issue_discovery" | "split"; +export type IssuePolicy = "issue_discovery_enabled" | "split_pr_and_issue_discovery_enabled" | "direct_pr_requires_linked_issue" | "direct_pr_no_issue_required"; + +export type InstallationHealthSummary = { + status: "healthy" | "needs_attention" | "broken"; + missingPermissions: string[]; + missingEvents: string[]; +}; + +export type LaneReadiness = { + ready: boolean; + recommendation: "enabled" | "recommended" | "not_recommended"; + reasons: string[]; +}; + +export type TestCoverageHealth = { + status: "gate_ready" | "gate_unknown"; + trustedLabelPipelineReady: boolean; + checkRunMode: RepositorySettings["checkRunMode"]; + requiredGate: string[]; + note: string; + warnings: string[]; +}; + +export type GithubAppBehavior = { + installed: boolean; + publicSurface: RepositorySettings["publicSurface"]; + commentMode: RepositorySettings["commentMode"]; + checkRunMode: RepositorySettings["checkRunMode"]; + quietByDefault: boolean; + behavior: string; + warnings: string[]; +}; + +export type RegistrationReadinessReport = { + repoFullName: string; + generatedAt: string; + ready: boolean; + recommendedRegistrationMode: RegistrationMode; + issuePolicy: IssuePolicy; + directPrReadiness: { ready: boolean; reasons: string[] }; + issueDiscoveryReadiness: LaneReadiness; + labelPolicy: { + autoLabelEnabled: boolean; + label: string; + createMissingLabel: boolean; + configuredRegistryLabels: string[]; + missingOrUnusedRegistryLabels: string[]; + trustedPipelineReady: boolean; + }; + maintainerCutReadiness: MaintainerCutReadiness; + testCoverageHealth: TestCoverageHealth; + queueHealth: { level: QueueHealth["level"]; burdenScore: number; reviewablePullRequests: number; summary: string }; + contributorIntakeHealth: ContributorIntakeHealth; + docsCompleteness: { status: string; requiredDocs: string[]; note: string }; + githubApp: GithubAppBehavior; + blockers: string[]; + warnings: string[]; +}; + +export type RegistrationReadinessInput = { + repoFullName: string; + repo: RepositoryRecord | null; + settings: RepositorySettings; + lane: LaneAdvice; + configQuality: ConfigQuality; + labelAudit: LabelAudit; + queueHealth: QueueHealth; + maintainerCutReadiness: MaintainerCutReadiness; + contributorIntakeHealth: ContributorIntakeHealth; + installation: InstallationHealthSummary | null; +}; + +const REQUIRED_DOCS = ["README", "CONTRIBUTING", "SECURITY", "SUPPORT"]; +const COVERAGE_GATE = ["npm run test:ci", "global coverage >= 95% (lines, statements, functions, branches)"]; + +function laneToMode(lane: LaneAdvice): RegistrationMode { + return lane.lane === "issue_discovery" ? "issue_discovery" : lane.lane === "split" ? "split" : "direct_pr"; +} + +function resolveIssuePolicy(lane: LaneAdvice, settings: RepositorySettings): IssuePolicy { + if (lane.lane === "issue_discovery") return "issue_discovery_enabled"; + if (lane.lane === "split") return "split_pr_and_issue_discovery_enabled"; + return settings.requireLinkedIssue ? "direct_pr_requires_linked_issue" : "direct_pr_no_issue_required"; +} + +function buildTestCoverageHealth(labelAudit: LabelAudit, settings: RepositorySettings): TestCoverageHealth { + const trustedLabelPipelineReady = labelAudit.trustedPipelineReady; + const status: TestCoverageHealth["status"] = trustedLabelPipelineReady ? "gate_ready" : "gate_unknown"; + const warnings = trustedLabelPipelineReady ? [] : ["No trusted label pipeline is verified; trusted-label scoring should stay off until labels are validated."]; + return { + status, + trustedLabelPipelineReady, + checkRunMode: settings.checkRunMode, + requiredGate: COVERAGE_GATE, + note: "Gittensory enforces its own coverage gate in CI; remote contributor repos must preserve an equivalent test gate before trusted-label or maintainer-cut promotion. Check runs intentionally default off; their state is informational here and is not a readiness warning.", + warnings, + }; +} + +function buildGithubAppBehavior(repo: RepositoryRecord | null, settings: RepositorySettings, installation: InstallationHealthSummary | null): GithubAppBehavior { + const installed = Boolean(repo?.isInstalled); + const quietByDefault = settings.publicSurface === "off" || settings.commentMode !== "all_prs"; + const warnings = [ + ...(installed ? [] : ["GitHub App is not installed on this repo; maintainers will not get any automated assistance."]), + ...(settings.publicSurface === "off" ? ["GitHub App public surface is disabled; maintainers will not get comment/label assistance."] : []), + ...(installation && settings.publicSurface !== "off" && installation.missingPermissions.length > 0 + ? [`GitHub App is missing permission(s) for the enabled public surface: ${installation.missingPermissions.join(", ")}.`] + : []), + ...(installation?.missingEvents.length ? [`GitHub App is not subscribed to webhook event(s): ${installation.missingEvents.join(", ")}.`] : []), + ]; + return { + installed, + publicSurface: settings.publicSurface, + commentMode: settings.commentMode, + checkRunMode: settings.checkRunMode, + quietByDefault, + behavior: !installed + ? "Gittensory would stay silent because the GitHub App is not installed." + : settings.publicSurface === "off" + ? "Gittensory stays quiet: no public comments or labels, confirmed-miner detection only." + : `Gittensory posts ${settings.publicSurface.replace(/_/g, " ")} for confirmed Gittensor miner PRs only, ${quietByDefault ? "quiet by default" : "for all PRs"}.`, + warnings, + }; +} + +/** + * Pure registration-readiness report for a repo owner. + * Advisory and private/API-first: no public GitHub output, no wallet/score exposure. + */ +export function buildRegistrationReadiness(input: RegistrationReadinessInput): RegistrationReadinessReport { + const { repoFullName, repo, settings, lane, configQuality, labelAudit, queueHealth, maintainerCutReadiness, contributorIntakeHealth, installation } = input; + const isRegistered = Boolean(repo?.isRegistered); + const configFragile = configQuality.level === "fragile"; + const configNeedsAttention = configQuality.level === "needs_attention"; + const intakeBlocked = contributorIntakeHealth.level === "blocked"; + + const testCoverageHealth = buildTestCoverageHealth(labelAudit, settings); + const githubApp = buildGithubAppBehavior(repo, settings, installation); + + const blockers = [ + ...(!isRegistered ? ["Repository is not registered in the latest Gittensory registry snapshot."] : []), + ...(configFragile ? ["Repository config quality is fragile."] : []), + ...(intakeBlocked ? ["Contributor intake health is blocked."] : []), + ]; + + const directPrReady = isRegistered && !configFragile && !configNeedsAttention && !intakeBlocked; + const directPrReadiness = { + ready: directPrReady, + reasons: [ + isRegistered ? "Repository is registered in the local Gittensory snapshot." : "Repository is not registered yet; direct-PR mining cannot be evaluated.", + directPrReady ? "Config quality and contributor intake are healthy enough for direct-PR-first intake." : `Direct-PR intake is gated by config quality (${configQuality.level}) and intake health (${contributorIntakeHealth.level}).`, + ], + }; + + const issueDiscoveryHealthy = contributorIntakeHealth.level === "healthy" && configQuality.level === "excellent"; + const issueDiscoveryReadiness: LaneReadiness = { + ready: issueDiscoveryHealthy, + recommendation: lane.lane === "issue_discovery" || lane.lane === "split" ? "enabled" : issueDiscoveryHealthy ? "recommended" : "not_recommended", + reasons: [ + lane.lane === "issue_discovery" || lane.lane === "split" ? "Issue-discovery intake is already part of the current registry lane." : "Issue-discovery intake is not part of the current registry lane.", + issueDiscoveryHealthy ? "Config quality is excellent and intake is healthy, so a small issue-discovery slice is defensible." : "Issue discovery should stay off until config quality is excellent and intake health is healthy.", + ], + }; + + const warnings = [ + ...(configNeedsAttention ? ["Repository config quality needs attention before registration promotion."] : []), + ...(contributorIntakeHealth.level === "strained" ? ["Contributor intake is strained; expect more maintainer triage."] : []), + ...(settings.publicSurface === "off" ? ["GitHub App public surface is disabled; maintainers will not get comment/label assistance."] : []), + ...testCoverageHealth.warnings, + ...labelAudit.missingConfiguredLabels.map((label) => `Configured registry label "${label}" is missing from live GitHub labels.`), + ]; + + const ready = blockers.length === 0 && !configFragile && !configNeedsAttention; + + return { + repoFullName, + generatedAt: nowIso(), + ready, + recommendedRegistrationMode: laneToMode(lane), + issuePolicy: resolveIssuePolicy(lane, settings), + directPrReadiness, + issueDiscoveryReadiness, + labelPolicy: { + autoLabelEnabled: settings.autoLabelEnabled, + label: settings.gittensorLabel, + createMissingLabel: settings.createMissingLabel, + configuredRegistryLabels: configQuality.configuredLabels, + missingOrUnusedRegistryLabels: configQuality.notObservedConfiguredLabels, + trustedPipelineReady: labelAudit.trustedPipelineReady, + }, + maintainerCutReadiness, + testCoverageHealth, + queueHealth: { + level: queueHealth.level, + burdenScore: queueHealth.burdenScore, + reviewablePullRequests: queueHealth.signals.likelyReviewablePullRequests, + summary: queueHealth.summary, + }, + contributorIntakeHealth, + docsCompleteness: { + status: "repo_docs_not_crawled", + requiredDocs: REQUIRED_DOCS, + note: "Gittensory validates public repo docs from the local project during CI; remote repo-doc crawling is not enabled in this signal yet.", + }, + githubApp, + blockers, + warnings, + }; +} + +export type GittensorConfigRecommendation = { + repoFullName: string; + generatedAt: string; + privateOnly: true; + current: RegistryRepoConfig | null; + recommended: { + participationMode: "direct_pr" | "split"; + issueDiscoveryShare: number; + directPrShare: number; + maintainerCut: number; + requireLinkedIssue: boolean; + labelMultipliers: "keep_current_and_prune_unused" | "start_without_trusted_label_multipliers"; + publicSurface: RepositorySettings["publicSurface"]; + confirmedMinerLabel: string; + }; + tradeoffs: string[]; + reasons: string[]; + warnings: string[]; +}; + +export type GittensorConfigRecommendationInput = { + repoFullName: string; + repo: RepositoryRecord | null; + settings: RepositorySettings; + lane: LaneAdvice; + configQuality: ConfigQuality; + contributorIntakeHealth: ContributorIntakeHealth; + maintainerCutReadiness: MaintainerCutReadiness; +}; + +/** + * Pure initial Gittensor config recommendation for a repo owner. + * Separates maintainer economics from miner rewards and always favors a safe direct-PR default. + */ +export function buildGittensorConfigRecommendation(input: GittensorConfigRecommendationInput): GittensorConfigRecommendation { + const { repoFullName, repo, settings, lane, configQuality, contributorIntakeHealth, maintainerCutReadiness } = input; + const current = repo?.registryConfig ?? null; + const shouldEnableIssueDiscovery = contributorIntakeHealth.level === "healthy" && configQuality.level === "excellent"; + // Direct-PR-first posture: only allocate an issue-discovery slice when intake is healthy and config is excellent. + const recommendedIssueDiscoveryShare = shouldEnableIssueDiscovery ? 0.1 : 0; + // issueDiscoveryShare and directPrShare are repo-config semantics for the in-repo split between issue-discovery and direct-PR flow. + // emissionShare is assigned externally and is intentionally not subtracted from here. + const directPrShare = 1 - recommendedIssueDiscoveryShare; + // Target a 30% maintainer cut when readiness is met; otherwise leave the configured value untouched. + const recommendedMaintainerCut = maintainerCutReadiness.ready ? Math.max(current?.maintainerCut ?? 0, 0.3) : current?.maintainerCut ?? 0; + + return { + repoFullName, + generatedAt: nowIso(), + privateOnly: true, + current, + recommended: { + participationMode: recommendedIssueDiscoveryShare > 0 ? "split" : "direct_pr", + issueDiscoveryShare: recommendedIssueDiscoveryShare, + directPrShare, + maintainerCut: recommendedMaintainerCut, + requireLinkedIssue: settings.requireLinkedIssue, + labelMultipliers: configQuality.configuredLabels.length > 0 ? "keep_current_and_prune_unused" : "start_without_trusted_label_multipliers", + publicSurface: settings.publicSurface, + confirmedMinerLabel: settings.gittensorLabel, + }, + tradeoffs: [ + recommendedIssueDiscoveryShare > 0 + ? "A small issue-discovery slice can surface more outside contributor work but adds triage load and duplicate-report risk." + : "Staying direct-PR-only keeps maintainer triage low but forgoes issue-discovery contributor flow.", + recommendedMaintainerCut > (current?.maintainerCut ?? 0) + ? "Introducing a maintainer cut rewards upkeep but reduces the share available to contributor miners." + : "Leaving maintainer cut unchanged keeps the full emission share with contributor miners.", + settings.requireLinkedIssue + ? "Requiring a linked issue improves traceability but can deter quick, well-scoped direct PRs." + : "Not requiring a linked issue lowers contributor friction but weakens issue/PR traceability.", + ], + reasons: [ + lane.lane === "issue_discovery" ? "The current registry lane already routes meaningful work through issue discovery." : "Direct-PR mode is the safest default until issue-discovery intake is intentionally staffed.", + shouldEnableIssueDiscovery ? "Config and intake signals are strong enough to consider a small issue-discovery slice." : "Issue discovery should stay disabled until config quality and intake health are excellent.", + maintainerCutReadiness.ready ? "Maintainer cut can be considered because config and queue signals are clean." : "Maintainer cut should stay unchanged until readiness blockers are cleared.", + ], + warnings: [ + ...(configQuality.notObservedConfiguredLabels.length > 0 ? [`${configQuality.notObservedConfiguredLabels.length} configured label(s) have not been observed in cached repo activity.`] : []), + ...(contributorIntakeHealth.level === "strained" || contributorIntakeHealth.level === "blocked" ? [`Contributor intake is ${contributorIntakeHealth.level}; avoid increasing noisy lanes yet.`] : []), + ], + }; +} diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 348481cad0..6a581ee59a 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -2915,7 +2915,7 @@ describe("api routes", () => { const excellentRecommendation = await app.request("/v1/repos/owner/excellent/gittensor-config-recommendation", { headers: apiHeaders(env) }, env); expect(excellentRecommendation.status).toBe(200); await expect(excellentRecommendation.json()).resolves.toMatchObject({ - recommended: { participationMode: "split", issueDiscoveryShare: 0.1, maintainerCut: 0.02 }, + recommended: { participationMode: "split", issueDiscoveryShare: 0.1, maintainerCut: 0.3 }, reasons: expect.arrayContaining(["Config and intake signals are strong enough to consider a small issue-discovery slice.", "Maintainer cut can be considered because config and queue signals are clean."]), }); const issueOnlyReadiness = await app.request("/v1/repos/owner/issue-only/registration-readiness", { headers: apiHeaders(env) }, env); diff --git a/test/unit/registration-readiness.test.ts b/test/unit/registration-readiness.test.ts new file mode 100644 index 0000000000..a6d1579325 --- /dev/null +++ b/test/unit/registration-readiness.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { + buildCollisionReport, + buildConfigQuality, + buildContributorIntakeHealth, + buildLabelAudit, + buildLaneAdvice, + buildMaintainerCutReadiness, + buildQueueHealth, +} from "../../src/signals/engine"; +import { buildGittensorConfigRecommendation, buildRegistrationReadiness, type InstallationHealthSummary } from "../../src/signals/registration-readiness"; +import type { IssueRecord, PullRequestRecord, RepoLabelRecord, RegistryRepoConfig, RepositoryRecord, RepositorySettings } from "../../src/types"; + +const FORBIDDEN_PUBLIC_LANGUAGE = /wallet|hotkey|payout|reward estimate|raw trust score|public score estimate|private reviewability|farming/i; + +function repoFor(fullName: string, registryConfig: RegistryRepoConfig | null, overrides: Partial = {}): RepositoryRecord { + const [owner, name] = fullName.split("/"); + return { + fullName, + owner: owner ?? fullName, + name: name ?? fullName, + installationId: 1, + isInstalled: true, + isRegistered: registryConfig !== null, + isPrivate: false, + registryConfig, + ...overrides, + }; +} + +function configFor(overrides: Partial = {}): RegistryRepoConfig { + return { repo: "x/y", emissionShare: 0.02, issueDiscoveryShare: 0, labelMultipliers: { bug: 1.1 }, trustedLabelPipeline: true, maintainerCut: 0, raw: {}, ...overrides }; +} + +function settingsFor(repoFullName: string, overrides: Partial = {}): RepositorySettings { + return { + repoFullName, + commentMode: "detected_contributors_only", + publicSignalLevel: "standard", + checkRunMode: "enabled", + checkRunDetailLevel: "standard", + autoLabelEnabled: true, + gittensorLabel: "gittensor", + createMissingLabel: true, + publicSurface: "comment_and_label", + includeMaintainerAuthors: false, + requireLinkedIssue: false, + backfillEnabled: true, + privateTrustEnabled: true, + ...overrides, + }; +} + +const healthyInstall: InstallationHealthSummary = { status: "healthy", missingPermissions: [], missingEvents: [] }; + +function signalsFor(repo: RepositoryRecord, issues: IssueRecord[], pullRequests: PullRequestRecord[], labels: RepoLabelRecord[]) { + const fullName = repo.fullName; + const collisions = buildCollisionReport(fullName, issues, pullRequests); + return { + lane: buildLaneAdvice(repo, fullName), + configQuality: buildConfigQuality(repo, issues, pullRequests, fullName), + labelAudit: buildLabelAudit(repo, labels, issues, pullRequests, fullName), + queueHealth: buildQueueHealth(repo, issues, pullRequests, collisions), + maintainerCutReadiness: buildMaintainerCutReadiness(repo, issues, pullRequests, fullName, {}, collisions), + contributorIntakeHealth: buildContributorIntakeHealth(repo, issues, pullRequests, fullName, collisions), + }; +} + +function label(name: string): RepoLabelRecord { + return { repoFullName: "x/y", name, isConfigured: true, observedCount: 3, payload: {} }; +} + +describe("buildRegistrationReadiness", () => { + it("marks a clean, registered, direct-PR repo as ready across every evaluated dimension", () => { + const repo = repoFor("octo/ready", configFor({ repo: "octo/ready" })); + const issues: IssueRecord[] = [{ repoFullName: repo.fullName, number: 4, title: "Fix flaky cache test", state: "open", labels: ["bug"], linkedPrs: [] }]; + const settings = settingsFor(repo.fullName); + const report = buildRegistrationReadiness({ repoFullName: repo.fullName, repo, settings, installation: healthyInstall, ...signalsFor(repo, issues, [], [label("bug")]) }); + + expect(report).toMatchObject({ + ready: true, + recommendedRegistrationMode: "direct_pr", + issuePolicy: "direct_pr_no_issue_required", + directPrReadiness: { ready: true }, + issueDiscoveryReadiness: { ready: true, recommendation: "recommended" }, + testCoverageHealth: { status: "gate_ready", trustedLabelPipelineReady: true, checkRunMode: "enabled" }, + githubApp: { installed: true, quietByDefault: true }, + }); + expect(report.queueHealth.level).toBe("low"); + expect(report.testCoverageHealth.requiredGate).toContain("npm run test:ci"); + expect(report.blockers).toHaveLength(0); + expect(report.githubApp.warnings).toHaveLength(0); + expect(report.labelPolicy.trustedPipelineReady).toBe(true); + }); + + it("blocks an unregistered repo and recommends direct-PR mode", () => { + const repo = repoFor("octo/unregistered", null); + const settings = settingsFor(repo.fullName, { publicSurface: "off" }); + const report = buildRegistrationReadiness({ repoFullName: repo.fullName, repo, settings, installation: null, ...signalsFor(repo, [], [], []) }); + + expect(report.ready).toBe(false); + expect(report.recommendedRegistrationMode).toBe("direct_pr"); + expect(report.blockers).toContain("Repository is not registered in the latest Gittensory registry snapshot."); + expect(report.directPrReadiness.ready).toBe(false); + expect(report.issueDiscoveryReadiness.recommendation).toBe("not_recommended"); + expect(report.warnings).toContain("GitHub App public surface is disabled; maintainers will not get comment/label assistance."); + }); + + it("warns when configured labels are missing and the per-PR test gate is unknown", () => { + const repo = repoFor("octo/labels", configFor({ repo: "octo/labels", labelMultipliers: { bug: 1.1, feature: 2 } })); + const issues: IssueRecord[] = [{ repoFullName: repo.fullName, number: 1, title: "Bug", state: "open", labels: ["bug"], linkedPrs: [] }]; + const settings = settingsFor(repo.fullName, { checkRunMode: "off" }); + const report = buildRegistrationReadiness({ repoFullName: repo.fullName, repo, settings, installation: healthyInstall, ...signalsFor(repo, issues, [], [label("bug")]) }); + + expect(report.testCoverageHealth.status).toBe("gate_unknown"); + expect(report.testCoverageHealth.warnings).toEqual(["No trusted label pipeline is verified; trusted-label scoring should stay off until labels are validated."]); + // Disabled check runs are intentional repo policy and must not produce a readiness warning. + expect(report.warnings).not.toContain("Check runs are disabled, so Gittensory cannot surface a per-PR quality gate to maintainers."); + expect(report.warnings).toContain('Configured registry label "feature" is missing from live GitHub labels.'); + }); + + it("surfaces missing GitHub App permissions and events when a public surface is enabled", () => { + const repo = repoFor("octo/perms", configFor({ repo: "octo/perms" })); + const settings = settingsFor(repo.fullName); + const installation: InstallationHealthSummary = { status: "needs_attention", missingPermissions: ["issues"], missingEvents: ["pull_request"] }; + const report = buildRegistrationReadiness({ repoFullName: repo.fullName, repo, settings, installation, ...signalsFor(repo, [], [], [label("bug")]) }); + + expect(report.githubApp.warnings).toEqual( + expect.arrayContaining(["GitHub App is missing permission(s) for the enabled public surface: issues.", "GitHub App is not subscribed to webhook event(s): pull_request."]), + ); + }); + + it("reports issue-discovery and split lanes from the registry config", () => { + const splitRepo = repoFor("octo/split", configFor({ repo: "octo/split", emissionShare: 0.05, issueDiscoveryShare: 0.4 })); + const split = buildRegistrationReadiness({ repoFullName: splitRepo.fullName, repo: splitRepo, settings: settingsFor(splitRepo.fullName), installation: healthyInstall, ...signalsFor(splitRepo, [], [], [label("bug")]) }); + expect(split).toMatchObject({ recommendedRegistrationMode: "split", issuePolicy: "split_pr_and_issue_discovery_enabled", issueDiscoveryReadiness: { recommendation: "enabled" } }); + + const idRepo = repoFor("octo/id", configFor({ repo: "octo/id", emissionShare: 0.05, issueDiscoveryShare: 1 })); + const idReport = buildRegistrationReadiness({ repoFullName: idRepo.fullName, repo: idRepo, settings: settingsFor(idRepo.fullName), installation: healthyInstall, ...signalsFor(idRepo, [], [], [label("bug")]) }); + expect(idReport).toMatchObject({ recommendedRegistrationMode: "issue_discovery", issuePolicy: "issue_discovery_enabled" }); + }); + + it("keeps disabled check runs out of readiness warnings and reports all-PR GitHub App behavior", () => { + const repo = repoFor("octo/all-prs", configFor({ repo: "octo/all-prs" })); + const settings = settingsFor(repo.fullName, { checkRunMode: "off", commentMode: "all_prs", publicSurface: "comment_and_label" }); + const report = buildRegistrationReadiness({ repoFullName: repo.fullName, repo, settings, installation: healthyInstall, ...signalsFor(repo, [], [], [label("bug")]) }); + + // Check runs are intentionally off by default; that must not produce a readiness penalty. + expect(report.testCoverageHealth.status).toBe("gate_ready"); + expect(report.testCoverageHealth.checkRunMode).toBe("off"); + expect(report.testCoverageHealth.warnings).toEqual([]); + expect(report.warnings).not.toContain("Check runs are disabled, so Gittensory cannot surface a per-PR quality gate to maintainers."); + expect(report.githubApp.quietByDefault).toBe(false); + expect(report.githubApp.behavior).toContain("for all PRs"); + }); + + it("notes when the GitHub App is not installed", () => { + const repo = repoFor("octo/uninstalled", configFor({ repo: "octo/uninstalled" }), { isInstalled: false, installationId: null }); + const report = buildRegistrationReadiness({ repoFullName: repo.fullName, repo, settings: settingsFor(repo.fullName), installation: null, ...signalsFor(repo, [], [], [label("bug")]) }); + + expect(report.githubApp.installed).toBe(false); + expect(report.githubApp.behavior).toBe("Gittensory would stay silent because the GitHub App is not installed."); + expect(report.githubApp.warnings).toContain("GitHub App is not installed on this repo; maintainers will not get any automated assistance."); + }); + + it("warns about config attention and strained intake", () => { + const repo = repoFor("octo/strained", configFor({ repo: "octo/strained" })); + const base = signalsFor(repo, [], [], [label("bug")]); + const report = buildRegistrationReadiness({ + repoFullName: repo.fullName, + repo, + settings: settingsFor(repo.fullName), + installation: healthyInstall, + ...base, + configQuality: { ...base.configQuality, level: "needs_attention" }, + contributorIntakeHealth: { ...base.contributorIntakeHealth, level: "strained" }, + }); + + expect(report.ready).toBe(false); + expect(report.warnings).toEqual( + expect.arrayContaining(["Repository config quality needs attention before registration promotion.", "Contributor intake is strained; expect more maintainer triage."]), + ); + }); + + it("keeps the report free of forbidden public language", () => { + const repo = repoFor("octo/ready", configFor({ repo: "octo/ready" })); + const report = buildRegistrationReadiness({ repoFullName: repo.fullName, repo, settings: settingsFor(repo.fullName), installation: healthyInstall, ...signalsFor(repo, [], [], [label("bug")]) }); + expect(JSON.stringify(report)).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + }); +}); + +describe("buildGittensorConfigRecommendation", () => { + it("recommends a small issue-discovery slice and maintainer cut for a clean, registered repo", () => { + const repo = repoFor("octo/ready", configFor({ repo: "octo/ready", emissionShare: 0.2 })); + const issues: IssueRecord[] = [{ repoFullName: repo.fullName, number: 4, title: "Fix flaky cache test", state: "open", labels: ["bug"], linkedPrs: [] }]; + const signals = signalsFor(repo, issues, [], [label("bug")]); + const recommendation = buildGittensorConfigRecommendation({ repoFullName: repo.fullName, repo, settings: settingsFor(repo.fullName), lane: signals.lane, configQuality: signals.configQuality, contributorIntakeHealth: signals.contributorIntakeHealth, maintainerCutReadiness: signals.maintainerCutReadiness }); + + expect(recommendation).toMatchObject({ + privateOnly: true, + recommended: { participationMode: "split", issueDiscoveryShare: 0.1, directPrShare: 0.9, maintainerCut: 0.3 }, + }); + expect(recommendation.reasons).toEqual( + expect.arrayContaining(["Config and intake signals are strong enough to consider a small issue-discovery slice.", "Maintainer cut can be considered because config and queue signals are clean."]), + ); + expect(recommendation.tradeoffs.length).toBeGreaterThanOrEqual(3); + }); + + it("acknowledges an existing issue-discovery lane and a linked-issue requirement in tradeoffs", () => { + const repo = repoFor("octo/idlane", configFor({ repo: "octo/idlane", emissionShare: 0.05, issueDiscoveryShare: 1 })); + const signals = signalsFor(repo, [], [], [label("bug")]); + const recommendation = buildGittensorConfigRecommendation({ repoFullName: repo.fullName, repo, settings: settingsFor(repo.fullName, { requireLinkedIssue: true }), lane: signals.lane, configQuality: signals.configQuality, contributorIntakeHealth: signals.contributorIntakeHealth, maintainerCutReadiness: signals.maintainerCutReadiness }); + + expect(recommendation.reasons).toContain("The current registry lane already routes meaningful work through issue discovery."); + expect(recommendation.tradeoffs).toEqual(expect.arrayContaining([expect.stringContaining("Requiring a linked issue improves traceability")])); + }); + + it("preserves an existing maintainer cut and warns when intake is blocked", () => { + const repo = repoFor("octo/existingcut", configFor({ repo: "octo/existingcut", emissionShare: 0.05, maintainerCut: 0.03 })); + const base = signalsFor(repo, [], [], [label("bug")]); + const recommendation = buildGittensorConfigRecommendation({ + repoFullName: repo.fullName, + repo, + settings: settingsFor(repo.fullName), + lane: base.lane, + configQuality: base.configQuality, + contributorIntakeHealth: { ...base.contributorIntakeHealth, level: "blocked" }, + maintainerCutReadiness: { ...base.maintainerCutReadiness, ready: false }, + }); + + expect(recommendation.recommended.maintainerCut).toBe(0.03); + expect(recommendation.warnings).toContain("Contributor intake is blocked; avoid increasing noisy lanes yet."); + }); + + it("keeps issue discovery disabled and maintainer cut unchanged for a not-ready repo", () => { + const repo = repoFor("octo/unregistered", null); + const signals = signalsFor(repo, [], [], []); + const recommendation = buildGittensorConfigRecommendation({ repoFullName: repo.fullName, repo, settings: settingsFor(repo.fullName), lane: signals.lane, configQuality: signals.configQuality, contributorIntakeHealth: signals.contributorIntakeHealth, maintainerCutReadiness: signals.maintainerCutReadiness }); + + expect(recommendation.recommended.participationMode).toBe("direct_pr"); + expect(recommendation.recommended.issueDiscoveryShare).toBe(0); + expect(recommendation.recommended.maintainerCut).toBe(0); + expect(recommendation.current).toBeNull(); + expect(recommendation.reasons).toContain("Issue discovery should stay disabled until config quality and intake health are excellent."); + expect(JSON.stringify(recommendation)).not.toMatch(FORBIDDEN_PUBLIC_LANGUAGE); + }); +});