diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index e44c9e4fbb..3e2a344831 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -1910,6 +1910,192 @@ "missing" ] }, + "ContributorOpenPrNextStepPacket": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "number": { + "type": "number" + }, + "title": { + "type": "string" + }, + "classification": { + "type": "string", + "enum": [ + "approved", + "blocked", + "stale", + "needs_author", + "failing_checks", + "missing_tests", + "duplicate_prone", + "reviewable", + "should_close_or_withdraw", + "maintainer_lane", + "draft" + ] + }, + "summary": { + "type": "string" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repoFullName", + "number", + "title", + "classification", + "summary", + "reasons", + "nextSteps" + ] + }, + "ContributorOpenPrMonitor": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "openPrCount": { + "type": "number" + }, + "registeredRepoCount": { + "type": "number" + }, + "cleanupFirst": { + "type": "boolean" + }, + "summary": { + "type": "string" + }, + "guidance": { + "type": "array", + "items": { + "type": "string" + } + }, + "pendingScenarios": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "detection": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "github_observed", + "user_supplied" + ] + }, + "pendingMergedPrCount": { + "type": "number" + }, + "pendingClosedPrCount": { + "type": "number" + }, + "approvedPrCount": { + "type": "number" + }, + "expectedOpenPrCountAfterMerge": { + "type": "number" + }, + "scenarioNotes": { + "type": "array", + "items": { + "type": "string" + } + }, + "classified": { + "type": "array", + "items": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "number": { + "type": "number" + }, + "title": { + "type": "string" + }, + "classification": { + "type": "string" + }, + "reasons": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repoFullName", + "number", + "title", + "classification", + "reasons" + ] + } + } + }, + "required": [ + "source", + "pendingMergedPrCount", + "pendingClosedPrCount", + "approvedPrCount", + "scenarioNotes", + "classified" + ] + } + }, + "required": [ + "repoFullName", + "detection" + ] + } + }, + "pullRequests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContributorOpenPrNextStepPacket" + } + } + }, + "required": [ + "login", + "generatedAt", + "openPrCount", + "registeredRepoCount", + "cleanupFirst", + "summary", + "guidance", + "pendingScenarios", + "pullRequests" + ] + }, "ContributorDecisionPack": { "type": "object", "properties": { @@ -2045,6 +2231,9 @@ "items": { "type": "string" } + }, + "openPrMonitor": { + "$ref": "#/components/schemas/ContributorOpenPrMonitor" } }, "required": [ @@ -9003,6 +9192,30 @@ ] } }, + "/v1/contributors/{login}/open-pr-monitor": { + "get": { + "responses": { + "200": { + "description": "Contributor open-PR monitor with classifications and public-safe next-step packets from cached metadata.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ContributorOpenPrMonitor" + } + } + } + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/contributors/{login}/repos/{owner}/{repo}/decision": { "get": { "responses": { diff --git a/src/api/routes.ts b/src/api/routes.ts index 32f28d1589..a3487bfeb4 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -134,6 +134,7 @@ import { buildRegistryChangeReport, } from "../signals/engine"; import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, buildRepoDataQuality, buildSignalFidelity } from "../signals/data-quality"; +import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildPullRequestReviewability } from "../signals/reward-risk"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildRepoSettingsPreview } from "../signals/settings-preview"; @@ -1150,6 +1151,13 @@ export function createApp() { return c.json(serving.refresh, 202); }); + app.get("/v1/contributors/:login/open-pr-monitor", async (c) => { + const login = c.req.param("login"); + const unauthorized = await requireContributorAccess(c, login); + if (unauthorized) return unauthorized; + return c.json(await buildContributorOpenPrMonitor(c.env, login)); + }); + app.get("/v1/contributors/:login/repos/:owner/:repo/decision", async (c) => { const login = c.req.param("login"); const unauthorized = await requireContributorAccess(c, login); diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index b7adb13da7..c06fcfcd4d 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -102,6 +102,7 @@ export function routeClassForPath(path: string): RateLimitClass { path.includes("/v1/agent/") || path.includes("/scoring/preview") || path.includes("/decision-pack") || + path.includes("/open-pr-monitor") || path.includes("/upstream/") || path.includes("/internal/jobs/generate-signal-snapshots") || path.includes("/internal/jobs/build-contributor-decision-packs") || diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7d20c72cae..b0ca77fcdd 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -55,6 +55,7 @@ import { buildRegistryChangeReport, buildRoleContext, } from "../signals/engine"; +import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { buildRepoDataQuality } from "../signals/data-quality"; import { loadUpstreamStatus } from "../upstream/ruleset"; @@ -282,6 +283,16 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.getDecisionPack(input.login)), ); + server.registerTool( + "gittensory_monitor_open_prs", + { + description: + "Inspect a contributor's open PRs on registered repos, classify queue state, and return public-safe next-step packets from cached metadata.", + inputSchema: loginShape, + }, + async (input) => this.toolResult(await this.monitorOpenPullRequests(input.login)), + ); + server.registerTool( "gittensory_explain_repo_decision", { @@ -610,6 +621,15 @@ export class GittensoryMcp { }; } + private async monitorOpenPullRequests(login: string): Promise { + this.requireContributorAccess(login); + const monitor = await buildContributorOpenPrMonitor(this.env, login); + return { + summary: monitor.summary, + data: monitor as unknown as Record, + }; + } + private async explainRepoDecision(input: { login: string; owner: string; repo: string }): Promise { this.requireContributorAccess(input.login); const fullName = `${input.owner}/${input.repo}`; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 7aef464e07..debdeb98cf 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -277,6 +277,65 @@ export const ContributorProfileSchema = z }) .openapi("ContributorProfile"); +export const ContributorOpenPrNextStepPacketSchema = z + .object({ + repoFullName: z.string(), + number: z.number(), + title: z.string(), + classification: z.enum([ + "approved", + "blocked", + "stale", + "needs_author", + "failing_checks", + "missing_tests", + "duplicate_prone", + "reviewable", + "should_close_or_withdraw", + "maintainer_lane", + "draft", + ]), + summary: z.string(), + reasons: z.array(z.string()), + nextSteps: z.array(z.string()), + }) + .openapi("ContributorOpenPrNextStepPacket"); + +export const ContributorOpenPrMonitorSchema = z + .object({ + login: z.string(), + generatedAt: z.string(), + openPrCount: z.number(), + registeredRepoCount: z.number(), + cleanupFirst: z.boolean(), + summary: z.string(), + guidance: z.array(z.string()), + pendingScenarios: z.array( + z.object({ + repoFullName: z.string(), + detection: z.object({ + source: z.enum(["github_observed", "user_supplied"]), + pendingMergedPrCount: z.number(), + pendingClosedPrCount: z.number(), + approvedPrCount: z.number(), + expectedOpenPrCountAfterMerge: z.number().optional(), + scenarioNotes: z.array(z.string()), + classified: z.array( + z.object({ + repoFullName: z.string(), + number: z.number(), + title: z.string(), + classification: z.string(), + reasons: z.array(z.string()), + }), + ), + }), + }), + ), + pullRequests: z.array(ContributorOpenPrNextStepPacketSchema), + }) + .openapi("ContributorOpenPrMonitor"); + export const ContributorOpportunitySchema = z .object({ repoFullName: z.string(), @@ -1208,6 +1267,7 @@ export const ContributorDecisionPackSchema = z dataQuality: z.record(z.unknown()), summary: z.string(), nextActions: z.array(z.string()), + openPrMonitor: ContributorOpenPrMonitorSchema.optional(), }) .openapi("ContributorDecisionPack"); diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 0c80a19118..b358cc1b1b 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -19,6 +19,7 @@ import { ContributorOpportunitySchema, ContributorPatternReportSchema, ContributorDecisionPackSchema, + ContributorOpenPrMonitorSchema, ContributorRewardRiskStrategySchema, ContributorProfileSchema, ContributorScoringProfileSchema, @@ -344,6 +345,16 @@ export function buildOpenApiSpec() { 202: { description: "Decision pack snapshot is missing; a background rebuild has been requested", content: { "application/json": { schema: DecisionPackRefreshNeededSchema } } }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/contributors/{login}/open-pr-monitor", + responses: { + 200: { + description: "Contributor open-PR monitor with classifications and public-safe next-step packets from cached metadata.", + content: { "application/json": { schema: ContributorOpenPrMonitorSchema } }, + }, + }, + }); registry.registerPath({ method: "get", path: "/v1/contributors/{login}/repos/{owner}/{repo}/decision", diff --git a/src/scoring/pending-pr-scenarios.ts b/src/scoring/pending-pr-scenarios.ts new file mode 100644 index 0000000000..ed68d009fa --- /dev/null +++ b/src/scoring/pending-pr-scenarios.ts @@ -0,0 +1,242 @@ +import { listCheckSummaries, listPullRequestReviews } from "../db/repositories"; +import { isMaintainerAssociation } from "../github/commands"; +import type { CheckSummaryRecord, PullRequestRecord, PullRequestReviewRecord } from "../types"; +import type { RoleContext } from "../signals/engine"; +import type { ScorePreviewInput } from "./preview"; + +export type OpenPrPendingClass = + | "merge_ready" + | "stale_likely_close" + | "draft" + | "blocked" + | "maintainer_lane" + | "open_other"; + +export type ClassifiedOpenPullRequest = { + repoFullName: string; + number: number; + title: string; + classification: OpenPrPendingClass; + reasons: string[]; +}; + +export type PendingPrScenarioDetection = { + source: "github_observed" | "user_supplied"; + pendingMergedPrCount: number; + pendingClosedPrCount: number; + approvedPrCount: number; + expectedOpenPrCountAfterMerge?: number | undefined; + scenarioNotes: string[]; + classified: ClassifiedOpenPullRequest[]; +}; + +export type ContributorRepoOpenPrSignals = { + reviewsByPullNumber: Map; + checksByPullNumber: Map; +}; + +const STALE_DAYS = 14; + +export async function loadContributorRepoOpenPrSignalRecords( + env: Env, + repoFullName: string, + login: string, + pullRequests: PullRequestRecord[], +): Promise<{ pullRequestReviews: PullRequestReviewRecord[]; pullRequestChecks: CheckSummaryRecord[] }> { + const open = pullRequests.filter( + (pr) => pr.repoFullName === repoFullName && pr.state === "open" && sameLogin(pr.authorLogin, login), + ); + const signals = await loadContributorRepoOpenPrSignals(env, repoFullName, open); + return { + pullRequestReviews: [...signals.reviewsByPullNumber.values()].flat(), + pullRequestChecks: [...signals.checksByPullNumber.values()].flat(), + }; +} + +export async function loadContributorRepoOpenPrSignals( + env: Env, + repoFullName: string, + pullRequests: PullRequestRecord[], +): Promise { + const open = pullRequests.filter((pr) => pr.repoFullName === repoFullName && pr.state === "open"); + const reviewsByPullNumber = new Map(); + const checksByPullNumber = new Map(); + await Promise.all( + open.map(async (pr) => { + const [reviews, checks] = await Promise.all([ + listPullRequestReviews(env, repoFullName, pr.number), + listCheckSummaries(env, repoFullName, pr.number), + ]); + reviewsByPullNumber.set(pr.number, reviews); + checksByPullNumber.set(pr.number, checks); + }), + ); + return { reviewsByPullNumber, checksByPullNumber }; +} + +export function detectPendingPrScenario(args: { + login: string; + repoFullName: string; + pullRequests: PullRequestRecord[]; + roleContext: RoleContext; + openPrCount?: number | undefined; + reviewsByPullNumber?: Map | undefined; + checksByPullNumber?: Map | undefined; + excludePullNumbers?: number[] | undefined; + userSupplied?: Pick< + ScorePreviewInput, + "pendingMergedPrCount" | "pendingClosedPrCount" | "approvedPrCount" | "expectedOpenPrCountAfterMerge" | "projectedCredibility" | "scenarioNotes" + > | undefined; +}): PendingPrScenarioDetection | null { + const user = args.userSupplied; + const hasUserCounts = + user?.pendingMergedPrCount !== undefined || + user?.pendingClosedPrCount !== undefined || + user?.approvedPrCount !== undefined || + user?.expectedOpenPrCountAfterMerge !== undefined; + if (hasUserCounts) { + return { + source: "user_supplied", + pendingMergedPrCount: nonNegative(user?.pendingMergedPrCount), + pendingClosedPrCount: nonNegative(user?.pendingClosedPrCount), + approvedPrCount: nonNegative(user?.approvedPrCount), + ...(user?.expectedOpenPrCountAfterMerge !== undefined ? { expectedOpenPrCountAfterMerge: nonNegative(user.expectedOpenPrCountAfterMerge) } : {}), + scenarioNotes: user?.scenarioNotes ?? [], + classified: [], + }; + } + + const excluded = new Set(args.excludePullNumbers ?? []); + const contributorOpen = args.pullRequests.filter( + (pr) => + pr.repoFullName === args.repoFullName && + pr.state === "open" && + sameLogin(pr.authorLogin, args.login) && + !excluded.has(pr.number), + ); + if (contributorOpen.length === 0) return null; + + const classified = contributorOpen.map((pr) => + classifyOpenPullRequest({ + pr, + roleContext: args.roleContext, + reviews: args.reviewsByPullNumber?.get(pr.number) ?? [], + checks: args.checksByPullNumber?.get(pr.number) ?? [], + }), + ); + + const mergeReady = classified.filter((entry) => entry.classification === "merge_ready"); + const staleLikelyClose = classified.filter((entry) => entry.classification === "stale_likely_close"); + const pendingMergedPrCount = mergeReady.length; + const pendingClosedPrCount = staleLikelyClose.length; + if (pendingMergedPrCount === 0 && pendingClosedPrCount === 0) return null; + + const currentOpen = args.openPrCount ?? contributorOpen.length; + const expectedOpenPrCountAfterMerge = Math.max(0, currentOpen - pendingMergedPrCount - pendingClosedPrCount); + const scenarioNotes = [ + "GitHub-observed open PR state from cached reviews, checks, and activity timestamps (estimate only).", + ...(pendingMergedPrCount > 0 + ? [`${pendingMergedPrCount} open PR(s) look merge-ready (approved, no changes requested, no failing checks, not draft/stale).`] + : []), + ...(pendingClosedPrCount > 0 ? [`${pendingClosedPrCount} open PR(s) look stale and may be closed instead of merged.`] : []), + ...classified + .filter((entry) => entry.classification === "draft" || entry.classification === "blocked" || entry.classification === "maintainer_lane") + .map((entry) => `PR #${entry.number} treated as ${entry.classification.replace(/_/g, " ")} for this projection.`), + ]; + + return { + source: "github_observed", + pendingMergedPrCount, + pendingClosedPrCount, + approvedPrCount: mergeReady.length, + expectedOpenPrCountAfterMerge, + scenarioNotes, + classified, + }; +} + +export function classifyOpenPullRequest(args: { + pr: PullRequestRecord; + roleContext: RoleContext; + reviews: PullRequestReviewRecord[]; + checks: CheckSummaryRecord[]; + duplicateProne?: boolean | undefined; + missingTests?: boolean | undefined; +}): ClassifiedOpenPullRequest { + const reasons: string[] = []; + if (args.roleContext.maintainerLane) { + reasons.push("Maintainer-lane context for this repo; not counted as outside-contributor pending reward work."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons }; + } + if (isMaintainerAssociation(args.pr.authorAssociation)) { + reasons.push("Author association indicates maintainer-authored work."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons }; + } + if (isDraftPullRequest(args.pr)) { + reasons.push("Draft PRs are not treated as likely to land."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "draft", reasons }; + } + + const approvalCount = args.reviews.filter((review) => review.state.toUpperCase() === "APPROVED").length; + const changeRequestCount = args.reviews.filter((review) => review.state.toUpperCase() === "CHANGES_REQUESTED").length; + const checkFailureCount = args.checks.filter( + (check) => check.conclusion === "failure" || check.conclusion === "timed_out" || check.conclusion === "cancelled", + ).length; + const ageDays = daysSince(args.pr.updatedAt ?? args.pr.createdAt); + + if (args.duplicateProne) reasons.push("Overlapping open work detected in the same repo (possible duplicate or WIP collision)."); + if (args.missingTests) reasons.push("Cached file list shows code changes without matching test files."); + if (changeRequestCount > 0) reasons.push(`${changeRequestCount} changes-requested review(s).`); + if (checkFailureCount > 0) reasons.push(`${checkFailureCount} failing or cancelled check(s).`); + if (approvalCount === 0) reasons.push("No approved review in cache."); + + if (changeRequestCount > 0 || checkFailureCount > 0 || approvalCount === 0) { + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "blocked", reasons }; + } + + if (ageDays >= STALE_DAYS) { + reasons.push(`No meaningful update in at least ${STALE_DAYS} days; may be closed instead of merged.`); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "stale_likely_close", reasons }; + } + + reasons.push("Approved with passing checks and recent activity; treated as likely to merge (estimate)."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "merge_ready", reasons }; +} + +export function applyPendingPrDetectionToScoreInput( + input: ScorePreviewInput, + detection: PendingPrScenarioDetection | null, +): ScorePreviewInput { + if (!detection || detection.source === "user_supplied") return input; + return { + ...input, + pendingMergedPrCount: detection.pendingMergedPrCount, + pendingClosedPrCount: detection.pendingClosedPrCount, + approvedPrCount: detection.approvedPrCount, + expectedOpenPrCountAfterMerge: detection.expectedOpenPrCountAfterMerge, + scenarioNotes: [...(input.scenarioNotes ?? []), ...detection.scenarioNotes], + pendingScenarioObserved: true, + }; +} + +function isDraftPullRequest(pr: PullRequestRecord): boolean { + const title = pr.title.trim(); + if (/^\[?\s*draft\s*\]?/i.test(title) || /^draft:/i.test(title)) return true; + return pr.labels.some((label) => label.toLowerCase() === "draft" || label.toLowerCase() === "wip"); +} + +function sameLogin(value: string | null | undefined, login: string): boolean { + return Boolean(value && value.toLowerCase() === login.toLowerCase()); +} + +function daysSince(value: string | null | undefined): number { + if (!value) return Number.POSITIVE_INFINITY; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY; + return Math.max(0, (Date.now() - parsed) / 86_400_000); +} + +function nonNegative(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 18d647a231..33267af94c 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -31,6 +31,7 @@ export type ScorePreviewInput = { expectedOpenPrCountAfterMerge?: number | undefined; projectedCredibility?: number | undefined; scenarioNotes?: string[] | undefined; + pendingScenarioObserved?: boolean | undefined; observedScenarioNotes?: string[] | undefined; }; @@ -333,7 +334,11 @@ function buildScenarioPreviews( ], repo), scenario( "afterPendingMerges", - userPendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined ? "user_supplied" : "gittensory_projection", + input.pendingScenarioObserved + ? "github_observed" + : userPendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined + ? "user_supplied" + : "gittensory_projection", afterPendingInput, computeScoreCore(afterPendingInput, repo, snapshot, contributorEvidence), [ diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 4a136e3b15..2599c87924 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -26,6 +26,7 @@ import { loadContributorDecisionPackForServing, repoDecisionFromPack, type Contr import { loadOrComputeIssueQualityResponse } from "./issue-quality"; import { summarizeAgentBundleWithAi } from "./ai-summaries"; import { buildContributorFit, buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile } from "../signals/engine"; +import { buildContributorOpenPrMonitor, type ContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch"; import type { AgentActionRecord, @@ -221,7 +222,10 @@ async function executeDecisionPackRun(env: Env, run: AgentRunRecord, kind: strin }); return (await getAgentRunBundle(env, run.id))!; } - const pack = serving.pack; + const pack = { + ...serving.pack, + openPrMonitor: serving.pack.openPrMonitor ?? (await buildContributorOpenPrMonitor(env, login)), + }; const isStale = pack.freshness !== "fresh"; const decisions = repoFullName ? pack.repoDecisions.filter((decision) => sameRepo(decision.repoFullName, repoFullName)) : pack.repoDecisions; const allowCrossRepoFallback = !repoFullName || run.surface !== "github_comment"; @@ -334,12 +338,60 @@ async function loadCheckSummariesForPullRequests(env: Env, repoFullName: string, function buildDecisionActions(run: AgentRunRecord, pack: ContributorDecisionPack, decisions: RepoDecision[]): AgentActionRecord[] { const decisionByRepo = new Map(decisions.map((decision) => [decision.repoFullName, decision])); + const monitorActions = buildOpenPrMonitorActions(run, pack, decisions); const candidateActions = pack.topActions .filter((action) => decisionByRepo.has(action.repoFullName)) .slice(0, 8) - .map((action, index) => actionFromDecisionAction(run, action, decisionByRepo.get(action.repoFullName)!, index)); - if (candidateActions.length > 0) return candidateActions; - return decisions.slice(0, 5).map((decision, index) => actionFromRepoDecision(run, decision, index)); + .map((action, index) => actionFromDecisionAction(run, action, decisionByRepo.get(action.repoFullName)!, monitorActions.length + index)); + if (candidateActions.length > 0) return [...monitorActions, ...candidateActions].slice(0, 8); + const fallback = decisions.slice(0, 5).map((decision, index) => actionFromRepoDecision(run, decision, monitorActions.length + index)); + return [...monitorActions, ...fallback].slice(0, 8); +} + +function buildOpenPrMonitorActions(run: AgentRunRecord, pack: ContributorDecisionPack, decisions: RepoDecision[]): AgentActionRecord[] { + const monitor = pack.openPrMonitor; + if (!monitor || monitor.pullRequests.length === 0) return []; + const decisionByRepo = new Map(decisions.map((decision) => [decision.repoFullName.toLowerCase(), decision])); + const urgentClassifications = new Set([ + "needs_author", + "failing_checks", + "duplicate_prone", + "stale", + "should_close_or_withdraw", + "blocked", + ]); + return monitor.pullRequests + .filter((packet) => urgentClassifications.has(packet.classification)) + .slice(0, 4) + .map((packet, index) => { + const decision = decisionByRepo.get(packet.repoFullName.toLowerCase()); + const actionType: AgentActionType = + packet.classification === "approved" || packet.classification === "reviewable" ? "monitor_existing_pr" : "cleanup_existing_prs"; + return actionRecord({ + run, + actionType, + index, + targetRepoFullName: packet.repoFullName, + targetPullNumber: packet.number, + status: packet.classification === "approved" ? "recommended" : "blocked", + recommendation: packet.nextSteps[0] ?? packet.summary, + why: packet.reasons.slice(0, 4), + scoreabilityImpact: monitor.cleanupFirst + ? "Resolving open PR queue pressure can unblock scoreability before opening new work." + : "Open PR hygiene affects maintainer review load and lane fit.", + riskImpact: packet.classification === "duplicate_prone" ? "Duplicate or overlapping PRs increase collision risk." : "Stale or failing PRs consume review bandwidth.", + maintainerImpact: "Focused cleanup reduces maintainer queue noise before new submissions.", + blockedBy: [packet.classification], + rerunWhen: "Rerun after this PR merges, closes, or passes checks and review.", + publicSafeSummary: sanitizePublicSummary(`${packet.repoFullName}#${packet.number}: ${packet.summary}`), + payload: { + openPrPacket: packet as unknown as JsonValue, + decision: (decision ?? null) as unknown as JsonValue, + }, + safetyClass: "public_safe", + approvalRequired: false, + }); + }); } function buildBlockerActions( @@ -553,6 +605,7 @@ function contextSnapshotFromPack(runId: string, pack: ContributorDecisionPack, d source: pack.source, selectedRepos: decisions.map((decision) => decision.repoFullName), dataQuality: pack.dataQuality as unknown as JsonValue, + openPrMonitor: (pack.openPrMonitor ?? null) as unknown as JsonValue, }, }; } @@ -631,6 +684,7 @@ function sameRepo(left: string, right: string): boolean { export const __agentOrchestratorInternals = { buildDecisionActions, + buildOpenPrMonitorActions, buildBlockerActions, buildLocalBranchActions, buildLocalBlockerActions, diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index 7bd2558207..8f887310e3 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -33,6 +33,7 @@ import { 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 type { BountyRecord, @@ -91,6 +92,7 @@ export type ContributorDecisionPack = { }; summary: string; nextActions: string[]; + openPrMonitor?: ContributorOpenPrMonitor | undefined; }; export type DecisionPackRefreshNeeded = { @@ -299,6 +301,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st }); const fit = buildContributorFit(profile, repositories, allIssues, allPullRequests, syncStates, repoStats, bounties, issueQualityByRepo); const scoringProfile = buildContributorScoringProfile({ login, fit, scoringSnapshot }); + const openPrMonitor = await buildContributorOpenPrMonitor(env, login); const pack = buildContributorDecisionPack({ login, profile, @@ -312,6 +315,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st contributorPullRequests, contributorIssues, issueQualityByRepo, + openPrMonitor, }); await upsertContributorEvidence(env, { @@ -362,6 +366,7 @@ function buildContributorDecisionPack(args: { contributorPullRequests: Parameters[0]["pullRequests"]; contributorIssues: Parameters[0]["issues"]; issueQualityByRepo?: Map | undefined; + openPrMonitor: ContributorOpenPrMonitor; }): ContributorDecisionPack { const registeredRepositories = args.repositories.filter((repo) => repo.isRegistered); const syncByRepo = new Map(args.syncStates.map((state) => [state.repoFullName.toLowerCase(), state])); @@ -403,6 +408,10 @@ function buildContributorDecisionPack(args: { const dataQuality = { signalFidelity: buildSignalFidelity(registeredRepositories.length, args.syncStates, args.syncSegments), }; + const monitor = args.openPrMonitor; + const monitorNextSteps = monitor.guidance.slice(0, 6); + const packNextActions = [...new Set([...monitorNextSteps, ...topActions.flatMap((action) => action.nextActions)])].slice(0, 12); + const monitorSummary = monitor.openPrCount > 0 ? ` ${monitor.summary}` : ""; return { status: "ready", source: "computed", @@ -431,8 +440,9 @@ function buildContributorDecisionPack(args: { maintainerLaneRepos: repoDecisions.filter((decision) => decision.recommendation === "maintainer_lane").slice(0, 8), scoreBlockers, dataQuality, - summary: `${args.login} has ${topActions.length} ranked action(s), ${scoreBlockers.length} scoreability blocker(s), and ${repoDecisions.length} registered repo decision(s).`, - nextActions: [...new Set(topActions.flatMap((action) => action.nextActions))].slice(0, 10), + summary: `${args.login} has ${topActions.length} ranked action(s), ${scoreBlockers.length} scoreability blocker(s), and ${repoDecisions.length} registered repo decision(s).${monitorSummary}`, + nextActions: packNextActions, + openPrMonitor: monitor, }; } diff --git a/src/signals/contributor-open-pr-monitor.ts b/src/signals/contributor-open-pr-monitor.ts new file mode 100644 index 0000000000..813345000d --- /dev/null +++ b/src/signals/contributor-open-pr-monitor.ts @@ -0,0 +1,297 @@ +import { listContributorPullRequests, listPullRequestFiles, listPullRequests, listRepositories } from "../db/repositories"; +import { sanitizePublicComment } from "../github/commands"; +import { + classifyOpenPullRequest, + detectPendingPrScenario, + loadContributorRepoOpenPrSignals, + type ClassifiedOpenPullRequest, + type PendingPrScenarioDetection, +} from "../scoring/pending-pr-scenarios"; +import type { CheckSummaryRecord, PullRequestFileRecord, PullRequestRecord, PullRequestReviewRecord } from "../types"; +import { nowIso } from "../utils/json"; +import { buildRoleContext } from "./engine"; + +export type OpenPrWorkClassification = + | "approved" + | "blocked" + | "stale" + | "needs_author" + | "failing_checks" + | "missing_tests" + | "duplicate_prone" + | "reviewable" + | "should_close_or_withdraw" + | "maintainer_lane" + | "draft"; + +export type ContributorOpenPrNextStepPacket = { + repoFullName: string; + number: number; + title: string; + classification: OpenPrWorkClassification; + summary: string; + reasons: string[]; + nextSteps: string[]; +}; + +export type ContributorOpenPrMonitor = { + login: string; + generatedAt: string; + openPrCount: number; + registeredRepoCount: number; + cleanupFirst: boolean; + summary: string; + guidance: string[]; + pendingScenarios: Array<{ repoFullName: string; detection: PendingPrScenarioDetection }>; + pullRequests: ContributorOpenPrNextStepPacket[]; +}; + +export async function buildContributorOpenPrMonitor(env: Env, login: string): Promise { + const [pullRequests, repositories] = await Promise.all([listContributorPullRequests(env, login), listRepositories(env)]); + const registered = new Set(repositories.map((repo) => repo.fullName.toLowerCase())); + const openByContributor = pullRequests.filter( + (pr) => pr.state === "open" && sameLogin(pr.authorLogin, login) && registered.has(pr.repoFullName.toLowerCase()), + ); + + const byRepo = groupByRepo(openByContributor); + const pendingScenarios: ContributorOpenPrMonitor["pendingScenarios"] = []; + const packets: ContributorOpenPrNextStepPacket[] = []; + + for (const [repoFullName, repoOpen] of byRepo.entries()) { + const repo = repositories.find((entry) => entry.fullName.toLowerCase() === repoFullName.toLowerCase()) ?? null; + const roleContext = buildRoleContext({ + login, + repo, + repoFullName, + pullRequests, + issues: [], + profile: null, + }); + const signals = await loadContributorRepoOpenPrSignals(env, repoFullName, repoOpen); + const repoPullRequests = await listPullRequests(env, repoFullName); + const duplicateNumbers = duplicatePronePullNumbers(repoOpen); + + for (const pr of repoOpen) { + const reviews = signals.reviewsByPullNumber.get(pr.number) ?? []; + const checks = signals.checksByPullNumber.get(pr.number) ?? []; + const files = await listPullRequestFiles(env, repoFullName, pr.number); + const classified = classifyOpenPullRequest({ + pr, + roleContext, + reviews, + checks, + duplicateProne: duplicateNumbers.has(pr.number), + missingTests: missingTestsFromFiles(files), + }); + packets.push(buildNextStepPacket(classified, reviews, checks, duplicateNumbers.has(pr.number), missingTestsFromFiles(files))); + } + + const detection = detectPendingPrScenario({ + login, + repoFullName, + pullRequests: repoPullRequests, + roleContext, + openPrCount: repoOpen.length, + reviewsByPullNumber: signals.reviewsByPullNumber, + checksByPullNumber: signals.checksByPullNumber, + }); + if (detection) pendingScenarios.push({ repoFullName, detection }); + } + + packets.sort((left, right) => priorityRank(left.classification) - priorityRank(right.classification) || left.repoFullName.localeCompare(right.repoFullName) || left.number - right.number); + + const cleanupFirst = packets.some((entry) => + ["needs_author", "failing_checks", "duplicate_prone", "stale", "should_close_or_withdraw", "blocked"].includes(entry.classification), + ); + const approvedCount = packets.filter((entry) => entry.classification === "approved").length; + const summary = summarizeMonitor(openByContributor.length, approvedCount, cleanupFirst); + const guidance = buildMonitorGuidance(packets, cleanupFirst); + + return { + login, + generatedAt: nowIso(), + openPrCount: openByContributor.length, + registeredRepoCount: registered.size, + cleanupFirst, + summary: sanitizePublicComment(summary), + guidance: guidance.map((line) => sanitizePublicComment(line)), + pendingScenarios, + pullRequests: packets, + }; +} + +export function mapPendingClassToWorkClassification( + classified: ClassifiedOpenPullRequest, + args: { changeRequestCount: number; checkFailureCount: number; duplicateProne: boolean; missingTests: boolean }, +): OpenPrWorkClassification { + if (classified.classification === "maintainer_lane") return "maintainer_lane"; + if (classified.classification === "draft") return "draft"; + if (classified.classification === "stale_likely_close") return "should_close_or_withdraw"; + if (args.duplicateProne) return "duplicate_prone"; + if (args.checkFailureCount > 0) return "failing_checks"; + if (args.changeRequestCount > 0) return "needs_author"; + if (args.missingTests) return "missing_tests"; + if (classified.classification === "merge_ready") return "approved"; + if (classified.classification === "blocked") return "blocked"; + return "reviewable"; +} + +function buildNextStepPacket( + classified: ClassifiedOpenPullRequest, + reviews: PullRequestReviewRecord[], + checks: CheckSummaryRecord[], + duplicateProne: boolean, + missingTests: boolean, +): ContributorOpenPrNextStepPacket { + const changeRequestCount = reviews.filter((review) => review.state.toUpperCase() === "CHANGES_REQUESTED").length; + const checkFailureCount = checks.filter((check) => check.conclusion === "failure" || check.conclusion === "timed_out" || check.conclusion === "cancelled").length; + const classification = mapPendingClassToWorkClassification(classified, { changeRequestCount, checkFailureCount, duplicateProne, missingTests }); + const nextSteps = nextStepsForClassification(classification, classified.repoFullName, classified.number); + const summary = `${classified.repoFullName}#${classified.number}: ${classification.replace(/_/g, " ")} — ${classified.title}`; + return { + repoFullName: classified.repoFullName, + number: classified.number, + title: classified.title, + classification, + summary: sanitizePublicComment(summary), + reasons: classified.reasons.map((reason) => sanitizePublicComment(reason)), + nextSteps: nextSteps.map((step) => sanitizePublicComment(step)), + }; +} + +function nextStepsForClassification(classification: OpenPrWorkClassification, repoFullName: string, number: number): string[] { + const ref = `${repoFullName}#${number}`; + switch (classification) { + case "approved": + return [`Confirm CI is green on ${ref}, then nudge maintainers or wait for merge.`, `Avoid opening new PRs in this repo until ${ref} lands or you close it.`]; + case "failing_checks": + return [`Fix failing checks on ${ref} before requesting another review.`, `Re-run CI after pushing fixes; do not open parallel PRs for the same fix.`]; + case "needs_author": + return [`Address review comments on ${ref} and push updates.`, `Reply on the PR thread summarizing what changed.`]; + case "missing_tests": + return [`Add or update tests on ${ref} if the repo expects test coverage.`, `Note test commands run in the PR description.`]; + case "duplicate_prone": + return [`Check overlap with other open PRs in ${repoFullName}; close or consolidate duplicates.`, `Comment on ${ref} linking the canonical PR if one exists.`]; + case "stale": + case "should_close_or_withdraw": + return [`Update ${ref} with a short status comment or close it if no longer needed.`, `Do not open new work until stale queue pressure is reduced.`]; + case "maintainer_lane": + return [`Treat ${ref} as maintainer/repo-owner work, not normal outside-contributor mining evidence.`, `Focus on repo health, intake quality, or maintainer-cut readiness instead of score chasing.`]; + case "draft": + return [`Mark ${ref} ready for review when complete, or close the draft.`, `Ensure linked issues and test plan are filled before undrafting.`]; + case "reviewable": + return [`Polish ${ref} description, link issues, and confirm lane fit before pinging reviewers.`, `Keep only one active PR per narrow topic in ${repoFullName}.`]; + case "blocked": + default: + return [`Resolve blockers on ${ref} (reviews, checks, or missing context) before expanding scope.`, `Run local preflight again after updates.`]; + } +} + +function summarizeMonitor(openCount: number, approvedCount: number, cleanupFirst: boolean): string { + if (openCount === 0) return "No open pull requests on registered repos in cache."; + if (cleanupFirst) { + return `${openCount} open PR(s) across registered repos; clean up existing work before opening more (${approvedCount} look merge-ready).`; + } + return `${openCount} open PR(s) across registered repos; ${approvedCount} look merge-ready from cached metadata.`; +} + +function buildMonitorGuidance(packets: ContributorOpenPrNextStepPacket[], cleanupFirst: boolean): string[] { + const lines: string[] = []; + if (cleanupFirst) lines.push("Prioritize existing open PRs before starting new issues or branches."); + const failing = packets.filter((entry) => entry.classification === "failing_checks").length; + const needsAuthor = packets.filter((entry) => entry.classification === "needs_author").length; + const duplicate = packets.filter((entry) => entry.classification === "duplicate_prone").length; + if (failing > 0) lines.push(`${failing} PR(s) need failing checks addressed first.`); + if (needsAuthor > 0) lines.push(`${needsAuthor} PR(s) need author follow-up on review comments.`); + if (duplicate > 0) lines.push(`${duplicate} PR(s) look duplicate-prone; consolidate before adding more queue load.`); + if (packets.some((entry) => entry.classification === "approved")) { + lines.push("Merge-ready PRs can improve pending-merge score projections after they land."); + } + if (lines.length === 0) lines.push("Queue looks manageable from cached metadata; still run preflight before new PRs."); + return lines; +} + +function groupByRepo(pullRequests: PullRequestRecord[]): Map { + const map = new Map(); + for (const pr of pullRequests) { + const bucket = map.get(pr.repoFullName) ?? []; + bucket.push(pr); + map.set(pr.repoFullName, bucket); + } + return map; +} + +function duplicatePronePullNumbers(openPullRequests: PullRequestRecord[]): Set { + const flagged = new Set(); + const byNormalizedTitle = new Map(); + for (const pr of openPullRequests) { + const key = normalizeTitle(pr.title); + const bucket = byNormalizedTitle.get(key) ?? []; + bucket.push(pr); + byNormalizedTitle.set(key, bucket); + } + for (const bucket of byNormalizedTitle.values()) { + if (bucket.length < 2) continue; + for (const pr of bucket) flagged.add(pr.number); + } + const wip = openPullRequests.filter((pr) => pr.labels.some((label) => /^(wip|duplicate)$/i.test(label))); + for (const pr of wip) flagged.add(pr.number); + return flagged; +} + +function normalizeTitle(title: string): string { + return title + .toLowerCase() + .replace(/^\[?\s*draft\s*\]?\s*/i, "") + .replace(/^wip:\s*/i, "") + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function missingTestsFromFiles(files: PullRequestFileRecord[]): boolean { + if (files.length === 0) return false; + const codeFiles = files.filter((file) => file.path && !isTestPath(file.path)); + const testFiles = files.filter((file) => file.path && isTestPath(file.path)); + return codeFiles.length > 0 && testFiles.length === 0; +} + +function isTestPath(path: string): boolean { + return ( + /(^|\/)(test|tests|spec|__tests__)\//i.test(path) || + /\.(test|spec)\.(ts|tsx|js|jsx|py|go|rs)$/i.test(path) || + /(^|\/)[^/]+_test\.go$/i.test(path) + ); +} + +function priorityRank(classification: OpenPrWorkClassification): number { + const order: OpenPrWorkClassification[] = [ + "failing_checks", + "needs_author", + "duplicate_prone", + "missing_tests", + "blocked", + "should_close_or_withdraw", + "stale", + "draft", + "reviewable", + "approved", + "maintainer_lane", + ]; + const index = order.indexOf(classification); + return index === -1 ? order.length : index; +} + +function sameLogin(value: string | null | undefined, login: string): boolean { + return Boolean(value && value.toLowerCase() === login.toLowerCase()); +} + +export const __contributorOpenPrMonitorInternals = { + mapPendingClassToWorkClassification, + nextStepsForClassification, + summarizeMonitor, + buildMonitorGuidance, + duplicatePronePullNumbers, + missingTestsFromFiles, + priorityRank, + buildNextStepPacket, +}; diff --git a/test/helpers/d1.ts b/test/helpers/d1.ts index 871a9820c4..e2767db718 100644 --- a/test/helpers/d1.ts +++ b/test/helpers/d1.ts @@ -29,8 +29,9 @@ export class TestD1Database { return { results: statement.all(...bound) as T[] }; }, async raw() { - const columns = statement.columns().map((column) => column.name); const rows = statement.all(...bound) as Record[]; + if (rows.length === 0) return [] as T[]; + const columns = Object.keys(rows[0]!); return rows.map((row) => columns.map((column) => row[column])) as T[]; }, async run() { diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index e7fc38b761..9b6db3d548 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -161,6 +161,18 @@ describe("api route guards and error branches", () => { expect(victimDecisionPack.status).toBe(403); await expect(victimDecisionPack.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); + const ownOpenPrMonitor = await app.request("/v1/contributors/attacker/open-pr-monitor", { headers: sessionHeaders }, env); + expect(ownOpenPrMonitor.status).toBe(200); + await expect(ownOpenPrMonitor.json()).resolves.toMatchObject({ login: "attacker", pullRequests: expect.any(Array) }); + + const victimOpenPrMonitor = await app.request("/v1/contributors/victim/open-pr-monitor", { headers: sessionHeaders }, env); + expect(victimOpenPrMonitor.status).toBe(403); + await expect(victimOpenPrMonitor.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); + + const staticTokenOpenPrMonitor = await app.request("/v1/contributors/victim/open-pr-monitor", { headers: apiHeaders(env) }, env); + expect(staticTokenOpenPrMonitor.status).toBe(200); + await expect(staticTokenOpenPrMonitor.json()).resolves.toMatchObject({ login: "victim" }); + const victimRepoDecision = await app.request("/v1/contributors/victim/repos/owner/private-repo/decision", { headers: sessionHeaders }, env); expect(victimRepoDecision.status).toBe(403); await expect(victimRepoDecision.json()).resolves.toMatchObject({ error: "forbidden_contributor" }); diff --git a/test/unit/agent-orchestrator.test.ts b/test/unit/agent-orchestrator.test.ts index 096cc6e1b4..702f20a6e3 100644 --- a/test/unit/agent-orchestrator.test.ts +++ b/test/unit/agent-orchestrator.test.ts @@ -336,6 +336,168 @@ describe("agent orchestrator", () => { expect(noDecisionActions[0]).toMatchObject({ actionType: "explain_repo_fit", status: "recommended" }); expect(blockerFallback[0]).toMatchObject({ actionType: "explain_score_blockers", status: "blocked" }); expect(__agentOrchestratorInternals.summarizeRun({ ...run, status: "failed", errorSummary: undefined }, [])).toContain("unknown"); + + const monitorRun = __agentOrchestratorInternals.buildRunRecord({ + objective: "open pr monitor actions", + actorLogin: "oktofeesh1", + surface: "mcp", + status: "running", + payload: {}, + }); + const monitorPack = decisionPackFixture({ + generatedAt, + openPrMonitor: { + login: "oktofeesh1", + generatedAt, + openPrCount: 2, + registeredRepoCount: 1, + cleanupFirst: true, + summary: "Two open PRs need cleanup.", + guidance: ["Land or close stale PRs before opening new work."], + pendingScenarios: [], + pullRequests: [ + { + repoFullName: "we-promise/sure", + number: 9, + title: "Stale fix", + classification: "stale", + summary: "PR is stale.", + reasons: ["No updates in 30 days."], + nextSteps: ["Rebase or close the PR."], + }, + { + repoFullName: "entrius/gittensor", + number: 4, + title: "Overlapping change", + classification: "duplicate_prone", + summary: "Overlaps with another open PR.", + reasons: ["Similar files touched."], + nextSteps: ["Consolidate into one PR."], + }, + ], + }, + }); + const monitorActions = __agentOrchestratorInternals.buildOpenPrMonitorActions(monitorRun, monitorPack, [readyDecision, criticalDecision]); + expect(monitorActions).toHaveLength(2); + expect(monitorActions[0]).toMatchObject({ + actionType: "cleanup_existing_prs", + targetRepoFullName: "we-promise/sure", + targetPullNumber: 9, + status: "blocked", + }); + expect(monitorActions[0]?.scoreabilityImpact).toMatch(/queue pressure/); + expect(monitorActions[1]?.riskImpact).toMatch(/Duplicate/); + expect(monitorActions[1]?.payload).toMatchObject({ decision: null }); + expect(__agentOrchestratorInternals.buildOpenPrMonitorActions(monitorRun, { ...monitorPack, openPrMonitor: undefined }, [readyDecision])).toEqual([]); + expect( + __agentOrchestratorInternals.buildOpenPrMonitorActions(monitorRun, { ...monitorPack, openPrMonitor: { ...monitorPack.openPrMonitor!, pullRequests: [] } }, []), + ).toEqual([]); + const mergedActions = __agentOrchestratorInternals.buildDecisionActions(monitorRun, monitorPack, [readyDecision]); + expect(mergedActions.slice(0, 2).map((entry) => entry.actionType)).toEqual(["cleanup_existing_prs", "cleanup_existing_prs"]); + expect(mergedActions.some((entry) => entry.actionType === "explain_repo_fit")).toBe(true); + + const approvedPack = decisionPackFixture({ + generatedAt, + openPrMonitor: { + login: "oktofeesh1", + generatedAt, + openPrCount: 1, + registeredRepoCount: 1, + cleanupFirst: false, + summary: "One merge-ready PR.", + guidance: [], + pendingScenarios: [], + pullRequests: [ + { + repoFullName: "we-promise/sure", + number: 12, + title: "Ready patch", + classification: "approved", + summary: "Approved and passing.", + reasons: ["Checks green."], + nextSteps: ["Merge when ready."], + }, + ], + }, + }); + const approvedActions = __agentOrchestratorInternals.buildOpenPrMonitorActions(monitorRun, approvedPack, [readyDecision]); + expect(approvedActions).toHaveLength(0); + + const reviewablePack = decisionPackFixture({ + generatedAt, + openPrMonitor: { + ...approvedPack.openPrMonitor!, + pullRequests: [ + { + repoFullName: "we-promise/sure", + number: 13, + title: "Reviewable patch", + classification: "reviewable", + summary: "Ready for review.", + reasons: ["Checks passed."], + nextSteps: ["Request review."], + }, + ], + }, + }); + expect(__agentOrchestratorInternals.buildOpenPrMonitorActions(monitorRun, reviewablePack, [])).toEqual([]); + + const nonUrgentPack = decisionPackFixture({ + generatedAt, + openPrMonitor: { + ...approvedPack.openPrMonitor!, + cleanupFirst: false, + pullRequests: [ + { + repoFullName: "we-promise/sure", + number: 14, + title: "Draft work", + classification: "draft", + summary: "Still a draft.", + reasons: ["Not ready."], + nextSteps: ["Finish the change."], + }, + ], + }, + }); + expect(__agentOrchestratorInternals.buildOpenPrMonitorActions(monitorRun, nonUrgentPack, [])).toEqual([]); + + const snapshot = __agentOrchestratorInternals.contextSnapshotFromPack("run-1", decisionPackFixture({ + generatedAt, + freshness: "rebuilding", + snapshotAgeSeconds: 90, + dataQuality: { + signalFidelity: { + status: "degraded", + repoCount: 2, + completeRepos: 1, + degradedRepos: 1, + blockedRepos: 0, + partialRepos: ["owner/partial"], + cappedRepos: ["owner/capped"], + staleRepos: ["owner/stale"], + rateLimitedRepos: ["owner/rate"], + }, + }, + }), [readyDecision]); + expect(snapshot.freshnessWarnings).toEqual( + expect.arrayContaining([ + "decision pack is stale (age 90s); background rebuild enqueued", + "owner/partial: partial signal coverage", + "owner/capped: capped signal coverage", + "owner/stale: stale signal coverage", + "owner/rate: rate limited signal coverage", + ]), + ); + expect(snapshot.payload.openPrMonitor).toBeNull(); + + const staleSnapshot = __agentOrchestratorInternals.contextSnapshotFromPack("run-2", decisionPackFixture({ + generatedAt, + freshness: "stale", + openPrMonitor: approvedPack.openPrMonitor, + }), []); + expect(staleSnapshot.freshnessWarnings[0]).toBe("decision pack is stale; rebuild not enqueued"); + expect(staleSnapshot.payload.openPrMonitor).toEqual(approvedPack.openPrMonitor); }); it("covers local action ready and blocker-free branches from prepared metadata", () => { diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 6179dcb501..e093ae5fd3 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -84,6 +84,7 @@ describe("private-beta auth and rate limiting", () => { expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive"); expect(routeClassForPath("/v1/upstream/status")).toBe("expensive"); expect(routeClassForPath("/v1/contributors/jsonbored/decision-pack")).toBe("expensive"); + expect(routeClassForPath("/v1/contributors/jsonbored/open-pr-monitor")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/generate-signal-snapshots")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/build-contributor-decision-packs")).toBe("expensive"); expect(routeClassForPath("/v1/internal/jobs/refresh-upstream-drift")).toBe("expensive"); diff --git a/test/unit/contributor-open-pr-monitor.test.ts b/test/unit/contributor-open-pr-monitor.test.ts new file mode 100644 index 0000000000..9a1d86494e --- /dev/null +++ b/test/unit/contributor-open-pr-monitor.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it, vi } from "vitest"; +import * as repositories from "../../src/db/repositories"; +import { sanitizePublicComment } from "../../src/github/commands"; +import { + __contributorOpenPrMonitorInternals, + buildContributorOpenPrMonitor, + mapPendingClassToWorkClassification, +} from "../../src/signals/contributor-open-pr-monitor"; +import { classifyOpenPullRequest } from "../../src/scoring/pending-pr-scenarios"; +import type { PullRequestRecord, PullRequestReviewRecord } from "../../src/types"; +import type { RoleContext } from "../../src/signals/engine"; +import { createTestEnv } from "../helpers/d1"; + +const outsideContributorRole: RoleContext = { + login: "miner-a", + repoFullName: "entrius/allways-ui", + generatedAt: "2026-05-28T00:00:00.000Z", + role: "outside_contributor", + maintainerLane: false, + normalContributorEvidenceAllowed: true, + source: "cache", + association: "NONE", + reasons: [], + guidance: "contributor", +}; + +const maintainerRole: RoleContext = { + ...outsideContributorRole, + login: "repo-owner", + role: "owner", + maintainerLane: true, + normalContributorEvidenceAllowed: false, + source: "repo_owner_match", + guidance: "maintainer", +}; + +function pr(overrides: Partial & Pick): PullRequestRecord { + return { + repoFullName: "entrius/allways-ui", + title: `PR #${overrides.number}`, + state: "open", + authorLogin: "miner-a", + labels: [], + linkedIssues: [1], + createdAt: "2026-05-20T00:00:00.000Z", + updatedAt: "2026-05-27T00:00:00.000Z", + ...overrides, + }; +} + +function approvedReview(pullNumber: number): PullRequestReviewRecord { + return { + id: `review-${pullNumber}`, + repoFullName: "entrius/allways-ui", + pullNumber, + state: "APPROVED", + payload: {}, + }; +} + +describe("contributor open PR monitor", () => { + it("maps issue #36 classifications from cached review/check metadata", () => { + const approved = classifyOpenPullRequest({ + pr: pr({ number: 1 }), + roleContext: outsideContributorRole, + reviews: [approvedReview(1)], + checks: [], + }); + expect(mapPendingClassToWorkClassification(approved, { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false })).toBe("approved"); + + const failing = classifyOpenPullRequest({ + pr: pr({ number: 2 }), + roleContext: outsideContributorRole, + reviews: [approvedReview(2)], + checks: [{ id: "c1", repoFullName: "entrius/allways-ui", pullNumber: 2, name: "ci", status: "completed", conclusion: "failure", payload: {} }], + }); + expect(mapPendingClassToWorkClassification(failing, { changeRequestCount: 0, checkFailureCount: 1, duplicateProne: false, missingTests: false })).toBe("failing_checks"); + + const needsAuthor = classifyOpenPullRequest({ + pr: pr({ number: 3 }), + roleContext: outsideContributorRole, + reviews: [{ ...approvedReview(3), state: "CHANGES_REQUESTED" }], + checks: [], + }); + expect(mapPendingClassToWorkClassification(needsAuthor, { changeRequestCount: 1, checkFailureCount: 0, duplicateProne: false, missingTests: false })).toBe("needs_author"); + + const staleDate = new Date(Date.now() - 20 * 86_400_000).toISOString(); + const stale = classifyOpenPullRequest({ + pr: pr({ number: 4, updatedAt: staleDate, createdAt: staleDate }), + roleContext: outsideContributorRole, + reviews: [approvedReview(4)], + checks: [], + }); + expect(mapPendingClassToWorkClassification(stale, { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false })).toBe("should_close_or_withdraw"); + + expect( + mapPendingClassToWorkClassification( + classifyOpenPullRequest({ pr: pr({ number: 5, title: "fix overlap" }), roleContext: outsideContributorRole, reviews: [approvedReview(5)], checks: [] }), + { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: true, missingTests: false }, + ), + ).toBe("duplicate_prone"); + + expect( + mapPendingClassToWorkClassification( + classifyOpenPullRequest({ pr: pr({ number: 6 }), roleContext: outsideContributorRole, reviews: [approvedReview(6)], checks: [] }), + { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: true }, + ), + ).toBe("missing_tests"); + + expect( + mapPendingClassToWorkClassification( + classifyOpenPullRequest({ pr: pr({ number: 7, authorAssociation: "OWNER" }), roleContext: outsideContributorRole, reviews: [approvedReview(7)], checks: [] }), + { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false }, + ), + ).toBe("maintainer_lane"); + + expect( + mapPendingClassToWorkClassification( + classifyOpenPullRequest({ pr: pr({ number: 8 }), roleContext: maintainerRole, reviews: [approvedReview(8)], checks: [] }), + { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false }, + ), + ).toBe("maintainer_lane"); + + const draft = classifyOpenPullRequest({ + pr: pr({ number: 9, title: "Draft: wip feature" }), + roleContext: outsideContributorRole, + reviews: [], + checks: [], + }); + expect(mapPendingClassToWorkClassification(draft, { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false })).toBe("draft"); + + const blocked = classifyOpenPullRequest({ pr: pr({ number: 12 }), roleContext: outsideContributorRole, reviews: [], checks: [] }); + expect(mapPendingClassToWorkClassification(blocked, { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false })).toBe("blocked"); + + expect( + mapPendingClassToWorkClassification( + { repoFullName: "entrius/allways-ui", number: 13, title: "mystery", classification: "unknown" as never, reasons: [] }, + { changeRequestCount: 0, checkFailureCount: 0, duplicateProne: false, missingTests: false }, + ), + ).toBe("reviewable"); + }); + + it("builds contributor-wide monitor answer from registered repos only", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([ + { fullName: "entrius/allways-ui", owner: "entrius", name: "allways-ui", isInstalled: true, isRegistered: true, isPrivate: false }, + ] as Awaited>); + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([ + pr({ number: 10 }), + pr({ number: 11, repoFullName: "other/unregistered", authorLogin: "miner-a" }), + ]); + vi.spyOn(repositories, "listPullRequests").mockResolvedValue([pr({ number: 10 }), pr({ number: 11 })]); + vi.spyOn(repositories, "listPullRequestReviews").mockImplementation(async (_env, _repo, pullNumber) => + pullNumber === 10 ? [approvedReview(10)] : [{ ...approvedReview(11), state: "CHANGES_REQUESTED" }], + ); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([]); + vi.spyOn(repositories, "listPullRequestFiles").mockResolvedValue([ + { repoFullName: "entrius/allways-ui", pullNumber: 10, path: "src/a.ts", additions: 3, deletions: 0, changes: 3, status: "modified", payload: {} }, + { repoFullName: "entrius/allways-ui", pullNumber: 10, path: "src/a.test.ts", additions: 5, deletions: 0, changes: 5, status: "added", payload: {} }, + ]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + expect(monitor.openPrCount).toBe(1); + expect(monitor.pullRequests).toHaveLength(1); + expect(monitor.pullRequests[0]).toMatchObject({ number: 10, classification: "approved" }); + expect(monitor.pendingScenarios[0]?.detection.pendingMergedPrCount).toBe(1); + expect(monitor.summary).toContain("open PR"); + expect(monitor.guidance.length).toBeGreaterThan(0); + }); + + it("keeps public monitor output free of forbidden private language", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([ + { fullName: "entrius/allways-ui", owner: "entrius", name: "allways-ui", isInstalled: true, isRegistered: true, isPrivate: false }, + ] as Awaited>); + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([pr({ number: 20 })]); + vi.spyOn(repositories, "listPullRequests").mockResolvedValue([pr({ number: 20 })]); + vi.spyOn(repositories, "listPullRequestReviews").mockResolvedValue([approvedReview(20)]); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([]); + vi.spyOn(repositories, "listPullRequestFiles").mockResolvedValue([]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + const blob = JSON.stringify(monitor); + expect(blob).not.toMatch(/\b(wallet|hotkey|coldkey|payout|reward estimate|farming)\b/i); + expect(sanitizePublicComment(monitor.summary)).toBe(monitor.summary); + }); + + it("does not require source upload paths in next-step packets", () => { + const packet = __contributorOpenPrMonitorInternals.buildNextStepPacket( + classifyOpenPullRequest({ pr: pr({ number: 30 }), roleContext: outsideContributorRole, reviews: [], checks: [] }), + [], + [], + false, + false, + ); + expect(packet.nextSteps.join(" ")).not.toMatch(/\/Users\/|\/home\/|upload source/i); + }); + + it("flags duplicate-prone titles across open PRs in the same repo", () => { + const open = [pr({ number: 40, title: "fix parser bug" }), pr({ number: 41, title: "fix parser bug" })]; + const flagged = __contributorOpenPrMonitorInternals.duplicatePronePullNumbers(open); + expect(flagged.has(40)).toBe(true); + expect(flagged.has(41)).toBe(true); + expect(__contributorOpenPrMonitorInternals.duplicatePronePullNumbers([pr({ number: 42, labels: ["wip"] })]).has(42)).toBe(true); + }); + + it("covers monitor summaries, guidance, next steps, and file heuristics", () => { + const { nextStepsForClassification, summarizeMonitor, buildMonitorGuidance, missingTestsFromFiles, priorityRank } = + __contributorOpenPrMonitorInternals; + + expect(summarizeMonitor(0, 0, false)).toContain("No open pull requests"); + expect(summarizeMonitor(2, 1, true)).toContain("clean up existing work"); + expect(summarizeMonitor(2, 1, false)).toContain("merge-ready from cached metadata"); + + expect(buildMonitorGuidance([], false)).toEqual(["Queue looks manageable from cached metadata; still run preflight before new PRs."]); + expect(buildMonitorGuidance([{ classification: "approved" } as never], false)).toContain( + "Merge-ready PRs can improve pending-merge score projections after they land.", + ); + expect( + buildMonitorGuidance( + [ + { classification: "failing_checks" } as never, + { classification: "needs_author" } as never, + { classification: "duplicate_prone" } as never, + ], + true, + ), + ).toEqual( + expect.arrayContaining([ + "Prioritize existing open PRs before starting new issues or branches.", + "1 PR(s) need failing checks addressed first.", + "1 PR(s) need author follow-up on review comments.", + "1 PR(s) look duplicate-prone; consolidate before adding more queue load.", + ]), + ); + + for (const classification of [ + "reviewable", + "blocked", + "stale", + "draft", + "maintainer_lane", + "missing_tests", + "duplicate_prone", + "failing_checks", + "needs_author", + "approved", + "should_close_or_withdraw", + ] as const) { + expect(nextStepsForClassification(classification, "entrius/allways-ui", 99).length).toBeGreaterThan(0); + } + expect(priorityRank("approved")).toBeLessThan(priorityRank("unknown" as never)); + + expect(missingTestsFromFiles([])).toBe(false); + expect(missingTestsFromFiles([{ path: "pkg/foo_test.go" } as never])).toBe(false); + expect(missingTestsFromFiles([{ path: "pkg/foo.go" } as never])).toBe(true); + expect(missingTestsFromFiles([{ path: "tests/integration.spec.ts" } as never])).toBe(false); + }); + + it("returns an empty monitor when the contributor has no cached open PRs", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([]); + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + expect(monitor.openPrCount).toBe(0); + expect(monitor.pullRequests).toEqual([]); + expect(monitor.summary).toContain("No open pull requests"); + }); + + it("omits pending scenarios when nothing is merge-ready or stale-close", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([ + { fullName: "entrius/allways-ui", owner: "entrius", name: "allways-ui", isInstalled: true, isRegistered: true, isPrivate: false }, + ] as Awaited>); + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([pr({ number: 55 })]); + vi.spyOn(repositories, "listPullRequests").mockResolvedValue([pr({ number: 55 })]); + vi.spyOn(repositories, "listPullRequestReviews").mockResolvedValue([]); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([]); + vi.spyOn(repositories, "listPullRequestFiles").mockResolvedValue([]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + expect(monitor.pendingScenarios).toEqual([]); + expect(monitor.pullRequests[0]?.classification).toBe("blocked"); + }); + + it("classifies cancelled checks as failing work", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([ + { fullName: "entrius/allways-ui", owner: "entrius", name: "allways-ui", isInstalled: true, isRegistered: true, isPrivate: false }, + ] as Awaited>); + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([pr({ number: 56 })]); + vi.spyOn(repositories, "listPullRequests").mockResolvedValue([pr({ number: 56 })]); + vi.spyOn(repositories, "listPullRequestReviews").mockResolvedValue([approvedReview(56)]); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([ + { id: "c56", repoFullName: "entrius/allways-ui", pullNumber: 56, name: "ci", status: "completed", conclusion: "cancelled", payload: {} }, + ]); + vi.spyOn(repositories, "listPullRequestFiles").mockResolvedValue([ + { repoFullName: "entrius/allways-ui", pullNumber: 56, path: "src/x.ts", additions: 1, deletions: 0, changes: 1, payload: {} }, + { repoFullName: "entrius/allways-ui", pullNumber: 56, path: "src/x.test.ts", additions: 1, deletions: 0, changes: 1, payload: {} }, + ]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + expect(monitor.pullRequests[0]?.classification).toBe("failing_checks"); + }); + + it("aggregates open PRs across multiple registered repos", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([ + { fullName: "entrius/allways-ui", owner: "entrius", name: "allways-ui", isInstalled: true, isRegistered: true, isPrivate: false }, + { fullName: "other/registered", owner: "other", name: "registered", isInstalled: true, isRegistered: true, isPrivate: false }, + ] as Awaited>); + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([ + pr({ number: 57, repoFullName: "entrius/allways-ui" }), + pr({ number: 58, repoFullName: "other/registered" }), + ]); + vi.spyOn(repositories, "listPullRequests").mockImplementation(async (_env, repo) => + repo === "entrius/allways-ui" ? [pr({ number: 57 })] : [pr({ number: 58, repoFullName: "other/registered" })], + ); + vi.spyOn(repositories, "listPullRequestReviews").mockResolvedValue([]); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([]); + vi.spyOn(repositories, "listPullRequestFiles").mockResolvedValue([]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + expect(monitor.openPrCount).toBe(2); + expect(monitor.pendingScenarios).toHaveLength(0); + }); + + it("sorts cleanup-first PRs ahead of merge-ready work", async () => { + const env = createTestEnv(); + vi.spyOn(repositories, "listRepositories").mockResolvedValue([ + { fullName: "entrius/allways-ui", owner: "entrius", name: "allways-ui", isInstalled: true, isRegistered: true, isPrivate: false }, + ] as Awaited>); + vi.spyOn(repositories, "listContributorPullRequests").mockResolvedValue([pr({ number: 50 }), pr({ number: 51 })]); + vi.spyOn(repositories, "listPullRequests").mockResolvedValue([pr({ number: 50 }), pr({ number: 51 })]); + vi.spyOn(repositories, "listPullRequestReviews").mockImplementation(async (_env, _repo, pullNumber) => + pullNumber === 50 + ? [{ ...approvedReview(50), state: "CHANGES_REQUESTED" }] + : [approvedReview(51)], + ); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([]); + vi.spyOn(repositories, "listPullRequestFiles").mockResolvedValue([ + { repoFullName: "entrius/allways-ui", pullNumber: 50, path: "src/a.ts", additions: 1, deletions: 0, changes: 1, payload: {} }, + { repoFullName: "entrius/allways-ui", pullNumber: 50, path: "src/a.test.ts", additions: 1, deletions: 0, changes: 1, payload: {} }, + { repoFullName: "entrius/allways-ui", pullNumber: 51, path: "src/b.ts", additions: 1, deletions: 0, changes: 1, payload: {} }, + { repoFullName: "entrius/allways-ui", pullNumber: 51, path: "src/b.test.ts", additions: 1, deletions: 0, changes: 1, payload: {} }, + ]); + + const monitor = await buildContributorOpenPrMonitor(env, "miner-a"); + expect(monitor.cleanupFirst).toBe(true); + expect(monitor.pullRequests[0]?.classification).toBe("needs_author"); + expect(monitor.pullRequests[1]?.classification).toBe("approved"); + }); +}); diff --git a/test/unit/db-parsers.test.ts b/test/unit/db-parsers.test.ts index 93e0096f0f..4fe155a4ca 100644 --- a/test/unit/db-parsers.test.ts +++ b/test/unit/db-parsers.test.ts @@ -13,6 +13,12 @@ import { import { createTestEnv } from "../helpers/d1"; describe("database row parser hardening", () => { + it("returns empty arrays from D1 raw() when a select has no rows", async () => { + const env = createTestEnv(); + const rows = await env.DB.prepare("select id from installations where 1 = 0").raw(); + expect(rows).toEqual([]); + }); + it("preserves cached pull request review and mergeability scenario fields", async () => { const env = createTestEnv(); diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index 93ca722594..793dc39fe7 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -573,6 +573,7 @@ describe("decision-pack service", () => { scoringModelSnapshotId: "scoring-1", contributorPullRequests: [{ repoFullName: "owner/cleanup", authorLogin: "jsonbored", authorAssociation: "CONTRIBUTOR" }] as any, contributorIssues: [], + openPrMonitor: emptyOpenPrMonitor("jsonbored"), }); expect(pack.repoDecisions).toHaveLength(6); @@ -586,6 +587,67 @@ describe("decision-pack service", () => { expect(pack.nextActions.length).toBeGreaterThan(0); }); + it("merges open PR monitor guidance into pack summary and next actions", () => { + const monitor = { + login: "jsonbored", + generatedAt: "2026-05-25T00:00:00.000Z", + openPrCount: 1, + registeredRepoCount: 1, + cleanupFirst: true, + summary: "One open PR needs attention on owner/cleanup.", + guidance: ["Close or land owner/cleanup#42 before opening new direct PR work."], + pendingScenarios: [], + pullRequests: [ + { + repoFullName: "owner/cleanup", + number: 42, + title: "WIP cleanup", + classification: "needs_author" as const, + summary: "Changes requested.", + reasons: ["Reviewer asked for tests."], + nextSteps: ["Add tests and push updates."], + }, + ], + }; + const pack = __decisionPackInternals.buildContributorDecisionPack({ + login: "jsonbored", + profile: { + login: "jsonbored", + generatedAt: "2026-05-25T00:00:00.000Z", + github: {}, + source: {}, + gittensor: null, + registeredRepoActivity: { reposTouched: ["owner/cleanup"] }, + trustSignals: {}, + } as any, + outcomeHistory: { + login: "jsonbored", + generatedAt: "2026-05-25T00:00:00.000Z", + source: {}, + totals: {}, + repoOutcomes: [ + { repoFullName: "owner/cleanup", role: "outside_contributor", lane: "direct_pr", maintainerLane: false, openPullRequests: 6, closedPullRequestRate: 0.4, credibility: 0.5, mergedPullRequests: 1, closedPullRequests: 2, validSolvedIssues: 0 }, + ], + successPatterns: [], + failurePatterns: [], + summary: "fixture", + } as any, + repositories: [repo("owner/cleanup", 0.03, 0)], + syncStates: [ + { repoFullName: "owner/cleanup", status: "complete", openPullRequestsCount: 30, openIssuesCount: 150, recentMergedPullRequestsCount: 5, warnings: [], lastCompletedAt: "2026-05-25T00:00:00.000Z" }, + ] as any, + syncSegments: [], + totals: [], + scoringModelSnapshotId: "scoring-1", + contributorPullRequests: [{ repoFullName: "owner/cleanup", authorLogin: "jsonbored", authorAssociation: "CONTRIBUTOR" }] as any, + contributorIssues: [], + openPrMonitor: monitor, + }); + expect(pack.summary).toContain("One open PR needs attention"); + expect(pack.nextActions[0]).toMatch(/owner\/cleanup#42/); + expect(pack.openPrMonitor).toEqual(monitor); + }); + it("issues repo-specific direct-PR reasoning that names language and label fit", () => { const decision = __decisionPackInternals.buildRepoDecision({ repo: repoWithLabels("owner/direct", 0.04, 0, { bug: 1.2, "good-first-issue": 1.1, perf: 1 }), @@ -820,6 +882,7 @@ describe("decision-pack service", () => { scoringModelSnapshotId: "scoring-1", contributorPullRequests: [], contributorIssues: [], + openPrMonitor: emptyOpenPrMonitor("jsonbored"), }); const tsDecision = pack.repoDecisions.find((d) => d.repoFullName === "owner/ts")!; expect(tsDecision.languageMatch).toEqual({ language: "TypeScript", match: true }); @@ -982,6 +1045,7 @@ describe("decision-pack service", () => { scoringModelSnapshotId: "scoring-1", contributorPullRequests: [], contributorIssues: [], + openPrMonitor: emptyOpenPrMonitor("jsonbored"), }); const packA = __decisionPackInternals.buildContributorDecisionPack(fixedArgs()); const packB = __decisionPackInternals.buildContributorDecisionPack(fixedArgs()); @@ -992,6 +1056,20 @@ describe("decision-pack service", () => { }); }); +function emptyOpenPrMonitor(login: string) { + return { + login, + generatedAt: "2026-05-25T00:00:00.000Z", + openPrCount: 0, + registeredRepoCount: 0, + cleanupFirst: false, + summary: "No open PRs on registered repos.", + guidance: [], + pendingScenarios: [], + pullRequests: [], + }; +} + function noStructuralCountLeak(lines: string[]): boolean { const joined = lines.join(" | "); if (/\b(openPullRequests?|openIssues?|mergedPullRequests?|closedPullRequests?|priorityScore)\b/.test(joined)) return false; diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index 633b0cde77..030caa0baf 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -1,9 +1,23 @@ import { describe, expect, it, vi } from "vitest"; +import { authenticatePrivateToken, createSessionForGitHubUser } from "../../src/auth/security"; import { persistUpstreamRulesetSnapshot, upsertUpstreamDriftReport } from "../../src/db/repositories"; import { GittensoryMcp } from "../../src/mcp/server"; import type { UpstreamDriftReportRecord, UpstreamRulesetSnapshotRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; +describe("MCP contributor access", () => { + it("blocks session actors from another contributor open-pr monitor", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "attacker" }); + const { token } = await createSessionForGitHubUser(env, { login: "attacker", id: 7 }); + const identity = await authenticatePrivateToken(env, token); + if (!identity || identity.kind !== "session") throw new Error("expected session identity"); + const mcp = new GittensoryMcp(env, identity); + await expect((mcp as unknown as { monitorOpenPullRequests(login: string): Promise }).monitorOpenPullRequests("victim")).rejects.toThrow( + /Forbidden: session can only access the authenticated GitHub login/, + ); + }); +}); + describe("MCP upstream drift tool", () => { it("summarizes current, drifted, stale, and unavailable upstream states", async () => { const currentEnv = createTestEnv(); diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index 7d64f30678..d92699efe2 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -18,6 +18,7 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/reviewability"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/profile"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/decision-pack"]).toBeDefined(); + expect(spec.paths["/v1/contributors/{login}/open-pr-monitor"]).toBeDefined(); expect(spec.paths["/v1/contributors/{login}/repos/{owner}/{repo}/decision"]).toBeDefined(); expect(spec.paths["/v1/preflight/pr"]).toBeDefined(); expect(spec.paths["/v1/preflight/local-diff"]).toBeDefined(); diff --git a/test/unit/pending-pr-scenarios.test.ts b/test/unit/pending-pr-scenarios.test.ts new file mode 100644 index 0000000000..0b74ad1ac9 --- /dev/null +++ b/test/unit/pending-pr-scenarios.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it, vi } from "vitest"; +import * as repositories from "../../src/db/repositories"; +import { sanitizePublicComment } from "../../src/github/commands"; +import { + applyPendingPrDetectionToScoreInput, + classifyOpenPullRequest, + detectPendingPrScenario, + loadContributorRepoOpenPrSignalRecords, +} from "../../src/scoring/pending-pr-scenarios"; +import { buildScorePreview } from "../../src/scoring/preview"; +import type { PullRequestRecord, PullRequestReviewRecord, ScoringModelSnapshotRecord } from "../../src/types"; +import type { RoleContext } from "../../src/signals/engine"; + +const outsideContributorRole: RoleContext = { + login: "miner-a", + repoFullName: "entrius/allways-ui", + generatedAt: "2026-05-28T00:00:00.000Z", + role: "outside_contributor", + maintainerLane: false, + normalContributorEvidenceAllowed: true, + source: "cache", + association: "NONE", + reasons: [], + guidance: "contributor", +}; + +const maintainerRole: RoleContext = { + ...outsideContributorRole, + login: "repo-owner", + role: "owner", + maintainerLane: true, + normalContributorEvidenceAllowed: false, + source: "repo_owner_match", + guidance: "maintainer", +}; + +function pr(overrides: Partial & Pick): PullRequestRecord { + return { + repoFullName: "entrius/allways-ui", + title: `PR #${overrides.number}`, + state: "open", + authorLogin: "miner-a", + labels: [], + linkedIssues: [1], + createdAt: "2026-05-20T00:00:00.000Z", + updatedAt: "2026-05-27T00:00:00.000Z", + ...overrides, + }; +} + +function approvedReview(pullNumber: number): PullRequestReviewRecord { + return { + id: `review-${pullNumber}`, + repoFullName: "entrius/allways-ui", + pullNumber, + state: "APPROVED", + payload: {}, + }; +} + +describe("pending PR scenario detection", () => { + it("treats approved-but-unmerged PRs as merge-ready pending work", () => { + const detection = detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 11 }), pr({ number: 12, title: "blocked work" })], + roleContext: outsideContributorRole, + openPrCount: 3, + reviewsByPullNumber: new Map([ + [11, [approvedReview(11)]], + [12, [{ ...approvedReview(12), state: "CHANGES_REQUESTED" }]], + ]), + checksByPullNumber: new Map([ + [11, []], + [12, []], + ]), + }); + expect(detection).toMatchObject({ + source: "github_observed", + pendingMergedPrCount: 1, + pendingClosedPrCount: 0, + expectedOpenPrCountAfterMerge: 2, + }); + expect(detection?.classified.find((entry) => entry.number === 12)?.classification).toBe("blocked"); + }); + + it("does not treat draft, stale, or maintainer-lane PRs as likely-to-land", () => { + const staleDate = new Date(Date.now() - 20 * 86_400_000).toISOString(); + const classified = [ + classifyOpenPullRequest({ + pr: pr({ number: 1, title: "Draft: experiment", labels: ["draft"] }), + roleContext: outsideContributorRole, + reviews: [approvedReview(1)], + checks: [], + }), + classifyOpenPullRequest({ + pr: pr({ number: 2, updatedAt: staleDate, createdAt: staleDate }), + roleContext: outsideContributorRole, + reviews: [approvedReview(2)], + checks: [], + }), + classifyOpenPullRequest({ + pr: pr({ number: 3, authorAssociation: "MEMBER" }), + roleContext: outsideContributorRole, + reviews: [approvedReview(3)], + checks: [], + }), + classifyOpenPullRequest({ + pr: pr({ number: 4 }), + roleContext: maintainerRole, + reviews: [approvedReview(4)], + checks: [], + }), + ]; + expect(classified.map((entry) => entry.classification)).toEqual(["draft", "stale_likely_close", "maintainer_lane", "maintainer_lane"]); + }); + + it("labels user-supplied assumptions separately from GitHub-observed state", () => { + const user = detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 9 })], + roleContext: outsideContributorRole, + userSupplied: { pendingMergedPrCount: 2, scenarioNotes: ["manual assumption"] }, + }); + expect(user?.source).toBe("user_supplied"); + + const observed = detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 10 })], + roleContext: outsideContributorRole, + reviewsByPullNumber: new Map([[10, [approvedReview(10)]]]), + checksByPullNumber: new Map([[10, []]]), + }); + expect(observed?.source).toBe("github_observed"); + expect(observed?.scenarioNotes[0]).toMatch(/GitHub-observed/i); + }); + + it("keeps effective score distinct from underlying potential in observed after-pending scenario", () => { + const snapshot: ScoringModelSnapshotRecord = { + id: "score-model-fixture", + sourceKind: "test", + sourceUrl: "fixture://constants.py", + fetchedAt: "2026-05-23T00:00:00.000Z", + activeModel: "current_density_model", + constants: { + OSS_EMISSION_SHARE: 0.9, + MERGED_PR_BASE_SCORE: 25, + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + MAX_CONTRIBUTION_BONUS: 25, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + MIN_CREDIBILITY: 0.8, + REVIEW_PENALTY_RATE: 0.15, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + SRC_TOK_SATURATION_SCALE: 58, + }, + programmingLanguages: {}, + registrySnapshotId: "registry-fixture", + warnings: [], + payload: {}, + }; + const preview = buildScorePreview({ + repo: { + fullName: "entrius/allways-ui", + owner: "entrius", + name: "allways-ui", + isInstalled: false, + isRegistered: true, + isPrivate: false, + registryConfig: { repo: "entrius/allways-ui", emissionShare: 0.02, issueDiscoveryShare: 0.25, labelMultipliers: {}, maintainerCut: 0, raw: {} }, + }, + snapshot, + input: { + repoFullName: "entrius/allways-ui", + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 3, + credibility: 1, + pendingMergedPrCount: 1, + pendingScenarioObserved: true, + }, + }); + const afterPending = preview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"); + expect(preview.effectiveEstimatedScore).toBe(0); + expect(preview.underlyingPotentialScore).toBeGreaterThan(0); + expect(afterPending?.source).toBe("github_observed"); + expect(afterPending?.effectiveEstimatedScore).toBeGreaterThan(0); + }); + + it("sanitizes public comment text that mentions score, reward, wallet, or hotkey language", () => { + const dirty = + "Estimated score 42, reward estimate, wallet abc, hotkey xyz, payout farming, reviewability ranking, raw trust score."; + expect(sanitizePublicComment(dirty)).not.toMatch(/estimated score|reward estimate|wallet|hotkey|payout|farming|reviewability|raw trust score/i); + expect(sanitizePublicComment(dirty)).toContain("private context"); + }); + + it("returns null when there is nothing to project and handles closed-only queues", () => { + expect( + detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 1, state: "closed" })], + roleContext: outsideContributorRole, + }), + ).toBeNull(); + + expect( + detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 2, title: "Draft only", labels: ["draft"] })], + roleContext: outsideContributorRole, + reviewsByPullNumber: new Map([[2, [approvedReview(2)]]]), + checksByPullNumber: new Map([[2, []]]), + }), + ).toBeNull(); + }); + + it("projects stale-close pressure without merge-ready PRs", () => { + const staleDate = new Date(Date.now() - 20 * 86_400_000).toISOString(); + const detection = detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 30, updatedAt: staleDate, createdAt: staleDate })], + roleContext: outsideContributorRole, + reviewsByPullNumber: new Map([[30, [approvedReview(30)]]]), + checksByPullNumber: new Map([[30, []]]), + }); + expect(detection).toMatchObject({ pendingMergedPrCount: 0, pendingClosedPrCount: 1, expectedOpenPrCountAfterMerge: 0 }); + expect(detection?.scenarioNotes.join(" ")).toMatch(/stale/i); + }); + + it("classifies blocked PRs from failing checks and missing approvals", () => { + const blockedByChecks = classifyOpenPullRequest({ + pr: pr({ number: 40 }), + roleContext: outsideContributorRole, + reviews: [approvedReview(40)], + checks: [{ id: "c1", repoFullName: "entrius/allways-ui", pullNumber: 40, name: "ci", status: "completed", conclusion: "timed_out", payload: {} }], + }); + const blockedByCancelled = classifyOpenPullRequest({ + pr: pr({ number: 42 }), + roleContext: outsideContributorRole, + reviews: [approvedReview(42)], + checks: [{ id: "c2", repoFullName: "entrius/allways-ui", pullNumber: 42, name: "ci", status: "completed", conclusion: "cancelled", payload: {} }], + }); + const blockedByApproval = classifyOpenPullRequest({ + pr: pr({ number: 41 }), + roleContext: outsideContributorRole, + reviews: [], + checks: [], + }); + const withOverlapFlags = classifyOpenPullRequest({ + pr: pr({ number: 43 }), + roleContext: outsideContributorRole, + reviews: [], + checks: [], + duplicateProne: true, + missingTests: true, + }); + expect(blockedByChecks.classification).toBe("blocked"); + expect(blockedByCancelled.classification).toBe("blocked"); + expect(blockedByApproval.classification).toBe("blocked"); + expect(withOverlapFlags.reasons.join(" ")).toMatch(/duplicate|test files/i); + }); + + it("recognizes draft heuristics and excludes pull numbers from detection", () => { + expect( + classifyOpenPullRequest({ + pr: pr({ number: 50, title: "[Draft] spike", labels: [] }), + roleContext: outsideContributorRole, + reviews: [], + checks: [], + }).classification, + ).toBe("draft"); + expect( + classifyOpenPullRequest({ + pr: pr({ number: 51, title: "WIP change", labels: ["wip"] }), + roleContext: outsideContributorRole, + reviews: [], + checks: [], + }).classification, + ).toBe("draft"); + + const detection = detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 52 }), pr({ number: 53 })], + roleContext: outsideContributorRole, + excludePullNumbers: [52], + reviewsByPullNumber: new Map([ + [52, [approvedReview(52)]], + [53, [approvedReview(53)]], + ]), + checksByPullNumber: new Map([ + [52, []], + [53, []], + ]), + }); + expect(detection?.pendingMergedPrCount).toBe(1); + expect(detection?.classified.some((entry) => entry.number === 52)).toBe(false); + }); + + it("preserves user-supplied expected open PR counts and skips observed score input merges", () => { + const user = detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [], + roleContext: outsideContributorRole, + userSupplied: { expectedOpenPrCountAfterMerge: 1, approvedPrCount: 2 }, + }); + expect(user).toMatchObject({ source: "user_supplied", expectedOpenPrCountAfterMerge: 1, approvedPrCount: 2 }); + + const base = { repoFullName: "entrius/allways-ui", openPrCount: 3 }; + expect(applyPendingPrDetectionToScoreInput(base, null)).toBe(base); + expect( + applyPendingPrDetectionToScoreInput(base, { + source: "user_supplied", + pendingMergedPrCount: 1, + pendingClosedPrCount: 0, + approvedPrCount: 0, + scenarioNotes: [], + classified: [], + }), + ).toBe(base); + expect( + applyPendingPrDetectionToScoreInput(base, { + source: "github_observed", + pendingMergedPrCount: 1, + pendingClosedPrCount: 0, + approvedPrCount: 0, + expectedOpenPrCountAfterMerge: 2, + scenarioNotes: ["observed"], + classified: [], + }), + ).toMatchObject({ pendingMergedPrCount: 1, pendingScenarioObserved: true, scenarioNotes: ["observed"] }); + }); + + it("loads cached reviews and checks for contributor open PRs", async () => { + const env = {} as Env; + vi.spyOn(repositories, "listPullRequestReviews").mockResolvedValue([approvedReview(70)]); + vi.spyOn(repositories, "listCheckSummaries").mockResolvedValue([]); + const records = await loadContributorRepoOpenPrSignalRecords(env, "entrius/allways-ui", "miner-a", [ + pr({ number: 70 }), + pr({ number: 71, authorLogin: "other-user" }), + pr({ number: 72, state: "closed" }), + ]); + expect(records.pullRequestReviews).toHaveLength(1); + expect(records.pullRequestChecks).toHaveLength(0); + vi.restoreAllMocks(); + }); +});