From 958ee4b4dba51c07c7780b52c1a73cf5bb0d322b Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:13:20 -0700 Subject: [PATCH] fix(db): escape review burst repo prefix matching --- src/db/repositories.ts | 11 ++++++++--- test/unit/db-parsers.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/db/repositories.ts b/src/db/repositories.ts index 7fed01f327..6509303b38 100644 --- a/src/db/repositories.ts +++ b/src/db/repositories.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq, gte, inArray, like, not, or, sql, type SQL } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, not, or, sql, type SQL } from "drizzle-orm"; import { getDb } from "./client"; import { activeReviewTracking, @@ -2595,26 +2595,31 @@ export async function countRecentAuditEventsForActorAndTarget(env: Env, actor: s * overwrites rather than accumulates), this correctly counts repeat publishes even when the head SHA never * changes -- exactly the shape of a stuck-CI or sweep retry-storm bleed. Returns null when the repo published * no surfaces in the window at all. */ +function escapeSqlLikePattern(value: string): string { + return value.replace(/[\\%_]/g, "\\$&"); +} + export async function findHottestReviewTargetForRepo( env: Env, repoFullName: string, sinceIso: string, ): Promise<{ targetKey: string; count: number } | null> { const db = getDb(env.DB); + const targetPrefixPattern = `${escapeSqlLikePattern(repoFullName)}#%`; const [row] = await db .select({ targetKey: auditEvents.targetKey, count: sql`count(*)` }) .from(auditEvents) .where( and( eq(auditEvents.eventType, "github_app.pr_public_surface_published"), - like(auditEvents.targetKey, `${repoFullName}#%`), + sql`${auditEvents.targetKey} LIKE ${targetPrefixPattern} ESCAPE '\\'`, gte(auditEvents.createdAt, sinceIso), ), ) .groupBy(auditEvents.targetKey) .orderBy(desc(sql`count(*)`)) .limit(1); - /* v8 ignore next -- the WHERE clause's `like(auditEvents.targetKey, ...)` can never match a NULL target_key + /* v8 ignore next -- the WHERE clause's escaped LIKE predicate can never match a NULL target_key * (SQL LIKE against NULL is NULL, never true), so a returned row always has a non-null targetKey; the * column's nullable TS type is a schema-wide default this specific query structurally rules out. */ if (!row || row.targetKey === null) return null; diff --git a/test/unit/db-parsers.test.ts b/test/unit/db-parsers.test.ts index b87fedefc0..9b555abef3 100644 --- a/test/unit/db-parsers.test.ts +++ b/test/unit/db-parsers.test.ts @@ -608,6 +608,25 @@ describe("database row parser hardening", () => { expect(await findHottestReviewTargetForRepo(env, "owner/nothing-here", "2026-06-24T09:00:00.000Z")).toBeNull(); }); + it("findHottestReviewTargetForRepo treats repo names as literal LIKE prefixes (regression for review-burst scope pollution)", async () => { + const env = createTestEnv(); + const publish = (targetKey: string, createdAt: string) => + recordAuditEvent(env, { eventType: "github_app.pr_public_surface_published", actor: "contributor", targetKey, outcome: "completed", createdAt }); + + await publish("owner/foo_bar#1", "2026-06-24T10:00:00.000Z"); + await publish("owner/foo_bar#1", "2026-06-24T10:05:00.000Z"); + await publish("owner/foo_bar#1", "2026-06-24T10:10:00.000Z"); + await publish("owner/fooXbar#99", "2026-06-24T10:00:00.000Z"); + await publish("owner/fooXbar#99", "2026-06-24T10:05:00.000Z"); + await publish("owner/fooXbar#99", "2026-06-24T10:10:00.000Z"); + await publish("owner/fooXbar#99", "2026-06-24T10:15:00.000Z"); + + expect(await findHottestReviewTargetForRepo(env, "owner/foo_bar", "2026-06-24T09:00:00.000Z")).toEqual({ + targetKey: "owner/foo_bar#1", + count: 3, + }); + }); + it("hasAuditEventForDelivery finds a matching deliveryId inside metadata_json, scoped to actor+eventType+targetKey (#2560)", async () => { const env = createTestEnv(); await recordAuditEvent(env, {