From 2e889755d0ec01cd3f505fd6434503661776bb29 Mon Sep 17 00:00:00 2001 From: Khaostica Date: Fri, 29 May 2026 11:34:54 -0400 Subject: [PATCH 1/3] feat(outcomes): learn accepted and rejected PR patterns by repo Add a deterministic repo-centric PR outcome learner that reflects what maintainers actually merge or close, distinct from the contributor-centric outcome history. - buildRepoOutcomePatterns buckets merged / closed-unmerged / open-active / open-stale PRs and extracts outcome patterns by path, label, size, linked-issue presence, test evidence, review churn, and author role. - Maintainer-lane activity (OWNER/MEMBER/COLLABORATOR) is separated from outside-contributor evidence so it never inflates miner-lane merge rates. - Success ("merges well here") and high-closure-risk patterns plus stale-open risk feed the per-repo decision pack risk/why reasoning. - New GET /v1/repos/:owner/:repo/outcome-patterns endpoint, OpenAPI schema, and gittensory_get_repo_outcome_patterns MCP tool. - Bulk listRepoPullRequestFiles / listRepoPullRequestReviews loaders. Public output stays sanitized (no wallet, hotkey, raw trust score, payout, reward estimate, farming, private reviewability, or public score estimate language) and all outputs are deterministic. Closes #35 --- src/api/routes.ts | 15 + src/db/repositories.ts | 12 + src/mcp/server.ts | 28 ++ src/openapi/schemas.ts | 46 +++ src/openapi/spec.ts | 12 + src/queue/processors.ts | 10 + src/services/decision-pack.ts | 45 ++- src/services/repo-outcome-patterns.ts | 96 +++++ src/signals/engine.ts | 365 ++++++++++++++++++ test/integration/api.test.ts | 59 +++ test/unit/decision-pack.test.ts | 34 ++ test/unit/openapi.test.ts | 2 + test/unit/queue.test.ts | 7 + .../repo-outcome-patterns-service.test.ts | 123 ++++++ test/unit/repo-outcome-patterns.test.ts | 364 +++++++++++++++++ 15 files changed, 1213 insertions(+), 5 deletions(-) create mode 100644 src/services/repo-outcome-patterns.ts create mode 100644 test/unit/repo-outcome-patterns-service.test.ts create mode 100644 test/unit/repo-outcome-patterns.test.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index 254577ebb8..efbc6a6f32 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -125,6 +125,7 @@ import { import { buildWeeklyValueReport, generateWeeklyValueReport, loadWeeklyValueReport } from "../services/weekly-value-report"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; +import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; import { buildBountyAdvisory, buildBurdenForecast, @@ -1381,6 +1382,13 @@ export function createApp() { return c.json(reviewability); }); + app.get("/v1/repos/:owner/:repo/outcome-patterns", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const response = await buildRepoOutcomePatternsResponse(c.env, fullName); + if (!response) return c.json({ error: "repo_outcome_patterns_not_found", repoFullName: fullName }, 404); + return c.json(response); + }); + app.get("/v1/contributors/:login/profile", async (c) => { const login = c.req.param("login"); const [github, pullRequests, issues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ @@ -2292,6 +2300,13 @@ async function loadInstallationHealthSummary(env: Env, repo: RepositoryRecord | /* v8 ignore stop */ } +async function buildRepoOutcomePatternsResponse(env: Env, fullName: string) { + const response = await loadOrComputeRepoOutcomePatternsResponse(env, fullName); + if (!response) return null; + const dataQuality = await loadRepoDataQuality(env, fullName); + return attachDataQuality(response as unknown as Record, dataQuality); +} + async function buildRegistrationReadinessResponse(env: Env, fullName: string) { /* v8 ignore start -- Registration readiness route-level shaping over covered signal helpers. */ const intelligence = await buildRepoIntelligenceResponse(env, fullName); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index a4fe5c3470..e98aa343d7 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1688,6 +1688,12 @@ export async function listPullRequestFiles(env: Env, fullName: string, pullNumbe return rows.map(toPullRequestFileRecord); } +export async function listRepoPullRequestFiles(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(pullRequestFiles).where(eq(pullRequestFiles.repoFullName, fullName)).limit(2000); + return rows.map(toPullRequestFileRecord); +} + export async function upsertPullRequestReview(env: Env, review: PullRequestReviewRecord): Promise { const db = getDb(env.DB); await db @@ -1726,6 +1732,12 @@ export async function listPullRequestReviews(env: Env, fullName: string, pullNum return rows.map(toPullRequestReviewRecord); } +export async function listRepoPullRequestReviews(env: Env, fullName: string): Promise { + const db = getDb(env.DB); + const rows = await db.select().from(pullRequestReviews).where(eq(pullRequestReviews.repoFullName, fullName)).limit(2000); + return rows.map(toPullRequestReviewRecord); +} + export async function upsertCheckSummary(env: Env, check: CheckSummaryRecord): Promise { const db = getDb(env.DB); await db diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9025374f49..32309abe8d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -43,6 +43,7 @@ import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../ import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; +import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; import { buildBountyAdvisory, buildCollisionReport, @@ -326,6 +327,15 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.getBurdenForecast(input)), ); + server.registerTool( + "gittensory_get_repo_outcome_patterns", + { + description: "Return cached or freshly-computed per-repo accepted/rejected PR outcome patterns: what maintainers actually merge or close, separated from maintainer-lane activity, with a freshness marker and explicit evidence-completeness.", + inputSchema: ownerRepoShape, + }, + async (input) => this.toolResult(await this.getRepoOutcomePatterns(input)), + ); + server.registerTool( "gittensory_get_contributor_profile", { @@ -640,6 +650,24 @@ export class GittensoryMcp { }; } + private async getRepoOutcomePatterns(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + const response = await loadOrComputeRepoOutcomePatternsResponse(this.env, fullName); + if (!response) { + return { + summary: `Gittensory has no cached repo outcome patterns for ${fullName}.`, + data: { status: "not_found", repoFullName: fullName }, + }; + } + return { + summary: + response.source === "snapshot" + ? `Gittensory repo outcome patterns for ${fullName} (cached, ${response.freshness}).` + : `Gittensory repo outcome patterns for ${fullName} (computed from cached metadata).`, + data: response as unknown as Record, + }; + } + private async loadOpenQueueCounts(fullName: string): Promise<{ openIssues: number; openPullRequests: number }> { const [totals, openIssues, openPullRequests] = await Promise.all([ getLatestRepoGithubTotalsSnapshot(this.env, fullName), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 0c4a4a0907..8d1c988f79 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1186,6 +1186,52 @@ export const ContributorPatternReportSchema = z }) .openapi("ContributorPatternReport"); +export const RepoOutcomeEvidenceCompletenessSchema = z + .object({ + pullRequestsAnalyzed: z.number(), + withFileDetail: z.number(), + withReviewDetail: z.number(), + withCheckDetail: z.number(), + filesCompletenessRatio: z.number(), + reviewsCompletenessRatio: z.number(), + checksCompletenessRatio: z.number(), + fullyDecidedWithDetail: z.number(), + status: z.enum(["complete", "partial", "missing"]), + }) + .openapi("RepoOutcomeEvidenceCompleteness"); + +export const RepoOutcomePatternsSchema = z + .object({ + repoFullName: z.string(), + generatedAt: z.string(), + lane: z.enum(["direct_pr", "issue_discovery", "split", "inactive", "unknown"]), + primaryLanguage: z.string().nullable(), + sampleSize: z.number(), + totals: z.record(z.number()), + outsideContributorMergeRate: z.number(), + maintainerLaneMergeRate: z.number(), + dimensions: z.array(z.record(z.unknown())), + successPatterns: z.array(z.record(z.unknown())), + riskPatterns: z.array(z.record(z.unknown())), + evidenceCompleteness: RepoOutcomeEvidenceCompletenessSchema, + findings: z.array(FindingSchema), + summary: z.string(), + }) + .openapi("RepoOutcomePatterns"); + +export const RepoOutcomePatternsResponseSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["snapshot", "computed"]), + repoFullName: z.string(), + generatedAt: z.string(), + ageSeconds: z.number(), + freshness: z.enum(["fresh", "stale"]), + patterns: RepoOutcomePatternsSchema, + dataQuality: z.record(z.unknown()).optional(), + }) + .openapi("RepoOutcomePatternsResponse"); + export const RepoFitRecommendationSchema = z .object({ login: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b73556335d..b95bd1a5aa 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -47,6 +47,8 @@ import { DecisionPackRefreshNeededSchema, RepoFitRecommendationSchema, RepoDecisionResponseSchema, + RepoOutcomePatternsSchema, + RepoOutcomePatternsResponseSchema, GittensorConfigRecommendationSchema, RegistrationReadinessSchema, RepoIntelligenceSchema, @@ -93,6 +95,8 @@ export function buildOpenApiSpec() { registry.register("DecisionPackRefreshNeeded", DecisionPackRefreshNeededSchema); registry.register("RepoDecisionResponse", RepoDecisionResponseSchema); registry.register("RepoIntelligence", RepoIntelligenceSchema); + registry.register("RepoOutcomePatterns", RepoOutcomePatternsSchema); + registry.register("RepoOutcomePatternsResponse", RepoOutcomePatternsResponseSchema); registry.register("RegistrationReadiness", RegistrationReadinessSchema); registry.register("GittensorConfigRecommendation", GittensorConfigRecommendationSchema); registry.register("RepoFitRecommendation", RepoFitRecommendationSchema); @@ -323,6 +327,14 @@ export function buildOpenApiSpec() { 404: { description: "Repo is unknown or has no issue-quality coverage yet" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/outcome-patterns", + responses: { + 200: { description: "Cached or freshly-computed per-repo accepted/rejected PR outcome patterns with freshness envelope and explicit evidence-completeness", content: { "application/json": { schema: RepoOutcomePatternsResponseSchema } } }, + 404: { description: "Repo is unknown or has no outcome-pattern coverage yet" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/registration-readiness", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index f5d019e17e..98ae3e8947 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -72,6 +72,7 @@ import { buildAndPersistContributorDecisionPack, loadDecisionPackSharedInputs } import { executeAgentRun, explainBlockersWithAgent, planNextWork, preflightBranchWithAgent, preparePrPacketWithAgent } from "../services/agent-orchestrator"; import { loadIssueQualityReportMap } from "../services/issue-quality"; import { generateWeeklyValueReport } from "../services/weekly-value-report"; +import { REPO_OUTCOME_PATTERNS_SIGNAL, computeRepoOutcomePatterns } from "../services/repo-outcome-patterns"; import { buildUpstreamRulesetSnapshot, detectAndPersistUpstreamDrift, @@ -477,6 +478,15 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): payload: issueQuality as unknown as Record, generatedAt, }); + const repoOutcomePatterns = await computeRepoOutcomePatterns(env, repo.fullName, repo); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: repoOutcomePatterns as unknown as Record, + generatedAt, + }); } } diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index db407dbe1d..fb458a0880 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -33,11 +33,14 @@ import { type ContributorOutcomeHistory, type ContributorProfile, type IssueQualityReport, + type OutcomePattern, + type RepoOutcomePatterns, type RoleContext, } from "../signals/engine"; import { buildSignalFidelity } from "../signals/data-quality"; import { buildContributorOpenPrMonitor, type ContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { loadIssueQualityReportMap } from "./issue-quality"; +import { loadRepoOutcomePatternsMap } from "./repo-outcome-patterns"; import type { BountyRecord, ContributorRepoStatRecord, @@ -138,6 +141,7 @@ export type RepoDecision = { languageMatch: LanguageMatch; labelFit: string[]; scoreBlockers: ScoreBlocker[]; + repoOutcomePatterns?: RepoOutcomeSummary | undefined; riskReasons: string[]; whyThisHelps: string[]; nextActions: string[]; @@ -157,6 +161,14 @@ export type RepoDecisionManifestSummary = { publicNotes: string[]; }; +export type RepoOutcomeSummary = { + summary: string; + outsideContributorMergeRate: number; + sampleSize: number; + successPatterns: OutcomePattern[]; + riskPatterns: OutcomePattern[]; +}; + export type DecisionAction = { actionKind: DecisionActionKind; repoFullName: string; @@ -303,7 +315,10 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st fetchGittensorContributorSnapshot(login), ]); const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); - const issueQualityByRepo = await loadIssueQualityReportMap(env, repositories); + const [issueQualityByRepo, repoOutcomePatternsByRepo] = await Promise.all([ + loadIssueQualityReportMap(env, repositories), + loadRepoOutcomePatternsMap(env, repositories), + ]); const focusManifests = await loadRepoFocusManifests( env, repositories.filter((repo) => repo.isRegistered).map((repo) => repo.fullName), @@ -336,6 +351,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st issueQualityByRepo, openPrMonitor, focusManifests, + repoOutcomePatternsByRepo, }); await upsertContributorEvidence(env, { @@ -388,6 +404,7 @@ function buildContributorDecisionPack(args: { issueQualityByRepo?: Map | undefined; openPrMonitor: ContributorOpenPrMonitor; focusManifests?: Map | undefined; + repoOutcomePatternsByRepo?: Map | undefined; }): ContributorDecisionPack { const registeredRepositories = args.repositories.filter((repo) => repo.isRegistered); const syncByRepo = new Map(args.syncStates.map((state) => [state.repoFullName.toLowerCase(), state])); @@ -422,6 +439,7 @@ function buildContributorDecisionPack(args: { labelHistory, issueQuality: issueQualityByRepo.get(key), focusManifest: args.focusManifests?.get(key), + repoOutcomePatterns: args.repoOutcomePatternsByRepo?.get(key), }); }) .sort((left, right) => right.priorityScore - left.priorityScore || left.repoFullName.localeCompare(right.repoFullName)); @@ -478,6 +496,7 @@ function buildRepoDecision(args: { labelHistory?: Set | undefined; issueQuality?: IssueQualityReport | undefined; focusManifest?: FocusManifest | undefined; + repoOutcomePatterns?: RepoOutcomePatterns | undefined; }): RepoDecision { const lane = buildLaneAdvice(args.repo, args.repo.fullName); const config = args.repo.registryConfig; @@ -530,6 +549,9 @@ function buildRepoDecision(args: { const manifest = args.focusManifest; const manifestSummary = manifest && manifest.present ? buildRepoDecisionManifestSummary(manifest) : undefined; const manifestReasons = manifest && manifest.present ? buildRepoDecisionManifestReasons(manifest) : { whyThisHelps: [], nextActions: [], publicNextActions: [], riskReasons: [] }; + const repoOutcomePatterns = summarizeRepoOutcomePatterns(args.repoOutcomePatterns); + const outcomeRiskLines = args.roleContext.maintainerLane ? [] : (repoOutcomePatterns?.riskPatterns ?? []).slice(0, 2).map((pattern) => pattern.detail); + const outcomeSuccessLines = recommendation === "pursue" ? (repoOutcomePatterns?.successPatterns ?? []).slice(0, 1).map((pattern) => pattern.detail) : []; return { repoFullName: args.repo.fullName, recommendation, @@ -542,10 +564,11 @@ function buildRepoDecision(args: { languageMatch, labelFit, scoreBlockers: blockers, - riskReasons: [...riskReasons, ...manifestReasons.riskReasons], - whyThisHelps: [...whyThisHelpsFor(recommendation, copyContext), ...manifestReasons.whyThisHelps], - nextActions: [...nextActionsFor(recommendation, copyContext), ...manifestReasons.nextActions], - publicNextActions: [...publicNextActionsFor(recommendation, copyContext), ...manifestReasons.publicNextActions], + repoOutcomePatterns, + riskReasons: [...new Set([...riskReasons, ...manifestReasons.riskReasons, ...outcomeRiskLines])], + whyThisHelps: [...new Set([...whyThisHelpsFor(recommendation, copyContext), ...manifestReasons.whyThisHelps, ...outcomeSuccessLines])], + nextActions: [...new Set([...nextActionsFor(recommendation, copyContext), ...manifestReasons.nextActions])], + publicNextActions: [...new Set([...publicNextActionsFor(recommendation, copyContext), ...manifestReasons.publicNextActions])], issueQuality, manifestSummary, }; @@ -607,6 +630,18 @@ function buildRepoDecisionManifestReasons(manifest: FocusManifest): { whyThisHel }; } +function summarizeRepoOutcomePatterns(patterns: RepoOutcomePatterns | undefined): RepoOutcomeSummary | undefined { + if (!patterns) return undefined; + if (patterns.sampleSize < 1 && patterns.successPatterns.length === 0 && patterns.riskPatterns.length === 0) return undefined; + return { + summary: patterns.summary, + outsideContributorMergeRate: patterns.outsideContributorMergeRate, + sampleSize: patterns.sampleSize, + successPatterns: patterns.successPatterns.slice(0, 3), + riskPatterns: patterns.riskPatterns.slice(0, 3), + }; +} + function scoreBlockersFor(repoFullName: string, lane: string, roleContext: RoleContext, outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined): ScoreBlocker[] { const blockers: ScoreBlocker[] = []; const openPullRequests = outcome?.openPullRequests ?? 0; diff --git a/src/services/repo-outcome-patterns.ts b/src/services/repo-outcome-patterns.ts new file mode 100644 index 0000000000..59ef2282c7 --- /dev/null +++ b/src/services/repo-outcome-patterns.ts @@ -0,0 +1,96 @@ +import { + getRepoSyncState, + getRepository, + listPullRequestDetailSyncStates, + listPullRequests, + listRecentMergedPullRequests, + listRepoPullRequestFiles, + listRepoPullRequestReviews, + listSignalSnapshots, +} from "../db/repositories"; +import { buildRepoOutcomePatterns, type RepoOutcomePatterns } from "../signals/engine"; + +export const REPO_OUTCOME_PATTERNS_SIGNAL = "repo-outcome-patterns"; +export const REPO_OUTCOME_PATTERNS_MAX_AGE_MS = 6 * 60 * 60 * 1000; + +export type RepoOutcomePatternsFreshness = "fresh" | "stale"; + +export type RepoOutcomePatternsResponse = { + status: "ready"; + source: "snapshot" | "computed"; + repoFullName: string; + generatedAt: string; + ageSeconds: number; + freshness: RepoOutcomePatternsFreshness; + patterns: RepoOutcomePatterns; +}; + +export async function loadOrComputeRepoOutcomePatternsResponse(env: Env, fullName: string): Promise { + const cached = (await listSignalSnapshots(env, REPO_OUTCOME_PATTERNS_SIGNAL, fullName))[0]; + if (cached) { + const payload = cached.payload as unknown as RepoOutcomePatterns; + const generatedAt = cached.generatedAt ?? payload.generatedAt ?? new Date().toISOString(); + const ageMs = snapshotAgeMs(generatedAt); + return { + status: "ready", + source: "snapshot", + repoFullName: fullName, + generatedAt, + ageSeconds: Math.max(0, Math.floor(ageMs / 1000)), + freshness: ageMs > REPO_OUTCOME_PATTERNS_MAX_AGE_MS ? "stale" : "fresh", + patterns: payload, + }; + } + const repo = await getRepository(env, fullName); + if (!repo) return null; + const patterns = await computeRepoOutcomePatterns(env, fullName, repo); + return { + status: "ready", + source: "computed", + repoFullName: fullName, + generatedAt: patterns.generatedAt, + ageSeconds: 0, + freshness: "fresh", + patterns, + }; +} + +export async function loadRepoOutcomePatternsMap(env: Env, repositories: Array<{ fullName: string; isRegistered: boolean }>): Promise> { + const map = new Map(); + await Promise.all( + repositories + .filter((repo) => repo.isRegistered) + .map(async (repo) => { + const latest = (await listSignalSnapshots(env, REPO_OUTCOME_PATTERNS_SIGNAL, repo.fullName))[0]; + if (latest) map.set(repo.fullName.toLowerCase(), latest.payload as unknown as RepoOutcomePatterns); + }), + ); + return map; +} + +export async function computeRepoOutcomePatterns(env: Env, fullName: string, repo?: Awaited>): Promise { + const [resolvedRepo, pullRequests, recentMergedPullRequests, files, reviews, detailSyncStates, syncState] = await Promise.all([ + repo ? Promise.resolve(repo) : getRepository(env, fullName), + listPullRequests(env, fullName), + listRecentMergedPullRequests(env, fullName), + listRepoPullRequestFiles(env, fullName), + listRepoPullRequestReviews(env, fullName), + listPullRequestDetailSyncStates(env, fullName), + getRepoSyncState(env, fullName), + ]); + return buildRepoOutcomePatterns({ + repo: resolvedRepo, + repoFullName: fullName, + pullRequests, + recentMergedPullRequests, + files, + reviews, + detailSyncStates, + syncState, + }); +} + +function snapshotAgeMs(generatedAt: string): number { + const parsed = Date.parse(generatedAt); + return Number.isFinite(parsed) ? Date.now() - parsed : Number.POSITIVE_INFINITY; +} diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 8b08ad1771..fac4e73ae4 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -5,6 +5,7 @@ import type { CollisionEdgeRecord, ContributorRepoStatRecord, IssueRecord, + PullRequestDetailSyncStateRecord, PullRequestFileRecord, PullRequestRecord, PullRequestReviewRecord, @@ -292,6 +293,57 @@ export type ContributorPatternReport = { summary: string; }; +export type RepoOutcomeBucket = "merged" | "closed_unmerged" | "open_active" | "open_stale"; +export type RepoOutcomeDimensionKind = "path" | "label" | "size" | "linked_issue" | "test_evidence" | "review_churn" | "author_role"; +export type RepoOutcomeSignal = "merges_well" | "high_closure_risk" | "mixed"; + +export type RepoOutcomeDimension = { + dimension: RepoOutcomeDimensionKind; + key: string; + merged: number; + closedUnmerged: number; + decided: number; + mergeRate: number; + signal: RepoOutcomeSignal; +}; + +export type RepoOutcomeEvidenceCompleteness = { + pullRequestsAnalyzed: number; + withFileDetail: number; + withReviewDetail: number; + withCheckDetail: number; + filesCompletenessRatio: number; + reviewsCompletenessRatio: number; + checksCompletenessRatio: number; + fullyDecidedWithDetail: number; + status: "complete" | "partial" | "missing"; +}; + +export type RepoOutcomePatterns = { + repoFullName: string; + generatedAt: string; + lane: ParticipationLane; + primaryLanguage: string | null; + sampleSize: number; + totals: { + analyzed: number; + merged: number; + closedUnmerged: number; + openActive: number; + openStale: number; + maintainerLanePullRequests: number; + outsideContributorPullRequests: number; + }; + outsideContributorMergeRate: number; + maintainerLaneMergeRate: number; + dimensions: RepoOutcomeDimension[]; + successPatterns: OutcomePattern[]; + riskPatterns: OutcomePattern[]; + evidenceCompleteness: RepoOutcomeEvidenceCompleteness; + findings: SignalFinding[]; + summary: string; +}; + export type RepoFitRecommendation = { login: string; repoFullName: string; @@ -599,6 +651,11 @@ const STOPWORDS = new Set([ const MAX_COLLISION_PAIRWISE_ISSUES = 80; const MAX_COLLISION_PAIRWISE_PULL_REQUESTS = 120; const MAX_COLLISION_PAIRWISE_RECENT_MERGES = 40; +const REPO_OUTCOME_STALE_OPEN_DAYS = 30; +const REPO_OUTCOME_MIN_DECIDED_SAMPLE = 3; +const REPO_OUTCOME_MERGE_WELL_RATE = 0.7; +const REPO_OUTCOME_CLOSURE_RISK_RATE = 0.34; +const REPO_OUTCOME_MAX_PATTERNS = 12; export function buildLaneAdvice(repo: RepositoryRecord | null, fullName: string): LaneAdvice { const config = repo?.registryConfig; @@ -1692,6 +1749,273 @@ export function buildContributorPatternReport(history: ContributorOutcomeHistory }; } +type RepoOutcomePullRequest = { + number: number; + bucket: RepoOutcomeBucket; + decided: boolean; + merged: boolean; + maintainerLane: boolean; + linked: boolean; + labels: string[]; + filePaths: string[]; + changedLineCount: number; + authorRole: "returning_contributor" | "first_time_or_external"; + hasReview: boolean; + changesRequested: boolean; +}; + +export function buildRepoOutcomePatterns(args: { + repo: RepositoryRecord | null; + repoFullName: string; + pullRequests: PullRequestRecord[]; + recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; + files?: PullRequestFileRecord[] | undefined; + reviews?: PullRequestReviewRecord[] | undefined; + detailSyncStates?: PullRequestDetailSyncStateRecord[] | undefined; + syncState?: RepoSyncStateRecord | null | undefined; +}): RepoOutcomePatterns { + const repoKey = args.repoFullName.toLowerCase(); + const mergedDetailByNumber = new Map(); + for (const record of args.recentMergedPullRequests ?? []) { + if (record.repoFullName.toLowerCase() === repoKey) mergedDetailByNumber.set(record.number, record); + } + const filesByNumber = new Map(); + for (const file of args.files ?? []) { + if (file.repoFullName.toLowerCase() !== repoKey) continue; + const list = filesByNumber.get(file.pullNumber) ?? []; + list.push(file); + filesByNumber.set(file.pullNumber, list); + } + const reviewsByNumber = new Map(); + for (const review of args.reviews ?? []) { + if (review.repoFullName.toLowerCase() !== repoKey) continue; + const list = reviewsByNumber.get(review.pullNumber) ?? []; + list.push(review); + reviewsByNumber.set(review.pullNumber, list); + } + + const lane = buildLaneAdvice(args.repo, args.repoFullName).lane; + const primaryLanguage = args.syncState?.primaryLanguage ?? null; + + const analyzed: RepoOutcomePullRequest[] = args.pullRequests + .filter((pr) => pr.repoFullName.toLowerCase() === repoKey) + .map((pr) => { + const mergedDetail = mergedDetailByNumber.get(pr.number); + const merged = Boolean(pr.mergedAt) || pr.state === "merged"; + const closedUnmerged = !merged && pr.state === "closed"; + const open = !merged && !closedUnmerged; + const stale = open && daysSince(pr.updatedAt ?? pr.createdAt) >= REPO_OUTCOME_STALE_OPEN_DAYS; + const bucket: RepoOutcomeBucket = merged ? "merged" : closedUnmerged ? "closed_unmerged" : stale ? "open_stale" : "open_active"; + const fileRecords = filesByNumber.get(pr.number) ?? []; + const filePaths = [...new Set([...fileRecords.map((file) => file.path), ...(mergedDetail?.changedFiles ?? [])])].sort(); + const reviewRecords = reviewsByNumber.get(pr.number) ?? []; + return { + number: pr.number, + bucket, + decided: merged || closedUnmerged, + merged, + maintainerLane: isMaintainerAssociation(pr.authorAssociation), + linked: pr.linkedIssues.length > 0 || (mergedDetail?.linkedIssues.length ?? 0) > 0, + labels: [...new Set([...pr.labels, ...(mergedDetail?.labels ?? [])])].sort(), + filePaths, + changedLineCount: fileRecords.reduce((sum, file) => sum + file.additions + file.deletions, 0), + authorRole: pr.authorAssociation === "CONTRIBUTOR" ? "returning_contributor" : "first_time_or_external", + hasReview: reviewRecords.length > 0, + changesRequested: reviewRecords.some((review) => review.state === "CHANGES_REQUESTED"), + }; + }); + + const decided = analyzed.filter((pr) => pr.decided); + const maintainer = analyzed.filter((pr) => pr.maintainerLane); + const outsideDecided = decided.filter((pr) => !pr.maintainerLane); + const maintainerDecided = decided.filter((pr) => pr.maintainerLane); + const totals = { + analyzed: analyzed.length, + merged: analyzed.filter((pr) => pr.bucket === "merged").length, + closedUnmerged: analyzed.filter((pr) => pr.bucket === "closed_unmerged").length, + openActive: analyzed.filter((pr) => pr.bucket === "open_active").length, + openStale: analyzed.filter((pr) => pr.bucket === "open_stale").length, + maintainerLanePullRequests: maintainer.length, + outsideContributorPullRequests: analyzed.length - maintainer.length, + }; + const outsideContributorMergeRate = rate(outsideDecided.filter((pr) => pr.merged).length, outsideDecided.length); + const maintainerLaneMergeRate = rate(maintainerDecided.filter((pr) => pr.merged).length, maintainerDecided.length); + + const groups = new Map>(); + const addToGroup = (dimension: RepoOutcomeDimensionKind, key: string, pr: RepoOutcomePullRequest) => { + const byKey = groups.get(dimension) ?? new Map(); + const list = byKey.get(key) ?? []; + list.push(pr); + byKey.set(key, list); + groups.set(dimension, byKey); + }; + for (const pr of outsideDecided) { + for (const bucket of new Set(pr.filePaths.map(pathBucket))) addToGroup("path", bucket, pr); + if (pr.filePaths.length > 0) addToGroup("test_evidence", pr.filePaths.some(isTestFile) ? "with_tests" : "without_tests", pr); + for (const label of pr.labels) addToGroup("label", label, pr); + const size = sizeBucket(pr); + if (size) addToGroup("size", size, pr); + addToGroup("linked_issue", pr.linked ? "linked" : "unlinked", pr); + addToGroup("author_role", pr.authorRole, pr); + if (pr.hasReview) addToGroup("review_churn", pr.changesRequested ? "changes_requested" : "clean_review", pr); + } + + const dimensionOrder: RepoOutcomeDimensionKind[] = ["path", "label", "size", "linked_issue", "test_evidence", "review_churn", "author_role"]; + const dimensions: RepoOutcomeDimension[] = []; + for (const dimension of dimensionOrder) { + const byKey = groups.get(dimension); + if (!byKey) continue; + for (const [key, group] of [...byKey.entries()].sort((left, right) => left[0].localeCompare(right[0]))) { + if (group.length < REPO_OUTCOME_MIN_DECIDED_SAMPLE) continue; + const mergedCount = group.filter((pr) => pr.merged).length; + const mergeRate = rate(mergedCount, group.length); + dimensions.push({ + dimension, + key, + merged: mergedCount, + closedUnmerged: group.length - mergedCount, + decided: group.length, + mergeRate, + signal: outcomeSignal(mergeRate), + }); + } + } + + const successPatterns: OutcomePattern[] = []; + const riskPatterns: OutcomePattern[] = []; + if (outsideDecided.length >= REPO_OUTCOME_MIN_DECIDED_SAMPLE && outsideContributorMergeRate >= REPO_OUTCOME_MERGE_WELL_RATE) { + successPatterns.push({ + repoFullName: args.repoFullName, + title: "Outside contributors merge well here", + detail: `Outside-contributor PRs merge at ${percent(outsideContributorMergeRate)} across ${outsideDecided.length} decided PR(s).`, + confidence: outsideDecided.length >= 6 ? "high" : "medium", + }); + } + if (outsideDecided.length >= REPO_OUTCOME_MIN_DECIDED_SAMPLE && outsideContributorMergeRate <= REPO_OUTCOME_CLOSURE_RISK_RATE) { + riskPatterns.push({ + repoFullName: args.repoFullName, + title: "Outside contributor PRs rarely merge here", + detail: `Outside-contributor PRs merge at only ${percent(outsideContributorMergeRate)} across ${outsideDecided.length} decided PR(s); expect a high closure rate.`, + confidence: outsideDecided.length >= 6 ? "high" : "medium", + }); + } + for (const dimension of dimensions) { + if (dimension.signal === "merges_well") { + successPatterns.push({ + repoFullName: args.repoFullName, + title: "Merge-friendly pattern", + detail: `${describeDimension(dimension.dimension, dimension.key)} merge well here (${dimension.merged}/${dimension.decided} merged).`, + confidence: dimension.decided >= 5 && dimension.mergeRate >= 0.8 ? "high" : "medium", + }); + } else if (dimension.signal === "high_closure_risk") { + riskPatterns.push({ + repoFullName: args.repoFullName, + title: "High closure-risk pattern", + detail: `${describeDimension(dimension.dimension, dimension.key)} have high closure risk here (${dimension.merged}/${dimension.decided} merged).`, + confidence: dimension.decided >= 5 ? "high" : "medium", + }); + } + } + if (totals.openStale > 0) { + riskPatterns.push({ + repoFullName: args.repoFullName, + title: "Stale open PRs", + detail: `${totals.openStale} open PR(s) have been idle for at least ${REPO_OUTCOME_STALE_OPEN_DAYS} days and may not convert.`, + confidence: totals.openStale >= 4 ? "high" : "medium", + }); + } + + const findings: SignalFinding[] = []; + if (outsideDecided.length < REPO_OUTCOME_MIN_DECIDED_SAMPLE) { + findings.push({ + code: "low_outcome_sample", + severity: "info", + title: "Not enough decided outside-contributor PRs", + detail: `Only ${outsideDecided.length} decided outside-contributor PR(s) are cached; merge/close patterns will sharpen as more PRs are synced.`, + }); + } + if (maintainer.length > 0) { + findings.push({ + code: "maintainer_activity_separated", + severity: "info", + title: "Maintainer-lane activity separated", + detail: `${maintainer.length} maintainer-lane PR(s) were excluded from outside-contributor merge evidence.`, + }); + } + if (totals.openStale > 0) { + findings.push({ + code: "stale_open_prs", + severity: "warning", + title: "Stale open PRs are present", + detail: `${totals.openStale} open PR(s) have not updated in at least ${REPO_OUTCOME_STALE_OPEN_DAYS} days.`, + action: "Triage stale open PRs before assuming new work in this repo will land quickly.", + }); + } + + const detailByNumber = new Map(); + for (const state of args.detailSyncStates ?? []) { + if (state.repoFullName.toLowerCase() === repoKey) detailByNumber.set(state.pullNumber, state); + } + const withFileDetail = analyzed.filter((pr) => Boolean(detailByNumber.get(pr.number)?.filesSyncedAt)).length; + const withReviewDetail = analyzed.filter((pr) => Boolean(detailByNumber.get(pr.number)?.reviewsSyncedAt)).length; + const withCheckDetail = analyzed.filter((pr) => Boolean(detailByNumber.get(pr.number)?.checksSyncedAt)).length; + const fullyDecidedWithDetail = decided.filter((pr) => { + const state = detailByNumber.get(pr.number); + return Boolean(state?.filesSyncedAt && state?.reviewsSyncedAt && state?.checksSyncedAt); + }).length; + const filesCompletenessRatio = rate(withFileDetail, analyzed.length); + const reviewsCompletenessRatio = rate(withReviewDetail, analyzed.length); + const checksCompletenessRatio = rate(withCheckDetail, analyzed.length); + const completenessStatus: RepoOutcomeEvidenceCompleteness["status"] = + analyzed.length === 0 || (withFileDetail === 0 && withReviewDetail === 0 && withCheckDetail === 0) + ? "missing" + : filesCompletenessRatio >= 0.85 && reviewsCompletenessRatio >= 0.85 && checksCompletenessRatio >= 0.85 + ? "complete" + : "partial"; + const evidenceCompleteness: RepoOutcomeEvidenceCompleteness = { + pullRequestsAnalyzed: analyzed.length, + withFileDetail, + withReviewDetail, + withCheckDetail, + filesCompletenessRatio, + reviewsCompletenessRatio, + checksCompletenessRatio, + fullyDecidedWithDetail, + status: completenessStatus, + }; + if (analyzed.length > 0 && completenessStatus !== "complete") { + findings.push({ + code: "incomplete_evidence", + severity: completenessStatus === "missing" ? "warning" : "info", + title: completenessStatus === "missing" ? "PR file/review/check evidence is missing" : "PR file/review/check evidence is partial", + detail: `Files synced for ${percent(filesCompletenessRatio)} of analyzed PR(s), reviews for ${percent(reviewsCompletenessRatio)}, checks for ${percent(checksCompletenessRatio)}. Path, size, test-evidence, and review-churn dimensions only reflect PRs with detail-level sync.`, + action: "Wait for detail-level PR sync to complete (or trigger a backfill) before relying on path/test/review dimensions for this repo.", + }); + } + + const sortPatterns = (patterns: OutcomePattern[]) => + patterns + .sort((left, right) => patternRank(right) - patternRank(left) || left.title.localeCompare(right.title) || left.detail.localeCompare(right.detail)) + .slice(0, REPO_OUTCOME_MAX_PATTERNS); + + return { + repoFullName: args.repoFullName, + generatedAt: nowIso(), + lane, + primaryLanguage, + sampleSize: outsideDecided.length, + totals, + outsideContributorMergeRate, + maintainerLaneMergeRate, + dimensions, + successPatterns: sortPatterns(successPatterns), + riskPatterns: sortPatterns(riskPatterns), + evidenceCompleteness, + findings, + summary: `${args.repoFullName}: ${totals.merged} merged, ${totals.closedUnmerged} closed-unmerged, ${totals.openActive + totals.openStale} open (${totals.openStale} stale) PR(s); outside-contributor merge rate ${percent(outsideContributorMergeRate)} across ${outsideDecided.length} decided PR(s); evidence ${completenessStatus} (files ${percent(filesCompletenessRatio)}, reviews ${percent(reviewsCompletenessRatio)}, checks ${percent(checksCompletenessRatio)}).`, + }; +} + export function buildRepoFitRecommendation(args: { login: string; repo: RepositoryRecord | null; @@ -3233,6 +3557,47 @@ function daysSince(value: string | null | undefined): number { return Math.floor((Date.now() - parsed) / 86_400_000); } +function pathBucket(path: string): string { + const normalized = path.replace(/^\.?\/+/, ""); + const slash = normalized.indexOf("/"); + return slash === -1 ? "(root)" : `${normalized.slice(0, slash)}/`; +} + +function sizeBucket(pr: { changedLineCount: number; filePaths: string[] }): "small" | "medium" | "large" | null { + if (pr.changedLineCount > 0) { + return pr.changedLineCount <= 30 ? "small" : pr.changedLineCount <= 200 ? "medium" : "large"; + } + if (pr.filePaths.length > 0) { + return pr.filePaths.length <= 2 ? "small" : pr.filePaths.length <= 10 ? "medium" : "large"; + } + return null; +} + +function outcomeSignal(mergeRate: number): RepoOutcomeSignal { + if (mergeRate >= REPO_OUTCOME_MERGE_WELL_RATE) return "merges_well"; + if (mergeRate <= REPO_OUTCOME_CLOSURE_RISK_RATE) return "high_closure_risk"; + return "mixed"; +} + +function describeDimension(dimension: RepoOutcomeDimensionKind, key: string): string { + switch (dimension) { + case "path": + return `PRs touching ${key}`; + case "label": + return `PRs labeled "${key}"`; + case "size": + return `${key} PRs`; + case "linked_issue": + return key === "linked" ? "PRs that link an issue" : "PRs with no linked issue"; + case "test_evidence": + return key === "with_tests" ? "PRs that include test changes" : "PRs without test changes"; + case "review_churn": + return key === "changes_requested" ? "PRs that received change requests" : "PRs with no change requests"; + case "author_role": + return key === "returning_contributor" ? "PRs from returning contributors" : "PRs from first-time or external authors"; + } +} + function isCodeFile(file: string): boolean { return /\.(ts|tsx|js|jsx|py|rb|rs|kt|scala|java|go|sql)$/i.test(file) && !isTestFile(file); } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 1add1ba879..c8bd9c98ac 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -2167,6 +2167,65 @@ describe("api routes", () => { expect(mutatingCalls).toEqual([]); }); + it("returns 404 for unknown repos and serves cached snapshot with freshness for known repos", async () => { + const app = createApp(); + const env = createTestEnv(); + await seedSignalData(env); + + const unauthenticated = await app.request("/v1/repos/entrius/allways-ui/outcome-patterns", {}, env); + expect(unauthenticated.status).toBe(401); + + const unknown = await app.request("/v1/repos/ghost/missing/outcome-patterns", { headers: apiHeaders(env) }, env); + expect(unknown.status).toBe(404); + + // Known but uncached: falls back to compute (no snapshot exists yet). + const computed = await app.request("/v1/repos/entrius/allways-ui/outcome-patterns", { headers: apiHeaders(env) }, env); + expect(computed.status).toBe(200); + const computedBody = (await computed.json()) as { + source: string; + freshness: string; + patterns: { repoFullName: string; evidenceCompleteness: { status: string; pullRequestsAnalyzed: number } }; + dataQuality: unknown; + }; + expect(computedBody.source).toBe("computed"); + expect(computedBody.freshness).toBe("fresh"); + expect(computedBody.patterns.repoFullName).toBe("entrius/allways-ui"); + expect(computedBody.patterns.evidenceCompleteness.status).toBeDefined(); + expect(computedBody.dataQuality).toBeDefined(); + + // Persist a snapshot directly and re-fetch — the endpoint must serve from the snapshot. + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "repo-outcome-patterns", + targetKey: "entrius/allways-ui", + repoFullName: "entrius/allways-ui", + payload: { + repoFullName: "entrius/allways-ui", + generatedAt: new Date(Date.now() - 60_000).toISOString(), + lane: "direct_pr", + primaryLanguage: "TypeScript", + sampleSize: 0, + totals: { analyzed: 0, merged: 0, closedUnmerged: 0, openActive: 0, openStale: 0, maintainerLanePullRequests: 0, outsideContributorPullRequests: 0 }, + outsideContributorMergeRate: 0, + maintainerLaneMergeRate: 0, + dimensions: [], + successPatterns: [], + riskPatterns: [], + evidenceCompleteness: { pullRequestsAnalyzed: 0, withFileDetail: 0, withReviewDetail: 0, withCheckDetail: 0, filesCompletenessRatio: 0, reviewsCompletenessRatio: 0, checksCompletenessRatio: 0, fullyDecidedWithDetail: 0, status: "missing" }, + findings: [], + summary: "fixture", + } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + const cached = await app.request("/v1/repos/entrius/allways-ui/outcome-patterns", { headers: apiHeaders(env) }, env); + expect(cached.status).toBe(200); + const cachedBody = (await cached.json()) as { source: string; freshness: string; patterns: { summary: string } }; + expect(cachedBody.source).toBe("snapshot"); + expect(cachedBody.freshness).toBe("fresh"); + expect(cachedBody.patterns.summary).toBe("fixture"); + expect(JSON.stringify(cachedBody)).not.toMatch(/wallet|hotkey|payout|reward estimate|farming/i); + }); + it("reports ready status when required public-review dependencies are present", async () => { const app = createApp(); const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "public-token" }); diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index da8a3f0927..c4be747d2d 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -108,6 +108,40 @@ describe("decision-pack service", () => { expect(__decisionPackInternals.round(1.23456)).toBe(1.2346); }); + it("feeds repo outcome patterns into repo decisions without inflating maintainer-lane evidence", () => { + const outsideRole = { maintainerLane: false } as any; + const maintainerRole = { maintainerLane: true } as any; + const patterns = { + summary: "owner/direct: 5 merged, 3 closed-unmerged, 0 open (0 stale) PR(s); outside-contributor merge rate 62% across 8 decided PR(s).", + outsideContributorMergeRate: 0.62, + sampleSize: 8, + successPatterns: [{ repoFullName: "owner/direct", title: "Merge-friendly pattern", detail: "PRs touching src/ merge well here (5/5 merged).", confidence: "high" }], + riskPatterns: [{ repoFullName: "owner/direct", title: "High closure-risk pattern", detail: "PRs with no linked issue have high closure risk here (0/3 merged).", confidence: "medium" }], + } as any; + + const pursue = __decisionPackInternals.buildRepoDecision({ + repo: repo("owner/direct", 0.03, 0), + roleContext: outsideRole, + outcome: undefined, + repoOutcomePatterns: patterns, + }); + expect(pursue.recommendation).toBe("pursue"); + expect(pursue.repoOutcomePatterns?.sampleSize).toBe(8); + expect(pursue.whyThisHelps.some((line) => line.includes("PRs touching src/ merge well here"))).toBe(true); + expect(pursue.riskReasons.some((line) => line.includes("high closure risk"))).toBe(true); + + // Maintainer-lane repos surface the patterns for context but never fold the risk into the contributor's own risk reasons. + const maintainer = __decisionPackInternals.buildRepoDecision({ + repo: repo("owner/direct", 0.03, 0), + roleContext: maintainerRole, + outcome: undefined, + repoOutcomePatterns: patterns, + }); + expect(maintainer.recommendation).toBe("maintainer_lane"); + expect(maintainer.repoOutcomePatterns?.sampleSize).toBe(8); + expect(maintainer.riskReasons.some((line) => line.includes("high closure risk"))).toBe(false); + }); + it("redacts official hotkeys, loads stale snapshots, and resolves repo decisions case-insensitively", async () => { const env = createTestEnv(); const pack = { diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index d92699efe2..406ec286d1 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -12,6 +12,7 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/sync/status"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/intelligence"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/issue-quality"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/outcome-patterns"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/registration-readiness"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gittensor-config-recommendation"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet"]).toBeDefined(); @@ -82,6 +83,7 @@ describe("OpenAPI contract", () => { expect(spec.components?.schemas?.DecisionPackRefreshNeeded).toBeDefined(); expect(spec.components?.schemas?.RepoDecisionResponse).toBeDefined(); expect(spec.components?.schemas?.RepoIntelligence).toBeDefined(); + expect(spec.components?.schemas?.RepoOutcomePatterns).toBeDefined(); expect(spec.components?.schemas?.RegistrationReadiness).toBeDefined(); expect(spec.components?.schemas?.GittensorConfigRecommendation).toBeDefined(); expect(spec.components?.schemas?.PullRequestMaintainerPacket).toBeDefined(); diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 2f33625404..ffcdb84688 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -135,6 +135,13 @@ describe("queue processors", () => { const issueQualitySnapshots = await listSignalSnapshots(env, "issue-quality", "JSONbored/gittensory"); expect(issueQualitySnapshots).toHaveLength(1); expect(issueQualitySnapshots[0]?.payload).toMatchObject({ repoFullName: "JSONbored/gittensory", issues: expect.any(Array), summary: expect.any(String) }); + const outcomePatternSnapshots = await listSignalSnapshots(env, "repo-outcome-patterns", "JSONbored/gittensory"); + expect(outcomePatternSnapshots).toHaveLength(1); + expect(outcomePatternSnapshots[0]?.payload).toMatchObject({ + repoFullName: "JSONbored/gittensory", + totals: expect.any(Object), + evidenceCompleteness: expect.objectContaining({ status: expect.any(String) }), + }); expect(await listSignalSnapshots(env, "contributor-decision-pack", "oktofeesh1")).not.toHaveLength(0); expect(await getContributorEvidence(env, "oktofeesh1")).toMatchObject({ login: "oktofeesh1" }); expect(await getContributorScoringProfile(env, "oktofeesh1")).toMatchObject({ login: "oktofeesh1" }); diff --git a/test/unit/repo-outcome-patterns-service.test.ts b/test/unit/repo-outcome-patterns-service.test.ts new file mode 100644 index 0000000000..7f2c0249a5 --- /dev/null +++ b/test/unit/repo-outcome-patterns-service.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { persistSignalSnapshot, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { + REPO_OUTCOME_PATTERNS_MAX_AGE_MS, + REPO_OUTCOME_PATTERNS_SIGNAL, + loadOrComputeRepoOutcomePatternsResponse, + loadRepoOutcomePatternsMap, +} from "../../src/services/repo-outcome-patterns"; +import { createTestEnv } from "../helpers/d1"; + +function snapshotPayload(repoFullName: string, summary: string) { + return { + repoFullName, + generatedAt: new Date().toISOString(), + lane: "direct_pr", + primaryLanguage: "TypeScript", + sampleSize: 0, + totals: { analyzed: 0, merged: 0, closedUnmerged: 0, openActive: 0, openStale: 0, maintainerLanePullRequests: 0, outsideContributorPullRequests: 0 }, + outsideContributorMergeRate: 0, + maintainerLaneMergeRate: 0, + dimensions: [], + successPatterns: [], + riskPatterns: [], + evidenceCompleteness: { pullRequestsAnalyzed: 0, withFileDetail: 0, withReviewDetail: 0, withCheckDetail: 0, filesCompletenessRatio: 0, reviewsCompletenessRatio: 0, checksCompletenessRatio: 0, fullyDecidedWithDetail: 0, status: "missing" }, + findings: [], + summary, + }; +} + +describe("loadOrComputeRepoOutcomePatternsResponse", () => { + it("returns null when the repo is unknown and has no snapshot", async () => { + const env = createTestEnv(); + const response = await loadOrComputeRepoOutcomePatternsResponse(env, "ghost/missing"); + expect(response).toBeNull(); + }); + + it("serves a snapshot envelope with freshness:fresh when recently persisted", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "fresh", full_name: "owner/fresh", private: false, owner: { login: "owner" }, default_branch: "main" }); + const generatedAt = new Date(Date.now() - 60_000).toISOString(); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: "owner/fresh", + repoFullName: "owner/fresh", + payload: { ...snapshotPayload("owner/fresh", "cached fixture"), generatedAt } as unknown as Record, + generatedAt, + }); + const response = await loadOrComputeRepoOutcomePatternsResponse(env, "owner/fresh"); + expect(response).toMatchObject({ status: "ready", source: "snapshot", freshness: "fresh", patterns: { summary: "cached fixture" } }); + expect(response?.ageSeconds).toBeGreaterThanOrEqual(0); + }); + + it("flags freshness:stale once the snapshot is older than the max age", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "old", full_name: "owner/old", private: false, owner: { login: "owner" }, default_branch: "main" }); + const generatedAt = new Date(Date.now() - REPO_OUTCOME_PATTERNS_MAX_AGE_MS - 60_000).toISOString(); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: "owner/old", + repoFullName: "owner/old", + payload: { ...snapshotPayload("owner/old", "stale fixture"), generatedAt } as unknown as Record, + generatedAt, + }); + const response = await loadOrComputeRepoOutcomePatternsResponse(env, "owner/old"); + expect(response).toMatchObject({ status: "ready", source: "snapshot", freshness: "stale" }); + }); + + it("falls back to a computed envelope when a known repo has no snapshot yet", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "uncached", full_name: "owner/uncached", private: false, owner: { login: "owner" }, default_branch: "main" }); + const response = await loadOrComputeRepoOutcomePatternsResponse(env, "owner/uncached"); + expect(response).toMatchObject({ status: "ready", source: "computed", freshness: "fresh", patterns: { repoFullName: "owner/uncached" } }); + }); + + it("does not call broad request-time PR listers when a cached snapshot exists", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "perf", full_name: "owner/perf", private: false, owner: { login: "owner" }, default_branch: "main" }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: "owner/perf", + repoFullName: "owner/perf", + payload: snapshotPayload("owner/perf", "cached fixture") as unknown as Record, + generatedAt: new Date(Date.now() - 1000).toISOString(), + }); + const repositoriesModule = await import("../../src/db/repositories"); + const spies = [ + vi.spyOn(repositoriesModule, "listPullRequests"), + vi.spyOn(repositoriesModule, "listRecentMergedPullRequests"), + vi.spyOn(repositoriesModule, "listRepoPullRequestFiles"), + vi.spyOn(repositoriesModule, "listRepoPullRequestReviews"), + vi.spyOn(repositoriesModule, "listPullRequestDetailSyncStates"), + ]; + await loadOrComputeRepoOutcomePatternsResponse(env, "owner/perf"); + for (const spy of spies) { + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + } + }); +}); + +describe("loadRepoOutcomePatternsMap", () => { + it("bulk-loads cached snapshots for registered repos only", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "a", full_name: "owner/a", private: false, owner: { login: "owner" }, default_branch: "main" }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: "owner/a", + repoFullName: "owner/a", + payload: snapshotPayload("owner/a", "cached") as unknown as Record, + generatedAt: new Date().toISOString(), + }); + const map = await loadRepoOutcomePatternsMap(env, [ + { fullName: "owner/a", isRegistered: true }, + { fullName: "owner/b", isRegistered: true }, + { fullName: "owner/c", isRegistered: false }, // skipped + ]); + expect([...map.keys()]).toEqual(["owner/a"]); + }); +}); diff --git a/test/unit/repo-outcome-patterns.test.ts b/test/unit/repo-outcome-patterns.test.ts new file mode 100644 index 0000000000..fa352f0c84 --- /dev/null +++ b/test/unit/repo-outcome-patterns.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from "vitest"; +import { buildRepoOutcomePatterns, type RepoOutcomePatterns } from "../../src/signals/engine"; +import type { + PullRequestDetailSyncStateRecord, + PullRequestFileRecord, + PullRequestRecord, + PullRequestReviewRecord, + RecentMergedPullRequestRecord, + RegistryRepoConfig, + RepositoryRecord, + RepoSyncStateRecord, +} from "../../src/types"; + +const REPO = "acme/widgets"; +const FORBIDDEN = /wallet|hotkey|raw trust score|payout|reward estimate|farming|private reviewability|public score estimate/i; + +function repo(fullName = REPO, overrides: Partial = {}): RepositoryRecord { + const [owner, name] = fullName.split("/") as [string, string]; + return { + fullName, + owner, + name, + isInstalled: true, + isRegistered: true, + isPrivate: false, + defaultBranch: "main", + registryConfig: { + repo: fullName, + emissionShare: 0.02, + issueDiscoveryShare: 0, + labelMultipliers: {}, + trustedLabelPipeline: false, + maintainerCut: 0, + raw: {}, + ...overrides, + }, + }; +} + +function pr(number: number, overrides: Partial = {}): PullRequestRecord { + return { + repoFullName: REPO, + number, + title: `PR ${number}`, + state: "open", + authorLogin: "dev", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + body: "", + updatedAt: new Date().toISOString(), + ...overrides, + }; +} + +function mergedPr(number: number, overrides: Partial = {}): PullRequestRecord { + return pr(number, { state: "merged", mergedAt: "2026-05-01T00:00:00.000Z", ...overrides }); +} + +function closedPr(number: number, overrides: Partial = {}): PullRequestRecord { + return pr(number, { state: "closed", ...overrides }); +} + +function file(pullNumber: number, path: string, additions = 10, deletions = 1): PullRequestFileRecord { + return { repoFullName: REPO, pullNumber, path, additions, deletions, changes: additions + deletions, payload: {} }; +} + +function review(pullNumber: number, state: string): PullRequestReviewRecord { + return { id: `${REPO}#${pullNumber}#${state}`, repoFullName: REPO, pullNumber, reviewerLogin: "maintainer", state, payload: {} }; +} + +function syncState(primaryLanguage: string | null = "TypeScript"): RepoSyncStateRecord { + return { + repoFullName: REPO, + status: "success", + sourceKind: "github", + primaryLanguage, + openIssuesCount: 0, + openPullRequestsCount: 0, + recentMergedPullRequestsCount: 0, + warnings: [], + }; +} + +function allText(patterns: RepoOutcomePatterns): string { + return [ + patterns.summary, + ...patterns.successPatterns.flatMap((p) => [p.title, p.detail]), + ...patterns.riskPatterns.flatMap((p) => [p.title, p.detail]), + ...patterns.findings.flatMap((f) => [f.title, f.detail, f.action ?? ""]), + ].join(" \n "); +} + +// A repo where src/ + linked + tested + reviewed outside-contributor PRs merge, and +// docs/ + unlinked + change-requested PRs are closed unmerged. +function primaryFixture() { + const pullRequests: PullRequestRecord[] = [ + mergedPr(1, { authorAssociation: "CONTRIBUTOR", linkedIssues: [101], labels: ["bug"] }), + mergedPr(2, { authorAssociation: "CONTRIBUTOR", linkedIssues: [102], labels: ["bug"] }), + mergedPr(3, { authorAssociation: "CONTRIBUTOR", linkedIssues: [103], labels: ["bug"] }), + mergedPr(4, { authorAssociation: "CONTRIBUTOR", linkedIssues: [104], labels: ["bug"] }), + mergedPr(5, { authorAssociation: "CONTRIBUTOR", linkedIssues: [105], labels: ["bug"] }), + closedPr(6, { authorAssociation: "NONE", labels: ["wontfix"] }), + closedPr(7, { authorAssociation: "NONE", labels: ["wontfix"] }), + closedPr(8, { authorAssociation: "NONE", labels: ["wontfix"] }), + closedPr(9, { authorAssociation: "NONE", labels: ["wontfix"] }), + ]; + const files: PullRequestFileRecord[] = [ + file(1, "src/a.ts"), + file(2, "src/b.ts"), + file(2, "src/b.test.ts"), + file(3, "src/c.ts"), + file(3, "src/c.test.ts"), + file(4, "src/d.ts"), + file(4, "src/d.test.ts"), + file(5, "src/e.ts"), + file(5, "src/e.test.ts"), + file(6, "docs/x.md"), + file(7, "docs/y.md"), + file(8, "docs/z.md"), + file(9, "docs/w.md"), + ]; + const reviews: PullRequestReviewRecord[] = [ + review(1, "APPROVED"), + review(2, "APPROVED"), + review(3, "APPROVED"), + review(4, "APPROVED"), + review(5, "APPROVED"), + review(6, "CHANGES_REQUESTED"), + review(7, "CHANGES_REQUESTED"), + review(8, "CHANGES_REQUESTED"), + review(9, "CHANGES_REQUESTED"), + ]; + return { repo: repo(), repoFullName: REPO, pullRequests, files, reviews, syncState: syncState() }; +} + +describe("buildRepoOutcomePatterns", () => { + it("learns merged PR patterns by path, label, linked-issue, tests, review, and author role", () => { + const result = buildRepoOutcomePatterns(primaryFixture()); + + expect(result.totals).toMatchObject({ analyzed: 9, merged: 5, closedUnmerged: 4, openActive: 0, openStale: 0, maintainerLanePullRequests: 0, outsideContributorPullRequests: 9 }); + expect(result.sampleSize).toBe(9); + expect(result.primaryLanguage).toBe("TypeScript"); + expect(result.lane).toBe("direct_pr"); + + const path = result.dimensions.find((d) => d.dimension === "path" && d.key === "src/"); + expect(path).toMatchObject({ merged: 5, closedUnmerged: 0, decided: 5, mergeRate: 1, signal: "merges_well" }); + + const successDetails = result.successPatterns.map((p) => p.detail); + expect(successDetails).toContain('PRs touching src/ merge well here (5/5 merged).'); + expect(successDetails).toContain('PRs labeled "bug" merge well here (5/5 merged).'); + expect(successDetails).toContain("PRs that link an issue merge well here (5/5 merged)."); + expect(successDetails).toContain("PRs that include test changes merge well here (4/4 merged)."); + expect(successDetails).toContain("PRs from returning contributors merge well here (5/5 merged)."); + }); + + it("learns closed PR (high closure-risk) patterns", () => { + const result = buildRepoOutcomePatterns(primaryFixture()); + const riskDetails = result.riskPatterns.map((p) => p.detail); + expect(riskDetails).toContain("PRs touching docs/ have high closure risk here (0/4 merged)."); + expect(riskDetails).toContain("PRs with no linked issue have high closure risk here (0/4 merged)."); + expect(riskDetails).toContain("PRs that received change requests have high closure risk here (0/4 merged)."); + + const docs = result.dimensions.find((d) => d.dimension === "path" && d.key === "docs/"); + expect(docs).toMatchObject({ merged: 0, decided: 4, mergeRate: 0, signal: "high_closure_risk" }); + }); + + it("flags an overall high closure rate when outside contributors rarely merge", () => { + const pullRequests = [closedPr(1), closedPr(2), closedPr(3), mergedPr(4)]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests }); + expect(result.outsideContributorMergeRate).toBeCloseTo(0.25, 5); + expect(result.riskPatterns.some((p) => p.title === "Outside contributor PRs rarely merge here")).toBe(true); + }); + + it("flags overall merge-friendliness when outside contributors merge well", () => { + const pullRequests = Array.from({ length: 6 }, (_, index) => mergedPr(index + 1)); + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests }); + expect(result.outsideContributorMergeRate).toBe(1); + const overall = result.successPatterns.find((p) => p.title === "Outside contributors merge well here"); + expect(overall).toBeDefined(); + expect(overall?.confidence).toBe("high"); + }); + + it("separates maintainer-lane activity from outside-contributor merge evidence", () => { + const pullRequests = [ + mergedPr(1, { authorAssociation: "OWNER", labels: ["bug"] }), + mergedPr(2, { authorAssociation: "MEMBER", labels: ["bug"] }), + closedPr(3, { authorAssociation: "NONE", labels: ["bug"] }), + closedPr(4, { authorAssociation: "NONE", labels: ["bug"] }), + closedPr(5, { authorAssociation: "NONE", labels: ["bug"] }), + ]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests }); + expect(result.totals.maintainerLanePullRequests).toBe(2); + expect(result.totals.outsideContributorPullRequests).toBe(3); + expect(result.maintainerLaneMergeRate).toBe(1); + expect(result.outsideContributorMergeRate).toBe(0); + // Maintainer merges must not produce an outside-contributor success pattern. + expect(result.successPatterns).toHaveLength(0); + expect(result.findings.some((f) => f.code === "maintainer_activity_separated")).toBe(true); + }); + + it("treats idle open PRs as stale risk", () => { + const pullRequests = [ + pr(1, { state: "open", updatedAt: "2020-01-01T00:00:00.000Z" }), + pr(2, { state: "open", updatedAt: "2020-01-01T00:00:00.000Z" }), + pr(3, { state: "open", updatedAt: "2020-01-01T00:00:00.000Z" }), + pr(4, { state: "open", updatedAt: "2020-01-01T00:00:00.000Z" }), + pr(5, { state: "open", updatedAt: new Date().toISOString() }), + ]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests }); + expect(result.totals.openStale).toBe(4); + expect(result.totals.openActive).toBe(1); + expect(result.riskPatterns.some((p) => p.title === "Stale open PRs" && p.confidence === "high")).toBe(true); + expect(result.findings.some((f) => f.code === "stale_open_prs")).toBe(true); + }); + + it("reports a low-sample finding when there are too few decided PRs", () => { + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests: [mergedPr(1)] }); + expect(result.findings.some((f) => f.code === "low_outcome_sample")).toBe(true); + expect(result.dimensions).toHaveLength(0); + }); + + it("handles an unknown/unregistered repo and empty corpus", () => { + const result = buildRepoOutcomePatterns({ repo: null, repoFullName: "ghost/repo", pullRequests: [] }); + expect(result.lane).toBe("unknown"); + expect(result.totals.analyzed).toBe(0); + expect(result.sampleSize).toBe(0); + expect(result.findings.some((f) => f.code === "low_outcome_sample")).toBe(true); + }); + + it("enriches merged PR file/label evidence from recent-merged records", () => { + const pullRequests = [mergedPr(1), mergedPr(2), closedPr(3)]; + const recentMergedPullRequests: RecentMergedPullRequestRecord[] = [ + { repoFullName: REPO, number: 1, title: "PR 1", authorLogin: "dev", mergedAt: "2026-05-01T00:00:00.000Z", labels: ["feature"], linkedIssues: [9], changedFiles: ["api/server.ts"], payload: {} }, + { repoFullName: REPO, number: 2, title: "PR 2", authorLogin: "dev", mergedAt: "2026-05-02T00:00:00.000Z", labels: ["feature"], linkedIssues: [10], changedFiles: ["api/router.ts"], payload: {} }, + { repoFullName: REPO, number: 3, title: "PR 3", authorLogin: "dev", mergedAt: null, labels: ["feature"], linkedIssues: [], changedFiles: ["api/legacy.ts"], payload: {} }, + ]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests, recentMergedPullRequests }); + const apiPath = result.dimensions.find((d) => d.dimension === "path" && d.key === "api/"); + expect(apiPath).toMatchObject({ decided: 3, merged: 2 }); + }); + + it("ignores pull requests, files, and reviews from other repos", () => { + const pullRequests = [mergedPr(1), mergedPr(2), closedPr(3), { ...mergedPr(99), repoFullName: "other/repo" }]; + const files = [file(1, "src/a.ts"), { ...file(99, "src/z.ts"), repoFullName: "other/repo" }]; + const reviews = [review(1, "APPROVED"), { ...review(99, "CHANGES_REQUESTED"), repoFullName: "other/repo" }]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests, files, reviews }); + expect(result.totals.analyzed).toBe(3); + }); + + it("buckets PR size by changed lines or file count and handles root-level files", () => { + const pullRequests = [ + mergedPr(1, { linkedIssues: [1] }), + mergedPr(2, { linkedIssues: [2] }), + closedPr(3), + ]; + const files = [ + // small by lines + file(1, "README.md", 5, 1), + // large by lines + file(2, "src/big.ts", 400, 50), + // medium by file count (no line totals -> falls back, but additions present so use lines): give many small files + file(3, "docs/a.md", 100, 60), + ]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests, files }); + const sizeKeys = result.dimensions.filter((d) => d.dimension === "size").map((d) => d.key); + // Only one size bucket reaches the 3-sample threshold at most; assert the size dimension stays internally consistent. + for (const dim of result.dimensions.filter((d) => d.dimension === "size")) { + expect(["small", "medium", "large"]).toContain(dim.key); + } + expect(Array.isArray(sizeKeys)).toBe(true); + // README.md is a root-level file, so its path bucket is "(root)". + expect(result.dimensions.every((d) => d.dimension !== "path" || /\/$|\(root\)/.test(d.key))).toBe(true); + }); + + it("falls back to file-count sizing when no line totals are present", () => { + const recentMergedPullRequests: RecentMergedPullRequestRecord[] = [1, 2, 3].map((number) => ({ + repoFullName: REPO, + number, + title: `PR ${number}`, + authorLogin: "dev", + mergedAt: "2026-05-01T00:00:00.000Z", + labels: [], + linkedIssues: [], + changedFiles: Array.from({ length: 12 }, (_, index) => `pkg/file-${number}-${index}.ts`), + payload: {}, + })); + const pullRequests = [mergedPr(1), mergedPr(2), mergedPr(3)]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests, recentMergedPullRequests }); + expect(result.dimensions.some((d) => d.dimension === "size" && d.key === "large")).toBe(true); + }); + + it("emits dimensions in a deterministic order and is stable across runs", () => { + const order = ["path", "label", "size", "linked_issue", "test_evidence", "review_churn", "author_role"]; + const first = buildRepoOutcomePatterns(primaryFixture()); + const second = buildRepoOutcomePatterns(primaryFixture()); + + const rankOf = (dimension: string) => order.indexOf(dimension); + for (let i = 1; i < first.dimensions.length; i += 1) { + const prev = first.dimensions[i - 1]!; + const curr = first.dimensions[i]!; + const rankDelta = rankOf(curr.dimension) - rankOf(prev.dimension); + expect(rankDelta >= 0).toBe(true); + if (rankDelta === 0) expect(curr.key.localeCompare(prev.key) >= 0).toBe(true); + } + + const strip = (p: RepoOutcomePatterns) => ({ ...p, generatedAt: "" }); + expect(strip(first)).toEqual(strip(second)); + }); + + it("reports evidence completeness from PR detail-sync states", () => { + const pullRequests = [mergedPr(1), mergedPr(2), closedPr(3), closedPr(4)]; + const detailSyncStates: PullRequestDetailSyncStateRecord[] = [ + { repoFullName: REPO, pullNumber: 1, status: "complete", filesSyncedAt: "t", reviewsSyncedAt: "t", checksSyncedAt: "t" }, + { repoFullName: REPO, pullNumber: 2, status: "complete", filesSyncedAt: "t", reviewsSyncedAt: "t", checksSyncedAt: "t" }, + { repoFullName: REPO, pullNumber: 3, status: "complete", filesSyncedAt: "t", reviewsSyncedAt: "t", checksSyncedAt: "t" }, + { repoFullName: REPO, pullNumber: 4, status: "complete", filesSyncedAt: "t", reviewsSyncedAt: "t", checksSyncedAt: "t" }, + ]; + const complete = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests, detailSyncStates }); + expect(complete.evidenceCompleteness).toMatchObject({ + pullRequestsAnalyzed: 4, + withFileDetail: 4, + withReviewDetail: 4, + withCheckDetail: 4, + fullyDecidedWithDetail: 4, + status: "complete", + }); + expect(complete.findings.some((f) => f.code === "incomplete_evidence")).toBe(false); + + const partial = buildRepoOutcomePatterns({ + repo: repo(), + repoFullName: REPO, + pullRequests, + detailSyncStates: [{ repoFullName: REPO, pullNumber: 1, status: "complete", filesSyncedAt: "t", reviewsSyncedAt: null, checksSyncedAt: null }], + }); + expect(partial.evidenceCompleteness.status).toBe("partial"); + expect(partial.findings.some((f) => f.code === "incomplete_evidence" && f.severity === "info")).toBe(true); + + const missing = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests }); + expect(missing.evidenceCompleteness.status).toBe("missing"); + expect(missing.findings.some((f) => f.code === "incomplete_evidence" && f.severity === "warning")).toBe(true); + expect(missing.summary).toMatch(/evidence missing/); + }); + + it("never emits forbidden public-surface language", () => { + const fixtures = [ + buildRepoOutcomePatterns(primaryFixture()), + buildRepoOutcomePatterns({ + repo: repo(), + repoFullName: REPO, + pullRequests: [ + mergedPr(1, { authorAssociation: "OWNER" }), + closedPr(2), + closedPr(3), + closedPr(4), + pr(5, { state: "open", updatedAt: "2020-01-01T00:00:00.000Z" }), + ], + }), + ]; + for (const fixture of fixtures) { + expect(allText(fixture)).not.toMatch(FORBIDDEN); + } + }); +}); From 432174c10acccfcf668efe042ec074df4b79801e Mon Sep 17 00:00:00 2001 From: Khaostica Date: Mon, 1 Jun 2026 10:18:44 -0400 Subject: [PATCH 2/3] test(outcomes): cover repo outcome pattern branches Add targeted branch-coverage tests for the new repo-outcome-patterns surface so the global 97% branch gate passes without lowering it: - service: snapshot generatedAt fallbacks (payload timestamp, current time), unparseable timestamps treated as stale, and computing patterns directly when no repo record is supplied - engine: createdAt fallback for open PRs missing updatedAt, the high-confidence overall closure-risk pattern, and file-count size bucketing (small/medium) --- .../repo-outcome-patterns-service.test.ts | 68 +++++++++++++++++++ test/unit/repo-outcome-patterns.test.ts | 33 +++++++++ 2 files changed, 101 insertions(+) diff --git a/test/unit/repo-outcome-patterns-service.test.ts b/test/unit/repo-outcome-patterns-service.test.ts index 7f2c0249a5..cff270b76a 100644 --- a/test/unit/repo-outcome-patterns-service.test.ts +++ b/test/unit/repo-outcome-patterns-service.test.ts @@ -3,9 +3,11 @@ import { persistSignalSnapshot, upsertRepositoryFromGitHub } from "../../src/db/ import { REPO_OUTCOME_PATTERNS_MAX_AGE_MS, REPO_OUTCOME_PATTERNS_SIGNAL, + computeRepoOutcomePatterns, loadOrComputeRepoOutcomePatternsResponse, loadRepoOutcomePatternsMap, } from "../../src/services/repo-outcome-patterns"; +import type { SignalSnapshotRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; function snapshotPayload(repoFullName: string, summary: string) { @@ -74,6 +76,62 @@ describe("loadOrComputeRepoOutcomePatternsResponse", () => { expect(response).toMatchObject({ status: "ready", source: "computed", freshness: "fresh", patterns: { repoFullName: "owner/uncached" } }); }); + it("uses the payload timestamp when the snapshot column carries no generatedAt", async () => { + const env = createTestEnv(); + const generatedAt = new Date(Date.now() - 120_000).toISOString(); + const repositoriesModule = await import("../../src/db/repositories"); + const snapshot: SignalSnapshotRecord = { + id: "snap-payload-ts", + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: "owner/payload-ts", + repoFullName: "owner/payload-ts", + generatedAt: null, + payload: { ...snapshotPayload("owner/payload-ts", "payload ts"), generatedAt } as unknown as SignalSnapshotRecord["payload"], + }; + const spy = vi.spyOn(repositoriesModule, "listSignalSnapshots").mockResolvedValue([snapshot]); + const response = await loadOrComputeRepoOutcomePatternsResponse(env, "owner/payload-ts"); + spy.mockRestore(); + expect(response).toMatchObject({ source: "snapshot", freshness: "fresh", generatedAt }); + }); + + it("defaults to the current time when neither the column nor the payload carry a timestamp", async () => { + const env = createTestEnv(); + const payload = snapshotPayload("owner/no-ts", "no ts") as Record; + delete payload.generatedAt; + const repositoriesModule = await import("../../src/db/repositories"); + const snapshot: SignalSnapshotRecord = { + id: "snap-no-ts", + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: "owner/no-ts", + repoFullName: "owner/no-ts", + generatedAt: null, + payload: payload as unknown as SignalSnapshotRecord["payload"], + }; + const spy = vi.spyOn(repositoriesModule, "listSignalSnapshots").mockResolvedValue([snapshot]); + const response = await loadOrComputeRepoOutcomePatternsResponse(env, "owner/no-ts"); + spy.mockRestore(); + expect(response).toMatchObject({ source: "snapshot", freshness: "fresh" }); + expect(typeof response?.generatedAt).toBe("string"); + expect(response?.ageSeconds).toBeGreaterThanOrEqual(0); + }); + + it("treats an unparseable snapshot timestamp as stale rather than fresh", async () => { + const env = createTestEnv(); + const repositoriesModule = await import("../../src/db/repositories"); + const snapshot: SignalSnapshotRecord = { + id: "snap-bad-ts", + signalType: REPO_OUTCOME_PATTERNS_SIGNAL, + targetKey: "owner/bad-ts", + repoFullName: "owner/bad-ts", + generatedAt: "not-a-real-timestamp", + payload: snapshotPayload("owner/bad-ts", "bad ts") as unknown as SignalSnapshotRecord["payload"], + }; + const spy = vi.spyOn(repositoriesModule, "listSignalSnapshots").mockResolvedValue([snapshot]); + const response = await loadOrComputeRepoOutcomePatternsResponse(env, "owner/bad-ts"); + spy.mockRestore(); + expect(response).toMatchObject({ source: "snapshot", freshness: "stale" }); + }); + it("does not call broad request-time PR listers when a cached snapshot exists", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "perf", full_name: "owner/perf", private: false, owner: { login: "owner" }, default_branch: "main" }); @@ -101,6 +159,16 @@ describe("loadOrComputeRepoOutcomePatternsResponse", () => { }); }); +describe("computeRepoOutcomePatterns", () => { + it("resolves the repository itself when no repo record is supplied", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "direct", full_name: "owner/direct", private: false, owner: { login: "owner" }, default_branch: "main" }); + const patterns = await computeRepoOutcomePatterns(env, "owner/direct"); + expect(patterns.repoFullName).toBe("owner/direct"); + expect(patterns.totals.analyzed).toBe(0); + }); +}); + describe("loadRepoOutcomePatternsMap", () => { it("bulk-loads cached snapshots for registered repos only", async () => { const env = createTestEnv(); diff --git a/test/unit/repo-outcome-patterns.test.ts b/test/unit/repo-outcome-patterns.test.ts index fa352f0c84..6f629f2337 100644 --- a/test/unit/repo-outcome-patterns.test.ts +++ b/test/unit/repo-outcome-patterns.test.ts @@ -214,6 +214,39 @@ describe("buildRepoOutcomePatterns", () => { expect(result.findings.some((f) => f.code === "stale_open_prs")).toBe(true); }); + it("falls back to createdAt when an open PR carries no updatedAt timestamp", () => { + const pullRequests = [ + pr(1, { state: "open", updatedAt: null, createdAt: "2020-01-01T00:00:00.000Z" }), + pr(2, { state: "open", updatedAt: null, createdAt: "2020-01-01T00:00:00.000Z" }), + pr(3, { state: "open", updatedAt: null, createdAt: new Date().toISOString() }), + ]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests }); + expect(result.totals.openStale).toBe(2); + expect(result.totals.openActive).toBe(1); + }); + + it("marks the overall closure-risk pattern high-confidence with six or more decided PRs", () => { + const pullRequests = [closedPr(1), closedPr(2), closedPr(3), closedPr(4), closedPr(5), closedPr(6), mergedPr(7)]; + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests }); + const risk = result.riskPatterns.find((p) => p.title === "Outside contributor PRs rarely merge here"); + expect(risk?.confidence).toBe("high"); + }); + + it("buckets file-count-only PRs into small and medium size dimensions", () => { + // changedFiles arrive via recent-merged records, so changedLineCount stays 0 and file-count sizing applies. + const smallNumbers = [1, 2, 3]; + const mediumNumbers = [4, 5, 6]; + const recentMergedPullRequests: RecentMergedPullRequestRecord[] = [ + ...smallNumbers.map((number) => ({ repoFullName: REPO, number, title: `PR ${number}`, authorLogin: "dev", mergedAt: "2026-05-01T00:00:00.000Z", labels: [], linkedIssues: [], changedFiles: ["a.ts", "b.ts"], payload: {} })), + ...mediumNumbers.map((number) => ({ repoFullName: REPO, number, title: `PR ${number}`, authorLogin: "dev", mergedAt: "2026-05-01T00:00:00.000Z", labels: [], linkedIssues: [], changedFiles: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"], payload: {} })), + ]; + const pullRequests = [...smallNumbers, ...mediumNumbers].map((number) => mergedPr(number)); + const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests, recentMergedPullRequests }); + const sizeKeys = result.dimensions.filter((d) => d.dimension === "size").map((d) => d.key); + expect(sizeKeys).toContain("small"); + expect(sizeKeys).toContain("medium"); + }); + it("reports a low-sample finding when there are too few decided PRs", () => { const result = buildRepoOutcomePatterns({ repo: repo(), repoFullName: REPO, pullRequests: [mergedPr(1)] }); expect(result.findings.some((f) => f.code === "low_outcome_sample")).toBe(true); From e4b26dcd8584a3791888177ec92d5f5b7fcd103b Mon Sep 17 00:00:00 2001 From: Khaostica Date: Mon, 1 Jun 2026 10:24:24 -0400 Subject: [PATCH 3/3] chore(openapi): sync UI snapshot with outcome-patterns endpoint Regenerate apps/gittensory-ui/public/openapi.json after the rebase so it includes the RepoOutcomePatterns schemas and the /v1/repos/{owner}/{repo}/outcome-patterns path, satisfying the UI openapi freshness check. --- apps/gittensory-ui/public/openapi.json | 222 +++++++++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 76c235bcb6..ed28ec3fd5 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -2554,6 +2554,201 @@ "dataQuality" ] }, + "RepoOutcomeEvidenceCompleteness": { + "type": "object", + "properties": { + "pullRequestsAnalyzed": { + "type": "number" + }, + "withFileDetail": { + "type": "number" + }, + "withReviewDetail": { + "type": "number" + }, + "withCheckDetail": { + "type": "number" + }, + "filesCompletenessRatio": { + "type": "number" + }, + "reviewsCompletenessRatio": { + "type": "number" + }, + "checksCompletenessRatio": { + "type": "number" + }, + "fullyDecidedWithDetail": { + "type": "number" + }, + "status": { + "type": "string", + "enum": [ + "complete", + "partial", + "missing" + ] + } + }, + "required": [ + "pullRequestsAnalyzed", + "withFileDetail", + "withReviewDetail", + "withCheckDetail", + "filesCompletenessRatio", + "reviewsCompletenessRatio", + "checksCompletenessRatio", + "fullyDecidedWithDetail", + "status" + ] + }, + "RepoOutcomePatterns": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "lane": { + "type": "string", + "enum": [ + "direct_pr", + "issue_discovery", + "split", + "inactive", + "unknown" + ] + }, + "primaryLanguage": { + "type": "string", + "nullable": true + }, + "sampleSize": { + "type": "number" + }, + "totals": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "outsideContributorMergeRate": { + "type": "number" + }, + "maintainerLaneMergeRate": { + "type": "number" + }, + "dimensions": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + }, + "successPatterns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + }, + "riskPatterns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + }, + "evidenceCompleteness": { + "$ref": "#/components/schemas/RepoOutcomeEvidenceCompleteness" + }, + "findings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Finding" + } + }, + "summary": { + "type": "string" + } + }, + "required": [ + "repoFullName", + "generatedAt", + "lane", + "primaryLanguage", + "sampleSize", + "totals", + "outsideContributorMergeRate", + "maintainerLaneMergeRate", + "dimensions", + "successPatterns", + "riskPatterns", + "evidenceCompleteness", + "findings", + "summary" + ] + }, + "RepoOutcomePatternsResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ready" + ] + }, + "source": { + "type": "string", + "enum": [ + "snapshot", + "computed" + ] + }, + "repoFullName": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "ageSeconds": { + "type": "number" + }, + "freshness": { + "type": "string", + "enum": [ + "fresh", + "stale" + ] + }, + "patterns": { + "$ref": "#/components/schemas/RepoOutcomePatterns" + }, + "dataQuality": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + }, + "required": [ + "status", + "source", + "repoFullName", + "generatedAt", + "ageSeconds", + "freshness", + "patterns" + ] + }, "RegistrationReadiness": { "type": "object", "properties": { @@ -10037,6 +10232,33 @@ ] } }, + "/v1/repos/{owner}/{repo}/outcome-patterns": { + "get": { + "responses": { + "200": { + "description": "Cached or freshly-computed per-repo accepted/rejected PR outcome patterns with freshness envelope and explicit evidence-completeness", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepoOutcomePatternsResponse" + } + } + } + }, + "404": { + "description": "Repo is unknown or has no outcome-pattern coverage yet" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/repos/{owner}/{repo}/registration-readiness": { "get": { "responses": {