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
91 changes: 91 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -14299,6 +14299,54 @@
"actingActionClasses",
"pendingActionCount"
]
},
"RepoDocRefreshResult": {
"oneOf": [
{
"type": "object",
"properties": {
"opened": {
"type": "boolean",
"enum": [
true
]
},
"reused": {
"type": "boolean"
},
"pullNumber": {
"type": "integer"
},
"url": {
"type": "string"
}
},
"required": [
"opened",
"reused",
"pullNumber",
"url"
]
},
{
"type": "object",
"properties": {
"opened": {
"type": "boolean",
"enum": [
false
]
},
"reason": {
"type": "string"
}
},
"required": [
"opened",
"reason"
]
}
]
}
},
"parameters": {},
Expand Down Expand Up @@ -18499,6 +18547,49 @@
}
]
}
},
"/v1/repos/{owner}/{repo}/repo-docs/refresh": {
"post": {
"summary": "Open (or find the already-open) AGENTS.md/CLAUDE.md generation pull request",
"parameters": [
{
"schema": {
"type": "string"
},
"required": true,
"name": "owner",
"in": "path"
},
{
"schema": {
"type": "string"
},
"required": true,
"name": "repo",
"in": "path"
}
],
"responses": {
"200": {
"description": "The repo-doc pull request result -- opened (new or reused) or a reason it was not opened",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepoDocRefreshResult"
}
}
}
}
},
"security": [
{
"LoopOverBearer": []
},
{
"LoopOverSessionCookie": []
}
]
}
}
},
"servers": [
Expand Down
15 changes: 13 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ const CLI_COMMAND_SPEC = {
profile: ["list", "create", "switch", "remove"],
cache: ["status", "clear", "list"],
agent: ["plan", "status", "explain", "packet"],
maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state"],
maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "outcome-calibration", "onboarding-pack", "audit-feed", "automation-state", "refresh-docs"],
};
const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
Expand Down Expand Up @@ -3107,6 +3107,7 @@ function printMaintainHelp() {
" [--limit N] Cap the events returned (1-200).",
" [--pull N] Scope the feed to one pull request.",
" automation-state Show the derived agent automation state (mode, readiness, pending).",
" refresh-docs Open (or find the already-open) the AGENTS.md/CLAUDE.md generation PR.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
Expand Down Expand Up @@ -3293,8 +3294,18 @@ async function maintainCli(args) {
);
return;
}
if (subcommand === "refresh-docs") {
// #6743: REST mirror of the loopover_refresh_repo_docs MCP tool -- only ever opens a PR (never merges,
// closes, or commits directly), so a single synchronous POST with no body is the whole contract.
const payload = await apiPost(`${repoBase}/repo-docs/refresh`, {});
const line = payload.opened
? `${payload.reused ? "Found the already-open" : "Opened a new"} repo-doc pull request for ${repoFullName}: ${sanitizePlainTextTerminalOutput(payload.url)}`
: `No repo-doc pull request opened for ${repoFullName}: ${sanitizePlainTextTerminalOutput(payload.reason)}`;
emit(payload, line);
return;
}
throw new Error(
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state.`,
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs.`,
);
}

Expand Down
23 changes: 23 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import {
refreshInstallationHealthForInstallation,
} from "../github/backfill";
import { getRepositoryCollaboratorPermission } from "../github/app";
import { performRepoDocRefresh } from "../github/repo-doc-refresh-runner";
import type { LoopOverFooterEnv } from "../github/footer";
import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api";
import { fetchPublicContributorProfile, fetchPublicRepoStats } from "../github/public";
Expand Down Expand Up @@ -2739,6 +2740,21 @@ export function createApp() {
return c.json(result);
});

