From b6d508598c000efc0d9f03640ccc5afce07b49b0 Mon Sep 17 00:00:00 2001 From: oktofeesh1 <287075021+oktofeesh1@users.noreply.github.com> Date: Fri, 29 May 2026 01:45:42 -0700 Subject: [PATCH] feat(scoring): derive observed pending PR scenarios --- src/api/routes.ts | 2 + src/db/repositories.ts | 38 ++++++++- src/github/backfill.ts | 9 +++ src/mcp/server.ts | 3 + src/openapi/schemas.ts | 15 +++- src/scoring/preview.ts | 86 +++++++++++++++++--- src/services/agent-orchestrator.ts | 2 + src/signals/local-branch.ts | 121 +++++++++++++++++++++++++++++ src/types.ts | 9 +++ test/unit/backfill.test.ts | 18 ++++- test/unit/db-parsers.test.ts | 36 +++++++++ test/unit/local-branch.test.ts | 102 ++++++++++++++++++++++++ test/unit/scoring.test.ts | 33 ++++++++ 13 files changed, 457 insertions(+), 17 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index e99ebd1da7..6b7eb29c43 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -759,7 +759,9 @@ export function createApp() { repo, issues, pullRequests, + contributorPullRequests: context.contributorPullRequests, recentMergedPullRequests, + repositories: context.repositories, profile: context.profile, outcomeHistory: context.outcomeHistory, scoringSnapshot: snapshot, diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 8de27569a3..a3d1497d52 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1918,6 +1918,9 @@ function toPullRequestRecord(repoFullName: string, pr: GitHubPullRequestPayload) baseRef: pr.base?.ref, htmlUrl: pr.html_url, mergedAt: pr.merged_at, + isDraft: pr.draft ?? pr.isDraft, + mergeableState: pr.mergeable_state ?? pr.mergeableState ?? mergeableBooleanState(pr.mergeable), + reviewDecision: pr.reviewDecision, body: pr.body, labels: (pr.labels ?? []).flatMap((label) => (label.name ? [label.name] : [])), linkedIssues: extractLinkedIssueNumbers(pr.body ?? ""), @@ -1925,7 +1928,14 @@ function toPullRequestRecord(repoFullName: string, pr: GitHubPullRequestPayload) } function toPullRequestRecordFromRow(row: typeof pullRequests.$inferSelect): PullRequestRecord { - const payload = parseJson<{ body?: string | null; created_at?: string | null; updated_at?: string | null }>(row.payloadJson, {}); + const payload = parseJson<{ + body?: string | null; + created_at?: string | null; + updated_at?: string | null; + draft?: boolean | null; + mergeable_state?: string | null; + reviewDecision?: string | null; + }>(row.payloadJson, {}); return { repoFullName: row.repoFullName, number: row.number, @@ -1938,6 +1948,9 @@ function toPullRequestRecordFromRow(row: typeof pullRequests.$inferSelect): Pull baseRef: row.baseRef, htmlUrl: row.htmlUrl, mergedAt: row.mergedAt, + isDraft: payload.draft, + mergeableState: payload.mergeable_state, + reviewDecision: payload.reviewDecision, body: payload.body, createdAt: payload.created_at, updatedAt: payload.updated_at ?? row.updatedAt, @@ -1961,14 +1974,35 @@ function toIssueRecord(repoFullName: string, issue: GitHubIssuePayload): IssueRe }; } -function compactGitHubPayload(payload: { body?: string | null; created_at?: string | null; updated_at?: string | null }): Record { +function compactGitHubPayload(payload: { + body?: string | null; + created_at?: string | null; + updated_at?: string | null; + draft?: boolean | null; + isDraft?: boolean | null; + mergeable?: boolean | null; + mergeable_state?: string | null; + mergeableState?: string | null; + reviewDecision?: string | null; +}): Record { + const draft = payload.draft ?? payload.isDraft; + const mergeableState = payload.mergeable_state ?? payload.mergeableState ?? mergeableBooleanState(payload.mergeable); return { body: truncateBody(payload.body), created_at: payload.created_at ?? null, updated_at: payload.updated_at ?? null, + ...(draft !== undefined ? { draft } : {}), + ...(mergeableState !== undefined ? { mergeable_state: mergeableState } : {}), + ...(payload.reviewDecision !== undefined ? { reviewDecision: payload.reviewDecision } : {}), }; } +function mergeableBooleanState(value: boolean | null | undefined): string | undefined { + if (value === true) return "mergeable"; + if (value === false) return "blocked"; + return undefined; +} + function truncateBody(body: string | null | undefined): string | null { if (!body) return body ?? null; return body.length > MAX_STORED_BODY_CHARS ? body.slice(0, MAX_STORED_BODY_CHARS) : body; diff --git a/src/github/backfill.ts b/src/github/backfill.ts index 7c409c504e..8bef379ccb 100644 --- a/src/github/backfill.ts +++ b/src/github/backfill.ts @@ -201,6 +201,9 @@ type GitHubOpenPullRequestsResponse = { state?: string; url?: string; body?: string | null; + isDraft?: boolean | null; + mergeable?: string | null; + reviewDecision?: string | null; createdAt?: string | null; updatedAt?: string | null; authorAssociation?: string | null; @@ -1125,6 +1128,9 @@ async function supplementOpenPullRequestsFromGraphQl(env: Env, repo: RepositoryR state url body + isDraft + mergeable + reviewDecision createdAt updatedAt authorAssociation @@ -1151,6 +1157,9 @@ async function supplementOpenPullRequestsFromGraphQl(env: Env, repo: RepositoryR ...(pr.createdAt === undefined ? {} : { created_at: pr.createdAt }), ...(pr.updatedAt === undefined ? {} : { updated_at: pr.updatedAt }), ...(pr.body === undefined ? {} : { body: pr.body }), + ...(pr.isDraft === undefined ? {} : { draft: pr.isDraft }), + ...(pr.mergeable === undefined ? {} : { mergeableState: pr.mergeable }), + ...(pr.reviewDecision === undefined ? {} : { reviewDecision: pr.reviewDecision }), ...(pr.author?.login ? { user: { login: pr.author.login } } : {}), ...(pr.authorAssociation ? { author_association: pr.authorAssociation } : {}), head: { ...(pr.headRefOid ? { sha: pr.headRefOid } : {}), ...(pr.headRefName ? { ref: pr.headRefName } : {}) }, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f85179f4d5..c6e9dd836f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -756,7 +756,9 @@ export class GittensoryMcp { repo, issues, pullRequests, + contributorPullRequests: context.contributorPullRequests, recentMergedPullRequests, + repositories: context.repositories, profile: context.profile, outcomeHistory: context.outcomeHistory, scoringSnapshot: snapshot, @@ -798,6 +800,7 @@ export class GittensoryMcp { }); return { profile, + contributorPullRequests, repositories, syncStates, repoStats, diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 12b5235c25..e34bf0908a 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -829,8 +829,8 @@ const ScoreGateDeltaSchema = z.object({ }); const ScoreScenarioPreviewSchema = z.object({ - name: z.enum(["current", "cleanGates", "afterPendingMerges", "linkedIssueFixed", "bestReasonableCase"]), - source: z.enum(["current_data", "user_supplied", "gittensory_projection"]), + name: z.enum(["current", "cleanGates", "afterPendingMerges", "afterApprovedPrsMerge", "afterStalePrsClose", "linkedIssueFixed", "bestReasonableCase"]), + source: z.enum(["current_data", "user_supplied", "github_observed", "gittensory_projection"]), assumptions: z.array(z.string()), scoreEstimate: ScoreEstimateSchema, gates: ScoreGatesSchema, @@ -1243,9 +1243,20 @@ export const LocalBranchAnalysisSchema = z current: ScoreScenarioPreviewSchema, bestReasonableCase: ScoreScenarioPreviewSchema, afterPendingMerges: ScoreScenarioPreviewSchema.optional(), + afterApprovedPrsMerge: ScoreScenarioPreviewSchema.optional(), + afterStalePrsClose: ScoreScenarioPreviewSchema.optional(), gateDeltas: z.array(ScoreGateDeltaSchema), blockedBy: z.array(ScoreGateBlockerSchema), }), + observedPullRequestScenarios: z.object({ + approvedOrMergeable: z.number(), + stale: z.number(), + closed: z.number(), + draft: z.number(), + blocked: z.number(), + maintainerLane: z.number(), + notes: z.array(z.string()), + }), rewardRisk: RepoRewardRiskSchema, scoreBlockers: z.array(z.string()), branchQualityBlockers: z.array(z.string()), diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 12feb8b45b..fb0db6aed3 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -22,9 +22,16 @@ export type ScorePreviewInput = { pendingMergedPrCount?: number | undefined; pendingClosedPrCount?: number | undefined; approvedPrCount?: number | undefined; + observedApprovedPrCount?: number | undefined; + observedStalePrCount?: number | undefined; + observedClosedPrCount?: number | undefined; + observedDraftPrCount?: number | undefined; + observedBlockedPrCount?: number | undefined; + observedMaintainerPrCount?: number | undefined; expectedOpenPrCountAfterMerge?: number | undefined; projectedCredibility?: number | undefined; scenarioNotes?: string[] | undefined; + observedScenarioNotes?: string[] | undefined; }; export type ScoreGateBlocker = { @@ -48,8 +55,8 @@ export type ScoreGateDelta = { }; export type ScoreScenarioPreview = { - name: "current" | "cleanGates" | "afterPendingMerges" | "linkedIssueFixed" | "bestReasonableCase"; - source: "current_data" | "user_supplied" | "gittensory_projection"; + name: "current" | "cleanGates" | "afterPendingMerges" | "afterApprovedPrsMerge" | "afterStalePrsClose" | "linkedIssueFixed" | "bestReasonableCase"; + source: "current_data" | "user_supplied" | "github_observed" | "gittensory_projection"; assumptions: string[]; scoreEstimate: ScorePreviewResult["scoreEstimate"]; gates: ScorePreviewResult["gates"]; @@ -268,15 +275,29 @@ function buildScenarioPreviews( contributorEvidence: ContributorEvidenceRecord | null | undefined, current: ScoreCore, ): ScoreScenarioPreview[] { - const pendingCount = nonNegative(input.pendingMergedPrCount) + nonNegative(input.pendingClosedPrCount) + nonNegative(input.approvedPrCount); + const userPendingCount = nonNegative(input.pendingMergedPrCount) + nonNegative(input.pendingClosedPrCount) + nonNegative(input.approvedPrCount); + const observedApprovedCount = nonNegative(input.observedApprovedPrCount); + const observedCloseCount = nonNegative(input.observedStalePrCount) + nonNegative(input.observedClosedPrCount); + const combinedPendingCount = userPendingCount + observedApprovedCount + observedCloseCount; const expectedOpenPrCountAfterMerge = - input.expectedOpenPrCountAfterMerge !== undefined ? nonNegative(input.expectedOpenPrCountAfterMerge) : Math.max(0, current.gates.openPrCount - pendingCount); + input.expectedOpenPrCountAfterMerge !== undefined ? nonNegative(input.expectedOpenPrCountAfterMerge) : Math.max(0, current.gates.openPrCount - userPendingCount); const projectedCredibility = input.projectedCredibility !== undefined ? clamp(input.projectedCredibility, 0, 1) - : pendingCount > 0 + : userPendingCount > 0 ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) : current.gates.credibilityObserved; + const observedApprovalCredibility = observedApprovedCount > 0 ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) : current.gates.credibilityObserved; + const afterApprovedInput = { + ...input, + openPrCount: Math.max(0, current.gates.openPrCount - observedApprovedCount), + credibility: observedApprovalCredibility, + }; + const afterStaleInput = { + ...input, + openPrCount: Math.max(0, current.gates.openPrCount - observedCloseCount), + credibility: current.gates.credibilityObserved, + }; const cleanGatesInput = { ...input, openPrCount: Math.min(current.gates.openPrCount, current.gates.openPrThreshold), @@ -293,8 +314,11 @@ function buildScenarioPreviews( }; const bestReasonableInput = { ...linkedIssueInput, - openPrCount: Math.min(expectedOpenPrCountAfterMerge, current.gates.openPrThreshold), - credibility: Math.max(projectedCredibility, current.gates.credibilityFloor), + openPrCount: Math.min( + input.expectedOpenPrCountAfterMerge !== undefined ? expectedOpenPrCountAfterMerge : Math.max(0, current.gates.openPrCount - combinedPendingCount), + current.gates.openPrThreshold, + ), + credibility: Math.max(projectedCredibility, observedApprovalCredibility, current.gates.credibilityFloor), }; return [ scenario("current", "current_data", input, current, ["Current cached/account state and supplied local diff metadata."], repo), @@ -303,22 +327,50 @@ function buildScenarioPreviews( ], repo), scenario( "afterPendingMerges", - pendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined ? "user_supplied" : "gittensory_projection", + userPendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined ? "user_supplied" : "gittensory_projection", afterPendingInput, computeScoreCore(afterPendingInput, repo, snapshot, contributorEvidence), [ - pendingCount > 0 - ? `${pendingCount} supplied pending approved/merged/closed PR(s) are treated as no longer open for this scenario.` + userPendingCount > 0 + ? `${userPendingCount} user-supplied pending approved/merged/closed PR(s) are treated as no longer open for this scenario.` : "No pending merge/close count was supplied; this scenario preserves current open PR pressure.", ...(input.projectedCredibility !== undefined ? [`Projected credibility is user-supplied as ${roundScore(projectedCredibility)}.`] - : pendingCount > 0 - ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} because pending merges were supplied.`] + : userPendingCount > 0 + ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} because pending merges were supplied by the caller.`] : []), ...(input.scenarioNotes ?? []), ], repo, ), + scenario( + "afterApprovedPrsMerge", + "github_observed", + afterApprovedInput, + computeScoreCore(afterApprovedInput, repo, snapshot, contributorEvidence), + [ + observedApprovedCount > 0 + ? `${observedApprovedCount} GitHub-observed approved or mergeable open PR(s) are treated as no longer open if they merge.` + : "No GitHub-observed approved or mergeable open PRs were available for this scenario.", + ...(observedApprovedCount > 0 ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} after observed mergeable work lands.`] : []), + ...observedScenarioNotes(input), + ], + repo, + ), + scenario( + "afterStalePrsClose", + "github_observed", + afterStaleInput, + computeScoreCore(afterStaleInput, repo, snapshot, contributorEvidence), + [ + observedCloseCount > 0 + ? `${observedCloseCount} GitHub-observed stale or closed PR(s) are treated as no longer open if they close or withdraw.` + : "No GitHub-observed stale or closed PRs were available for this scenario.", + "Credibility is not increased in this scenario because stale or closed PR cleanup is not the same as merged work.", + ...observedScenarioNotes(input), + ], + repo, + ), scenario("linkedIssueFixed", "gittensory_projection", linkedIssueInput, computeScoreCore(linkedIssueInput, repo, snapshot, contributorEvidence), [ input.linkedIssueMode === "none" || !input.linkedIssueMode ? "A standard linked-issue/no-issue rationale multiplier is projected as present." @@ -327,10 +379,20 @@ function buildScenarioPreviews( scenario("bestReasonableCase", "gittensory_projection", bestReasonableInput, computeScoreCore(bestReasonableInput, repo, snapshot, contributorEvidence), [ "Combines plausible near-term gate cleanup: open PR pressure at threshold or below, credibility at floor or above, and linked-issue context where applicable.", ...(input.scenarioNotes ?? []), + ...observedScenarioNotes(input), ], repo), ]; } +function observedScenarioNotes(input: ScorePreviewInput): string[] { + return [ + ...(nonNegative(input.observedDraftPrCount) > 0 ? [`${nonNegative(input.observedDraftPrCount)} draft PR(s) were excluded from likely-to-land projections.`] : []), + ...(nonNegative(input.observedBlockedPrCount) > 0 ? [`${nonNegative(input.observedBlockedPrCount)} blocked PR(s) were excluded from likely-to-land projections.`] : []), + ...(nonNegative(input.observedMaintainerPrCount) > 0 ? [`${nonNegative(input.observedMaintainerPrCount)} maintainer-lane PR(s) were kept out of outside-contributor projections.`] : []), + ...(input.observedScenarioNotes ?? []), + ]; +} + function scenario( name: ScoreScenarioPreview["name"], source: ScoreScenarioPreview["source"], diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 2eb8cfe355..c47d1c08a6 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -305,7 +305,9 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr repo, issues, pullRequests, + contributorPullRequests, recentMergedPullRequests, + repositories, profile, outcomeHistory, scoringSnapshot, diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 1a0bf7f8d8..8414270801 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -67,6 +67,16 @@ export type LocalBranchAnalysisInput = { scenarioNotes?: string[] | undefined; }; +type ObservedPullRequestScenarios = { + approvedOrMergeable: number; + stale: number; + closed: number; + draft: number; + blocked: number; + maintainerLane: number; + notes: string[]; +}; + export type LocalBranchAnalysis = { login: string; repoFullName: string; @@ -95,9 +105,12 @@ export type LocalBranchAnalysis = { current: ScorePreviewResult["scenarioPreviews"][number]; bestReasonableCase: ScorePreviewResult["scenarioPreviews"][number]; afterPendingMerges?: ScorePreviewResult["scenarioPreviews"][number] | undefined; + afterApprovedPrsMerge?: ScorePreviewResult["scenarioPreviews"][number] | undefined; + afterStalePrsClose?: ScorePreviewResult["scenarioPreviews"][number] | undefined; gateDeltas: ScorePreviewResult["gateDeltas"]; blockedBy: ScorePreviewResult["blockedBy"]; }; + observedPullRequestScenarios: ObservedPullRequestScenarios; rewardRisk: RepoRewardRisk; scoreBlockers: string[]; branchQualityBlockers: string[]; @@ -140,7 +153,9 @@ export function buildLocalBranchAnalysis(args: { repo: RepositoryRecord | null; issues: IssueRecord[]; pullRequests: PullRequestRecord[]; + contributorPullRequests?: PullRequestRecord[] | undefined; recentMergedPullRequests?: RecentMergedPullRequestRecord[] | undefined; + repositories?: RepositoryRecord[] | undefined; profile: ContributorProfile; outcomeHistory: ContributorOutcomeHistory; scoringSnapshot: ScoringModelSnapshotRecord; @@ -180,6 +195,12 @@ export function buildLocalBranchAnalysis(args: { }); const lane = buildLaneAdvice(args.repo, args.input.repoFullName); const repoOutcome = args.outcomeHistory.repoOutcomes.find((outcome) => sameRepo(outcome.repoFullName, args.input.repoFullName)); + const observedPullRequestScenarios = buildObservedPullRequestScenarios({ + login: args.input.login, + repoFullName: args.input.repoFullName, + pullRequests: args.contributorPullRequests ?? args.pullRequests, + repositories: args.repositories, + }); const scoreInput = buildLocalScoreInput({ input: args.input, changedFiles, @@ -189,6 +210,7 @@ export function buildLocalBranchAnalysis(args: { roleContext, outcomeHistory: args.outcomeHistory, repoOutcome, + observedPullRequestScenarios, }); const scorePreview = buildScorePreview({ input: scoreInput, @@ -227,6 +249,8 @@ export function buildLocalBranchAnalysis(args: { current: currentScenario, bestReasonableCase: bestReasonableScenario, afterPendingMerges: scorePreview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"), + afterApprovedPrsMerge: scorePreview.scenarioPreviews.find((scenario) => scenario.name === "afterApprovedPrsMerge"), + afterStalePrsClose: scorePreview.scenarioPreviews.find((scenario) => scenario.name === "afterStalePrsClose"), gateDeltas: scorePreview.gateDeltas, blockedBy: scorePreview.blockedBy, }; @@ -260,6 +284,7 @@ export function buildLocalBranchAnalysis(args: { preflight, scorePreview, scenarioScorePreview, + observedPullRequestScenarios, rewardRisk, scoreBlockers: [...new Set(scoreBlockers)], branchQualityBlockers, @@ -289,6 +314,7 @@ function buildLocalScoreInput(args: { roleContext: RoleContext; outcomeHistory: ContributorOutcomeHistory; repoOutcome?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; + observedPullRequestScenarios: ObservedPullRequestScenarios; }): ScorePreviewInput { const scorer = args.input.localScorer; const testLineCount = args.changedFiles.filter((file) => isTestFile(file.path)).reduce((sum, file) => sum + nonNegative(file.additions) + nonNegative(file.deletions), 0); @@ -314,12 +340,103 @@ function buildLocalScoreInput(args: { pendingMergedPrCount: args.input.pendingMergedPrCount, pendingClosedPrCount: args.input.pendingClosedPrCount, approvedPrCount: args.input.approvedPrCount, + observedApprovedPrCount: args.observedPullRequestScenarios.approvedOrMergeable, + observedStalePrCount: args.observedPullRequestScenarios.stale, + observedClosedPrCount: args.observedPullRequestScenarios.closed, + observedDraftPrCount: args.observedPullRequestScenarios.draft, + observedBlockedPrCount: args.observedPullRequestScenarios.blocked, + observedMaintainerPrCount: args.observedPullRequestScenarios.maintainerLane, expectedOpenPrCountAfterMerge: args.input.expectedOpenPrCountAfterMerge, projectedCredibility: args.input.projectedCredibility, scenarioNotes: args.input.scenarioNotes, + observedScenarioNotes: args.observedPullRequestScenarios.notes, + }; +} + +function buildObservedPullRequestScenarios(args: { + login: string; + repoFullName: string; + pullRequests: PullRequestRecord[]; + repositories?: RepositoryRecord[] | undefined; + nowMs?: number | undefined; +}): ObservedPullRequestScenarios { + const repoByName = new Map((args.repositories ?? []).map((repo) => [repo.fullName.toLowerCase(), repo])); + const registeredRepos = new Set((args.repositories ?? []).filter((repo) => repo.isRegistered).map((repo) => repo.fullName.toLowerCase())); + const scopedPullRequests = args.pullRequests.filter((pr) => { + if (!sameLogin(pr.authorLogin, args.login)) return false; + if (registeredRepos.size > 0) return registeredRepos.has(pr.repoFullName.toLowerCase()); + return sameRepo(pr.repoFullName, args.repoFullName); + }); + let approvedOrMergeable = 0; + let stale = 0; + let closed = 0; + let draft = 0; + let blocked = 0; + let maintainerLane = 0; + for (const pr of scopedPullRequests) { + const repo = repoByName.get(pr.repoFullName.toLowerCase()); + if (isMaintainerAuthoredPr(pr, repo, args.login)) { + maintainerLane += 1; + continue; + } + if (pr.state !== "open") { + if (pr.state === "closed" && !pr.mergedAt) closed += 1; + continue; + } + if (pr.isDraft) { + draft += 1; + continue; + } + if (isStaleOpenPr(pr, args.nowMs)) { + stale += 1; + continue; + } + if (isBlockedOpenPr(pr)) { + blocked += 1; + continue; + } + if (isApprovedOrMergeableOpenPr(pr)) approvedOrMergeable += 1; + } + return { + approvedOrMergeable, + stale, + closed, + draft, + blocked, + maintainerLane, + notes: observedPullRequestNotes({ approvedOrMergeable, stale, closed, draft, blocked, maintainerLane }), }; } +function observedPullRequestNotes(scenarios: Omit): string[] { + return [ + ...(scenarios.approvedOrMergeable > 0 ? [`${scenarios.approvedOrMergeable} cached approved or mergeable open PR(s) can be modeled as likely-to-land.`] : []), + ...(scenarios.stale > 0 ? [`${scenarios.stale} cached stale open PR(s) can be modeled as cleanup-first rather than likely-to-land.`] : []), + ...(scenarios.closed > 0 ? [`${scenarios.closed} cached closed PR(s) can be modeled as no longer open.`] : []), + ]; +} + +function isMaintainerAuthoredPr(pr: PullRequestRecord, repo: RepositoryRecord | undefined, login: string): boolean { + return sameLogin(repo?.owner, login) || ["owner", "member", "collaborator"].includes((pr.authorAssociation ?? "").toLowerCase()); +} + +function isStaleOpenPr(pr: PullRequestRecord, nowMs: number | undefined): boolean { + const updatedAt = Date.parse(pr.updatedAt ?? pr.createdAt ?? ""); + return Number.isFinite(updatedAt) && (nowMs ?? Date.now()) - updatedAt >= 14 * 24 * 60 * 60 * 1000; +} + +function isBlockedOpenPr(pr: PullRequestRecord): boolean { + const reviewDecision = (pr.reviewDecision ?? "").toLowerCase(); + const mergeableState = (pr.mergeableState ?? "").toLowerCase(); + return reviewDecision === "changes_requested" || ["blocked", "dirty", "conflicting", "unknown", "unstable"].includes(mergeableState); +} + +function isApprovedOrMergeableOpenPr(pr: PullRequestRecord): boolean { + const reviewDecision = (pr.reviewDecision ?? "").toLowerCase(); + const mergeableState = (pr.mergeableState ?? "").toLowerCase(); + return reviewDecision === "approved" || ["clean", "has_hooks", "mergeable", "mergeable_state_clean"].includes(mergeableState); +} + function buildLocalFindings( input: LocalBranchAnalysisInput, changedFiles: LocalBranchChangedFile[], @@ -627,6 +744,10 @@ function sameRepo(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } +function sameLogin(left: string | null | undefined, right: string | null | undefined): boolean { + return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); +} + function nonNegative(value: number | undefined): number { return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; } diff --git a/src/types.ts b/src/types.ts index 161c1430ef..e5d1f2a385 100644 --- a/src/types.ts +++ b/src/types.ts @@ -122,6 +122,12 @@ export type GitHubPullRequestPayload = { state: string; html_url?: string; merged_at?: string | null; + draft?: boolean | null; + isDraft?: boolean | null; + mergeable?: boolean | null; + mergeable_state?: string | null; + mergeableState?: string | null; + reviewDecision?: string | null; created_at?: string | null; updated_at?: string | null; user?: { @@ -249,6 +255,9 @@ export type PullRequestRecord = { baseRef?: string | null | undefined; htmlUrl?: string | null | undefined; mergedAt?: string | null | undefined; + isDraft?: boolean | null | undefined; + mergeableState?: string | null | undefined; + reviewDecision?: string | null | undefined; body?: string | null | undefined; createdAt?: string | null | undefined; updatedAt?: string | null | undefined; diff --git a/test/unit/backfill.test.ts b/test/unit/backfill.test.ts index 5901a5a631..7d9ace4ce9 100644 --- a/test/unit/backfill.test.ts +++ b/test/unit/backfill.test.ts @@ -1029,6 +1029,9 @@ describe("GitHub backfill", () => { if (url === "https://api.github.com/graphql") { const query = JSON.parse(String(init?.body ?? "{}")).query as string; if (query.includes("GittensoryOpenPullRequestsSupplement")) { + expect(query).toContain("isDraft"); + expect(query).toContain("mergeable"); + expect(query).toContain("reviewDecision"); if (query.includes("after:")) { return Response.json({ data: { @@ -1054,6 +1057,9 @@ describe("GitHub backfill", () => { state: "OPEN", url: "https://github.com/JSONbored/gittensory/pull/11", body: "GraphQL supplement", + isDraft: false, + mergeable: "CLEAN", + reviewDecision: "APPROVED", author: { login: "oktofeesh1" }, authorAssociation: "NONE", headRefName: "feature", @@ -1080,7 +1086,17 @@ describe("GitHub backfill", () => { expect(result.warnings).toEqual(expect.arrayContaining([expect.stringContaining("Supplemented 2 open pull request")])); expect(await listPullRequests(env, "JSONbored/gittensory")).toEqual( expect.arrayContaining([ - expect.objectContaining({ number: 11, title: "GraphQL-only PR", labels: ["bug"], headSha: "abc123", headRef: "feature", baseRef: "main" }), + expect.objectContaining({ + number: 11, + title: "GraphQL-only PR", + isDraft: false, + mergeableState: "CLEAN", + reviewDecision: "APPROVED", + labels: ["bug"], + headSha: "abc123", + headRef: "feature", + baseRef: "main", + }), ]), ); }); diff --git a/test/unit/db-parsers.test.ts b/test/unit/db-parsers.test.ts index 9849f3c2a3..434599ee6c 100644 --- a/test/unit/db-parsers.test.ts +++ b/test/unit/db-parsers.test.ts @@ -3,14 +3,50 @@ import { getLatestScorePreview, getLatestScoringModelSnapshot, getFreshOfficialMinerDetection, + listPullRequests, listPullRequestDetailSyncStates, listRepoSyncSegments, listRepoSyncStates, upsertOfficialMinerDetection, + upsertPullRequestFromGitHub, } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; describe("database row parser hardening", () => { + it("preserves cached pull request review and mergeability scenario fields", async () => { + const env = createTestEnv(); + + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 1, + title: "Blocked branch", + state: "open", + draft: true, + mergeable: false, + reviewDecision: "CHANGES_REQUESTED", + user: { login: "oktofeesh1" }, + labels: [{ name: "bug" }], + body: "Fixes #7", + }); + await upsertPullRequestFromGitHub(env, "owner/repo", { + number: 2, + title: "Mergeable branch", + state: "open", + isDraft: false, + mergeable: true, + reviewDecision: "APPROVED", + user: { login: "oktofeesh1" }, + labels: [], + body: null, + }); + + await expect(listPullRequests(env, "owner/repo")).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ number: 1, isDraft: true, mergeableState: "blocked", reviewDecision: "CHANGES_REQUESTED", linkedIssues: [7] }), + expect.objectContaining({ number: 2, isDraft: false, mergeableState: "mergeable", reviewDecision: "APPROVED" }), + ]), + ); + }); + it("normalizes enum-like database values from stored sync, scoring, and preview rows", async () => { const env = createTestEnv(); diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 970bf0fc46..1e82013bb8 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -114,6 +114,108 @@ describe("local branch analysis", () => { expect(analysis.nextActions[0]?.whyThisHelps.join(" ")).toMatch(/waiting for pending PRs/i); }); + it("derives observed pending PR scenarios from cached GitHub PR state", () => { + const otherRepo: RepositoryRecord = { ...repo, fullName: "we-promise/sure", owner: "we-promise", name: "sure" }; + const pressuredHistory: ContributorOutcomeHistory = { + ...outcomeHistory, + totals: { ...outcomeHistory.totals, openPullRequests: 6, credibility: 0.2 }, + repoOutcomes: [{ ...outcomeHistory.repoOutcomes[0]!, openPullRequests: 6, credibility: 0.2, closedPullRequestRate: 0 }], + }; + const basePr = { + authorLogin: "oktofeesh1", + authorAssociation: "CONTRIBUTOR", + labels: ["bug"], + linkedIssues: [7], + createdAt: "2026-05-25T00:00:00.000Z", + updatedAt: "2999-01-01T00:00:00.000Z", + }; + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [ + { path: "src/cache.ts", additions: 12, deletions: 1, status: "modified" }, + { path: "src/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + validation: [{ command: "npm test -- cache", status: "passed" }], + localScorer: { mode: "external_command", sourceTokenScore: 42, totalTokenScore: 70, sourceLines: 42, testTokenScore: 20 }, + }, + repo, + repositories: [repo, otherRepo], + issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache edge", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + contributorPullRequests: [ + { ...basePr, repoFullName: repo.fullName, number: 1, title: "Approved cache fix", state: "open", reviewDecision: "APPROVED" }, + { ...basePr, repoFullName: otherRepo.fullName, number: 2, title: "Draft branch", state: "open", isDraft: true }, + { ...basePr, repoFullName: otherRepo.fullName, number: 3, title: "Needs changes", state: "open", reviewDecision: "CHANGES_REQUESTED" }, + { ...basePr, repoFullName: otherRepo.fullName, number: 4, title: "Stale branch", state: "open", updatedAt: "2020-01-01T00:00:00.000Z" }, + { ...basePr, repoFullName: otherRepo.fullName, number: 5, title: "Closed branch", state: "closed" }, + { ...basePr, repoFullName: repo.fullName, number: 6, title: "Maintainer lane", state: "open", authorAssociation: "OWNER" }, + { ...basePr, repoFullName: repo.fullName, number: 7, title: "Someone else's approved PR", state: "open", authorLogin: "someone-else", reviewDecision: "APPROVED" }, + ], + profile, + outcomeHistory: pressuredHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.observedPullRequestScenarios).toMatchObject({ approvedOrMergeable: 1, stale: 1, closed: 1, draft: 1, blocked: 1, maintainerLane: 1 }); + expect(analysis.scenarioScorePreview.afterApprovedPrsMerge).toMatchObject({ source: "github_observed", gates: { openPrCount: 5, credibilityObserved: 0.8 } }); + expect(analysis.scenarioScorePreview.afterStalePrsClose).toMatchObject({ source: "github_observed", gates: { openPrCount: 4, credibilityObserved: 0.2 } }); + expect(analysis.scenarioScorePreview.afterApprovedPrsMerge?.assumptions.join(" ")).toMatch(/draft PR.*excluded|blocked PR.*excluded|maintainer-lane PR.*outside-contributor/); + expect(analysis.scorePreview.effectiveEstimatedScore).toBe(0); + expect(analysis.scorePreview.underlyingPotentialScore).toBeGreaterThan(0); + expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); + }); + + it("falls back to same-repo observed PR scenarios when the registered repo list is unavailable", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [{ path: "src/cache.ts", additions: 12, deletions: 1, status: "modified" }], + validation: [{ command: "npm test -- cache", status: "passed" }], + localScorer: { mode: "external_command", sourceTokenScore: 42, totalTokenScore: 60, sourceLines: 42 }, + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache edge", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + contributorPullRequests: [ + { + repoFullName: repo.fullName, + number: 1, + title: "Mergeable same-repo branch", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "CONTRIBUTOR", + mergeableState: "CLEAN", + labels: [], + linkedIssues: [], + }, + { + repoFullName: "we-promise/sure", + number: 2, + title: "Out-of-scope branch", + state: "open", + authorLogin: "oktofeesh1", + authorAssociation: "CONTRIBUTOR", + reviewDecision: "APPROVED", + labels: [], + linkedIssues: [], + }, + ], + profile, + outcomeHistory: { ...outcomeHistory, totals: { ...outcomeHistory.totals, openPullRequests: 2 } }, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.observedPullRequestScenarios.approvedOrMergeable).toBe(1); + expect(analysis.scenarioScorePreview.afterApprovedPrsMerge?.gates.openPrCount).toBe(1); + }); + it("classifies stale base state and treats passed validation as test evidence", () => { const analysis = buildLocalBranchAnalysis({ input: { diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 04e2efd041..d2e4c717bf 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -148,6 +148,39 @@ IGNORED = "not numeric" expect(JSON.stringify(preview)).not.toMatch(/guaranteed payout|wallet|hotkey|farming/i); }); + it("keeps GitHub-observed pending PR scenarios separate from user assumptions", () => { + const preview = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 5, + credibility: 0.2, + pendingMergedPrCount: 1, + projectedCredibility: 0.5, + observedApprovedPrCount: 1, + observedStalePrCount: 1, + observedDraftPrCount: 1, + observedBlockedPrCount: 1, + observedMaintainerPrCount: 1, + }, + }); + const userSupplied = preview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"); + const approved = preview.scenarioPreviews.find((scenario) => scenario.name === "afterApprovedPrsMerge"); + const stale = preview.scenarioPreviews.find((scenario) => scenario.name === "afterStalePrsClose"); + + expect(userSupplied).toMatchObject({ source: "user_supplied", gates: { openPrCount: 4, credibilityObserved: 0.5 } }); + expect(approved).toMatchObject({ source: "github_observed", gates: { openPrCount: 4, credibilityObserved: 0.8 } }); + expect(stale).toMatchObject({ source: "github_observed", gates: { openPrCount: 4, credibilityObserved: 0.2 } }); + expect(approved?.assumptions.join(" ")).toMatch(/draft PR.*excluded|blocked PR.*excluded|maintainer-lane PR.*outside-contributor/); + expect(preview.effectiveEstimatedScore).toBe(0); + expect(preview.underlyingPotentialScore).toBeGreaterThan(0); + expect(JSON.stringify(preview)).not.toMatch(/guaranteed payout|wallet|hotkey|farming/i); + }); + it("warns on metadata-only weak previews without using public reward or wallet language", () => { const preview = buildScorePreview({ repo: null,