diff --git a/src/api/routes.ts b/src/api/routes.ts index 2ef368e7a6..fe2a30de37 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -42,6 +42,7 @@ import { getRepoQueueTrendSnapshot, getRepositorySettings, getPendingAgentAction, + listAgentAuditEvents, listPendingAgentActions, recordAuditEvent, getContributorEvidence, @@ -2064,6 +2065,23 @@ export function createApp() { return c.json(result); }); + // #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). + app.get("/v1/repos/:owner/:repo/agent/audit-feed", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const gate = await requireRepoMaintainer(c, fullName); + /* v8 ignore next -- unauthorized requests are rejected by the auth middleware before reaching the handler. */ + if (gate instanceof Response) return gate; + const since = c.req.query("since"); + const limit = Number(c.req.query("limit") ?? ""); + const events = await listAgentAuditEvents(c.env, { + repoFullName: fullName, + ...(since ? { sinceIso: since } : {}), + ...(Number.isInteger(limit) && limit > 0 ? { limit } : {}), + }); + return c.json({ repoFullName: fullName, events }); + }); + // Maintainer activation demo (#701): a repo-specific "here's what Gittensory would have surfaced" preview // over recent PRs, plus a one-click advisory ramp. Maintainer-scoped + per-repo. Deterministic (no AI run). app.get("/v1/repos/:owner/:repo/activation-preview", async (c) => { diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 9993f75c00..9efb68fc78 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -2032,6 +2032,47 @@ export async function listPrVisibilitySkipAuditEvents( return { limit, hasMore: items.length > limit, items: items.slice(0, limit) }; } +// #784 audit feed: the agent's own action history for a repo — both executed actions (`agent.action.`) +// and approval-queue decisions (`agent.pending_action.accepted|rejected`). Repo-scoped via the `repo#pr` +// targetKey prefix range (mirrors listPrVisibilitySkipAuditEvents). Read-only; private trust/score metadata +// is never selected, only the public-safe action posture. +export type AgentAuditEvent = { + eventType: string; + pullNumber: number | null; + outcome: string; + actor: string | null; + detail: string | null; + createdAt: string; +}; + +export async function listAgentAuditEvents( + env: Env, + options: { repoFullName: string; sinceIso?: string | undefined; limit?: number | undefined }, +): Promise { + const limit = clampInteger(options.limit ?? 50, 1, 200); + const prefix = `${options.repoFullName.toLowerCase()}#`; + const upperBound = `${options.repoFullName.toLowerCase()}$`; + const conditions: SQL[] = [ + sql`(${auditEvents.eventType} like 'agent.action.%' or ${auditEvents.eventType} like 'agent.pending_action.%')`, + sql`lower(${auditEvents.targetKey}) >= ${prefix} and lower(${auditEvents.targetKey}) < ${upperBound}`, + ]; + if (options.sinceIso) conditions.push(gte(auditEvents.createdAt, options.sinceIso)); + const rows = await getDb(env.DB) + .select({ eventType: auditEvents.eventType, targetKey: auditEvents.targetKey, outcome: auditEvents.outcome, actor: auditEvents.actor, detail: auditEvents.detail, createdAt: auditEvents.createdAt }) + .from(auditEvents) + .where(and(...conditions)) + .orderBy(desc(auditEvents.createdAt), desc(auditEvents.id)) + .limit(limit); + return rows.map((row) => ({ + eventType: row.eventType, + pullNumber: parsePullRequestTargetKey(row.targetKey)?.pullNumber ?? null, + outcome: row.outcome, + actor: row.actor, + detail: row.detail, + createdAt: row.createdAt, + })); +} + export async function getFreshOfficialMinerDetection(env: Env, login: string, now = nowIso()): Promise { const [row] = await getDb(env.DB).select().from(officialMinerDetections).where(and(eq(officialMinerDetections.login, login.toLowerCase()), gte(officialMinerDetections.expiresAt, now))).limit(1); return row ? toOfficialMinerDetection(row) : null; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 9a3527c85a..4b3659216f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -20,6 +20,7 @@ import { getRepository, getRepositorySettings, getRepoQueueTrendSnapshot, + listAgentAuditEvents, listCheckSummaries, listPendingAgentActions, listContributorRepoStats, @@ -370,6 +371,30 @@ const automationStateOutputSchema = { pendingActionCount: z.number().optional(), }; +// #784 (MCP slice) — the agent audit feed: executed actions + approval decisions for a repo. +const auditFeedShape = { + owner: z.string().min(1), + repo: z.string().min(1), + since: z.string().min(1).optional(), + limit: z.number().int().positive().max(200).optional(), +}; + +const auditFeedOutputSchema = { + repoFullName: z.string().optional(), + events: z + .array( + z.object({ + eventType: z.string(), + pullNumber: z.number().nullable(), + outcome: z.string(), + actor: z.string().nullable(), + detail: z.string().nullable(), + createdAt: z.string(), + }), + ) + .optional(), +}; + const focusManifestInputSchema = z .record(z.string(), z.unknown()) .refine((manifest) => isJsonByteLengthWithinLimit(manifest, MAX_FOCUS_MANIFEST_BYTES), { @@ -1256,6 +1281,17 @@ export class GittensoryMcp { async (input) => this.toolResult(await this.proposeAction(input)), ); + server.registerTool( + "gittensory_get_agent_audit_feed", + { + description: + "Return a repo's agent audit feed: executed actions (agent.action.*) and approval-queue decisions (accepted/rejected), newest first. Read-only and public-safe (action posture only). Maintainer access required.", + inputSchema: auditFeedShape, + outputSchema: auditFeedOutputSchema, + }, + async (input) => this.toolResult(await this.getAgentAuditFeed(input)), + ); + server.registerTool( "gittensory_explain_score_breakdown", { @@ -2121,6 +2157,22 @@ export class GittensoryMcp { }; } + // #784 — the agent audit feed: executed actions + approval decisions for a repo, newest first. + // Maintainer-manage scoped; read-only and public-safe (action posture only — no trust/score metadata). + private async getAgentAuditFeed(input: z.infer>): Promise { + const fullName = `${input.owner}/${input.repo}`; + await this.requireRepoManageAccess(fullName); + const events = await listAgentAuditEvents(this.env, { + repoFullName: fullName, + ...(input.since !== undefined ? { sinceIso: input.since } : {}), + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }); + return { + summary: `${events.length} recent agent audit event(s) for ${fullName}.`, + data: { repoFullName: fullName, events }, + }; + } + private async explainScoreBreakdown(input: z.infer>): Promise { if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown."); this.requireContributorAccess(input.contributorLogin); diff --git a/test/unit/mcp-automation-state.test.ts b/test/unit/mcp-automation-state.test.ts index 341de48311..4e2503fde4 100644 --- a/test/unit/mcp-automation-state.test.ts +++ b/test/unit/mcp-automation-state.test.ts @@ -3,7 +3,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GittensoryMcp } from "../../src/mcp/server"; import { getRepositoryCollaboratorPermission } from "../../src/github/app"; -import { createPendingAgentActionIfAbsent, listPendingAgentActions, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createPendingAgentActionIfAbsent, listPendingAgentActions, recordAuditEvent, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; import type { AuthIdentity } from "../../src/auth/security"; import { createTestEnv } from "../helpers/d1"; @@ -176,3 +176,47 @@ describe("MCP gittensory_propose_action (#784)", () => { expect(await listPendingAgentActions(env, { repoFullName: "owner/repo" })).toHaveLength(0); }); }); + +describe("MCP gittensory_get_agent_audit_feed (#784)", () => { + async function seedAudit(env: Env) { + await recordAuditEvent(env, { eventType: "agent.action.merge", actor: "gittensory", targetKey: "owner/repo#7", outcome: "completed", detail: "merged", createdAt: "2026-06-18T10:00:00.000Z" }); + await recordAuditEvent(env, { eventType: "agent.pending_action.rejected", actor: "owner", targetKey: "owner/repo#8", outcome: "completed", detail: "rejected merge", createdAt: "2026-06-18T11:00:00.000Z" }); + await recordAuditEvent(env, { eventType: "github_app.pr_visibility_skipped", actor: "x", targetKey: "owner/repo#9", outcome: "completed", createdAt: "2026-06-18T12:00:00.000Z" }); + await recordAuditEvent(env, { eventType: "agent.action.label", actor: "gittensory", targetKey: "other/repo#1", outcome: "completed", createdAt: "2026-06-18T13:00:00.000Z" }); + } + + it("surfaces this repo's agent action + decision events newest-first, excluding non-agent and other-repo events", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await seedAudit(env); + const client = await connect(env); + const result = await client.callTool({ name: "gittensory_get_agent_audit_feed", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBeFalsy(); + const data = result.structuredContent as { repoFullName: string; events: Array<{ eventType: string; pullNumber: number | null; outcome: string }> }; + expect(data.repoFullName).toBe("owner/repo"); + expect(data.events.map((event) => event.eventType)).toEqual(["agent.pending_action.rejected", "agent.action.merge"]); + expect(data.events[0]).toMatchObject({ pullNumber: 8, outcome: "completed" }); + }); + + it("honors the since filter and the limit", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await seedAudit(env); + const client = await connect(env); + const since = await client.callTool({ name: "gittensory_get_agent_audit_feed", arguments: { owner: "owner", repo: "repo", since: "2026-06-18T10:30:00.000Z" } }); + expect((since.structuredContent as { events: unknown[] }).events).toHaveLength(1); + const limited = await client.callTool({ name: "gittensory_get_agent_audit_feed", arguments: { owner: "owner", repo: "repo", limit: 1 } }); + expect((limited.structuredContent as { events: unknown[] }).events).toHaveLength(1); + }); + + it("forbids a session without live write access", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await seedAudit(env); + mockedPermission.mockResolvedValue("read"); + const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); + const result = await client.callTool({ name: "gittensory_get_agent_audit_feed", arguments: { owner: "owner", repo: "repo" } }); + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/write access/i); + }); +}); diff --git a/test/unit/routes-agent-approval.test.ts b/test/unit/routes-agent-approval.test.ts index 7cdce5f552..c0757d89f6 100644 --- a/test/unit/routes-agent-approval.test.ts +++ b/test/unit/routes-agent-approval.test.ts @@ -13,7 +13,7 @@ vi.mock("../../src/github/labels", () => ({ import { mergePullRequest } from "../../src/github/pr-actions"; import { createSessionForGitHubUser } from "../../src/auth/security"; import { createApp } from "../../src/api/routes"; -import { createPendingAgentActionIfAbsent, getPendingAgentAction, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createPendingAgentActionIfAbsent, getPendingAgentAction, recordAuditEvent, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; import { createTestEnv } from "../helpers/d1"; const app = createApp(); @@ -113,3 +113,52 @@ describe("agent approval-queue routes (#779)", () => { expect(again.status).toBe(409); }); }); + +describe("agent audit-feed route (#784)", () => { + async function seedAudit(env: Env) { + await recordAuditEvent(env, { eventType: "agent.action.merge", actor: "gittensory", targetKey: "owner/repo#7", outcome: "completed", detail: "merged", createdAt: "2026-06-18T10:00:00.000Z" }); + await recordAuditEvent(env, { eventType: "agent.pending_action.rejected", actor: "owner", targetKey: "owner/repo#8", outcome: "completed", detail: "rejected merge", createdAt: "2026-06-18T11:00:00.000Z" }); + // excluded: a non-agent event on this repo, and an agent event on a different repo. + await recordAuditEvent(env, { eventType: "github_app.pr_visibility_skipped", actor: "x", targetKey: "owner/repo#9", outcome: "completed", createdAt: "2026-06-18T12:00:00.000Z" }); + await recordAuditEvent(env, { eventType: "agent.action.label", actor: "gittensory", targetKey: "other/repo#1", outcome: "completed", createdAt: "2026-06-18T13:00:00.000Z" }); + } + + it("returns this repo's agent action + decision events newest-first, excluding non-agent and other-repo events", async () => { + const env = createTestEnv(); + await seedAudit(env); + const res = await app.request("/v1/repos/owner/repo/agent/audit-feed", { headers: headers(env) }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { repoFullName: string; events: Array<{ eventType: string; pullNumber: number | null; outcome: string }> }; + expect(body.repoFullName).toBe("owner/repo"); + expect(body.events.map((event) => event.eventType)).toEqual(["agent.pending_action.rejected", "agent.action.merge"]); + expect(body.events[0]).toMatchObject({ pullNumber: 8, outcome: "completed" }); + }); + + it("honors the since filter and the limit", async () => { + const env = createTestEnv(); + await seedAudit(env); + const since = await app.request("/v1/repos/owner/repo/agent/audit-feed?since=2026-06-18T10:30:00.000Z", { headers: headers(env) }, env); + expect(((await since.json()) as { events: unknown[] }).events).toHaveLength(1); // only the 11:00 reject + const limited = await app.request("/v1/repos/owner/repo/agent/audit-feed?limit=1", { headers: headers(env) }, env); + expect(((await limited.json()) as { events: unknown[] }).events).toHaveLength(1); + }); + + it("requires authentication and forbids a non-operator session", async () => { + const env = createTestEnv(); + await seedAudit(env); + const noauth = await app.request("/v1/repos/owner/repo/agent/audit-feed", {}, env); + expect([401, 403]).toContain(noauth.status); + const { token } = await createSessionForGitHubUser(env, { login: "rando", id: 555 }); + const forbidden = await app.request("/v1/repos/owner/repo/agent/audit-feed", { headers: { authorization: `Bearer ${token}` } }, env); + expect([401, 403]).toContain(forbidden.status); + }); + + it("reports a null pullNumber for an agent event whose targetKey has no numeric PR", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { eventType: "agent.action.label", actor: "gittensory", targetKey: "owner/repo#manual", outcome: "completed", createdAt: "2026-06-18T09:00:00.000Z" }); + const res = await app.request("/v1/repos/owner/repo/agent/audit-feed", { headers: headers(env) }, env); + const body = (await res.json()) as { events: Array<{ pullNumber: number | null }> }; + expect(body.events).toHaveLength(1); + expect(body.events[0]?.pullNumber).toBeNull(); + }); +});