Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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 });
Expand All @@ -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<string, JsonValue>, analysis.generatedAt);
Expand Down Expand Up @@ -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);
Expand Down
42 changes: 37 additions & 5 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -515,6 +525,24 @@ export class GittensoryMcp {
};
}

private async getIssueQuality(input: { owner: string; repo: string }): Promise<ToolPayload> {
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<string, unknown>,
};
}

private async loadOpenQueueCounts(fullName: string): Promise<{ openIssues: number; openPullRequests: number }> {
const [totals, openIssues, openPullRequests] = await Promise.all([
getLatestRepoGithubTotalsSnapshot(this.env, fullName),
Expand Down Expand Up @@ -592,26 +620,28 @@ export class GittensoryMcp {
}

private async preflightPr(input: z.infer<z.ZodObject<typeof preflightShape>>): Promise<ToolPayload> {
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<string, unknown>,
data: buildPreflightResult(input, repo, issues, pullRequests, issueQuality?.report) as unknown as Record<string, unknown>,
};
}

private async preflightLocalDiff(input: z.infer<z.ZodObject<typeof localDiffPreflightShape>>): Promise<ToolPayload> {
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<string, unknown>,
data: buildLocalDiffPreflightResult(input, repo, issues, pullRequests, issueQuality?.report) as unknown as Record<string, unknown>,
};
}

Expand Down Expand Up @@ -768,13 +798,14 @@ export class GittensoryMcp {
}

private async analyzeLocalBranch(input: z.infer<z.ZodObject<typeof localBranchAnalysisShape>>) {
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 });
Expand All @@ -791,6 +822,7 @@ export class GittensoryMcp {
outcomeHistory: context.outcomeHistory,
scoringSnapshot: snapshot,
scoringProfile,
issueQuality: issueQuality?.report,
}),
dataQuality: await this.loadRepoDataQuality(input.repoFullName),
};
Expand Down
10 changes: 10 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
HealthSchema,
InstallationHealthSchema,
IssueQualityReportSchema,
IssueQualityResponseSchema,
LabelAuditSchema,
LaneAdviceSchema,
LocalBranchAnalysisSchema,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -75,6 +76,7 @@ import {
buildContributorScoringProfile,
buildContributorStrategy,
buildContributorIntakeHealth,
buildIssueQualityReport,
buildLabelAudit,
buildMaintainerCutReadiness,
buildMaintainerLaneReport,
Expand Down Expand Up @@ -283,6 +285,7 @@ async function buildContributorEvidence(env: Env, login?: string): Promise<void>
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),
Expand All @@ -293,7 +296,7 @@ async function buildContributorEvidence(env: Env, login?: string): Promise<void>
]);
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 });
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -420,6 +424,14 @@ export async function generateSignalSnapshots(env: Env, repoFullName?: string):
payload: contributorIntakeHealth as unknown as Record<string, never>,
generatedAt,
});
await persistSignalSnapshot(env, {
id: crypto.randomUUID(),
signalType: "issue-quality",
targetKey: repo.fullName,
repoFullName: repo.fullName,
payload: issueQuality as unknown as Record<string, never>,
generatedAt,
});
}
}

Expand Down
5 changes: 4 additions & 1 deletion src/services/agent-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -280,7 +281,7 @@ async function executeLocalBranchRun(env: Env, run: AgentRunRecord, kind: string
}

async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Promise<LocalBranchAnalysis & { dataQuality?: { status: "complete" | "degraded" | "blocked" | "unknown"; warnings: string[] } }> {
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),
Expand All @@ -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);
Expand All @@ -312,6 +314,7 @@ async function analyzeLocalBranch(env: Env, input: LocalBranchAnalysisInput): Pr
outcomeHistory,
scoringSnapshot,
scoringProfile,
issueQuality: issueQuality?.report,
});
}

Expand Down
Loading