diff --git a/platform/src/app/api/ingest/usage/route.ts b/platform/src/app/api/ingest/usage/route.ts index 82e7d1a..c08bb23 100644 --- a/platform/src/app/api/ingest/usage/route.ts +++ b/platform/src/app/api/ingest/usage/route.ts @@ -83,13 +83,27 @@ function bareRepoName(repo: string): string { return parts.length ? parts[parts.length - 1] : repo; } -async function resolveRepositoryId( +/** + * Look up an EXISTING repository for this org by bare name. Returns its id, or + * null when the org has no such repo. + * + * Deliberately never creates rows. A repository belongs to an org only once + * durability metrics have been pushed for it (via /api/ingest); usage is + * enrichment that attaches to an already-onboarded repo. If usage could + * materialize repositories, a machine-global usage spool flushed under one + * org's token would leak every personal repo the developer ran the agent in + * into that org's repo list — each showing "0 runs" because it has usage but no + * analysis run. Unknown repos are skipped by the caller instead. + * + * Misses are cached (value null) so an unknown repo in a large batch is looked + * up once, not once per record. + */ +async function resolveExistingRepositoryId( organizationId: string, repoName: string, - cache: Map, + cache: Map, ): Promise { - const cached = cache.get(repoName); - if (cached) return cached; + if (cache.has(repoName)) return cache.get(repoName) ?? null; const { data: existing } = await supabaseAdmin .from("repositories") @@ -98,20 +112,8 @@ async function resolveRepositoryId( .eq("name", repoName) .single(); - let id: string | null = null; - if (existing) { - id = existing.id; - } else { - const { data: created, error } = await supabaseAdmin - .from("repositories") - .insert({ organization_id: organizationId, name: repoName }) - .select("id") - .single(); - if (error || !created) return null; - id = created.id; - } - - if (id) cache.set(repoName, id); + const id: string | null = existing?.id ?? null; + cache.set(repoName, id); return id; } @@ -166,21 +168,23 @@ export async function POST(request: Request) { } // 5. Resolve repos + apply each record via the additive upsert RPC. - const repoCache = new Map(); + // Usage only enriches repos the org already tracks; usage for any other + // repo (e.g. a developer's personal repo picked up by the machine-global + // spool) is dropped here at the org boundary. + const repoCache = new Map(); let applied = 0; let duplicates = 0; + let skipped = 0; for (const record of parsed.data.records) { - const repoId = await resolveRepositoryId( + const repoId = await resolveExistingRepositoryId( tokenData.organization_id, bareRepoName(record.repo), repoCache, ); if (!repoId) { - return Response.json( - { error: "Failed to resolve repository", repo: record.repo }, - { status: 500 }, - ); + skipped += 1; + continue; } const { data: wasApplied, error } = await supabaseAdmin.rpc( @@ -215,14 +219,18 @@ export async function POST(request: Request) { else applied += 1; } + // Distinct repos that actually matched (cache holds null for misses). + const matchedRepos = [...repoCache.values()].filter(Boolean).length; + parentSpan.setAttributes({ "iris.usage.applied": applied, "iris.usage.duplicates": duplicates, - "iris.usage.repositories": repoCache.size, + "iris.usage.skipped": skipped, + "iris.usage.repositories": matchedRepos, }); return Response.json( - { applied, duplicates, repositories: repoCache.size }, + { applied, duplicates, skipped, repositories: matchedRepos }, { status: 200 }, ); }, diff --git a/platform/tests/ingest-usage-api-route.test.ts b/platform/tests/ingest-usage-api-route.test.ts index 0b8603e..e5838af 100644 --- a/platform/tests/ingest-usage-api-route.test.ts +++ b/platform/tests/ingest-usage-api-route.test.ts @@ -58,6 +58,20 @@ function existingRepoChain(id = "repo-1") { return chain; } +// A repositories query chain for a repo the org has never onboarded. `.single()` +// resolves with no row (PostgREST's shape for "0 rows"). Exposes `insert` so +// tests can assert usage ingestion never creates repositories. +function missingRepoChain() { + const chain: Record = {}; + chain.select = vi.fn(() => chain); + chain.eq = vi.fn(() => chain); + chain.single = vi.fn(() => + Promise.resolve({ data: null, error: { code: "PGRST116" } }), + ); + chain.insert = vi.fn(() => chain); + return chain; +} + function makeRequest( body: unknown, authorization = "Bearer iris_test", @@ -126,7 +140,12 @@ describe("POST /api/ingest/usage", () => { const res = await POST(makeRequest({ records: [VALID_RECORD] })); expect(res.status).toBe(200); const json = await res.json(); - expect(json).toEqual({ applied: 1, duplicates: 0, repositories: 1 }); + expect(json).toEqual({ + applied: 1, + duplicates: 0, + skipped: 0, + repositories: 1, + }); expect(mockedRpc).toHaveBeenCalledWith( "ingest_usage_rollup", @@ -157,7 +176,32 @@ describe("POST /api/ingest/usage", () => { mockedRpc.mockResolvedValue({ data: false, error: null }); const res = await POST(makeRequest({ records: [VALID_RECORD] })); const json = await res.json(); - expect(json).toEqual({ applied: 0, duplicates: 1, repositories: 1 }); + expect(json).toEqual({ + applied: 0, + duplicates: 1, + skipped: 0, + repositories: 1, + }); + }); + + it("skips usage for a repo the org has not onboarded — never creates it", async () => { + const chain = missingRepoChain(); + mockedFrom.mockReturnValue(chain); + + const res = await POST(makeRequest({ records: [VALID_RECORD] })); + + expect(res.status).toBe(200); + const json = await res.json(); + expect(json).toEqual({ + applied: 0, + duplicates: 0, + skipped: 1, + repositories: 0, + }); + // The whole point: usage never materializes a repository row, and unknown + // repos never reach the rollup. + expect(chain.insert).not.toHaveBeenCalled(); + expect(mockedRpc).not.toHaveBeenCalled(); }); it("500 when the RPC errors", async () => {