diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 623c5f9d0..2da8fc4a9 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -6,7 +6,11 @@ */ import type { SentryContext } from "../../context.js"; -import { getProject, tryGetPrimaryDsn } from "../../lib/api-client.js"; +import { + getProject, + resolveOrgDisplayName, + tryGetPrimaryDsn, +} from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -15,6 +19,7 @@ import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; import { AuthError, ContextError, withAuthGuard } from "../../lib/errors.js"; import { divider, formatProjectDetails } from "../../lib/formatters/index.js"; +import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { applyFreshFlag, @@ -179,6 +184,50 @@ async function fetchAllProjectDetails( return { projects, dsns, targets: validTargets }; } +/** + * Re-hydrate `organization.name` on a project entry. + * + * `getProject()` passes `?collapse=organization` so the server returns + * only `{id, slug}` for `organization` (~400-500ms faster). For JSON + * consumers that scrape `.organization.name`, we refill the field from + * the cached organizations list (or the slug as last resort) so the + * JSON output shape stays stable across CLI versions. + */ +function hydrateOrganizationName(entry: ProjectViewEntry): ProjectViewEntry { + if (!entry.organization || entry.organization.name) { + return entry; + } + return { + ...entry, + organization: { + ...entry.organization, + name: resolveOrgDisplayName(entry.organization.slug), + }, + }; +} + +/** + * Build the JSON payload: strip `detectedFrom` (human-only), re-hydrate + * `organization.name`, and apply `--fields` filtering. + * + * Replaces the simpler `jsonExclude: ["detectedFrom"]` config so we can + * also restore `organization.name` that the collapsed API response omits. + */ +function jsonTransformProjectView( + entries: ProjectViewEntry[], + fields?: string[] +): unknown { + const hydrated = entries.map((entry) => { + const { detectedFrom: _detectedFrom, ...rest } = + hydrateOrganizationName(entry); + return rest; + }); + if (fields && fields.length > 0) { + return hydrated.map((item) => filterFields(item, fields)); + } + return hydrated; +} + /** * Format project view entries for human-readable terminal output. * @@ -221,7 +270,7 @@ export const viewCommand = buildCommand({ }, output: { human: formatProjectViewHuman, - jsonExclude: ["detectedFrom"], + jsonTransform: jsonTransformProjectView, }, parameters: { positional: { diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 957964c70..b2ae8fca4 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -97,6 +97,7 @@ export { matchesWordBoundary, type ProjectSearchResult, type ProjectWithOrg, + resolveOrgDisplayName, tryGetPrimaryDsn, } from "./api/projects.js"; export { diff --git a/src/lib/api/projects.ts b/src/lib/api/projects.ts index dd9cdecaa..cf207f7bb 100644 --- a/src/lib/api/projects.ts +++ b/src/lib/api/projects.ts @@ -26,10 +26,7 @@ import { getCachedOrganizations } from "../db/regions.js"; import { type AuthGuardSuccess, withAuthGuard } from "../errors.js"; import { logger } from "../logger.js"; import { resolveOrgRegion } from "../region.js"; -import { - invalidateCachedResponse, - invalidateCachedResponsesMatching, -} from "../response-cache.js"; +import { invalidateCachedResponsesMatching } from "../response-cache.js"; import { getApiBaseUrl } from "../sentry-client.js"; import { buildProjectUrl } from "../sentry-urls.js"; import { isAllDigits } from "../utils.js"; @@ -229,6 +226,12 @@ export async function deleteProject( * Flush the project-detail GET and the org-wide project list so * follow-up `project list` / `project view` reads don't see the * deleted project. Never throws. + * + * Uses prefix-matching on the project detail URL rather than an exact + * match because `getProject()` appends `?collapse=organization`, and + * the response cache keys entries by the full URL (including query + * string). A prefix sweep catches the collapsed variant plus any + * future query-parameter additions. */ async function invalidateProjectCaches( orgSlug: string, @@ -237,7 +240,7 @@ async function invalidateProjectCaches( try { const regionUrl = await resolveOrgRegion(orgSlug); await Promise.all([ - invalidateCachedResponse( + invalidateCachedResponsesMatching( buildApiUrl(regionUrl, "projects", orgSlug, projectSlug) ), invalidateCachedResponsesMatching( @@ -453,6 +456,16 @@ export async function findProjectByDsnKey( /** * Get a specific project. * Uses region-aware routing for multi-region support. + * + * Passes `?collapse=organization` so the server skips full-org + * serialization (~400-500ms faster). The response's `organization` field + * is trimmed to `{id, slug}` — no `name`, feature flags, or options. + * Callers needing a display name should use `resolveOrgDisplayName()` + * which falls back to the cached organizations list. + * + * Self-hosted or older Sentry versions that don't recognize `collapse` + * silently ignore the query param and return the full `organization` + * payload, so this is safe for all deployments. */ export async function getProject( orgSlug: string, @@ -460,18 +473,56 @@ export async function getProject( ): Promise { const config = await getOrgSdkConfig(orgSlug); - const result = await retrieveAProject({ + // `collapse` is server-supported but not in the OpenAPI spec, so the + // SDK types `query` as `never` on `RetrieveAProjectData`. Double-cast + // via `unknown` to bypass the stricter argument type while still + // sending the param at runtime. Same intent as the `per_page` cast + // used above. + const result = (await retrieveAProject({ ...config, path: { organization_id_or_slug: orgSlug, project_id_or_slug: projectSlug, }, - }); + query: { collapse: "organization" }, + } as unknown as Parameters[0])) as + | { data: unknown; error: undefined } + | { data: undefined; error: unknown }; const data = unwrapResult(result, "Failed to get project"); return data as unknown as SentryProject; } +/** + * Resolve an organization's display name from the best available source. + * + * `getProject()` passes `?collapse=organization` so the server skips + * full-org serialization (~400-500ms faster). Collapsed responses omit + * `organization.name`, so callers that want a human-friendly label must + * fall back to cached org metadata. + * + * Resolution order: + * 1. Explicit `name` if present (self-hosted or Sentry versions that + * ignore the `collapse` query param still return the full payload). + * 2. The locally cached organizations list (populated by login and every + * org-fanout operation). + * 3. The slug itself — always a valid human identifier, worst case. + * + * @param orgSlug - Organization slug (required for cache lookup) + * @param explicitName - The `organization.name` from an API response, if any + * @returns A display-ready organization name (never empty) + */ +export function resolveOrgDisplayName( + orgSlug: string, + explicitName?: string +): string { + if (explicitName) { + return explicitName; + } + const cached = getCachedOrganizations().find((o) => o.slug === orgSlug); + return cached?.name ?? orgSlug; +} + /** * Get project keys (DSNs) for a project. * Uses region-aware routing for multi-region support. diff --git a/src/lib/dsn/resolver.ts b/src/lib/dsn/resolver.ts index 483bb7318..0ac5f35e7 100644 --- a/src/lib/dsn/resolver.ts +++ b/src/lib/dsn/resolver.ts @@ -9,6 +9,7 @@ import { findProjectByDsnKey, listOrganizations, listProjects, + resolveOrgDisplayName, } from "../api-client.js"; import { getCachedDsn, updateCachedResolution } from "../db/dsn-cache.js"; import { getDsnSourceDescription } from "./detector.js"; @@ -65,7 +66,10 @@ export async function resolveProject( const resolved: ResolvedProjectInfo = { orgSlug: project.organization.slug, - orgName: project.organization.name, + orgName: resolveOrgDisplayName( + project.organization.slug, + project.organization.name + ), projectSlug: project.slug, projectName: project.name, }; diff --git a/src/lib/formatters/human.ts b/src/lib/formatters/human.ts index 6c98407be..e7c2a6ffa 100644 --- a/src/lib/formatters/human.ts +++ b/src/lib/formatters/human.ts @@ -28,6 +28,7 @@ import type { TraceSpan, Writer, } from "../../types/index.js"; +import { resolveOrgDisplayName } from "../api-client.js"; import { withSerializeSpan } from "../telemetry.js"; import { type FixabilityTier, muted } from "./colors.js"; import { @@ -1537,9 +1538,13 @@ export function formatProjectDetails( kvRows.push(["Created", new Date(project.dateCreated).toLocaleString()]); } if (project.organization) { + const orgName = resolveOrgDisplayName( + project.organization.slug, + project.organization.name + ); kvRows.push([ "Organization", - `${escapeMarkdownInline(project.organization.name)} (${safeCodeSpan(project.organization.slug)})`, + `${escapeMarkdownInline(orgName)} (${safeCodeSpan(project.organization.slug)})`, ]); } if (project.firstEvent) { diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 10a35e0bf..971660b1d 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -24,6 +24,7 @@ import { getProject, listOrganizations, listProjects, + resolveOrgDisplayName, } from "./api-client.js"; import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js"; import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js"; @@ -193,9 +194,13 @@ export async function resolveFromDsn( const projectInfo = await getProject(dsn.orgId, dsn.projectId); if (projectInfo.organization) { + const orgName = resolveOrgDisplayName( + projectInfo.organization.slug, + projectInfo.organization.name + ); setCachedProject(dsn.orgId, dsn.projectId, { orgSlug: projectInfo.organization.slug, - orgName: projectInfo.organization.name, + orgName, projectSlug: projectInfo.slug, projectName: projectInfo.name, projectId: projectInfo.id, @@ -205,7 +210,7 @@ export async function resolveFromDsn( org: projectInfo.organization.slug, project: projectInfo.slug, projectId: toNumericId(projectInfo.id), - orgDisplay: projectInfo.organization.name, + orgDisplay: orgName, projectDisplay: projectInfo.name, detectedFrom, }; @@ -333,9 +338,13 @@ export async function resolveDsnByPublicKey( } if (projectInfo.organization) { + const orgName = resolveOrgDisplayName( + projectInfo.organization.slug, + projectInfo.organization.name + ); setCachedProjectByDsnKey(dsn.publicKey, { orgSlug: projectInfo.organization.slug, - orgName: projectInfo.organization.name, + orgName, projectSlug: projectInfo.slug, projectName: projectInfo.name, projectId: projectInfo.id, @@ -345,7 +354,7 @@ export async function resolveDsnByPublicKey( org: projectInfo.organization.slug, project: projectInfo.slug, projectId: toNumericId(projectInfo.id), - orgDisplay: projectInfo.organization.name, + orgDisplay: orgName, projectDisplay: projectInfo.name, detectedFrom, packagePath: dsn.packagePath, @@ -402,9 +411,13 @@ async function resolveDsnToTarget( const projectInfo = await getProject(orgId, dsnProjectId); if (projectInfo.organization) { + const orgName = resolveOrgDisplayName( + projectInfo.organization.slug, + projectInfo.organization.name + ); setCachedProject(orgId, dsnProjectId, { orgSlug: projectInfo.organization.slug, - orgName: projectInfo.organization.name, + orgName, projectSlug: projectInfo.slug, projectName: projectInfo.name, projectId: projectInfo.id, @@ -414,7 +427,7 @@ async function resolveDsnToTarget( org: projectInfo.organization.slug, project: projectInfo.slug, projectId: toNumericId(projectInfo.id), - orgDisplay: projectInfo.organization.name, + orgDisplay: orgName, projectDisplay: projectInfo.name, detectedFrom, packagePath, @@ -880,7 +893,10 @@ export async function fetchProjectId( try { setCachedProject(project_.organization.id, project_.id, { orgSlug: project_.organization.slug, - orgName: project_.organization.name, + orgName: resolveOrgDisplayName( + project_.organization.slug, + project_.organization.name + ), projectSlug: project_.slug, projectName: project_.name, projectId: project_.id, diff --git a/src/types/sentry.ts b/src/types/sentry.ts index eec4882ef..55dc4b5b3 100644 --- a/src/types/sentry.ts +++ b/src/types/sentry.ts @@ -58,11 +58,19 @@ export type SentryProject = Partial & { id: string; slug: string; name: string; - /** Organization context (present in detail responses, absent in list) */ + /** + * Organization context (present in detail responses, absent in list). + * + * `name` is optional because `getProject()` passes `?collapse=organization` + * to skip full-org serialization on the server (~400-500ms faster). The + * collapsed payload only carries `{id, slug}`. Callers needing a display + * name should use `resolveOrgDisplayName()` which falls back to the + * cached organizations list. + */ organization?: { id: string; slug: string; - name: string; + name?: string; [key: string]: unknown; }; /** Project status (returned by API but not in the OpenAPI spec) */ diff --git a/test/commands/project/view.func.test.ts b/test/commands/project/view.func.test.ts index 65bd59c8c..f4cb78b34 100644 --- a/test/commands/project/view.func.test.ts +++ b/test/commands/project/view.func.test.ts @@ -327,4 +327,85 @@ describe("viewCommand.func", () => { func.call(context, { json: false, web: false }, "my-org/test-project") ).rejects.toThrow(AuthError); }); + + test("JSON output re-hydrates organization.name when API response omits it", async () => { + // Collapsed API response: `organization.name` is absent. The command's + // jsonTransform must refill it via `resolveOrgDisplayName()` so scripts + // / agents that scrape `.organization.name` continue to see a value. + getProjectSpy.mockResolvedValue({ + ...sampleProject, + organization: { id: "1", slug: "my-org" }, + }); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false }, "my-org/test-project"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed[0].organization.slug).toBe("my-org"); + // Fallback to slug when no cached display name is available + expect(parsed[0].organization.name).toBe("my-org"); + }); + + test("JSON output preserves organization.name when API response includes it", async () => { + // Self-hosted / older Sentry ignore `?collapse=organization` and return + // the full payload — don't clobber `name` when it's already set. + getProjectSpy.mockResolvedValue({ + ...sampleProject, + organization: { id: "1", slug: "my-org", name: "My Organization" }, + }); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false }, "my-org/test-project"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed[0].organization.name).toBe("My Organization"); + }); + + test("JSON output still strips detectedFrom (human-only field)", async () => { + getProjectSpy.mockResolvedValue(sampleProject); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false }, "my-org/test-project"); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed[0]).not.toHaveProperty("detectedFrom"); + }); + + test("JSON output honours --fields filter", async () => { + getProjectSpy.mockResolvedValue({ + ...sampleProject, + organization: { id: "1", slug: "my-org" }, + }); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + // `fields` is auto-injected by the buildCommand wrapper; pass through + // the flag shape the wrapper would forward. + await func.call( + context, + { + json: true, + web: false, + fields: ["slug", "organization.slug"], + }, + "my-org/test-project" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed[0]).toEqual({ + slug: "test-project", + organization: { slug: "my-org" }, + }); + }); }); diff --git a/test/lib/api-client.test.ts b/test/lib/api-client.test.ts index f28f264af..e41154eea 100644 --- a/test/lib/api-client.test.ts +++ b/test/lib/api-client.test.ts @@ -1854,3 +1854,142 @@ describe("getLogs", () => { expect(result).toHaveLength(2); }); }); + +describe("getProject", () => { + test("sends ?collapse=organization query parameter", async () => { + const { getProject } = await import("../../src/lib/api-client.js"); + + // Seed region cache so the SDK targets us.sentry.io without /users/me/regions/ + setOrgRegions([ + { + slug: "acme", + regionUrl: DEFAULT_SENTRY_URL, + orgId: "42", + orgName: "Acme Corp", + }, + ]); + + const capturedUrls: string[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + capturedUrls.push(req.url); + + if (req.url.includes("/projects/acme/frontend/")) { + return new Response( + JSON.stringify({ + id: "101", + slug: "frontend", + name: "Frontend", + // Collapsed response: no `name` on organization + organization: { id: "42", slug: "acme" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const project = await getProject("acme", "frontend"); + + expect(project.slug).toBe("frontend"); + expect(project.organization?.slug).toBe("acme"); + + // Verify the outgoing URL carries ?collapse=organization + const projectRequest = capturedUrls.find((u) => + u.includes("/projects/acme/frontend/") + ); + expect(projectRequest).toBeDefined(); + const parsed = new URL(projectRequest as string); + expect(parsed.searchParams.get("collapse")).toBe("organization"); + }); + + test("tolerates collapsed response missing organization.name", async () => { + const { getProject } = await import("../../src/lib/api-client.js"); + + setOrgRegions([ + { + slug: "acme", + regionUrl: DEFAULT_SENTRY_URL, + orgId: "42", + orgName: "Acme Corp", + }, + ]); + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + + if (req.url.includes("/projects/acme/frontend/")) { + return new Response( + JSON.stringify({ + id: "101", + slug: "frontend", + name: "Frontend", + organization: { id: "42", slug: "acme" }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + }; + + const project = await getProject("acme", "frontend"); + + // `name` is optional on collapsed responses + expect(project.organization?.name).toBeUndefined(); + expect(project.organization?.slug).toBe("acme"); + }); +}); + +describe("resolveOrgDisplayName", () => { + test("prefers explicit name when provided", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + expect(resolveOrgDisplayName("acme", "Acme Corp")).toBe("Acme Corp"); + }); + + test("falls back to cached org name when explicit name is absent", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + setOrgRegions([ + { + slug: "acme", + regionUrl: DEFAULT_SENTRY_URL, + orgId: "42", + orgName: "Acme Corp", + }, + ]); + + expect(resolveOrgDisplayName("acme")).toBe("Acme Corp"); + }); + + test("falls back to slug when no cache entry exists", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + // Empty cache — no entry for this slug + expect(resolveOrgDisplayName("unknown-org")).toBe("unknown-org"); + }); + + test("falls back to slug when explicit name is empty string", async () => { + const { resolveOrgDisplayName } = await import( + "../../src/lib/api-client.js" + ); + + // Empty string is falsy, so the cache/slug fallback kicks in + expect(resolveOrgDisplayName("unknown-org", "")).toBe("unknown-org"); + }); +}); diff --git a/test/lib/formatters/human.details.test.ts b/test/lib/formatters/human.details.test.ts index 8951f3dd2..a6b0eaecc 100644 --- a/test/lib/formatters/human.details.test.ts +++ b/test/lib/formatters/human.details.test.ts @@ -21,6 +21,13 @@ import type { SentryOrganization, SentryProject, } from "../../../src/types/index.js"; +import { useTestConfigDir } from "../../helpers.js"; + +// Isolated per-test config dir so `resolveOrgDisplayName`'s cached-org lookup +// (via `getCachedOrganizations`) starts empty. Without this, an earlier test +// run in the same process could seed `org_regions` and mask the slug-fallback +// path below. +useTestConfigDir("human-details-"); // Helper to strip ANSI codes for content testing function stripAnsi(str: string): string { @@ -177,7 +184,7 @@ describe("formatProjectDetails", () => { test("includes organization context when present", () => { const project = createMockProject({ - organization: { slug: "acme", name: "Acme Corp" }, + organization: { id: "1", slug: "acme", name: "Acme Corp" }, }); const result = stripAnsi(formatProjectDetails(project)); @@ -185,6 +192,32 @@ describe("formatProjectDetails", () => { expect(result).toContain("acme"); }); + test("falls back to org slug when collapsed response omits name", () => { + // `getProject()` passes `?collapse=organization` to save ~400-500ms, + // which drops `organization.name`. `formatProjectDetails` must still + // render a reasonable Organization row. With no cached org name + // available (empty config dir + no seeded org_regions), the slug is + // the last-resort fallback. + const project = createMockProject({ + organization: { id: "1", slug: "acme-only-slug" }, + }); + + const result = stripAnsi(formatProjectDetails(project)); + + // Organization row renders the slug twice — once as the display name + // (fallback when no cached name exists) and once inside the parens + // as the raw slug identifier. Matching both positions guarantees the + // fallback actually fired; a stray cached name would show up between + // the "Organization" label and `(acme-only-slug)`. + // Renders as `Organization │ acme-only-slug (acme-only-slug)` (box-drawing + // separator). Matching both positions guarantees the fallback actually + // fired; a stray cached display name would show up between the + // "Organization" label and `(acme-only-slug)`. + expect(result).toMatch( + /Organization\s*\S\s*acme-only-slug\s*\(\s*acme-only-slug\s*\)/ + ); + }); + test("includes capability flags", () => { const project = createMockProject({ hasSessions: true,