diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 6ef1bff7eb..8a608c8bb9 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -3456,6 +3456,63 @@ "credibilityObserved" ] }, + "branchEligibility": { + "type": "object", + "properties": { + "required": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "eligible", + "ineligible", + "unknown", + "not_required" + ] + }, + "evidence": { + "type": "string", + "enum": [ + "provided", + "missing" + ] + }, + "source": { + "type": "string", + "enum": [ + "github_metadata", + "local_metadata", + "registry", + "user_supplied", + "missing" + ] + }, + "reason": { + "type": "string" + }, + "checkedAt": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "required", + "status", + "evidence", + "source", + "stale", + "warnings" + ] + }, "effectiveEstimatedScore": { "type": "number" }, @@ -3478,7 +3535,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -3663,7 +3722,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -3836,6 +3897,7 @@ "scoreEstimate", "linkedIssueMultiplier", "gates", + "branchEligibility", "effectiveEstimatedScore", "underlyingPotentialScore", "blockedBy", @@ -4591,7 +4653,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -4837,7 +4901,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -5083,7 +5149,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -5329,7 +5397,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -5575,7 +5645,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -5738,7 +5810,9 @@ "review_penalty", "metadata_only", "linked_issue_invalid", - "linked_issue_unvalidated" + "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing" ] }, "severity": { @@ -5854,6 +5928,63 @@ "notes" ] }, + "branchEligibility": { + "type": "object", + "properties": { + "required": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "eligible", + "ineligible", + "unknown", + "not_required" + ] + }, + "evidence": { + "type": "string", + "enum": [ + "provided", + "missing" + ] + }, + "source": { + "type": "string", + "enum": [ + "github_metadata", + "local_metadata", + "registry", + "user_supplied", + "missing" + ] + }, + "reason": { + "type": "string" + }, + "checkedAt": { + "type": "string" + }, + "stale": { + "type": "boolean" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "required", + "status", + "evidence", + "source", + "stale", + "warnings" + ] + }, "rewardRisk": { "$ref": "#/components/schemas/RepoRewardRisk" }, @@ -6185,6 +6316,7 @@ "scenarioScorePreview", "observedPullRequestScenarios", "githubBranchStatus", + "branchEligibility", "rewardRisk", "scoreBlockers", "branchQualityBlockers", diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 6ed1faa3d7..078d7abd61 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -66,6 +66,14 @@ const localDiffShape = { commitMessage: z.string().optional(), }; +const branchEligibilityShape = { + status: z.enum(["eligible", "ineligible", "unknown"]), + source: z.enum(["github_metadata", "local_metadata", "registry", "user_supplied"]).optional(), + reason: z.string().optional(), + checkedAt: z.string().optional(), + stale: z.boolean().optional(), +}; + const localScoreShape = { ...localDiffShape, targetKey: z.string().optional(), @@ -82,6 +90,7 @@ const localScoreShape = { expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).optional(), + branchEligibility: z.object(branchEligibilityShape).strict().optional(), scorePreviewCommand: z.string().optional(), }; @@ -106,6 +115,7 @@ const currentBranchShape = { expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).optional(), + branchEligibility: z.object(branchEligibilityShape).strict().optional(), validation: z .array( z.object({ @@ -488,6 +498,7 @@ async function runCli(args) { expectedOpenPrCountAfterMerge: optionalInteger(options.expectedOpenPrs), projectedCredibility: optionalNumber(options.projectedCredibility), scenarioNotes: options.scenarioNote, + branchEligibility: branchEligibilityFromOptions(options), validation: validationFromOptions(options), scorePreviewCommand: options.scorePreviewCommand, }); @@ -543,6 +554,7 @@ async function runAgentCli(args) { expectedOpenPrCountAfterMerge: optionalInteger(options.expectedOpenPrs), projectedCredibility: optionalNumber(options.projectedCredibility), scenarioNotes: options.scenarioNote, + branchEligibility: branchEligibilityFromOptions(options), validation: validationFromOptions(options), scorePreviewCommand: options.scorePreviewCommand, }); @@ -644,8 +656,8 @@ function printHelp() { gittensory-mcp changelog [--json] gittensory-mcp doctor [--cwd path] [--json] gittensory-mcp init-client --print codex|claude|cursor|mcp [--json] - gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--json] - gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] + gittensory-mcp analyze-branch --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--scenario-note "..."] [--validation "passed|npm test|summary"] [--json] + gittensory-mcp preflight --login [--repo owner/repo] [--base origin/main] [--branch-eligibility eligible|ineligible|unknown] [--pending-merged-prs 3] [--expected-open-prs 0] [--projected-credibility 0.8] [--validation "passed|npm test|summary"] [--json] gittensory-mcp agent plan --login [--repo owner/repo] [--json] gittensory-mcp agent status [--json] gittensory-mcp agent explain [--json] @@ -1429,6 +1441,7 @@ async function previewLocalScore(input) { expectedOpenPrCountAfterMerge: input.expectedOpenPrCountAfterMerge, projectedCredibility: input.projectedCredibility, scenarioNotes: input.scenarioNotes, + branchEligibility: input.branchEligibility, metadataOnly: !upstreamPreview.ok, }; return { @@ -1447,6 +1460,30 @@ async function previewLocalScore(input) { }; } +function branchEligibilityFromOptions(options) { + const status = options.branchEligibility ?? options.branchEligibilityStatus; + if (!["eligible", "ineligible", "unknown"].includes(status)) return undefined; + const source = ["github_metadata", "local_metadata", "registry", "user_supplied"].includes(options.branchEligibilitySource) ? options.branchEligibilitySource : "user_supplied"; + return stripUndefined({ + status, + source, + reason: options.branchEligibilityReason, + checkedAt: options.branchEligibilityCheckedAt, + stale: optionalBoolean(options.branchEligibilityStale), + }); +} + +function optionalBoolean(value) { + if (value === undefined) return undefined; + if (value === true) return true; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["false", "0", "no", "off"].includes(normalized)) return false; + if (["true", "1", "yes", "on"].includes(normalized)) return true; + } + return Boolean(value); +} + function toolResult(summary, data) { return { content: [ diff --git a/packages/gittensory-mcp/lib/local-branch.js b/packages/gittensory-mcp/lib/local-branch.js index 24378ff3b6..6fa933db65 100644 --- a/packages/gittensory-mcp/lib/local-branch.js +++ b/packages/gittensory-mcp/lib/local-branch.js @@ -76,6 +76,7 @@ export function collectLocalBranchMetadata(input) { scenarioNotes: input.scenarioNotes, pendingCommitCount, ciStatusHints, + branchEligibility: input.branchEligibility, }; return stripUndefined(payload); } diff --git a/src/api/routes.ts b/src/api/routes.ts index 7af81d0c9e..e289b7d2fc 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -278,6 +278,16 @@ const linkedIssueContextSchema = z }) .strict(); +const branchEligibilitySchema = z + .object({ + status: z.enum(["eligible", "ineligible", "unknown"]), + source: z.enum(["github_metadata", "local_metadata", "registry", "user_supplied"]).optional(), + reason: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), + checkedAt: z.string().max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + stale: z.boolean().optional(), + }) + .strict(); + const localBranchAnalysisSchema = z .object({ login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), @@ -306,6 +316,7 @@ const localBranchAnalysisSchema = z pendingCommitCount: z.number().int().min(0).optional(), ciStatusHints: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(20).optional(), focusManifest: z.record(z.unknown()).optional(), + branchEligibility: branchEligibilitySchema.optional(), }) .strict(); @@ -334,6 +345,7 @@ const scorePreviewSchema = z.object({ expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).max(20).optional(), + branchEligibility: branchEligibilitySchema.optional(), }); const agentSurfaceSchema = z.enum(["api", "mcp", "github_comment"]).default("api"); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 32309abe8d..fdfbd0bb81 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -115,6 +115,14 @@ const localDiffPreflightShape = { commitMessage: z.string().optional(), }; +const branchEligibilityShape = { + status: z.enum(["eligible", "ineligible", "unknown"]), + source: z.enum(["github_metadata", "local_metadata", "registry", "user_supplied"]).optional(), + reason: z.string().optional(), + checkedAt: z.string().optional(), + stale: z.boolean().optional(), +}; + const localBranchAnalysisShape = { login: z.string().min(1), repoFullName: z.string().min(3), @@ -166,6 +174,7 @@ const localBranchAnalysisShape = { projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).max(20).optional(), focusManifest: z.record(z.unknown()).optional(), + branchEligibility: z.object(branchEligibilityShape).strict().optional(), localScorer: z .object({ mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), @@ -236,6 +245,7 @@ const scorePreviewShape = { expectedOpenPrCountAfterMerge: z.number().int().min(0).optional(), projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).max(20).optional(), + branchEligibility: z.object(branchEligibilityShape).strict().optional(), }; const variantsShape = { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 770ecdb6ff..ad81a08559 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -951,6 +951,17 @@ const ScoreGatesSchema = z.object({ credibilityObserved: z.number(), }); +const BranchEligibilitySchema = z.object({ + required: z.boolean(), + status: z.enum(["eligible", "ineligible", "unknown", "not_required"]), + evidence: z.enum(["provided", "missing"]), + source: z.enum(["github_metadata", "local_metadata", "registry", "user_supplied", "missing"]), + reason: z.string().optional(), + checkedAt: z.string().optional(), + stale: z.boolean(), + warnings: z.array(z.string()), +}); + const ScoreGateBlockerSchema = z.object({ code: z.enum([ "repo_not_registered", @@ -962,6 +973,8 @@ const ScoreGateBlockerSchema = z.object({ "metadata_only", "linked_issue_invalid", "linked_issue_unvalidated", + "branch_ineligible", + "branch_eligibility_missing", ]), severity: z.enum(["blocker", "reducer", "context"]), detail: z.string(), @@ -1011,6 +1024,7 @@ export const ScorePreviewResultSchema = z scoreEstimate: ScoreEstimateSchema, linkedIssueMultiplier: LinkedIssueMultiplierDecisionSchema, gates: ScoreGatesSchema, + branchEligibility: BranchEligibilitySchema, effectiveEstimatedScore: z.number(), underlyingPotentialScore: z.number(), blockedBy: z.array(ScoreGateBlockerSchema), @@ -1637,6 +1651,7 @@ export const LocalBranchAnalysisSchema = z mergeableState: z.string().nullable().optional(), notes: z.array(z.string()), }), + branchEligibility: BranchEligibilitySchema, rewardRisk: RepoRewardRiskSchema, scoreBlockers: z.array(z.string()), branchQualityBlockers: z.array(z.string()), diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 33509b8b1a..7b8cc98b27 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -34,6 +34,26 @@ export type ScorePreviewInput = { scenarioNotes?: string[] | undefined; pendingScenarioObserved?: boolean | undefined; observedScenarioNotes?: string[] | undefined; + branchEligibility?: BranchEligibilityInput | undefined; +}; + +export type BranchEligibilityInput = { + status: "eligible" | "ineligible" | "unknown"; + source?: "github_metadata" | "local_metadata" | "registry" | "user_supplied" | undefined; + reason?: string | undefined; + checkedAt?: string | undefined; + stale?: boolean | undefined; +}; + +export type BranchEligibilityResult = { + required: boolean; + status: "eligible" | "ineligible" | "unknown" | "not_required"; + evidence: "provided" | "missing"; + source: "github_metadata" | "local_metadata" | "registry" | "user_supplied" | "missing"; + reason?: string | undefined; + checkedAt?: string | undefined; + stale: boolean; + warnings: string[]; }; export type LinkedIssueMultiplierStatus = "not_required" | "raw" | "plausible" | "validated" | "invalid" | "unavailable"; @@ -72,7 +92,9 @@ export type ScoreGateBlocker = { | "review_penalty" | "metadata_only" | "linked_issue_invalid" - | "linked_issue_unvalidated"; + | "linked_issue_unvalidated" + | "branch_ineligible" + | "branch_eligibility_missing"; severity: "blocker" | "reducer" | "context"; detail: string; }; @@ -132,6 +154,7 @@ export type ScorePreviewResult = { credibilityFloor: number; credibilityObserved: number; }; + branchEligibility: BranchEligibilityResult; effectiveEstimatedScore: number; underlyingPotentialScore: number; blockedBy: ScoreGateBlocker[]; @@ -152,14 +175,15 @@ export function buildScorePreview(args: { snapshot: ScoringModelSnapshotRecord; contributorEvidence?: ContributorEvidenceRecord | null | undefined; }): ScorePreviewResult { + const branchEligibility = normalizeBranchEligibility(args.input); const current = computeScoreCore(args.input, args.repo, args.snapshot, args.contributorEvidence); const scenarioPreviews = buildScenarioPreviews(args.input, args.repo, args.snapshot, args.contributorEvidence, current); - const blockedBy = blockedByFor(args.input, args.repo, current); + const blockedBy = blockedByFor(args.input, args.repo, current, branchEligibility); const gateDeltas = buildGateDeltas(current, scenarioPreviews); const effectiveEstimatedScore = current.scoreEstimate.estimatedMergedScore; const underlyingPotentialScore = current.scoreEstimate.pendingSaturationScore; const scoreabilityStatus = statusFor(args.repo, blockedBy, effectiveEstimatedScore, scenarioPreviews); - const warnings = warningsFor(args.input, args.repo, current); + const warnings = warningsFor(args.input, args.repo, current, branchEligibility); const actions = [ ...(!current.gates.baseTokenGatePassed ? ["Increase meaningful source change size or scope clarity before relying on this preview."] : []), ...(current.scoreEstimate.openPrMultiplier === 0 ? ["Land or close existing open PRs before opening more concurrent work."] : []), @@ -168,6 +192,10 @@ 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."] : []), + ...(branchEligibility.required && branchEligibility.status === "ineligible" ? ["Use an eligible branch or remove linked-issue assumptions before relying on this preview."] : []), + ...(branchEligibility.required && (branchEligibility.evidence === "missing" || branchEligibility.stale) + ? ["Refresh branch/base eligibility metadata before relying on linked-issue assumptions."] + : []), ...(current.linkedIssueMultiplier.mode === "standard" && !current.linkedIssueMultiplier.eligible ? ["Validate linked issue context with solved-by-PR evidence before relying on the standard issue multiplier."] : []), @@ -183,6 +211,7 @@ export function buildScorePreview(args: { scoreEstimate: current.scoreEstimate, linkedIssueMultiplier: current.linkedIssueMultiplier, gates: current.gates, + branchEligibility, effectiveEstimatedScore, underlyingPotentialScore, blockedBy, @@ -195,6 +224,7 @@ export function buildScorePreview(args: { "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}`, + ...branchEligibility.warnings, ...(args.input.scenarioNotes ?? []).map((note) => `User scenario note: ${note}`), ], recommendation: { @@ -261,7 +291,8 @@ function computeScoreCore( : densityBaseScore; const activeContributionBonus = snapshot.activeModel === "pending_saturation_model" ? saturationContributionBonusValue : densityContributionBonus; const labelMultiplier = selectLabelMultiplier(input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1); - const linkedIssueMultiplier = decideLinkedIssueMultiplier(input.linkedIssueMode ?? "none", input.linkedIssueContext, constants); + const branchEligibility = normalizeBranchEligibility(input); + const linkedIssueMultiplier = decideLinkedIssueMultiplier(input.linkedIssueMode ?? "none", input.linkedIssueContext, constants, branchEligibility); const issueMultiplier = linkedIssueMultiplier.appliedMultiplier; const credibilityObserved = clamp(input.credibility ?? inferCredibility(contributorEvidence), 0, 1); const credibilityFloor = constant(constants, "MIN_CREDIBILITY", 0.8); @@ -461,7 +492,7 @@ function scenario( }; } -function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore): ScoreGateBlocker[] { +function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore, branchEligibility = normalizeBranchEligibility(input)): ScoreGateBlocker[] { return [ ...(!repo?.isRegistered ? [{ code: "repo_not_registered" as const, severity: "blocker" as const, detail: "Repository is not registered in the local Gittensory cache." }] @@ -472,6 +503,27 @@ function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, c ...(input.metadataOnly ? [{ code: "metadata_only" as const, severity: "context" as const, detail: "Preview used metadata-only inputs, so token and density estimates are rough." }] : []), + ...(branchEligibility.required && branchEligibility.status === "ineligible" + ? [ + { + code: "branch_ineligible" as const, + severity: "reducer" as const, + detail: "Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled.", + }, + ] + : []), + ...(branchEligibility.required && branchEligibility.status === "unknown" + ? [ + { + code: "branch_eligibility_missing" as const, + severity: "context" as const, + detail: + branchEligibility.evidence === "missing" + ? "Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions." + : "Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions.", + }, + ] + : []), ...(!core.gates.baseTokenGatePassed ? [{ code: "base_token_gate" as const, severity: "blocker" as const, detail: "Source token score does not pass the current base-score token gate." }] : []), @@ -561,8 +613,8 @@ function buildGateDeltas(current: ScoreCore, scenarios: ScoreScenarioPreview[]): ]; } -function warningsFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore): string[] { - return [...new Set([...blockedByFor(input, repo, core).map((blocker) => blocker.detail), ...core.linkedIssueMultiplier.warnings])]; +function warningsFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore, branchEligibility = normalizeBranchEligibility(input)): string[] { + return [...new Set([...blockedByFor(input, repo, core, branchEligibility).map((blocker) => blocker.detail), ...core.linkedIssueMultiplier.warnings])]; } function statusFor( @@ -596,6 +648,7 @@ function decideLinkedIssueMultiplier( mode: "none" | "standard" | "maintainer", context: LinkedIssueMultiplierContext | undefined, constants: Record, + branchEligibility: BranchEligibilityResult, ): LinkedIssueMultiplierDecision { const baseMultiplier = selectIssueMultiplier(mode, constants); const issueNumbers = uniquePositiveInts(context?.issueNumbers ?? []); @@ -631,8 +684,12 @@ function decideLinkedIssueMultiplier( 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); + const branchEligible = !(branchEligibility.required && branchEligibility.status === "ineligible"); + const eligible = status === "validated" && branchEligible; + const reason = + branchEligible || status !== "validated" + ? context?.reason ?? linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) + : "Branch eligibility is confirmed ineligible; standard issue multiplier is not applied."; return { mode, status, @@ -643,7 +700,7 @@ function decideLinkedIssueMultiplier( baseMultiplier, appliedMultiplier: eligible ? baseMultiplier : 1, reason, - warnings: [...new Set([...linkedIssueWarnings(status), ...(context?.warnings ?? [])])], + warnings: [...new Set([...linkedIssueWarnings(status), ...branchEligibility.warnings, ...(context?.warnings ?? [])])], }; } @@ -702,6 +759,50 @@ function selectIssueMultiplier(mode: "none" | "standard" | "maintainer", constan return 1; } +function normalizeBranchEligibility(input: ScorePreviewInput): BranchEligibilityResult { + const required = input.linkedIssueMode === "standard"; + const supplied = input.branchEligibility; + if (!required) { + return { + required: false, + status: "not_required", + evidence: supplied ? "provided" : "missing", + source: supplied ? supplied.source ?? "user_supplied" : "missing", + reason: supplied?.reason, + checkedAt: supplied?.checkedAt, + stale: Boolean(supplied?.stale), + warnings: [], + }; + } + if (!supplied) { + return { + required: true, + status: "unknown", + evidence: "missing", + source: "missing", + stale: false, + warnings: ["Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions."], + }; + } + const status = supplied.status ?? "unknown"; + const stale = Boolean(supplied.stale); + const warnings = [ + ...(status === "ineligible" ? ["Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled."] : []), + ...(status === "unknown" ? ["Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions."] : []), + ...(stale ? ["Branch eligibility evidence is stale; refresh branch/base metadata before relying on linked-issue assumptions."] : []), + ]; + return { + required: true, + status, + evidence: "provided", + source: supplied.source ?? "user_supplied", + reason: supplied.reason, + checkedAt: supplied.checkedAt, + stale, + warnings, + }; +} + function inferCredibility(evidence?: ContributorEvidenceRecord | null): number { const payload = evidence?.payload; const merged = Number(payload?.mergedPullRequests ?? 0); diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 99e0954683..c74bd6171b 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -276,6 +276,7 @@ async function executeLocalBranchRun(env: Env, run: AgentRunRecord, kind: string payload: { repoFullName: analysis.repoFullName, baseFreshness: analysis.baseFreshness as unknown as JsonValue, + branchEligibility: analysis.branchEligibility as unknown as JsonValue, scoreabilityStatus: analysis.scorePreview.scoreabilityStatus, dataQuality: (analysis.dataQuality ?? null) as unknown as JsonValue, }, diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 1c79fe094b..ce07e5ed16 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -1,4 +1,4 @@ -import type { LinkedIssueMultiplierContext, ScorePreviewInput, ScorePreviewResult } from "../scoring/preview"; +import type { BranchEligibilityInput, BranchEligibilityResult, 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"; @@ -75,6 +75,7 @@ export type LocalBranchAnalysisInput = { pendingCommitCount?: number | undefined; ciStatusHints?: string[] | undefined; focusManifest?: unknown; + branchEligibility?: BranchEligibilityInput | undefined; }; type ObservedPullRequestScenarios = { @@ -132,6 +133,7 @@ export type LocalBranchAnalysis = { }; observedPullRequestScenarios: ObservedPullRequestScenarios; githubBranchStatus: GitHubBranchStatus; + branchEligibility: BranchEligibilityResult; rewardRisk: RepoRewardRisk; scoreBlockers: string[]; branchQualityBlockers: string[]; @@ -287,7 +289,7 @@ export function buildLocalBranchAnalysis(args: { passedValidationCount: validationSummary.passed, }); const localFindings = [ - ...buildLocalFindings(args.input, changedFiles, preflight, scorePreview, baseFreshness, githubBranchStatus), + ...buildLocalFindings(args.input, changedFiles, preflight, scorePreview, baseFreshness, githubBranchStatus, scorePreview.branchEligibility), ...manifestGuidance.findings.map((finding) => ({ code: finding.code, severity: finding.severity, @@ -311,7 +313,7 @@ export function buildLocalBranchAnalysis(args: { gateDeltas: scorePreview.gateDeltas, blockedBy: scorePreview.blockedBy, }; - const recommendedRerunCondition = recommendedRerunFor(baseFreshness, branchQualityBlockers, accountStateBlockers, scorePreview); + const recommendedRerunCondition = recommendedRerunFor(baseFreshness, branchQualityBlockers, accountStateBlockers, scorePreview, scorePreview.branchEligibility); const prPacket = buildPublicSafePrPacket({ title, preflight, @@ -322,12 +324,13 @@ export function buildLocalBranchAnalysis(args: { localFindings, baseFreshness, githubBranchStatus, + branchEligibility: scorePreview.branchEligibility, recommendedRerunCondition, manifestGuidance, }); const scoreBlockers = [ ...rewardRisk.scoreBlockers, - ...scorePreview.warnings.filter((warning) => /not registered|no active|exceeds|credibility|token gate/i.test(warning)), + ...scorePreview.warnings.filter((warning) => /not registered|no active|exceeds|credibility|token gate|confirmed ineligible/i.test(warning)), ...preflight.findings.filter((finding) => finding.severity !== "info").map((finding) => finding.title), ]; return { @@ -345,6 +348,7 @@ export function buildLocalBranchAnalysis(args: { scenarioScorePreview, observedPullRequestScenarios, githubBranchStatus, + branchEligibility: scorePreview.branchEligibility, rewardRisk, scoreBlockers: [...new Set(scoreBlockers)], branchQualityBlockers, @@ -424,6 +428,7 @@ function buildLocalScoreInput(args: { projectedCredibility: args.input.projectedCredibility, scenarioNotes: args.input.scenarioNotes, observedScenarioNotes: args.observedPullRequestScenarios.notes, + branchEligibility: args.input.branchEligibility, }; } @@ -703,6 +708,7 @@ function buildLocalFindings( scorePreview: ScorePreviewResult, baseFreshness: LocalBranchAnalysis["baseFreshness"], githubBranchStatus: GitHubBranchStatus, + branchEligibility: BranchEligibilityResult, ): LocalBranchAnalysis["localFindings"] { const failedValidation = (input.validation ?? []).filter((entry) => entry.status === "failed"); const localScorerWarnings = sanitizeLocalScorerWarnings(input.localScorer?.warnings); @@ -797,12 +803,15 @@ function buildLocalFindings( ] : []), ...githubBranchFindings(githubBranchStatus), - ...scorePreview.warnings.map((warning) => ({ - code: "score_preview_warning", - severity: /not registered|no active|exceeds|credibility/i.test(warning) ? ("warning" as const) : ("info" as const), - title: "Private preview warning", - detail: warning, - })), + ...branchEligibilityFindings(branchEligibility), + ...scorePreview.warnings + .filter((warning) => !/branch eligibility/i.test(warning)) + .map((warning) => ({ + code: "score_preview_warning", + severity: /not registered|no active|exceeds|credibility/i.test(warning) ? ("warning" as const) : ("info" as const), + title: "Private preview warning", + detail: warning, + })), ...preflight.findings.map((finding) => ({ code: `preflight_${finding.code}`, severity: finding.severity, @@ -813,6 +822,45 @@ function buildLocalFindings( ]; } +function branchEligibilityFindings(branchEligibility: BranchEligibilityResult): LocalBranchAnalysis["localFindings"] { + if (!branchEligibility.required) return []; + const detail = branchEligibility.warnings.join(" ") || "Branch eligibility metadata did not confirm the linked issue assumption."; + if (branchEligibility.status === "ineligible") { + return [ + { + code: "branch_eligibility_ineligible", + severity: "warning" as const, + title: "Branch eligibility blocks linked-issue assumptions", + detail, + action: "Clean up linked issue context or use a branch whose linked issue context is valid before submission.", + }, + ]; + } + if (branchEligibility.stale) { + return [ + { + code: "branch_eligibility_stale", + severity: "warning" as const, + title: "Branch eligibility metadata is stale", + detail, + action: "Refresh branch/base metadata and rerun branch analysis before submission.", + }, + ]; + } + if (branchEligibility.status === "unknown") { + return [ + { + code: branchEligibility.evidence === "missing" ? "branch_eligibility_missing" : "branch_eligibility_unknown", + severity: "info" as const, + title: branchEligibility.evidence === "missing" ? "Branch eligibility evidence missing" : "Branch eligibility unknown", + detail, + action: "Refresh branch/base metadata before relying on linked issue context.", + }, + ]; + } + return []; +} + function githubBranchFindings(status: GitHubBranchStatus): LocalBranchAnalysis["localFindings"] { if (status.status === "failing_checks" || status.status === "needs_author") { return [ @@ -899,8 +947,13 @@ function recommendedRerunFor( branchQualityBlockers: string[], accountStateBlockers: string[], scorePreview: ScorePreviewResult, + branchEligibility: BranchEligibilityResult, ): string { if (baseFreshness.status === "stale" || baseFreshness.status === "possibly_stale") return "Run `git fetch origin` and rerun; current diff size may be inflated by stale base state."; + if (branchEligibility.required && branchEligibility.status === "ineligible") return "Rerun after branch/base metadata confirms eligibility or after linked issue assumptions change."; + if (branchEligibility.required && (branchEligibility.stale || (branchEligibility.status === "unknown" && branchEligibility.evidence === "provided"))) { + return "Refresh branch/base eligibility metadata and rerun before relying on linked issue assumptions."; + } if (branchQualityBlockers.length > 0) return "Rerun after fixing branch-quality blockers or adding explicit validation/linked-context evidence."; const afterPending = scorePreview.scenarioPreviews.find((scenario) => scenario.name === "afterPendingMerges"); if (accountStateBlockers.length > 0 && afterPending && afterPending.effectiveEstimatedScore > scorePreview.effectiveEstimatedScore) { @@ -950,6 +1003,7 @@ function buildPublicSafePrPacket(args: { localFindings: LocalBranchAnalysis["localFindings"]; baseFreshness: LocalBranchAnalysis["baseFreshness"]; githubBranchStatus: GitHubBranchStatus; + branchEligibility: BranchEligibilityResult; recommendedRerunCondition: string; manifestGuidance: FocusManifestGuidance; }): LocalBranchAnalysis["prPacket"] { @@ -973,7 +1027,7 @@ function buildPublicSafePrPacket(args: { ...publicSafeWarnings, ...args.manifestGuidance.publicNextSteps, args.baseFreshness.recommendation, - args.recommendedRerunCondition, + publicSafeRerunCondition(args.recommendedRerunCondition), "Keep source upload disabled; this packet is based on local git metadata only.", ].filter((line): line is string => Boolean(line && isPublicSafeText(line))); const manifestFocus = manifestFocusLines(args.manifestGuidance); @@ -990,6 +1044,7 @@ function buildPublicSafePrPacket(args: { heading: "Linked Context", lines: args.preflight.linkedIssues.length > 0 ? args.preflight.linkedIssues.map((issue) => `- Closes #${issue}`) : ["- No linked issue detected; explain why this is a no-issue PR."], }, + { heading: "Linked Issue Hygiene", lines: linkedIssueHygieneLines(args.branchEligibility) }, { heading: "Branch Freshness", lines: branchFreshnessLines(args.baseFreshness) }, { heading: "GitHub Status", lines: githubStatusLines(args.githubBranchStatus) }, ...(manifestFocus.length > 0 ? [{ heading: "Maintainer Focus", lines: manifestFocus }] : []), @@ -1018,6 +1073,24 @@ function buildPublicSafePrPacket(args: { }; } +function linkedIssueHygieneLines(branchEligibility: BranchEligibilityResult): string[] { + if (!branchEligibility.required) return ["- No issue-specific branch gate was required from supplied metadata."]; + if (branchEligibility.status === "eligible") { + return [ + "- Linked issue context was checked from local/GitHub metadata.", + ...(branchEligibility.stale ? ["- Reconfirm linked issue and base branch metadata before submission."] : []), + ]; + } + if (branchEligibility.status === "ineligible") { + return ["- Linked issue context needs cleanup before presenting this PR as solving the issue."]; + } + return ["- Linked issue context was not confirmed; verify the issue reference and base branch before submission."]; +} + +function publicSafeRerunCondition(condition: string): string { + return /eligibility|multiplier|scoreability|score/i.test(condition) ? "Refresh linked issue and base branch metadata before submission." : condition; +} + function branchFreshnessLines(freshness: LocalBranchAnalysis["baseFreshness"]): string[] { return [`- Base freshness: ${freshness.status}.`, ...freshness.warnings.filter(isPublicSafeText).map((warning) => `- ${warning}`), freshness.passedValidationCount > 0 ? `- Validation evidence supplied: ${freshness.passedValidationCount} passed command(s).` : "- No passed validation evidence was supplied."]; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 3f4dd0075b..8401ac92cf 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -808,6 +808,7 @@ describe("api routes", () => { ], validation: [{ command: "npm test -- cache", status: "passed", summary: "cache regression passed" }], localScorer: { mode: "external_command", sourceTokenScore: 42, totalTokenScore: 66, sourceLines: 44, testTokenScore: 20 }, + branchEligibility: { status: "eligible", source: "github_metadata", checkedAt: "2026-05-30T00:00:00.000Z" }, }), }, env, @@ -821,6 +822,7 @@ describe("api routes", () => { repoFullName: "entrius/allways-ui", preflight: { localDiff: { testFileCount: 1, inferredLinkedIssues: [7] } }, scorePreview: { privateOnly: true }, + branchEligibility: { required: true, status: "eligible", evidence: "provided" }, rewardRisk: { rewardUpside: { relevantLane: "direct_pr" } }, prPacket: { titleSuggestion: "Fix dashboard cache refresh after reconnect" }, }); diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index b87f6cf0eb..f9f072493c 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -228,6 +228,79 @@ describe("local branch analysis", () => { expect(JSON.stringify(analysis.prPacket)).not.toMatch(/multiplier|reward|score|wallet|hotkey|farming|payout|ranking|trust score/i); }); + it("separates branch eligibility from account blockers in local branch analysis", () => { + const commonInput = { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [ + { path: "src/cache.ts", additions: 24, deletions: 2, status: "modified" as const }, + { path: "src/cache.test.ts", additions: 18, deletions: 0, status: "added" as const }, + ], + validation: [{ command: "npm test -- cache", status: "passed" as const }], + localScorer: { mode: "external_command" as const, sourceTokenScore: 40, totalTokenScore: 70, sourceLines: 38, testTokenScore: 18 }, + }; + const commonArgs = { + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache refresh fails", state: "open" as const, labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + gittensorSnapshot: { + issueMirrorAvailable: true, + issues: [{ repoFullName: repo.fullName, number: 7, state: "closed", solvedByPullRequest: 144, labels: ["bug"] }], + } as never, + }; + const eligible = buildLocalBranchAnalysis({ + input: { + ...commonInput, + branchEligibility: { status: "eligible", source: "github_metadata", checkedAt: "2026-05-30T00:00:00.000Z" }, + }, + ...commonArgs, + }); + const ineligible = buildLocalBranchAnalysis({ + input: { + ...commonInput, + branchEligibility: { status: "ineligible", source: "github_metadata", reason: "base branch is not eligible" }, + }, + ...commonArgs, + }); + const missing = buildLocalBranchAnalysis({ + input: commonInput, + ...commonArgs, + }); + const stale = buildLocalBranchAnalysis({ + input: { + ...commonInput, + branchEligibility: { status: "eligible", source: "local_metadata", stale: true, checkedAt: "2026-05-01T00:00:00.000Z" }, + }, + ...commonArgs, + }); + + expect(eligible.branchEligibility).toMatchObject({ status: "eligible", evidence: "provided" }); + expect(eligible.scorePreview.scoreEstimate.issueMultiplier).toBe(1.33); + expect(eligible.branchQualityBlockers.join(" ")).not.toMatch(/eligibility/i); + expect(eligible.prPacket.markdown).toContain("Linked issue context was checked"); + expect(ineligible.scorePreview.scoreEstimate.issueMultiplier).toBe(1); + expect(ineligible.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "branch_eligibility_ineligible" })])); + expect(ineligible.branchQualityBlockers).toEqual(expect.arrayContaining(["Branch eligibility blocks linked-issue assumptions"])); + expect(ineligible.accountStateBlockers.join(" ")).not.toMatch(/eligibility/i); + expect(ineligible.recommendedRerunCondition).toMatch(/eligibility/i); + expect(ineligible.prPacket.markdown).toContain("Linked issue context needs cleanup"); + expect(missing.branchEligibility).toMatchObject({ status: "unknown", evidence: "missing" }); + expect(missing.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "branch_eligibility_missing", severity: "info" })])); + expect(missing.branchQualityBlockers.join(" ")).not.toMatch(/Branch eligibility evidence missing/i); + expect(stale.localFindings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "branch_eligibility_stale", severity: "warning" })])); + expect(stale.recommendedRerunCondition).toMatch(/branch\/base eligibility metadata/i); + expect(stale.prPacket.markdown).toContain("Reconfirm linked issue and base branch metadata"); + expect(stale.prPacket.markdown).not.toMatch(/eligibility|issue multiplier|scoreability/i); + expect(JSON.stringify({ eligible: eligible.prPacket, ineligible: ineligible.prPacket, missing: missing.prPacket, stale: stale.prPacket })).not.toMatch( + /eligibility|issue multiplier|scoreability|reward|score|wallet|hotkey|farming|payout|ranking|trust score/i, + ); + }); + it("surfaces mirror raw, invalid, and unavailable linkage fallbacks privately", () => { const baseInput = { login: "oktofeesh1", diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index eac6d661f7..65aaf23fed 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -481,6 +481,48 @@ describe("gittensory-mcp CLI", () => { expect(JSON.stringify(packet.validation)).not.toMatch(/C:\/Users|alice/i); }); + it("sends branch eligibility metadata without local source contents", async () => { + tempDir = createPacketRepo(); + mkdirSync(join(tempDir, "src")); + writeFileSync(join(tempDir, "src/eligible.ts"), "export const source = 'must stay local';\n"); + git(tempDir, "add", "src/eligible.ts"); + const requests: unknown[] = []; + const url = await startFixtureServer({ onPacketRequest: (body) => requests.push(body) }); + await runAsync( + [ + "agent", + "packet", + "--login", + "oktofeesh1", + "--cwd", + tempDir, + "--base", + "HEAD", + "--body", + "Fixes #90", + "--branch-eligibility", + "ineligible", + "--branch-eligibility-source", + "github_metadata", + "--branch-eligibility-reason", + "head branch is not eligible", + "--branch-eligibility-stale", + "false", + "--json", + ], + { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + }, + ); + + const packet = requests[0] as { branchEligibility: { status: string; source: string; reason: string; stale: boolean }; changedFiles: Array<{ path: string }> }; + expect(packet.branchEligibility).toMatchObject({ status: "ineligible", source: "github_metadata", reason: "head branch is not eligible", stale: false }); + expect(packet.changedFiles).toEqual(expect.arrayContaining([expect.objectContaining({ path: "src/eligible.ts" })])); + expect(JSON.stringify(packet)).not.toMatch(/must stay local|export const source/); + }); + it("classifies nonzero validation status phrases as failed", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); git(tempDir, "init"); diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 565244b676..ddf75e5d72 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -253,6 +253,66 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 expect(preview.scoreEstimate.labelMultiplier).toBe(1); }); + it("gates linked-issue assumptions with branch eligibility evidence", () => { + const baseInput = { + repoFullName: repo.fullName, + labels: ["bug"], + linkedIssueMode: "standard" as const, + linkedIssueContext: { status: "validated" as const, source: "official_mirror" as const, issueNumbers: [7], solvedByPullRequests: [100] }, + sourceTokenScore: 60, + totalTokenScore: 90, + sourceLines: 50, + openPrCount: 0, + credibility: 1, + }; + const eligible = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, branchEligibility: { status: "eligible", source: "github_metadata", checkedAt: "2026-05-30T00:00:00.000Z" } }, + }); + const ineligible = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, branchEligibility: { status: "ineligible", source: "github_metadata", reason: "head branch is not eligible" } }, + }); + const missing = buildScorePreview({ repo, snapshot, input: baseInput }); + const unknown = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, branchEligibility: { status: "unknown", stale: true } }, + }); + const implicitUnknown = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, branchEligibility: {} as never }, + }); + const notRequired = buildScorePreview({ + repo, + snapshot, + input: { ...baseInput, linkedIssueMode: "none", branchEligibility: { status: "eligible" } }, + }); + + expect(eligible.branchEligibility).toMatchObject({ required: true, status: "eligible", evidence: "provided" }); + expect(eligible.scoreEstimate.issueMultiplier).toBe(1.33); + expect(eligible.blockedBy.map((blocker) => blocker.code)).not.toContain("branch_ineligible"); + expect(ineligible.branchEligibility).toMatchObject({ required: true, status: "ineligible", evidence: "provided", reason: "head branch is not eligible" }); + expect(ineligible.scoreEstimate.issueMultiplier).toBe(1); + expect(ineligible.scoreEstimate.estimatedMergedScore).toBeLessThan(eligible.scoreEstimate.estimatedMergedScore); + expect(ineligible.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "branch_ineligible", severity: "reducer" })])); + expect(ineligible.recommendation.actions).toEqual(expect.arrayContaining([expect.stringMatching(/eligible branch/i)])); + expect(missing.branchEligibility).toMatchObject({ required: true, status: "unknown", evidence: "missing", source: "missing" }); + expect(missing.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "branch_eligibility_missing", severity: "context" })])); + expect(missing.scoreEstimate.issueMultiplier).toBe(1.33); + expect(unknown.branchEligibility).toMatchObject({ required: true, status: "unknown", evidence: "provided", source: "user_supplied", stale: true }); + expect(unknown.branchEligibility.warnings.join(" ")).toMatch(/unknown.*stale/i); + expect(unknown.recommendation.actions).toEqual(expect.arrayContaining([expect.stringMatching(/refresh branch\/base eligibility metadata/i)])); + expect(implicitUnknown.branchEligibility).toMatchObject({ required: true, status: "unknown", evidence: "provided", source: "user_supplied" }); + expect(notRequired.branchEligibility).toMatchObject({ required: false, status: "not_required", evidence: "provided", source: "user_supplied" }); + expect(notRequired.scoreEstimate.issueMultiplier).toBe(1); + expect(notRequired.blockedBy.map((blocker) => blocker.code)).not.toContain("branch_eligibility_missing"); + expect(JSON.stringify({ eligible, ineligible, missing, unknown, implicitUnknown, notRequired })).not.toMatch(/guaranteed payout|wallet|hotkey|farming/i); + }); + it("requires solved-by-PR validation before applying the standard linked-issue multiplier", () => { const baseInput = { repoFullName: repo.fullName,