diff --git a/src/api/routes.ts b/src/api/routes.ts index 12f95f4cf0..e6d865b7f1 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 { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildBountyAdvisory, @@ -574,6 +575,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)); @@ -728,33 +736,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 }); @@ -770,6 +785,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); @@ -1129,6 +1145,10 @@ function withDataQualityWarning(dataQuality: DataQuality, warning: string): Data }; } +async function buildIssueQualityResponse(env: Env, fullName: string) { + return loadOrComputeIssueQualityResponse(env, fullName); +} + async function buildRegistrationReadinessResponse(env: Env, fullName: string) { const intelligence = await buildRepoIntelligenceResponse(env, fullName); const settings = await getRepositorySettings(env, fullName); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b36b70b91a..2a559325c2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -36,6 +36,7 @@ import { startAgentRun, } from "../services/agent-orchestrator"; import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../services/decision-pack"; +import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; import { buildBountyAdvisory, @@ -308,6 +309,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", { @@ -515,6 +525,24 @@ export class GittensoryMcp { }; } + private async getIssueQuality(input: { owner: string; repo: string }): Promise { + const fullName = `${input.owner}/${input.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 }, + }; + } + return { + 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, + }; + } + private async loadOpenQueueCounts(fullName: string): Promise<{ openIssues: number; openPullRequests: number }> { const [totals, openIssues, openPullRequests] = await Promise.all([ getLatestRepoGithubTotalsSnapshot(this.env, fullName), @@ -592,26 +620,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 +798,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 }); @@ -791,6 +822,7 @@ export class GittensoryMcp { outcomeHistory: context.outcomeHistory, scoringSnapshot: snapshot, scoringProfile, + issueQuality: issueQuality?.report, }), dataQuality: await this.loadRepoDataQuality(input.repoFullName), }; diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 9406b48f03..0eccc0eadc 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 d386c18f18..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); @@ -223,6 +225,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: IssueQualityResponseSchema } } }, + 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 48487576a7..cb951d4f94 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -60,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, @@ -75,6 +76,7 @@ import { buildContributorScoringProfile, buildContributorStrategy, buildContributorIntakeHealth, + buildIssueQualityReport, buildLabelAudit, buildMaintainerCutReadiness, buildMaintainerLaneReport, @@ -283,6 +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 loadIssueQualityReportMap(env, repositories); for (const contributorLogin of logins) { const [github, contributorPullRequests, contributorIssues, cachedRepoStats, gittensorSnapshot] = await Promise.all([ fetchPublicContributorProfile(contributorLogin), @@ -293,7 +296,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 }); @@ -370,6 +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, recentMergedPullRequests); await replaceCollisionEdges(env, repo.fullName, buildCollisionEdges(collisions)); const generatedAt = new Date().toISOString(); await persistSignalSnapshot(env, { @@ -420,6 +424,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/src/services/agent-orchestrator.ts b/src/services/agent-orchestrator.ts index c47d1c08a6..3f3ca2fb24 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); @@ -312,6 +314,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 new file mode 100644 index 0000000000..b3897c5fbe --- /dev/null +++ b/src/services/issue-quality.ts @@ -0,0 +1,47 @@ +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: 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 unknown as IssueQualityReport; + 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, 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, + }; +} + +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 b16da8b95e..f114bd9464 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,24 @@ 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); - for (const issue of availableIssues.slice(0, 5)) { + const qualityReport = qualityByKey?.get(repo.fullName.toLowerCase()); + const qualityByIssue = qualityReport + ? new Map(qualityReport.issues.map((entry) => [entry.number, entry])) + : null; + 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); 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 +1104,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."] : []), ], }); } @@ -1119,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 @@ -1543,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); @@ -1577,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))) { @@ -1607,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")); @@ -1620,6 +1649,7 @@ export function buildLocalDiffPreflightResult( repo, issues, pullRequests, + issueQuality, ); const codeFileCount = changedFiles.filter(isCodeFile).length; const testFileCount = changedFiles.filter(isTestFile).length; @@ -1860,32 +1890,38 @@ export function buildIssueQualityReport( issues: IssueRecord[], pullRequests: PullRequestRecord[], fullName: string, + prebuiltCollisions?: CollisionReport, + recentMergedPullRequests: RecentMergedPullRequestRecord[] = [], ): IssueQualityReport { const lane = buildLaneAdvice(repo, fullName); - const collisions = 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" @@ -1904,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 5424b58f0b..8f6b61ebc1 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"; @@ -160,6 +161,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); @@ -184,6 +186,7 @@ export function buildLocalBranchAnalysis(args: { args.repo, args.issues, args.pullRequests, + args.issueQuality, ); const roleContext = buildRoleContext({ login: args.input.login, diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index 4837781e69..cb45405b5d 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -730,6 +730,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}`, @@ -772,8 +782,21 @@ describe("api routes", () => { expect(degradedBody.dataQuality.status).toBe("degraded"); expect(degradedBody.dataQuality.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/Burden forecast unavailable/i)])); + 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", @@ -1214,6 +1237,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_burden_forecast"); expect(toolNames).toContain("gittensory_get_contributor_profile"); expect(toolNames).toContain("gittensory_get_decision_pack"); @@ -1295,6 +1319,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" }], @@ -1332,6 +1368,21 @@ 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", + }); + const missingBurdenForecast = await app.request( "/mcp", { @@ -1397,6 +1448,7 @@ describe("api routes", () => { 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_burden_forecast", { owner: "entrius", repo: "allways-ui" }], ["gittensory_get_contributor_profile", { login: "oktofeesh1" }], ["gittensory_get_decision_pack", { login: "oktofeesh1" }], 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 new file mode 100644 index 0000000000..18a9f4de36 --- /dev/null +++ b/test/unit/issue-quality.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; +import { + buildContributorFit, + buildContributorOpportunities, + buildIssueQualityReport, + type ContributorProfile, + type IssueQualityReport, +} from "../../src/signals/engine"; +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", () => { + 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]).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)])); + }); + + 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]).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", () => { + 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("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"] }); + 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); + 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", () => { + 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("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) })]; + 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 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", + 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; +} diff --git a/test/unit/local-branch.test.ts b/test/unit/local-branch.test.ts index 736862678b..81863cb06e 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("derives observed pending PR scenarios from cached GitHub PR state", () => { const otherRepo: RepositoryRecord = { ...repo, fullName: "we-promise/sure", owner: "we-promise", name: "sure" }; const pressuredHistory: ContributorOutcomeHistory = { diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index ea6e675258..ffc3e377a2 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", diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index 561c7b4c93..a134357ec2 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -98,6 +98,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" });