From 0d74d75bd352373221b81c7063263cf29abc7806 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:07:35 +0200 Subject: [PATCH 1/8] feat(signals): persist issue-quality snapshots and serve them via REST generateSignalSnapshots now builds and persists an issue-quality snapshot alongside the other six signal types. New GET /v1/repos/:owner/:repo/issue-quality returns the cached snapshot or computes one on demand when the repo is known; 404 when the repo is unknown. The path is registered in the OpenAPI spec. --- src/api/routes.ts | 20 ++++++++++++++++++++ src/openapi/spec.ts | 8 ++++++++ src/queue/processors.ts | 10 ++++++++++ test/unit/openapi.test.ts | 2 +- 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index 9ac6c363c5..6577b153a6 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -87,6 +87,7 @@ import { buildContributorProfile, buildContributorScoringProfile, buildContributorIntakeHealth, + buildIssueQualityReport, buildLabelAudit, buildLaneAdvice, buildLocalDiffPreflightResult, @@ -558,6 +559,13 @@ export function createApp() { return c.json(await buildRepoIntelligenceResponse(c.env, fullName)); }); + app.get("/v1/repos/:owner/:repo/issue-quality", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const response = await buildIssueQualityResponse(c.env, fullName); + if (!response) return c.json({ error: "issue_quality_not_found", repoFullName: fullName }, 404); + return c.json(response); + }); + app.get("/v1/repos/:owner/:repo/registration-readiness", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; return c.json(await buildRegistrationReadinessResponse(c.env, fullName)); @@ -1081,6 +1089,18 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) { }; } +async function buildIssueQualityResponse(env: Env, fullName: string) { + const cached = (await listSignalSnapshots(env, "issue-quality", fullName))[0]; + if (cached) { + return { status: "ready" as const, source: "snapshot" as const, repoFullName: fullName, generatedAt: cached.generatedAt ?? nowIso(), report: cached.payload }; + } + const repo = await getRepository(env, fullName); + if (!repo) return null; + const [issues, pullRequests] = await Promise.all([listIssueSignalSample(env, fullName), listOpenPullRequests(env, fullName)]); + const report = buildIssueQualityReport(repo, issues, pullRequests, fullName); + return { status: "ready" as const, source: "computed" as const, repoFullName: fullName, generatedAt: nowIso(), report }; +} + async function buildRegistrationReadinessResponse(env: Env, fullName: string) { const intelligence = await buildRepoIntelligenceResponse(env, fullName); const settings = await getRepositorySettings(env, fullName); diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index d386c18f18..3c3ca74495 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -223,6 +223,14 @@ export function buildOpenApiSpec() { 200: { description: "Canonical repository intelligence bundle", content: { "application/json": { schema: RepoIntelligenceSchema } } }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/issue-quality", + responses: { + 200: { description: "Cached or computed issue quality report for the repo", content: { "application/json": { schema: IssueQualityReportSchema } } }, + 404: { description: "Repo is unknown or has no issue-quality coverage yet" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/registration-readiness", diff --git a/src/queue/processors.ts b/src/queue/processors.ts index c655f36aab..c9dd5bc23b 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -68,6 +68,7 @@ import { buildContributorScoringProfile, buildContributorStrategy, buildContributorIntakeHealth, + buildIssueQualityReport, buildLabelAudit, buildMaintainerCutReadiness, buildMaintainerLaneReport, @@ -353,6 +354,7 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): const maintainerLane = buildMaintainerLaneReport(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); const maintainerCutReadiness = buildMaintainerCutReadiness(repo, issues, pullRequests, repo.fullName, queueCounts, collisions); const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, repo.fullName); await replaceCollisionEdges(env, repo.fullName, buildCollisionEdges(collisions)); const generatedAt = new Date().toISOString(); await persistSignalSnapshot(env, { @@ -403,6 +405,14 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): payload: contributorIntakeHealth as unknown as Record, generatedAt, }); + await persistSignalSnapshot(env, { + id: crypto.randomUUID(), + signalType: "issue-quality", + targetKey: repo.fullName, + repoFullName: repo.fullName, + payload: issueQuality as unknown as Record, + generatedAt, + }); } } diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index f1d656f100..3cde3f0796 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -10,6 +10,7 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/readiness"]).toBeDefined(); expect(spec.paths["/v1/sync/status"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/intelligence"]).toBeDefined(); + expect(spec.paths["/v1/repos/{owner}/{repo}/issue-quality"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/registration-readiness"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/gittensor-config-recommendation"]).toBeDefined(); expect(spec.paths["/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet"]).toBeDefined(); @@ -47,7 +48,6 @@ describe("OpenAPI contract", () => { "/v1/repos/{owner}/{repo}/collisions", "/v1/repos/{owner}/{repo}/config-quality", "/v1/repos/{owner}/{repo}/labels/audit", - "/v1/repos/{owner}/{repo}/issue-quality", "/v1/repos/{owner}/{repo}/burden-forecast", "/v1/repos/{owner}/{repo}/registry-drift", "/v1/repos/{owner}/{repo}/maintainer-lane", From 804c56c27dda0a8561c25e7615600597b7308972 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:07:37 +0200 Subject: [PATCH 2/8] feat(mcp): add gittensory_get_issue_quality tool Returns the cached or freshly-computed issue-quality report for a repo. Falls back to a not_found payload when the repo is unknown locally so MCP clients can render the state without erroring. --- src/mcp/server.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f85179f4d5..5b035cc55d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -22,6 +22,7 @@ import { listRepoSyncSegments, listRepoSyncStates, listRepositories, + listSignalSnapshots, } from "../db/repositories"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; @@ -44,6 +45,7 @@ import { buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile, + buildIssueQualityReport, buildLaneAdvice, buildLocalDiffPreflightResult, buildPreflightResult, @@ -298,6 +300,15 @@ export class GittensoryMcp { async () => this.toolResult(await this.getRegistryChanges()), ); + server.registerTool( + "gittensory_get_issue_quality", + { + description: "Return the cached or freshly-computed issue-quality report for a repo, ranking which open issues are actionable, need proof, are stale/duplicate-prone, or already solved.", + inputSchema: ownerRepoShape, + }, + async (input) => this.toolResult(await this.getIssueQuality(input)), + ); + server.registerTool( "gittensory_preflight_local_diff", { @@ -487,6 +498,31 @@ export class GittensoryMcp { }; } + private async getIssueQuality(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.repo}`; + const cached = (await listSignalSnapshots(this.env, "issue-quality", fullName))[0]; + if (cached) { + const payload = cached.payload as unknown as Record; + return { + summary: `Gittensory issue quality for ${fullName} (cached).`, + data: { repoFullName: fullName, source: "snapshot", generatedAt: cached.generatedAt ?? null, report: payload }, + }; + } + const repo = await getRepository(this.env, fullName); + if (!repo) { + return { + summary: `Gittensory has no cached issue quality for ${fullName}.`, + data: { status: "not_found", repoFullName: fullName }, + }; + } + const [issues, pullRequests] = await Promise.all([listIssueSignalSample(this.env, fullName), listOpenPullRequests(this.env, fullName)]); + const report = buildIssueQualityReport(repo, issues, pullRequests, fullName); + return { + summary: `Gittensory issue quality for ${fullName} (computed from cached metadata).`, + data: { repoFullName: fullName, source: "computed", generatedAt: report.generatedAt, report: report as unknown as Record }, + }; + } + private async loadOpenQueueCounts(fullName: string): Promise<{ openIssues: number; openPullRequests: number }> { const [totals, openIssues, openPullRequests] = await Promise.all([ getLatestRepoGithubTotalsSnapshot(this.env, fullName), From ff9dd64463318fae37fd8701b2226ee3a13ed56b Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:07:43 +0200 Subject: [PATCH 3/8] feat(signals): weight contributor opportunities by cached issue quality buildContributorOpportunities now accepts an optional Map and uses each issue's quality status to drop do_not_use issues, downgrade needs_proof to caution even at high lane fit, and boost ready issues. Lookup is case-insensitive. Adds fixtures for direct-PR / issue-discovery / vague / stale / already-solved / duplicate-prone / high-volume issue paths plus opportunity-integration tests for the do_not_use drop, needs_proof downgrade, and hold-status warnings. Integration test flips the formerly-legacy /v1/repos/:owner/:repo/issue-quality 404 path to assert both snapshot and computed responses and seeds an issue-quality snapshot for the MCP tool happy path. --- CHANGELOG.md | 23 ++- src/signals/engine.ts | 27 +++- test/integration/api.test.ts | 54 ++++++- test/unit/issue-quality.test.ts | 275 ++++++++++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 test/unit/issue-quality.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e4cb5c582..4167198a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,21 @@ - Add deterministic base-agent orchestrator (#14) -- Detect stale installs and API compatibility in doctor and status +- Add settings preview diagnostics (#31) + +- Make next-action recommendations repo-specific (#30) + +- Detect stale installs and API compatibility in doctor and status (#28) + +- Add command-specific @gittensory responses (#25) + +- Serve stale decision packs with freshness marker and background rebuild (#26) + +- Persist issue-quality snapshots and serve them via REST + +- Add gittensory_get_issue_quality tool + +- Weight contributor opportunities by cached issue quality @@ -77,12 +91,5 @@ - Ignore stale beta api origins -- Restore actionKind guards and add split-lane copy - - - -### Tests -- Tighten and extend decision-pack regression coverage -- Cover review-requested branches and tier sanitization diff --git a/src/signals/engine.ts b/src/signals/engine.ts index b16da8b95e..dcce15f5e6 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -1063,10 +1063,14 @@ export function buildContributorOpportunities( repositories: RepositoryRecord[], issues: IssueRecord[], pullRequests: PullRequestRecord[], + issueQualityByRepo?: Map, ): ContributorOpportunity[] { const opportunities: ContributorOpportunity[] = []; const touchedRepos = new Set(profile.registeredRepoActivity.reposTouched); const labelHistory = new Set(profile.registeredRepoActivity.dominantLabels); + const qualityByKey = issueQualityByRepo + ? new Map(Array.from(issueQualityByRepo.entries()).map(([key, value]) => [key.toLowerCase(), value])) + : null; for (const repo of repositories.filter((candidate) => candidate.isRegistered)) { const lane = buildLaneAdvice(repo, repo.fullName); @@ -1075,8 +1079,22 @@ export function buildContributorOpportunities( const linkedIssueNumbers = new Set(repoPullRequests.flatMap((pr) => pr.linkedIssues)); const availableIssues = repoIssues.filter((issue) => issue.linkedPrs.length === 0 && !linkedIssueNumbers.has(issue.number)); const queuePenalty = Math.min(20, repoPullRequests.length * 2); + const qualityReport = qualityByKey?.get(repo.fullName.toLowerCase()); + const qualityByIssue = qualityReport + ? new Map(qualityReport.issues.map((entry) => [entry.number, entry])) + : null; for (const issue of availableIssues.slice(0, 5)) { + const quality = qualityByIssue?.get(issue.number); + if (quality && quality.status === "do_not_use") continue; const labelFit = issue.labels.filter((label) => labelHistory.has(label)).length; + const qualityAdjustment = + quality?.status === "ready" + ? 10 + : quality?.status === "needs_proof" + ? -8 + : quality?.status === "hold" + ? -15 + : 0; const score = clamp( 50 + (touchedRepos.has(repo.fullName) ? 20 : 0) + @@ -1084,26 +1102,31 @@ export function buildContributorOpportunities( (lane.lane === "split" ? 8 : 0) + (lane.lane === "direct_pr" ? 5 : 0) - queuePenalty - - (lane.lane === "inactive" || lane.lane === "unknown" ? 35 : 0), + (lane.lane === "inactive" || lane.lane === "unknown" ? 35 : 0) + + qualityAdjustment, 0, 100, ); + const downgradeToCaution = quality?.status === "needs_proof" && score >= 70; opportunities.push({ repoFullName: repo.fullName, issueNumber: issue.number, title: issue.title, - fit: score >= 70 ? "good" : score >= 40 ? "caution" : "hold", + fit: downgradeToCaution ? "caution" : score >= 70 ? "good" : score >= 40 ? "caution" : "hold", score, lane: lane.lane, reasons: [ lane.summary, ...(touchedRepos.has(repo.fullName) ? ["Contributor has prior activity in this registered repo."] : []), ...(labelFit > 0 ? [`Issue labels overlap contributor history: ${issue.labels.filter((label) => labelHistory.has(label)).join(", ")}.`] : []), + ...(quality?.status === "ready" ? ["Issue quality report rates this issue as ready."] : []), ], warnings: [ ...(repoPullRequests.length >= 8 ? ["This repo has a busy open PR queue."] : []), ...(lane.lane === "issue_discovery" ? ["This repo is not a direct-PR-first lane."] : []), ...(lane.lane === "unknown" || lane.lane === "inactive" ? ["Gittensory cannot recommend this as a strong contribution target right now."] : []), + ...(quality?.status === "needs_proof" ? ["Issue quality report flags this issue as needing more proof before acting."] : []), + ...(quality?.status === "hold" ? ["Issue quality report rates this issue as hold; consider skipping."] : []), ], }); } diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 8d354a59b5..de0cd3f292 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -710,6 +710,16 @@ describe("api routes", () => { ["maintainer-lane", { repoFullName: "entrius/allways-ui" }], ["maintainer-cut-readiness", { repoFullName: "entrius/allways-ui" }], ["contributor-intake-health", { repoFullName: "entrius/allways-ui" }], + [ + "issue-quality", + { + repoFullName: "entrius/allways-ui", + generatedAt: "2026-05-25T00:00:00.000Z", + lane: { lane: "direct_pr" }, + issues: [{ number: 7, title: "fixture", status: "ready", score: 80, reasons: [], warnings: [] }], + summary: "fixture", + }, + ], ] as const) { await persistSignalSnapshot(env, { id: `snapshot-${signalType}`, @@ -724,8 +734,21 @@ describe("api routes", () => { expect(snapshotIntelligence.status).toBe(200); await expect(snapshotIntelligence.json()).resolves.toMatchObject({ source: "snapshot", queueHealth: { signals: { openPullRequests: 2 } } }); + const issueQuality = await app.request("/v1/repos/entrius/allways-ui/issue-quality", { headers: apiHeaders(env) }, env); + expect(issueQuality.status).toBe(200); + await expect(issueQuality.json()).resolves.toMatchObject({ + status: "ready", + source: "snapshot", + repoFullName: "entrius/allways-ui", + report: { repoFullName: "entrius/allways-ui", issues: expect.any(Array) }, + }); + + await upsertRepositoryFromGitHub(env, { name: "uncached", full_name: "entrius/uncached", private: false, owner: { login: "entrius" }, default_branch: "main" }); + const computedIssueQuality = await app.request("/v1/repos/entrius/uncached/issue-quality", { headers: apiHeaders(env) }, env); + expect(computedIssueQuality.status).toBe(200); + await expect(computedIssueQuality.json()).resolves.toMatchObject({ status: "ready", source: "computed", repoFullName: "entrius/uncached" }); + for (const path of [ - "/v1/repos/entrius/allways-ui/issue-quality", "/v1/repos/entrius/allways-ui/burden-forecast", "/v1/repos/entrius/allways-ui/pulls/12/scoring-preview", "/v1/contributors/oktofeesh1/scoring-profile", @@ -1053,6 +1076,7 @@ describe("api routes", () => { const toolsPayload = (await mcpJson(toolsList)) as { result: { tools: Array<{ name: string }> } }; const toolNames = toolsPayload.result.tools.map((tool) => tool.name); expect(toolNames).toContain("gittensory_get_repo_context"); + expect(toolNames).toContain("gittensory_get_issue_quality"); expect(toolNames).toContain("gittensory_get_contributor_profile"); expect(toolNames).toContain("gittensory_get_decision_pack"); expect(toolNames).toContain("gittensory_explain_repo_decision"); @@ -1133,6 +1157,18 @@ describe("api routes", () => { const noTotalsPayload = (await mcpJson(noTotalsContext)) as { result: { structuredContent: { queueHealth: { signals: { openIssues: number; openPullRequests: number } } } } }; expect(noTotalsPayload.result.structuredContent.queueHealth.signals).toMatchObject({ openIssues: 0, openPullRequests: 0 }); + const missingIssueQuality = await app.request( + "/mcp", + { + method: "POST", + headers: mcpHeaders(env), + body: JSON.stringify({ jsonrpc: "2.0", id: "missing-issue-quality", method: "tools/call", params: { name: "gittensory_get_issue_quality", arguments: { owner: "ghost", repo: "missing" } } }), + }, + env, + ); + expect(missingIssueQuality.status).toBe(200); + await expect(mcpJson(missingIssueQuality)).resolves.toMatchObject({ result: { structuredContent: { status: "not_found", repoFullName: "ghost/missing" } } }); + for (const [name, args] of [ ["gittensory_get_decision_pack", { login: "needs-snapshot" }], ["gittensory_explain_repo_decision", { login: "needs-snapshot", owner: "entrius", repo: "allways-ui" }], @@ -1170,8 +1206,24 @@ describe("api routes", () => { expect(missingRepoDecision.status).toBe(200); await expect(mcpJson(missingRepoDecision)).resolves.toMatchObject({ result: { structuredContent: { status: "not_found", decision: null } } }); + await persistSignalSnapshot(env, { + id: "mcp-issue-quality", + signalType: "issue-quality", + targetKey: "entrius/allways-ui", + repoFullName: "entrius/allways-ui", + payload: { + repoFullName: "entrius/allways-ui", + generatedAt: "2026-05-25T00:00:00.000Z", + lane: { lane: "direct_pr" }, + issues: [{ number: 7, title: "fixture", status: "ready", score: 80, reasons: [], warnings: [] }], + summary: "fixture", + } as unknown as Record, + generatedAt: "2026-05-25T00:00:00.000Z", + }); + for (const [name, args] of [ ["gittensory_get_repo_context", { owner: "entrius", repo: "allways-ui" }], + ["gittensory_get_issue_quality", { owner: "entrius", repo: "allways-ui" }], ["gittensory_get_contributor_profile", { login: "oktofeesh1" }], ["gittensory_get_decision_pack", { login: "oktofeesh1" }], ["gittensory_explain_repo_decision", { login: "oktofeesh1", owner: "entrius", repo: "allways-ui" }], diff --git a/test/unit/issue-quality.test.ts b/test/unit/issue-quality.test.ts new file mode 100644 index 0000000000..44ade9ea2a --- /dev/null +++ b/test/unit/issue-quality.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import { + buildContributorOpportunities, + buildIssueQualityReport, + type ContributorProfile, + type IssueQualityReport, +} from "../../src/signals/engine"; +import type { IssueRecord, PullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../src/types"; + +describe("issue quality reports", () => { + it("downgrades issue filing in direct-PR-only repos to needs_proof", () => { + const repo = directPrRepo("owner/direct"); + const report = buildIssueQualityReport(repo, [issue(repo.fullName, 1, "Concrete fix needed", { body: "x".repeat(220), labels: ["bug"], updatedAt: now() })], [], repo.fullName); + expect(report.issues[0]).toMatchObject({ + status: "needs_proof", + warnings: expect.arrayContaining([expect.stringMatching(/direct-PR first/i)]), + }); + expect(report.lane.lane).toBe("direct_pr"); + }); + + it("flags issue-discovery repo issues as ready without lane warnings", () => { + const repo = issueDiscoveryRepo("owner/discovery"); + const report = buildIssueQualityReport(repo, [issue(repo.fullName, 2, "Actionable discovery", { body: "x".repeat(220), labels: ["good first issue"], updatedAt: now() })], [], repo.fullName); + expect(report.lane.lane).toBe("issue_discovery"); + expect(report.issues[0]?.status).toBe("ready"); + expect(report.issues[0]?.warnings).not.toEqual(expect.arrayContaining([expect.stringMatching(/direct-PR/i)])); + }); + + it("marks a thin (vague) issue body as needs_proof", () => { + const repo = directPrRepo("owner/vague"); + const report = buildIssueQualityReport(repo, [issue(repo.fullName, 3, "Fix bug", { body: "Short.", updatedAt: now() })], [], repo.fullName); + expect(report.issues[0]).toMatchObject({ + status: "needs_proof", + warnings: expect.arrayContaining([expect.stringMatching(/thin/i)]), + }); + }); + + it("warns when an issue is stale in cached metadata", () => { + const repo = issueDiscoveryRepo("owner/stale"); + const report = buildIssueQualityReport( + repo, + [issue(repo.fullName, 4, "Old report", { body: "x".repeat(220), updatedAt: "2025-01-01T00:00:00.000Z" })], + [], + repo.fullName, + ); + expect(report.issues[0]?.warnings).toEqual(expect.arrayContaining([expect.stringContaining("stale")])); + }); + + it("marks an already-solved issue as do_not_use when a linked PR exists", () => { + const repo = directPrRepo("owner/solved"); + 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]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/already reference this issue/i)])); + }); + + it("surfaces duplicate-prone context via collision detection", () => { + 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"] }); + const b = issue(repo.fullName, 11, "Login flow fails after reconnect when user disconnects", { body: "x".repeat(220), labels: ["bug"] }); + const report = buildIssueQualityReport(repo, [a, b], [], repo.fullName); + expect(report.issues.some((entry) => entry.warnings.some((warning) => /duplicate|overlapping/i.test(warning)))).toBe(true); + }); + + it("downgrades direct-PR-lane issue filing in warnings", () => { + const repo = directPrRepo("owner/direct-only"); + const report = buildIssueQualityReport(repo, [issue(repo.fullName, 20, "Random idea", { body: "Short." })], [], repo.fullName); + expect(report.issues[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/direct-PR first/i)])); + }); + + it("respects a worker-budget cap of 100 issues per repo", () => { + const repo = issueDiscoveryRepo("owner/big"); + const issues = Array.from({ length: 150 }, (_, index) => issue(repo.fullName, index + 1, `bulk ${index}`, { body: "x".repeat(220) })); + const report = buildIssueQualityReport(repo, issues, [], repo.fullName); + expect(report.issues.length).toBeLessThanOrEqual(100); + }); +}); + +describe("buildContributorOpportunities x issue quality", () => { + it("drops do_not_use issues from opportunities", () => { + const repo = issueDiscoveryRepo("owner/dropper"); + const issues = [ + issue(repo.fullName, 1, "Drop me", { body: "x".repeat(220), labels: ["bug"] }), + issue(repo.fullName, 2, "Keep me", { body: "x".repeat(220), labels: ["bug"] }), + ]; + const quality: IssueQualityReport = { + repoFullName: repo.fullName, + generatedAt: now(), + lane: { repoFullName: repo.fullName, lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [ + { number: 1, title: "Drop me", status: "do_not_use", score: 0, reasons: [], warnings: [] }, + { number: 2, title: "Keep me", status: "ready", score: 88, reasons: [], warnings: [] }, + ], + summary: "", + }; + const opportunities = buildContributorOpportunities( + sampleProfile(), + [repo], + issues, + [], + new Map([[repo.fullName, quality]]), + ); + expect(opportunities.map((o) => o.issueNumber)).toEqual([2]); + expect(opportunities[0]?.reasons).toEqual(expect.arrayContaining(["Issue quality report rates this issue as ready."])); + }); + + it("downgrades needs_proof issues to caution and adds a warning", () => { + const repo = issueDiscoveryRepo("owner/caution"); + const issues = [issue(repo.fullName, 1, "Vague candidate", { body: "x".repeat(220), labels: ["bug"] })]; + const quality: IssueQualityReport = { + repoFullName: repo.fullName, + generatedAt: now(), + lane: { repoFullName: repo.fullName, lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [{ number: 1, title: "Vague candidate", status: "needs_proof", score: 50, reasons: [], warnings: [] }], + summary: "", + }; + const opportunities = buildContributorOpportunities( + sampleProfile({ reposTouched: [repo.fullName], dominantLabels: ["bug"] }), + [repo], + issues, + [], + new Map([[repo.fullName, quality]]), + ); + expect(opportunities[0]).toMatchObject({ fit: "caution" }); + expect(opportunities[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/needing more proof/i)])); + }); + + it("downgrades a high-score needs_proof issue to caution even when the lane fit is strong", () => { + const repo = splitLaneRepo("owner/strong-fit"); + const issues = [issue(repo.fullName, 1, "Strong fit but vague", { body: "x".repeat(220), labels: ["bug", "good first issue", "regression"] })]; + const quality: IssueQualityReport = { + repoFullName: repo.fullName, + generatedAt: now(), + lane: { repoFullName: repo.fullName, lane: "split", issueDiscoveryShare: 0.5, directPrShare: 0.5, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [{ number: 1, title: "Strong fit but vague", status: "needs_proof", score: 50, reasons: [], warnings: [] }], + summary: "", + }; + const opportunities = buildContributorOpportunities( + sampleProfile({ reposTouched: [repo.fullName], dominantLabels: ["bug", "good first issue", "regression"] }), + [repo], + issues, + [], + new Map([[repo.fullName, quality]]), + ); + expect(opportunities[0]).toMatchObject({ fit: "caution" }); + expect(opportunities[0]?.score).toBeGreaterThanOrEqual(70); + }); + + it("applies hold-status penalties and warnings", () => { + const repo = issueDiscoveryRepo("owner/hold"); + const issues = [issue(repo.fullName, 1, "On hold", { body: "x".repeat(220), labels: ["bug"] })]; + const quality: IssueQualityReport = { + repoFullName: repo.fullName, + generatedAt: now(), + lane: { repoFullName: repo.fullName, lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [{ number: 1, title: "On hold", status: "hold", score: 30, reasons: [], warnings: [] }], + summary: "", + }; + const opportunities = buildContributorOpportunities( + sampleProfile(), + [repo], + issues, + [], + new Map([[repo.fullName, quality]]), + ); + expect(opportunities[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/hold; consider skipping/i)])); + }); + + it("matches the repo case-insensitively when looking up cached quality", () => { + const repo = issueDiscoveryRepo("Owner/MixedCase"); + const issues = [issue(repo.fullName, 1, "Title", { body: "x".repeat(220) })]; + const quality: IssueQualityReport = { + repoFullName: repo.fullName, + generatedAt: now(), + lane: { repoFullName: repo.fullName, lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [{ number: 1, title: "Title", status: "do_not_use", score: 0, reasons: [], warnings: [] }], + summary: "", + }; + const opportunities = buildContributorOpportunities( + sampleProfile(), + [repo], + issues, + [], + new Map([["owner/mixedcase", quality]]), + ); + expect(opportunities).toHaveLength(0); + }); +}); + +function now(): string { + return new Date().toISOString(); +} + +function directPrRepo(fullName: string): RepositoryRecord { + return repoCommon(fullName, { emissionShare: 0.04, issueDiscoveryShare: 0 }); +} + +function issueDiscoveryRepo(fullName: string): RepositoryRecord { + return repoCommon(fullName, { emissionShare: 0.02, issueDiscoveryShare: 1 }); +} + +function splitLaneRepo(fullName: string): RepositoryRecord { + return repoCommon(fullName, { emissionShare: 0.03, issueDiscoveryShare: 0.5 }); +} + +function repoCommon(fullName: string, config: Partial): RepositoryRecord { + const [owner, name] = fullName.split("/"); + return { + fullName, + owner, + name, + isInstalled: false, + isRegistered: true, + isPrivate: false, + registryConfig: { + repo: fullName, + emissionShare: config.emissionShare ?? 0, + issueDiscoveryShare: config.issueDiscoveryShare ?? 0, + maintainerCut: 0, + labelMultipliers: { bug: 1.1 }, + raw: {}, + }, + } as RepositoryRecord; +} + +function issue(repoFullName: string, number: number, title: string, overrides: Partial = {}): IssueRecord { + return { + repoFullName, + number, + title, + state: "open", + authorLogin: "reporter", + authorAssociation: "NONE", + labels: [], + linkedPrs: [], + body: "Issue body detailed enough to evaluate properly with reproduction steps.", + updatedAt: now(), + ...overrides, + } as IssueRecord; +} + +function pr(repoFullName: string, number: number, title: string, overrides: Partial = {}): PullRequestRecord { + return { + repoFullName, + number, + title, + state: "open", + authorLogin: "dev", + authorAssociation: "NONE", + labels: [], + linkedIssues: [], + body: "", + updatedAt: now(), + ...overrides, + } as PullRequestRecord; +} + +function sampleProfile(overrides: Partial = {}): ContributorProfile { + return { + login: "tester", + generatedAt: now(), + github: { login: "tester", topLanguages: ["TypeScript"], source: "github" } as ContributorProfile["github"], + source: "github_cache", + gittensor: null, + registeredRepoActivity: { + pullRequests: 0, + mergedPullRequests: 0, + issues: 0, + reposTouched: [], + dominantLabels: [], + ...overrides, + }, + trustSignals: { evidenceScore: 0, level: "new", unlinkedOpenPullRequests: 0, maintainerAssociatedPullRequests: 0 }, + } as unknown as ContributorProfile; +} From 2c66a185ac9bd466d96d7e32c78e72e022d7cfd0 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:29:38 +0200 Subject: [PATCH 4/8] revert(changelog): restore main CHANGELOG.md PR #56 should not regenerate CHANGELOG.md per CONTRIBUTING rules (changelog edits are release-prep only). --- CHANGELOG.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4167198a5b..3e4cb5c582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,21 +53,7 @@ - Add deterministic base-agent orchestrator (#14) -- Add settings preview diagnostics (#31) - -- Make next-action recommendations repo-specific (#30) - -- Detect stale installs and API compatibility in doctor and status (#28) - -- Add command-specific @gittensory responses (#25) - -- Serve stale decision packs with freshness marker and background rebuild (#26) - -- Persist issue-quality snapshots and serve them via REST - -- Add gittensory_get_issue_quality tool - -- Weight contributor opportunities by cached issue quality +- Detect stale installs and API compatibility in doctor and status @@ -91,5 +77,12 @@ - Ignore stale beta api origins +- Restore actionKind guards and add split-lane copy + + + +### Tests +- Tighten and extend decision-pack regression coverage +- Cover review-requested branches and tier sanitization From 67ebf2299ccd4b6c03f2054ab485cdae07a0d702 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:29:43 +0200 Subject: [PATCH 5/8] fix(signals): thread issue-quality map through buildContributorFit buildContributorOpportunities accepted an issueQualityByRepo map but buildContributorFit was still calling it without one, so production opportunity ranking never saw quality status. Threads the map through buildContributorFit and loads cached snapshots once per build-contributor-evidence run so do_not_use issues drop from contributor opportunities and needs_proof/hold downgrade correctly. Also moves the do_not_use filter before slice(0, 5) so dropped issues don't silently shrink the top-N output, and reuses the already-built CollisionReport when persisting issue-quality snapshots in generateSignalSnapshots to honor the Worker-budget acceptance. --- src/queue/processors.ts | 18 ++++++++++++++++-- src/signals/engine.ts | 12 ++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/queue/processors.ts b/src/queue/processors.ts index 5eff7e9a9a..cab28a8642 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -25,6 +25,7 @@ import { markInstallationDeleted, persistAdvisory, recordAuditEvent, + listSignalSnapshots, persistSignalSnapshot, recordWebhookEvent, replaceCollisionEdges, @@ -76,6 +77,7 @@ import { buildContributorStrategy, buildContributorIntakeHealth, buildIssueQualityReport, + type IssueQualityReport, buildLabelAudit, buildMaintainerCutReadiness, buildMaintainerLaneReport, @@ -284,6 +286,7 @@ async function buildContributorEvidence(env: Env, login?: string): Promise getOrCreateScoringModelSnapshot(env), ]); const logins = login ? [login] : [...new Set([...allPullRequests, ...allIssues].flatMap((record) => (record.authorLogin ? [record.authorLogin] : [])))].slice(0, 500); + const issueQualityByRepo = await loadIssueQualityByRepo(env, repositories); for (const contributorLogin of logins) { const [github, contributorPullRequests, contributorIssues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ fetchPublicContributorProfile(contributorLogin), @@ -294,7 +297,7 @@ async function buildContributorEvidence(env: Env, login?: string): Promise ]); const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); const profile = buildContributorProfile(contributorLogin, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); - const fit = buildContributorFit(profile, repositories, allIssues, allPullRequests, syncStates, repoStats); + const fit = buildContributorFit(profile, repositories, allIssues, allPullRequests, syncStates, repoStats, issueQualityByRepo); const scoringProfile = buildContributorScoringProfile({ login: contributorLogin, fit, scoringSnapshot: snapshot }); const outcomeHistory = buildContributorOutcomeHistory({ login: contributorLogin, profile, repositories, pullRequests: allPullRequests, issues: allIssues, repoStats }); const strategy = buildContributorStrategy({ login: contributorLogin, fit, scoringProfile, scoringSnapshot: snapshot, outcomeHistory }); @@ -371,7 +374,7 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): const maintainerLane = buildMaintainerLaneReport(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); const maintainerCutReadiness = buildMaintainerCutReadiness(repo, issues, pullRequests, repo.fullName, queueCounts, collisions); const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); - const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, repo.fullName); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, repo.fullName, collisions); await replaceCollisionEdges(env, repo.fullName, buildCollisionEdges(collisions)); const generatedAt = new Date().toISOString(); await persistSignalSnapshot(env, { @@ -433,6 +436,17 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): } } +async function loadIssueQualityByRepo(env: Env, repositories: Array<{ fullName: string; isRegistered: boolean }>): Promise> { + const map = new Map(); + await Promise.all( + repositories.filter((repo) => repo.isRegistered).map(async (repo) => { + const latest = (await listSignalSnapshots(env, "issue-quality", repo.fullName))[0]; + if (latest) map.set(repo.fullName, latest.payload as unknown as IssueQualityReport); + }), + ); + return map; +} + async function loadOpenQueueCounts(env: Env, repoFullName: string): Promise<{ openIssues: number; openPullRequests: number }> { const [totals, openIssues, openPullRequests] = await Promise.all([getLatestRepoGithubTotalsSnapshot(env, repoFullName), countOpenIssues(env, repoFullName), countOpenPullRequests(env, repoFullName)]); return { diff --git a/src/signals/engine.ts b/src/signals/engine.ts index dcce15f5e6..5f180ffbd7 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -1083,9 +1083,11 @@ export function buildContributorOpportunities( const qualityByIssue = qualityReport ? new Map(qualityReport.issues.map((entry) => [entry.number, entry])) : null; - for (const issue of availableIssues.slice(0, 5)) { + const rankable = qualityByIssue + ? availableIssues.filter((issue) => qualityByIssue.get(issue.number)?.status !== "do_not_use") + : availableIssues; + for (const issue of rankable.slice(0, 5)) { const quality = qualityByIssue?.get(issue.number); - if (quality && quality.status === "do_not_use") continue; const labelFit = issue.labels.filter((label) => labelHistory.has(label)).length; const qualityAdjustment = quality?.status === "ready" @@ -1142,8 +1144,9 @@ export function buildContributorFit( pullRequests: PullRequestRecord[], repoSyncStates: RepoSyncStateRecord[], repoStats: ContributorRepoStatRecord[], + issueQualityByRepo?: Map, ): ContributorFit { - const opportunities = buildContributorOpportunities(profile, repositories, issues, pullRequests); + const opportunities = buildContributorOpportunities(profile, repositories, issues, pullRequests, issueQualityByRepo); const languageSet = new Set(profile.github.topLanguages.map((language) => language.toLowerCase())); const syncByRepo = new Map(repoSyncStates.map((state) => [state.repoFullName, state])); const languageFit = repositories @@ -1883,9 +1886,10 @@ export function buildIssueQualityReport( issues: IssueRecord[], pullRequests: PullRequestRecord[], fullName: string, + prebuiltCollisions?: CollisionReport, ): IssueQualityReport { const lane = buildLaneAdvice(repo, fullName); - const collisions = buildCollisionReport(fullName, issues, pullRequests); + const collisions = prebuiltCollisions ?? buildCollisionReport(fullName, issues, pullRequests); const reports = issues .filter((issue) => issue.state === "open") .slice(0, 100) From d7dc1ac8fe921b42fb7c546b99080ea306c0e82d Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:29:47 +0200 Subject: [PATCH 6/8] refactor(signals): share REST + MCP issue-quality loader Extracts the cache-or-compute load path into services/issue-quality.ts so REST and MCP return the same envelope shape with the same generatedAt fallback. Adds an enveloped IssueQualityResponseSchema in OpenAPI so the documented contract matches what the handler actually returns. --- src/api/routes.ts | 12 ++---------- src/mcp/server.ts | 24 ++++++++--------------- src/openapi/schemas.ts | 10 ++++++++++ src/openapi/spec.ts | 4 +++- src/services/issue-quality.ts | 36 +++++++++++++++++++++++++++++++++++ 5 files changed, 59 insertions(+), 27 deletions(-) create mode 100644 src/services/issue-quality.ts diff --git a/src/api/routes.ts b/src/api/routes.ts index 5547a2ac89..e92f13f228 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -78,6 +78,7 @@ import { loadContributorDecisionPackForServing, repoDecisionFromPack, } from "../services/decision-pack"; +import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { buildBountyAdvisory, buildBurdenForecast, @@ -88,7 +89,6 @@ import { buildContributorProfile, buildContributorScoringProfile, buildContributorIntakeHealth, - buildIssueQualityReport, buildLabelAudit, buildLaneAdvice, buildLocalDiffPreflightResult, @@ -1102,15 +1102,7 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) { } async function buildIssueQualityResponse(env: Env, fullName: string) { - const cached = (await listSignalSnapshots(env, "issue-quality", fullName))[0]; - if (cached) { - return { status: "ready" as const, source: "snapshot" as const, repoFullName: fullName, generatedAt: cached.generatedAt ?? nowIso(), report: cached.payload }; - } - const repo = await getRepository(env, fullName); - if (!repo) return null; - const [issues, pullRequests] = await Promise.all([listIssueSignalSample(env, fullName), listOpenPullRequests(env, fullName)]); - const report = buildIssueQualityReport(repo, issues, pullRequests, fullName); - return { status: "ready" as const, source: "computed" as const, repoFullName: fullName, generatedAt: nowIso(), report }; + return loadOrComputeIssueQualityResponse(env, fullName); } async function buildRegistrationReadinessResponse(env: Env, fullName: string) { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5b035cc55d..320e7d61d0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -22,7 +22,6 @@ import { listRepoSyncSegments, listRepoSyncStates, listRepositories, - listSignalSnapshots, } from "../db/repositories"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; @@ -37,6 +36,7 @@ import { startAgentRun, } from "../services/agent-orchestrator"; import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../services/decision-pack"; +import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { buildBountyAdvisory, buildCollisionReport, @@ -45,7 +45,6 @@ import { buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile, - buildIssueQualityReport, buildLaneAdvice, buildLocalDiffPreflightResult, buildPreflightResult, @@ -500,26 +499,19 @@ export class GittensoryMcp { private async getIssueQuality(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; - const cached = (await listSignalSnapshots(this.env, "issue-quality", fullName))[0]; - if (cached) { - const payload = cached.payload as unknown as Record; - return { - summary: `Gittensory issue quality for ${fullName} (cached).`, - data: { repoFullName: fullName, source: "snapshot", generatedAt: cached.generatedAt ?? null, report: payload }, - }; - } - const repo = await getRepository(this.env, fullName); - if (!repo) { + const response = await loadOrComputeIssueQualityResponse(this.env, fullName); + if (!response) { return { summary: `Gittensory has no cached issue quality for ${fullName}.`, data: { status: "not_found", repoFullName: fullName }, }; } - const [issues, pullRequests] = await Promise.all([listIssueSignalSample(this.env, fullName), listOpenPullRequests(this.env, fullName)]); - const report = buildIssueQualityReport(repo, issues, pullRequests, fullName); return { - summary: `Gittensory issue quality for ${fullName} (computed from cached metadata).`, - data: { repoFullName: fullName, source: "computed", generatedAt: report.generatedAt, report: report as unknown as Record }, + summary: + response.source === "snapshot" + ? `Gittensory issue quality for ${fullName} (cached).` + : `Gittensory issue quality for ${fullName} (computed from cached metadata).`, + data: response as unknown as Record, }; } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 12b5235c25..01cf157ccb 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -898,6 +898,16 @@ export const IssueQualityReportSchema = z }) .openapi("IssueQualityReport"); +export const IssueQualityResponseSchema = z + .object({ + status: z.enum(["ready"]), + source: z.enum(["snapshot", "computed"]), + repoFullName: z.string(), + generatedAt: z.string(), + report: IssueQualityReportSchema, + }) + .openapi("IssueQualityResponse"); + export const BurdenForecastSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 3c3ca74495..fe7ac1e492 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -25,6 +25,7 @@ import { HealthSchema, InstallationHealthSchema, IssueQualityReportSchema, + IssueQualityResponseSchema, LabelAuditSchema, LaneAdviceSchema, LocalBranchAnalysisSchema, @@ -117,6 +118,7 @@ export function buildOpenApiSpec() { registry.register("ScoringModelSnapshot", ScoringModelSnapshotSchema); registry.register("ScorePreview", ScorePreviewSchema); registry.register("IssueQualityReport", IssueQualityReportSchema); + registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("BurdenForecast", BurdenForecastSchema); registry.register("ContributorScoringProfile", ContributorScoringProfileSchema); registry.register("ContributorStrategy", ContributorStrategySchema); @@ -227,7 +229,7 @@ export function buildOpenApiSpec() { method: "get", path: "/v1/repos/{owner}/{repo}/issue-quality", responses: { - 200: { description: "Cached or computed issue quality report for the repo", content: { "application/json": { schema: IssueQualityReportSchema } } }, + 200: { description: "Cached or computed issue quality report for the repo", content: { "application/json": { schema: IssueQualityResponseSchema } } }, 404: { description: "Repo is unknown or has no issue-quality coverage yet" }, }, }); diff --git a/src/services/issue-quality.ts b/src/services/issue-quality.ts new file mode 100644 index 0000000000..c95ef3292f --- /dev/null +++ b/src/services/issue-quality.ts @@ -0,0 +1,36 @@ +import { getRepository, listIssueSignalSample, listOpenPullRequests, listSignalSnapshots } from "../db/repositories"; +import { buildIssueQualityReport } from "../signals/engine"; + +export type IssueQualityResponse = { + status: "ready"; + source: "snapshot" | "computed"; + repoFullName: string; + generatedAt: string; + report: Record; +}; + +export async function loadOrComputeIssueQualityResponse(env: Env, fullName: string): Promise { + const cached = (await listSignalSnapshots(env, "issue-quality", fullName))[0]; + if (cached) { + const payload = cached.payload as Record; + const generatedAt = cached.generatedAt ?? (payload.generatedAt as string | undefined) ?? new Date().toISOString(); + return { + status: "ready", + source: "snapshot", + repoFullName: fullName, + generatedAt, + report: payload, + }; + } + const repo = await getRepository(env, fullName); + if (!repo) return null; + const [issues, pullRequests] = await Promise.all([listIssueSignalSample(env, fullName), listOpenPullRequests(env, fullName)]); + const report = buildIssueQualityReport(repo, issues, pullRequests, fullName); + return { + status: "ready", + source: "computed", + repoFullName: fullName, + generatedAt: report.generatedAt, + report: report as unknown as Record, + }; +} From 98d20ac879c546b20c5600492eb7bf2e0eb6425a Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:29:50 +0200 Subject: [PATCH 7/8] test(signals): tighten issue-quality fixtures and assert wiring - Stale fixture now asserts status: needs_proof and exact warning. - Issue-discovery fixture asserts score >= 70 and the reasons array. - Duplicate-prone fixture asserts both issue numbers and the exact warning string. - New end-to-end unit test routes a do_not_use issue through buildContributorFit and asserts it is dropped from opportunities. - queue.test.ts now positively asserts an issue-quality snapshot is persisted after generate-signal-snapshots and that the payload has the expected shape. --- test/unit/issue-quality.test.ts | 48 ++++++++++++++++++++++++++++++--- test/unit/queue.test.ts | 3 +++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/test/unit/issue-quality.test.ts b/test/unit/issue-quality.test.ts index 44ade9ea2a..4bc0ddae96 100644 --- a/test/unit/issue-quality.test.ts +++ b/test/unit/issue-quality.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildContributorFit, buildContributorOpportunities, buildIssueQualityReport, type ContributorProfile, @@ -22,7 +23,14 @@ describe("issue quality reports", () => { const repo = issueDiscoveryRepo("owner/discovery"); const report = buildIssueQualityReport(repo, [issue(repo.fullName, 2, "Actionable discovery", { body: "x".repeat(220), labels: ["good first issue"], updatedAt: now() })], [], repo.fullName); expect(report.lane.lane).toBe("issue_discovery"); - expect(report.issues[0]?.status).toBe("ready"); + expect(report.issues[0]).toMatchObject({ + status: "ready", + reasons: expect.arrayContaining([ + "Issue has enough body detail to evaluate.", + "No active PR is linked in cached metadata.", + ]), + }); + expect(report.issues[0]?.score).toBeGreaterThanOrEqual(70); expect(report.issues[0]?.warnings).not.toEqual(expect.arrayContaining([expect.stringMatching(/direct-PR/i)])); }); @@ -43,7 +51,10 @@ describe("issue quality reports", () => { [], repo.fullName, ); - expect(report.issues[0]?.warnings).toEqual(expect.arrayContaining([expect.stringContaining("stale")])); + expect(report.issues[0]).toMatchObject({ + status: "needs_proof", + warnings: expect.arrayContaining(["Issue is stale in cached metadata."]), + }); }); it("marks an already-solved issue as do_not_use when a linked PR exists", () => { @@ -54,12 +65,13 @@ describe("issue quality reports", () => { expect(report.issues[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/already reference this issue/i)])); }); - it("surfaces duplicate-prone context via collision detection", () => { + 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"] }); const b = issue(repo.fullName, 11, "Login flow fails after reconnect when user disconnects", { body: "x".repeat(220), labels: ["bug"] }); const report = buildIssueQualityReport(repo, [a, b], [], repo.fullName); - expect(report.issues.some((entry) => entry.warnings.some((warning) => /duplicate|overlapping/i.test(warning)))).toBe(true); + const flagged = report.issues.filter((entry) => entry.warnings.includes("Potential duplicate or overlapping issue/PR context exists.")); + expect(flagged.map((entry) => entry.number).sort()).toEqual([10, 11]); }); it("downgrades direct-PR-lane issue filing in warnings", () => { @@ -166,6 +178,34 @@ describe("buildContributorOpportunities x issue quality", () => { expect(opportunities[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/hold; consider skipping/i)])); }); + it("threads the issue-quality map through buildContributorFit so do_not_use is dropped end-to-end", () => { + const repo = issueDiscoveryRepo("owner/threaded"); + const issues = [ + issue(repo.fullName, 1, "Drop me", { body: "x".repeat(220), labels: ["bug"] }), + issue(repo.fullName, 2, "Keep me", { body: "x".repeat(220), labels: ["bug"] }), + ]; + const quality: IssueQualityReport = { + repoFullName: repo.fullName, + generatedAt: now(), + lane: { repoFullName: repo.fullName, lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [ + { number: 1, title: "Drop me", status: "do_not_use", score: 0, reasons: [], warnings: [] }, + { number: 2, title: "Keep me", status: "ready", score: 88, reasons: [], warnings: [] }, + ], + summary: "", + }; + const fit = buildContributorFit( + sampleProfile(), + [repo], + issues, + [], + [], + [], + new Map([[repo.fullName, quality]]), + ); + expect(fit.opportunities.map((o) => o.issueNumber)).toEqual([2]); + }); + it("matches the repo case-insensitively when looking up cached quality", () => { const repo = issueDiscoveryRepo("Owner/MixedCase"); const issues = [issue(repo.fullName, 1, "Title", { body: "x".repeat(220) })]; diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 7250fa161f..6cb1574fe8 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -97,6 +97,9 @@ describe("queue processors", () => { expect(await listRepoSyncStates(env)).toMatchObject([{ repoFullName: "JSONbored/gittensory", status: "success" }]); expect(await listCollisionEdges(env, "JSONbored/gittensory")).not.toHaveLength(0); expect(await listSignalSnapshots(env, "queue-health", "JSONbored/gittensory")).toHaveLength(1); + const issueQualitySnapshots = await listSignalSnapshots(env, "issue-quality", "JSONbored/gittensory"); + expect(issueQualitySnapshots).toHaveLength(1); + expect(issueQualitySnapshots[0]?.payload).toMatchObject({ repoFullName: "JSONbored/gittensory", issues: expect.any(Array), summary: expect.any(String) }); expect(await listSignalSnapshots(env, "contributor-decision-pack", "oktofeesh1")).not.toHaveLength(0); expect(await getContributorEvidence(env, "oktofeesh1")).toMatchObject({ login: "oktofeesh1" }); expect(await getContributorScoringProfile(env, "oktofeesh1")).toMatchObject({ login: "oktofeesh1" }); From 3e7031d2e491de381b870eca1cc16df1b960f559 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 29 May 2026 10:44:41 +0200 Subject: [PATCH 8/8] fix: complete issue-quality wiring --- src/api/routes.ts | 26 +++++++---- src/mcp/server.ts | 14 ++++-- src/queue/processors.ts | 18 ++----- src/services/agent-orchestrator.ts | 5 +- src/services/decision-pack.ts | 73 +++++++++++++++++++++++++---- src/services/issue-quality.ts | 25 +++++++--- src/signals/engine.ts | 58 +++++++++++++++++++++-- src/signals/local-branch.ts | 3 ++ test/unit/decision-pack.test.ts | 75 ++++++++++++++++++++++++++++++ test/unit/issue-quality.test.ts | 41 +++++++++++++++- test/unit/local-branch.test.ts | 47 ++++++++++++++++++- 11 files changed, 334 insertions(+), 51 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index e92f13f228..8721f3d75c 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -732,33 +732,40 @@ export function createApp() { const body = await c.req.json().catch(() => null); const parsed = preflightSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_preflight_request", issues: parsed.error.issues }, 400); - const repo = await getRepository(c.env, parsed.data.repoFullName); - const issues = await listIssues(c.env, parsed.data.repoFullName); - const pullRequests = await listPullRequests(c.env, parsed.data.repoFullName); - return c.json(buildPreflightResult(parsed.data, repo, issues, pullRequests)); + const [repo, issues, pullRequests, issueQuality] = await Promise.all([ + getRepository(c.env, parsed.data.repoFullName), + listIssues(c.env, parsed.data.repoFullName), + listPullRequests(c.env, parsed.data.repoFullName), + loadOrComputeIssueQualityResponse(c.env, parsed.data.repoFullName), + ]); + return c.json(buildPreflightResult(parsed.data, repo, issues, pullRequests, issueQuality?.report)); }); app.post("/v1/preflight/local-diff", async (c) => { const body = await c.req.json().catch(() => null); const parsed = localDiffPreflightSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_local_diff_preflight_request", issues: parsed.error.issues }, 400); - const repo = await getRepository(c.env, parsed.data.repoFullName); - const issues = await listIssues(c.env, parsed.data.repoFullName); - const pullRequests = await listPullRequests(c.env, parsed.data.repoFullName); - return c.json(buildLocalDiffPreflightResult(parsed.data, repo, issues, pullRequests)); + const [repo, issues, pullRequests, issueQuality] = await Promise.all([ + getRepository(c.env, parsed.data.repoFullName), + listIssues(c.env, parsed.data.repoFullName), + listPullRequests(c.env, parsed.data.repoFullName), + loadOrComputeIssueQualityResponse(c.env, parsed.data.repoFullName), + ]); + return c.json(buildLocalDiffPreflightResult(parsed.data, repo, issues, pullRequests, issueQuality?.report)); }); app.post("/v1/local/branch-analysis", async (c) => { const body = await c.req.json().catch(() => null); const parsed = localBranchAnalysisSchema.safeParse(body); if (!parsed.success) return c.json({ error: "invalid_local_branch_analysis_request", issues: parsed.error.issues }, 400); - const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot] = await Promise.all([ + const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot, issueQuality] = await Promise.all([ loadContributorFastContext(c.env, parsed.data.login), getRepository(c.env, parsed.data.repoFullName), listIssues(c.env, parsed.data.repoFullName), listPullRequests(c.env, parsed.data.repoFullName), listRecentMergedPullRequests(c.env, parsed.data.repoFullName), getOrCreateScoringModelSnapshot(c.env), + loadOrComputeIssueQualityResponse(c.env, parsed.data.repoFullName), ]); const fit = buildContributorFit(context.profile, context.repositories, [], [], context.syncStates, context.repoStats); const scoringProfile = buildContributorScoringProfile({ login: parsed.data.login, fit, scoringSnapshot: snapshot }); @@ -772,6 +779,7 @@ export function createApp() { outcomeHistory: context.outcomeHistory, scoringSnapshot: snapshot, scoringProfile, + issueQuality: issueQuality?.report, }); 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/mcp/server.ts b/src/mcp/server.ts index 320e7d61d0..afc747f1fa 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -592,26 +592,28 @@ export class GittensoryMcp { } private async preflightPr(input: z.infer>): Promise { - const [repo, issues, pullRequests] = await Promise.all([ + const [repo, issues, pullRequests, issueQuality] = await Promise.all([ getRepository(this.env, input.repoFullName), listIssues(this.env, input.repoFullName), listPullRequests(this.env, input.repoFullName), + loadOrComputeIssueQualityResponse(this.env, input.repoFullName), ]); return { summary: `Gittensory PR preflight for ${input.repoFullName}.`, - data: buildPreflightResult(input, repo, issues, pullRequests) as unknown as Record, + data: buildPreflightResult(input, repo, issues, pullRequests, issueQuality?.report) as unknown as Record, }; } private async preflightLocalDiff(input: z.infer>): Promise { - const [repo, issues, pullRequests] = await Promise.all([ + const [repo, issues, pullRequests, issueQuality] = await Promise.all([ getRepository(this.env, input.repoFullName), listIssues(this.env, input.repoFullName), listPullRequests(this.env, input.repoFullName), + loadOrComputeIssueQualityResponse(this.env, input.repoFullName), ]); return { summary: `Gittensory local diff preflight for ${input.repoFullName}.`, - data: buildLocalDiffPreflightResult(input, repo, issues, pullRequests) as unknown as Record, + data: buildLocalDiffPreflightResult(input, repo, issues, pullRequests, issueQuality?.report) as unknown as Record, }; } @@ -768,13 +770,14 @@ export class GittensoryMcp { } private async analyzeLocalBranch(input: z.infer>) { - const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot] = await Promise.all([ + const [context, repo, issues, pullRequests, recentMergedPullRequests, snapshot, issueQuality] = await Promise.all([ this.loadContributorFastContext(input.login), getRepository(this.env, input.repoFullName), listIssues(this.env, input.repoFullName), listPullRequests(this.env, input.repoFullName), listRecentMergedPullRequests(this.env, input.repoFullName), getOrCreateScoringModelSnapshot(this.env), + loadOrComputeIssueQualityResponse(this.env, input.repoFullName), ]); const fit = buildContributorFit(context.profile, context.repositories, [], [], context.syncStates, context.repoStats); const scoringProfile = buildContributorScoringProfile({ login: input.login, fit, scoringSnapshot: snapshot }); @@ -789,6 +792,7 @@ export class GittensoryMcp { outcomeHistory: context.outcomeHistory, scoringSnapshot: snapshot, scoringProfile, + issueQuality: issueQuality?.report, }), dataQuality: await this.loadRepoDataQuality(input.repoFullName), }; diff --git a/src/queue/processors.ts b/src/queue/processors.ts index cab28a8642..b4550267dd 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -25,7 +25,6 @@ import { markInstallationDeleted, persistAdvisory, recordAuditEvent, - listSignalSnapshots, persistSignalSnapshot, recordWebhookEvent, replaceCollisionEdges, @@ -61,6 +60,7 @@ import { buildIssueAdvisory, buildPullRequestAdvisory } from "../rules/advisory" import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; import { buildAndPersistContributorDecisionPack } from "../services/decision-pack"; import { executeAgentRun, explainBlockersWithAgent, planNextWork } from "../services/agent-orchestrator"; +import { loadIssueQualityReportMap } from "../services/issue-quality"; import { buildFreshnessSloReport, freshnessAuditMetadata, @@ -77,7 +77,6 @@ import { buildContributorStrategy, buildContributorIntakeHealth, buildIssueQualityReport, - type IssueQualityReport, buildLabelAudit, buildMaintainerCutReadiness, buildMaintainerLaneReport, @@ -286,7 +285,7 @@ async function buildContributorEvidence(env: Env, login?: string): Promise getOrCreateScoringModelSnapshot(env), ]); const logins = login ? [login] : [...new Set([...allPullRequests, ...allIssues].flatMap((record) => (record.authorLogin ? [record.authorLogin] : [])))].slice(0, 500); - const issueQualityByRepo = await loadIssueQualityByRepo(env, repositories); + const issueQualityByRepo = await loadIssueQualityReportMap(env, repositories); for (const contributorLogin of logins) { const [github, contributorPullRequests, contributorIssues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ fetchPublicContributorProfile(contributorLogin), @@ -374,7 +373,7 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): const maintainerLane = buildMaintainerLaneReport(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); const maintainerCutReadiness = buildMaintainerCutReadiness(repo, issues, pullRequests, repo.fullName, queueCounts, collisions); const contributorIntakeHealth = buildContributorIntakeHealth(repo, issues, pullRequests, repo.fullName, collisions, queueCounts); - const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, repo.fullName, collisions); + const issueQuality = buildIssueQualityReport(repo, issues, pullRequests, repo.fullName, collisions, recentMergedPullRequests); await replaceCollisionEdges(env, repo.fullName, buildCollisionEdges(collisions)); const generatedAt = new Date().toISOString(); await persistSignalSnapshot(env, { @@ -436,17 +435,6 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string): } } -async function loadIssueQualityByRepo(env: Env, repositories: Array<{ fullName: string; isRegistered: boolean }>): Promise> { - const map = new Map(); - await Promise.all( - repositories.filter((repo) => repo.isRegistered).map(async (repo) => { - const latest = (await listSignalSnapshots(env, "issue-quality", repo.fullName))[0]; - if (latest) map.set(repo.fullName, latest.payload as unknown as IssueQualityReport); - }), - ); - return map; -} - async function loadOpenQueueCounts(env: Env, repoFullName: string): Promise<{ openIssues: number; openPullRequests: number }> { const [totals, openIssues, openPullRequests] = await Promise.all([getLatestRepoGithubTotalsSnapshot(env, repoFullName), countOpenIssues(env, repoFullName), countOpenPullRequests(env, repoFullName)]); return { diff --git a/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index 2eb8cfe355..1f52fb2ecd 100644 --- a/src/services/agent-orchestrator.ts +++ b/src/services/agent-orchestrator.ts @@ -21,6 +21,7 @@ import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } import { fetchPublicContributorProfile } from "../github/public"; import { getOrCreateScoringModelSnapshot } from "../scoring/model"; import { loadContributorDecisionPackForServing, repoDecisionFromPack, type ContributorDecisionPack, type DecisionAction, type RepoDecision } from "./decision-pack"; +import { loadOrComputeIssueQualityResponse } from "./issue-quality"; import { summarizeAgentBundleWithAi } from "./ai-summaries"; import { buildContributorFit, buildContributorOutcomeHistory, buildContributorProfile, buildContributorScoringProfile } from "../signals/engine"; import { buildLocalBranchAnalysis, type LocalBranchAnalysis, type LocalBranchAnalysisInput } from "../signals/local-branch"; @@ -280,7 +281,7 @@ async function executeLocalBranchRun(env: Env, run: AgentRunRecord, kind: string } async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Promise { - const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot, repo, issues, pullRequests, recentMergedPullRequests, scoringSnapshot] = + const [github, contributorPullRequests, contributorIssues, repositories, syncStates, cachedRepoStats, gittensorSnapshot, repo, issues, pullRequests, recentMergedPullRequests, scoringSnapshot, issueQuality] = await Promise.all([ fetchPublicContributorProfile(input.login), listContributorPullRequests(env, input.login), @@ -294,6 +295,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr listPullRequests(env, input.repoFullName), listRecentMergedPullRequests(env, input.repoFullName), getOrCreateScoringModelSnapshot(env), + loadOrComputeIssueQualityResponse(env, input.repoFullName), ]); const repoStats = contributorRepoStatsFromGittensor(gittensorSnapshot).length > 0 ? contributorRepoStatsFromGittensor(gittensorSnapshot) : cachedRepoStats; const profile = buildContributorProfile(input.login, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); @@ -310,6 +312,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr outcomeHistory, scoringSnapshot, scoringProfile, + issueQuality: issueQuality?.report, }); } diff --git a/src/services/decision-pack.ts b/src/services/decision-pack.ts index f847259aef..dbc783f410 100644 --- a/src/services/decision-pack.ts +++ b/src/services/decision-pack.ts @@ -25,9 +25,11 @@ import { buildRoleContext, type ContributorOutcomeHistory, type ContributorProfile, + type IssueQualityReport, type RoleContext, } from "../signals/engine"; import { buildSignalFidelity } from "../signals/data-quality"; +import { loadIssueQualityReportMap } from "./issue-quality"; import type { ContributorRepoStatRecord, JsonValue, RepositoryRecord, RepoGithubTotalsSnapshotRecord, RepoSyncSegmentRecord, RepoSyncStateRecord, SignalSnapshotRecord } from "../types"; import { nowIso } from "../utils/json"; @@ -118,6 +120,7 @@ export type RepoDecision = { whyThisHelps: string[]; nextActions: string[]; publicNextActions: string[]; + issueQuality?: IssueQualitySummary | undefined; }; export type DecisionAction = { @@ -137,6 +140,14 @@ export type ScoreBlocker = { detail: string; }; +export type IssueQualitySummary = { + readyCount: number; + needsProofCount: number; + holdCount: number; + doNotUseCount: number; + topReadyIssues: Array<{ number: number; title: string; score: number }>; +}; + export async function loadContributorDecisionPack(env: Env, login: string): Promise { const latest = (await listSignalSnapshots(env, CONTRIBUTOR_DECISION_PACK_SIGNAL, login))[0]; if (!latest) return null; @@ -242,6 +253,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st getOrCreateScoringModelSnapshot(env), ]); const repoStats = authoritativeContributorRepoStats(gittensorSnapshot, cachedRepoStats); + const issueQualityByRepo = await loadIssueQualityReportMap(env, repositories); const profile = buildContributorProfile(login, github, contributorPullRequests, contributorIssues, repoStats, gittensorSnapshot); const outcomeHistory = buildContributorOutcomeHistory({ login, @@ -264,6 +276,7 @@ export async function buildAndPersistContributorDecisionPack(env: Env, login: st scoringModelSnapshotId: scoringSnapshot.id, contributorPullRequests, contributorIssues, + issueQualityByRepo, }); await upsertContributorEvidence(env, { @@ -312,11 +325,15 @@ function buildContributorDecisionPack(args: { scoringModelSnapshotId: string; contributorPullRequests: Parameters[0]["pullRequests"]; contributorIssues: Parameters[0]["issues"]; + issueQualityByRepo?: Map | undefined; }): ContributorDecisionPack { const registeredRepositories = args.repositories.filter((repo) => repo.isRegistered); const syncByRepo = new Map(args.syncStates.map((state) => [state.repoFullName.toLowerCase(), state])); const totalsByRepo = new Map(args.totals.map((total) => [total.repoFullName.toLowerCase(), total])); const outcomeByRepo = new Map(args.outcomeHistory.repoOutcomes.map((outcome) => [outcome.repoFullName.toLowerCase(), outcome])); + const issueQualityByRepo = args.issueQualityByRepo + ? new Map([...args.issueQualityByRepo.entries()].map(([repoFullName, report]) => [repoFullName.toLowerCase(), report])) + : new Map(); const languageSet = new Set((args.profile.github?.topLanguages ?? []).map((language) => language.toLowerCase())); const labelHistory = new Set(args.profile.registeredRepoActivity?.dominantLabels ?? []); const roleContexts = registeredRepositories.map((repo) => @@ -341,6 +358,7 @@ function buildContributorDecisionPack(args: { totals: totalsByRepo.get(key), languageSet, labelHistory, + issueQuality: issueQualityByRepo.get(key), }); }) .sort((left, right) => right.priorityScore - left.priorityScore || left.repoFullName.localeCompare(right.repoFullName)); @@ -389,6 +407,7 @@ function buildRepoDecision(args: { totals?: RepoGithubTotalsSnapshotRecord | undefined; languageSet?: Set | undefined; labelHistory?: Set | undefined; + issueQuality?: IssueQualityReport | undefined; }): RepoDecision { const lane = buildLaneAdvice(args.repo, args.repo.fullName); const config = args.repo.registryConfig; @@ -407,15 +426,18 @@ function buildRepoDecision(args: { maintainerCut: round(config?.maintainerCut ?? 0), }; const blockers = scoreBlockersFor(args.repo.fullName, lane.lane, args.roleContext, args.outcome); + const issueQuality = summarizeIssueQuality(args.issueQuality); const riskReasons = [ ...(queue.openPullRequests >= 25 ? [`Repo queue is busy with ${queue.openPullRequests} open PR(s).`] : []), ...(queue.openIssues >= 100 ? [`Repo issue queue is large with ${queue.openIssues} open issue(s).`] : []), ...(args.outcome && args.outcome.closedPullRequestRate >= 0.35 ? [`Repo-specific closed PR rate is ${Math.round(args.outcome.closedPullRequestRate * 100)}%.`] : []), ...(args.outcome && args.outcome.openPullRequests >= 3 ? [`Contributor has ${args.outcome.openPullRequests} open PR(s) in this repo.`] : []), ...(lane.lane === "issue_discovery" ? ["Direct PRs are not the useful lane here; use issue-discovery behavior only."] : []), + ...(issueQuality && issueQuality.doNotUseCount > 0 ? [`Issue quality marks ${issueQuality.doNotUseCount} cached issue(s) as already covered or duplicate-prone.`] : []), + ...(issueQuality && issueQuality.readyCount === 0 && (lane.lane === "issue_discovery" || lane.lane === "split") ? ["No ready issue-quality candidate is cached for this repo."] : []), ]; const recommendation = recommendationFor(lane.lane, args.roleContext, args.outcome, blockers); - const priorityScore = priorityFor(recommendation, rewardUpside, args.outcome, queue, blockers); + const priorityScore = clamp(priorityFor(recommendation, rewardUpside, args.outcome, queue, blockers) + issueQualityPriorityAdjustment(lane.lane, issueQuality), 0, 100); const syncLanguage = args.syncState?.primaryLanguage ?? null; const languageMatch: LanguageMatch = { language: syncLanguage, @@ -433,6 +455,7 @@ function buildRepoDecision(args: { outcome: args.outcome, languageMatch, labelFit, + issueQuality, }; return { repoFullName: args.repo.fullName, @@ -450,6 +473,7 @@ function buildRepoDecision(args: { whyThisHelps: whyThisHelpsFor(recommendation, copyContext), nextActions: nextActionsFor(recommendation, copyContext), publicNextActions: publicNextActionsFor(recommendation, copyContext), + issueQuality, }; } @@ -529,12 +553,14 @@ type RepoCopyContext = { outcome: ContributorOutcomeHistory["repoOutcomes"][number] | undefined; languageMatch: LanguageMatch; labelFit: string[]; + issueQuality?: IssueQualitySummary | undefined; }; function whyThisHelpsFor(recommendation: DecisionRecommendation, context: RepoCopyContext): string[] { - const { repoFullName, rewardUpside, outcome, languageMatch, labelFit, lane } = context; + const { repoFullName, rewardUpside, outcome, languageMatch, labelFit, lane, issueQuality } = context; const labelPhrase = labelFit.length > 0 ? ` Label overlap with your history: ${labelFit.slice(0, 3).join(", ")}.` : ""; const languagePhrase = languageMatch.match && languageMatch.language ? ` Primary language ${languageMatch.language} matches your top languages.` : ""; + const qualityPhrase = issueQuality && issueQuality.readyCount > 0 ? ` Issue quality has ${issueQuality.readyCount} ready candidate(s).` : ""; if (recommendation === "cleanup_first") { const openCount = outcome?.openPullRequests ?? 0; return [`${repoFullName}: ${openCount} of your open PR(s) here block scoreability; clearing them lowers maintainer friction.${labelPhrase}`]; @@ -546,20 +572,21 @@ function whyThisHelpsFor(recommendation: DecisionRecommendation, context: RepoCo const merged = outcome?.mergedPullRequests ?? 0; const historyPhrase = merged > 0 ? ` You have ${merged} merged PR(s) in this repo already.` : ""; if (lane === "split") { - return [`${repoFullName}: split lane (direct PR ${round(rewardUpside.directPrShare)}, issue-discovery ${round(rewardUpside.issueDiscoveryShare)}); both lanes are useful here.${languagePhrase}${labelPhrase}${historyPhrase}`]; + return [`${repoFullName}: split lane (direct PR ${round(rewardUpside.directPrShare)}, issue-discovery ${round(rewardUpside.issueDiscoveryShare)}); both lanes are useful here.${languagePhrase}${labelPhrase}${historyPhrase}${qualityPhrase}`]; } return [`${repoFullName}: direct PR lane share ${round(rewardUpside.directPrShare)} with no hard personal blocker.${languagePhrase}${labelPhrase}${historyPhrase}`]; } if (recommendation === "watch") { - return [`${repoFullName}: ${lane === "issue_discovery" ? "issue-discovery-only" : "low-direct-PR"} lane; only actionable, non-duplicate issue reports add value.${labelPhrase}`]; + return [`${repoFullName}: ${lane === "issue_discovery" ? "issue-discovery-only" : "low-direct-PR"} lane; only actionable, non-duplicate issue reports add value.${labelPhrase}${qualityPhrase}`]; } return [`${repoFullName}: risk-adjusted priority is low until blockers improve.`]; } function nextActionsFor(recommendation: DecisionRecommendation, context: RepoCopyContext): string[] { - const { repoFullName, queue, outcome, languageMatch, labelFit, lane } = context; + const { repoFullName, queue, outcome, languageMatch, labelFit, lane, issueQuality } = context; const labelHint = labelFit.length > 0 ? ` (target labels: ${labelFit.slice(0, 3).join(", ")})` : ""; const languageHint = languageMatch.match && languageMatch.language ? ` in ${languageMatch.language}` : ""; + const topReadyIssue = issueQuality?.topReadyIssues[0]; if (recommendation === "cleanup_first") { const openCount = outcome?.openPullRequests ?? 0; return [ @@ -575,6 +602,11 @@ function nextActionsFor(recommendation: DecisionRecommendation, context: RepoCop } if (recommendation === "pursue") { if (lane === "split") { + if (topReadyIssue) { + return [ + `${repoFullName}: split lane — either open a narrow direct PR${languageHint}${labelHint} or file issue-discovery on #${topReadyIssue.number}: ${topReadyIssue.title}.`, + ]; + } return [ `${repoFullName}: split lane — choose direct PR${languageHint}${labelHint} OR file an actionable issue-discovery report; queue has ${queue.openPullRequests} open PR(s) and ${queue.openIssues} open issue(s).`, ]; @@ -584,6 +616,11 @@ function nextActionsFor(recommendation: DecisionRecommendation, context: RepoCop ]; } if (recommendation === "watch" || lane === "issue_discovery") { + if (topReadyIssue) { + return [ + `${repoFullName}: file issue-discovery on ready candidate #${topReadyIssue.number}: ${topReadyIssue.title}${labelHint}.`, + ]; + } return [ `${repoFullName}: file only high-confidence, actionable, non-duplicate issue-discovery reports${labelHint}. Open issues in queue: ${queue.openIssues}.`, ]; @@ -592,9 +629,10 @@ function nextActionsFor(recommendation: DecisionRecommendation, context: RepoCop } function publicNextActionsFor(recommendation: DecisionRecommendation, context: RepoCopyContext): string[] { - const { repoFullName, languageMatch, labelFit, lane } = context; + const { repoFullName, languageMatch, labelFit, lane, issueQuality } = context; const languageHint = languageMatch.match && languageMatch.language ? ` in ${languageMatch.language}` : ""; const labelHint = labelFit.length > 0 ? ` (consider labels: ${labelFit.slice(0, 3).join(", ")})` : ""; + const issueQualityHint = issueQuality && issueQuality.readyCount > 0 ? " Use issue-quality ready candidates before posting." : ""; if (recommendation === "cleanup_first") { return [`${repoFullName}: resolve open PR pressure before opening additional review load.`]; } @@ -603,16 +641,35 @@ function publicNextActionsFor(recommendation: DecisionRecommendation, context: R } if (recommendation === "pursue") { if (lane === "split") { - return [`${repoFullName}: split lane — direct PR or actionable issue report${languageHint}${labelHint}; use Gittensory preflight before posting public PR context.`]; + return [`${repoFullName}: split lane — direct PR or actionable issue report${languageHint}${labelHint}; use Gittensory preflight before posting public PR context.${issueQualityHint}`]; } return [`${repoFullName}: pick a narrow change${languageHint}${labelHint}; use Gittensory preflight before posting public PR context.`]; } if (recommendation === "watch" || lane === "issue_discovery") { - return [`${repoFullName}: file only actionable, non-duplicate issue-discovery reports${labelHint}.`]; + return [`${repoFullName}: file only actionable, non-duplicate issue-discovery reports${labelHint}.${issueQualityHint}`]; } return [`${repoFullName}: consider a different repo until lane/credibility signals improve.`]; } +function summarizeIssueQuality(report: IssueQualityReport | undefined): IssueQualitySummary | undefined { + if (!report) return undefined; + const ready = report.issues.filter((issue) => issue.status === "ready"); + return { + readyCount: ready.length, + needsProofCount: report.issues.filter((issue) => issue.status === "needs_proof").length, + holdCount: report.issues.filter((issue) => issue.status === "hold").length, + doNotUseCount: report.issues.filter((issue) => issue.status === "do_not_use").length, + topReadyIssues: ready.slice(0, 3).map((issue) => ({ number: issue.number, title: issue.title, score: issue.score })), + }; +} + +function issueQualityPriorityAdjustment(lane: string, issueQuality: IssueQualitySummary | undefined): number { + if (!issueQuality || (lane !== "issue_discovery" && lane !== "split")) return 0; + if (issueQuality.readyCount > 0) return 8; + if (issueQuality.doNotUseCount > 0 || issueQuality.needsProofCount > 0 || issueQuality.holdCount > 0) return -8; + return 0; +} + function sanitizeOfficialStats(profile: ContributorProfile): ContributorDecisionPack["profile"]["officialStats"] { if (!profile.gittensor) return null; const { hotkey: _hotkey, ...safe } = profile.gittensor; diff --git a/src/services/issue-quality.ts b/src/services/issue-quality.ts index c95ef3292f..b3897c5fbe 100644 --- a/src/services/issue-quality.ts +++ b/src/services/issue-quality.ts @@ -1,18 +1,18 @@ -import { getRepository, listIssueSignalSample, listOpenPullRequests, listSignalSnapshots } from "../db/repositories"; -import { buildIssueQualityReport } from "../signals/engine"; +import { getRepository, listIssueSignalSample, listOpenPullRequests, listRecentMergedPullRequests, listSignalSnapshots } from "../db/repositories"; +import { buildIssueQualityReport, type IssueQualityReport } from "../signals/engine"; export type IssueQualityResponse = { status: "ready"; source: "snapshot" | "computed"; repoFullName: string; generatedAt: string; - report: Record; + report: IssueQualityReport; }; export async function loadOrComputeIssueQualityResponse(env: Env, fullName: string): Promise { const cached = (await listSignalSnapshots(env, "issue-quality", fullName))[0]; if (cached) { - const payload = cached.payload as Record; + const payload = cached.payload as unknown as IssueQualityReport; const generatedAt = cached.generatedAt ?? (payload.generatedAt as string | undefined) ?? new Date().toISOString(); return { status: "ready", @@ -24,13 +24,24 @@ export async function loadOrComputeIssueQualityResponse(env: Env, fullName: stri } const repo = await getRepository(env, fullName); if (!repo) return null; - const [issues, pullRequests] = await Promise.all([listIssueSignalSample(env, fullName), listOpenPullRequests(env, fullName)]); - const report = buildIssueQualityReport(repo, issues, pullRequests, fullName); + const [issues, pullRequests, recentMergedPullRequests] = await Promise.all([listIssueSignalSample(env, fullName), listOpenPullRequests(env, fullName), listRecentMergedPullRequests(env, fullName)]); + const report = buildIssueQualityReport(repo, issues, pullRequests, fullName, undefined, recentMergedPullRequests); return { status: "ready", source: "computed", repoFullName: fullName, generatedAt: report.generatedAt, - report: report as unknown as Record, + report, }; } + +export async function loadIssueQualityReportMap(env: Env, repositories: Array<{ fullName: string; isRegistered: boolean }>): Promise> { + const map = new Map(); + await Promise.all( + repositories.filter((repo) => repo.isRegistered).map(async (repo) => { + const latest = (await listSignalSnapshots(env, "issue-quality", repo.fullName))[0]; + if (latest) map.set(repo.fullName, latest.payload as unknown as IssueQualityReport); + }), + ); + return map; +} diff --git a/src/signals/engine.ts b/src/signals/engine.ts index 5f180ffbd7..f114bd9464 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -1569,6 +1569,7 @@ export function buildPreflightResult( repo: RepositoryRecord | null, issues: IssueRecord[], pullRequests: PullRequestRecord[], + issueQuality?: IssueQualityReport | null | undefined, ): PreflightResult { const lane = buildLaneAdvice(repo, input.repoFullName); const linkedIssues = [...new Set([...(input.linkedIssues ?? []), ...extractLinkedIssueNumbers(input.body ?? "")])].sort((left, right) => left - right); @@ -1603,6 +1604,7 @@ export function buildPreflightResult( action: "Check active issues and PRs before submitting.", }); } + findings.push(...issueQualityFindings(linkedIssues, issueQuality)); const changedFiles = input.changedFiles ?? []; const tests = input.tests ?? []; if (changedFiles.some((file) => isCodeFile(file)) && tests.length === 0 && !changedFiles.some((file) => isTestFile(file))) { @@ -1633,6 +1635,7 @@ export function buildLocalDiffPreflightResult( repo: RepositoryRecord | null, issues: IssueRecord[], pullRequests: PullRequestRecord[], + issueQuality?: IssueQualityReport | null | undefined, ): LocalDiffPreflightResult { const changedFiles = [...new Set([...(input.changedFiles ?? []), ...(input.testFiles ?? [])])]; const linkedFromCommit = extractLinkedIssueNumbers([input.commitMessage, input.body, input.title].filter(Boolean).join("\n")); @@ -1646,6 +1649,7 @@ export function buildLocalDiffPreflightResult( repo, issues, pullRequests, + issueQuality, ); const codeFileCount = changedFiles.filter(isCodeFile).length; const testFileCount = changedFiles.filter(isTestFile).length; @@ -1887,32 +1891,37 @@ export function buildIssueQualityReport( pullRequests: PullRequestRecord[], fullName: string, prebuiltCollisions?: CollisionReport, + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], ): IssueQualityReport { const lane = buildLaneAdvice(repo, fullName); - const collisions = prebuiltCollisions ?? buildCollisionReport(fullName, issues, pullRequests); + const collisions = prebuiltCollisions ?? buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests); const reports = issues .filter((issue) => issue.state === "open") .slice(0, 100) .map((issue) => { - const linkedPrs = pullRequests.filter((pr) => pr.linkedIssues.includes(issue.number)); + const linkedPrs = pullRequests.filter((pr) => pr.linkedIssues.includes(issue.number) || issue.linkedPrs.includes(pr.number)); + const linkedMergedPrs = recentMergedPullRequests.filter((pr) => pr.linkedIssues.includes(issue.number) || issue.linkedPrs.includes(pr.number)); const issueCollisions = collisions.clusters.filter((cluster) => cluster.items.some((item) => item.type === "issue" && item.number === issue.number)); const age = daysSince(issue.updatedAt ?? issue.createdAt); const bodyLength = issue.body?.trim().length ?? 0; + const linkedWorkCount = linkedPrs.length + linkedMergedPrs.length + issue.linkedPrs.length; const reasons = [ ...(bodyLength >= 200 ? ["Issue has enough body detail to evaluate."] : []), ...(issue.labels.length > 0 ? [`Labels: ${issue.labels.join(", ")}.`] : []), - ...(linkedPrs.length === 0 ? ["No active PR is linked in cached metadata."] : []), + ...(linkedWorkCount === 0 ? ["No active PR is linked in cached metadata."] : []), ]; const warnings = [ ...(bodyLength < 80 ? ["Issue body is thin; contributor may need more proof before acting."] : []), ...(linkedPrs.length > 0 ? [`${linkedPrs.length} active PR(s) already reference this issue.`] : []), + ...(linkedMergedPrs.length > 0 ? [`${linkedMergedPrs.length} merged PR(s) already reference this issue.`] : []), + ...(issue.linkedPrs.length > 0 && linkedPrs.length === 0 && linkedMergedPrs.length === 0 ? [`Cached issue metadata already references PR(s): ${issue.linkedPrs.map((number) => `#${number}`).join(", ")}.`] : []), ...(issueCollisions.length > 0 ? ["Potential duplicate or overlapping issue/PR context exists."] : []), ...(age > 90 ? ["Issue is stale in cached metadata."] : []), ...(lane.lane === "direct_pr" ? ["Repo is direct-PR first; issue filing is not the primary Gittensor lane."] : []), ]; const score = clamp(100 - warnings.length * 18 + reasons.length * 5 - (age > 180 ? 15 : 0), 0, 100); const status: IssueQualityReport["issues"][number]["status"] = - linkedPrs.length > 0 || issueCollisions.some((cluster) => cluster.risk === "high") + linkedWorkCount > 0 || issueCollisions.some((cluster) => cluster.risk === "high") ? "do_not_use" : warnings.some((warning) => /thin|stale|direct-PR/i.test(warning)) ? "needs_proof" @@ -1931,6 +1940,47 @@ export function buildIssueQualityReport( }; } +function issueQualityFindings(linkedIssues: number[], issueQuality: IssueQualityReport | null | undefined): SignalFinding[] { + if (!issueQuality || linkedIssues.length === 0) return []; + const byIssue = new Map(issueQuality.issues.map((issue) => [issue.number, issue])); + return linkedIssues.flatMap((issueNumber) => { + const quality = byIssue.get(issueNumber); + if (!quality || quality.status === "ready") return []; + const detail = quality.warnings[0] ?? `Issue quality report marks #${issueNumber} as ${quality.status}.`; + if (quality.status === "do_not_use") { + return [ + { + code: "issue_quality_do_not_use", + severity: "warning" as const, + title: "Linked issue is already covered or duplicate-prone", + detail, + action: "Confirm the linked issue is still actionable before posting public PR context.", + }, + ]; + } + if (quality.status === "needs_proof") { + return [ + { + code: "issue_quality_needs_proof", + severity: "warning" as const, + title: "Linked issue needs stronger proof", + detail, + action: "Add concrete reproduction, scope, or maintainer context before proceeding.", + }, + ]; + } + return [ + { + code: "issue_quality_hold", + severity: "warning" as const, + title: "Linked issue is on hold", + detail, + action: "Choose a clearer candidate or wait for maintainer context.", + }, + ]; + }); +} + export function buildBurdenForecast( repo: RepositoryRecord | null, issues: IssueRecord[], diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index 1a0bf7f8d8..c22f98a487 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -10,6 +10,7 @@ import { type ContributorOutcomeHistory, type ContributorProfile, type ContributorScoringProfile, + type IssueQualityReport, type LocalDiffPreflightResult, type RoleContext, } from "./engine"; @@ -145,6 +146,7 @@ export function buildLocalBranchAnalysis(args: { outcomeHistory: ContributorOutcomeHistory; scoringSnapshot: ScoringModelSnapshotRecord; scoringProfile?: ContributorScoringProfile | null | undefined; + issueQuality?: IssueQualityReport | null | undefined; }): LocalBranchAnalysis { const changedFiles = args.input.changedFiles ?? []; const changedPaths = changedFiles.map((file) => file.path); @@ -169,6 +171,7 @@ export function buildLocalBranchAnalysis(args: { args.repo, args.issues, args.pullRequests, + args.issueQuality, ); const roleContext = buildRoleContext({ login: args.input.login, diff --git a/test/unit/decision-pack.test.ts b/test/unit/decision-pack.test.ts index fde0ae1ff7..e3cc5db4f3 100644 --- a/test/unit/decision-pack.test.ts +++ b/test/unit/decision-pack.test.ts @@ -634,6 +634,81 @@ describe("decision-pack service", () => { expect(noStructuralCountLeak(decision.publicNextActions)).toBe(true); }); + it("uses issue-quality candidates in issue-discovery next actions", () => { + const decision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/issues", 0.02, 1, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: undefined, + syncState: { primaryLanguage: "TypeScript", openIssuesCount: 42 } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + issueQuality: { + repoFullName: "owner/issues", + generatedAt: "2026-05-29T00:00:00.000Z", + lane: { repoFullName: "owner/issues", lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [ + { number: 42, title: "Actionable bug report", status: "ready", score: 90, reasons: [], warnings: [] }, + { number: 41, title: "Covered issue", status: "do_not_use", score: 0, reasons: [], warnings: [] }, + ], + summary: "2 open issues evaluated.", + }, + }); + expect(decision.issueQuality).toMatchObject({ readyCount: 1, doNotUseCount: 1 }); + expect(decision.priorityScore).toBeGreaterThan(35); + expect(decision.nextActions[0]).toContain("#42"); + expect(decision.whyThisHelps[0]).toMatch(/ready candidate/); + expect(noStructuralCountLeak(decision.publicNextActions)).toBe(true); + + const splitDecision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/split-issues", 0.02, 0.5, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: { openPullRequests: 0, mergedPullRequests: 0, closedPullRequestRate: 0, credibility: 1 } as any, + syncState: { primaryLanguage: "TypeScript", openIssuesCount: 4 } as any, + languageSet: new Set(["typescript"]), + labelHistory: new Set(["bug"]), + issueQuality: { + repoFullName: "owner/split-issues", + generatedAt: "2026-05-29T00:00:00.000Z", + lane: { repoFullName: "owner/split-issues", lane: "split", issueDiscoveryShare: 0.5, directPrShare: 0.5, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [{ number: 50, title: "Split-lane ready report", status: "ready", score: 85, reasons: [], warnings: [] }], + summary: "1 open issue evaluated.", + }, + }); + expect(splitDecision.nextActions[0]).toContain("#50"); + expect(splitDecision.publicNextActions[0]).toContain("issue-quality ready candidates"); + expect(noStructuralCountLeak(splitDecision.publicNextActions)).toBe(true); + + const blockedQualityDecision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/blocked-issues", 0.02, 1, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: undefined, + issueQuality: { + repoFullName: "owner/blocked-issues", + generatedAt: "2026-05-29T00:00:00.000Z", + lane: { repoFullName: "owner/blocked-issues", lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [{ number: 41, title: "Covered issue", status: "do_not_use", score: 0, reasons: [], warnings: [] }], + summary: "1 open issue evaluated.", + }, + }); + expect(blockedQualityDecision.issueQuality).toMatchObject({ readyCount: 0, doNotUseCount: 1 }); + expect(blockedQualityDecision.priorityScore).toBeLessThan(35); + expect(blockedQualityDecision.riskReasons).toEqual(expect.arrayContaining([expect.stringContaining("No ready issue-quality candidate")])); + + const emptyQualityDecision = __decisionPackInternals.buildRepoDecision({ + repo: repoWithLabels("owner/empty-issues", 0.02, 1, { bug: 1.1 }), + roleContext: { maintainerLane: false } as any, + outcome: undefined, + issueQuality: { + repoFullName: "owner/empty-issues", + generatedAt: "2026-05-29T00:00:00.000Z", + lane: { repoFullName: "owner/empty-issues", lane: "issue_discovery", issueDiscoveryShare: 1, directPrShare: 0, summary: "", contributorGuidance: "", maintainerGuidance: "" }, + issues: [], + summary: "0 open issues evaluated.", + }, + }); + expect(emptyQualityDecision.priorityScore).toBe(40); + }); + it("issues avoid_for_now reasoning with sanitized public copy", () => { const decision = __decisionPackInternals.buildRepoDecision({ repo: repoWithLabels("owner/inactive", 0, 0, {}), diff --git a/test/unit/issue-quality.test.ts b/test/unit/issue-quality.test.ts index 4bc0ddae96..18a9f4de36 100644 --- a/test/unit/issue-quality.test.ts +++ b/test/unit/issue-quality.test.ts @@ -6,7 +6,7 @@ import { type ContributorProfile, type IssueQualityReport, } from "../../src/signals/engine"; -import type { IssueRecord, PullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../src/types"; +import type { IssueRecord, PullRequestRecord, RecentMergedPullRequestRecord, RegistryRepoConfig, RepositoryRecord } from "../../src/types"; describe("issue quality reports", () => { it("downgrades issue filing in direct-PR-only repos to needs_proof", () => { @@ -65,6 +65,29 @@ describe("issue quality reports", () => { expect(report.issues[0]?.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/already reference this issue/i)])); }); + it("marks issues as do_not_use when cached issue or merged PR metadata already links work", () => { + const repo = issueDiscoveryRepo("owner/solved-later"); + const report = buildIssueQualityReport( + repo, + [ + issue(repo.fullName, 5, "Issue body links a PR", { body: "x".repeat(220), linkedPrs: [100] }), + issue(repo.fullName, 6, "Recently merged work", { body: "x".repeat(220) }), + ], + [], + repo.fullName, + undefined, + [recentMergedPr(repo.fullName, 101, "Fixes #6", { linkedIssues: [6] })], + ); + expect(report.issues.find((entry) => entry.number === 5)).toMatchObject({ + status: "do_not_use", + warnings: expect.arrayContaining([expect.stringMatching(/already references PR/i)]), + }); + expect(report.issues.find((entry) => entry.number === 6)).toMatchObject({ + status: "do_not_use", + warnings: expect.arrayContaining([expect.stringMatching(/merged PR/i)]), + }); + }); + 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"] }); @@ -295,6 +318,22 @@ function pr(repoFullName: string, number: number, title: string, overrides: Part } as PullRequestRecord; } +function recentMergedPr(repoFullName: string, number: number, title: string, overrides: Partial = {}): RecentMergedPullRequestRecord { + return { + repoFullName, + number, + title, + authorLogin: "dev", + htmlUrl: `https://github.com/${repoFullName}/pull/${number}`, + mergedAt: now(), + labels: [], + linkedIssues: [], + changedFiles: [], + payload: {}, + ...overrides, + }; +} + function sampleProfile(overrides: Partial = {}): ContributorProfile { return { login: "tester", diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 970bf0fc46..849e94c55c 100644 --- a/test/unit/local-branch.test.ts +++ b/test/unit/local-branch.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { buildLocalBranchAnalysis } from "../../src/signals/local-branch"; -import type { ContributorOutcomeHistory, ContributorProfile, ContributorScoringProfile } from "../../src/signals/engine"; +import type { ContributorOutcomeHistory, ContributorProfile, ContributorScoringProfile, IssueQualityReport } from "../../src/signals/engine"; import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; describe("local branch analysis", () => { @@ -114,6 +114,51 @@ describe("local branch analysis", () => { expect(analysis.nextActions[0]?.whyThisHelps.join(" ")).toMatch(/waiting for pending PRs/i); }); + it("threads issue-quality warnings into local preflight and public-safe next steps", () => { + const issueQuality: IssueQualityReport = { + 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", + status: "do_not_use", + score: 0, + reasons: [], + warnings: ["1 merged PR(s) already reference this issue."], + }, + ], + summary: "1 open issue evaluated.", + }; + const analysis = buildLocalBranchAnalysis({ + input: { + login: "oktofeesh1", + repoFullName: repo.fullName, + body: "Fixes #7", + changedFiles: [ + { path: "src/cache.ts", additions: 12, deletions: 1, status: "modified" }, + { path: "src/cache.test.ts", additions: 20, deletions: 0, status: "added" }, + ], + validation: [{ command: "npm test -- cache", status: "passed" }], + }, + repo, + issues: [{ repoFullName: repo.fullName, number: 7, title: "Cache refresh fails", state: "open", labels: ["bug"], linkedPrs: [] }], + pullRequests: [], + profile, + outcomeHistory, + scoringSnapshot, + scoringProfile, + issueQuality, + }); + + expect(analysis.preflight.status).toBe("needs_work"); + expect(analysis.preflight.findings).toEqual(expect.arrayContaining([expect.objectContaining({ code: "issue_quality_do_not_use" })])); + 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("classifies stale base state and treats passed validation as test evidence", () => { const analysis = buildLocalBranchAnalysis({ input: {