From 8f833f13a19856d03ac56d7508f4ddf0fe619319 Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:45:16 +0900 Subject: [PATCH] fix(metrics): exclude dry-run shadow actions from the gate-outcome breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listGateOutcomeAuditEventRollups` counted every agent.action.{merge,close,hold} row whose outcome is a terminal disposition, but a dry-run shadow lands in exactly that set: agent-action-executor rewrites its outcome to "completed" and the mode discriminator lives only in metadata.mode. So a repo's gate-outcome breakdown inflated its auto-merge/close/hold counts with shadow actions that never touched a PR — and a repo whose window held only dry-run actions produced a zeroed-rate report instead of the "no events" summary. Add the same dry-run exclusion the public accuracy trend already applies (loadReversalDayRows) as a `sql` fragment inside the existing `and(...)`: `coalesce(json_extract(metadata_json, '$.mode'), 'live') <> 'dry_run'` — same COALESCE default of 'live' so a legacy row without a mode key is still counted. The pure builders and the rollup type are unchanged; the exclusion is in the query. Closes #9694 --- src/db/repositories.ts | 4 ++ test/unit/gate-outcome-audit-rollups.test.ts | 67 ++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index effd3ad547..c64a802fac 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -3765,6 +3765,10 @@ export async function listGateOutcomeAuditEventRollups( and( inArray(auditEvents.eventType, ["agent.action.merge", "agent.action.close", "agent.action.hold"]), gte(auditEvents.createdAt, options.sinceIso), + // Exclude dry-run shadow actions, which agent-action-executor rewrites to outcome "completed" and so + // otherwise land in the terminal-outcome buckets. Mirrors public-accuracy-trend.ts's loadReversalDayRows: + // same COALESCE default of 'live', so a legacy row without a mode key is still counted (#9694). + sql`coalesce(json_extract(${auditEvents.metadataJson}, '$.mode'), 'live') <> 'dry_run'`, repoFilter, ), ) diff --git a/test/unit/gate-outcome-audit-rollups.test.ts b/test/unit/gate-outcome-audit-rollups.test.ts index c77e4ed9b0..94978cf0e2 100644 --- a/test/unit/gate-outcome-audit-rollups.test.ts +++ b/test/unit/gate-outcome-audit-rollups.test.ts @@ -48,6 +48,73 @@ describe("listGateOutcomeAuditEventRollups (#2203)", () => { expect(rollups.some((row) => row.eventType === "agent.action.merge" && row.count > 1)).toBe(false); }); + it("excludes dry-run shadow actions but keeps live and legacy (no-mode) rows (#9694)", async () => { + const env = createTestEnv(); + // A dry-run shadow: agent-action-executor rewrites its outcome to "completed", so it would otherwise land + // in the autoMerged bucket. The metadata.mode discriminator is the only thing that distinguishes it. + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "loopover", + targetKey: "octo/demo#1", + outcome: "completed", + metadata: { mode: "dry_run" }, + createdAt: "2026-07-10T12:00:00.000Z", + }); + // A real live action (explicit mode: "live"). + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "loopover", + targetKey: "octo/demo#2", + outcome: "completed", + metadata: { mode: "live" }, + createdAt: "2026-07-10T13:00:00.000Z", + }); + // A legacy row with no mode key at all — COALESCE defaults to 'live', so it must still be counted. + await recordAuditEvent(env, { + eventType: "agent.action.close", + actor: "loopover", + targetKey: "octo/demo#3", + outcome: "success", + createdAt: "2026-07-10T14:00:00.000Z", + }); + + const rollups = await listGateOutcomeAuditEventRollups(env, { + repoFullNames: ["octo/demo"], + sinceIso: "2026-07-01T00:00:00.000Z", + }); + // The live merge and the legacy close are counted; the dry-run merge is not (merge count is 1, not 2). + expect(rollups).toEqual( + expect.arrayContaining([ + { eventType: "agent.action.merge", outcome: "completed", count: 1 }, + { eventType: "agent.action.close", outcome: "success", count: 1 }, + ]), + ); + expect(rollups.some((row) => row.eventType === "agent.action.merge" && row.count > 1)).toBe(false); + }); + + it("returns nothing for a repo whose window holds only dry-run shadow actions (#9694)", async () => { + const env = createTestEnv(); + await recordAuditEvent(env, { + eventType: "agent.action.merge", + actor: "loopover", + targetKey: "octo/dry#1", + outcome: "completed", + metadata: { mode: "dry_run" }, + createdAt: "2026-07-10T12:00:00.000Z", + }); + await recordAuditEvent(env, { + eventType: "agent.action.close", + actor: "loopover", + targetKey: "octo/dry#2", + outcome: "completed", + metadata: { mode: "dry_run" }, + createdAt: "2026-07-10T13:00:00.000Z", + }); + await expect( + listGateOutcomeAuditEventRollups(env, { repoFullNames: ["octo/dry"], sinceIso: "2026-07-01T00:00:00.000Z" }), + ).resolves.toEqual([]); + }); + it("returns an empty rollup list when the scoped repo list is empty", async () => { const env = createTestEnv(); await expect(listGateOutcomeAuditEventRollups(env, { repoFullNames: [], sinceIso: "2026-07-01T00:00:00.000Z" })).resolves.toEqual([]);