diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 7eeb1e007a..20888d29c8 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -14536,63 +14536,115 @@ "get": { "responses": { "200": { - "description": "Maintainer-scoped agent audit feed (#784): executed actions + approval-queue decisions, newest first, public-safe action posture only. Supports ?since=ISO-8601&limit=1-200.", + "description": "Maintainer-scoped agent audit feed (#784): executed actions + approval-queue decisions, newest first, public-safe action posture only. Supports ?since=ISO-8601&limit=1-200. ?pull=N opts into the unfiltered sibling query: every audit_events row for that one PR's targetKey (no eventType restriction), still maintainer-gated and detail-sanitized the same way.", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "events": { - "type": "array", - "items": { - "type": "object", - "properties": { - "eventType": { - "type": "string" - }, - "pullNumber": { - "type": "number", - "nullable": true - }, - "outcome": { - "type": "string" - }, - "actor": { - "type": "string", - "nullable": true - }, - "detail": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "eventType": { + "type": "string" + }, + "pullNumber": { + "type": "number", + "nullable": true + }, + "outcome": { + "type": "string" + }, + "actor": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "eventType", + "pullNumber", + "outcome", + "actor", + "detail", + "createdAt" + ] } + } + }, + "required": [ + "repoFullName", + "events" + ] + }, + { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" }, - "required": [ - "eventType", - "pullNumber", - "outcome", - "actor", - "detail", - "createdAt" - ] - } + "pullNumber": { + "type": "number" + }, + "events": { + "type": "array", + "items": { + "type": "object", + "properties": { + "eventType": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "actor": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "eventType", + "outcome", + "actor", + "detail", + "createdAt" + ] + } + } + }, + "required": [ + "repoFullName", + "pullNumber", + "events" + ] } - }, - "required": [ - "repoFullName", - "events" ] } } } }, "400": { - "description": "Malformed since (not ISO-8601) or limit (not an integer in 1-200)" + "description": "Malformed since (not ISO-8601), limit (not an integer in 1-200), or pull (not a positive integer)" }, "403": { "description": "Insufficient role" diff --git a/src/api/routes.ts b/src/api/routes.ts index 2949e9e9aa..6d6c4abc1f 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -50,6 +50,7 @@ import { getRepositorySettings, getPendingAgentAction, listAgentAuditEvents, + listAuditEventsForTarget, listPendingAgentActions, recordAuditEvent, getContributorEvidence, @@ -2412,6 +2413,9 @@ export function createApp() { // #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 + // that one PR's targetKey, not just the agent.action.%/agent.pending_action.% subset — still maintainer-gated + // by the same requireRepoMaintainer check above, just scoped to a single PR instead of the whole repo. 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); @@ -2426,6 +2430,22 @@ export function createApp() { if (!Number.isInteger(parsed) || parsed < 1 || parsed > 200) return c.json({ error: "invalid_limit", detail: "limit must be an integer between 1 and 200" }, 400); limit = parsed; } + const pullParam = c.req.query("pull"); + if (pullParam !== undefined) { + const pullNumber = Number(pullParam); + if (!Number.isInteger(pullNumber) || pullNumber <= 0) return c.json({ error: "invalid_pull", detail: "pull must be a positive integer" }, 400); + const targetEvents = await listAuditEventsForTarget(c.env, { + repoFullName: fullName, + pullNumber, + ...(since !== undefined ? { sinceIso: since } : {}), + ...(limit !== undefined ? { limit } : {}), + }); + return c.json({ + repoFullName: fullName, + pullNumber, + events: targetEvents.map((event) => ({ ...event, detail: event.detail === null ? null : sanitizePublicComment(event.detail) })), + }); + } const events = await listAgentAuditEvents(c.env, { repoFullName: fullName, ...(since !== undefined ? { sinceIso: since } : {}), diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 0e3a6e096a..ad7c33026d 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3035,6 +3035,42 @@ export async function listAgentAuditEvents( })); } +// Unfiltered sibling of `listAgentAuditEvents`: same `repo#pr` targetKey correlation (exact match, not just +// the repo-prefix range, since a single PR's full history is the whole point here), but with NO `eventType` +// restriction -- every one of the ~140 event types this table records is eligible, not just the +// `agent.action.%`/`agent.pending_action.%` subset the public audit-feed exposes. Maintainer-gated at the +// route layer; this function itself does no authorization, matching every other list* helper in this file. +export type AuditEventForTarget = { + eventType: string; + outcome: string; + actor: string | null; + detail: string | null; + createdAt: string; +}; + +export async function listAuditEventsForTarget( + env: Env, + options: { repoFullName: string; pullNumber: number; sinceIso?: string | undefined; limit?: number | undefined }, +): Promise { + const limit = clampInteger(options.limit ?? 50, 1, 200); + const targetKey = `${options.repoFullName}#${options.pullNumber}`; + const conditions: SQL[] = [eq(sql`lower(${auditEvents.targetKey})`, targetKey.toLowerCase())]; + if (options.sinceIso) conditions.push(gte(auditEvents.createdAt, options.sinceIso)); + const rows = await getDb(env.DB) + .select({ eventType: auditEvents.eventType, 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, + 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/openapi/spec.ts b/src/openapi/spec.ts index 52833558bf..0b77fba715 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -442,26 +442,43 @@ export function buildOpenApiSpec() { path: "/v1/repos/{owner}/{repo}/agent/audit-feed", responses: { 200: { - description: "Maintainer-scoped agent audit feed (#784): executed actions + approval-queue decisions, newest first, public-safe action posture only. Supports ?since=ISO-8601&limit=1-200.", + description: + "Maintainer-scoped agent audit feed (#784): executed actions + approval-queue decisions, newest first, public-safe action posture only. Supports ?since=ISO-8601&limit=1-200. " + + "?pull=N opts into the unfiltered sibling query: every audit_events row for that one PR's targetKey (no eventType restriction), still maintainer-gated and detail-sanitized the same way.", content: { "application/json": { - schema: z.object({ - repoFullName: z.string(), - 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(), - }), - ), - }), + schema: z.union([ + z.object({ + repoFullName: z.string(), + 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(), + }), + ), + }), + z.object({ + repoFullName: z.string(), + pullNumber: z.number(), + events: z.array( + z.object({ + eventType: z.string(), + outcome: z.string(), + actor: z.string().nullable(), + detail: z.string().nullable(), + createdAt: z.string(), + }), + ), + }), + ]), }, }, }, - 400: { description: "Malformed since (not ISO-8601) or limit (not an integer in 1-200)" }, + 400: { description: "Malformed since (not ISO-8601), limit (not an integer in 1-200), or pull (not a positive integer)" }, 403: { description: "Insufficient role" }, }, }); diff --git a/src/queue/processors.ts b/src/queue/processors.ts index f09b83bdf0..c5fdf00ff5 100644 --- a/src/queue/processors.ts +++ b/src/queue/processors.ts @@ -8164,6 +8164,19 @@ async function maybePublishPrPublicSurface( source: decisionResult.source, }), ); + await recordAuditEvent(env, { + eventType: "github_app.type_label_decision", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "completed", + // `|| "none"` is unreachable: resolvePrTypeLabel's "title" source always resolves a non-empty + // label (deriveKindFromTitle only ever returns "bug"/"feature", and parseTypeLabelSet always + // falls back a built-in category to its default rather than an empty string), and its + // propagation sources only ever use a mapping's `prLabel`, which normalizeMapping drops + // entirely when empty -- applyLabels can never be [] here. + /* v8 ignore next */ + detail: `applied labels: ${decisionResult.applyLabels.join(", ") || "none"}`, + metadata: { labels: decisionResult.applyLabels, source: decisionResult.source }, + }).catch(() => undefined); } catch (error) { console.log( JSON.stringify({ @@ -8173,22 +8186,36 @@ async function maybePublishPrPublicSurface( message: errorMessage(error).slice(0, 150), }), ); + await recordAuditEvent(env, { + eventType: "github_app.type_label_decision", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "error", + detail: errorMessage(error).slice(0, 150), + metadata: { labels: [], source: null }, + }).catch(() => undefined); } } else { + const skipReason = settings.agentPaused + ? "agent_paused" + : decision.skipReason === "miner_detection_unavailable" || decision.skipReason === "not_official_gittensor_miner" + ? decision.skipReason + : "typeLabelsEnabled_false"; console.log( JSON.stringify({ event: "type_label_decision", repoFullName, pull: pr.number, applied: false, - reason: settings.agentPaused - ? "agent_paused" - : decision.skipReason === "miner_detection_unavailable" || - decision.skipReason === "not_official_gittensor_miner" - ? decision.skipReason - : "typeLabelsEnabled_false", + reason: skipReason, }), ); + await recordAuditEvent(env, { + eventType: "github_app.type_label_decision", + targetKey: `${repoFullName}#${pr.number}`, + outcome: "denied", + detail: skipReason, + metadata: { labels: [], source: null }, + }).catch(() => undefined); } // Respect the per-repo agent pause: suppress all public surface mutations (label, comment, context diff --git a/test/unit/queue.test.ts b/test/unit/queue.test.ts index ba7e9176d6..67fafed072 100644 --- a/test/unit/queue.test.ts +++ b/test/unit/queue.test.ts @@ -23467,6 +23467,26 @@ describe("queue processors", () => { }); } + // Fails only the ONE audit_events insert whose bound values include `needle` (e.g. a specific + // eventType), leaving every other audit write in the same job untouched -- a blanket "throw on any + // audit_events insert" (as the sibling #orb-ci-stuck-repeat fail-open tests use for a narrower job + // type) breaks unrelated earlier writes on the fuller pull_request webhook path used here. + function failAuditEventInsertsContaining(env: Env, needle: string) { + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = ((sql: string) => { + const statement = realPrepare(sql); + if (!/insert\s+into\s+["`]?audit_events["`]?/i.test(sql)) return statement; + return { + ...statement, + bind(...values: unknown[]) { + const bound = statement.bind(...(values as never[])); + if (!values.some((value) => typeof value === "string" && value.includes(needle))) return bound; + return { ...bound, run: () => Promise.reject(new Error("audit write failed")) }; + }, + }; + }) as typeof env.DB.prepare; + } + it("applies the type label when oss_maintainer mode + an unconfirmed miner suppress the context label", async () => { const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); @@ -23996,6 +24016,112 @@ describe("queue processors", () => { expect(seen.posted).toEqual(["gittensor:bug"]); expect(seen.removed.sort()).toEqual(["gittensor:feature", "gittensor:priority"]); }); + + it("records the audit event for a normal applied label decision (#label-decoupling audit)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(219, seen); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-recorded", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 219, title: "fix: broken pagination", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha219" }, labels: [], body: "Fixes #1" }, + }, + }); + + expect(seen.posted).toEqual(["gittensor:bug"]); + const labelEvent = await env.DB.prepare("select outcome, detail from audit_events where event_type = ? and target_key = ?") + .bind("github_app.type_label_decision", "JSONbored/gittensory#219") + .first<{ outcome: string; detail: string }>(); + expect(labelEvent?.outcome).toBe("completed"); + expect(labelEvent?.detail).toBe("applied labels: gittensor:bug"); + }); + + it("does not let a failing audit write stop label application (completed outcome, fail-open)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "label_only", + autoLabelEnabled: true, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(220, seen); + failAuditEventInsertsContaining(env, "github_app.type_label_decision"); + + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-completed-audit-fail", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 220, title: "fix: broken pagination", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha220" }, labels: [], body: "Fixes #1" }, + }, + }); + + // The label application itself must complete even though its audit-event write threw. + expect(seen.posted).toEqual(["gittensor:bug"]); + }); + + it("does not let a failing audit write stop the decision when type labels are disabled (denied outcome, fail-open)", async () => { + const env = createTestEnv({ GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem() }); + await upsertRepositoryFromGitHub(env, { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, 123); + await upsertRepositorySettings(env, { + repoFullName: "JSONbored/gittensory", + commentMode: "off", + publicSurface: "off", + autoLabelEnabled: true, + typeLabelsEnabled: false, + createMissingLabel: false, + checkRunMode: "off", + gateCheckMode: "enabled", + linkedIssueGateMode: "off", + aiReviewMode: "off", + }); + const seen = { posted: [] as string[], removed: [] as string[], checkRunCreated: false }; + stubTypeLabelFetch(221, seen); + failAuditEventInsertsContaining(env, "github_app.type_label_decision"); + + // Fail-open: the webhook job must still complete (and still reach the type-label decision) even + // though recording it fails. + await processJob(env, { + type: "github-webhook", + deliveryId: "type-label-denied-audit-fail", + eventName: "pull_request", + payload: { + action: "opened", + installation: { id: 123, account: { login: "JSONbored", id: 1, type: "User" } }, + repository: { name: "gittensory", full_name: "JSONbored/gittensory", private: false, owner: { login: "JSONbored" } }, + pull_request: { number: 221, title: "fix: broken pagination", state: "open", user: { login: "contributor" }, author_association: "NONE", head: { sha: "sha221" }, labels: [], body: "Fixes #1" }, + }, + }); + expect(seen.posted).toEqual([]); + }); }); }); diff --git a/test/unit/routes-agent-approval.test.ts b/test/unit/routes-agent-approval.test.ts index 73e5d8931a..a97e4add0e 100644 --- a/test/unit/routes-agent-approval.test.ts +++ b/test/unit/routes-agent-approval.test.ts @@ -292,4 +292,72 @@ describe("agent audit-feed route (#784)", () => { expect(body.events[0]?.detail).not.toMatch(/reward/i); expect(body.events[0]?.detail).toContain("private context"); }); + + describe("?pull=N unfiltered target query", () => { + async function seedUnfilteredAudit(env: Env) { + // Unlike seedAudit above, none of these are agent.action.%/agent.pending_action.% -- proving the + // ?pull= branch carries NO eventType restriction (the whole point of listAuditEventsForTarget). + await recordAuditEvent(env, { eventType: "github_app.type_label_decision", actor: "gittensory", targetKey: "owner/repo#7", outcome: "completed", detail: "applied labels: gittensor:bug", createdAt: "2026-06-18T10:00:00.000Z" }); + await recordAuditEvent(env, { eventType: "github_app.pr_visibility_skipped", actor: "x", targetKey: "owner/repo#7", outcome: "completed", detail: "not_official_gittensor_miner", createdAt: "2026-06-18T11:00:00.000Z" }); + // excluded: a different PR on the same repo, and a same-number PR on a different repo. + await recordAuditEvent(env, { eventType: "github_app.type_label_decision", actor: "gittensory", targetKey: "owner/repo#8", outcome: "completed", createdAt: "2026-06-18T12:00:00.000Z" }); + await recordAuditEvent(env, { eventType: "github_app.type_label_decision", actor: "gittensory", targetKey: "other/repo#7", outcome: "completed", createdAt: "2026-06-18T13:00:00.000Z" }); + } + + it("returns every event type for the single PR's targetKey, newest-first, excluding other targets", async () => { + const env = createTestEnv(); + await seedUnfilteredAudit(env); + const res = await app.request("/v1/repos/owner/repo/agent/audit-feed?pull=7", { headers: headers(env) }, env); + expect(res.status).toBe(200); + const body = (await res.json()) as { repoFullName: string; pullNumber: number; events: Array<{ eventType: string; outcome: string }> }; + expect(body.repoFullName).toBe("owner/repo"); + expect(body.pullNumber).toBe(7); + expect(body.events.map((event) => event.eventType)).toEqual(["github_app.pr_visibility_skipped", "github_app.type_label_decision"]); + }); + + it("honors since and limit on the ?pull= branch", async () => { + const env = createTestEnv(); + await seedUnfilteredAudit(env); + const since = await app.request("/v1/repos/owner/repo/agent/audit-feed?pull=7&since=2026-06-18T10:30:00.000Z", { headers: headers(env) }, env); + expect(((await since.json()) as { events: unknown[] }).events).toHaveLength(1); + const limited = await app.request("/v1/repos/owner/repo/agent/audit-feed?pull=7&limit=1", { headers: headers(env) }, env); + expect(((await limited.json()) as { events: unknown[] }).events).toHaveLength(1); + }); + + it("rejects a non-positive-integer pull with 400", async () => { + const env = createTestEnv(); + for (const bad of ["0", "-1", "abc", "1.5"]) { + const res = await app.request(`/v1/repos/owner/repo/agent/audit-feed?pull=${bad}`, { headers: headers(env) }, env); + expect(res.status, `pull=${bad}`).toBe(400); + await expect(res.json()).resolves.toMatchObject({ error: "invalid_pull" }); + } + }); + + it("still requires maintainer auth on the ?pull= branch", async () => { + const env = createTestEnv(); + await seedUnfilteredAudit(env); + const noauth = await app.request("/v1/repos/owner/repo/agent/audit-feed?pull=7", {}, 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?pull=7", { headers: { authorization: `Bearer ${token}` } }, env); + expect([401, 403]).toContain(forbidden.status); + }); + + it("scrubs forbidden terms from detail on the ?pull= branch too", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { eventType: "github_app.type_label_decision", actor: "gittensory", targetKey: "owner/repo#7", outcome: "completed", detail: "reward estimate leaked", createdAt: "2026-06-18T10:00:00.000Z" }); + const res = await app.request("/v1/repos/owner/repo/agent/audit-feed?pull=7", { headers: headers(env) }, env); + const body = (await res.json()) as { events: Array<{ detail: string | null }> }; + expect(body.events[0]?.detail).not.toMatch(/reward/i); + expect(body.events[0]?.detail).toContain("private context"); + }); + + it("passes through a null detail on the ?pull= branch unchanged (no sanitizer call on a null)", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { eventType: "github_app.type_label_decision", actor: "gittensory", targetKey: "owner/repo#7", outcome: "completed", detail: null, createdAt: "2026-06-18T10:00:00.000Z" }); + const res = await app.request("/v1/repos/owner/repo/agent/audit-feed?pull=7", { headers: headers(env) }, env); + const body = (await res.json()) as { events: Array<{ detail: string | null }> }; + expect(body.events[0]?.detail).toBeNull(); + }); + }); });