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
34 changes: 32 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,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", "onboarding-pack"],
maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "onboarding-pack", "audit-feed"],
};
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 @@ -2934,6 +2934,9 @@ function printMaintainHelp() {
` levels: ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}`,
" precision [--window-days N] Show gate false-positive telemetry (blocked-then-merged per gate type).",
" onboarding-pack [--refresh] Preview the repo's contributor onboarding pack.",
" audit-feed [--since ISO] Show the agent audit feed (who did what, when).",
" [--limit N] Cap the events returned (1-200).",
" [--pull N] Scope the feed to one pull request.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
Expand Down Expand Up @@ -3056,8 +3059,35 @@ async function maintainCli(args) {
);
return;
}
if (subcommand === "audit-feed") {
// #6733: read-only mirror of GET {repoBase}/agent/audit-feed (the same surface the remote
// loopover_get_agent_audit_feed tool exposes). The API enforces maintainer authorization and validates
// every query param -- `since` must be ISO-8601, `limit` 1..200, `pull` a positive integer -- so the CLI
// forwards them verbatim rather than re-deciding locally, and a bad value surfaces as the API's own 400
// detail. Omitted flags are omitted from the query entirely, so the route applies its own defaults.
const query = new URLSearchParams();
if (options.since !== undefined) query.set("since", String(options.since));
if (options.limit !== undefined) query.set("limit", String(options.limit));
if (options.pull !== undefined) query.set("pull", String(options.pull));
const payload = await apiGet(`${repoBase}/agent/audit-feed${query.size > 0 ? `?${query}` : ""}`);
const events = payload.events ?? [];
// `pullNumber` is echoed by the route only on the ?pull= branch, so the scope line reports what was asked for.
const scope = payload.pullNumber ? `${repoFullName}#${payload.pullNumber}` : repoFullName;
emit(
payload,
[
`Agent audit feed for ${scope}: ${events.length} event${events.length === 1 ? "" : "s"}.`,
// `detail` is the one free-form field here; sanitized on the plain-text path like onboarding-pack's
// dump above (--json re-serializes `payload` untouched, so the JSON contract is unaffected).
...events.map((event) =>
sanitizePlainTextTerminalOutput([event.createdAt, event.eventType, event.actor, event.outcome, event.detail].filter(Boolean).join(" ")),
),
].join("\n"),
);
return;
}
throw new Error(
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | onboarding-pack.`,
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | onboarding-pack | audit-feed.`,
);
}

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', 'onboarding-pack')",
"'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'onboarding-pack', 'audit-feed')",
);
});

Expand Down
41 changes: 41 additions & 0 deletions test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,47 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
expect(requests.at(-1)).toBe("/v1/repos/owner/repo/onboarding-pack/preview");
});

it("audit-feed shows the agent audit feed (plain + json), with output parity between the surfaces (#6733)", async () => {
const e = await env();
const out = await runAsync(["maintain", "audit-feed", "--repo", "owner/repo"], e);
expect(out).toMatch(/Agent audit feed for owner\/repo: 2 events\./);
expect(out).toMatch(/2026-05-30T00:00:00\.000Z {2}github_app\.merged {2}loopover {2}success {2}merged #7/);
// A null detail is dropped from the line rather than printed as the string "null".
expect(out).toMatch(/github_app\.review_evasion_closed {2}loopover {2}denied$/m);
// Parity: --json re-serializes the API payload untouched, so the same events reach both surfaces.
const json = JSON.parse(await runAsync(["maintain", "audit-feed", "--repo", "owner/repo", "--json"], e)) as {
repoFullName: string;
events: Array<{ id: string }>;
};
expect(json.repoFullName).toBe("owner/repo");
expect(json.events.map((event) => event.id)).toEqual(["ae-1", "ae-2"]);
});

it("audit-feed forwards --since/--limit/--pull to the route and scopes the header to the pull (#6733)", async () => {
const e = await env();
// The API validates these (ISO since, limit 1..200, positive pull), so the CLI must forward them verbatim
// rather than re-deciding locally -- this pins that they actually arrive.
const payload = JSON.parse(
await runAsync(
["maintain", "audit-feed", "--repo", "owner/repo", "--since", "2026-05-29T00:00:00.000Z", "--limit", "1", "--pull", "7", "--json"],
e,
),
) as { echoedQuery: { since: string; limit: string; pull: string }; events: unknown[] };
expect(payload.echoedQuery).toEqual({ since: "2026-05-29T00:00:00.000Z", limit: "1", pull: "7" });
expect(payload.events).toHaveLength(1);
// The ?pull= branch echoes pullNumber, and the plain-text header reflects that scope.
const scoped = await runAsync(["maintain", "audit-feed", "--repo", "owner/repo", "--pull", "7"], e);
expect(scoped).toMatch(/Agent audit feed for owner\/repo#7: /);
});

it("audit-feed omits absent flags from the query entirely, so the route applies its own defaults (#6733)", async () => {
const e = await env();
const payload = JSON.parse(await runAsync(["maintain", "audit-feed", "--repo", "owner/repo", "--json"], e)) as {
echoedQuery: { since: string | null; limit: string | null; pull: string | null };
};
expect(payload.echoedQuery).toEqual({ since: null, limit: null, pull: null });
});

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
20 changes: 20 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,26 @@ export async function startFixtureServer(
response.end(JSON.stringify({ repoFullName: "owner/repo", agentPaused: body.agentPaused === true, ...(body.autonomy ? { autonomy: body.autonomy } : {}) }));
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") {
const params = new URL(request.url, "http://localhost").searchParams;
const pull = params.get("pull");
const limit = params.get("limit");
const events = [
{ id: "ae-1", createdAt: "2026-05-30T00:00:00.000Z", eventType: "github_app.merged", actor: "loopover", outcome: "success", detail: "merged #7" },
{ id: "ae-2", createdAt: "2026-05-29T00:00:00.000Z", eventType: "github_app.review_evasion_closed", actor: "loopover", outcome: "denied", detail: null },
];
response.end(
JSON.stringify({
repoFullName: "owner/repo",
...(pull ? { pullNumber: Number(pull) } : {}),
echoedQuery: { since: params.get("since"), limit, pull },
events: limit ? events.slice(0, Number(limit)) : events,
}),
);
return;
}
// #554 gate precision telemetry (read-only). Echoes ?windowDays so the CLI window pass-through is testable.
if (request.url?.startsWith("/v1/repos/owner/repo/gate-precision") && request.method === "GET") {
const windowDays = new URL(request.url, "http://localhost").searchParams.get("windowDays");
Expand Down