Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
34 changes: 2 additions & 32 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
96 changes: 96 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
getRepoQueueTrendSnapshot,
listAgentAuditEvents,
listCheckSummaries,
listPrVisibilitySkipAuditEvents,
listPendingAgentActions,
listContributorRepoStats,
listContributorIssues,
Expand All @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<string[] | undefined> {
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<ToolPayload> {
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
Expand Down
38 changes: 38 additions & 0 deletions src/signals/settings-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions test/unit/mcp-output-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading