diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 6becd7e0a1..028b458254 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,6 +11640,91 @@ "report" ] }, + "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" + ] + }, "ContributorScoringProfile": { "type": "object", "properties": { @@ -11924,46 +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" - ] } }, "parameters": {}, @@ -12014,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": { @@ -12793,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": { @@ -13637,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": { @@ -14554,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": { 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 970a27a4d3..707c0a0df6 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, @@ -204,7 +205,6 @@ import { fileUpstreamDriftIssues, loadUpstreamStatus, refreshUpstreamDrift, regi import type { BountyLifecycleEventRecord, ControlPanelRoleName, - ContributorEvidenceRecord, DataQuality, InstallationHealthRecord, JobMessage, @@ -992,6 +992,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; @@ -3566,35 +3580,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/db/repositories.ts b/src/db/repositories.ts index 6776329817..ad66eb4fff 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 326a4f028d..678cdc97f8 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/mcp/server.ts b/src/mcp/server.ts index 51389d5068..afcd3027e3 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"; @@ -458,6 +459,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", { @@ -877,6 +887,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))) { @@ -1204,7 +1230,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/src/openapi/schemas.ts b/src/openapi/schemas.ts index de425d36c1..cf1b557025 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -146,6 +146,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 76920ef8b1..90d84501eb 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); @@ -668,6 +672,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; + 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 0632156529..a1181cf4f6 100644 --- a/src/signals/engine.ts +++ b/src/signals/engine.ts @@ -4376,6 +4376,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 fb1d0a17a2..cff8fed238 100644 --- a/src/types.ts +++ b/src/types.ts @@ -959,6 +959,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/integration/api.test.ts b/test/integration/api.test.ts index 7eb4e80826..6b7ba06334 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 7d30557c4c..fbb848ab89 100644 --- a/test/integration/routes-errors.test.ts +++ b/test/integration/routes-errors.test.ts @@ -141,6 +141,30 @@ 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("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..f9c960c89d 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); + }); +}); diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index bd9bc65dcc..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"; @@ -157,6 +158,26 @@ 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 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; + 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); + }); }); // ── Public/private safety ───────────────────────────────────────────────────── diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index c3aa59ad3e..6e08cb228e 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -17,6 +17,17 @@ describe("MCP contributor access", () => { ); }); + 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(); + }); + 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" }); @@ -44,6 +55,38 @@ 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 }); + 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" }); diff --git a/test/unit/queue-federation.test.ts b/test/unit/queue-federation.test.ts new file mode 100644 index 0000000000..291657c97a --- /dev/null +++ b/test/unit/queue-federation.test.ts @@ -0,0 +1,327 @@ +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, upsertQueueFederationSnapshot, 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 +// --------------------------------------------------------------------------- + +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); + 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 () => { + 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()); + } + }); +}); + +// --------------------------------------------------------------------------- +// 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[]; 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 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 +// --------------------------------------------------------------------------- + +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/routes-self-dogfood-registration-pack.test.ts b/test/unit/routes-self-dogfood-registration-pack.test.ts index 096afdc51e..b735e0efa4 100644 --- a/test/unit/routes-self-dogfood-registration-pack.test.ts +++ b/test/unit/routes-self-dogfood-registration-pack.test.ts @@ -100,3 +100,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", + }); + }); +});