// #6743 — REST mirror of the loopover_refresh_repo_docs MCP tool (src/mcp/server.ts's refreshRepoDocs):
// opens (or finds the already-open) AGENTS.md/CLAUDE.md generation PR. Only ever opens a PR (never merges,
// closes, or commits directly), so — like the decision route above — it's safe to run synchronously in one
// call rather than needing the propose/decide staging pattern. Trims the runner's internal `claudeMode`
// field the same way the MCP tool's own response does, so both mirrors expose the identical public shape.
app.post("/v1/repos/:owner/:repo/repo-docs/refresh", async (c) => {
const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`;
const gate = await requireRepoWriteAccess(c, fullName);
/* v8 ignore next -- unauthorized requests are rejected by the auth middleware before reaching the handler. */
if (gate instanceof Response) return gate;
const result = await performRepoDocRefresh(c.env, fullName);
if (!result.opened) return c.json(result);
return c.json({ opened: true, reused: result.reused, pullNumber: result.pullNumber, url: result.url });
});

// #784 audit feed: the agent's executed actions + approval-queue decisions for this repo. Maintainer-scoped,
// read-only, public-safe (action posture only — no trust/score metadata). `?since=ISO&limit=N` (max 200).
// `?pull=N` opts into the unfiltered sibling query (listAuditEventsForTarget): every audit_events row for
Expand Down Expand Up @@ -6045,6 +6061,7 @@ function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind:
if (isRepoCheckBeforeStartPath(path)) return true;
if (isRepoValidateLinkedIssuePath(path)) return true;
if (isRepoAgentAuditFeedPath(path)) return true; // route's requireRepoMaintainer enforces per-repo authority (contributors → 403)
if (isRepoDocRefreshPath(path)) return true; // route's requireRepoWriteAccess enforces real per-repo write authority
if (isRepoAgentPendingActionsPath(path)) return true; // list-only: requireRepoMaintainer; decision POSTs require server tokens
if (isRepoIncidentReportsPath(path)) return true; // #5672: route's requireRepoMaintainer enforces per-repo authority (contributors → 403)
if (isRepoContributorIssueDraftGeneratePath(path)) return true;
Expand Down Expand Up @@ -6109,6 +6126,12 @@ function isRepoAgentAuditFeedPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/agent\/audit-feed$/.test(path);
}

// #6743: coarse path admission only -- the route's own requireRepoWriteAccess enforces real per-repo write
// authority (a session with mere read/maintainer-data access still 403s there).
function isRepoDocRefreshPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/repo-docs\/refresh$/.test(path);
}

function isRepoIncidentReportsPath(path: string): boolean {
return /^\/v1\/repos\/[^/]+\/[^/]+\/pulls\/[^/]+\/incident-reports$/.test(path);
}
Expand Down
11 changes: 11 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,17 @@ export const AutomationStateSchema = z
})
.openapi("AutomationState");

// #6743 — the public result shape of the loopover_refresh_repo_docs MCP tool and its REST
// (`POST /v1/repos/:owner/:repo/repo-docs/refresh`) mirror. Both trim RepoDocPullRequestResult's internal
// `claudeMode` field (src/github/repo-doc-pr.ts) the same way, so this schema matches what each surface
// actually returns, not the runner's raw result.
export const RepoDocRefreshResultSchema = z
.discriminatedUnion("opened", [
z.object({ opened: z.literal(true), reused: z.boolean(), pullNumber: z.number().int(), url: z.string() }),
z.object({ opened: z.literal(false), reason: z.string() }),
])
.openapi("RepoDocRefreshResult");

export const RepoSettingsPreviewSchema = z
.object({
repoFullName: z.string(),
Expand Down
11 changes: 11 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
RepositorySchema,
AutomationStateSchema,
RepositorySettingsSchema,
RepoDocRefreshResultSchema,
RoleContextSchema,
RewardRiskActionSchema,
ScorePreviewSchema,
Expand Down Expand Up @@ -131,6 +132,7 @@ export function buildOpenApiSpec() {
registry.register("BountyLifecycleEvents", BountyLifecycleEventsSchema);
registry.register("RepositorySettings", RepositorySettingsSchema);
registry.register("AutomationState", AutomationStateSchema);
registry.register("RepoDocRefreshResult", RepoDocRefreshResultSchema);
registry.register("InstallationRepair", InstallationRepairSchema);
registry.register("RepoSettingsPreview", RepoSettingsPreviewSchema);
registry.register("SkippedPrAuditExport", SkippedPrAuditExportSchema);
Expand Down Expand Up @@ -703,6 +705,15 @@ export function buildOpenApiSpec() {
},
},
});
registry.registerPath({
method: "post",
path: "/v1/repos/{owner}/{repo}/repo-docs/refresh",
summary: "Open (or find the already-open) AGENTS.md/CLAUDE.md generation pull request",
request: { params: z.object({ owner: z.string(), repo: z.string() }) },
responses: {
200: { description: "The repo-doc pull request result -- opened (new or reused) or a reason it was not opened", content: { "application/json": { schema: RepoDocRefreshResultSchema } } },
},
});
registry.registerPath({
method: "post",
path: "/v1/repos/{owner}/{repo}/settings-preview",
Expand Down
2 changes: 1 addition & 1 deletion test/unit/mcp-cli-basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ describe("loopover-mcp CLI — basics", () => {
expect(ps).toContain("[System.Management.Automation.CompletionResult]::new");
expect(ps).toContain("$commands = @('login', 'logout'");
expect(ps).toContain(
"'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state')",
"'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'outcome-calibration', 'onboarding-pack', 'audit-feed', 'automation-state', 'refresh-docs')",
);
});

Expand Down
33 changes: 30 additions & 3 deletions test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
tempDir = null;
});

async function env(onApiRequest?: (request: import("node:http").IncomingMessage) => void) {
async function env(options: Parameters<typeof startFixtureServer>[0] = {}) {
tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-"));
const url = await startFixtureServer(onApiRequest ? { onApiRequest } : {});
const url = await startFixtureServer(options);
return { LOOPOVER_API_URL: url, LOOPOVER_TOKEN: "session-token", LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_API_TIMEOUT_MS: "1000" };
}

Expand Down Expand Up @@ -115,7 +115,7 @@ describe("loopover-mcp CLI — maintain (#784)", () => {

it("onboarding-pack mirrors the session-gated API payload and forwards refresh", async () => {
const requests: string[] = [];
const e = await env((request) => requests.push(request.url ?? ""));
const e = await env({ onApiRequest: (request) => requests.push(request.url ?? "") });

const json = JSON.parse(
await runAsync(["maintain", "onboarding-pack", "--repo", "owner/repo", "--refresh", "--json"], e),
Expand Down Expand Up @@ -186,6 +186,33 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
expect(json).toMatchObject({ repoFullName: "owner/repo", mode: "live", permissionReadiness: "ready", pendingActionCount: 3 });
});

it("refresh-docs reports a newly opened repo-doc PR (plain + json), with output parity between the surfaces (#6743)", async () => {
const e = await env({
repoDocRefresh: { opened: true, reused: false, pullNumber: 42, url: "https://github.com/owner/repo/pull/42", claudeMode: "symlink" },
});
const out = await runAsync(["maintain", "refresh-docs", "--repo", "owner/repo"], e);
expect(out).toBe("Opened a new repo-doc pull request for owner/repo: https://github.com/owner/repo/pull/42\n");
const json = JSON.parse(await runAsync(["maintain", "refresh-docs", "--repo", "owner/repo", "--json"], e)) as {
opened: boolean;
pullNumber: number;
};
expect(json).toMatchObject({ opened: true, pullNumber: 42 });
});

it("refresh-docs reports the already-open PR when the route reuses one (#6743)", async () => {
const e = await env({
repoDocRefresh: { opened: true, reused: true, pullNumber: 42, url: "https://github.com/owner/repo/pull/42", claudeMode: "copy" },
});
const out = await runAsync(["maintain", "refresh-docs", "--repo", "owner/repo"], e);
expect(out).toBe("Found the already-open repo-doc pull request for owner/repo: https://github.com/owner/repo/pull/42\n");
});

it("refresh-docs reports why no PR was opened, sanitizing the reason (#6743)", async () => {
const e = await env({ repoDocRefresh: { opened: false, reason: "no changes needed" } });
const out = await runAsync(["maintain", "refresh-docs", "--repo", "owner/repo"], e);
expect(out).toBe("No repo-doc pull request opened for owner/repo: no changes needed\n");
});

it("validates inputs: --repo required, id required for approve, known subcommand + action/level", async () => {
const e = await env();
await expect(runAsync(["maintain", "status"], e)).rejects.toThrow(/Pass --repo/);
Expand Down
Loading
Loading