diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index fe665240bb..b169be1029 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -12777,6 +12777,91 @@ "weekly", "byProject" ] + }, + "FederatedRepoEntry": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "burdenScore": { + "type": "number" + }, + "level": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "compositeScore": { + "type": "number" + }, + "stalePullRequestRate": { + "type": "number", + "nullable": true + }, + "pullRequestGrowth7d": { + "type": "number", + "nullable": true + }, + "freshness": { + "type": "string", + "enum": [ + "fresh", + "stale" + ] + }, + "summary": { + "type": "string" + } + }, + "required": [ + "repoFullName", + "burdenScore", + "level", + "compositeScore", + "stalePullRequestRate", + "pullRequestGrowth7d", + "freshness", + "summary" + ] + }, + "FederatedQueueIndex": { + "type": "object", + "properties": { + "generatedAt": { + "type": "string" + }, + "repoCount": { + "type": "number" + }, + "limitApplied": { + "type": "number" + }, + "source": { + "type": "string", + "enum": [ + "snapshot", + "computed" + ] + }, + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FederatedRepoEntry" + } + } + }, + "required": [ + "generatedAt", + "repoCount", + "limitApplied", + "source", + "entries" + ] } }, "parameters": {}, @@ -15648,6 +15733,49 @@ } ] } + }, + "/v1/app/queue-health/federation": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Ranked cross-repo queue pressure index (operator only)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FederatedQueueIndex" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient role — operator access required" + }, + "422": { + "description": "Invalid limit parameter" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/migrations/0074_queue_federation_cache.sql b/migrations/0074_queue_federation_cache.sql new file mode 100644 index 0000000000..d3979713df --- /dev/null +++ b/migrations/0074_queue_federation_cache.sql @@ -0,0 +1,11 @@ +-- Cache table for the federated queue pressure index. +-- TTL enforcement matches the burden forecast pattern (6-hour freshness threshold applied at read time). +CREATE TABLE IF NOT EXISTS queue_federation_snapshots ( + id TEXT PRIMARY KEY, + generated_at TEXT NOT NULL, + repo_count INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS queue_federation_snapshots_generated_idx ON queue_federation_snapshots (generated_at); diff --git a/src/api/routes.ts b/src/api/routes.ts index ae2a8ff286..c5509aefae 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -172,6 +172,7 @@ import { MINIMUM_SUPPORTED_MCP_VERSION, } from "../services/mcp-compatibility"; import { buildOperatorDashboardPayload } from "../services/operator-dashboard"; +import { buildFederatedQueueIndex, FEDERATED_QUEUE_INDEX_MAX_LIMIT } from "../services/queue-federation"; import { buildSelfDogfoodRegistrationPack, resolveSelfDogfoodRepoFullName } from "../services/self-dogfood-registration-pack"; import { buildSubnetInterfaceDescriptor } from "../services/subnet-interface"; import { buildPublicRepoQuality, type PublicRepoQuality } from "../services/public-repo-quality"; @@ -1332,6 +1333,20 @@ export function createApp() { return c.json(await buildOperatorDashboardPayload(c.env)); }); + app.get("/v1/app/queue-health/federation", async (c) => { + const forbidden = await requireAppRole(c, ["operator"]); + if (forbidden) return forbidden; + const rawLimit = c.req.query("limit"); + if (rawLimit !== undefined) { + const parsed = Number(rawLimit); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > FEDERATED_QUEUE_INDEX_MAX_LIMIT) { + return c.json({ error: "invalid_limit", message: `limit must be an integer between 1 and ${FEDERATED_QUEUE_INDEX_MAX_LIMIT}` }, 422); + } + } + const limit = rawLimit !== undefined ? Number(rawLimit) : undefined; + return c.json(await buildFederatedQueueIndex(c.env, limit)); + }); + app.get("/v1/app/notification-model", async (c) => { const forbidden = await requireAppRole(c, ["maintainer", "owner", "operator"]); if (forbidden) return forbidden; diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 1d8e7a55d4..82268f5b53 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -42,6 +42,7 @@ import { repositories, repoGithubTotalsSnapshots, repoQueueTrendSnapshots, + queueFederationSnapshots, registryDriftEvents, repoLabels, repoSnapshots, @@ -89,6 +90,7 @@ import type { BountyLifecycleEventRecord, BountyRecord, BurdenForecastRecord, + QueueFederationSnapshotRecord, CheckSummaryRecord, CollisionEdgeRecord, CommandUsefulnessSummary, @@ -2477,6 +2479,32 @@ export async function getBurdenForecast(env: Env, repoFullName: string): Promise }; } +const QUEUE_FEDERATION_SNAPSHOT_ID = "current"; + +export async function upsertQueueFederationSnapshot(env: Env, snapshot: QueueFederationSnapshotRecord): Promise { + const db = getDb(env.DB); + await db + .insert(queueFederationSnapshots) + .values({ id: QUEUE_FEDERATION_SNAPSHOT_ID, generatedAt: snapshot.generatedAt, repoCount: snapshot.repoCount, payloadJson: jsonString(snapshot.payload) }) + .onConflictDoUpdate({ + target: queueFederationSnapshots.id, + set: { generatedAt: snapshot.generatedAt, repoCount: snapshot.repoCount, payloadJson: jsonString(snapshot.payload) }, + }); +} + +export async function getQueueFederationSnapshot(env: Env): Promise { + const db = getDb(env.DB); + const row = await db.select().from(queueFederationSnapshots).where(eq(queueFederationSnapshots.id, QUEUE_FEDERATION_SNAPSHOT_ID)).limit(1); + const first = row[0]; + if (!first) return null; + return { + id: first.id, + generatedAt: first.generatedAt, + repoCount: first.repoCount, + payload: parseJson>(first.payloadJson, {}), + }; +} + export async function persistRegistryDriftEvents(env: Env, events: RegistryDriftEventRecord[]): Promise { const db = getDb(env.DB); for (const event of events) { diff --git a/src/db/schema.ts b/src/db/schema.ts index 973cc63537..5ac545282d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -768,6 +768,14 @@ export const repoQueueTrendSnapshots = sqliteTable("repo_queue_trend_snapshots", generatedAt: text("generated_at").notNull().$defaultFn(() => nowIso()), }); +export const queueFederationSnapshots = sqliteTable("queue_federation_snapshots", { + id: text("id").primaryKey(), + generatedAt: text("generated_at").notNull(), + repoCount: integer("repo_count").notNull().default(0), + payloadJson: text("payload_json").notNull().default("{}"), + createdAt: text("created_at").notNull().default("CURRENT_TIMESTAMP"), +}); + export const registryDriftEvents = sqliteTable("registry_drift_events", { id: text("id").primaryKey(), repoFullName: text("repo_full_name").notNull(), diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ecce1c4289..0e991786d1 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -67,6 +67,7 @@ import { buildRemediationPlan } from "../services/remediation-plan"; import { explainScoreBreakdown } from "../services/score-breakdown"; import { loadOrComputeIssueQualityResponse } from "../services/issue-quality"; import { loadOrComputeBurdenForecastResponse } from "../services/burden-forecast"; +import { buildFederatedQueueIndex, FEDERATED_QUEUE_INDEX_MAX_LIMIT } from "../services/queue-federation"; import { buildMcpClientTelemetry } from "../services/client-telemetry"; import { loadOrComputeRepoOutcomePatternsResponse } from "../services/repo-outcome-patterns"; import { buildRepoOutcomeCalibration, outcomeCalibrationSummary } from "../services/outcome-calibration"; @@ -159,6 +160,14 @@ const fleetAnalyticsOutputSchema = { outliers: z.array(z.unknown()).optional(), }; +const queueHealthFederationOutputSchema = { + generatedAt: z.string().optional(), + repoCount: z.number().optional(), + limitApplied: z.number().optional(), + source: z.enum(["snapshot", "computed"]).optional(), + entries: z.array(z.unknown()).optional(), +}; + const loginShape = { login: z.string().min(1), }; @@ -1072,6 +1081,16 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.getBurdenForecast(input)), ); + server.registerTool( + "gittensory_queue_health_federation", + { + description: "Return a ranked cross-repo queue pressure index showing the worst-burden registered repos. Operator-only.", + inputSchema: { limit: z.number().int().min(1).max(FEDERATED_QUEUE_INDEX_MAX_LIMIT).optional() }, + outputSchema: queueHealthFederationOutputSchema, + }, + async (input) => this.toolResult(await this.getQueueHealthFederation(input.limit)), + ); + server.registerTool( "gittensory_get_repo_outcome_patterns", { @@ -1845,6 +1864,22 @@ export class GittensoryMcp { }; } + private async getQueueHealthFederation(limit?: number): Promise { + if (this.identity.kind !== "session") { + throw new Error("Forbidden: gittensory_queue_health_federation requires operator role."); + } + const summary = await loadControlPanelRoleSummary(this.env, this.identity.actor); + if (!summary.roles.includes("operator")) { + throw new Error("Forbidden: gittensory_queue_health_federation requires operator role."); + } + const index = await buildFederatedQueueIndex(this.env, limit); + const criticalCount = index.entries.filter((entry) => entry.level === "critical" || entry.level === "high").length; + return { + summary: `Cross-repo queue pressure index: ${index.repoCount} repo(s) ranked, ${criticalCount} at critical/high burden.`, + data: index as unknown as Record, + }; + } + private async getIssueQuality(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; if (!(await this.canAccessRepo(fullName))) { diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 356566f9ac..33b6463476 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -177,6 +177,29 @@ export const CollisionReportSchema = z }) .openapi("CollisionReport"); +export const FederatedRepoEntrySchema = z + .object({ + repoFullName: z.string(), + burdenScore: z.number(), + level: z.enum(["low", "medium", "high", "critical"]), + compositeScore: z.number(), + stalePullRequestRate: z.number().nullable(), + pullRequestGrowth7d: z.number().nullable(), + freshness: z.enum(["fresh", "stale"]), + summary: z.string(), + }) + .openapi("FederatedRepoEntry"); + +export const FederatedQueueIndexSchema = z + .object({ + generatedAt: z.string(), + repoCount: z.number(), + limitApplied: z.number(), + source: z.enum(["snapshot", "computed"]), + entries: z.array(FederatedRepoEntrySchema), + }) + .openapi("FederatedQueueIndex"); + export const QueueHealthSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 80e2dc6a28..6cb97a5db0 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -11,6 +11,8 @@ import { BountyLifecycleEventsSchema, BountySchema, BurdenForecastSchema, + FederatedQueueIndexSchema, + FederatedRepoEntrySchema, CollisionReportSchema, ConfigQualitySchema, CommandPreviewResponseSchema, @@ -147,6 +149,8 @@ export function buildOpenApiSpec() { registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); registry.register("BurdenForecast", BurdenForecastSchema); + registry.register("FederatedRepoEntry", FederatedRepoEntrySchema); + registry.register("FederatedQueueIndex", FederatedQueueIndexSchema); registry.register("ContributorScoringProfile", ContributorScoringProfileSchema); registry.register("ContributorStrategy", ContributorStrategySchema); registry.register("RewardRiskAction", RewardRiskActionSchema); @@ -715,6 +719,17 @@ export function buildOpenApiSpec() { 401: { description: "Unauthorized" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/app/queue-health/federation", + request: { query: z.object({ limit: z.string().optional() }) }, + responses: { + 200: { description: "Ranked cross-repo queue pressure index (operator only)", content: { "application/json": { schema: FederatedQueueIndexSchema } } }, + 401: { description: "Unauthorized" }, + 403: { description: "Insufficient role — operator access required" }, + 422: { description: "Invalid limit parameter" }, + }, + }); for (const path of [ "/v1/app/roles", "/v1/app/miner-dashboard", diff --git a/src/services/operator-dashboard.ts b/src/services/operator-dashboard.ts index 134bc49a80..b06eff5614 100644 --- a/src/services/operator-dashboard.ts +++ b/src/services/operator-dashboard.ts @@ -29,6 +29,7 @@ import { computeFleetAnalytics, type FleetAnalytics } from "../orb/analytics"; import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset"; import { nowIso } from "../utils/json"; import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report"; +import { buildFederatedQueueIndex, type FederatedQueueIndex } from "./queue-federation"; import { buildWeeklyValueReport } from "./weekly-value-report"; export type OperatorDashboardMetric = { @@ -59,6 +60,7 @@ export type OperatorDashboardPayload = { scoringModel: ScoringModelSnapshotRecord | null; upstreamDrift: UpstreamStatus; fleetMetrics: FleetAnalytics; + queueFederation: FederatedQueueIndex; }; const USAGE_WINDOW_DAYS = 7; @@ -100,6 +102,10 @@ export async function buildOperatorDashboardPayload(env: Env): Promise entry.level === "critical" || entry.level === "high", + ).length; const weeklyValueReport = buildWeeklyValueReport({ generatedAt: nowIso(), variant: "operator", @@ -115,6 +121,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise repo.isInstalled).length; const registeredRepos = repositories.filter((repo: RepositoryRecord) => repo.isRegistered).length; @@ -192,6 +199,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise = { + critical: 4, + high: 3, + medium: 2, + low: 1, +}; + +export type FederatedRepoEntry = { + repoFullName: string; + burdenScore: number; + level: BurdenForecast["level"]; + compositeScore: number; + stalePullRequestRate: number | null; + pullRequestGrowth7d: number | null; + freshness: "fresh" | "stale"; + summary: string; +}; + +export type FederatedQueueIndex = { + generatedAt: string; + repoCount: number; + limitApplied: number; + source: "snapshot" | "computed"; + entries: FederatedRepoEntry[]; +}; + +export async function buildFederatedQueueIndex( + env: Env, + limit: number = FEDERATED_QUEUE_INDEX_DEFAULT_LIMIT, +): Promise { + const safeLimit = Math.min(Math.max(1, limit), FEDERATED_QUEUE_INDEX_MAX_LIMIT); + + const cached = await getQueueFederationSnapshot(env); + if (cached) { + const ageMs = federationAgeMs(cached.generatedAt); + if (ageMs <= BURDEN_FORECAST_MAX_AGE_MS) { + const full = cached.payload as unknown as { entries: FederatedRepoEntry[] }; + const entries = Array.isArray(full.entries) ? full.entries : []; + return { + generatedAt: cached.generatedAt, + repoCount: cached.repoCount, + limitApplied: safeLimit, + source: "snapshot", + entries: entries.slice(0, safeLimit), + }; + } + } + + const repos = (await listRepositories(env)).filter((repo) => repo.isRegistered && repo.isInstalled); + + const [forecasts, trendSnapshots] = await Promise.all([ + Promise.all(repos.map((repo) => loadOrComputeBurdenForecastResponse(env, repo.fullName))), + Promise.all(repos.map((repo) => getRepoQueueTrendSnapshot(env, repo.fullName))), + ]); + + const entries: FederatedRepoEntry[] = []; + for (let i = 0; i < repos.length; i++) { + const repo = repos[i]!; + const forecast = forecasts[i]; + /* v8 ignore next -- loadOrComputeBurdenForecastResponse only returns null for unknown repos; registered+installed repos are always known */ + if (!forecast) continue; + const trendSnapshot = trendSnapshots[i]; + const trendReport: QueueTrendReport = trendSnapshot + ? (trendSnapshot.payload as unknown as QueueTrendReport) + : buildUnavailableQueueTrendReport(repo.fullName); + /* v8 ignore next -- find returns undefined only when a trend report has no 7d window at all; unavailable trend reports always include all three window stubs */ + const window7d = trendReport.windows.find((w) => w.windowDays === 7) ?? null; + const stalePullRequestRate = window7d?.stalePullRequestRate ?? null; + const pullRequestGrowth7d = window7d?.pullRequestGrowth ?? null; + const burdenScore = forecast.report.forecast?.projectedReviewLoad ?? 0; + const composite = compositeQueuePressureScore( + burdenScore, + stalePullRequestRate, + pullRequestGrowth7d, + ); + entries.push({ + repoFullName: repo.fullName, + burdenScore, + level: forecast.report.level, + compositeScore: Math.round(composite * 100) / 100, + stalePullRequestRate, + pullRequestGrowth7d, + freshness: forecast.freshness, + summary: forecast.report.summary, + }); + } + + entries.sort((a, b) => { + const scoreDiff = b.compositeScore - a.compositeScore; + if (scoreDiff !== 0) return scoreDiff; + return LEVEL_RANK[b.level] - LEVEL_RANK[a.level]; + }); + + const generatedAt = nowIso(); + await upsertQueueFederationSnapshot(env, { + id: "current", + generatedAt, + repoCount: entries.length, + payload: { entries } as unknown as Record, + }); + + return { + generatedAt, + repoCount: entries.length, + limitApplied: safeLimit, + source: "computed", + entries: entries.slice(0, safeLimit), + }; +} + +function federationAgeMs(generatedAt: string): number { + const parsed = Date.parse(generatedAt); + return Number.isFinite(parsed) ? Date.now() - parsed : Number.POSITIVE_INFINITY; +} diff --git a/src/services/weekly-value-report.ts b/src/services/weekly-value-report.ts index ed39b43296..0e37303b94 100644 --- a/src/services/weekly-value-report.ts +++ b/src/services/weekly-value-report.ts @@ -44,6 +44,7 @@ type WeeklyValueReportInputs = { usageRollupStatus: ProductUsageRollupStatus; activeSessions?: number | null | undefined; digestSubscriptions?: number | null | undefined; + topCriticalRepos?: number | null | undefined; }; type WeeklyAggregate = { @@ -143,6 +144,7 @@ export function buildWeeklyValueReport(args: WeeklyValueReportInputs): WeeklyVal unhealthyInstallations, activeSessions: args.activeSessions ?? 0, digestSubscriptions: args.digestSubscriptions ?? 0, + topCriticalRepos: args.topCriticalRepos ?? 0, }); const summary = ( variant === "public" @@ -243,6 +245,7 @@ function buildWeeklyMetrics(args: { unhealthyInstallations: number; activeSessions: number; digestSubscriptions: number; + topCriticalRepos: number; }): WeeklyValueReportMetric[] { return [ metric("active_users", "Active users", args.activeActors, "distinct hashed actors in the report window", "public"), @@ -261,6 +264,7 @@ function buildWeeklyMetrics(args: { metric("installed_repos", "Installed repos", args.installedRepos, "repos with installation coverage in cache", "operator"), metric("installations", "Installations", args.installations, "GitHub App installations in cache", "operator"), metric("install_issues", "Install issues", args.unhealthyInstallations, "installation health records needing attention", "operator"), + metric("top_critical_repos", "Critical queue repos", args.topCriticalRepos, "repos at critical or high queue burden level", "operator"), ]; } diff --git a/src/signals/engine.ts b/src/signals/engine.ts index eddf1a29f7..81b802ba57 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -5279,6 +5279,16 @@ function isTestFile(file: string): boolean { ); } +export function compositeQueuePressureScore( + burdenScore: number, + stalePullRequestRate: number | null, + pullRequestGrowth7d: number | null, +): number { + const stale = stalePullRequestRate ?? 0; + const growth = pullRequestGrowth7d ?? 0; + return burdenScore * (1 + stale) + growth; +} + function riskRank(risk: CollisionCluster["risk"]): number { if (risk === "high") return 3; /* v8 ignore next -- Low collision rank is the default branch; high/medium sorting behavior is covered by collision tests. */ diff --git a/src/types.ts b/src/types.ts index 7c1588a8b7..52c717b4c5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1256,6 +1256,13 @@ export type BurdenForecastRecord = { generatedAt: string; }; +export type QueueFederationSnapshotRecord = { + id: string; + generatedAt: string; + repoCount: number; + payload: Record; +}; + export type RegistryDriftEventRecord = { id: string; repoFullName: string; diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 4db0eb5c4c..a399625a4a 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -2,6 +2,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { describe, expect, it } from "vitest"; import { persistSignalSnapshot, upsertBounty, upsertIssueFromGitHub, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, updatePullRequestSlopAssessment } from "../../src/db/repositories"; +import { authenticatePrivateToken, createSessionForGitHubUser } from "../../src/auth/security"; import { GittensoryMcp } from "../../src/mcp/server"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; import { persistRegistrySnapshot } from "../../src/registry/sync"; @@ -13,6 +14,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "gittensory_get_repo_context", "gittensory_get_maintainer_noise", "gittensory_get_burden_forecast", + "gittensory_queue_health_federation", "gittensory_get_repo_outcome_patterns", "gittensory_get_outcome_calibration", "gittensory_get_contributor_profile", @@ -177,6 +179,27 @@ describe("MCP tool calls return schema-valid structured content", () => { ]); }); + it("gittensory_queue_health_federation returns a ranked index with no private financial fields", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator-user" }); + const { token } = await createSessionForGitHubUser(env, { login: "operator-user", id: 99 }); + const identity = await authenticatePrivateToken(env, token); + if (!identity || identity.kind !== "session") throw new Error("expected session identity"); + const mcpServer = new GittensoryMcp(env, identity).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await mcpServer.connect(serverTransport); + const client = new Client({ name: "gittensory-output-schema-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + const result = await client.callTool({ name: "gittensory_queue_health_federation", arguments: { limit: 2 } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(data.limitApplied).toBe(2); + expect(typeof data.repoCount).toBe("number"); + expect(Array.isArray(data.entries)).toBe(true); + expect(data.source === "snapshot" || data.source === "computed").toBe(true); + const serialized = JSON.stringify(data); + expect(serialized).not.toMatch(/wallet|hotkey|coldkey|trustScore|payout|reward estimate|farming/i); + }); + it("gittensory_get_repo_context returns validated structured content", async () => { const { client } = await connectTestClient(); const result = await client.callTool({ name: "gittensory_get_repo_context", arguments: { owner: "octo", repo: "demo" } }); diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index 418fcc92de..4e5b276a13 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -60,6 +60,38 @@ describe("MCP contributor access", () => { expect(payload.data).toEqual({ status: "forbidden", repoFullName: "victim/private-repo" }); }); + it("blocks static api identity from queue health federation", async () => { + const env = createTestEnv(); + const identity = await authenticatePrivateToken(env, "test-api-token"); + if (!identity || identity.kind !== "static") throw new Error("expected static identity"); + const mcp = new GittensoryMcp(env, identity) as unknown as { getQueueHealthFederation(): Promise }; + await expect(mcp.getQueueHealthFederation()).rejects.toThrow( + /Forbidden: gittensory_queue_health_federation requires operator role/, + ); + }); + + it("blocks non-operator session from queue health federation", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + const { token } = await createSessionForGitHubUser(env, { login: "non-operator", id: 9 }); + const identity = await authenticatePrivateToken(env, token); + if (!identity || identity.kind !== "session") throw new Error("expected session identity"); + const mcp = new GittensoryMcp(env, identity) as unknown as { getQueueHealthFederation(): Promise }; + await expect(mcp.getQueueHealthFederation()).rejects.toThrow( + /Forbidden: gittensory_queue_health_federation requires operator role/, + ); + }); + + it("allows operator session to access queue health federation", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator-user" }); + const { token } = await createSessionForGitHubUser(env, { login: "operator-user", id: 10 }); + const identity = await authenticatePrivateToken(env, token); + if (!identity || identity.kind !== "session") throw new Error("expected session identity"); + const mcp = new GittensoryMcp(env, identity) as unknown as { getQueueHealthFederation(limit?: number): Promise<{ summary: string; data: Record }> }; + const result = await mcp.getQueueHealthFederation(3); + expect(typeof result.summary).toBe("string"); + expect(result.data).toMatchObject({ limitApplied: 3 }); + }); + it("does not reveal inaccessible bounty ids through advisory errors", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim/private-repo", private: true, owner: { login: "victim" }, default_branch: "main" }); diff --git a/test/unit/openapi.test.ts b/test/unit/openapi.test.ts index ddcc3b3ca0..597c5824ab 100644 --- a/test/unit/openapi.test.ts +++ b/test/unit/openapi.test.ts @@ -42,6 +42,7 @@ describe("OpenAPI contract", () => { expect(spec.paths["/v1/app/miner-dashboard"]).toBeDefined(); expect(spec.paths["/v1/app/maintainer-dashboard"]).toBeDefined(); expect(spec.paths["/v1/app/operator-dashboard"]).toBeDefined(); + expect(spec.paths["/v1/app/queue-health/federation"]).toBeDefined(); expect(spec.paths["/v1/app/commands"]).toBeDefined(); expect(spec.paths["/v1/app/commands/preview"]).toBeDefined(); expect(spec.paths["/v1/app/commands/usefulness"]).toBeDefined(); diff --git a/test/unit/operator-dashboard.test.ts b/test/unit/operator-dashboard.test.ts index 08170716ef..d81799d8fe 100644 --- a/test/unit/operator-dashboard.test.ts +++ b/test/unit/operator-dashboard.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; +import { upsertBurdenForecast, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { buildOperatorDashboardPayload, latestUsageRollup } from "../../src/services/operator-dashboard"; -import type { ProductUsageDailyRollupRecord } from "../../src/types"; +import type { JsonValue, ProductUsageDailyRollupRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; const FORBIDDEN_EXPORT_TERMS = @@ -20,6 +21,7 @@ describe("operator dashboard payload", () => { ]), ); expect(payload.weeklyValueReport.variant).toBe("operator"); + expect(payload.queueFederation).toMatchObject({ repoCount: expect.any(Number), entries: expect.any(Array) }); expect(payload.usageSummary).toMatchObject({ totalEvents: expect.any(Number), activeActors: expect.any(Number) }); expect(payload.commandUsefulness.totals).toMatchObject({ feedbackCount: expect.any(Number) }); expect(serialized).not.toMatch(FORBIDDEN_EXPORT_TERMS); @@ -61,6 +63,33 @@ describe("operator dashboard payload", () => { ); }); + it("counts critical and high queue repos in the weekly value report metric", async () => { + const env = createTestEnv({ PRODUCT_USAGE_HASH_SALT: "operator-dashboard-critical-queue-salt" }); + for (const [name, level] of [ + ["critical-repo", "critical"], + ["high-repo", "high"], + ["low-repo", "low"], + ] as const) { + await upsertRepositoryFromGitHub(env, { name, full_name: `owner/${name}`, private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, `owner/${name}`); + await markRegistered(env, `owner/${name}`); + await upsertBurdenForecast(env, { + repoFullName: `owner/${name}`, + payload: { + repoFullName: `owner/${name}`, + level, + forecast: { projectedReviewLoad: level === "low" ? 10 : 80, reviewablePullRequests: 0, stalePullRequests: 0, duplicateTrend: 0, queueGrowthRisk: 0 }, + summary: `${level} burden`, + } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + } + const payload = await buildOperatorDashboardPayload(env); + expect(payload.queueFederation.entries.filter((entry) => entry.level === "critical" || entry.level === "high")).toHaveLength(2); + const criticalMetric = payload.weeklyValueReport.metrics.find((metric) => metric.id === "top_critical_repos"); + expect(criticalMetric).toMatchObject({ value: 2, visibility: "operator" }); + }); + it("picks the newest rollup day for adoption insights", () => { const rollups: ProductUsageDailyRollupRecord[] = [ rollup("2026-05-28"), @@ -119,3 +148,17 @@ function rollup(day: string): ProductUsageDailyRollupRecord { updatedAt: "2026-06-01T00:00:00.000Z", }; } + +async function markInstalled(env: ReturnType, fullName: string): Promise { + const { getDb } = await import("../../src/db/client"); + const { repositories } = await import("../../src/db/schema"); + const { eq } = await import("drizzle-orm"); + await getDb(env.DB).update(repositories).set({ isInstalled: true }).where(eq(repositories.fullName, fullName)); +} + +async function markRegistered(env: ReturnType, fullName: string): Promise { + const { getDb } = await import("../../src/db/client"); + const { repositories } = await import("../../src/db/schema"); + const { eq } = await import("drizzle-orm"); + await getDb(env.DB).update(repositories).set({ isRegistered: true }).where(eq(repositories.fullName, fullName)); +} diff --git a/test/unit/queue-federation.test.ts b/test/unit/queue-federation.test.ts new file mode 100644 index 0000000000..22a179031b --- /dev/null +++ b/test/unit/queue-federation.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { createApp } from "../../src/api/routes"; +import { upsertBurdenForecast, upsertQueueFederationSnapshot, upsertRepositoryFromGitHub, upsertRepoQueueTrendSnapshot } from "../../src/db/repositories"; +import { BURDEN_FORECAST_MAX_AGE_MS } from "../../src/services/burden-forecast"; +import { buildFederatedQueueIndex, FEDERATED_QUEUE_INDEX_DEFAULT_LIMIT, FEDERATED_QUEUE_INDEX_MAX_LIMIT } from "../../src/services/queue-federation"; +import { compositeQueuePressureScore } from "../../src/signals/engine"; +import type { JsonValue } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +describe("compositeQueuePressureScore", () => { + it("uses burdenScore when stale rate and growth are both null", () => { + expect(compositeQueuePressureScore(80, null, null)).toBe(80); + }); + + it("amplifies burden score by stale rate", () => { + expect(compositeQueuePressureScore(50, 0.5, null)).toBeCloseTo(75); + }); + + it("adds pull request growth to the score", () => { + expect(compositeQueuePressureScore(50, 0, 10)).toBeCloseTo(60); + }); + + it("combines stale rate and growth correctly", () => { + expect(compositeQueuePressureScore(40, 0.25, 5)).toBeCloseTo(55); + }); + + it("handles zero burden score", () => { + expect(compositeQueuePressureScore(0, 0.9, 20)).toBeCloseTo(20); + }); +}); + +describe("buildFederatedQueueIndex", () => { + it("returns an empty index when no repos are registered and installed", async () => { + const env = createTestEnv(); + const index = await buildFederatedQueueIndex(env); + expect(index.repoCount).toBe(0); + expect(index.entries).toEqual([]); + expect(index.limitApplied).toBe(FEDERATED_QUEUE_INDEX_DEFAULT_LIMIT); + expect(index.source).toBe("computed"); + }); + + it("returns source=snapshot and entries from cache when a fresh snapshot exists", async () => { + const env = createTestEnv(); + await upsertQueueFederationSnapshot(env, { + id: "current", + generatedAt: new Date(Date.now() - 30_000).toISOString(), + repoCount: 1, + payload: { + entries: [{ repoFullName: "owner/cached", burdenScore: 55, level: "high", compositeScore: 55, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "fresh", summary: "high burden" }], + } as unknown as Record, + }); + const index = await buildFederatedQueueIndex(env); + expect(index.source).toBe("snapshot"); + expect(index.repoCount).toBe(1); + expect(index.entries[0]?.repoFullName).toBe("owner/cached"); + }); + + it("recomputes when the cached snapshot is older than the freshness threshold", async () => { + const env = createTestEnv(); + await upsertQueueFederationSnapshot(env, { + id: "current", + generatedAt: new Date(Date.now() - BURDEN_FORECAST_MAX_AGE_MS - 60_000).toISOString(), + repoCount: 1, + payload: { + entries: [{ repoFullName: "owner/stale", burdenScore: 99, level: "critical", compositeScore: 99, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "stale", summary: "stale cache" }], + } as unknown as Record, + }); + const index = await buildFederatedQueueIndex(env); + expect(index.source).toBe("computed"); + expect(index.repoCount).toBe(0); + }); + + it("recomputes when the cached snapshot has an unparseable generatedAt", async () => { + const env = createTestEnv(); + await upsertQueueFederationSnapshot(env, { + id: "current", + generatedAt: "not-a-timestamp", + repoCount: 1, + payload: { + entries: [{ repoFullName: "owner/stale", burdenScore: 99, level: "critical", compositeScore: 99, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "fresh", summary: "stale cache" }], + } as unknown as Record, + }); + const index = await buildFederatedQueueIndex(env); + expect(index.source).toBe("computed"); + expect(index.repoCount).toBe(0); + }); + + it("returns an empty entry list when a fresh snapshot payload has no entries array", async () => { + const env = createTestEnv(); + await upsertQueueFederationSnapshot(env, { + id: "current", + generatedAt: new Date(Date.now() - 30_000).toISOString(), + repoCount: 0, + payload: { entries: "not-an-array" } as unknown as Record, + }); + const index = await buildFederatedQueueIndex(env); + expect(index.source).toBe("snapshot"); + expect(index.entries).toEqual([]); + }); + + it("slices cached snapshot entries to the requested limit", async () => { + const env = createTestEnv(); + await upsertQueueFederationSnapshot(env, { + id: "current", + generatedAt: new Date(Date.now() - 30_000).toISOString(), + repoCount: 3, + payload: { + entries: [ + { repoFullName: "owner/a", burdenScore: 90, level: "critical", compositeScore: 90, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "fresh", summary: "a" }, + { repoFullName: "owner/b", burdenScore: 80, level: "high", compositeScore: 80, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "fresh", summary: "b" }, + { repoFullName: "owner/c", burdenScore: 70, level: "high", compositeScore: 70, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "fresh", summary: "c" }, + ], + } as unknown as Record, + }); + const index = await buildFederatedQueueIndex(env, 1); + expect(index.source).toBe("snapshot"); + expect(index.entries).toHaveLength(1); + expect(index.entries[0]?.repoFullName).toBe("owner/a"); + expect(index.limitApplied).toBe(1); + }); + + it("includes a repo with a cached burden forecast", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "alpha", full_name: "owner/alpha", private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, "owner/alpha"); + await markRegistered(env, "owner/alpha"); + await upsertBurdenForecast(env, { + repoFullName: "owner/alpha", + payload: { repoFullName: "owner/alpha", level: "high", forecast: { projectedReviewLoad: 70, reviewablePullRequests: 0, stalePullRequests: 0, duplicateTrend: 0, queueGrowthRisk: 0 }, summary: "high burden" } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + const index = await buildFederatedQueueIndex(env); + expect(index.repoCount).toBe(1); + expect(index.entries[0]?.repoFullName).toBe("owner/alpha"); + expect(index.entries[0]?.level).toBe("high"); + expect(index.entries[0]?.burdenScore).toBe(70); + }); + + it("defaults burdenScore to zero when the cached forecast omits projectedReviewLoad", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "no-forecast", full_name: "owner/no-forecast", private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, "owner/no-forecast"); + await markRegistered(env, "owner/no-forecast"); + await upsertBurdenForecast(env, { + repoFullName: "owner/no-forecast", + payload: { repoFullName: "owner/no-forecast", level: "low", summary: "low burden without forecast block" } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + const index = await buildFederatedQueueIndex(env); + expect(index.repoCount).toBe(1); + expect(index.entries[0]?.burdenScore).toBe(0); + expect(index.entries[0]?.compositeScore).toBe(0); + }); + + it("ranks repos descending by composite score", async () => { + const env = createTestEnv(); + for (const [name, score, level] of [["low-repo", 20, "low"], ["high-repo", 80, "critical"], ["mid-repo", 50, "high"]] as const) { + await upsertRepositoryFromGitHub(env, { name, full_name: `owner/${name}`, private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, `owner/${name}`); + await markRegistered(env, `owner/${name}`); + await upsertBurdenForecast(env, { + repoFullName: `owner/${name}`, + payload: { repoFullName: `owner/${name}`, level, forecast: { projectedReviewLoad: score, reviewablePullRequests: 0, stalePullRequests: 0, duplicateTrend: 0, queueGrowthRisk: 0 }, summary: `${level} burden` } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + } + const index = await buildFederatedQueueIndex(env); + expect(index.entries.map((e) => e.repoFullName)).toEqual(["owner/high-repo", "owner/mid-repo", "owner/low-repo"]); + }); + + it("sorts critical above high when composite scores are equal", async () => { + const env = createTestEnv(); + for (const [name, level] of [["repo-high", "high"], ["repo-critical", "critical"]] as const) { + await upsertRepositoryFromGitHub(env, { name, full_name: `owner/${name}`, private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, `owner/${name}`); + await markRegistered(env, `owner/${name}`); + await upsertBurdenForecast(env, { + repoFullName: `owner/${name}`, + payload: { repoFullName: `owner/${name}`, level, forecast: { projectedReviewLoad: 60, reviewablePullRequests: 0, stalePullRequests: 0, duplicateTrend: 0, queueGrowthRisk: 0 }, summary: `${level} burden` } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + } + const index = await buildFederatedQueueIndex(env); + expect(index.entries[0]?.level).toBe("critical"); + expect(index.entries[1]?.level).toBe("high"); + }); + + it("omits repos that are registered but not installed", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "uninstalled", full_name: "owner/uninstalled", private: false, owner: { login: "owner" }, default_branch: "main" }); + await markRegistered(env, "owner/uninstalled"); + const index = await buildFederatedQueueIndex(env); + expect(index.repoCount).toBe(0); + }); + + it("respects the limit parameter and clamps it to the maximum", async () => { + const env = createTestEnv(); + for (let i = 0; i < 5; i++) { + const name = `repo-${i}`; + await upsertRepositoryFromGitHub(env, { name, full_name: `owner/${name}`, private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, `owner/${name}`); + await markRegistered(env, `owner/${name}`); + await upsertBurdenForecast(env, { + repoFullName: `owner/${name}`, + payload: { repoFullName: `owner/${name}`, level: "low", forecast: { projectedReviewLoad: i * 5, reviewablePullRequests: 0, stalePullRequests: 0, duplicateTrend: 0, queueGrowthRisk: 0 }, summary: "low" } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + } + const limited = await buildFederatedQueueIndex(env, 2); + expect(limited.entries).toHaveLength(2); + expect(limited.limitApplied).toBe(2); + expect(limited.repoCount).toBe(5); + + const clamped = await buildFederatedQueueIndex(env, FEDERATED_QUEUE_INDEX_MAX_LIMIT + 100); + expect(clamped.limitApplied).toBe(FEDERATED_QUEUE_INDEX_MAX_LIMIT); + }); + + it("still includes a repo with no trend snapshot (pullRequestGrowth7d: null)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "notrend", full_name: "owner/notrend", private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, "owner/notrend"); + await markRegistered(env, "owner/notrend"); + await upsertBurdenForecast(env, { + repoFullName: "owner/notrend", + payload: { repoFullName: "owner/notrend", level: "medium", forecast: { projectedReviewLoad: 40, reviewablePullRequests: 0, stalePullRequests: 0, duplicateTrend: 0, queueGrowthRisk: 0 }, summary: "medium" } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + const index = await buildFederatedQueueIndex(env); + expect(index.repoCount).toBe(1); + expect(index.entries[0]?.pullRequestGrowth7d).toBeNull(); + expect(index.entries[0]?.stalePullRequestRate).toBeNull(); + }); + + it("reads stalePullRequestRate and pullRequestGrowth7d from a stored trend snapshot", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "trend", full_name: "owner/trend", private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, "owner/trend"); + await markRegistered(env, "owner/trend"); + await upsertBurdenForecast(env, { + repoFullName: "owner/trend", + payload: { repoFullName: "owner/trend", level: "medium", forecast: { projectedReviewLoad: 45, reviewablePullRequests: 2, stalePullRequests: 1, duplicateTrend: 0, queueGrowthRisk: 20 }, summary: "medium" } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + await upsertRepoQueueTrendSnapshot(env, { + repoFullName: "owner/trend", + generatedAt: new Date(Date.now() - 60_000).toISOString(), + payload: { + repoFullName: "owner/trend", + status: "ready", + generatedAt: new Date(Date.now() - 60_000).toISOString(), + source: "snapshot", + windows: [ + { + windowDays: 7, + status: "ready", + observedDays: 7, + baselineAt: null, + latestAt: null, + pullRequestGrowth: 3, + issueGrowth: 1, + mergedPullRequests: 5, + closedUnmergedPullRequests: 1, + reviewVelocityPerDay: 0.86, + stalePullRequestRate: 0.25, + stalePullRequestRateDelta: 0.05, + duplicateTrend: 0, + summary: "7d trend: PR queue +3, review velocity 0.86/day.", + }, + ], + warnings: [], + summary: "1 queue trend window available.", + } as unknown as Record, + }); + const index = await buildFederatedQueueIndex(env); + expect(index.repoCount).toBe(1); + expect(index.entries[0]?.stalePullRequestRate).toBeCloseTo(0.25); + expect(index.entries[0]?.pullRequestGrowth7d).toBe(3); + expect(index.entries[0]?.compositeScore).toBeCloseTo(59.25); + }); + + it("does not include private signal fields (privateTrustEnabled, hotkeys, raw trust scores)", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "safe", full_name: "owner/safe", private: false, owner: { login: "owner" }, default_branch: "main" }); + await markInstalled(env, "owner/safe"); + await markRegistered(env, "owner/safe"); + await upsertBurdenForecast(env, { + repoFullName: "owner/safe", + payload: { repoFullName: "owner/safe", level: "low", forecast: { projectedReviewLoad: 10, reviewablePullRequests: 0, stalePullRequests: 0, duplicateTrend: 0, queueGrowthRisk: 0 }, summary: "low" } as unknown as Record, + generatedAt: new Date(Date.now() - 60_000).toISOString(), + }); + const index = await buildFederatedQueueIndex(env); + const entry = index.entries[0]; + expect(entry).toBeDefined(); + const entryKeys = Object.keys(entry!); + for (const forbidden of ["privateTrustEnabled", "hotkey", "trustScore", "wallet", "reward", "payout"]) { + expect(entryKeys).not.toContain(forbidden); + } + const serialized = JSON.stringify(entry); + for (const forbidden of ["wallet", "hotkey", "trust score", "payout", "reward estimate", "farming"]) { + expect(serialized.toLowerCase()).not.toContain(forbidden.toLowerCase()); + } + }); +}); + +describe("GET /v1/app/queue-health/federation route", () => { + function apiHeaders(env: ReturnType): Record { + return { authorization: `Bearer ${env.GITTENSORY_API_TOKEN}` }; + } + + it("returns 401 for unauthenticated requests", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/app/queue-health/federation", {}, env); + expect(response.status).toBe(401); + }); + + it("returns 200 with an empty index when no repos are registered", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/app/queue-health/federation", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + const body = (await response.json()) as { repoCount: number; entries: unknown[]; source: string }; + expect(body.repoCount).toBe(0); + expect(body.entries).toEqual([]); + expect(body.source).toBe("computed"); + }); + + it("returns source=snapshot when a fresh cached index exists", async () => { + const app = createApp(); + const env = createTestEnv(); + await upsertQueueFederationSnapshot(env, { + id: "current", + generatedAt: new Date(Date.now() - 60_000).toISOString(), + repoCount: 2, + payload: { + entries: [ + { repoFullName: "owner/alpha", burdenScore: 70, level: "high", compositeScore: 70, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "fresh", summary: "high" }, + { repoFullName: "owner/beta", burdenScore: 40, level: "medium", compositeScore: 40, stalePullRequestRate: null, pullRequestGrowth7d: null, freshness: "fresh", summary: "medium" }, + ], + } as unknown as Record, + }); + const response = await app.request("/v1/app/queue-health/federation", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + const body = (await response.json()) as { repoCount: number; entries: unknown[]; source: string }; + expect(body.source).toBe("snapshot"); + expect(body.repoCount).toBe(2); + expect((body.entries as unknown[]).length).toBe(2); + }); + + it("returns 422 for an invalid limit parameter", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/app/queue-health/federation?limit=0", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(422); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_limit" }); + }); + + it("returns 422 for a non-integer limit parameter", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/app/queue-health/federation?limit=abc", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(422); + }); + + it("returns 422 for a limit above the maximum", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request(`/v1/app/queue-health/federation?limit=${FEDERATED_QUEUE_INDEX_MAX_LIMIT + 1}`, { headers: apiHeaders(env) }, env); + expect(response.status).toBe(422); + }); + + it("returns 403 for a signed-in user without operator role", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + const { token } = await createSessionForGitHubUser(env, { login: "plain-user", id: 50 }); + const response = await app.request("/v1/app/queue-health/federation", { headers: { cookie: `gittensory_session=${token}` } }, env); + expect(response.status).toBe(403); + }); + + it("returns 200 with a valid limit parameter", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/app/queue-health/federation?limit=5", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + const body = (await response.json()) as { limitApplied: number }; + expect(body.limitApplied).toBe(5); + }); +}); + +async function markInstalled(env: ReturnType, fullName: string): Promise { + const { getDb } = await import("../../src/db/client"); + const { repositories } = await import("../../src/db/schema"); + const { eq } = await import("drizzle-orm"); + await getDb(env.DB).update(repositories).set({ isInstalled: true }).where(eq(repositories.fullName, fullName)); +} + +async function markRegistered(env: ReturnType, fullName: string): Promise { + const { getDb } = await import("../../src/db/client"); + const { repositories } = await import("../../src/db/schema"); + const { eq } = await import("drizzle-orm"); + await getDb(env.DB).update(repositories).set({ isRegistered: true }).where(eq(repositories.fullName, fullName)); +}