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
39 changes: 35 additions & 4 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 { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast";
import {
buildBountyAdvisory,
buildBurdenForecast,
Expand All @@ -102,7 +103,7 @@ import { attachDataQuality, buildCoreSignalFidelity, buildFreshnessSloReport, bu
import { buildPullRequestReviewability } from "../signals/reward-risk";
import { buildLocalBranchAnalysis } from "../signals/local-branch";
import { buildRepoSettingsPreview } from "../signals/settings-preview";
import type { ContributorEvidenceRecord, JobMessage, JsonValue, RepoSyncSegmentRecord } from "../types";
import type { ContributorEvidenceRecord, DataQuality, JobMessage, JsonValue, RepoSyncSegmentRecord } from "../types";
import { errorMessage, nowIso } from "../utils/json";

type AppBindings = { Bindings: Env };
Expand Down Expand Up @@ -1038,7 +1039,8 @@ export function createApp() {
}

async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
const [repo, snapshots, dataQuality] = await Promise.all([
let burdenForecastError: unknown;
const [repo, snapshots, dataQuality, burdenForecast] = await Promise.all([
getRepository(env, fullName),
Promise.all(
["queue-health", "config-quality", "label-audit", "maintainer-lane", "maintainer-cut-readiness", "contributor-intake-health"].map(async (signalType) => [
Expand All @@ -1047,8 +1049,26 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
]),
),
loadRepoDataQuality(env, fullName),
loadOrComputeBurdenForecastResponse(env, fullName).catch((error) => {
burdenForecastError = error;
return null;
}),
]);
const intelligenceDataQuality = burdenForecastError
? withDataQualityWarning(dataQuality, `Burden forecast unavailable for ${fullName}: ${errorMessage(burdenForecastError)}`)
: dataQuality;
const snapshotMap = Object.fromEntries(snapshots);
const burdenForecastSlice = burdenForecast
? {
burdenForecast: burdenForecast.report,
burdenForecastFreshness: {
source: burdenForecast.source,
generatedAt: burdenForecast.generatedAt,
ageSeconds: burdenForecast.ageSeconds,
freshness: burdenForecast.freshness,
},
}
: {};
if (snapshotMap["queue-health"] && snapshotMap["config-quality"] && snapshotMap["label-audit"]) {
return {
status: "ready",
Expand All @@ -1063,7 +1083,8 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
maintainerLane: snapshotMap["maintainer-lane"],
maintainerCutReadiness: snapshotMap["maintainer-cut-readiness"],
contributorIntakeHealth: snapshotMap["contributor-intake-health"],
dataQuality,
dataQuality: intelligenceDataQuality,
...burdenForecastSlice,
};
}
const [issues, pullRequests, recentMergedPullRequests, labels, queueCounts] = await Promise.all([
Expand Down Expand Up @@ -1094,7 +1115,17 @@ async function buildRepoIntelligenceResponse(env: Env, fullName: string) {
maintainerLane,
maintainerCutReadiness,
contributorIntakeHealth,
dataQuality,
dataQuality: intelligenceDataQuality,
...burdenForecastSlice,
};
}

function withDataQualityWarning(dataQuality: DataQuality, warning: string): DataQuality {
return {
...dataQuality,
status: dataQuality.status === "complete" ? "degraded" : dataQuality.status,
partial: true,
warnings: [...new Set([...dataQuality.warnings, warning])],
};
}

Expand Down
12 changes: 12 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,18 @@ export async function upsertBurdenForecast(env: Env, forecast: BurdenForecastRec
});
}

export async function getBurdenForecast(env: Env, repoFullName: string): Promise<BurdenForecastRecord | null> {
const db = getDb(env.DB);
const row = await db.select().from(burdenForecasts).where(eq(burdenForecasts.repoFullName, repoFullName)).limit(1);
const first = row[0];
if (!first) return null;
return {
repoFullName: first.repoFullName,
payload: parseJson<Record<string, JsonValue>>(first.payloadJson, {}),
generatedAt: first.generatedAt,
};
}

export async function persistRegistryDriftEvents(env: Env, events: RegistryDriftEventRecord[]): Promise<void> {
const db = getDb(env.DB);
for (const event of events) {
Expand Down
28 changes: 28 additions & 0 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 { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast";
import {
buildBountyAdvisory,
buildCollisionReport,
Expand Down Expand Up @@ -244,6 +245,15 @@ export class GittensoryMcp {
async (input) => this.toolResult(await this.getRepoContext(input)),
);

server.registerTool(
"gittensory_get_burden_forecast",
{
description: "Return the cached or freshly-computed maintainer burden forecast for a repo, including projected review load, queue growth risk, stale PR signals, and a freshness marker.",
inputSchema: ownerRepoShape,
},
async (input) => this.toolResult(await this.getBurdenForecast(input)),
);

server.registerTool(
"gittensory_get_contributor_profile",
{
Expand Down Expand Up @@ -487,6 +497,24 @@ export class GittensoryMcp {
};
}

private async getBurdenForecast(input: { owner: string; repo: string }): Promise<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
const response = await loadOrComputeBurdenForecastResponse(this.env, fullName);
if (!response) {
return {
summary: `Gittensory has no cached burden forecast for ${fullName}.`,
data: { status: "not_found", repoFullName: fullName },
};
}
return {
summary:
response.source === "snapshot"
? `Gittensory burden forecast for ${fullName} (cached, ${response.freshness}).`
: `Gittensory burden forecast 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
9 changes: 9 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,15 @@ export const RepoIntelligenceSchema = z
maintainerCutReadiness: z.record(z.unknown()).nullable().optional(),
contributorIntakeHealth: z.record(z.unknown()).nullable().optional(),
dataQuality: z.record(z.unknown()),
burdenForecast: BurdenForecastSchema.optional(),
burdenForecastFreshness: z
.object({
source: z.enum(["snapshot", "computed"]),
generatedAt: z.string(),
ageSeconds: z.number(),
freshness: z.enum(["fresh", "stale"]),
})
.optional(),
})
.openapi("RepoIntelligence");

Expand Down
61 changes: 61 additions & 0 deletions src/services/burden-forecast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
getBurdenForecast,
getRepository,
listIssueSignalSample,
listOpenPullRequests,
listRecentMergedPullRequests,
} from "../db/repositories";
import { buildBurdenForecast, buildCollisionReport, type BurdenForecast } from "../signals/engine";

export const BURDEN_FORECAST_MAX_AGE_MS = 6 * 60 * 60 * 1000;

export type BurdenForecastFreshness = "fresh" | "stale";

export type BurdenForecastResponse = {
status: "ready";
source: "snapshot" | "computed";
repoFullName: string;
generatedAt: string;
ageSeconds: number;
freshness: BurdenForecastFreshness;
report: BurdenForecast;
};

export async function loadOrComputeBurdenForecastResponse(env: Env, fullName: string): Promise<BurdenForecastResponse | null> {
const cached = await getBurdenForecast(env, fullName);
if (cached) {
const ageMs = forecastAgeMs(cached.generatedAt);
return {
status: "ready",
source: "snapshot",
repoFullName: fullName,
generatedAt: cached.generatedAt,
ageSeconds: Math.max(0, Math.floor(ageMs / 1000)),
freshness: ageMs > BURDEN_FORECAST_MAX_AGE_MS ? "stale" : "fresh",
report: cached.payload as unknown as BurdenForecast,
};
}
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 collisions = buildCollisionReport(fullName, issues, pullRequests, recentMergedPullRequests);
const report = buildBurdenForecast(repo, issues, pullRequests, collisions, 30);
return {
status: "ready",
source: "computed",
repoFullName: fullName,
generatedAt: report.generatedAt,
ageSeconds: 0,
freshness: "fresh",
report,
};
}

function forecastAgeMs(generatedAt: string): number {
const parsed = Date.parse(generatedAt);
return Number.isFinite(parsed) ? Date.now() - parsed : Number.POSITIVE_INFINITY;
}
114 changes: 113 additions & 1 deletion test/integration/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
upsertBounty,
upsertBurdenForecast,
upsertCheckSummary,
upsertInstallation,
upsertInstallationHealth,
Expand All @@ -21,9 +22,11 @@ import {
upsertRepositorySettings,
} from "../../src/db/repositories";
import { createApp } from "../../src/api/routes";
import { BURDEN_FORECAST_MAX_AGE_MS } from "../../src/services/burden-forecast";
import { normalizeRegistryPayload } from "../../src/registry/normalize";
import { persistRegistrySnapshot } from "../../src/registry/sync";
import { createTestEnv } from "../helpers/d1";
import type { JsonValue } from "../../src/types";

describe("api routes", () => {
afterEach(() => {
Expand Down Expand Up @@ -737,9 +740,37 @@ describe("api routes", () => {
generatedAt: "2026-05-25T00:00:00.000Z",
});
}
const staleForecastGeneratedAt = new Date(Date.now() - BURDEN_FORECAST_MAX_AGE_MS - 60_000).toISOString();
await upsertBurdenForecast(env, {
repoFullName: "entrius/allways-ui",
payload: { repoFullName: "entrius/allways-ui", level: "medium", summary: "intelligence fixture" } as unknown as Record<string, JsonValue>,
generatedAt: staleForecastGeneratedAt,
});
const snapshotIntelligence = await app.request("/v1/repos/entrius/allways-ui/intelligence", { headers: apiHeaders(env) }, env);
expect(snapshotIntelligence.status).toBe(200);
await expect(snapshotIntelligence.json()).resolves.toMatchObject({ source: "snapshot", queueHealth: { signals: { openPullRequests: 2 } } });
const snapshotIntelligenceBody = (await snapshotIntelligence.json()) as Record<string, unknown> & { burdenForecast?: Record<string, unknown>; burdenForecastFreshness?: { freshness: string; source: string; ageSeconds: number } };
expect(snapshotIntelligenceBody).toMatchObject({ source: "snapshot", queueHealth: { signals: { openPullRequests: 2 } } });
expect(snapshotIntelligenceBody.burdenForecast).toMatchObject({ level: "medium" });
expect(snapshotIntelligenceBody.burdenForecastFreshness).toMatchObject({ source: "snapshot", freshness: "stale" });
expect(snapshotIntelligenceBody.burdenForecastFreshness?.ageSeconds).toBeGreaterThanOrEqual(Math.floor((BURDEN_FORECAST_MAX_AGE_MS + 50_000) / 1000));
expect(snapshotIntelligenceBody.burdenForecastFreshness?.ageSeconds).toBeLessThan(Math.floor((BURDEN_FORECAST_MAX_AGE_MS + 120_000) / 1000));

await upsertRepositoryFromGitHub(env, { name: "uncached-burden", full_name: "entrius/uncached-burden", private: false, owner: { login: "entrius" }, default_branch: "main" });
const computedIntelligence = await app.request("/v1/repos/entrius/uncached-burden/intelligence", { headers: apiHeaders(env) }, env);
expect(computedIntelligence.status).toBe(200);
await expect(computedIntelligence.json()).resolves.toMatchObject({
source: "computed",
burdenForecast: { repoFullName: "entrius/uncached-burden", level: "low" },
burdenForecastFreshness: { source: "computed", freshness: "fresh", ageSeconds: 0 },
});

const degradedForecastEnv = withBurdenForecastReadFailure(env);
const degradedIntelligence = await app.request("/v1/repos/entrius/allways-ui/intelligence", { headers: apiHeaders(env) }, degradedForecastEnv);
expect(degradedIntelligence.status).toBe(200);
const degradedBody = (await degradedIntelligence.json()) as Record<string, unknown> & { dataQuality: { status: string; warnings: string[] }; burdenForecast?: unknown };
expect(degradedBody.burdenForecast).toBeUndefined();
expect(degradedBody.dataQuality.status).toBe("degraded");
expect(degradedBody.dataQuality.warnings).toEqual(expect.arrayContaining([expect.stringMatching(/Burden forecast unavailable/i)]));

for (const path of [
"/v1/repos/entrius/allways-ui/issue-quality",
Expand Down Expand Up @@ -1183,6 +1214,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_burden_forecast");
expect(toolNames).toContain("gittensory_get_contributor_profile");
expect(toolNames).toContain("gittensory_get_decision_pack");
expect(toolNames).toContain("gittensory_explain_repo_decision");
Expand Down Expand Up @@ -1300,8 +1332,72 @@ describe("api routes", () => {
expect(missingRepoDecision.status).toBe(200);
await expect(mcpJson(missingRepoDecision)).resolves.toMatchObject({ result: { structuredContent: { status: "not_found", decision: null } } });

const missingBurdenForecast = await app.request(
"/mcp",
{
method: "POST",
headers: mcpHeaders(env),
body: JSON.stringify({ jsonrpc: "2.0", id: "missing-burden", method: "tools/call", params: { name: "gittensory_get_burden_forecast", arguments: { owner: "ghost", repo: "nothing" } } }),
},
env,
);
expect(missingBurdenForecast.status).toBe(200);
await expect(mcpJson(missingBurdenForecast)).resolves.toMatchObject({ result: { structuredContent: { status: "not_found", repoFullName: "ghost/nothing" } } });

await upsertBurdenForecast(env, {
repoFullName: "entrius/allways-ui",
payload: { repoFullName: "entrius/allways-ui", level: "low", summary: "mcp fixture", forecast: { projectedReviewLoad: 0, queueGrowthRisk: 0, stalePullRequests: 0, duplicateTrend: 0, reviewablePullRequests: 0 }, findings: [] } as unknown as Record<string, JsonValue>,
generatedAt: new Date(Date.now() - 1000).toISOString(),
});

const cachedBurdenForecast = await app.request(
"/mcp",
{
method: "POST",
headers: mcpHeaders(env),
body: JSON.stringify({ jsonrpc: "2.0", id: "cached-burden", method: "tools/call", params: { name: "gittensory_get_burden_forecast", arguments: { owner: "entrius", repo: "allways-ui" } } }),
},
env,
);
expect(cachedBurdenForecast.status).toBe(200);
await expect(mcpJson(cachedBurdenForecast)).resolves.toMatchObject({
result: {
structuredContent: {
status: "ready",
source: "snapshot",
repoFullName: "entrius/allways-ui",
freshness: "fresh",
report: { level: "low" },
},
},
});

await upsertRepositoryFromGitHub(env, { name: "mcp-computed-burden", full_name: "entrius/mcp-computed-burden", private: false, owner: { login: "entrius" }, default_branch: "main" });
const computedBurdenForecast = await app.request(
"/mcp",
{
method: "POST",
headers: mcpHeaders(env),
body: JSON.stringify({ jsonrpc: "2.0", id: "computed-burden", method: "tools/call", params: { name: "gittensory_get_burden_forecast", arguments: { owner: "entrius", repo: "mcp-computed-burden" } } }),
},
env,
);
expect(computedBurdenForecast.status).toBe(200);
await expect(mcpJson(computedBurdenForecast)).resolves.toMatchObject({
result: {
structuredContent: {
status: "ready",
source: "computed",
repoFullName: "entrius/mcp-computed-burden",
freshness: "fresh",
report: { repoFullName: "entrius/mcp-computed-burden", level: "low" },
},
},
});

for (const [name, args] of [
["gittensory_get_repo_context", { 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" }],
["gittensory_explain_repo_decision", { login: "oktofeesh1", owner: "entrius", repo: "allways-ui" }],
Expand Down Expand Up @@ -2118,6 +2214,22 @@ function apiHeaders(env: Env): Record<string, string> {
};
}

function withBurdenForecastReadFailure(env: Env): Env {
const db = env.DB as unknown as { prepare: (sql: string) => unknown; batch: (statements: unknown[]) => Promise<unknown[]> };
return {
...env,
DB: {
prepare(sql: string) {
if (/burden_forecasts/i.test(sql)) throw new Error("forecast table unavailable");
return db.prepare(sql);
},
batch(statements: unknown[]) {
return db.batch(statements);
},
} as unknown as D1Database,
};
}

function stubOktofeeshFetch(): void {
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
Expand Down
Loading