diff --git a/src/api/routes.ts b/src/api/routes.ts index 8db71ad86c..0497155ad6 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -469,7 +469,8 @@ const branchEligibilitySchema = z checkedAt: z.string().max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), stale: z.boolean().optional(), }) - .strict(); + .strict() + .transform((value) => ({ ...value, status: value.status === "eligible" ? ("unknown" as const) : value.status, source: "user_supplied" as const })); const localBranchAnalysisSchema = z .object({ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ec60cea129..a82659e2a2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -198,6 +198,11 @@ const branchEligibilityShape = { stale: z.boolean().optional(), }; +const callerBranchEligibilitySchema = z + .object(branchEligibilityShape) + .strict() + .transform((value) => ({ ...value, status: value.status === "eligible" ? ("unknown" as const) : value.status, source: "user_supplied" as const })); + // Changed-file metadata + local validation results — shared by the local-branch analysis and the #782 local // scorer. METADATA ONLY (paths + line counts), never source content, so the no-upload boundary holds. const changedFileSchema = z @@ -381,7 +386,7 @@ const localBranchAnalysisShape = { projectedCredibility: z.number().min(0).max(1).optional(), scenarioNotes: z.array(z.string()).max(20).optional(), focusManifest: z.record(z.string(), z.unknown()).optional(), - branchEligibility: z.object(branchEligibilityShape).strict().optional(), + branchEligibility: callerBranchEligibilitySchema.optional(), localScorer: z .object({ mode: z.enum(["metadata_only", "external_command", "gittensor_root"]), @@ -454,7 +459,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(), + branchEligibility: callerBranchEligibilitySchema.optional(), }; const variantsShape = { diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index d358cedb4b..5498577f36 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -740,14 +740,14 @@ function decideLinkedIssueMultiplier( const hasSolvedByPullRequestEvidence = solvedByPullRequests.length > 0 || projectedSolvedByPullRequestValidation; const status = requestedStatus === "validated" && !hasSolvedByPullRequestEvidence ? (issueNumbers.length > 0 ? "raw" : "unavailable") : requestedStatus; const source = context?.source ?? (status === "unavailable" ? "missing" : "user_supplied"); - const branchEligible = !(branchEligibility.required && branchEligibility.status === "ineligible"); + const branchEligible = isConfirmedBranchEligible(branchEligibility); const eligible = status === "validated" && hasSolvedByPullRequestEvidence && branchEligible; const reason = branchEligible || status !== "validated" ? status === requestedStatus ? context?.reason ?? linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) : linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) - : "Branch eligibility is confirmed ineligible; standard issue multiplier is not applied."; + : branchEligibilityFailureReason(branchEligibility); return { mode, status, @@ -762,6 +762,19 @@ function decideLinkedIssueMultiplier( }; } +function isConfirmedBranchEligible(branchEligibility: BranchEligibilityResult): boolean { + return !branchEligibility.required || (branchEligibility.status === "eligible" && branchEligibility.evidence === "provided" && !branchEligibility.stale); +} + +function branchEligibilityFailureReason(branchEligibility: BranchEligibilityResult): string { + if (branchEligibility.status === "ineligible") return "Branch eligibility is confirmed ineligible; standard issue multiplier is not applied."; + if (branchEligibility.evidence === "missing") return "Branch eligibility evidence is missing; standard issue multiplier is not applied."; + if (branchEligibility.status === "unknown") return "Branch eligibility is unknown; standard issue multiplier is not applied."; + if (branchEligibility.stale) return "Branch eligibility evidence is stale; standard issue multiplier is not applied."; + if (branchEligibility.source === "user_supplied") return "Branch eligibility evidence is user-supplied; standard issue multiplier is not applied until verified metadata is available."; + return "Branch eligibility is not confirmed; standard issue multiplier is not applied."; +} + function withValidatedLinkedIssueScenario(input: ScorePreviewInput): ScorePreviewInput { const mode = input.linkedIssueMode ?? "none"; if (mode === "maintainer") return input; diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index dc7ca7dba6..92868dd618 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -1123,12 +1123,15 @@ 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") { + if (branchEligibility.status === "eligible" && branchEligibility.source !== "user_supplied") { return [ "- Linked issue context was checked from local/GitHub metadata.", ...(branchEligibility.stale ? ["- Reconfirm linked issue and base branch metadata before submission."] : []), ]; } + if (branchEligibility.status === "eligible") { + return ["- Linked issue context was not confirmed; verify the issue reference and base branch before submission."]; + } if (branchEligibility.status === "ineligible") { return ["- Linked issue context needs cleanup before presenting this PR as solving the issue."]; } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index c98a0d0d1d..709375cd48 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1339,7 +1339,7 @@ describe("api routes", () => { repoFullName: "entrius/allways-ui", preflight: { localDiff: { testFileCount: 1, inferredLinkedIssues: [7] } }, scorePreview: { privateOnly: true }, - branchEligibility: { required: true, status: "eligible", evidence: "provided" }, + branchEligibility: { required: true, status: "unknown", evidence: "provided", source: "user_supplied" }, 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 01eeb1171c..06c3b43900 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -234,6 +234,7 @@ describe("local branch analysis", () => { 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 }, + branchEligibility: { status: "eligible", source: "github_metadata" }, }, repo, issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache refresh fails", state: "open", labels: ["bug"], linkedPrs: [] }], diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index fc05c09756..3c7c55d9ed 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -178,6 +178,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 labels: ["bug"], linkedIssueMode: "standard", linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [100] }, + branchEligibility: { status: "eligible", source: "github_metadata" }, sourceTokenScore: 58, totalTokenScore: 1500, sourceLines: 120, @@ -260,6 +261,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 labels: ["bug"], linkedIssueMode: "standard", linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [100] }, + branchEligibility: { status: "eligible", source: "github_metadata" }, sourceTokenScore: 60, totalTokenScore: 90, sourceLines: 50, @@ -345,7 +347,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 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(missing.scoreEstimate.issueMultiplier).toBe(1); 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)])); @@ -370,7 +372,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 const validated = buildScorePreview({ repo, snapshot, - input: { ...baseInput, linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [101] } }, + input: { ...baseInput, linkedIssueContext: { status: "validated", source: "official_mirror", issueNumbers: [7], solvedByPullRequests: [101] }, branchEligibility: { status: "eligible", source: "github_metadata" } }, }); const invalid = buildScorePreview({ repo, @@ -390,7 +392,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 const defaultValidated = buildScorePreview({ repo, snapshot, - input: { ...baseInput, linkedIssueContext: { source: "user_supplied", issueNumbers: [10], solvedByPullRequests: [110] } }, + input: { ...baseInput, linkedIssueContext: { source: "user_supplied", issueNumbers: [10], solvedByPullRequests: [110] }, branchEligibility: { status: "eligible", source: "github_metadata" } }, }); const validatedWithoutSolverNumber = buildScorePreview({ repo, @@ -436,8 +438,8 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 expect(raw.scoreEstimate.issueMultiplier).toBe(1); expect(raw.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "linked_issue_unvalidated", severity: "context" })])); const rawFixedScenario = raw.scenarioPreviews.find((scenario) => scenario.name === "linkedIssueFixed"); - expect(rawFixedScenario?.linkedIssueMultiplier).toMatchObject({ status: "validated", appliedMultiplier: 1.33 }); - expect(rawFixedScenario?.linkedIssueMultiplier.reason).toBe("Linked issue context is solved-by-PR validated for issue(s) #7."); + expect(rawFixedScenario?.linkedIssueMultiplier).toMatchObject({ status: "validated", appliedMultiplier: 1 }); + expect(rawFixedScenario?.linkedIssueMultiplier.reason).toMatch(/Branch eligibility evidence is missing/); 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 }); @@ -506,7 +508,7 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 expect(afterPending?.source).toBe("user_supplied"); expect(afterPending?.gates.credibilityObserved).toBe(0.8); expect(afterPending?.effectiveEstimatedScore).toBeGreaterThan(0); - expect(linkedIssueFixed?.scoreEstimate.issueMultiplier).toBe(1.33); + expect(linkedIssueFixed?.scoreEstimate.issueMultiplier).toBe(1); expect(JSON.stringify(preview)).not.toMatch(/guaranteed payout|wallet|hotkey|farming/i); });