diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aca2e23366..32c221a168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -548,6 +548,7 @@ jobs: # covers it. - name: Upload coverage to Codecov (fork PR tokenless) if: ${{ success() && github.event.pull_request.head.repo.fork == true }} + continue-on-error: true uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./coverage/lcov.info @@ -562,6 +563,7 @@ jobs: # after the tests and hard coverage upload have already passed. - name: Upload Vitest results to Codecov if: ${{ !cancelled() && github.event.pull_request.head.repo.fork != true }} + continue-on-error: true uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -575,6 +577,7 @@ jobs: fail_ci_if_error: false - name: Upload Vitest results to Codecov (fork PR tokenless) if: ${{ !cancelled() && github.event.pull_request.head.repo.fork == true }} + continue-on-error: true uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: ./reports/junit/vitest.xml diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index bce2e533e5..79e5d3c3f3 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_get_skipped_pr_audit", "loopover_preflight_pr"], 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..928b231913 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -188,6 +188,7 @@ import { loadControlPanelAccessScope, loadControlPanelRoleSummary, } from "../services/control-panel-roles"; +import { skippedPrAuditQuerySchema, skippedPrAuditRemediation, skippedPrAuditRepoScope, 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,16 +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), contributorLogin: z.string().min(1).max(PREFLIGHT_LIMITS.contributorLoginChars).optional(), @@ -500,15 +491,6 @@ const selfhostDeadLetterQueueQuerySchema = z }) .strict(); -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(), - since: z.string().trim().min(1).max(64).optional(), - }) - .strict(); - const localBranchChangedFileSchema = z .object({ path: z.string().min(1).max(MAX_LOCAL_BRANCH_REF_CHARS), @@ -1503,11 +1485,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 skippedPrAuditRepoScope(c.env, identity, summary.roles, requestedRepo); + if (!scope.ok) return c.json({ error: scope.code }, 403); const page = await listPrVisibilitySkipAuditEvents(c.env, { limit: clampInteger(parsed.data.limit ?? 50, 1, 100), - repoFullNames, + repoFullNames: scope.repoFullNames, reason: parsed.data.reason, sinceIso, }); @@ -5759,48 +5741,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..82b50d9f76 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -29,7 +29,8 @@ import { isMcpReadUnscoped, type AuthIdentity, } from "../auth/security"; -import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; +import { buildStaticControlPanelRoleSummary, canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles"; +import { PR_VISIBILITY_SKIP_REASONS, skippedPrAuditRemediation, skippedPrAuditRepoScope, toIsoQueryDate } from "../services/skipped-pr-audit"; import { countOpenIssues, countPendingAgentActions, @@ -61,6 +62,7 @@ import { listOpenPullRequests, listPullRequests, listRecentMergedPullRequests, + listPrVisibilitySkipAuditEvents, listRepoSyncSegments, listRepoSyncStates, listRepositories, @@ -170,6 +172,7 @@ import { buildFindingTaxonomyDocument, FINDING_TAXONOMY_URI } from "../review/fi import { buildEnrichmentAnalyzersTaxonomyDocument, ENRICHMENT_ANALYZERS_URI } from "../review/enrichment-analyzers-taxonomy"; import { recordPredictedGateCall } from "../review/predicted-gate-calls"; import { computeContributorCalibration } from "../review/predicted-gate-calibration-ledger"; +import { nowIso } from "../utils/json"; type AppContext = Context<{ Bindings: Env }>; type ToolPayload = { @@ -195,6 +198,13 @@ const ownerRepoWindowShape = { windowDays: z.number().int().positive().optional(), }; +const skippedPrAuditShape = { + repoFullName: z.string().trim().min(3).max(200).optional(), + reason: z.enum(PR_VISIBILITY_SKIP_REASONS).optional(), + since: z.string().trim().min(1).max(64).optional(), + limit: z.number().int().optional(), +}; + const windowOnlyShape = { windowDays: z.number().int().positive().optional(), }; @@ -815,6 +825,30 @@ const gatePrecisionOutputSchema = { signals: z.array(z.string()).optional(), }; +const skippedPrAuditOutputSchema = { + generatedAt: z.string().optional(), + limit: z.number().optional(), + hasMore: z.boolean().optional(), + filters: z + .object({ + repoFullName: z.string().nullable().optional(), + reason: z.string().nullable().optional(), + since: z.string().nullable().optional(), + }) + .optional(), + items: z + .array( + z.object({ + repoFullName: z.string(), + pullNumber: z.number(), + reason: z.string(), + timestamp: z.string(), + remediation: z.string(), + }), + ) + .optional(), +}; + const contributorProfileOutputSchema = { login: z.string().optional(), github: z.unknown().optional(), @@ -1679,6 +1713,17 @@ export class LoopoverMcp { async (input) => this.toolResult(await this.getGatePrecision(input)), ); + server.registerTool( + "loopover_get_skipped_pr_audit", + { + description: + "Return the read-only skipped-PR audit trail: PRs the automated reviewer skipped publicly, with reason codes and optional repo/reason/since filters. Maintainer-authenticated; measurement only.", + inputSchema: skippedPrAuditShape, + outputSchema: skippedPrAuditOutputSchema, + }, + async (input) => this.toolResult(await this.getSkippedPrAudit(input)), + ); + server.registerTool( "loopover_get_fleet_analytics", { @@ -2975,6 +3020,55 @@ export class LoopoverMcp { }; } + private async getSkippedPrAudit(input: { + repoFullName?: string | undefined; + reason?: (typeof PR_VISIBILITY_SKIP_REASONS)[number] | undefined; + since?: string | undefined; + limit?: number | undefined; + }): Promise { + const roleSummary = + this.identity.kind === "session" + ? await loadControlPanelRoleSummary(this.env, this.identity.actor) + : buildStaticControlPanelRoleSummary(this.identity.actor); + if ((this.identity.kind === "static" && this.identity.actor === "mcp") || !roleSummary.roles.some((role) => ["maintainer", "owner", "operator"].includes(role))) { + throw new Error("Forbidden: maintainer access is required for the skipped PR audit."); + } + const sinceIso = input.since ? toIsoQueryDate(input.since) : undefined; + if (input.since && !sinceIso) { + throw new Error("Invalid request: since must be a valid date string."); + } + const scope = await skippedPrAuditRepoScope(this.env, this.identity, roleSummary.roles, input.repoFullName); + if (!scope.ok) { + throw new Error("Forbidden: maintainer access is required for this repository."); + } + const page = await listPrVisibilitySkipAuditEvents(this.env, { + limit: clampInteger(input.limit ?? 50, 1, 100), + repoFullNames: scope.repoFullNames, + reason: input.reason, + sinceIso, + }); + return { + summary: `LoopOver skipped-PR audit returned ${page.items.length} entr${page.items.length === 1 ? "y" : "ies"}.`, + 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 @@ -4080,6 +4174,11 @@ function redactSensitiveForMcp(value: unknown): unknown { ); } +function clampInteger(value: number, min: number, max: number): number { + const floored = Number.isFinite(value) ? Math.trunc(value) : min; + return Math.min(max, Math.max(min, floored)); +} + async function authenticateMcpRequest(c: AppContext): Promise { const identity = await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization"))); if (!identity || identity.kind !== "session") return identity; diff --git a/src/services/skipped-pr-audit.ts b/src/services/skipped-pr-audit.ts new file mode 100644 index 0000000000..81c39fa43d --- /dev/null +++ b/src/services/skipped-pr-audit.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import { loadControlPanelAccessScope } from "./control-panel-roles"; +import type { AuthIdentity } from "../auth/security"; +import type { ControlPanelRoleName } from "../types"; +import type { PublicSurfaceSkipReason } from "../signals/settings-preview"; + +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 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(), + since: z.string().trim().min(1).max(64).optional(), + }) + .strict(); + +export type SkippedPrAuditScopeResult = + | { ok: true; repoFullNames: string[] | undefined } + | { ok: false; code: "forbidden_repo" }; + +export async function skippedPrAuditRepoScope( + 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, code: "forbidden_repo" }; + } + 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/integration/api.test.ts b/test/integration/api.test.ts index 01689c96c1..2a8a39f3a8 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -5311,6 +5311,7 @@ describe("api routes", () => { expect(toolNames).toContain("loopover_get_repo_onboarding_pack"); expect(toolNames).toContain("loopover_get_issue_quality"); expect(toolNames).toContain("loopover_get_burden_forecast"); + expect(toolNames).toContain("loopover_get_skipped_pr_audit"); expect(toolNames).toContain("loopover_get_contributor_profile"); expect(toolNames).toContain("loopover_get_decision_pack"); expect(toolNames).toContain("loopover_explain_repo_decision"); diff --git a/test/unit/codecov-policy.test.ts b/test/unit/codecov-policy.test.ts index 23909efd69..55a66b926f 100644 --- a/test/unit/codecov-policy.test.ts +++ b/test/unit/codecov-policy.test.ts @@ -73,6 +73,7 @@ describe("Codecov policy", () => { expect(testResultsUploadWith.report_type).toBe("test_results"); expect(testResultsUploadWith.disable_search).toBe(true); expect(testResultsUploadWith.fail_ci_if_error).toBe(false); + expect(testResultsUpload["continue-on-error"]).toBe(true); }); it("measures miner lib changes for codecov patch coverage (#4864)", () => { @@ -130,6 +131,7 @@ describe("Codecov policy", () => { expect(forkCoverageWith.files).toBe("./coverage/lcov.info"); expect(forkCoverageWith.disable_search).toBe(true); expect(forkCoverageWith.fail_ci_if_error).toBe(true); + expect(forkCoverageUpload!["continue-on-error"]).toBe(true); // GITHUB_SHA is the ephemeral auto-merge commit on pull_request events, and codecov-cli's fallback to // recover the real head sha assumes a 2-parent merge commit at HEAD -- which our checkout step (it // fetches github.event.pull_request.head.sha directly) never produces. Without an explicit override, @@ -153,6 +155,7 @@ describe("Codecov policy", () => { expect(forkTestResultsWith.token).toBeUndefined(); expect(forkTestResultsWith.report_type).toBe("test_results"); expect(forkTestResultsWith.fail_ci_if_error).toBe(false); + expect(forkTestResultsUpload!["continue-on-error"]).toBe(true); expect(forkTestResultsWith.override_commit).toBe("${{ github.event.pull_request.head.sha }}"); expect(String(forkTestResultsWith.override_branch)).toContain(":"); diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 014722f4a8..a5b9cd77ba 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -83,6 +83,16 @@ describe("loopover-mcp CLI — basics", () => { } }); + it("adds skipped-PR audit to the maintainer-triage recommended toolset", () => { + 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_repo_context", "loopover_get_burden_forecast", "loopover_get_skipped_pr_audit", "loopover_preflight_pr"]), + ); + }); + it("prints the gate-throttled miner-auto-dev profile with a plan→implement→push driving loop (#781)", () => { const payload = JSON.parse(run(["init-client", "--print", "codex", "--agent-profile", "miner-auto-dev", "--json"])) as { agentProfile: { id: string; title: string; recommendedTools: string[]; drivingLoop: string[]; boundaries: string[]; whenNotToUse: string }; 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..1c9d261cfa --- /dev/null +++ b/test/unit/mcp-skipped-pr-audit.test.ts @@ -0,0 +1,214 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { describe, expect, it } from "vitest"; +import type { AuthIdentity } from "../../src/auth/security"; +import { createSessionForGitHubUser } 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) { + const server = new LoopoverMcp(env, identity).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 seedSkippedPrAudit(env: Env) { + 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, + ); + const secretMetadata = { deliveryId: "delivery-secret", token: "github_pat_should_not_export", privateNote: "wallet hotkey raw trust" }; + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "legacy-secret", + targetKey: "repo-owner/owned-repo#1", + outcome: "completed", + detail: "legacy_skip_reason", + metadata: secretMetadata, + createdAt: "2026-05-28T00:00:00.250Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "missing-secret", + targetKey: "repo-owner/owned-repo#2", + outcome: "completed", + detail: "missing_author", + metadata: secretMetadata, + createdAt: "2026-05-28T00:00:00.500Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "private-author", + targetKey: "repo-owner/owned-repo#3", + outcome: "completed", + detail: "not_official_gittensor_miner", + metadata: secretMetadata, + createdAt: "2026-05-28T00:00:01.000Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "bot-secret", + targetKey: "repo-owner/owned-repo#4", + outcome: "completed", + detail: "bot_author", + metadata: secretMetadata, + createdAt: "2026-05-28T00:00:02.000Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "detector-secret", + targetKey: "repo-owner/owned-repo#5", + outcome: "completed", + detail: "miner_detection_unavailable", + metadata: secretMetadata, + createdAt: "2026-05-28T00:00:03.000Z", + }); + await recordAuditEvent(env, { + eventType: "github_app.pr_visibility_skipped", + actor: "surface-secret", + targetKey: "repo-owner/owned-repo#6", + outcome: "completed", + detail: "surface_off", + metadata: secretMetadata, + createdAt: "2026-05-28T00:00:04.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: secretMetadata, + createdAt: "2026-05-28T00:00:05.000Z", + }); +} + +async function ownerIdentity(env: Env): Promise { + const { session } = await createSessionForGitHubUser(env, { login: "repo-owner", id: 101 }); + return { + kind: "session", + actor: "repo-owner", + session, + }; +} + +describe("MCP loopover_get_skipped_pr_audit (#5825)", () => { + it("returns the default scoped audit page for an owner/maintainer session with no filters", async () => { + const env = createTestEnv(); + await seedSkippedPrAudit(env); + const client = await connect(env, await ownerIdentity(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(6); + expect(data.items.map((item) => item.pullNumber)).toEqual([6, 5, 4, 3, 2, 1]); + expect(JSON.stringify(data)).not.toContain("victim-org"); + expect(JSON.stringify(data)).not.toMatch(/delivery-secret|github_pat|wallet|hotkey|raw trust/i); + }); + + it("applies repoFullName, reason, and since filters individually", async () => { + const env = createTestEnv(); + await seedSkippedPrAudit(env); + const client = await connect(env, await ownerIdentity(env)); + + const repoFiltered = await client.callTool({ + name: "loopover_get_skipped_pr_audit", + arguments: { repoFullName: "repo-owner/owned-repo" }, + }); + expect((repoFiltered.structuredContent as { items: Array<{ repoFullName: string }> }).items.every((item) => item.repoFullName === "repo-owner/owned-repo")).toBe(true); + + const reasonFiltered = await client.callTool({ + name: "loopover_get_skipped_pr_audit", + arguments: { reason: "bot_author" }, + }); + expect((reasonFiltered.structuredContent as { items: Array<{ reason: string; pullNumber: number }> }).items).toEqual([ + expect.objectContaining({ reason: "bot_author", pullNumber: 4 }), + ]); + + const sinceFiltered = await client.callTool({ + name: "loopover_get_skipped_pr_audit", + arguments: { since: "2026-05-28T00:00:03.500Z" }, + }); + expect((sinceFiltered.structuredContent as { filters: { since: string | null } }).filters.since).toBe("2026-05-28T00:00:03.500Z"); + expect((sinceFiltered.structuredContent as { items: Array<{ pullNumber: number }> }).items.map((item) => item.pullNumber)).toEqual([6]); + }); + + it("clamps limit to the same 1..100 bounds as the route", async () => { + const env = createTestEnv(); + await seedSkippedPrAudit(env); + const client = await connect(env, await ownerIdentity(env)); + + const lowerBound = await client.callTool({ + name: "loopover_get_skipped_pr_audit", + arguments: { limit: 0 }, + }); + expect((lowerBound.structuredContent as { limit: number; items: Array<{ pullNumber: number }> }).limit).toBe(1); + expect((lowerBound.structuredContent as { items: Array<{ pullNumber: number }> }).items).toEqual([expect.objectContaining({ pullNumber: 6 })]); + + const upperBound = await client.callTool({ + name: "loopover_get_skipped_pr_audit", + arguments: { limit: 500 }, + }); + expect((upperBound.structuredContent as { limit: number; items: Array<{ pullNumber: number }> }).limit).toBe(100); + expect((upperBound.structuredContent as { items: Array<{ pullNumber: number }> }).items).toHaveLength(6); + }); + + it("returns an empty result when filters match no scoped audit events", async () => { + const env = createTestEnv(); + await seedSkippedPrAudit(env); + const client = await connect(env, await ownerIdentity(env)); + const result = await client.callTool({ + name: "loopover_get_skipped_pr_audit", + arguments: { reason: "maintainer_author" }, + }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { hasMore: boolean; items: unknown[] }; + expect(data.hasMore).toBe(false); + expect(data.items).toEqual([]); + }); + + it("forbids non-maintainer callers", async () => { + const env = createTestEnv(); + await seedSkippedPrAudit(env); + const client = await connect(env, { kind: "static", actor: "mcp" }); + const result = await client.callTool({ name: "loopover_get_skipped_pr_audit", arguments: {} }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result.content)).toContain("maintainer access is required"); + }); +});