Skip to content
Closed
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 @@ -120,10 +120,10 @@
"maintainer-triage": {
id: "maintainer-triage",
title: "Maintainer queue triage",
audience: "maintainers preparing low-noise queue and PR review context",

Check notice on line 123 in packages/loopover-mcp/bin/loopover-mcp.js

View check run for this annotation

Loopover ORB / LoopOver Context

Possible duplicate overlap

Titles/paths share 3 meaningful terms.

Check notice on line 123 in packages/loopover-mcp/bin/loopover-mcp.js

View check run for this annotation

Loopover ORB / LoopOver Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
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
67 changes: 6 additions & 61 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,15 @@
import {
buildMinerDashboardNextActions,
buildMinerDashboardRepoFit,
previousDecisionPackFromSnapshots,

Check notice on line 183 in src/api/routes.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Possible duplicate overlap

Titles/paths share 3 meaningful terms.

Check notice on line 183 in src/api/routes.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
} 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 {
Expand Down Expand Up @@ -278,7 +279,7 @@
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,
Expand Down Expand Up @@ -418,15 +419,6 @@

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 @@ -1503,11 +1495,11 @@
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,
});
Expand Down Expand Up @@ -5595,11 +5587,6 @@
return authenticateSessionToken(c.env, browserSessionToken);
}

async function getRoleSummaryForIdentity(env: Env, identity: AuthIdentity) {
if (identity.kind === "session") return loadControlPanelRoleSummary(env, identity.actor);
return buildStaticControlPanelRoleSummary(identity.actor);
}

async function requireAppRole(c: ProtectedRouteContext, allowedRoles: ControlPanelRoleName[]): Promise<Response | null> {
const identity = await authenticateRequestIdentity(c);
if (!identity) return c.json({ error: "unauthorized" }, 401);
Expand Down Expand Up @@ -5759,48 +5746,6 @@
return gate;
}

async function skippedPrAuditRepoScope(
c: ProtectedRouteContext,
identity: AuthIdentity,
roles: ControlPanelRoleName[],
requestedRepo: string | undefined,
): Promise<string[] | undefined | Response> {
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
Expand Down
98 changes: 97 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,20 @@
isAuthorizedGitHubSessionLogin,
isMcpActuationRepoAllowed,
isMcpReadRepoAllowed,
isMcpReadUnscoped,

Check notice on line 29 in src/mcp/server.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Possible duplicate overlap

Titles/paths share 3 meaningful terms.

Check notice on line 29 in src/mcp/server.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
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,
Expand Down Expand Up @@ -59,6 +69,7 @@
listNotificationDeliveriesForRecipient,
upsertIssueWatchSubscription,
listOpenPullRequests,
listPrVisibilitySkipAuditEvents,
listPullRequests,
listRecentMergedPullRequests,
listRepoSyncSegments,
Expand Down Expand Up @@ -189,6 +200,16 @@
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),
Expand Down Expand Up @@ -815,6 +836,14 @@
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(),
Expand Down Expand Up @@ -1679,6 +1708,17 @@
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",
{
Expand Down Expand Up @@ -2975,6 +3015,62 @@
};
}

// #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<ControlPanelRoleSummary> {
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<ToolPayload> {
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
Expand Down
11 changes: 10 additions & 1 deletion src/services/control-panel-roles.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isAuthorizedGitHubSessionLogin } from "../auth/security";
import { isAuthorizedGitHubSessionLogin, type AuthIdentity } from "../auth/security";

Check notice on line 1 in src/services/control-panel-roles.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Possible duplicate overlap

Titles/paths share 3 meaningful terms.

Check notice on line 1 in src/services/control-panel-roles.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
import { getFreshOfficialMinerDetection, getRepository, listAllPullRequests, listInstallations, listRepositories } from "../db/repositories";
import type { ControlPanelRoleCard, ControlPanelRoleName, ControlPanelRoleSummary, InstallationRecord, PullRequestRecord, RepositoryRecord } from "../types";
import { nowIso } from "../utils/json";
Expand Down Expand Up @@ -53,6 +53,15 @@
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<ControlPanelRoleSummary> {
if (identity.kind === "session") return loadControlPanelRoleSummary(env, identity.actor);
return buildStaticControlPanelRoleSummary(identity.actor);
}

export async function loadControlPanelRoleSummary(env: Env, login: string): Promise<ControlPanelRoleSummary> {
const [miner, repositories, installations, pullRequests] = await Promise.all([
getFreshOfficialMinerDetection(env, login).catch(() => null),
Expand Down
62 changes: 62 additions & 0 deletions src/services/skipped-pr-audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { AuthIdentity } from "../auth/security";

Check notice on line 1 in src/services/skipped-pr-audit.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Possible duplicate overlap

Titles/paths share 3 meaningful terms.

Check notice on line 1 in src/services/skipped-pr-audit.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
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<SkippedPrAuditRepoScope> {
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;
}
10 changes: 10 additions & 0 deletions test/unit/mcp-cli-basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,19 @@
expect(plain).toContain('"mcpServers"');
expect(plain).toContain("LoopOver agent profile: Repo-owner intake");
expect(plain).toContain("loopover_repo_owner_intake_readiness");
expect(plain).toMatch(/do not.*publish public output/i);

Check notice on line 67 in test/unit/mcp-cli-basics.test.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Possible duplicate overlap

Titles/paths share 3 meaningful terms.

Check notice on line 67 in test/unit/mcp-cli-basics.test.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
});

// #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 {
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 @@ -35,9 +35,10 @@
"loopover_local_status",
"loopover_remediation_plan",
"loopover_explain_score_breakdown",
"loopover_get_eligibility_plan",

Check notice on line 38 in test/unit/mcp-output-schemas.test.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Possible duplicate overlap

Titles/paths share 3 meaningful terms.

Check notice on line 38 in test/unit/mcp-output-schemas.test.ts

View check run for this annotation

Loopover ORB / LoopOver Context

Review queue is busy

This repo has a busy review queue in the local Gittensory cache.
"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
Loading