diff --git a/src/api/routes.ts b/src/api/routes.ts index aa8ceff4ab..7b1eeb78d1 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -23,6 +23,7 @@ import { } from "../auth/security"; import { normalizeGittBountySnapshot } from "../bounties/ingest"; import { DEFAULT_COMMAND_AUTHORIZATION_POLICY, normalizeCommandAuthorizationPolicy } from "../settings/command-authorization"; +import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { countOpenIssues, countOpenPullRequests, @@ -410,10 +411,10 @@ const branchEligibilitySchema = z const localBranchAnalysisSchema = z .object({ login: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), - repoFullName: z.string().min(3).max(MAX_LOCAL_BRANCH_REF_CHARS), - baseRef: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - headRef: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), - branchName: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + baseRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), + headRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), + branchName: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), baseSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), headSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), mergeBaseSha: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), @@ -421,7 +422,7 @@ const localBranchAnalysisSchema = z commitMessages: z.array(z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS)).max(30).optional(), changedFiles: z.array(localBranchChangedFileSchema).max(500).optional(), validation: z.array(localBranchValidationSchema).max(50).optional(), - linkedIssues: z.array(z.number().int().positive()).optional(), + linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), labels: z.array(z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS)).max(50).optional(), title: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS).optional(), body: z.string().max(MAX_LOCAL_BRANCH_TEXT_CHARS).optional(), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3535179f26..65cb99cfef 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -79,6 +79,7 @@ import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signa import { loadRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoDataQuality } from "../signals/data-quality"; import { PREFLIGHT_LIMITS } from "../signals/preflight-limits"; +import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS, SCENARIO_MAX_REPO_FULL_NAME_CHARS } from "../scenarios/input-model"; import { loadUpstreamStatus } from "../upstream/ruleset"; type AppContext = Context<{ Bindings: Env }>; @@ -162,11 +163,11 @@ const branchEligibilityShape = { }; const localBranchAnalysisShape = { - login: z.string().min(1), - repoFullName: z.string().min(3), - baseRef: z.string().min(1).optional(), - headRef: z.string().min(1).optional(), - branchName: z.string().min(1).optional(), + login: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS), + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), + baseRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), + headRef: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), + branchName: z.string().min(1).max(SCENARIO_MAX_BRANCH_REF_CHARS).optional(), baseSha: z.string().min(1).optional(), headSha: z.string().min(1).optional(), mergeBaseSha: z.string().min(1).optional(), @@ -201,7 +202,7 @@ const localBranchAnalysisShape = { ) .max(50) .optional(), - linkedIssues: z.array(z.number().int().positive()).optional(), + linkedIssues: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), labels: z.array(z.string()).optional(), title: z.string().min(1).optional(), body: z.string().optional(), diff --git a/src/scenarios/input-model.ts b/src/scenarios/input-model.ts index 109c66dab4..f7a08d9973 100644 --- a/src/scenarios/input-model.ts +++ b/src/scenarios/input-model.ts @@ -2,6 +2,10 @@ import { z } from "zod"; import { sanitizePublicComment } from "../github/commands"; export const SCENARIO_INPUT_VERSION = 1 as const; +export const SCENARIO_MAX_REPO_FULL_NAME_CHARS = 200; +export const SCENARIO_MAX_BRANCH_REF_CHARS = 200; +export const SCENARIO_MAX_LINKED_ISSUE_NUMBERS = 50; +export const SCENARIO_MAX_SIGNAL_DETAIL_CHARS = 2000; export const scenarioInputKinds = ["fact", "assumption", "estimate", "unavailable"] as const; export type ScenarioInputKind = (typeof scenarioInputKinds)[number]; @@ -36,7 +40,7 @@ const scenarioSignalEntrySchema = z id: z.string().min(1).max(120), kind: z.enum(scenarioInputKinds), label: z.string().min(1).max(200), - detail: z.string().min(1).max(2000), + detail: z.string().min(1).max(SCENARIO_MAX_SIGNAL_DETAIL_CHARS), source: z.enum(scenarioSignalSources), }) .strict(); @@ -45,7 +49,7 @@ export type ScenarioSignalEntry = z.infer; const scenarioRepoConfigSchema = z .object({ - repoFullName: z.string().min(3).max(200), + repoFullName: z.string().min(3).max(SCENARIO_MAX_REPO_FULL_NAME_CHARS), registered: z.boolean().optional(), maintainerLane: z.boolean().optional(), }) @@ -56,7 +60,7 @@ export type ScenarioRepoConfig = z.infer; const scenarioIssueStateSchema = z .object({ openIssueCount: z.number().int().min(0).optional(), - linkedIssueNumbers: z.array(z.number().int().positive()).max(50).optional(), + linkedIssueNumbers: z.array(z.number().int().positive()).max(SCENARIO_MAX_LINKED_ISSUE_NUMBERS).optional(), }) .strict(); @@ -75,9 +79,9 @@ export type ScenarioPullRequestState = z.infer trimScenarioText(note, SCENARIO_MAX_SIGNAL_DETAIL_CHARS)) + .filter((note) => note.length > 0); + const facts: ScenarioSignalEntry[] = [ createScenarioSignalEntry({ id: "actor", kind: "fact", label: "Contributor", - detail: `Planning scenario for ${args.login}.`, + detail: `Planning scenario for ${login}.`, source: "github_observed", }), createScenarioSignalEntry({ id: "repo", kind: "fact", label: "Repository", - detail: `Repo context is ${args.repoFullName}.`, + detail: `Repo context is ${repoFullName}.`, source: "local_metadata", }), ]; - if (args.branchName) { + if (branchName) { facts.push( createScenarioSignalEntry({ id: "branch", kind: "fact", label: "Branch", - detail: `Active branch ${args.branchName}${args.baseRef ? ` against ${args.baseRef}` : ""}.`, + detail: `Active branch ${branchName}${baseRef ? ` against ${baseRef}` : ""}.`, source: "local_metadata", }), ); } const assumptions = - args.scenarioNotes?.map((note, index) => + scenarioNotes?.map((note, index) => createScenarioSignalEntry({ id: `assumption_${index + 1}`, kind: "assumption", label: "Caller assumption", - detail: note.trim(), + detail: note, source: "user_supplied", }), ) ?? []; @@ -268,12 +281,17 @@ export function scenarioInputFromLocalBranchMetadata(args: { }), ); } - const branchState = compactBranchState(args); + const branchState = compactBranchState({ + ...(branchName ? { branchName } : {}), + ...(baseRef ? { baseRef } : {}), + ...(args.changedFileCount !== undefined ? { changedFileCount: args.changedFileCount } : {}), + ...(args.eligibilityStatus ? { eligibilityStatus: args.eligibilityStatus } : {}), + }); return buildScenarioInput({ scenarioType: args.scenarioType, - repoFullName: args.repoFullName, + repoFullName, ...(branchState ? { branchState } : {}), - ...(args.linkedIssues?.length ? { issueState: { linkedIssueNumbers: args.linkedIssues } } : {}), + ...(linkedIssues?.length ? { issueState: { linkedIssueNumbers: linkedIssues } } : {}), facts, assumptions, unavailableSignals, @@ -339,6 +357,16 @@ function validateBucketKinds( } } +function trimScenarioText(value: string, maxLength: number): string { + return value.trim().slice(0, maxLength); +} + +function optionalScenarioText(value: string | undefined, maxLength: number): string | undefined { + if (!value) return undefined; + const trimmed = trimScenarioText(value, maxLength); + return trimmed.length > 0 ? trimmed : undefined; +} + function sortEntries(entries: ScenarioSignalEntry[]): ScenarioSignalEntry[] { return [...entries].sort((left, right) => left.id.localeCompare(right.id)); } diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 31dfa9b86a..01eeb1171c 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; +import { SCENARIO_MAX_BRANCH_REF_CHARS, SCENARIO_MAX_LINKED_ISSUE_NUMBERS } from "../../src/scenarios/input-model"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../../src/signals/local-branch"; import { MAX_LOCAL_SCORER_WARNING_CHARS, MAX_LOCAL_SCORER_WARNING_COUNT } from "../../src/signals/local-scorer-diagnostics"; import type { ContributorOutcomeHistory, ContributorProfile, ContributorScoringProfile, IssueQualityReport } from "../../src/signals/engine"; @@ -1593,6 +1594,30 @@ describe("local MCP git metadata collection", () => { expect(analysis.scenarioSummary.dataClassification.facts).toContain("Branch"); }); + it("does not throw while summarizing oversized local branch metadata", () => { + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + branchName: "b".repeat(SCENARIO_MAX_BRANCH_REF_CHARS + 1), + baseRef: "m".repeat(SCENARIO_MAX_BRANCH_REF_CHARS + 1), + linkedIssues: Array.from({ length: SCENARIO_MAX_LINKED_ISSUE_NUMBERS + 1 }, (_, index) => index + 1), + changedFiles: [{ path: "src/util.ts", additions: 20, deletions: 1, status: "modified" }], + localScorer: { mode: "external_command", sourceTokenScore: 35, totalTokenScore: 55, sourceLines: 30 }, + }, + repo, + issues: [], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.scenarioSummary.dataClassification.facts).toContain("Branch"); + expect(analysis.branchName).toHaveLength(SCENARIO_MAX_BRANCH_REF_CHARS + 1); + }); + it("populates scenarioSummary.eligibilityNotes from the derived eligibility plan", () => { const analysis = buildLocalBranchAnalysis({ input: { diff --git a/test/unit/scenario-input-model.test.ts b/test/unit/scenario-input-model.test.ts index 1575a698b8..28e17d8161 100644 --- a/test/unit/scenario-input-model.test.ts +++ b/test/unit/scenario-input-model.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; import { + SCENARIO_MAX_BRANCH_REF_CHARS, + SCENARIO_MAX_LINKED_ISSUE_NUMBERS, + SCENARIO_MAX_REPO_FULL_NAME_CHARS, + SCENARIO_MAX_SIGNAL_DETAIL_CHARS, assertScenarioLocalBranchInputSafe, buildScenarioInput, createScenarioSignalEntry, @@ -264,6 +268,24 @@ describe("source-upload safety", () => { expect(input.unavailableSignals[0]?.kind).toBe("unavailable"); expect(JSON.stringify(input)).not.toMatch(/fileContent|sourceContent|upload/i); }); + + it("bounds local branch metadata before scenario validation", () => { + const input = scenarioInputFromLocalBranchMetadata({ + scenarioType: "branch_preflight", + login: "miner", + repoFullName: `octo/${"r".repeat(260)}`, + branchName: "b".repeat(260), + baseRef: "m".repeat(260), + linkedIssues: Array.from({ length: 75 }, (_, index) => index + 1), + scenarioNotes: ["n".repeat(SCENARIO_MAX_SIGNAL_DETAIL_CHARS + 100)], + }); + + expect(input.repo.repoFullName).toHaveLength(SCENARIO_MAX_REPO_FULL_NAME_CHARS); + expect(input.branchState?.branchName).toHaveLength(SCENARIO_MAX_BRANCH_REF_CHARS); + expect(input.branchState?.baseRef).toHaveLength(SCENARIO_MAX_BRANCH_REF_CHARS); + expect(input.issueState?.linkedIssueNumbers).toHaveLength(SCENARIO_MAX_LINKED_ISSUE_NUMBERS); + expect(input.assumptions[0]?.detail).toHaveLength(SCENARIO_MAX_SIGNAL_DETAIL_CHARS); + }); }); describe("invariants", () => {