From a804009c985a354c8f28d6cfa7a92937be9ba51a Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 17 Jul 2026 12:30:52 -0400 Subject: [PATCH 1/3] feat(api): add POST /v1/repos/:owner/:repo/repo-docs/refresh + CLI mirror The MCP tool loopover_refresh_repo_docs (opens or finds the already-open AGENTS.md/CLAUDE.md generation PR) had no REST or CLI counterpart. Adds the write-access-gated REST route, a `maintain refresh-docs` CLI subcommand, and admits the new path through the session coarse-path allowlist so a browser maintainer session can actually reach it (the route's own requireRepoWriteAccess still enforces real per-repo write authority). Both mirrors trim the runner's internal claudeMode field the same way the MCP tool's own response already does, keeping all three surfaces' public shape identical. --- packages/loopover-mcp/bin/loopover-mcp.js | 15 +++- src/api/routes.ts | 23 +++++ src/openapi/schemas.ts | 11 +++ src/openapi/spec.ts | 11 +++ test/unit/mcp-cli-maintain.test.ts | 33 ++++++- test/unit/routes-repo-doc-refresh.test.ts | 105 ++++++++++++++++++++++ test/unit/support/mcp-cli-harness.ts | 19 ++++ 7 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 test/unit/routes-repo-doc-refresh.test.ts diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index ee02c9923d..7fbee808f4 100644 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -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"]; @@ -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", @@ -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 | reject | pause | resume | set-level | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state.`, + `Unknown maintain subcommand: ${subcommand}. Use status | queue | approve | reject | pause | resume | set-level | precision | outcome-calibration | onboarding-pack | audit-feed | automation-state | refresh-docs.`, ); } diff --git a/src/api/routes.ts b/src/api/routes.ts index d4ecc04cac..a570e054d1 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -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"; @@ -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 @@ -6045,6 +6061,7 @@ function canSessionAccessPath(env: Env, identity: Extract { tempDir = null; }); - async function env(onApiRequest?: (request: import("node:http").IncomingMessage) => void) { + async function env(options: Parameters[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" }; } @@ -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), @@ -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/); diff --git a/test/unit/routes-repo-doc-refresh.test.ts b/test/unit/routes-repo-doc-refresh.test.ts new file mode 100644 index 0000000000..2390e58e7e --- /dev/null +++ b/test/unit/routes-repo-doc-refresh.test.ts @@ -0,0 +1,105 @@ +import { generateKeyPairSync } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createSessionForGitHubUser } from "../../src/auth/security"; +import { getRepositoryCollaboratorPermission } from "../../src/github/app"; +import { upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub } from "../../src/db/repositories"; +import { upsertRepoFocusManifest } from "../../src/signals/focus-manifest-loader"; +import { createTestEnv } from "../helpers/d1"; + +// #6743: POST /v1/repos/:owner/:repo/repo-docs/refresh — the REST mirror of the loopover_refresh_repo_docs +// MCP tool, write-access gated like the pending-actions decision route. performRepoDocRefresh's own +// opened/reused/not-enabled behavior is already exhaustively covered by test/unit/mcp-refresh-repo-docs.test.ts; +// these pin the ROUTE contract only: the gate, and that the runner's result reaches the response unmodified. +vi.mock("../../src/github/app", async (importOriginal) => ({ + ...(await importOriginal()), + getRepositoryCollaboratorPermission: vi.fn(), +})); +const mockedPermission = vi.mocked(getRepositoryCollaboratorPermission); + +const REPO = "owner/widgets"; +const PATH = "/v1/repos/owner/widgets/repo-docs/refresh"; +const TOKEN_URL = /\/access_tokens$/; + +function generateRsaPrivateKeyPem(): string { + return generateKeyPairSync("rsa", { modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" }, publicKeyEncoding: { type: "pkcs1", format: "pem" } }).privateKey; +} + +async function seedChunk(env: Env, path: string, text: string): Promise { + await env.DB.prepare("INSERT INTO repo_chunks (id, project, repo, path, chunk_index, kind, text) VALUES (?,?,?,?,?,?,?)").bind(`${path}::0`, "owner", "widgets", path, 0, "code", text).run(); +} + +describe("POST /v1/repos/:owner/:repo/repo-docs/refresh (#6743)", () => { + afterEach(() => vi.unstubAllGlobals()); + beforeEach(() => mockedPermission.mockReset()); + + it("opens a repo-doc pull request and returns the runner's result unmodified", async () => { + const app = createApp(); + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: generateRsaPrivateKeyPem(), ADMIN_GITHUB_LOGINS: "operator-admin" }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" }, default_branch: "main" }, 555); + await upsertRepoFocusManifest(env, REPO, { repoDocGeneration: { enabled: true } }); + await seedChunk(env, "src/widget.ts", "export function widget() {}"); + await seedChunk(env, "package.json", JSON.stringify({ scripts: { build: "tsc" } })); + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = init?.method ?? "GET"; + if (TOKEN_URL.test(url)) return Response.json({ token: "t" }); + if (url.includes("/pulls?") && method === "GET") return Response.json([]); + if (url.includes("/contents/") && method === "GET") return new Response("not found", { status: 404 }); + if (url.endsWith("/branches/main")) return Response.json({ commit: { sha: "base-commit-sha", commit: { tree: { sha: "base-tree-sha" } } } }); + if (url.endsWith("/git/trees") && method === "POST") return Response.json({ sha: "tree-sha" }); + if (url.endsWith("/git/commits") && method === "POST") return Response.json({ sha: "commit-sha" }); + if (url.endsWith("/git/refs") && method === "POST") return Response.json({}); + if (url.endsWith("/repos/owner/widgets/pulls") && method === "POST") return Response.json({ number: 101, html_url: "https://github.com/owner/widgets/pull/101" }); + return new Response("unexpected", { status: 500 }); + }); + const { token } = await createSessionForGitHubUser(env, { login: "operator-admin", id: 1 }); + + const response = await app.request(PATH, { method: "POST", headers: { authorization: `Bearer ${token}` } }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ opened: true, reused: false, pullNumber: 101, url: "https://github.com/owner/widgets/pull/101" }); + }); + + it("reports opened: false without touching GitHub when repo-doc generation is not enabled", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "operator-admin" }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + const { token } = await createSessionForGitHubUser(env, { login: "operator-admin", id: 1 }); + + const response = await app.request(PATH, { method: "POST", headers: { authorization: `Bearer ${token}` } }, env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + opened: false, + reason: "repo-doc generation is not enabled for this repository (.loopover.yml repoDocGeneration.enabled)", + }); + }); + + it("forbids a session without real write access to the repo", async () => { + const app = createApp(); + const env = createTestEnv({ ADMIN_GITHUB_LOGINS: "" }); + // The repo must be INSTALLED (not just registered) for a collaborator PR to earn "reader" the maintainer + // app-role requireRepoWriteAccess checks first -- otherwise it 403s at that earlier gate instead of the + // per-repo write-permission check this test targets (mirrors maintainer-activation.test.ts's seedRepo). + await upsertInstallation(env, { + installation: { id: 555, account: { login: "owner", id: 555, type: "User" }, repository_selection: "selected", permissions: { metadata: "read" }, events: ["repository"] }, + }); + await upsertRepositoryFromGitHub(env, { name: "widgets", full_name: REPO, private: false, owner: { login: "owner" } }, 555); + await env.DB.prepare("UPDATE repositories SET is_registered = 1 WHERE full_name = ?").bind(REPO).run(); + await upsertPullRequestFromGitHub(env, REPO, { + number: 8, + title: "docs tweak", + state: "open", + user: { login: "reader" }, + author_association: "COLLABORATOR", + head: { sha: "def456", ref: "docs-2" }, + base: { ref: "main" }, + labels: [], + }); + mockedPermission.mockResolvedValue("read"); + const { token } = await createSessionForGitHubUser(env, { login: "reader", id: 777 }); + + const response = await app.request(PATH, { method: "POST", headers: { cookie: `loopover_session=${token}` } }, env); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "insufficient_repo_permission" }); + }); +}); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index 872d08aaff..856903b0bd 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -178,6 +178,9 @@ export async function startFixtureServer( openPrMonitor?: Record; intakeStatus?: number; localBranchAnalysisStatus?: number; + /** #6743: overrides the repo-doc refresh route's default "opened a new PR" response, e.g. to exercise + * the reused-PR or not-opened branches. */ + repoDocRefresh?: unknown; /** #6792: queued /v1/auth/github/device/poll responses, consumed one per request -- the last entry * repeats once exhausted. Lets a test simulate a transient 429 (or GitHub's own slow_down/pending * statuses) before the device flow eventually resolves. Requires deviceFlowStart to be set too. */ @@ -509,6 +512,22 @@ export async function startFixtureServer( response.end(JSON.stringify({ repoFullName: "owner/repo", agentPaused: body.agentPaused === true, ...(body.autonomy ? { autonomy: body.autonomy } : {}) })); return; } + // #6743 repo-doc refresh (write). Defaults to the "opened a new PR" shape; a test can override via + // options.repoDocRefresh to exercise the reused / not-opened branches. + if (request.url === "/v1/repos/owner/repo/repo-docs/refresh" && request.method === "POST") { + response.end( + JSON.stringify( + options.repoDocRefresh ?? { + opened: true, + reused: false, + pullNumber: 42, + url: "https://github.com/owner/repo/pull/42", + claudeMode: "symlink", + }, + ), + ); + return; + } // #6733 agent audit feed (read-only). Echoes the forwarded query so the CLI's pass-through is testable, and // mirrors the route's two shapes: a repo-wide feed, or a ?pull=N-scoped one that also echoes `pullNumber`. if (request.url?.startsWith("/v1/repos/owner/repo/agent/audit-feed") && request.method === "GET") { From bbec36920c6016ff1dafc6ca0736a7b43bd0fc1e Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 17 Jul 2026 12:39:18 -0400 Subject: [PATCH 2/3] chore(api): regenerate openapi.json after rebase onto the automation-state route --- apps/loopover-ui/public/openapi.json | 91 ++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 22eef52822..98c0d58a59 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -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": {}, @@ -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": [ From d1570011f6e13fd2542010673a9f37efe0245315 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Fri, 17 Jul 2026 12:55:46 -0400 Subject: [PATCH 3/3] fix(test): sync the PowerShell completion snapshot with the new refresh-docs subcommand --- test/unit/mcp-cli-basics.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 22a0e8abf9..a20137c8e0 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -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')", ); });