diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index 37776f646..bfab04e8a 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -21,6 +21,8 @@ import type { SentryProject, } from "../../types/index.js"; +import { cacheProjectsForOrg } from "../db/project-cache.js"; +import { getCachedOrganizations } from "../db/regions.js"; import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; import { logger } from "../logger.js"; import { getApiBaseUrl } from "../sentry-client.js"; @@ -81,6 +83,16 @@ export async function listProjects(orgSlug: string): Promise { } } + // Populate project cache for shell completions (best-effort). + // Mirrors how listOrganizations() calls setOrgRegions(). + try { + const orgs = await getCachedOrganizations(); + const orgName = orgs.find((o) => o.slug === orgSlug)?.name ?? orgSlug; + cacheProjectsForOrg(orgSlug, orgName, allResults); + } catch { + // Cache population is best-effort — never fail the command + } + return allResults; } diff --git a/src/lib/db/project-cache.ts b/src/lib/db/project-cache.ts index 104fef2ae..035bf42f1 100644 --- a/src/lib/db/project-cache.ts +++ b/src/lib/db/project-cache.ts @@ -166,6 +166,52 @@ export async function getCachedProjectsForOrg( })); } +/** + * Batch-cache projects for an organization. + * + * Called from `listProjects()` at the API layer so every command that + * lists projects (project list, findProjectsByPattern, etc.) automatically + * seeds the completion cache. Follows the `setOrgRegions()` pattern. + * + * @param orgSlug - Organization slug + * @param orgName - Organization display name + * @param projects - Projects to cache (id, slug, name from SentryProject) + */ +export function cacheProjectsForOrg( + orgSlug: string, + orgName: string, + projects: Array<{ id: string; slug: string; name: string }> +): void { + if (projects.length === 0) { + return; + } + + const db = getDatabase(); + const now = Date.now(); + + db.transaction(() => { + for (const p of projects) { + runUpsert( + db, + "project_cache", + { + cache_key: `list:${orgSlug}/${p.slug}`, + org_slug: orgSlug, + org_name: orgName, + project_slug: p.slug, + project_name: p.name, + project_id: p.id, + cached_at: now, + last_accessed: now, + }, + ["cache_key"] + ); + } + })(); + + maybeCleanupCaches(); +} + export async function clearProjectCache(): Promise { const db = getDatabase(); db.query("DELETE FROM project_cache").run(); diff --git a/test/lib/db/project-cache.test.ts b/test/lib/db/project-cache.test.ts index fbf365b7f..adf87cd99 100644 --- a/test/lib/db/project-cache.test.ts +++ b/test/lib/db/project-cache.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from "bun:test"; import { + cacheProjectsForOrg, clearProjectCache, getCachedProject, getCachedProjectByDsnKey, @@ -350,6 +351,68 @@ describe("cache key uniqueness", () => { }); }); +describe("cacheProjectsForOrg", () => { + test("caches multiple projects in one call", async () => { + cacheProjectsForOrg("my-org", "My Org", [ + { id: "1", slug: "frontend", name: "Frontend" }, + { id: "2", slug: "backend", name: "Backend" }, + { id: "3", slug: "mobile", name: "Mobile App" }, + ]); + + const result = await getCachedProjectsForOrg("my-org"); + expect(result).toHaveLength(3); + expect(result).toContainEqual({ + projectSlug: "frontend", + projectName: "Frontend", + }); + expect(result).toContainEqual({ + projectSlug: "backend", + projectName: "Backend", + }); + expect(result).toContainEqual({ + projectSlug: "mobile", + projectName: "Mobile App", + }); + }); + + test("is idempotent on repeated calls", async () => { + const projects = [ + { id: "1", slug: "frontend", name: "Frontend" }, + { id: "2", slug: "backend", name: "Backend" }, + ]; + + cacheProjectsForOrg("my-org", "My Org", projects); + cacheProjectsForOrg("my-org", "My Org", projects); + + const result = await getCachedProjectsForOrg("my-org"); + expect(result).toHaveLength(2); + }); + + test("empty array is a no-op", async () => { + cacheProjectsForOrg("my-org", "My Org", []); + const result = await getCachedProjectsForOrg("my-org"); + expect(result).toEqual([]); + }); + + test("does not conflict with orgId:projectId cache entries", async () => { + await setCachedProject("org-123", "proj-456", { + orgSlug: "my-org", + orgName: "My Org", + projectSlug: "frontend", + projectName: "Frontend (by DSN)", + }); + + cacheProjectsForOrg("my-org", "My Org", [ + { id: "456", slug: "frontend", name: "Frontend (by list)" }, + ]); + + // Both entries exist — getCachedProjectsForOrg deduplicates by slug + const result = await getCachedProjectsForOrg("my-org"); + expect(result).toHaveLength(1); + expect(result[0]?.projectSlug).toBe("frontend"); + }); +}); + describe("getCachedProjectsForOrg", () => { test("returns empty array when no projects for org", async () => { const result = await getCachedProjectsForOrg("nonexistent-org");