From 6e059b0b0cff94cb0ceb7c643ceecf739509378a Mon Sep 17 00:00:00 2001 From: nghetienhiep <13849419+nghetienhiep@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:12:22 +0000 Subject: [PATCH] feat(mcp): expose skipped-PR audit trail as a maintainer MCP tool Add loopover_get_skipped_pr_audit, mirroring GET /v1/app/skipped-pr-audit's maintainer-authenticated, repo-scoped, filterable read of PRs the automated reviewer intentionally skipped. Extract the shared reason enum and remediation text out of routes.ts into signals/settings-preview.ts so the route and the new tool stay in lockstep instead of duplicating the list. Closes #5825 --- packages/loopover-mcp/bin/loopover-mcp.js | 2 +- src/api/routes.ts | 34 +-- src/mcp/server.ts | 96 ++++++++ src/signals/settings-preview.ts | 38 ++++ test/unit/mcp-output-schemas.test.ts | 1 + test/unit/mcp-skipped-pr-audit.test.ts | 262 ++++++++++++++++++++++ 6 files changed, 400 insertions(+), 33 deletions(-) create mode 100644 test/unit/mcp-skipped-pr-audit.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index bce2e533e5..b3dff47e37 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -123,7 +123,7 @@ const AGENT_PROFILES = { audience: "maintainers preparing low-noise queue and PR review context", purpose: "Summarize queue risk, prepare review notes, and draft public guidance for human review.", recommendedPrompts: ["loopover_maintainer_queue_triage", "loopover_maintainer_review_prep", "loopover_maintainer_public_guidance"], - recommendedTools: ["loopover_get_repo_context", "loopover_get_burden_forecast", "loopover_preflight_pr"], + recommendedTools: ["loopover_get_repo_context", "loopover_get_burden_forecast", "loopover_preflight_pr", "loopover_get_skipped_pr_audit"], boundaries: [ "Human-approved only: prepare summaries and draft guidance; do not post comments, label, close, merge, or edit contributor work.", "Keep private review context, raw trust context, and authenticated-only evidence out of public snippets.", diff --git a/src/api/routes.ts b/src/api/routes.ts index b694d647b3..88d2ab6401 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -278,7 +278,7 @@ import { resolveRepositorySettings } from "../settings/repository-settings"; import { loadPublicRepoFocusManifest, loadRepoFocusManifest, upsertRepoFocusManifest } from "../signals/focus-manifest-loader"; import { buildRepoOnboardingPackPreviewForRepo } from "../services/repo-onboarding-pack"; import { generateContributorIssueDrafts } from "../services/contributor-issue-draft"; -import { buildRepoSettingsPreview, type PublicSurfaceSkipReason } from "../signals/settings-preview"; +import { buildRepoSettingsPreview, PUBLIC_SURFACE_SKIP_REASONS, skippedPrAuditRemediation } from "../signals/settings-preview"; import { buildGittensorConfigRecommendation, buildRegistrationReadiness, @@ -418,15 +418,6 @@ async function readRequestBodyWithLimit(request: Request, maxBytes: number): Pro const MAX_LOCAL_BRANCH_REF_CHARS = 256; const MAX_LOCAL_BRANCH_TEXT_CHARS = 4000; -const PR_VISIBILITY_SKIP_REASONS = [ - "surface_off", - "missing_author", - "bot_author", - "ignored_author", - "maintainer_author", - "miner_detection_unavailable", - "not_official_gittensor_miner", -] as const satisfies readonly PublicSurfaceSkipReason[]; const preflightSchema = z.object({ repoFullName: z.string().min(3).max(PREFLIGHT_LIMITS.repoFullNameChars), @@ -504,7 +495,7 @@ const skippedPrAuditQuerySchema = z .object({ limit: z.coerce.number().int().optional(), repoFullName: z.string().trim().min(3).max(200).optional(), - reason: z.enum(PR_VISIBILITY_SKIP_REASONS).optional(), + reason: z.enum(PUBLIC_SURFACE_SKIP_REASONS).optional(), since: z.string().trim().min(1).max(64).optional(), }) .strict(); @@ -5760,27 +5751,6 @@ async function skippedPrAuditRepoScope( return scope.repositoryFullNames; } -function skippedPrAuditRemediation(reason: string): string { - switch (reason) { - case "surface_off": - return "Enable a PR public surface or check runs in repository settings if maintainers want LoopOver to post."; - case "missing_author": - return "Retry after GitHub provides a resolvable pull request author."; - case "bot_author": - return "No action needed; bot-authored pull requests are intentionally kept quiet."; - case "ignored_author": - return "No action needed; the repository manifest explicitly skips review output for this author."; - case "maintainer_author": - return "Enable maintainer-authored PRs in repository settings only if those PRs should receive public GitHub App output."; - case "miner_detection_unavailable": - return "Retry after official Gittensor miner detection recovers; LoopOver skips instead of guessing."; - case "not_official_gittensor_miner": - return "No public action is needed unless the author should be recognized as an official Gittensor miner."; - default: - return "Review repository settings and installation health before reprocessing the pull request."; - } -} - function toIsoQueryDate(value: string): string | undefined { const timestamp = Date.parse(value); return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ae6d3d2c64..399dd7f628 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -48,6 +48,7 @@ import { getRepoQueueTrendSnapshot, listAgentAuditEvents, listCheckSummaries, + listPrVisibilitySkipAuditEvents, listPendingAgentActions, listContributorRepoStats, listContributorIssues, @@ -70,6 +71,7 @@ import { recordProductUsageEvent, } from "../db/repositories"; import { decidePendingAgentAction } from "../services/agent-approval-queue"; +import { nowIso } from "../utils/json"; import { buildNotificationFeed } from "../notifications/service"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { getRepositoryCollaboratorPermission } from "../github/app"; @@ -130,6 +132,7 @@ import { buildRegistryChangeReport, buildRoleContext, } from "../signals/engine"; +import { PUBLIC_SURFACE_SKIP_REASONS, skippedPrAuditRemediation, type PublicSurfaceSkipReason } from "../signals/settings-preview"; import { buildContributorOpenPrMonitor } from "../signals/contributor-open-pr-monitor"; import { buildLocalBranchAnalysis, findCurrentBranchPullRequest } from "../signals/local-branch"; import { computeLocalScorerTokens } from "../signals/local-scorer"; @@ -815,6 +818,25 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +// #5825 - maintainer-authenticated skipped-PR audit trail, mirroring GET /v1/app/skipped-pr-audit's +// filters (all optional: a bare call returns the caller's own repo-scoped feed). No owner/repo shape +// here on purpose: unlike ownerRepoShape tools this report can legitimately span every repo the caller +// is scoped to, so repoFullName narrows rather than requires. +const skippedPrAuditShape = { + repoFullName: z.string().trim().min(1).max(200).optional(), + reason: z.enum(PUBLIC_SURFACE_SKIP_REASONS).optional(), + since: z.string().trim().min(1).max(64).optional(), + limit: z.number().int().positive().optional(), +}; + +const skippedPrAuditOutputSchema = { + generatedAt: z.string().optional(), + limit: z.number().optional(), + hasMore: z.boolean().optional(), + filters: z.unknown().optional(), + items: z.array(z.unknown()).optional(), +}; + const contributorProfileOutputSchema = { login: z.string().optional(), github: z.unknown().optional(), @@ -1679,6 +1701,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGatePrecision(input)), ); + server.registerTool( + "loopover_get_skipped_pr_audit", + { + description: + "Return the skipped-PR audit trail: pull requests LoopOver's automated reviewer intentionally stayed quiet on, each with a reason code and a remediation hint. Optionally filter by repoFullName, reason, or since. Maintainer-authenticated; read-only measurement, not a moderation or override action.", + inputSchema: skippedPrAuditShape, + outputSchema: skippedPrAuditOutputSchema, + }, + async (input) => this.toolResult(await this.getSkippedPrAudit(input)), + ); + server.registerTool( "loopover_get_fleet_analytics", { @@ -2975,6 +3008,69 @@ export class LoopoverMcp { }; } + // #5825 - repo-scope resolution for the skipped-PR audit tool. Mirrors skippedPrAuditRepoScope in + // src/api/routes.ts (same underlying loadControlPanelRoleSummary/loadControlPanelAccessScope calls, + // same maintainer/owner/operator role gate, same "no filter -> caller's own scoped repos" fallback), + // adapted to this file's MCP identity/throw conventions since that route helper is bound to a Hono + // ProtectedRouteContext and returns a Response, neither of which fits an MCP tool method. The shared + // static `mcp` CLI token is NOT trusted implicitly for this cross-repo maintainer report (unlike the + // route's own static identities, which are operator-only Worker secrets) -- it must opt in via the + // unscoped MCP_READ_REPO_ALLOWLIST wildcard, matching requireOperatorAccess/requireDiscoveryAccess above. + private async requireSkippedPrAuditAccess(requestedRepo: string | undefined): Promise { + if (this.identity.kind === "session") { + const [summary, scope] = await Promise.all([loadControlPanelRoleSummary(this.env, this.identity.actor), this.loadSessionAccessScope()]); + if (!summary.roles.some((role) => role === "maintainer" || role === "owner" || role === "operator")) { + throw new Error("Forbidden: maintainer, owner, or operator role is required for the skipped-PR audit."); + } + if (scope.operator) return requestedRepo ? [requestedRepo] : undefined; + if (!requestedRepo) return scope.repositoryFullNames; + if (!scope.repositoryFullNames.some((name) => name.toLowerCase() === requestedRepo.toLowerCase())) { + throw new Error("Forbidden: session cannot access this repository's skipped-PR audit."); + } + return [requestedRepo]; + } + if (this.identity.kind === "static" && this.identity.actor === "mcp" && !isMcpReadUnscoped(this.env.MCP_READ_REPO_ALLOWLIST)) { + throw new Error("Forbidden: this MCP token is not authorized for the skipped-PR audit."); + } + return requestedRepo ? [requestedRepo] : undefined; + } + + private async getSkippedPrAudit(input: { + repoFullName?: string | undefined; + reason?: PublicSurfaceSkipReason | undefined; + since?: string | undefined; + limit?: number | undefined; + }): Promise { + const repoFullNames = await this.requireSkippedPrAuditAccess(input.repoFullName); + let sinceIso: string | undefined; + if (input.since !== undefined) { + const timestamp = Date.parse(input.since); + if (!Number.isFinite(timestamp)) throw new Error(`Invalid since: "${input.since}" is not a parseable date.`); + sinceIso = new Date(timestamp).toISOString(); + } + const page = await listPrVisibilitySkipAuditEvents(this.env, { limit: input.limit, repoFullNames, reason: input.reason, sinceIso }); + return { + summary: `LoopOver skipped-PR audit: ${page.items.length} event(s) (limit ${page.limit}${page.hasMore ? ", more available" : ""}).`, + data: { + generatedAt: nowIso(), + limit: page.limit, + hasMore: page.hasMore, + filters: { + repoFullName: input.repoFullName ?? null, + reason: input.reason ?? null, + since: sinceIso ?? null, + }, + items: page.items.map((item) => ({ + repoFullName: item.repoFullName, + pullNumber: item.pullNumber, + reason: item.reason, + timestamp: item.createdAt, + remediation: skippedPrAuditRemediation(item.reason), + })), + }, + }; + } + // #2224 - surface the deterministic open-PR pressure simulator over MCP. Pure and read-only: the caller // supplies all queue/role context, so nothing beyond a computation on that input is revealed and no repo // access is required (mirrors loopover_run_local_scorer). Output is already public-safe - every scenario diff --git a/src/signals/settings-preview.ts b/src/signals/settings-preview.ts index 08c04c84a6..a90cabbfd1 100644 --- a/src/signals/settings-preview.ts +++ b/src/signals/settings-preview.ts @@ -48,6 +48,19 @@ export type PublicSurfaceSkipReason = | "miner_detection_unavailable" | "not_official_gittensor_miner"; +// Canonical reason list, shared by the /v1/app/skipped-pr-audit route's query-param enum and the +// loopover_get_skipped_pr_audit MCP tool's input enum, so both surfaces stay in lockstep with +// PublicSurfaceSkipReason instead of maintaining their own copy of this literal list. +export const PUBLIC_SURFACE_SKIP_REASONS = [ + "surface_off", + "missing_author", + "bot_author", + "ignored_author", + "maintainer_author", + "miner_detection_unavailable", + "not_official_gittensor_miner", +] as const satisfies readonly PublicSurfaceSkipReason[]; + export type PublicSurfaceAction = "skip" | "comment" | "label" | "check_run" | "none"; export type PublicSurfaceDecisionInput = { @@ -83,6 +96,31 @@ function skipDecision(reason: PublicSurfaceSkipReason): PublicSurfaceDecision { return { willComment: false, willLabel: false, willCheckRun: false, skipped: true, skipReason: reason, actions: ["skip"], summary: SKIP_SUMMARY[reason] }; } +// Maintainer-facing remediation hint for a skipped-PR audit event's reason code. Shared by the +// /v1/app/skipped-pr-audit route and the loopover_get_skipped_pr_audit MCP tool. Takes a plain string +// (not PublicSurfaceSkipReason) because audit rows can carry historic/legacy reason values recorded +// before the current reason set existed; those fall through to the generic default. +export function skippedPrAuditRemediation(reason: string): string { + switch (reason) { + case "surface_off": + return "Enable a PR public surface or check runs in repository settings if maintainers want LoopOver to post."; + case "missing_author": + return "Retry after GitHub provides a resolvable pull request author."; + case "bot_author": + return "No action needed; bot-authored pull requests are intentionally kept quiet."; + case "ignored_author": + return "No action needed; the repository manifest explicitly skips review output for this author."; + case "maintainer_author": + return "Enable maintainer-authored PRs in repository settings only if those PRs should receive public GitHub App output."; + case "miner_detection_unavailable": + return "Retry after official Gittensor miner detection recovers; LoopOver skips instead of guessing."; + case "not_official_gittensor_miner": + return "No public action is needed unless the author should be recognized as an official Gittensor miner."; + default: + return "Review repository settings and installation health before reprocessing the pull request."; + } +} + /** * Pure decision for what the GitHub App's public surface would do for a PR. * This is the single source of truth shared by the live webhook processor and the diff --git a/test/unit/mcp-output-schemas.test.ts b/test/unit/mcp-output-schemas.test.ts index 54fb3c7356..b31ed6f56d 100644 --- a/test/unit/mcp-output-schemas.test.ts +++ b/test/unit/mcp-output-schemas.test.ts @@ -38,6 +38,7 @@ const TOOLS_WITH_OUTPUT_SCHEMA = [ "loopover_get_eligibility_plan", "loopover_simulate_open_pr_pressure", "loopover_get_gate_precision", + "loopover_get_skipped_pr_audit", ]; async function connectTestClient(env: Env = createTestEnv(), identity?: AuthIdentity) { diff --git a/test/unit/mcp-skipped-pr-audit.test.ts b/test/unit/mcp-skipped-pr-audit.test.ts new file mode 100644 index 0000000000..bc111319f1 --- /dev/null +++ b/test/unit/mcp-skipped-pr-audit.test.ts @@ -0,0 +1,262 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import { createSessionForGitHubUser, type AuthIdentity } from "../../src/auth/security"; +import { recordAuditEvent, upsertInstallation, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { LoopoverMcp } from "../../src/mcp/server"; +import { createTestEnv } from "../helpers/d1"; + +async function connect(env: Env, identity?: AuthIdentity): Promise { + const server = (identity ? new LoopoverMcp(env, identity) : new LoopoverMcp(env)).createServer(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + const client = new Client({ name: "skipped-pr-audit-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +async function seedOwnedRepo(env: Env, installationId: number, owner: string, name: string): Promise { + await upsertInstallation(env, { + installation: { + id: installationId, + account: { login: owner, id: installationId, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["pull_request", "repository"], + }, + }); + await upsertRepositoryFromGitHub(env, { name, full_name: `${owner}/${name}`, private: false, default_branch: "main", owner: { login: owner } }, installationId); +} + +type SkippedPrAuditData = { + generatedAt: string; + limit: number; + hasMore: boolean; + filters: { repoFullName: string | null; reason: string | null; since: string | null }; + items: Array<{ repoFullName: string; pullNumber: number; reason: string; timestamp: string; remediation: string }>; +}; + +describe("MCP loopover_get_skipped_pr_audit (#5825)", () => { + it("returns the default no-filter feed scoped to the caller's own repos", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + await seedOwnedRepo(env, 202, "victim-org", "secret-repo"); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "bot-secret", + targetKey: "repo-owner/owned-repo#4", + outcome: "completed", + detail: "bot_author", + metadata: { token: "github_pat_should_not_export" }, + createdAt: "2026-05-28T00:00:02.000Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "victim-secret", + targetKey: "victim-org/secret-repo#7", + outcome: "completed", + detail: "maintainer_author", + metadata: {}, + createdAt: "2026-05-28T00:00:05.000Z", + }); + + const { session } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 101 }); + const client = await connect(env, { kind: "session", actor: "repo-owner", session }); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as SkippedPrAuditData; + expect(data.items).toEqual([expect.objectContaining({ repoFullName: "repo-owner/owned-repo", pullNumber: 4, reason: "bot_author" })]); + expect(data.items[0]?.remediation).toContain("intentionally kept quiet"); + expect(data.limit).toBe(50); + expect(data.hasMore).toBe(false); + expect(data.filters).toEqual({ repoFullName: null, reason: null, since: null }); + expect(JSON.stringify(result.content)).not.toContain("github_pat_should_not_export"); + }); + + it("filters by repoFullName, reason, and since independently", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "a", + targetKey: "repo-owner/owned-repo#1", + outcome: "completed", + detail: "surface_off", + metadata: {}, + createdAt: "2026-05-28T00:00:01.000Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "b", + targetKey: "repo-owner/owned-repo#2", + outcome: "completed", + detail: "missing_author", + metadata: {}, + createdAt: "2026-05-28T00:00:02.000Z", + }); + + const { session } = await createSessionForGitHubUser(env, { login: "operator", id: 999 }); + const client = await connect(env, { kind: "session", actor: "operator", session }); + + const byRepo = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { repoFullName: "repo-owner/owned-repo" } }); + expect((byRepo.structuredContent as SkippedPrAuditData).items).toHaveLength(2); + + const byReason = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { reason: "missing_author" } }); + const byReasonData = byReason.structuredContent as SkippedPrAuditData; + expect(byReasonData.items).toEqual([expect.objectContaining({ reason: "missing_author", pullNumber: 2 })]); + expect(byReasonData.filters.reason).toBe("missing_author"); + + const bySince = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { since: "2026-05-28T00:00:01.500Z" } }); + const bySinceData = bySince.structuredContent as SkippedPrAuditData; + expect(bySinceData.items).toEqual([expect.objectContaining({ pullNumber: 2 })]); + expect(bySinceData.filters.since).toBe("2026-05-28T00:00:01.500Z"); + }); + + it("clamps limit to the [1, 100] range and reports hasMore across the boundary", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + for (let n = 1; n <= 3; n += 1) { + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: `actor-${n}`, + targetKey: `repo-owner/owned-repo#${n}`, + outcome: "completed", + detail: "surface_off", + metadata: {}, + createdAt: `2026-05-28T00:00:0${n}.000Z`, + }); + } + const { session } = await createSessionForGitHubUser(env, { login: "operator", id: 999 }); + const client = await connect(env, { kind: "session", actor: "operator", session }); + + const overLimit = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { limit: 500 } }); + const overLimitData = overLimit.structuredContent as SkippedPrAuditData; + expect(overLimitData.limit).toBe(100); + expect(overLimitData.hasMore).toBe(false); + + const underLimit = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { limit: 1 } }); + const underLimitData = underLimit.structuredContent as SkippedPrAuditData; + expect(underLimitData.limit).toBe(1); + expect(underLimitData.hasMore).toBe(true); + expect(underLimitData.items).toHaveLength(1); + }); + + it("returns an empty page for a scoped repo with no skipped-PR events", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + const { session } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 101 }); + const client = await connect(env, { kind: "session", actor: "repo-owner", session }); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as SkippedPrAuditData; + expect(data.items).toEqual([]); + expect(data.hasMore).toBe(false); + }); + + it("forbids a session with no maintainer/owner/operator role", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + const { session } = await createSessionForGitHubUser(env, { login: "unknown-user", id: 404 }); + const client = await connect(env, { kind: "session", actor: "unknown-user", session }); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: {} }); + expect(result.isError).toBeTruthy(); + expect(JSON.stringify(result.content)).toMatch(/maintainer, owner, or operator role is required/i); + }); + + it("allows a non-operator owner session to explicitly request its own scoped repo by name", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "a", + targetKey: "repo-owner/owned-repo#3", + outcome: "completed", + detail: "surface_off", + metadata: {}, + createdAt: "2026-05-28T00:00:03.000Z", + }); + const { session } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 101 }); + const client = await connect(env, { kind: "session", actor: "repo-owner", session }); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { repoFullName: "repo-owner/owned-repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as SkippedPrAuditData; + expect(data.items).toEqual([expect.objectContaining({ repoFullName: "repo-owner/owned-repo", pullNumber: 3 })]); + expect(data.filters.repoFullName).toBe("repo-owner/owned-repo"); + }); + + it("forbids a maintainer session from requesting a repo outside its scope", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + await seedOwnedRepo(env, 202, "victim-org", "secret-repo"); + const { session } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 101 }); + const client = await connect(env, { kind: "session", actor: "repo-owner", session }); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { repoFullName: "victim-org/secret-repo" } }); + expect(result.isError).toBeTruthy(); + expect(JSON.stringify(result.content)).toMatch(/cannot access this repository's skipped-pr audit/i); + }); + + it("rejects an unparseable since value", async () => { + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator" }); + const { session } = await createSessionForGitHubUser(env, { login: "operator", id: 999 }); + const client = await connect(env, { kind: "session", actor: "operator", session }); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { since: "not-a-date" } }); + expect(result.isError).toBeTruthy(); + expect(JSON.stringify(result.content)).toMatch(/not a parseable date/i); + }); + + it("forbids the static mcp identity without the unscoped MCP_READ_REPO_ALLOWLIST opt-in", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: {} }); + expect(result.isError).toBeTruthy(); + expect(JSON.stringify(result.content)).toMatch(/not authorized for the skipped-pr audit/i); + }); + + it("allows the static mcp identity once MCP_READ_REPO_ALLOWLIST is unscoped", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "*" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "a", + targetKey: "repo-owner/owned-repo#9", + outcome: "completed", + detail: "not_official_gittensor_miner", + metadata: {}, + createdAt: "2026-05-28T00:00:09.000Z", + }); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as SkippedPrAuditData; + expect(data.items).toEqual([expect.objectContaining({ pullNumber: 9, reason: "not_official_gittensor_miner" })]); + }); + + it("scopes the static mcp identity to an explicit repoFullName filter", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "*" }); + await seedOwnedRepo(env, 101, "repo-owner", "owned-repo"); + await seedOwnedRepo(env, 202, "victim-org", "secret-repo"); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "a", + targetKey: "repo-owner/owned-repo#9", + outcome: "completed", + detail: "not_official_gittensor_miner", + metadata: {}, + createdAt: "2026-05-28T00:00:09.000Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "b", + targetKey: "victim-org/secret-repo#10", + outcome: "completed", + detail: "surface_off", + metadata: {}, + createdAt: "2026-05-28T00:00:10.000Z", + }); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { repoFullName: "repo-owner/owned-repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as SkippedPrAuditData; + expect(data.items).toEqual([expect.objectContaining({ repoFullName: "repo-owner/owned-repo", pullNumber: 9 })]); + }); +});