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 d6bd5f4ce0..ba549467e1 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -183,11 +183,12 @@ import { previousDecisionPackFromSnapshots, } from "../services/miner-dashboard-recommendations"; import { - buildStaticControlPanelRoleSummary, canLoginAccessRepo, + getRoleSummaryForIdentity, loadControlPanelAccessScope, loadControlPanelRoleSummary, } from "../services/control-panel-roles"; +import { PR_VISIBILITY_SKIP_REASONS, resolveSkippedPrAuditRepoScope, skippedPrAuditRemediation, toIsoQueryDate } from "../services/skipped-pr-audit"; import { runFindOpportunities, validateFindOpportunitiesInput, type FindOpportunitiesInput } from "../mcp/find-opportunities"; import { runIssueRagRetrieval, validateIssueRagInput, type IssueRagInput } from "../mcp/issue-rag"; import { @@ -278,7 +279,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 } from "../signals/settings-preview"; import { buildGittensorConfigRecommendation, buildRegistrationReadiness, @@ -418,15 +419,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), @@ -1503,11 +1495,11 @@ export function createApp() { const sinceIso = parsed.data.since ? toIsoQueryDate(parsed.data.since) : undefined; if (parsed.data.since && !sinceIso) return c.json({ error: "invalid_since" }, 400); const requestedRepo = parsed.data.repoFullName; - const repoFullNames = await skippedPrAuditRepoScope(c, identity, summary.roles, requestedRepo); - if (repoFullNames instanceof Response) return repoFullNames; + const scope = await resolveSkippedPrAuditRepoScope(c.env, identity, summary.roles, requestedRepo); + if (!scope.ok) return c.json({ error: "forbidden_repo" }, 403); const page = await listPrVisibilitySkipAuditEvents(c.env, { limit: clampInteger(parsed.data.limit ?? 50, 1, 100), - repoFullNames, + repoFullNames: scope.repoFullNames, reason: parsed.data.reason, sinceIso, }); @@ -5595,11 +5587,6 @@ async function authenticateRequestIdentity(c: ProtectedRouteContext): Promise { const identity = await authenticateRequestIdentity(c); if (!identity) return c.json({ error: "unauthorized" }, 401); @@ -5759,48 +5746,6 @@ async function requireRepoWriteAccess(c: ProtectedRouteContext, fullName: string return gate; } -async function skippedPrAuditRepoScope( - c: ProtectedRouteContext, - identity: AuthIdentity, - roles: ControlPanelRoleName[], - requestedRepo: string | undefined, -): Promise { - if (identity.kind !== "session" || roles.includes("operator")) return requestedRepo ? [requestedRepo] : undefined; - const scope = await loadControlPanelAccessScope(c.env, identity.actor); - const scopedRepoNames = new Set(scope.repositoryFullNames.map((name) => name.toLowerCase())); - if (requestedRepo) { - return scopedRepoNames.has(requestedRepo.toLowerCase()) ? [requestedRepo] : c.json({ error: "forbidden_repo" }, 403); - } - 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; -} - - // Optional Orb-ingest auth (#1285). FAIL-OPEN by default: with no ORB_INGEST_TOKEN configured the ingress stays // OPEN (matching today's live fleet — deploying this is non-breaking). Once the operator sets the token, the // collector REQUIRES an exact bearer match, so the write path can be locked down after the matching diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 162d91f479..559c85a386 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -29,7 +29,17 @@ import { isMcpReadUnscoped, type AuthIdentity, } from "../auth/security"; -import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; +import { + canLoginAccessRepo, + canWatchRepo, + getRoleSummaryForIdentity, + loadControlPanelAccessScope, + loadControlPanelRoleSummary, + type ControlPanelAccessScope, +} from "../services/control-panel-roles"; +import { PR_VISIBILITY_SKIP_REASONS, resolveSkippedPrAuditRepoScope, skippedPrAuditRemediation, toIsoQueryDate } from "../services/skipped-pr-audit"; +import type { PublicSurfaceSkipReason } from "../signals/settings-preview"; +import type { ControlPanelRoleSummary } from "../types"; import { countOpenIssues, countPendingAgentActions, @@ -59,6 +69,7 @@ import { listNotificationDeliveriesForRecipient, upsertIssueWatchSubscription, listOpenPullRequests, + listPrVisibilitySkipAuditEvents, listPullRequests, listRecentMergedPullRequests, listRepoSyncSegments, @@ -189,6 +200,16 @@ const ownerRepoShape = { repo: z.string().min(1), }; +// Unlike ownerRepoShape's mandatory single-repo tools, the skipped-PR audit mirrors GET /v1/app/skipped-pr-audit's +// own optional query params: repoFullName narrows to one repo, omitting it returns every repo the caller's role +// covers (#5825). +const skippedPrAuditShape = { + repoFullName: z.string().min(1).optional(), + reason: z.enum(PR_VISIBILITY_SKIP_REASONS).optional(), + since: z.string().min(1).optional(), + limit: z.number().int().optional(), +}; + const ownerRepoWindowShape = { owner: z.string().min(1), repo: z.string().min(1), @@ -815,6 +836,14 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +// #5825 - skipped-PR audit trail surfaced over MCP; items mirror GET /v1/app/skipped-pr-audit's response shape. +const skippedPrAuditOutputSchema = { + 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 +1708,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGatePrecision(input)), ); + server.registerTool( + "loopover_get_skipped_pr_audit", + { + description: + "Return the audit trail of pull requests the automated reviewer decided NOT to visibly review/comment on, with a reason code per skip. 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 +3015,62 @@ export class LoopoverMcp { }; } + // #5825 - gate for loopover_get_skipped_pr_audit: maintainer/owner/operator role, same check as + // GET /v1/app/skipped-pr-audit. Unlike that route's api/internal static tokens, the shared `mcp` static + // identity is NOT implicitly trusted with this role — LOOPOVER_MCP_TOKEN is an ordinary end-user CLI + // credential. A repoFullName-scoped call needs that repo in MCP_READ_REPO_ALLOWLIST (mirrors canAccessRepo); + // an unscoped (every-repo) call needs the full wildcard opt-in (mirrors requireDiscoveryAccess) since there + // is no single repo to check a scoped allowlist entry against. + private async requireSkippedPrAuditAccess(repoFullName: string | undefined): Promise { + if (this.identity.kind === "static" && this.identity.actor === "mcp") { + const allowed = repoFullName ? isMcpReadRepoAllowed(this.env.MCP_READ_REPO_ALLOWLIST, repoFullName) : isMcpReadUnscoped(this.env.MCP_READ_REPO_ALLOWLIST); + if (!allowed) throw new Error("Forbidden: this MCP token is not authorized for the skipped-PR audit."); + } + const roleSummary = await getRoleSummaryForIdentity(this.env, this.identity); + if (!roleSummary.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."); + } + return roleSummary; + } + + private async getSkippedPrAudit(input: { + repoFullName?: string | undefined; + reason?: PublicSurfaceSkipReason | undefined; + since?: string | undefined; + limit?: number | undefined; + }): Promise { + const roleSummary = await this.requireSkippedPrAuditAccess(input.repoFullName); + const sinceIso = input.since ? toIsoQueryDate(input.since) : undefined; + if (input.since && !sinceIso) throw new Error("Invalid since date."); + const scope = await resolveSkippedPrAuditRepoScope(this.env, this.identity, roleSummary.roles, input.repoFullName); + if (!scope.ok) throw new Error("Forbidden: cannot access the skipped-PR audit for the requested repository."); + const page = await listPrVisibilitySkipAuditEvents(this.env, { + limit: input.limit, + repoFullNames: scope.repoFullNames, + reason: input.reason, + sinceIso, + }); + return { + summary: `LoopOver skipped-PR audit${input.repoFullName ? ` for ${input.repoFullName}` : ""}: ${page.items.length} event(s)${page.hasMore ? " (more available)" : ""}.`, + data: { + 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/services/control-panel-roles.ts b/src/services/control-panel-roles.ts index 323122c036..ba62b9cb95 100644 --- a/src/services/control-panel-roles.ts +++ b/src/services/control-panel-roles.ts @@ -1,4 +1,4 @@ -import { isAuthorizedGitHubSessionLogin } from "../auth/security"; +import { isAuthorizedGitHubSessionLogin, type AuthIdentity } from "../auth/security"; import { getFreshOfficialMinerDetection, getRepository, listAllPullRequests, listInstallations, listRepositories } from "../db/repositories"; import type { ControlPanelRoleCard, ControlPanelRoleName, ControlPanelRoleSummary, InstallationRecord, PullRequestRecord, RepositoryRecord } from "../types"; import { nowIso } from "../utils/json"; @@ -53,6 +53,15 @@ export async function canWatchRepo(env: Env, login: string, fullName: string): P return canLoginAccessRepo(env, login, fullName); } +/** Resolve a role summary for any authenticated identity: a session gets its real per-login roles, a static + * (api/internal/mcp) identity gets the trusted-service-credential summary. Shared by the HTTP maintainer + * routes and MCP tools that gate on role (e.g. #5825's skipped-PR audit) so both surfaces agree on who + * counts as maintainer/owner/operator. */ +export async function getRoleSummaryForIdentity(env: Env, identity: AuthIdentity): Promise { + if (identity.kind === "session") return loadControlPanelRoleSummary(env, identity.actor); + return buildStaticControlPanelRoleSummary(identity.actor); +} + export async function loadControlPanelRoleSummary(env: Env, login: string): Promise { const [miner, repositories, installations, pullRequests] = await Promise.all([ getFreshOfficialMinerDetection(env, login).catch(() => null), diff --git a/src/services/skipped-pr-audit.ts b/src/services/skipped-pr-audit.ts new file mode 100644 index 0000000000..6b21ddf7ae --- /dev/null +++ b/src/services/skipped-pr-audit.ts @@ -0,0 +1,62 @@ +import type { AuthIdentity } from "../auth/security"; +import type { ControlPanelRoleName } from "../types"; +import type { PublicSurfaceSkipReason } from "../signals/settings-preview"; +import { loadControlPanelAccessScope } from "./control-panel-roles"; + +export 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[]; + +export type SkippedPrAuditRepoScope = { ok: true; repoFullNames: string[] | undefined } | { ok: false }; + +/** Repo-scope resolution for the skipped-PR audit trail, shared by GET /v1/app/skipped-pr-audit and the + * loopover_get_skipped_pr_audit MCP tool so the two surfaces cannot drift on who gets to see which repos' + * skip events (#5825). An operator (or any non-session identity, which the caller must have already + * authorized separately) sees every repo unless a specific one is requested; a maintainer/owner session is + * scoped to the repos their control-panel access already covers. */ +export async function resolveSkippedPrAuditRepoScope( + env: Env, + identity: AuthIdentity, + roles: ControlPanelRoleName[], + requestedRepo: string | undefined, +): Promise { + if (identity.kind !== "session" || roles.includes("operator")) return { ok: true, repoFullNames: requestedRepo ? [requestedRepo] : undefined }; + const scope = await loadControlPanelAccessScope(env, identity.actor); + const scopedRepoNames = new Set(scope.repositoryFullNames.map((name) => name.toLowerCase())); + if (requestedRepo) { + return scopedRepoNames.has(requestedRepo.toLowerCase()) ? { ok: true, repoFullNames: [requestedRepo] } : { ok: false }; + } + return { ok: true, repoFullNames: scope.repositoryFullNames }; +} + +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."; + } +} + +export function toIsoQueryDate(value: string): string | undefined { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : undefined; +} diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 014722f4a8..548ed126b2 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -67,6 +67,16 @@ describe("loopover-mcp CLI — basics", () => { expect(plain).toMatch(/do not.*publish public output/i); }); + // #5825 - the skipped-PR audit tool belongs on the maintainer-triage profile: it's the "did the bot + // actually look at this PR" complement to the queue-triage/review-prep tools already recommended there. + it("recommends the skipped-PR audit tool on the maintainer-triage profile", () => { + const payload = JSON.parse(run(["init-client", "--print", "codex", "--agent-profile", "maintainer-triage", "--json"])) as { + agentProfile: { id: string; recommendedTools: string[] }; + }; + expect(payload.agentProfile.id).toBe("maintainer-triage"); + expect(payload.agentProfile.recommendedTools).toEqual(expect.arrayContaining(["loopover_get_skipped_pr_audit"])); + }); + it("supports all documented agent profiles without changing MCP server config", () => { for (const profile of ["miner-planner", "maintainer-triage", "repo-owner-intake"]) { const payload = JSON.parse(run(["init-client", "--print", "mcp", "--agent-profile", profile, "--json"])) as { 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..7a7b334f98 --- /dev/null +++ b/test/unit/mcp-skipped-pr-audit.test.ts @@ -0,0 +1,175 @@ +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: "gittensory-skipped-pr-audit-test", version: "0.1.0" }, { capabilities: {} }); + await client.connect(clientTransport); + return client; +} + +async function seedSkipEvents(env: Env): Promise { + await upsertInstallation(env, { + installation: { + id: 101, + account: { login: "repo-owner", id: 101, type: "User" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["pull_request", "repository"], + }, + }); + await upsertRepositoryFromGitHub(env, { name: "owned-repo", full_name: "repo-owner/owned-repo", private: false, default_branch: "main", owner: { login: "repo-owner" } }, 101); + await upsertInstallation(env, { + installation: { + id: 202, + account: { login: "victim-org", id: 202, type: "Organization" }, + repository_selection: "selected", + permissions: { metadata: "read", pull_requests: "read", issues: "write" }, + events: ["pull_request", "repository"], + }, + }); + await upsertRepositoryFromGitHub(env, { name: "secret-repo", full_name: "victim-org/secret-repo", private: true, default_branch: "main", owner: { login: "victim-org" } }, 202); + + await recordAuditEvent(env, { eventType: "github_app.pr_visibility_skipped", targetKey: "repo-owner/owned-repo#1", outcome: "completed", detail: "surface_off", createdAt: "2026-05-28T00:00:01.000Z" }); + await recordAuditEvent(env, { eventType: "github_app.pr_visibility_skipped", targetKey: "repo-owner/owned-repo#2", outcome: "completed", detail: "bot_author", createdAt: "2026-05-28T00:00:02.000Z" }); + await recordAuditEvent(env, { eventType: "github_app.pr_visibility_skipped", targetKey: "victim-org/secret-repo#3", outcome: "completed", detail: "maintainer_author", createdAt: "2026-05-28T00:00:03.000Z" }); +} + +describe("MCP loopover_get_skipped_pr_audit (#5825)", () => { + it("returns the unscoped audit trail for a trusted (static, wildcard-allowlisted) identity with no filters", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + 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 { + limit: number; + hasMore: boolean; + filters: { repoFullName: string | null; reason: string | null; since: string | null }; + items: Array<{ repoFullName: string; pullNumber: number; reason: string; remediation: string }>; + }; + expect(data.limit).toBe(50); + expect(data.hasMore).toBe(false); + expect(data.filters).toEqual({ repoFullName: null, reason: null, since: null }); + expect(data.items).toHaveLength(3); + expect(data.items[0]).toMatchObject({ repoFullName: "victim-org/secret-repo", pullNumber: 3, reason: "maintainer_author" }); + expect(data.items[0]?.remediation).toContain("maintainer-authored"); + }); + + it("filters by repoFullName", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + 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 { filters: { repoFullName: string | null }; items: Array<{ repoFullName: string }> }; + expect(data.filters.repoFullName).toBe("repo-owner/owned-repo"); + expect(data.items).toHaveLength(2); + expect(data.items.every((item) => item.repoFullName === "repo-owner/owned-repo")).toBe(true); + }); + + it("filters by reason", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { reason: "bot_author" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { filters: { reason: string | null }; items: Array<{ reason: string; pullNumber: number }> }; + expect(data.filters.reason).toBe("bot_author"); + expect(data.items).toEqual([expect.objectContaining({ reason: "bot_author", pullNumber: 2 })]); + }); + + it("filters by since and rejects an unparseable since value", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { since: "2026-05-28T00:00:02.500Z" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { filters: { since: string | null }; items: Array<{ pullNumber: number }> }; + expect(data.filters.since).toBe("2026-05-28T00:00:02.500Z"); + expect(data.items.map((item) => item.pullNumber)).toEqual([3]); + + const invalid = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { since: "not-a-date" } }); + expect(invalid.isError).toBeTruthy(); + expect(JSON.stringify(invalid.content)).toMatch(/invalid since/i); + }); + + it("clamps limit to the route's 1-100 bounds", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + const client = await connect(env); + const tooLow = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { limit: 0 } }); + expect(tooLow.isError).toBeFalsy(); + expect((tooLow.structuredContent as { limit: number }).limit).toBe(1); + const tooHigh = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { limit: 500 } }); + expect(tooHigh.isError).toBeFalsy(); + expect((tooHigh.structuredContent as { limit: number }).limit).toBe(100); + }); + + it("returns an empty result when nothing matches", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + const client = await connect(env); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { reason: "ignored_author" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { items: unknown[]; hasMore: boolean }; + expect(data.items).toEqual([]); + expect(data.hasMore).toBe(false); + }); + + it("forbids a session with no maintainer/owner/operator role", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + const { session } = await createSessionForGitHubUser(env, { login: "rando", id: 999 }); + const client = await connect(env, { kind: "session", actor: "rando", 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/i); + }); + + it("scopes a maintainer session to its own repos and forbids an out-of-scope repoFullName", async () => { + const env = createTestEnv(); + await seedSkipEvents(env); + const { session } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 101 }); + const client = await connect(env, { kind: "session", actor: "repo-owner", session }); + + const scoped = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: {} }); + expect(scoped.isError).toBeFalsy(); + const scopedData = scoped.structuredContent as { items: Array<{ repoFullName: string }> }; + expect(scopedData.items).toHaveLength(2); + expect(scopedData.items.every((item) => item.repoFullName === "repo-owner/owned-repo")).toBe(true); + + const forbidden = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { repoFullName: "victim-org/secret-repo" } }); + expect(forbidden.isError).toBeTruthy(); + expect(JSON.stringify(forbidden.content)).toMatch(/cannot access the skipped-PR audit/i); + }); + + it("scopes the static mcp identity to MCP_READ_REPO_ALLOWLIST for a repo-scoped request", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "repo-owner/owned-repo" }); + await seedSkipEvents(env); + const client = await connect(env); + + const allowed = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { repoFullName: "repo-owner/owned-repo" } }); + expect(allowed.isError).toBeFalsy(); + + const denied = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: { repoFullName: "victim-org/secret-repo" } }); + expect(denied.isError).toBeTruthy(); + expect(JSON.stringify(denied.content)).toMatch(/not authorized for the skipped-PR audit/i); + }); + + it("forbids the static mcp identity from an unscoped (all-repos) request without the wildcard opt-in", async () => { + const env = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "repo-owner/owned-repo" }); + await seedSkipEvents(env); + 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); + }); +});