Skip to content

Commit b600314

Browse files
betegonclaude
andauthored
fix(completions): populate project cache from listProjects (#517)
## Summary Shell completions showed org slugs but not projects after the slash. The completion PR (#465) described "lazy project cache population" but only DSN resolution actually wrote to the `project_cache` table. Commands like `project list`, `issue list`, etc. fetched projects but never cached them for completions. Adds `cacheProjectsForOrg()` at the API layer (in `listProjects()`), mirroring how `listOrganizations()` calls `setOrgRegions()`. Every command that lists projects now automatically seeds the completion cache. ## Changes - `src/lib/db/project-cache.ts` — Add `cacheProjectsForOrg()` batch function using transactional upsert (same pattern as `setOrgRegions()`) - `src/lib/api/projects.ts` — Call cache after `listProjects()` returns (best-effort, wrapped in try/catch) - `test/lib/db/project-cache.test.ts` — 4 new tests: batch insert, idempotency, empty no-op, no conflict with DSN cache keys ## Test Plan - [x] `bun test test/lib/db/project-cache.test.ts` — 26 tests pass (4 new) - [x] `bun test test/lib/complete.test.ts` — 20 tests pass - [x] `bun run typecheck` — clean - [x] `bun run lint` — clean - Manual: run `sentry project list myorg/`, then `sentry issue list myorg/<TAB>` — projects now complete Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7d2e2b2 commit b600314

File tree

3 files changed

+121
-0
lines changed

3 files changed

+121
-0
lines changed

src/lib/api/projects.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type {
2121
SentryProject,
2222
} from "../../types/index.js";
2323

24+
import { cacheProjectsForOrg } from "../db/project-cache.js";
25+
import { getCachedOrganizations } from "../db/regions.js";
2426
import { type AuthGuardSuccess, withAuthGuard } from "../errors.js";
2527
import { logger } from "../logger.js";
2628
import { getApiBaseUrl } from "../sentry-client.js";
@@ -81,6 +83,16 @@ export async function listProjects(orgSlug: string): Promise<SentryProject[]> {
8183
}
8284
}
8385

86+
// Populate project cache for shell completions (best-effort).
87+
// Mirrors how listOrganizations() calls setOrgRegions().
88+
try {
89+
const orgs = await getCachedOrganizations();
90+
const orgName = orgs.find((o) => o.slug === orgSlug)?.name ?? orgSlug;
91+
cacheProjectsForOrg(orgSlug, orgName, allResults);
92+
} catch {
93+
// Cache population is best-effort — never fail the command
94+
}
95+
8496
return allResults;
8597
}
8698

src/lib/db/project-cache.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,52 @@ export async function getCachedProjectsForOrg(
166166
}));
167167
}
168168

169+
/**
170+
* Batch-cache projects for an organization.
171+
*
172+
* Called from `listProjects()` at the API layer so every command that
173+
* lists projects (project list, findProjectsByPattern, etc.) automatically
174+
* seeds the completion cache. Follows the `setOrgRegions()` pattern.
175+
*
176+
* @param orgSlug - Organization slug
177+
* @param orgName - Organization display name
178+
* @param projects - Projects to cache (id, slug, name from SentryProject)
179+
*/
180+
export function cacheProjectsForOrg(
181+
orgSlug: string,
182+
orgName: string,
183+
projects: Array<{ id: string; slug: string; name: string }>
184+
): void {
185+
if (projects.length === 0) {
186+
return;
187+
}
188+
189+
const db = getDatabase();
190+
const now = Date.now();
191+
192+
db.transaction(() => {
193+
for (const p of projects) {
194+
runUpsert(
195+
db,
196+
"project_cache",
197+
{
198+
cache_key: `list:${orgSlug}/${p.slug}`,
199+
org_slug: orgSlug,
200+
org_name: orgName,
201+
project_slug: p.slug,
202+
project_name: p.name,
203+
project_id: p.id,
204+
cached_at: now,
205+
last_accessed: now,
206+
},
207+
["cache_key"]
208+
);
209+
}
210+
})();
211+
212+
maybeCleanupCaches();
213+
}
214+
169215
export async function clearProjectCache(): Promise<void> {
170216
const db = getDatabase();
171217
db.query("DELETE FROM project_cache").run();

test/lib/db/project-cache.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { describe, expect, test } from "bun:test";
88
import {
9+
cacheProjectsForOrg,
910
clearProjectCache,
1011
getCachedProject,
1112
getCachedProjectByDsnKey,
@@ -350,6 +351,68 @@ describe("cache key uniqueness", () => {
350351
});
351352
});
352353

354+
describe("cacheProjectsForOrg", () => {
355+
test("caches multiple projects in one call", async () => {
356+
cacheProjectsForOrg("my-org", "My Org", [
357+
{ id: "1", slug: "frontend", name: "Frontend" },
358+
{ id: "2", slug: "backend", name: "Backend" },
359+
{ id: "3", slug: "mobile", name: "Mobile App" },
360+
]);
361+
362+
const result = await getCachedProjectsForOrg("my-org");
363+
expect(result).toHaveLength(3);
364+
expect(result).toContainEqual({
365+
projectSlug: "frontend",
366+
projectName: "Frontend",
367+
});
368+
expect(result).toContainEqual({
369+
projectSlug: "backend",
370+
projectName: "Backend",
371+
});
372+
expect(result).toContainEqual({
373+
projectSlug: "mobile",
374+
projectName: "Mobile App",
375+
});
376+
});
377+
378+
test("is idempotent on repeated calls", async () => {
379+
const projects = [
380+
{ id: "1", slug: "frontend", name: "Frontend" },
381+
{ id: "2", slug: "backend", name: "Backend" },
382+
];
383+
384+
cacheProjectsForOrg("my-org", "My Org", projects);
385+
cacheProjectsForOrg("my-org", "My Org", projects);
386+
387+
const result = await getCachedProjectsForOrg("my-org");
388+
expect(result).toHaveLength(2);
389+
});
390+
391+
test("empty array is a no-op", async () => {
392+
cacheProjectsForOrg("my-org", "My Org", []);
393+
const result = await getCachedProjectsForOrg("my-org");
394+
expect(result).toEqual([]);
395+
});
396+
397+
test("does not conflict with orgId:projectId cache entries", async () => {
398+
await setCachedProject("org-123", "proj-456", {
399+
orgSlug: "my-org",
400+
orgName: "My Org",
401+
projectSlug: "frontend",
402+
projectName: "Frontend (by DSN)",
403+
});
404+
405+
cacheProjectsForOrg("my-org", "My Org", [
406+
{ id: "456", slug: "frontend", name: "Frontend (by list)" },
407+
]);
408+
409+
// Both entries exist — getCachedProjectsForOrg deduplicates by slug
410+
const result = await getCachedProjectsForOrg("my-org");
411+
expect(result).toHaveLength(1);
412+
expect(result[0]?.projectSlug).toBe("frontend");
413+
});
414+
});
415+
353416
describe("getCachedProjectsForOrg", () => {
354417
test("returns empty array when no projects for org", async () => {
355418
const result = await getCachedProjectsForOrg("nonexistent-org");

0 commit comments

Comments
 (0)