diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index d5bbd3202c..cf0351aaee 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -3153,6 +3153,83 @@ "pendingSaturationScore" ] }, + "linkedIssueMultiplier": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "standard", + "maintainer" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_required", + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "none", + "user_supplied", + "official_mirror", + "github_cache", + "issue_quality", + "missing" + ] + }, + "eligible": { + "type": "boolean" + }, + "issueNumbers": { + "type": "array", + "items": { + "type": "number" + } + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseMultiplier": { + "type": "number" + }, + "appliedMultiplier": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode", + "status", + "source", + "eligible", + "issueNumbers", + "solvedByPullRequests", + "baseMultiplier", + "appliedMultiplier", + "reason", + "warnings" + ] + }, "gates": { "type": "object", "properties": { @@ -3204,7 +3281,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -3387,7 +3466,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -3409,6 +3490,83 @@ ] } }, + "linkedIssueMultiplier": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "standard", + "maintainer" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_required", + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "none", + "user_supplied", + "official_mirror", + "github_cache", + "issue_quality", + "missing" + ] + }, + "eligible": { + "type": "boolean" + }, + "issueNumbers": { + "type": "array", + "items": { + "type": "number" + } + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseMultiplier": { + "type": "number" + }, + "appliedMultiplier": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode", + "status", + "source", + "eligible", + "issueNumbers", + "solvedByPullRequests", + "baseMultiplier", + "appliedMultiplier", + "reason", + "warnings" + ] + }, "deltaExplanation": { "type": "string" } @@ -3422,6 +3580,7 @@ "effectiveEstimatedScore", "underlyingPotentialScore", "blockedBy", + "linkedIssueMultiplier", "deltaExplanation" ] } @@ -3480,6 +3639,7 @@ "privateOnly", "laneMath", "scoreEstimate", + "linkedIssueMultiplier", "gates", "effectiveEstimatedScore", "underlyingPotentialScore", @@ -4234,7 +4394,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -4256,6 +4418,83 @@ ] } }, + "linkedIssueMultiplier": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "standard", + "maintainer" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_required", + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "none", + "user_supplied", + "official_mirror", + "github_cache", + "issue_quality", + "missing" + ] + }, + "eligible": { + "type": "boolean" + }, + "issueNumbers": { + "type": "array", + "items": { + "type": "number" + } + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseMultiplier": { + "type": "number" + }, + "appliedMultiplier": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode", + "status", + "source", + "eligible", + "issueNumbers", + "solvedByPullRequests", + "baseMultiplier", + "appliedMultiplier", + "reason", + "warnings" + ] + }, "deltaExplanation": { "type": "string" } @@ -4269,6 +4508,7 @@ "effectiveEstimatedScore", "underlyingPotentialScore", "blockedBy", + "linkedIssueMultiplier", "deltaExplanation" ] }, @@ -4400,7 +4640,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -4422,6 +4664,83 @@ ] } }, + "linkedIssueMultiplier": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "standard", + "maintainer" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_required", + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "none", + "user_supplied", + "official_mirror", + "github_cache", + "issue_quality", + "missing" + ] + }, + "eligible": { + "type": "boolean" + }, + "issueNumbers": { + "type": "array", + "items": { + "type": "number" + } + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseMultiplier": { + "type": "number" + }, + "appliedMultiplier": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode", + "status", + "source", + "eligible", + "issueNumbers", + "solvedByPullRequests", + "baseMultiplier", + "appliedMultiplier", + "reason", + "warnings" + ] + }, "deltaExplanation": { "type": "string" } @@ -4435,6 +4754,7 @@ "effectiveEstimatedScore", "underlyingPotentialScore", "blockedBy", + "linkedIssueMultiplier", "deltaExplanation" ] }, @@ -4566,7 +4886,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -4588,6 +4910,83 @@ ] } }, + "linkedIssueMultiplier": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "standard", + "maintainer" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_required", + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "none", + "user_supplied", + "official_mirror", + "github_cache", + "issue_quality", + "missing" + ] + }, + "eligible": { + "type": "boolean" + }, + "issueNumbers": { + "type": "array", + "items": { + "type": "number" + } + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseMultiplier": { + "type": "number" + }, + "appliedMultiplier": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode", + "status", + "source", + "eligible", + "issueNumbers", + "solvedByPullRequests", + "baseMultiplier", + "appliedMultiplier", + "reason", + "warnings" + ] + }, "deltaExplanation": { "type": "string" } @@ -4601,6 +5000,7 @@ "effectiveEstimatedScore", "underlyingPotentialScore", "blockedBy", + "linkedIssueMultiplier", "deltaExplanation" ] }, @@ -4732,7 +5132,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -4754,6 +5156,83 @@ ] } }, + "linkedIssueMultiplier": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "standard", + "maintainer" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_required", + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "none", + "user_supplied", + "official_mirror", + "github_cache", + "issue_quality", + "missing" + ] + }, + "eligible": { + "type": "boolean" + }, + "issueNumbers": { + "type": "array", + "items": { + "type": "number" + } + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseMultiplier": { + "type": "number" + }, + "appliedMultiplier": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode", + "status", + "source", + "eligible", + "issueNumbers", + "solvedByPullRequests", + "baseMultiplier", + "appliedMultiplier", + "reason", + "warnings" + ] + }, "deltaExplanation": { "type": "string" } @@ -4767,6 +5246,7 @@ "effectiveEstimatedScore", "underlyingPotentialScore", "blockedBy", + "linkedIssueMultiplier", "deltaExplanation" ] }, @@ -4898,7 +5378,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -4920,6 +5402,83 @@ ] } }, + "linkedIssueMultiplier": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "none", + "standard", + "maintainer" + ] + }, + "status": { + "type": "string", + "enum": [ + "not_required", + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "none", + "user_supplied", + "official_mirror", + "github_cache", + "issue_quality", + "missing" + ] + }, + "eligible": { + "type": "boolean" + }, + "issueNumbers": { + "type": "array", + "items": { + "type": "number" + } + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "baseMultiplier": { + "type": "number" + }, + "appliedMultiplier": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "mode", + "status", + "source", + "eligible", + "issueNumbers", + "solvedByPullRequests", + "baseMultiplier", + "appliedMultiplier", + "reason", + "warnings" + ] + }, "deltaExplanation": { "type": "string" } @@ -4933,6 +5492,7 @@ "effectiveEstimatedScore", "underlyingPotentialScore", "blockedBy", + "linkedIssueMultiplier", "deltaExplanation" ] }, @@ -4981,7 +5541,9 @@ "open_pr_threshold", "credibility_floor", "review_penalty", - "metadata_only" + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated" ] }, "severity": { @@ -8298,6 +8860,51 @@ "invalid" ] }, + "linkage": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "raw", + "plausible", + "validated", + "invalid", + "unavailable" + ] + }, + "source": { + "type": "string", + "enum": [ + "official_mirror", + "github_cache", + "missing" + ] + }, + "solvedByPullRequests": { + "type": "array", + "items": { + "type": "number" + } + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "status", + "source", + "solvedByPullRequests", + "reason", + "warnings" + ] + }, "status": { "type": "string", "enum": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 4a9e218307..1973835772 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -200,6 +200,17 @@ const localBranchScorerSchema = z }) .strict(); +const linkedIssueContextSchema = z + .object({ + status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]).optional(), + source: z.enum(["user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]).optional(), + issueNumbers: z.array(z.number().int().positive()).max(50).optional(), + solvedByPullRequests: z.array(z.number().int().positive()).max(50).optional(), + reason: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + warnings: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), + }) + .strict(); + const localBranchAnalysisSchema = z .object({ login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), @@ -237,6 +248,7 @@ const scorePreviewSchema = z.object({ contributorLogin: z.string().min(1).optional(), labels: z.array(z.string()).optional(), linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), + linkedIssueContext: linkedIssueContextSchema.optional(), sourceTokenScore: z.number().min(0).optional(), totalTokenScore: z.number().min(0).optional(), sourceLines: z.number().min(0).optional(), @@ -1246,6 +1258,7 @@ export function createApp() { scoringSnapshot: snapshot, scoringProfile, issueQuality: issueQuality?.report, + gittensorSnapshot: context.gittensorSnapshot, }); const response = { ...analysis, dataQuality: await loadRepoDataQuality(c.env, parsed.data.repoFullName) }; await persistSignal(c.env, "local-branch-analysis", `${parsed.data.login}:${parsed.data.repoFullName}:${parsed.data.branchName ?? parsed.data.headRef ?? "local"}`, parsed.data.repoFullName, response as unknown as Record, analysis.generatedAt); diff --git a/src/db/repositories.ts b/src/db/repositories.ts index e6c4afe2dc..1675847de0 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1037,6 +1037,8 @@ function toCacheableGittensorSnapshot(snapshot: Partial; + issueMirrorAvailable?: boolean | undefined; + issues?: Array<{ + repoFullName: string; + number: number; + state: string; + solvedByPullRequest?: number | null | undefined; + labels: string[]; + }> | undefined; issueLabels: string[]; }; @@ -156,6 +164,7 @@ export async function fetchGittensorContributorSnapshot(login: string): Promise< const detection = await fetchOfficialGittensorMiner(login); return detection.status === "confirmed" ? detection.snapshot : null; } catch { + /* v8 ignore next -- fetchOfficialGittensorMiner converts network failures into an unavailable status; this is a last-resort guard. */ return null; } } @@ -236,6 +245,8 @@ async function buildGittensorContributorSnapshot(miner: ConfirmedGittensorMinerS }, repositories: (detail.repositories ?? []).map(toRepositoryEvaluation).filter((repo) => repo.pullRequests + repo.openIssues + repo.closedIssues > 0), pullRequests: pullRequests.map(toPullRequest).filter((pr) => pr.repoFullName && pr.number > 0), + issueMirrorAvailable: issuesResult.status === "fulfilled", + issues: issues.map(toIssue).filter((issue) => issue.repoFullName && issue.number > 0), issueLabels: issues.flatMap((issue) => (issue.labels ?? []).flatMap((label) => (label.name ? [label.name] : []))), }; } @@ -285,6 +296,16 @@ function toPullRequest(pr: GittensorPullRequestResponse): GittensorContributorSn }; } +function toIssue(issue: NonNullable[number]): NonNullable[number] { + return { + repoFullName: issue.repo_full_name ?? "", + number: asNumber(issue.issue_number), + state: issue.state ?? "UNKNOWN", + solvedByPullRequest: issue.solved_by_pr ?? null, + labels: (issue.labels ?? []).flatMap((label) => (label.name ? [label.name] : [])), + }; +} + function asNumber(value: unknown, fallback = 0): number { if (typeof value === "number") return Number.isFinite(value) ? value : fallback; if (typeof value === "string") { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b0ca77fcdd..c3b3474ba0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -197,6 +197,15 @@ const agentPlanShape = { repoFullName: z.string().min(3).optional(), }; +const linkedIssueContextShape = { + status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]).optional(), + source: z.enum(["user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]).optional(), + issueNumbers: z.array(z.number().int().positive()).max(50).optional(), + solvedByPullRequests: z.array(z.number().int().positive()).max(50).optional(), + reason: z.string().optional(), + warnings: z.array(z.string()).max(20).optional(), +}; + const scorePreviewShape = { repoFullName: z.string().min(3), targetType: z.enum(["planned_pr", "pull_request", "local_diff", "variant"]).default("local_diff"), @@ -204,6 +213,7 @@ const scorePreviewShape = { contributorLogin: z.string().min(1).optional(), labels: z.array(z.string()).optional(), linkedIssueMode: z.enum(["none", "standard", "maintainer"]).default("none"), + linkedIssueContext: z.object(linkedIssueContextShape).strict().optional(), sourceTokenScore: z.number().min(0).optional(), totalTokenScore: z.number().min(0).optional(), sourceLines: z.number().min(0).optional(), @@ -901,6 +911,7 @@ export class GittensoryMcp { scoringSnapshot: snapshot, scoringProfile, issueQuality: issueQuality?.report, + gittensorSnapshot: context.gittensorSnapshot, }), dataQuality: await this.loadRepoDataQuality(input.repoFullName), }; @@ -952,6 +963,7 @@ export class GittensoryMcp { repositories, syncStates, repoStats, + gittensorSnapshot, outcomeHistory, }; } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 92944dcd29..0e1ab151cf 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -952,7 +952,17 @@ const ScoreGatesSchema = z.object({ }); const ScoreGateBlockerSchema = z.object({ - code: z.enum(["repo_not_registered", "inactive_allocation", "base_token_gate", "open_pr_threshold", "credibility_floor", "review_penalty", "metadata_only"]), + code: z.enum([ + "repo_not_registered", + "inactive_allocation", + "base_token_gate", + "open_pr_threshold", + "credibility_floor", + "review_penalty", + "metadata_only", + "linked_issue_invalid", + "linked_issue_unvalidated", + ]), severity: z.enum(["blocker", "reducer", "context"]), detail: z.string(), }); @@ -964,6 +974,19 @@ const ScoreGateDeltaSchema = z.object({ explanation: z.string(), }); +const LinkedIssueMultiplierDecisionSchema = z.object({ + mode: z.enum(["none", "standard", "maintainer"]), + status: z.enum(["not_required", "raw", "plausible", "validated", "invalid", "unavailable"]), + source: z.enum(["none", "user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]), + eligible: z.boolean(), + issueNumbers: z.array(z.number()), + solvedByPullRequests: z.array(z.number()), + baseMultiplier: z.number(), + appliedMultiplier: z.number(), + reason: z.string(), + warnings: z.array(z.string()), +}); + const ScoreScenarioPreviewSchema = z.object({ name: z.enum(["current", "cleanGates", "afterPendingMerges", "afterApprovedPrsMerge", "afterStalePrsClose", "linkedIssueFixed", "bestReasonableCase"]), source: z.enum(["current_data", "user_supplied", "github_observed", "gittensory_projection"]), @@ -973,6 +996,7 @@ const ScoreScenarioPreviewSchema = z.object({ effectiveEstimatedScore: z.number(), underlyingPotentialScore: z.number(), blockedBy: z.array(ScoreGateBlockerSchema), + linkedIssueMultiplier: LinkedIssueMultiplierDecisionSchema, deltaExplanation: z.string(), }); @@ -985,6 +1009,7 @@ export const ScorePreviewResultSchema = z privateOnly: z.literal(true), laneMath: z.record(z.number()), scoreEstimate: ScoreEstimateSchema, + linkedIssueMultiplier: LinkedIssueMultiplierDecisionSchema, gates: ScoreGatesSchema, effectiveEstimatedScore: z.number(), underlyingPotentialScore: z.number(), @@ -1025,6 +1050,15 @@ export const IssueQualityReportSchema = z number: z.number(), title: z.string(), lifecycle: z.enum(["open", "closed_not_solved", "solved", "valid_solved", "stale", "duplicate", "invalid"]).optional(), + linkage: z + .object({ + status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]), + source: z.enum(["official_mirror", "github_cache", "missing"]), + solvedByPullRequests: z.array(z.number()), + reason: z.string(), + warnings: z.array(z.string()), + }) + .optional(), status: z.enum(["ready", "needs_proof", "hold", "do_not_use"]), score: z.number(), reasons: z.array(z.string()), diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 33267af94c..d319376d42 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -8,6 +8,7 @@ export type ScorePreviewInput = { contributorLogin?: string | undefined; labels?: string[] | undefined; linkedIssueMode?: "none" | "standard" | "maintainer" | undefined; + linkedIssueContext?: LinkedIssueMultiplierContext | undefined; sourceTokenScore?: number | undefined; totalTokenScore?: number | undefined; sourceLines?: number | undefined; @@ -35,6 +36,32 @@ export type ScorePreviewInput = { observedScenarioNotes?: string[] | undefined; }; +export type LinkedIssueMultiplierStatus = "not_required" | "raw" | "plausible" | "validated" | "invalid" | "unavailable"; + +export type LinkedIssueMultiplierSource = "none" | "user_supplied" | "official_mirror" | "github_cache" | "issue_quality" | "missing"; + +export type LinkedIssueMultiplierContext = { + status?: Exclude | undefined; + source?: Exclude | undefined; + issueNumbers?: number[] | undefined; + solvedByPullRequests?: number[] | undefined; + reason?: string | undefined; + warnings?: string[] | undefined; +}; + +export type LinkedIssueMultiplierDecision = { + mode: "none" | "standard" | "maintainer"; + status: LinkedIssueMultiplierStatus; + source: LinkedIssueMultiplierSource; + eligible: boolean; + issueNumbers: number[]; + solvedByPullRequests: number[]; + baseMultiplier: number; + appliedMultiplier: number; + reason: string; + warnings: string[]; +}; + export type ScoreGateBlocker = { code: | "repo_not_registered" @@ -43,7 +70,9 @@ export type ScoreGateBlocker = { | "open_pr_threshold" | "credibility_floor" | "review_penalty" - | "metadata_only"; + | "metadata_only" + | "linked_issue_invalid" + | "linked_issue_unvalidated"; severity: "blocker" | "reducer" | "context"; detail: string; }; @@ -64,6 +93,7 @@ export type ScoreScenarioPreview = { effectiveEstimatedScore: number; underlyingPotentialScore: number; blockedBy: ScoreGateBlocker[]; + linkedIssueMultiplier: LinkedIssueMultiplierDecision; deltaExplanation: string; }; @@ -93,6 +123,7 @@ export type ScorePreviewResult = { estimatedMergedScore: number; pendingSaturationScore: number; }; + linkedIssueMultiplier: LinkedIssueMultiplierDecision; gates: { baseTokenGatePassed: boolean; openPrThreshold: number; @@ -137,6 +168,9 @@ export function buildScorePreview(args: { ...(current.scoreEstimate.labelMultiplier <= 1 && Object.keys(args.repo?.registryConfig?.labelMultipliers ?? {}).length > 0 ? ["Check whether the change legitimately matches one of the repo's configured trusted labels."] : []), + ...(current.linkedIssueMultiplier.mode === "standard" && !current.linkedIssueMultiplier.eligible + ? ["Validate linked issue context with solved-by-PR evidence before relying on the standard issue multiplier."] + : []), ]; return { @@ -147,6 +181,7 @@ export function buildScorePreview(args: { privateOnly: true, laneMath: current.laneMath, scoreEstimate: current.scoreEstimate, + linkedIssueMultiplier: current.linkedIssueMultiplier, gates: current.gates, effectiveEstimatedScore, underlyingPotentialScore, @@ -159,6 +194,7 @@ export function buildScorePreview(args: { "Advisory preview only; tied to the recorded scoring model snapshot and cached Gittensory data.", "No future outcome or exact payout is guaranteed.", "Private API/MCP output only; public comments intentionally omit these details.", + `Linked issue multiplier status: ${current.linkedIssueMultiplier.status}; ${current.linkedIssueMultiplier.reason}`, ...(args.input.scenarioNotes ?? []).map((note) => `User scenario note: ${note}`), ], recommendation: { @@ -188,7 +224,7 @@ export function makeScorePreviewRecord(input: ScorePreviewInput, snapshot: Scori }; } -type ScoreCore = Pick; +type ScoreCore = Pick; function computeScoreCore( input: ScorePreviewInput, @@ -227,7 +263,8 @@ function computeScoreCore( : densityBaseScore; const activeContributionBonus = snapshot.activeModel === "pending_saturation_model" ? saturationContributionBonusValue : densityContributionBonus; const labelMultiplier = selectLabelMultiplier(input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1); - const issueMultiplier = selectIssueMultiplier(input.linkedIssueMode ?? "none", constants); + const linkedIssueMultiplier = decideLinkedIssueMultiplier(input.linkedIssueMode ?? "none", input.linkedIssueContext, constants); + const issueMultiplier = linkedIssueMultiplier.appliedMultiplier; const credibilityObserved = clamp(input.credibility ?? inferCredibility(contributorEvidence), 0, 1); const credibilityFloor = constant(constants, "MIN_CREDIBILITY", 0.8); const credibilityMultiplier = credibilityObserved >= credibilityFloor ? 1 : credibilityObserved / credibilityFloor; @@ -263,6 +300,7 @@ function computeScoreCore( estimatedMergedScore, pendingSaturationScore, }, + linkedIssueMultiplier, gates: { baseTokenGatePassed, openPrThreshold, @@ -315,10 +353,7 @@ function buildScenarioPreviews( openPrCount: expectedOpenPrCountAfterMerge, credibility: projectedCredibility, }; - const linkedIssueInput = { - ...input, - linkedIssueMode: input.linkedIssueMode === "none" || !input.linkedIssueMode ? ("standard" as const) : input.linkedIssueMode, - }; + const linkedIssueInput = withValidatedLinkedIssueScenario(input); const bestReasonableInput = { ...linkedIssueInput, openPrCount: Math.min( @@ -385,8 +420,8 @@ function buildScenarioPreviews( ), 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." - : "Linked issue mode was already supplied; this scenario is unchanged.", + ? "A standard linked-issue/no-issue rationale multiplier is projected as solved-by-PR validated." + : "Linked issue mode was already supplied; this scenario projects solved-by-PR validation where needed.", ], repo), 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.", @@ -419,6 +454,7 @@ function scenario( source, assumptions, scoreEstimate: core.scoreEstimate, + linkedIssueMultiplier: core.linkedIssueMultiplier, gates: core.gates, effectiveEstimatedScore: core.scoreEstimate.estimatedMergedScore, underlyingPotentialScore: core.scoreEstimate.pendingSaturationScore, @@ -462,14 +498,37 @@ function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, c ...(core.scoreEstimate.reviewPenaltyMultiplier < 1 ? [{ code: "review_penalty" as const, severity: "reducer" as const, detail: "Change-request history reduces the estimate." }] : []), + ...(core.linkedIssueMultiplier.mode === "standard" && core.linkedIssueMultiplier.status === "invalid" + ? [ + { + code: "linked_issue_invalid" as const, + severity: "reducer" as const, + detail: core.linkedIssueMultiplier.reason, + }, + ] + : []), + ...(core.linkedIssueMultiplier.mode === "standard" && ["raw", "plausible", "unavailable"].includes(core.linkedIssueMultiplier.status) + ? [ + { + code: "linked_issue_unvalidated" as const, + severity: "context" as const, + detail: core.linkedIssueMultiplier.reason, + }, + ] + : []), ]; } function buildGateDeltas(current: ScoreCore, scenarios: ScoreScenarioPreview[]): ScoreGateDelta[] { const currentScenario = scenarios[0]; + /* v8 ignore next -- buildScenarioPreviews always emits a current scenario; this protects malformed adapters. */ if (!currentScenario) return []; - const best = scenarios.find((scenarioPreview) => scenarioPreview.name === "bestReasonableCase") ?? currentScenario; - const linked = scenarios.find((scenarioPreview) => scenarioPreview.name === "linkedIssueFixed") ?? best; + const bestMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "bestReasonableCase"); + /* v8 ignore next -- buildScenarioPreviews always emits bestReasonableCase; current is the defensive fallback. */ + const best = bestMatch ?? currentScenario; + const linkedMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "linkedIssueFixed"); + /* v8 ignore next -- buildScenarioPreviews always emits linkedIssueFixed; best is the defensive fallback. */ + const linked = linkedMatch ?? best; return [ ...(current.scoreEstimate.openPrMultiplier !== best.scoreEstimate.openPrMultiplier || current.gates.openPrCount !== best.gates.openPrCount ? [ @@ -505,7 +564,7 @@ function buildGateDeltas(current: ScoreCore, scenarios: ScoreScenarioPreview[]): } function warningsFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore): string[] { - return blockedByFor(input, repo, core).map((blocker) => blocker.detail); + return [...new Set([...blockedByFor(input, repo, core).map((blocker) => blocker.detail), ...core.linkedIssueMultiplier.warnings])]; } function statusFor( @@ -535,6 +594,110 @@ function selectLabelMultiplier(labels: string[], multipliers: Record, +): LinkedIssueMultiplierDecision { + const baseMultiplier = selectIssueMultiplier(mode, constants); + const issueNumbers = uniquePositiveInts(context?.issueNumbers ?? []); + const solvedByPullRequests = uniquePositiveInts(context?.solvedByPullRequests ?? []); + if (mode === "none") { + return { + mode, + status: "not_required", + source: "none", + eligible: false, + issueNumbers, + solvedByPullRequests, + baseMultiplier, + appliedMultiplier: 1, + reason: "No linked-issue multiplier mode was requested.", + warnings: [], + }; + } + if (mode === "maintainer") { + return { + mode, + status: "not_required", + source: context?.source ?? "none", + eligible: true, + issueNumbers, + solvedByPullRequests, + baseMultiplier, + appliedMultiplier: baseMultiplier, + reason: "Maintainer-lane multiplier does not require solved-by-PR issue linkage.", + warnings: context?.warnings ?? [], + }; + } + + const status = context?.status ?? (solvedByPullRequests.length > 0 ? "validated" : issueNumbers.length > 0 ? "raw" : "unavailable"); + const source = context?.source ?? (status === "unavailable" ? "missing" : "user_supplied"); + const eligible = status === "validated"; + const reason = context?.reason ?? linkedIssueReason(status, source, issueNumbers, solvedByPullRequests); + return { + mode, + status, + source, + eligible, + issueNumbers, + solvedByPullRequests, + baseMultiplier, + appliedMultiplier: eligible ? baseMultiplier : 1, + reason, + warnings: [...new Set([...linkedIssueWarnings(status), ...(context?.warnings ?? [])])], + }; +} + +function withValidatedLinkedIssueScenario(input: ScorePreviewInput): ScorePreviewInput { + const mode = input.linkedIssueMode ?? "none"; + if (mode === "maintainer") return input; + const issueNumbers = uniquePositiveInts(input.linkedIssueContext?.issueNumbers ?? []); + const solvedByPullRequests = uniquePositiveInts(input.linkedIssueContext?.solvedByPullRequests ?? []); + return { + ...input, + linkedIssueMode: "standard", + linkedIssueContext: { + ...input.linkedIssueContext, + status: "validated", + source: input.linkedIssueContext?.source ?? "user_supplied", + issueNumbers, + solvedByPullRequests, + reason: "Projection assumes linked issue context is solved-by-PR validated.", + warnings: [], + }, + }; +} + +function linkedIssueReason( + status: Exclude, + source: LinkedIssueMultiplierSource, + issueNumbers: number[], + solvedByPullRequests: number[], +): string { + const issues = issueNumbers.length > 0 ? ` for issue(s) ${issueNumbers.map((number) => `#${number}`).join(", ")}` : ""; + if (status === "validated") { + const solvers = solvedByPullRequests.length > 0 ? ` via solved-by-PR ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}` : ""; + return `Linked issue context is solved-by-PR validated${issues}${solvers}.`; + } + if (status === "invalid") return `Linked issue context is invalid${issues}; standard issue multiplier is not applied.`; + if (status === "plausible") return `Linked issue context is plausible${issues}, but solved-by-PR validation is not available yet.`; + if (status === "unavailable") return `Linked issue mirror/cache data is unavailable${issues}; standard issue multiplier is not applied until validation is available.`; + return `Raw linked issue reference${issues} has no solved-by-PR validation from ${source}.`; +} + +function linkedIssueWarnings(status: Exclude): string[] { + if (status === "validated") return []; + if (status === "invalid") return ["Linked issue context is invalid; standard issue multiplier is not applied."]; + if (status === "unavailable") return ["Linked issue mirror/cache data is unavailable; standard issue multiplier is not applied until validation is available."]; + if (status === "plausible") return ["Linked issue context is plausible but not solved-by-PR validated; standard issue multiplier is not applied."]; + return ["Raw linked issue reference has no solved-by-PR evidence; standard issue multiplier is not applied."]; +} + +function uniquePositiveInts(values: number[]): number[] { + return [...new Set(values.filter((value) => Number.isInteger(value) && value > 0))].sort((left, right) => left - right); +} + function selectIssueMultiplier(mode: "none" | "standard" | "maintainer", constants: Record): number { if (mode === "maintainer") return constant(constants, "MAINTAINER_ISSUE_MULTIPLIER", 1.66); if (mode === "standard") return constant(constants, "STANDARD_ISSUE_MULTIPLIER", 1.33); @@ -569,6 +732,7 @@ function saturationContributionBonus(totalTokenScore: number, constants: Record< } function nonNegative(value: number | undefined): number { + /* v8 ignore next -- API schemas and local scorers normalize numeric preview inputs before this defensive fallback. */ return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; } diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 2599c87924..3eca6ce997 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -328,6 +328,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr scoringSnapshot, scoringProfile, issueQuality: issueQuality?.report, + gittensorSnapshot, }); } diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 53c0334122..8b08ad1771 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -486,6 +486,7 @@ export type IssueQualityReport = { number: number; title: string; lifecycle?: IssueDiscoveryLifecycleState | undefined; + linkage?: IssueLinkageRecord | undefined; status: "ready" | "needs_proof" | "hold" | "do_not_use"; score: number; reasons: string[]; @@ -496,6 +497,14 @@ export type IssueQualityReport = { export type IssueDiscoveryLifecycleState = "open" | "closed_not_solved" | "solved" | "valid_solved" | "stale" | "duplicate" | "invalid"; +export type IssueLinkageRecord = { + status: "raw" | "plausible" | "validated" | "invalid" | "unavailable"; + source: "official_mirror" | "github_cache" | "missing"; + solvedByPullRequests: number[]; + reason: string; + warnings: string[]; +}; + export type IssueDiscoveryLifecycleReport = { repoFullName: string; generatedAt: string; @@ -2248,11 +2257,13 @@ export function buildIssueQualityReport( /* v8 ignore next -- Missing issue dates normalize to zero age; issue-quality status tests cover age-driven behavior. */ const age = daysSince(issue.updatedAt ?? issue.createdAt); /* v8 ignore next -- Lifecycle map is built from the same issue set; fallback protects malformed external issue-quality payloads. */ - const lifecycle = lifecycleByIssue.get(issue.number)?.state ?? "open"; + const lifecycleEntry = lifecycleByIssue.get(issue.number); + const lifecycle = lifecycleEntry?.state ?? "open"; const bodyLength = issue.body?.trim().length ?? 0; const bounty = bountyByIssue.get(bountyIssueKey(fullName, issue.number)) ?? null; const bountyLifecycle = bounty ? classifyBountyLifecycle(bounty, issue) : null; const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length; + const linkage = buildIssueLinkageRecord(issue, lifecycleEntry, linkedPrs, linkedMergedPrs); const reasons = [ ...(bodyLength >= 200 ? ["Issue has enough body detail to evaluate."] : []), ...(issue.labels.length > 0 ? [`Labels: ${issue.labels.join(", ")}.`] : []), @@ -2285,7 +2296,7 @@ export function buildIssueQualityReport( : score < 45 ? "hold" : "ready"; - return { number: issue.number, title: issue.title, lifecycle, status, score, reasons, warnings }; + return { number: issue.number, title: issue.title, lifecycle, linkage, status, score, reasons, warnings }; }) .sort((left, right) => right.score - left.score || left.number - right.number); return { @@ -2318,6 +2329,59 @@ export function buildIssueDiscoveryLifecycleReport( }; } +function buildIssueLinkageRecord( + issue: IssueRecord, + lifecycleEntry: IssueDiscoveryLifecycleReport["states"][number] | undefined, + linkedPrs: PullRequestRecord[], + linkedMergedPrs: RecentMergedPullRequestRecord[], +): IssueLinkageRecord { + const solvedByPullRequests = [ + ...new Set([ + ...(lifecycleEntry?.solvedByPullRequests ?? []), + ...linkedPrs.filter((pr) => pr.mergedAt || pr.state === "merged").map((pr) => pr.number), + ...linkedMergedPrs.map((pr) => pr.number), + ]), + ].sort((left, right) => left - right); + const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length; + const lifecycle = lifecycleEntry?.state; + const status: IssueLinkageRecord["status"] = + solvedByPullRequests.length > 0 || lifecycle === "solved" || lifecycle === "valid_solved" + ? "validated" + : lifecycle === "closed_not_solved" || lifecycle === "duplicate" || lifecycle === "invalid" || issue.state !== "open" + ? "invalid" + : linkedWorkCount > 0 + ? "plausible" + : lifecycle + ? "raw" + : "unavailable"; + const issueRef = `#${issue.number}`; + const reason = + status === "validated" + ? `Cached GitHub linkage has solved-by-PR evidence for ${issueRef}${solvedByPullRequests.length > 0 ? ` via ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}` : ""}.` + : status === "invalid" + ? `Cached GitHub linkage marks ${issueRef} as ${lifecycle?.replace(/_/g, " ") ?? issue.state}.` + : status === "plausible" + ? `Cached GitHub linkage has PR context for ${issueRef}, but no solved-by-PR evidence yet.` + : status === "unavailable" + ? `No cached linkage state was available for ${issueRef}.` + : `Cached GitHub linkage has only a raw issue reference for ${issueRef}.`; + return { + status, + source: status === "unavailable" ? "missing" : "github_cache", + solvedByPullRequests, + reason, + warnings: issueLinkageWarnings(status), + }; +} + +function issueLinkageWarnings(status: IssueLinkageRecord["status"]): string[] { + if (status === "validated") return []; + if (status === "invalid") return ["Issue linkage should not be treated as multiplier-validated."]; + if (status === "unavailable") return ["Issue linkage data is unavailable; confirm solved-by-PR state before relying on it."]; + if (status === "plausible") return ["Issue linkage is plausible but not solved-by-PR validated yet."]; + return ["Raw issue reference has no solved-by-PR evidence yet."]; +} + function classifyIssueDiscoveryLifecycle( issue: IssueRecord, pullRequests: PullRequestRecord[], diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 97e7c5a86b..ede8aacc07 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -1,5 +1,6 @@ -import type { ScorePreviewInput, ScorePreviewResult } from "../scoring/preview"; +import type { LinkedIssueMultiplierContext, ScorePreviewInput, ScorePreviewResult } from "../scoring/preview"; import { buildScorePreview } from "../scoring/preview"; +import type { GittensorContributorSnapshot } from "../gittensor/api"; import type { BountyRecord, CheckSummaryRecord, IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RepositoryRecord, ScoringModelSnapshotRecord } from "../types"; import { nowIso } from "../utils/json"; import { @@ -181,6 +182,7 @@ export function buildLocalBranchAnalysis(args: { scoringSnapshot: ScoringModelSnapshotRecord; scoringProfile?: ContributorScoringProfile | null | undefined; issueQuality?: IssueQualityReport | null | undefined; + gittensorSnapshot?: GittensorContributorSnapshot | null | undefined; }): LocalBranchAnalysis { const changedFiles = args.input.changedFiles ?? []; const changedPaths = changedFiles.map((file) => file.path); @@ -225,12 +227,19 @@ export function buildLocalBranchAnalysis(args: { repositories: args.repositories, }); const githubBranchStatus = buildGitHubBranchStatus(args.input, args.pullRequests, args.checkSummaries ?? []); + const linkedIssueContext = buildLinkedIssueMultiplierContext({ + repoFullName: args.input.repoFullName, + linkedIssues: preflight.linkedIssues, + issueQuality: args.issueQuality, + gittensorSnapshot: args.gittensorSnapshot, + }); const scoreInput = buildLocalScoreInput({ input: args.input, changedFiles, changedLineCount, testFiles, linkedIssueCount: preflight.linkedIssues.length, + linkedIssueContext, roleContext, outcomeHistory: args.outcomeHistory, repoOutcome, @@ -350,6 +359,7 @@ function buildLocalScoreInput(args: { changedLineCount: number; testFiles: string[]; linkedIssueCount: number; + linkedIssueContext?: LinkedIssueMultiplierContext | undefined; roleContext: RoleContext; outcomeHistory: ContributorOutcomeHistory; repoOutcome?: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; @@ -368,6 +378,7 @@ function buildLocalScoreInput(args: { contributorLogin: args.input.login, labels: args.input.labels ?? [], linkedIssueMode: args.roleContext.maintainerLane ? "maintainer" : args.linkedIssueCount > 0 ? "standard" : "none", + linkedIssueContext: args.linkedIssueContext, sourceTokenScore: scorer?.sourceTokenScore ?? Math.max(0, sourceLineCount), totalTokenScore: scorer?.totalTokenScore ?? Math.max(0, args.changedLineCount), sourceLines: scorer?.sourceLines ?? Math.max(1, sourceLineCount || args.changedLineCount || 1), @@ -392,6 +403,104 @@ function buildLocalScoreInput(args: { }; } +function buildLinkedIssueMultiplierContext(args: { + repoFullName: string; + linkedIssues: number[]; + issueQuality?: IssueQualityReport | null | undefined; + gittensorSnapshot?: GittensorContributorSnapshot | null | undefined; +}): LinkedIssueMultiplierContext | undefined { + const issueNumbers = uniquePositiveInts(args.linkedIssues); + if (issueNumbers.length === 0) return undefined; + const mirror = linkedIssueContextFromMirror(args.repoFullName, issueNumbers, args.gittensorSnapshot); + if (mirror.context) return mirror.context; + const github = linkedIssueContextFromIssueQuality(issueNumbers, args.issueQuality); + if (github) { + return { + ...github, + warnings: [...new Set([...mirror.warnings, ...(github.warnings ?? [])])], + }; + } + return { + status: "unavailable", + source: "missing", + issueNumbers, + solvedByPullRequests: [], + reason: `Linked issue mirror/cache data is unavailable for ${issueNumbers.map((number) => `#${number}`).join(", ")}.`, + warnings: mirror.warnings, + }; +} + +function linkedIssueContextFromMirror( + repoFullName: string, + issueNumbers: number[], + snapshot: GittensorContributorSnapshot | null | undefined, +): { context?: LinkedIssueMultiplierContext | undefined; warnings: string[] } { + if (!snapshot) return { warnings: ["Official mirror data is unavailable for this contributor; using cached GitHub linkage if present."] }; + if (snapshot.issueMirrorAvailable === false) return { warnings: ["Official mirror issue data is unavailable; using cached GitHub linkage if present."] }; + const issues = (snapshot.issues ?? []).filter((issue) => sameRepo(issue.repoFullName, repoFullName) && issueNumbers.includes(issue.number)); + if (issues.length === 0) return { warnings: ["Official mirror has no matching solved_by_pr row for the linked issue(s); using cached GitHub linkage if present."] }; + const solvedByPullRequests = uniquePositiveInts(issues.flatMap((issue) => (issue.solvedByPullRequest ? [issue.solvedByPullRequest] : []))); + const missingIssues = issueNumbers.filter((number) => !issues.some((issue) => issue.number === number)); + const closedWithoutSolver = issues.filter((issue) => issue.state.toLowerCase() !== "open" && !issue.solvedByPullRequest).map((issue) => issue.number); + const status: NonNullable = + solvedByPullRequests.length > 0 ? "validated" : closedWithoutSolver.length > 0 ? "invalid" : "raw"; + const reason = + status === "validated" + ? `Official mirror solved_by_pr validates linked issue(s) ${issues.map((issue) => `#${issue.number}`).join(", ")} via PR ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}.` + : status === "invalid" + ? `Official mirror has closed linked issue(s) without solved_by_pr evidence: ${closedWithoutSolver.map((number) => `#${number}`).join(", ")}.` + : `Official mirror has linked issue row(s) for ${issues.map((issue) => `#${issue.number}`).join(", ")}, but no solved_by_pr evidence yet.`; + return { + context: { + status, + source: "official_mirror", + issueNumbers, + solvedByPullRequests, + reason, + warnings: [ + ...(status === "raw" ? ["Official mirror issue row exists, but solved_by_pr is not set yet."] : []), + ...(missingIssues.length > 0 ? [`Official mirror did not include linked issue(s): ${missingIssues.map((number) => `#${number}`).join(", ")}.`] : []), + ], + }, + warnings: [], + }; +} + +function linkedIssueContextFromIssueQuality(issueNumbers: number[], issueQuality: IssueQualityReport | null | undefined): LinkedIssueMultiplierContext | undefined { + if (!issueQuality) return undefined; + const byIssue = new Map(issueQuality.issues.map((issue) => [issue.number, issue])); + const entries = issueNumbers.map((number) => byIssue.get(number)).filter((entry): entry is NonNullable => Boolean(entry)); + if (entries.length === 0) return undefined; + const solvedByPullRequests = uniquePositiveInts(entries.flatMap((entry) => entry.linkage?.solvedByPullRequests ?? [])); + const invalid = entries.filter((entry) => entry.linkage?.status === "invalid" || (entry.status === "do_not_use" && entry.linkage?.status !== "validated")); + const plausible = entries.filter((entry) => entry.linkage?.status === "plausible"); + const raw = entries.filter((entry) => entry.linkage?.status === "raw" || !entry.linkage); + const missingIssues = issueNumbers.filter((number) => !byIssue.has(number)); + const status: NonNullable = + invalid.length > 0 ? "invalid" : solvedByPullRequests.length > 0 ? "validated" : plausible.length > 0 ? "plausible" : raw.length > 0 ? "raw" : "unavailable"; + const reason = + status === "validated" + ? `Cached issue-quality linkage has solved-by-PR evidence via ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}.` + : status === "invalid" + ? `Cached issue-quality linkage marks linked issue(s) ${invalid.map((entry) => `#${entry.number}`).join(", ")} as not multiplier-eligible.` + : status === "plausible" + ? "Cached issue-quality linkage is plausible, but solved-by-PR evidence is not available yet." + : status === "raw" + ? "Cached issue-quality linkage has only raw issue references." + : "Cached issue-quality linkage is unavailable for the linked issue(s)."; + return { + status, + source: "github_cache", + issueNumbers, + solvedByPullRequests, + reason, + warnings: [ + ...entries.flatMap((entry) => entry.linkage?.warnings ?? []), + ...(missingIssues.length > 0 ? [`Issue-quality report did not include linked issue(s): ${missingIssues.map((number) => `#${number}`).join(", ")}.`] : []), + ], + }; +} + function buildObservedPullRequestScenarios(args: { login: string; repoFullName: string; @@ -823,10 +932,16 @@ function buildPublicSafePrPacket(args: { ...(args.roleContext.maintainerLane ? ["This is maintainer-lane context; present it as repo stewardship work."] : []), ...args.preflight.findings .filter((finding) => finding.severity !== "info") - .map((finding) => finding.publicText ?? finding.action ?? finding.title), + .map((finding) => { + /* v8 ignore next -- Local preflight findings currently use action/title; publicText is kept for the shared finding contract. */ + return finding.publicText ?? finding.action ?? finding.title; + }), ...args.localFindings .filter((finding) => finding.code !== "score_preview_warning" && finding.severity === "warning") - .flatMap((finding) => (finding.action ? [finding.action] : [finding.title])), + .flatMap((finding) => { + /* v8 ignore next -- Warning local findings currently carry actions; title fallback protects future adapters. */ + return finding.action ? [finding.action] : [finding.title]; + }), ].filter(isPublicSafeText); const nextSteps = [...publicSafeWarnings, args.baseFreshness.recommendation, args.recommendedRerunCondition, "Keep source upload disabled; this packet is based on local git metadata only."].filter( (line): line is string => Boolean(line && isPublicSafeText(line)), @@ -884,10 +999,18 @@ function overlapCautionLines(collisions: LocalDiffPreflightResult["collisions"]) if (collisions.length === 0) return ["- No active overlap or WIP was detected from cached issue/PR metadata."]; return collisions .slice(0, 3) - .map((cluster) => `- Possible overlap or WIP (${cluster.risk}): ${cluster.reason} Check ${cluster.items.slice(0, 3).map((item) => `${item.type === "pull_request" ? "PR" : item.type === "issue" ? "issue" : "merged PR"} #${item.number}`).join(", ")} before posting.`) + .map((cluster) => `- Possible overlap or WIP (${cluster.risk}): ${cluster.reason} Check ${cluster.items.slice(0, 3).map((item) => `${collisionItemLabel(item.type)} #${item.number}`).join(", ")} before posting.`) .filter(isPublicSafeText); } +function collisionItemLabel(type: LocalDiffPreflightResult["collisions"][number]["items"][number]["type"]): string { + if (type === "pull_request") return "PR"; + /* v8 ignore next -- Engine collision tests cover issue items; local packet tests focus on PR overlap rendering. */ + if (type === "issue") return "issue"; + /* v8 ignore next -- Local diff preflight does not currently include merged PR collision items. */ + return "merged PR"; +} + function changedFileSummary(file: LocalBranchChangedFile): string { return `${file.previousPath ? `${safeRepoPath(file.previousPath)} -> ${safeRepoPath(file.path)}` : safeRepoPath(file.path)} (${file.status ?? "modified"}, ${file.binary ? "binary" : `+${nonNegative(file.additions)}/-${nonNegative(file.deletions)}`})`; } @@ -947,6 +1070,10 @@ function sameRepo(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } +function uniquePositiveInts(values: number[]): number[] { + return [...new Set(values.filter((value) => Number.isInteger(value) && value > 0))].sort((left, right) => left - right); +} + function sameLogin(left: string | null | undefined, right: string | null | undefined): boolean { return Boolean(left && right && left.toLowerCase() === right.toLowerCase()); } diff --git a/test/unit/gittensor-api.test.ts b/test/unit/gittensor-api.test.ts index adc2f2873c..3b03082b34 100644 --- a/test/unit/gittensor-api.test.ts +++ b/test/unit/gittensor-api.test.ts @@ -65,7 +65,7 @@ describe("Gittensor API contributor snapshots", () => { ]); } if (url.endsWith("/miners/49853598/issues")) { - return Response.json({ issues: [{ labels: [{ name: "feature" }, { name: "help wanted" }] }] }); + return Response.json({ issues: [{ repo_full_name: "we-promise/sure", issue_number: 77, state: "closed", solved_by_pr: 1869, labels: [{ name: "feature" }, { name: "help wanted" }] }] }); } return new Response("not found", { status: 404 }); }); @@ -80,6 +80,8 @@ describe("Gittensor API contributor snapshots", () => { expect.objectContaining({ repoFullName: "we-promise/sure", pullRequests: 47, mergedPullRequests: 37, openPullRequests: 6 }), expect.objectContaining({ repoFullName: "jsonbored/awesome-claude", pullRequests: 0, openIssues: 42 }), ], + issueMirrorAvailable: true, + issues: [expect.objectContaining({ repoFullName: "we-promise/sure", number: 77, state: "closed", solvedByPullRequest: 1869, labels: ["feature", "help wanted"] })], }); expect(contributorRepoStatsFromGittensor(snapshot)).toEqual( expect.arrayContaining([ @@ -125,6 +127,8 @@ describe("Gittensor API contributor snapshots", () => { totals: { pullRequests: 63, mergedPullRequests: 46, openPullRequests: 9, closedPullRequests: 8 }, repositories: [], pullRequests: [], + issueMirrorAvailable: false, + issues: [], issueLabels: [], }); }); @@ -159,7 +163,7 @@ describe("Gittensor API contributor snapshots", () => { ]); } if (url.endsWith("/miners/123/issues")) { - return Response.json({ issues: [{ labels: [{}, { name: null }, { name: "bug" }] }] }); + return Response.json({ issues: [{ repo_full_name: "owner/repo", issue_number: "bad", labels: [{}, { name: null }, { name: "bug" }] }] }); } return Response.json({}); }); @@ -169,6 +173,8 @@ describe("Gittensor API contributor snapshots", () => { expect(snapshot).toMatchObject({ totals: { pullRequests: 0, mergedPullRequests: 2, openPullRequests: 0 }, issueCredibility: 1, + issueMirrorAvailable: true, + issues: [], issueLabels: ["bug"], pullRequests: [expect.objectContaining({ repoFullName: "owner/repo", number: 7, title: "Fix it", score: 0, baseScore: 1 })], repositories: [ @@ -243,7 +249,7 @@ describe("Gittensor API contributor snapshots", () => { return Response.json([{ repository: "owner/repo", pullRequestNumber: 9, pullRequestTitle: undefined, prState: undefined, mergedAt: null, label: "bug", tokenScore: "7" }]); } if (url.endsWith("/miners/49853598/issues")) { - return Response.json({ issues: [{ labels: undefined }, { labels: [{ name: "bug" }] }] }); + return Response.json({ issues: [{ labels: undefined }, { repo_full_name: "owner/issues", issue_number: 22, state: "open", solved_by_pr: null, labels: [{ name: "bug" }] }] }); } return Response.json({}); }); @@ -261,6 +267,8 @@ describe("Gittensor API contributor snapshots", () => { updatedAt: "2026-05-25T00:00:00.000Z", repositories: [expect.objectContaining({ repoFullName: "owner/issues", openIssues: 1, closedIssues: 1, validSolvedIssues: 1, issueCredibility: 0.9 })], pullRequests: [expect.objectContaining({ repoFullName: "owner/repo", number: 9, title: "", state: "UNKNOWN", label: "bug", tokenScore: 7 })], + issueMirrorAvailable: true, + issues: [expect.objectContaining({ repoFullName: "owner/issues", number: 22, state: "open", solvedByPullRequest: null, labels: ["bug"] })], issueLabels: ["bug"], }); expect(contributorRepoStatsFromGittensor(snapshot)).toEqual( diff --git a/test/unit/issue-quality.test.ts b/test/unit/issue-quality.test.ts index ec637b05cf..785eae05d1 100644 --- a/test/unit/issue-quality.test.ts +++ b/test/unit/issue-quality.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildContributorFit, buildContributorOpportunities, + buildIssueDiscoveryLifecycleReport, buildIssueQualityReport, type ContributorProfile, type IssueQualityReport, @@ -62,6 +63,7 @@ describe("issue quality reports", () => { const linkedPr = pr(repo.fullName, 100, "Fix for #5", { linkedIssues: [5] }); const report = buildIssueQualityReport(repo, [issue(repo.fullName, 5, "Already worked on", { body: "x".repeat(220) })], [linkedPr], repo.fullName); expect(report.issues[0]?.status).toBe("do_not_use"); + expect(report.issues[0]?.linkage).toMatchObject({ status: "plausible", source: "github_cache", solvedByPullRequests: [] }); expect(report.issues[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/already reference this issue/i)])); }); @@ -85,10 +87,26 @@ describe("issue quality reports", () => { }); expect(report.issues.find((entry) => entry.number === 6)).toMatchObject({ status: "do_not_use", + linkage: { status: "validated", solvedByPullRequests: [101] }, warnings: expect.arrayContaining([expect.stringMatching(/merged PR/i)]), }); }); + it("records raw, validated, and closed-not-solved linkage states separately", () => { + const repo = issueDiscoveryRepo("owner/linkage"); + const raw = issue(repo.fullName, 1, "Raw issue", { body: "x".repeat(220) }); + const solved = issue(repo.fullName, 2, "Solved issue", { body: "x".repeat(220) }); + const duplicate = issue(repo.fullName, 4, "Duplicate issue", { body: "x".repeat(220), labels: ["duplicate"] }); + const closed = issue(repo.fullName, 3, "Closed issue", { body: "x".repeat(220), state: "closed" }); + const quality = buildIssueQualityReport(repo, [raw, solved, closed, duplicate], [], repo.fullName, [], undefined, [recentMergedPr(repo.fullName, 202, "Fixes #2", { linkedIssues: [2] })]); + const lifecycle = buildIssueDiscoveryLifecycleReport(repo, [closed], [], repo.fullName); + + expect(quality.issues.find((entry) => entry.number === 1)?.linkage).toMatchObject({ status: "raw", source: "github_cache", solvedByPullRequests: [] }); + expect(quality.issues.find((entry) => entry.number === 2)?.linkage).toMatchObject({ status: "validated", source: "github_cache", solvedByPullRequests: [202] }); + expect(quality.issues.find((entry) => entry.number === 4)?.linkage).toMatchObject({ status: "invalid", source: "github_cache", solvedByPullRequests: [] }); + expect(lifecycle.states[0]).toMatchObject({ number: 3, state: "closed_not_solved", solvedByPullRequests: [] }); + }); + it("surfaces duplicate-prone context via collision detection on both issues", () => { const repo = issueDiscoveryRepo("owner/dupes"); const a = issue(repo.fullName, 10, "Login flow broken when user reconnects after disconnect", { body: "x".repeat(220), labels: ["bug"] }); diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 5aba88eb8e..595addcc8b 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -44,6 +44,8 @@ describe("local branch analysis", () => { expect(analysis.preflight.status).toBe("ready"); expect(analysis.preflight.localDiff).toMatchObject({ changedFileCount: 2, codeFileCount: 1, testFileCount: 1, inferredLinkedIssues: [7] }); expect(analysis.scorePreview.privateOnly).toBe(true); + expect(analysis.scorePreview.linkedIssueMultiplier).toMatchObject({ status: "unavailable", source: "missing", issueNumbers: [7], appliedMultiplier: 1 }); + expect(analysis.scorePreview.warnings.join(" ")).toMatch(/mirror.*unavailable/i); expect(analysis.rewardRisk.rewardUpside.relevantLane).toBe("direct_pr"); expect(analysis.nextActions.map((action) => action.actionKind)).toContain("open_new_direct_pr"); expect(analysis.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "source_upload_disabled" })])); @@ -160,11 +162,115 @@ describe("local branch analysis", () => { expect(analysis.preflight.status).toBe("needs_work"); expect(analysis.preflight.findings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "issue_quality_do_not_use" })])); + expect(analysis.scorePreview.linkedIssueMultiplier).toMatchObject({ status: "invalid", source: "github_cache", eligible: false, appliedMultiplier: 1 }); expect(analysis.branchQualityBlockers).toEqual(expect.arrayContaining(["Linked issue is already covered or duplicate-prone"])); expect(analysis.prPacket.markdown).toContain("Confirm the linked issue is still actionable"); expect(JSON.stringify(analysis.prPacket)).not.toMatch(/reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); }); + it("prefers official mirror solved_by_pr linkage for private score previews without leaking multiplier language publicly", () => { + 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: 70, sourceLines: 42 }, + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache refresh fails", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + issueQuality: { + repoFullName: repo.fullName, + generatedAt: new Date().toISOString(), + lane: { repoFullName: repo.fullName, lane: "direct_pr", issueDiscoveryShare: 0, directPrShare: 0.04, summary: "Direct PR lane", contributorGuidance: "", maintainerGuidance: "" }, + issues: [{ number: 7, title: "Cache refresh fails", linkage: { status: "raw", source: "github_cache", solvedByPullRequests: [], reason: "raw", warnings: [] }, status: "ready", score: 80, reasons: [], warnings: [] }], + summary: "1 issue evaluated.", + }, + gittensorSnapshot: { + issueMirrorAvailable: true, + issues: [{ repoFullName: repo.fullName, number: 7, state: "closed", solvedByPullRequest: 144, labels: ["bug"] }], + } as never, + }); + + expect(analysis.scorePreview.linkedIssueMultiplier).toMatchObject({ status: "validated", source: "official_mirror", solvedByPullRequests: [144], appliedMultiplier: 1.33 }); + expect(JSON.stringify(analysis.prPacket)).not.toMatch(/multiplier|reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); + }); + + it("surfaces mirror raw, invalid, and unavailable linkage fallbacks privately", () => { + const baseInput = { + login: "oktofeesh1", + repoFullName: repo.fullName, + linkedIssues: [7, 8], + changedFiles: [{ path: "src/cache.ts", additions: 12, deletions: 1, status: "modified" as const }], + validation: [{ command: "npm test -- cache", status: "passed" as const }], + localScorer: { mode: "external_command" as const, sourceTokenScore: 42, totalTokenScore: 70, sourceLines: 42 }, + }; + const baseArgs = { + repo, + issues: [ + { repoFullName: repo.fullName, number: 7, title: "Cache refresh fails", state: "open", labels: ["bug"], linkedPrs: [] }, + { repoFullName: repo.fullName, number: 8, title: "Cache refresh also fails", state: "open", labels: ["bug"], linkedPrs: [] }, + ], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }; + + const mirrorRaw = buildLocalBranchAnalysis({ + input: baseInput, + ...baseArgs, + gittensorSnapshot: { + issueMirrorAvailable: true, + issues: [{ repoFullName: repo.fullName, number: 7, state: "open", solvedByPullRequest: null, labels: ["bug"] }], + } as never, + }); + const mirrorInvalid = buildLocalBranchAnalysis({ + input: { ...baseInput, linkedIssues: [7] }, + ...baseArgs, + gittensorSnapshot: { + issueMirrorAvailable: true, + issues: [{ repoFullName: repo.fullName, number: 7, state: "closed", solvedByPullRequest: null, labels: ["bug"] }], + } as never, + }); + const mirrorUnavailableWithPlausibleCache = buildLocalBranchAnalysis({ + input: { ...baseInput, linkedIssues: [7, 8] }, + ...baseArgs, + issueQuality: { + repoFullName: repo.fullName, + generatedAt: new Date().toISOString(), + lane: { repoFullName: repo.fullName, lane: "direct_pr", issueDiscoveryShare: 0, directPrShare: 0.04, summary: "Direct PR lane", contributorGuidance: "", maintainerGuidance: "" }, + issues: [ + { + number: 7, + title: "Cache refresh fails", + linkage: { status: "plausible", source: "github_cache", solvedByPullRequests: [], reason: "active PR context", warnings: ["not solved yet"] }, + status: "ready", + score: 80, + reasons: [], + warnings: [], + }, + ], + summary: "1 issue evaluated.", + }, + gittensorSnapshot: { issueMirrorAvailable: false, issues: [] } as never, + }); + + expect(mirrorRaw.scorePreview.linkedIssueMultiplier).toMatchObject({ status: "raw", source: "official_mirror", issueNumbers: [7, 8], appliedMultiplier: 1 }); + expect(mirrorRaw.scorePreview.linkedIssueMultiplier.warnings.join(" ")).toMatch(/did not include linked issue.*#8/i); + expect(mirrorInvalid.scorePreview.linkedIssueMultiplier).toMatchObject({ status: "invalid", source: "official_mirror", appliedMultiplier: 1 }); + expect(mirrorInvalid.scorePreview.linkedIssueMultiplier.reason).toMatch(/closed linked issue/i); + expect(mirrorUnavailableWithPlausibleCache.scorePreview.linkedIssueMultiplier).toMatchObject({ status: "plausible", source: "github_cache", appliedMultiplier: 1 }); + expect(mirrorUnavailableWithPlausibleCache.scorePreview.linkedIssueMultiplier.warnings.join(" ")).toMatch(/mirror issue data is unavailable.*did not include linked issue.*#8/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 = { diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 14553a06b4..54bc587576 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -134,6 +134,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 repoFullName: repo.fullName, labels: ["bug"], linkedIssueMode: "standard", + linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [100] }, sourceTokenScore: 58, totalTokenScore: 1500, sourceLines: 120, @@ -187,6 +188,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 repoFullName: repo.fullName, labels: ["bug"], linkedIssueMode: "standard", + linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [100] }, sourceTokenScore: 60, totalTokenScore: 90, sourceLines: 50, @@ -206,6 +208,107 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 expect(preview.privateOnly).toBe(true); }); + it("falls back to a neutral label multiplier when repo defaults are zeroed", () => { + const preview = buildScorePreview({ + repo: { ...repo, registryConfig: { ...repo.registryConfig!, defaultLabelMultiplier: 0, labelMultipliers: {} } }, + snapshot, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + }, + }); + + expect(preview.scoreEstimate.labelMultiplier).toBe(1); + }); + + it("requires solved-by-PR validation before applying the standard linked-issue multiplier", () => { + const baseInput = { + repoFullName: repo.fullName, + linkedIssueMode: "standard" as const, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + }; + const raw = buildScorePreview({ repo, snapshot, input: { ...baseInput, linkedIssueContext: { status: "raw", source: "github_cache", issueNumbers: [7] } } }); + const validated = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [101] } }, + }); + const invalid = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [7], reason: "Issue #7 is closed without solved-by-PR evidence." } }, + }); + const invalidDefaultReason = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { status: "invalid", source: "github_cache", issueNumbers: [8] } }, + }); + const plausible = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { status: "plausible", source: "github_cache", issueNumbers: [9] } }, + }); + const defaultValidated = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { source: "user_supplied", issueNumbers: [10], solvedByPullRequests: [110] } }, + }); + const validatedWithoutSolverNumber = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { status: "validated", source: "github_cache", issueNumbers: [11] } }, + }); + const rawByDefault = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { issueNumbers: [12] } }, + }); + const unavailableByDefault = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: {} }, + }); + const malformedNumbers = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, sourceTokenScore: Number.NaN, linkedIssueContext: { status: "validated", source: "github_cache", issueNumbers: [13, 13, -1, 0, 1.5], solvedByPullRequests: [120, 120, 0] } }, + }); + const unavailable = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueContext: { status: "unavailable", source: "missing", issueNumbers: [7] } }, + }); + const missingContext = buildScorePreview({ repo, snapshot, input: baseInput }); + + expect(raw.linkedIssueMultiplier).toMatchObject({ status: "raw", eligible: false, appliedMultiplier: 1 }); + expect(raw.scoreEstimate.issueMultiplier).toBe(1); + expect(raw.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "linked_issue_unvalidated", severity: "context" })])); + expect(raw.scenarioPreviews.find((scenario) => scenario.name === "linkedIssueFixed")?.linkedIssueMultiplier).toMatchObject({ status: "validated", appliedMultiplier: 1.33 }); + expect(validated.linkedIssueMultiplier).toMatchObject({ status: "validated", eligible: true, solvedByPullRequests: [101], appliedMultiplier: 1.33 }); + expect(validated.scoreEstimate.issueMultiplier).toBe(1.33); + expect(invalid.linkedIssueMultiplier).toMatchObject({ status: "invalid", eligible: false, appliedMultiplier: 1 }); + expect(invalid.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "linked_issue_invalid", severity: "reducer" })])); + expect(invalidDefaultReason.linkedIssueMultiplier.reason).toMatch(/invalid.*#8/i); + expect(plausible.linkedIssueMultiplier).toMatchObject({ status: "plausible", eligible: false, appliedMultiplier: 1 }); + expect(plausible.warnings.join(" ")).toMatch(/plausible.*not solved-by-PR/i); + expect(defaultValidated.linkedIssueMultiplier).toMatchObject({ status: "validated", source: "user_supplied", solvedByPullRequests: [110], appliedMultiplier: 1.33 }); + expect(validatedWithoutSolverNumber.linkedIssueMultiplier.reason).toMatch(/validated for issue\(s\) #11\./i); + expect(rawByDefault.linkedIssueMultiplier).toMatchObject({ status: "raw", source: "user_supplied", issueNumbers: [12], appliedMultiplier: 1 }); + expect(unavailableByDefault.linkedIssueMultiplier).toMatchObject({ status: "unavailable", source: "missing", issueNumbers: [], appliedMultiplier: 1 }); + expect(malformedNumbers.linkedIssueMultiplier).toMatchObject({ issueNumbers: [13], solvedByPullRequests: [120] }); + expect(malformedNumbers.gates.baseTokenGatePassed).toBe(false); + expect(unavailable.warnings.join(" ")).toMatch(/unavailable/i); + expect(missingContext.linkedIssueMultiplier).toMatchObject({ status: "unavailable", source: "missing", appliedMultiplier: 1 }); + }); + it("shows conditional scoreability when current open PR pressure zeroes the effective score", () => { const preview = buildScorePreview({ repo, @@ -213,6 +316,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 input: { repoFullName: repo.fullName, linkedIssueMode: "standard", + linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [100] }, sourceTokenScore: 60, totalTokenScore: 90, sourceLines: 50,