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
18 changes: 18 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
getRepoQueueTrendSnapshot,
getRepositorySettings,
getPendingAgentAction,
listAgentAuditEvents,
listPendingAgentActions,
recordAuditEvent,
getContributorEvidence,
Expand Down Expand Up @@ -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) => {
Expand Down
41 changes: 41 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<class>`)
// 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<AgentAuditEvent[]> {
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<OfficialGittensorMinerDetection | null> {
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;
Expand Down
52 changes: 52 additions & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getRepository,
getRepositorySettings,
getRepoQueueTrendSnapshot,
listAgentAuditEvents,
listCheckSummaries,
listPendingAgentActions,
listContributorRepoStats,
Expand Down Expand Up @@ -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), {
Expand Down Expand Up @@ -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",
{
Expand Down Expand Up @@ -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<z.ZodObject<typeof auditFeedShape>>): Promise<ToolPayload> {
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<z.ZodObject<typeof scorePreviewShape>>): Promise<ToolPayload> {
if (!input.contributorLogin) throw new Error("contributorLogin is required for score breakdown.");
this.requireContributorAccess(input.contributorLogin);
Expand Down
46 changes: 45 additions & 1 deletion test/unit/mcp-automation-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
});
});
51 changes: 50 additions & 1 deletion test/unit/routes-agent-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
});