diff --git a/src/api/routes.ts b/src/api/routes.ts index 49511f819e..d79195b854 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -748,6 +748,11 @@ const digestSubscriptionSchema = z }) .strict(); +function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state: string }>, repoFullName: string): number { + const targetRepo = repoFullName.toLowerCase(); + return issues.filter((issue) => issue.repoFullName.toLowerCase() === targetRepo && issue.state === "open").length; +} + export function createApp() { const app = new Hono(); app.use("*", async (c, next) => { @@ -1595,13 +1600,15 @@ export function createApp() { const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin); if (unauthorized) return unauthorized; } - const [repo, snapshot, evidence] = await Promise.all([ + const [repo, snapshot, evidence, contributorIssues] = await Promise.all([ getRepository(c.env, parsed.data.repoFullName), getOrCreateScoringModelSnapshot(c.env), parsed.data.contributorLogin ? getContributorEvidence(c.env, parsed.data.contributorLogin) : Promise.resolve(null), + parsed.data.contributorLogin ? listContributorIssues(c.env, parsed.data.contributorLogin) : Promise.resolve([]), ]); + const openIssueCount = contributorOpenIssueCount(contributorIssues, parsed.data.repoFullName); // Time-decay (#703) is an owner-gated global, injected server-side (not caller-controllable). - const input = { ...parsed.data, applyTimeDecay: isTimeDecayEnabled(c.env) }; + const input = { ...parsed.data, openIssueCount, applyTimeDecay: isTimeDecayEnabled(c.env) }; const result = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence }); const record = makeScorePreviewRecord(input, snapshot, result); await persistScorePreview(c.env, record); @@ -1615,13 +1622,15 @@ export function createApp() { if (!parsed.data.contributorLogin) return c.json({ error: "contributor_login_required" }, 400); const unauthorized = await requireContributorAccess(c, parsed.data.contributorLogin); if (unauthorized) return unauthorized; - const [repo, snapshot, evidence] = await Promise.all([ + const [repo, snapshot, evidence, contributorIssues] = await Promise.all([ getRepository(c.env, parsed.data.repoFullName), getOrCreateScoringModelSnapshot(c.env), getContributorEvidence(c.env, parsed.data.contributorLogin), + listContributorIssues(c.env, parsed.data.contributorLogin), ]); + const openIssueCount = contributorOpenIssueCount(contributorIssues, parsed.data.repoFullName); // Time-decay (#703) is an owner-gated global, injected server-side (not caller-controllable). - const input = { ...parsed.data, applyTimeDecay: isTimeDecayEnabled(c.env) }; + const input = { ...parsed.data, openIssueCount, applyTimeDecay: isTimeDecayEnabled(c.env) }; const preview = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence }); return c.json(explainScoreBreakdown(preview)); }); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 914bae0f8b..2d4568338d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -513,6 +513,11 @@ const agentPlanShape = { repoFullName: z.string().min(3).optional(), }; +function contributorOpenIssueCount(issues: Array<{ repoFullName: string; state: string }>, repoFullName: string): number { + const targetRepo = repoFullName.toLowerCase(); + return issues.filter((issue) => issue.repoFullName.toLowerCase() === targetRepo && issue.state === "open").length; +} + const linkedIssueContextShape = { status: z.enum(["raw", "plausible", "validated", "invalid", "unavailable"]).optional(), source: z.enum(["user_supplied", "official_mirror", "github_cache", "issue_quality", "missing"]).optional(), @@ -2133,13 +2138,15 @@ export class GittensoryMcp { private async previewScore(input: z.infer>): Promise { if (input.contributorLogin) this.requireContributorAccess(input.contributorLogin); await this.requireRepoAccess(input.repoFullName); - const [repo, snapshot, evidence] = await Promise.all([ + const [repo, snapshot, evidence, contributorIssues] = await Promise.all([ getRepository(this.env, input.repoFullName), getOrCreateScoringModelSnapshot(this.env), input.contributorLogin ? getContributorEvidence(this.env, input.contributorLogin) : Promise.resolve(null), + input.contributorLogin ? listContributorIssues(this.env, input.contributorLogin) : Promise.resolve([]), ]); + const openIssueCount = contributorOpenIssueCount(contributorIssues, input.repoFullName); // Time-decay (#703) is an owner-gated global, injected server-side (not caller-controllable). - const scoreInput = { ...input, applyTimeDecay: isTimeDecayEnabled(this.env) }; + const scoreInput = { ...input, openIssueCount, applyTimeDecay: isTimeDecayEnabled(this.env) }; const result = buildScorePreview({ input: scoreInput, repo, snapshot, contributorEvidence: evidence }); return { summary: `Private Gittensory scoring preview for ${input.repoFullName}.`, diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 4291842e2e..e4ee531f4b 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -472,6 +472,7 @@ function buildLocalScoreInput(args: { testTokenScore: scorer?.testTokenScore ?? testLineCount, nonCodeTokenScore: scorer?.nonCodeTokenScore ?? nonCodeLineCount, openPrCount: args.outcomeHistory.totals.openPullRequests, + openIssueCount: args.repoOutcome?.openIssues ?? args.outcomeHistory.totals.openIssues, credibility: args.repoOutcome?.credibility ?? args.outcomeHistory.totals.credibility, metadataOnly: scorer?.mode !== "gittensor_root" && scorer?.mode !== "external_command", pendingMergedPrCount: args.input.pendingMergedPrCount, diff --git a/src/signals/reward-risk.ts b/src/signals/reward-risk.ts index 4798dbaf65..de9f923f98 100644 --- a/src/signals/reward-risk.ts +++ b/src/signals/reward-risk.ts @@ -182,6 +182,7 @@ export function buildRepoRewardRisk(args: { const labels = bestFitLabels(args.repo); const currentOpenPrCount = nonNegative(args.outcomeHistory.totals.openPullRequests); + const currentOpenIssueCount = nonNegative(repoOutcome?.openIssues ?? args.outcomeHistory.totals.openIssues); /* v8 ignore next -- Credibility fallback order protects sparse private snapshots; behavior is covered through scoring profile tests. */ const credibility = repoOutcome?.credibility && repoOutcome.credibility > 0 ? repoOutcome.credibility : args.scoringProfile?.evidence.credibilityAssumption ?? args.outcomeHistory.totals.credibility ?? 0.8; const commonPreviewInput = { @@ -198,6 +199,7 @@ export function buildRepoRewardRisk(args: { credibility, metadataOnly: true, duplicateRiskCount: collisions.summary.highRiskCount, + openIssueCount: currentOpenIssueCount, }; const currentPreview = buildScorePreview({ input: { ...commonPreviewInput, openPrCount: currentOpenPrCount }, diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 20788deb9e..afdd92b0fb 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -94,6 +94,34 @@ describe("local branch analysis", () => { expect(analysis.scorePreview.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "duplicate_risk" })])); }); + it("applies the open-issue spam gate from trusted outcome history", () => { + const issueHeavyHistory: ContributorOutcomeHistory = { + ...outcomeHistory, + totals: { ...outcomeHistory.totals, issues: 99, openIssues: 99 }, + repoOutcomes: [{ ...outcomeHistory.repoOutcomes[0]!, issues: 99, openIssues: 99 }], + }; + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + labels: ["enhancement"], + changedFiles: [{ path: "src/cache.ts", additions: 42, deletions: 4, status: "modified" }], + localScorer: { mode: "external_command", sourceTokenScore: 48, totalTokenScore: 80, sourceLines: 46 }, + }, + repo, + issues: [], + pullRequests: [], + profile, + outcomeHistory: issueHeavyHistory, + scoringSnapshot, + scoringProfile, + }); + + expect(analysis.scorePreview.gates.openIssueCount).toBe(99); + expect(analysis.scorePreview.scoreEstimate.openIssueMultiplier).toBe(0); + expect(analysis.scorePreview.blockedBy).toEqual(expect.arrayContaining([expect.objectContaining({ code: "open_issue_threshold" })])); + }); + it("bounds local scorer warnings before adding local findings", () => { const analysis = buildLocalBranchAnalysis({ input: {