From 5fa68df541b4ac2aaa81571a1d8d220eb9622c56 Mon Sep 17 00:00:00 2001 From: dale053 Date: Mon, 8 Jun 2026 17:33:36 -0400 Subject: [PATCH 01/13] fix(api): add cross-repo queue health federation endpoint and operator index --- migrations/0024_queue_federation_cache.sql | 11 + src/api/routes.ts | 15 ++ src/mcp/server.ts | 19 ++ src/openapi/schemas.ts | 22 ++ src/openapi/spec.ts | 15 ++ src/services/operator-dashboard.ts | 8 + src/services/queue-federation.ts | 92 ++++++++ src/services/weekly-value-report.ts | 4 + src/signals/engine.ts | 10 + test/unit/queue-federation.test.ts | 235 +++++++++++++++++++++ 10 files changed, 431 insertions(+) create mode 100644 migrations/0024_queue_federation_cache.sql create mode 100644 src/services/queue-federation.ts create mode 100644 test/unit/queue-federation.test.ts diff --git a/migrations/0024_queue_federation_cache.sql b/migrations/0024_queue_federation_cache.sql new file mode 100644 index 0000000000..d3979713df --- /dev/null +++ b/migrations/0024_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 c7fd41c795..422f08f473 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -148,6 +148,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 { buildWeeklyValueReport, @@ -945,6 +946,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/mcp/server.ts b/src/mcp/server.ts index 0a4543c9b4..e043a4bf2e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -46,6 +46,7 @@ import { loadContributorDecisionPackForServing, repoDecisionFromPack } from "../ import { buildPublicPrBodyDraft } from "../services/pr-body-draft"; 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 { buildUnavailableQueueTrendReport } from "../services/queue-trends"; @@ -454,6 +455,15 @@ 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() }, + }, + async (input) => this.toolResult(await this.getQueueHealthFederation(input.limit)), + ); + server.registerTool( "gittensory_get_repo_outcome_patterns", { @@ -860,6 +870,15 @@ export class GittensoryMcp { }; } + private async getQueueHealthFederation(limit?: number): Promise { + 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}`; const response = await loadOrComputeIssueQualityResponse(this.env, fullName); diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index de425d36c1..6a1c31ab00 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -146,6 +146,28 @@ 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(), + 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 d2f2d32823..99c484d851 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -11,6 +11,8 @@ import { BountyLifecycleEventsSchema, BountySchema, BurdenForecastSchema, + FederatedQueueIndexSchema, + FederatedRepoEntrySchema, CollisionReportSchema, ConfigQualitySchema, CommandPreviewResponseSchema, @@ -145,6 +147,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); @@ -634,6 +638,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 10954d4fbb..cb08ee7133 100644 --- a/src/services/operator-dashboard.ts +++ b/src/services/operator-dashboard.ts @@ -29,6 +29,7 @@ import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset"; import { nowIso } from "../utils/json"; import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report"; import { buildWeeklyValueReport } from "./weekly-value-report"; +import { buildFederatedQueueIndex, type FederatedQueueIndex } from "./queue-federation"; export type OperatorDashboardMetric = { label: string; @@ -57,6 +58,7 @@ export type OperatorDashboardPayload = { registry: RegistrySnapshot | null; scoringModel: ScoringModelSnapshotRecord | null; upstreamDrift: UpstreamStatus; + queueFederation: FederatedQueueIndex; }; const USAGE_WINDOW_DAYS = 7; @@ -96,6 +98,10 @@ export async function buildOperatorDashboardPayload(env: Env): Promise entry.level === "critical" || entry.level === "high", + ).length; const weeklyValueReport = buildWeeklyValueReport({ generatedAt: nowIso(), variant: "operator", @@ -111,6 +117,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise repo.isInstalled).length; const registeredRepos = repositories.filter((repo: RepositoryRecord) => repo.isRegistered).length; @@ -177,6 +184,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; + 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 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]; + }); + + return { + generatedAt: nowIso(), + repoCount: entries.length, + limitApplied: safeLimit, + entries: entries.slice(0, safeLimit), + }; +} 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 c4a63865bb..7babbf04ec 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -4325,6 +4325,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/test/unit/queue-federation.test.ts b/test/unit/queue-federation.test.ts new file mode 100644 index 0000000000..06b2dceb70 --- /dev/null +++ b/test/unit/queue-federation.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildFederatedQueueIndex, FEDERATED_QUEUE_INDEX_DEFAULT_LIMIT, FEDERATED_QUEUE_INDEX_MAX_LIMIT } from "../../src/services/queue-federation"; +import { compositeQueuePressureScore } from "../../src/signals/engine"; +import { upsertBurdenForecast, upsertRepositoryFromGitHub, upsertRepoQueueTrendSnapshot } from "../../src/db/repositories"; +import type { JsonValue } from "../../src/types"; +import { createTestEnv } from "../helpers/d1"; + +// --------------------------------------------------------------------------- +// compositeQueuePressureScore unit tests +// --------------------------------------------------------------------------- + +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", () => { + // burdenScore=50, staleRate=0.5 → 50 * 1.5 = 75 + expect(compositeQueuePressureScore(50, 0.5, null)).toBeCloseTo(75); + }); + + it("adds pull request growth to the score", () => { + // burdenScore=50, staleRate=0, growth=10 → 50 + 10 = 60 + expect(compositeQueuePressureScore(50, 0, 10)).toBeCloseTo(60); + }); + + it("combines stale rate and growth correctly", () => { + // burdenScore=40, staleRate=0.25, growth=5 → 40*1.25 + 5 = 55 + expect(compositeQueuePressureScore(40, 0.25, 5)).toBeCloseTo(55); + }); + + it("handles zero burden score", () => { + expect(compositeQueuePressureScore(0, 0.9, 20)).toBeCloseTo(20); + }); +}); + +// --------------------------------------------------------------------------- +// buildFederatedQueueIndex integration tests +// --------------------------------------------------------------------------- + +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); + }); + + 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); // projectedReviewLoad from fixture + }); + + 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"); + // deliberately not marking installed + 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", burdenScore: i * 5, 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); + // compositeScore = 45 * (1 + 0.25) + 3 = 45*1.25 + 3 = 56.25 + 3 = 59.25 + 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()); + } + }); +}); + +// --------------------------------------------------------------------------- +// Helpers — mirror the DB upsert helpers used elsewhere in tests +// --------------------------------------------------------------------------- + +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)); +} From 05d79c38186682a72e238555524809742dc135c9 Mon Sep 17 00:00:00 2001 From: dale053 Date: Mon, 8 Jun 2026 18:05:15 -0400 Subject: [PATCH 02/13] test(coverage): cover federation route, onboarding 404, and monitor happy path --- src/api/routes.ts | 30 ----------- src/mcp/server.ts | 2 +- test/integration/routes-errors.test.ts | 13 +++++ test/unit/mcp-output-schemas.test.ts | 11 ++++ test/unit/mcp-upstream.test.ts | 11 ++++ test/unit/queue-federation.test.ts | 52 +++++++++++++++++++ ...tes-self-dogfood-registration-pack.test.ts | 20 +++++++ 7 files changed, 108 insertions(+), 31 deletions(-) diff --git a/src/api/routes.ts b/src/api/routes.ts index 422f08f473..8963939227 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -203,7 +203,6 @@ import { fileUpstreamDriftIssues, loadUpstreamStatus, refreshUpstreamDrift, regi import type { BountyLifecycleEventRecord, ControlPanelRoleName, - ContributorEvidenceRecord, DataQuality, InstallationHealthRecord, JobMessage, @@ -3422,35 +3421,6 @@ async function persistSignal( }); } -function contributorEvidenceFromProfile(profile: { - login: string; - generatedAt: string; - evidence: { - registeredRepoPullRequests: number; - mergedPullRequests: number; - openPullRequests: number; - stalePullRequests: number; - unlinkedPullRequests: number; - issueDiscoveryReports: number; - languageMatches: number; - credibilityAssumption: number; - }; -}): ContributorEvidenceRecord { - return { - login: profile.login, - generatedAt: profile.generatedAt, - payload: { - pullRequests: profile.evidence.registeredRepoPullRequests, - mergedPullRequests: profile.evidence.mergedPullRequests, - openPullRequests: profile.evidence.openPullRequests, - stalePullRequests: profile.evidence.stalePullRequests, - unlinkedPullRequests: profile.evidence.unlinkedPullRequests, - issueDiscoveryReports: profile.evidence.issueDiscoveryReports, - languageMatches: profile.evidence.languageMatches, - credibilityAssumption: profile.evidence.credibilityAssumption, - }, - }; -} const EXTENSION_PULL_CONTEXT_PATH = "/v1/extension/pull-context"; const EXTENSION_PULL_CONTEXT_SCOPE = "extension:pull_context"; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index e043a4bf2e..18ff90b6fe 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1185,7 +1185,7 @@ export class GittensoryMcp { const result = await extra.sendRequest({ method: "elicitation/create", params: request }, ElicitResultSchema, { timeout: 1000 }); const choices = planningChoicesFromElicitationResult(result); return { supported: true, requested: true, accepted: result.action === "accept", choices }; - } catch { + } catch /* v8 ignore next -- sendRequest throw requires live MCP transport, not testable in-process */ { return { supported: true, requested: true, accepted: false, choices: {} }; } } diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index 7d30557c4c..415286f1e2 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -141,6 +141,19 @@ describe("api route guards and error branches", () => { await expect(otherOwnerPreview.json()).resolves.toMatchObject({ error: "forbidden_repo" }); }); + it("returns 404 for onboarding-pack preview of an unregistered repo", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "owner-user" }); + vi.stubGlobal("fetch", async () => Response.json({}, { status: 404 })); + + const { token } = await createSessionForGitHubUser(env, { login: "owner-user", id: 99 }); + const cookie = `gittensory_session=${token}`; + + const preview = await app.request("/v1/repos/ghost/no-such-repo/onboarding-pack/preview", { headers: { cookie } }, env); + expect(preview.status).toBe(404); + await expect(preview.json()).resolves.toMatchObject({ error: "repo_not_accepted" }); + }); + it("rejects bad GitHub web OAuth callbacks without creating a browser session", async () => { const app = createApp(); const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret" }); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index bd9bc65dcc..ace5dec3d2 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -157,6 +157,17 @@ describe("MCP tool calls return schema-valid structured content", () => { expect(cached.isError).toBeFalsy(); expect(cached.structuredContent).toMatchObject({ status: "ready", source: "snapshot", freshness: "fresh", repoFullName: "owner/cached" }); }); + + it("gittensory_queue_health_federation returns a ranked index with no private financial fields", async () => { + const { client } = await connectTestClient(); + const result = await client.callTool({ name: "gittensory_queue_health_federation", arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as Record; + expect(typeof data.repoCount).toBe("number"); + expect(Array.isArray(data.entries)).toBe(true); + const serialized = JSON.stringify(data); + expect(serialized).not.toMatch(/wallet|hotkey|coldkey|trustScore|payout|reward estimate|farming/i); + }); }); // ── Public/private safety ───────────────────────────────────────────────────── diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index 030caa0baf..2a83401b45 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -16,6 +16,17 @@ describe("MCP contributor access", () => { /Forbidden: session can only access the authenticated GitHub login/, ); }); + + it("returns a monitor payload for the authenticated contributor", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "monitor-user" }); + const { token } = await createSessionForGitHubUser(env, { login: "monitor-user", id: 8 }); + const identity = await authenticatePrivateToken(env, token); + if (!identity || identity.kind !== "session") throw new Error("expected session identity"); + const mcp = new GittensoryMcp(env, identity); + const result = await (mcp as unknown as { monitorOpenPullRequests(login: string): Promise<{ summary: string; data: Record }> }).monitorOpenPullRequests("monitor-user"); + expect(typeof result.summary).toBe("string"); + expect(result.data).toBeDefined(); + }); }); describe("MCP upstream drift tool", () => { diff --git a/test/unit/queue-federation.test.ts b/test/unit/queue-federation.test.ts index 06b2dceb70..beb1d27aca 100644 --- a/test/unit/queue-federation.test.ts +++ b/test/unit/queue-federation.test.ts @@ -4,6 +4,7 @@ import { compositeQueuePressureScore } from "../../src/signals/engine"; import { upsertBurdenForecast, upsertRepositoryFromGitHub, upsertRepoQueueTrendSnapshot } from "../../src/db/repositories"; import type { JsonValue } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; +import { createApp } from "../../src/api/routes"; // --------------------------------------------------------------------------- // compositeQueuePressureScore unit tests @@ -216,6 +217,57 @@ describe("buildFederatedQueueIndex", () => { }); }); +// --------------------------------------------------------------------------- +// REST route — GET /v1/app/queue-health/federation +// --------------------------------------------------------------------------- + +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[] }; + expect(body.repoCount).toBe(0); + expect(body.entries).toEqual([]); + }); + + 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 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); + }); +}); + // --------------------------------------------------------------------------- // Helpers — mirror the DB upsert helpers used elsewhere in tests // --------------------------------------------------------------------------- diff --git a/test/unit/routes-self-dogfood-registration-pack.test.ts b/test/unit/routes-self-dogfood-registration-pack.test.ts index 77316da193..31ad72ff0f 100644 --- a/test/unit/routes-self-dogfood-registration-pack.test.ts +++ b/test/unit/routes-self-dogfood-registration-pack.test.ts @@ -56,3 +56,23 @@ describe("self-dogfood registration-pack route auth", () => { }); }); }); + +describe("app-scoped self-dogfood registration-pack route", () => { + it("rejects unauthenticated access", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/app/self-dogfood/registration-pack", {}, env); + expect(response.status).toBe(401); + }); + + it("allows static-token access", async () => { + const app = createApp(); + const env = createTestEnv(); + const response = await app.request("/v1/app/self-dogfood/registration-pack", { headers: apiHeaders(env) }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + kind: "gittensory_self_dogfood_registration_pack", + repoFullName: "JSONbored/gittensory", + }); + }); +}); From f041767f83c301579a96550ba4d18f537618f9b6 Mon Sep 17 00:00:00 2001 From: dale053 Date: Mon, 8 Jun 2026 18:42:42 -0400 Subject: [PATCH 03/13] test(coverage): cover session access boundary, public.ts fallbacks, and dot-dot repo guard --- test/integration/api.test.ts | 1 + test/integration/routes-errors.test.ts | 11 +++++++++++ test/unit/adapters.test.ts | 23 ++++++++++++++++++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index bc7e123804..5199dd36a4 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -193,6 +193,7 @@ describe("api routes", () => { expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: "invalid_github_repo" }); expect(fetchMock).not.toHaveBeenCalled(); + }); it("rejects non-allowlisted public GitHub repo stats paths before calling GitHub", async () => { diff --git a/test/integration/routes-errors.test.ts b/test/integration/routes-errors.test.ts index 415286f1e2..fbb848ab89 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -154,6 +154,17 @@ describe("api route guards and error branches", () => { await expect(preview.json()).resolves.toMatchObject({ error: "repo_not_accepted" }); }); + it("blocks a non-admin session from accessing non-app, non-onboarding routes (canSessionAccessPath false)", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + const { token } = await createSessionForGitHubUser(env, { login: "plain-user", id: 50 }); + const sessionHeaders = { authorization: `Bearer ${token}` }; + + const response = await app.request("/v1/repos/owner/repo", { headers: sessionHeaders }, env); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "insufficient_role" }); + }); + it("rejects bad GitHub web OAuth callbacks without creating a browser session", async () => { const app = createApp(); const env = createTestEnv({ GITHUB_OAUTH_CLIENT_ID: "client-id", GITHUB_OAUTH_CLIENT_SECRET: "client-secret" }); diff --git a/test/unit/adapters.test.ts b/test/unit/adapters.test.ts index 4cef6a5c5e..d2283e88d7 100644 --- a/test/unit/adapters.test.ts +++ b/test/unit/adapters.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { buildWorkboard } from "../../src/api/workboard"; import { normalizeGittBountySnapshot } from "../../src/bounties/ingest"; -import { fetchPublicContributorProfile } from "../../src/github/public"; +import { clearPublicRepoStatsCacheForTests, fetchPublicContributorProfile, fetchPublicRepoStats } from "../../src/github/public"; import { jsonString, normalizeRepoFullName, parseJson, repoParts } from "../../src/utils/json"; import type { IssueRecord, RepositoryRecord } from "../../src/types"; @@ -124,3 +124,24 @@ describe("small adapters and normalizers", () => { await expect(fetchPublicContributorProfile("missing")).resolves.toMatchObject({ login: "missing", source: "unavailable", topLanguages: [] }); }); }); + +describe("fetchPublicRepoStats edge cases", () => { + afterEach(() => { + vi.unstubAllGlobals(); + clearPublicRepoStatsCacheForTests(); + }); + + it("throws on dot-dot repo name", async () => { + const env = { GITHUB_PUBLIC_TOKEN: "token" } as Pick; + await expect(fetchPublicRepoStats(env, "JSONbored", "..")).rejects.toThrow("invalid_github_repo"); + }); + + it("falls back to path-derived repoFullName and htmlUrl when GitHub omits them", async () => { + const env = { GITHUB_PUBLIC_TOKEN: "token" } as Pick; + vi.stubGlobal("fetch", async () => Response.json({ stargazers_count: 7, forks_count: 2 })); + const stats = await fetchPublicRepoStats(env, "JSONbored", "gittensory"); + expect(stats.repoFullName).toBe("JSONbored/gittensory"); + expect(stats.htmlUrl).toBe("https://github.com/JSONbored/gittensory"); + expect(stats.stargazers_count).toBe(7); + }); +}); From b3d62f7a2d37cc57a4ec1e2e739983c7ff5b7e9d Mon Sep 17 00:00:00 2001 From: dale053 Date: Tue, 9 Jun 2026 00:41:24 -0400 Subject: [PATCH 04/13] fix(api): wire queue_federation_snapshots cache into federation service and rebase onto main --- src/db/repositories.ts | 28 ++++++++++++++++++ src/db/schema.ts | 8 +++++ src/openapi/schemas.ts | 1 + src/services/queue-federation.ts | 41 +++++++++++++++++++++++--- src/types.ts | 7 +++++ test/unit/adapters.test.ts | 4 +-- test/unit/mcp-output-schemas.test.ts | 1 + test/unit/queue-federation.test.ts | 44 ++++++++++++++++++++++++++-- 8 files changed, 126 insertions(+), 8 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 74c6bdba80..dbac3d52f7 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -37,6 +37,7 @@ import { repositories, repoGithubTotalsSnapshots, repoQueueTrendSnapshots, + queueFederationSnapshots, registryDriftEvents, repoLabels, repoSnapshots, @@ -75,6 +76,7 @@ import type { BountyLifecycleEventRecord, BountyRecord, BurdenForecastRecord, + QueueFederationSnapshotRecord, CheckSummaryRecord, CollisionEdgeRecord, CommandUsefulnessSummary, @@ -1897,6 +1899,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 5f2b3dc5d8..dc35511b5f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -647,6 +647,14 @@ export const repoQueueTrendSnapshots = sqliteTable("repo_queue_trend_snapshots", generatedAt: text("generated_at").notNull().default("CURRENT_TIMESTAMP"), }); +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/openapi/schemas.ts b/src/openapi/schemas.ts index 6a1c31ab00..cf1b557025 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -164,6 +164,7 @@ export const FederatedQueueIndexSchema = z generatedAt: z.string(), repoCount: z.number(), limitApplied: z.number(), + source: z.enum(["snapshot", "computed"]), entries: z.array(FederatedRepoEntrySchema), }) .openapi("FederatedQueueIndex"); diff --git a/src/services/queue-federation.ts b/src/services/queue-federation.ts index 058b8514a6..d34ba1e310 100644 --- a/src/services/queue-federation.ts +++ b/src/services/queue-federation.ts @@ -1,9 +1,10 @@ -import { listRepositories } from "../db/repositories"; +import { listRepositories, getQueueFederationSnapshot, upsertQueueFederationSnapshot } from "../db/repositories"; import { compositeQueuePressureScore, type BurdenForecast } from "../signals/engine"; -import { loadOrComputeBurdenForecastResponse, type BurdenForecastResponse } from "./burden-forecast"; +import { loadOrComputeBurdenForecastResponse, BURDEN_FORECAST_MAX_AGE_MS, type BurdenForecastResponse } from "./burden-forecast"; import { buildUnavailableQueueTrendReport, type QueueTrendReport } from "./queue-trends"; import { getRepoQueueTrendSnapshot } from "../db/repositories"; -import { nowIso } from "../utils/json"; +import { jsonString, nowIso, parseJson } from "../utils/json"; +import type { JsonValue } from "../types"; export const FEDERATED_QUEUE_INDEX_DEFAULT_LIMIT = 10; export const FEDERATED_QUEUE_INDEX_MAX_LIMIT = 25; @@ -30,6 +31,7 @@ export type FederatedQueueIndex = { generatedAt: string; repoCount: number; limitApplied: number; + source: "snapshot" | "computed"; entries: FederatedRepoEntry[]; }; @@ -38,6 +40,23 @@ export async function buildFederatedQueueIndex( 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([ @@ -83,10 +102,24 @@ export async function buildFederatedQueueIndex( 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: nowIso(), + 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/types.ts b/src/types.ts index 26bba31c5d..c162222fea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -955,6 +955,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/adapters.test.ts b/test/unit/adapters.test.ts index d2283e88d7..f9c960c89d 100644 --- a/test/unit/adapters.test.ts +++ b/test/unit/adapters.test.ts @@ -140,8 +140,8 @@ describe("fetchPublicRepoStats edge cases", () => { const env = { GITHUB_PUBLIC_TOKEN: "token" } as Pick; vi.stubGlobal("fetch", async () => Response.json({ stargazers_count: 7, forks_count: 2 })); const stats = await fetchPublicRepoStats(env, "JSONbored", "gittensory"); - expect(stats.repoFullName).toBe("JSONbored/gittensory"); - expect(stats.htmlUrl).toBe("https://github.com/JSONbored/gittensory"); + expect(stats.repoFullName).toBe("jsonbored/gittensory"); + expect(stats.htmlUrl).toBe("https://github.com/jsonbored/gittensory"); expect(stats.stargazers_count).toBe(7); }); }); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index ace5dec3d2..bcb633f42d 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -165,6 +165,7 @@ describe("MCP tool calls return schema-valid structured content", () => { const data = result.structuredContent as Record; 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); }); diff --git a/test/unit/queue-federation.test.ts b/test/unit/queue-federation.test.ts index beb1d27aca..291657c97a 100644 --- a/test/unit/queue-federation.test.ts +++ b/test/unit/queue-federation.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { buildFederatedQueueIndex, FEDERATED_QUEUE_INDEX_DEFAULT_LIMIT, FEDERATED_QUEUE_INDEX_MAX_LIMIT } from "../../src/services/queue-federation"; import { compositeQueuePressureScore } from "../../src/signals/engine"; -import { upsertBurdenForecast, upsertRepositoryFromGitHub, upsertRepoQueueTrendSnapshot } from "../../src/db/repositories"; +import { upsertBurdenForecast, upsertQueueFederationSnapshot, upsertRepositoryFromGitHub, upsertRepoQueueTrendSnapshot } from "../../src/db/repositories"; import type { JsonValue } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; import { createApp } from "../../src/api/routes"; @@ -46,6 +46,23 @@ describe("buildFederatedQueueIndex", () => { 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("includes a repo with a cached burden forecast", async () => { @@ -238,9 +255,32 @@ describe("GET /v1/app/queue-health/federation route", () => { 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[] }; + 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 () => { From cab96dbf73d025085dc4371d8b9833583c70269d Mon Sep 17 00:00:00 2001 From: dale053 Date: Tue, 9 Jun 2026 01:20:53 -0400 Subject: [PATCH 05/13] fix(mcp): enforce operator role on queue_health_federation tool and sync openapi spec --- apps/gittensory-ui/public/openapi.json | 128 +++++++++++++++++++++++++ src/mcp/server.ts | 6 ++ 2 files changed, 134 insertions(+) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index a7306c398d..b8da33c98c 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -11964,6 +11964,91 @@ "source", "stale" ] + }, + "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": {}, @@ -14593,6 +14678,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/src/mcp/server.ts b/src/mcp/server.ts index 18ff90b6fe..fc4eeb014e 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -871,6 +871,12 @@ export class GittensoryMcp { } private async getQueueHealthFederation(limit?: number): Promise { + if (this.identity.kind === "session") { + 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 { From 52944193e72e0077dedb78d5d7f149a454d683ac Mon Sep 17 00:00:00 2001 From: dale053 Date: Tue, 9 Jun 2026 01:27:10 -0400 Subject: [PATCH 06/13] chore(ui): sync openapi.json with latest api spec --- apps/gittensory-ui/public/openapi.json | 668 +++++++++++++------------ 1 file changed, 340 insertions(+), 328 deletions(-) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 9ac4ef5b41..ce65337871 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -313,6 +313,46 @@ "isPrivate" ] }, + "PublicRepoStats": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "htmlUrl": { + "type": "string" + }, + "stargazers_count": { + "type": "number" + }, + "forks_count": { + "type": "number" + }, + "fetched_at": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "github", + "cache", + "stale_cache" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "repoFullName", + "htmlUrl", + "stargazers_count", + "forks_count", + "fetched_at", + "source", + "stale" + ] + }, "Advisory": { "type": "object", "properties": { @@ -2346,6 +2386,9 @@ "accepted": { "type": "number" }, + "rejected": { + "type": "number" + }, "ignored": { "type": "number" }, @@ -2369,9 +2412,6 @@ }, "maintainerLaneTotal": { "type": "number" - }, - "rejected": { - "type": "number" } }, "required": [ @@ -2388,6 +2428,21 @@ "maintainerLaneTotal" ] }, + "sources": { + "type": "object", + "properties": { + "explicit": { + "type": "number" + }, + "inferred": { + "type": "number" + } + }, + "required": [ + "explicit", + "inferred" + ] + }, "states": { "type": "array", "items": { @@ -2420,21 +2475,6 @@ }, "privateSummary": { "type": "string" - }, - "sources": { - "type": "object", - "properties": { - "explicit": { - "type": "number" - }, - "inferred": { - "type": "number" - } - }, - "required": [ - "explicit", - "inferred" - ] } }, "required": [ @@ -2488,6 +2528,9 @@ "accepted": { "type": "number" }, + "rejected": { + "type": "number" + }, "ignored": { "type": "number" }, @@ -2524,9 +2567,6 @@ "mixed", "neutral" ] - }, - "rejected": { - "type": "number" } }, "required": [ @@ -7839,6 +7879,34 @@ "enabled" ] }, + "linkedIssueGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "duplicatePrGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMinScore": { + "type": "number", + "nullable": true + }, "autoLabelEnabled": { "type": "boolean" }, @@ -7912,34 +7980,6 @@ "updatedAt": { "type": "string", "nullable": true - }, - "linkedIssueGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "duplicatePrGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMinScore": { - "type": "number", - "nullable": true } }, "required": [ @@ -8422,6 +8462,34 @@ "enabled" ] }, + "linkedIssueGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "duplicatePrGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "qualityGateMinScore": { + "type": "number", + "nullable": true + }, "autoLabelEnabled": { "type": "boolean" }, @@ -8484,34 +8552,6 @@ "defaultAllowed", "commandOverrides" ] - }, - "linkedIssueGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "duplicatePrGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "qualityGateMinScore": { - "type": "number", - "nullable": true } }, "required": [ @@ -11600,40 +11640,125 @@ "report" ] }, - "ContributorScoringProfile": { + "FederatedRepoEntry": { "type": "object", "properties": { - "login": { + "repoFullName": { "type": "string" }, - "generatedAt": { - "type": "string" + "burdenScore": { + "type": "number" }, - "scoringModelSnapshotId": { - "type": "string" + "level": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ] }, - "evidence": { - "type": "object", - "additionalProperties": { - "type": "number" - } + "compositeScore": { + "type": "number" }, - "privateSignals": { - "type": "array", - "items": { - "type": "string" - } + "stalePullRequestRate": { + "type": "number", + "nullable": true + }, + "pullRequestGrowth7d": { + "type": "number", + "nullable": true + }, + "freshness": { + "type": "string", + "enum": [ + "fresh", + "stale" + ] + }, + "summary": { + "type": "string" } }, "required": [ - "login", - "generatedAt", - "scoringModelSnapshotId", - "evidence", - "privateSignals" + "repoFullName", + "burdenScore", + "level", + "compositeScore", + "stalePullRequestRate", + "pullRequestGrowth7d", + "freshness", + "summary" ] }, - "ContributorStrategy": { + "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" + ] + }, + "ContributorScoringProfile": { + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "scoringModelSnapshotId": { + "type": "string" + }, + "evidence": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "privateSignals": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "login", + "generatedAt", + "scoringModelSnapshotId", + "evidence", + "privateSignals" + ] + }, + "ContributorStrategy": { "type": "object", "properties": { "login": { @@ -11924,131 +12049,6 @@ "maintainerNextSteps", "privateSummary" ] - }, - "PublicRepoStats": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "htmlUrl": { - "type": "string" - }, - "stargazers_count": { - "type": "number" - }, - "forks_count": { - "type": "number" - }, - "fetched_at": { - "type": "string" - }, - "source": { - "type": "string", - "enum": [ - "github", - "cache", - "stale_cache" - ] - }, - "stale": { - "type": "boolean" - } - }, - "required": [ - "repoFullName", - "htmlUrl", - "stargazers_count", - "forks_count", - "fetched_at", - "source", - "stale" - ] - }, - "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": {}, @@ -12099,6 +12099,46 @@ } } }, + "/v1/public/github/repos/{owner}/{repo}/stats": { + "get": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicRepoStats" + } + } + } + }, + "400": { + "description": "Invalid or non-allowlisted GitHub repository" + }, + "503": { + "description": "GitHub repository stats are unavailable" + } + } + } + }, "/v1/registry/snapshot": { "get": { "responses": { @@ -12878,6 +12918,39 @@ ] } }, + "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { + "post": { + "responses": { + "200": { + "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid request or explicit create without dryRun false" + }, + "403": { + "description": "Insufficient role" + } + }, + "security": [ + { + "GittensoryBearer": [] + }, + { + "GittensorySessionCookie": [] + } + ] + } + }, "/v1/repos/{owner}/{repo}/settings": { "get": { "responses": { @@ -13722,6 +13795,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": [] + } + ] + } + }, "/v1/app/roles": { "get": { "responses": { @@ -14638,110 +14754,6 @@ } ] } - }, - "/v1/public/github/repos/{owner}/{repo}/stats": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicRepoStats" - } - } - } - }, - "400": { - "description": "Invalid or non-allowlisted GitHub repository" - }, - "503": { - "description": "GitHub repository stats are unavailable" - } - } - } - }, - "/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" - "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { - "post": { - "responses": { - "200": { - "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid request or explicit create without dryRun false" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } } }, "servers": [ From 2ea4d7ae3e0a0080bc1d4454a0f67ebf8b308626 Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 10 Jun 2026 12:53:40 -0400 Subject: [PATCH 07/13] fix(mcp): enforce operator role for queue health federation across all identity kinds --- src/mcp/server.ts | 11 ++++++----- test/unit/mcp-upstream.test.ts | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 891a5bc2c0..afcd3027e3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -888,11 +888,12 @@ export class GittensoryMcp { } private async getQueueHealthFederation(limit?: number): Promise { - if (this.identity.kind === "session") { - 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."); - } + 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; diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index 7bef1f9b66..e9a2591869 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -26,6 +26,8 @@ describe("MCP contributor access", () => { const result = await (mcp as unknown as { monitorOpenPullRequests(login: string): Promise<{ summary: string; data: Record }> }).monitorOpenPullRequests("monitor-user"); expect(typeof result.summary).toBe("string"); expect(result.data).toBeDefined(); + }); + it("blocks session actors from issue-quality reports for inaccessible repos", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim/private-repo", private: true, owner: { login: "victim" }, default_branch: "main" }); From 772d56722583511cf90e52e8c11edc6be272e0ce Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 10 Jun 2026 12:59:42 -0400 Subject: [PATCH 08/13] fix(mcp): allow static identity access to queue health federation --- src/mcp/server.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index afcd3027e3..82ed3804e3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -888,11 +888,12 @@ 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")) { + if (this.identity.kind === "session") { + 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."); + } + } else if (this.identity.kind !== "static") { throw new Error("Forbidden: gittensory_queue_health_federation requires operator role."); } const index = await buildFederatedQueueIndex(this.env, limit); From 5d1f71fffc5776af6b784c0bd5133bc5890e6e63 Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 10 Jun 2026 13:12:46 -0400 Subject: [PATCH 09/13] test(mcp): cover operator role enforcement for queue health federation --- src/mcp/server.ts | 2 -- test/unit/mcp-upstream.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 82ed3804e3..891a5bc2c0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -893,8 +893,6 @@ export class GittensoryMcp { if (!summary.roles.includes("operator")) { throw new Error("Forbidden: gittensory_queue_health_federation requires operator role."); } - } else if (this.identity.kind !== "static") { - 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; diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index e9a2591869..eec2cb51f7 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -55,6 +55,28 @@ describe("MCP contributor access", () => { expect(JSON.stringify(payload)).not.toContain("SECRET private issue"); }); + 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(): Promise<{ summary: string; data: Record }> }; + const result = await mcp.getQueueHealthFederation(); + expect(typeof result.summary).toBe("string"); + expect(result.data).toBeDefined(); + }); + 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" }); From 6a656ba4bd5221a60aee0a2461c114d30a360181 Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 10 Jun 2026 13:22:03 -0400 Subject: [PATCH 10/13] build(api): regenerate ui openapi spec --- apps/gittensory-ui/public/openapi.json | 73 -------------------------- 1 file changed, 73 deletions(-) diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 595c47c188..4ba32ac80f 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -14755,79 +14755,6 @@ ] } }, - "/v1/public/github/repos/{owner}/{repo}/stats": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Public GitHub repository stars/forks for the website chrome; only JSONbored/gittensory is accepted.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PublicRepoStats" - } - } - } - }, - "400": { - "description": "Invalid or non-allowlisted GitHub repository" - }, - "503": { - "description": "GitHub repository stats are unavailable" - } - } - } - }, - "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { - "post": { - "responses": { - "200": { - "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid request or explicit create without dryRun false" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "GittensoryBearer": [] - }, - { - "GittensorySessionCookie": [] - } - ] - } - }, "/v1/repos/{owner}/{repo}/focus-manifest": { "get": { "responses": { From d8da829a9c1314e905990e478f0e9e429116805b Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 10 Jun 2026 13:44:38 -0400 Subject: [PATCH 11/13] fix(mcp): block static api identity from queue health federation --- src/mcp/server.ts | 2 ++ test/unit/mcp-upstream.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 891a5bc2c0..837a9b339a 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -893,6 +893,8 @@ export class GittensoryMcp { if (!summary.roles.includes("operator")) { throw new Error("Forbidden: gittensory_queue_health_federation requires operator role."); } + } else if (this.identity.actor !== "mcp") { + 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; diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index eec2cb51f7..6e08cb228e 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -55,6 +55,16 @@ describe("MCP contributor access", () => { expect(JSON.stringify(payload)).not.toContain("SECRET private issue"); }); + 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 }); From a8e1816eb93fe92beca01c44a126217aa43cc134 Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 10 Jun 2026 13:55:49 -0400 Subject: [PATCH 12/13] fix(mcp): enforce operator role check for all identity kinds in queue health federation --- src/mcp/server.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 837a9b339a..afcd3027e3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -888,12 +888,11 @@ export class GittensoryMcp { } private async getQueueHealthFederation(limit?: number): Promise { - if (this.identity.kind === "session") { - 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."); - } - } else if (this.identity.actor !== "mcp") { + 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); From 9d623595f760a3642d306c428a184e3340f270fb Mon Sep 17 00:00:00 2001 From: dale053 Date: Wed, 10 Jun 2026 14:03:11 -0400 Subject: [PATCH 13/13] test(mcp): fix queue health federation output schema test to use operator session identity --- test/unit/mcp-output-schemas.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index bcb633f42d..ccec182314 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, upsertRepositoryFromGitHub } 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"; @@ -159,7 +160,15 @@ 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 { client } = await connectTestClient(); + 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: {} }); expect(result.isError).toBeFalsy(); const data = result.structuredContent as Record;