From 91455f222aa0cf500ed41f6f4f0b18b425c11363 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:17:28 +0100 Subject: [PATCH 1/5] refactor(project): replace --org flag with org/project positional arg project view now accepts / as a single positional argument instead of separate --org flag and positional, matching the pattern used by event view and issue view. Auto-detect via DSN still works when no argument is provided, including monorepo multi-target. --- src/commands/project/list.ts | 4 +- src/commands/project/view.ts | 141 ++++++++++++++++++++++++++--------- src/lib/resolve-issue.ts | 6 +- src/lib/resolve-target.ts | 8 +- 4 files changed, 115 insertions(+), 44 deletions(-) diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index b22aa124..01aa11f3 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -150,7 +150,7 @@ async function resolveOrgsToFetch( orgFlag: string | undefined, cwd: string ): Promise { - // 1. If --org flag provided, use it directly + // 1. If org positional provided, use it directly if (orgFlag) { return { orgs: [orgFlag] }; } @@ -309,7 +309,7 @@ export const listCommand = buildCommand({ writeFooter( stdout, - "Tip: Use 'sentry project view --org ' for details" + "Tip: Use 'sentry project view /' for details" ); }, }); diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 8c5208e7..6f1c4be2 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -6,10 +6,18 @@ */ import type { SentryContext } from "../../context.js"; -import { getProject, getProjectKeys } from "../../lib/api-client.js"; +import { + findProjectsBySlug, + getProject, + getProjectKeys, +} from "../../lib/api-client.js"; +import { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { AuthError, ContextError } from "../../lib/errors.js"; +import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; import { divider, formatProjectDetails, @@ -24,27 +32,27 @@ import { buildProjectUrl } from "../../lib/sentry-urls.js"; import type { ProjectKey, SentryProject } from "../../types/index.js"; type ViewFlags = { - readonly org?: string; readonly json: boolean; readonly web: boolean; }; +/** Usage hint for ContextError messages */ +const USAGE_HINT = "sentry project view /"; + /** * Build an error message for missing context, with optional DSN resolution hint. */ function buildContextError(skippedSelfHosted?: number): ContextError { - const usageHint = "sentry project view --org "; - if (skippedSelfHosted) { return new ContextError( "Organization and project", - `${usageHint}\n\n` + + `${USAGE_HINT}\n\n` + `Note: Found ${skippedSelfHosted} DSN(s) that could not be resolved.\n` + "You may not have access to these projects, or specify the target explicitly." ); } - return new ContextError("Organization and project", usageHint); + return new ContextError("Organization and project", USAGE_HINT); } /** @@ -58,7 +66,7 @@ async function handleWebView( if (resolvedTargets.length > 1) { throw new ContextError( "Single project", - "sentry project view --org -w\n\n" + + `${USAGE_HINT} -w\n\n` + `Found ${resolvedTargets.length} projects. Specify which project to open in browser.` ); } @@ -191,15 +199,53 @@ function writeMultipleProjects( } } +/** + * Resolve target from a project search result. + * + * Searches for a project by slug across all accessible organizations. + * Throws if no project found or if multiple projects found in different orgs. + * + * @param projectSlug - Project slug to search for + * @returns Resolved target with org and project info + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + */ +async function resolveFromProjectSearch( + projectSlug: string +): Promise { + const found = await findProjectsBySlug(projectSlug); + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ + "Check that you have access to a project with this slug", + ]); + } + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}\n\n` + + `Example: sentry project view /${projectSlug}` + ); + } + // Safe assertion: length is exactly 1 after the checks above + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + orgDisplay: foundProject.orgSlug, + projectDisplay: foundProject.slug, + }; +} + export const viewCommand = buildCommand({ docs: { brief: "View details of a project", fullDescription: "View detailed information about Sentry projects.\n\n" + - "The organization and project are resolved from:\n" + - " 1. Positional argument and --org flag\n" + - " 2. Config defaults\n" + - " 3. SENTRY_DSN environment variable or source code detection\n\n" + + "Target specification:\n" + + " sentry project view # auto-detect from DSN or config\n" + + " sentry project view / # explicit org and project\n" + + " sentry project view # find project across all orgs\n\n" + "In monorepos with multiple Sentry projects, shows details for all detected projects.", }, parameters: { @@ -207,20 +253,14 @@ export const viewCommand = buildCommand({ kind: "tuple", parameters: [ { - placeholder: "project", - brief: "Project slug (optional if auto-detected)", + placeholder: "target", + brief: "Target: /, , or omit for auto-detect", parse: String, optional: true, }, ], }, flags: { - org: { - kind: "parsed", - parse: String, - brief: "Organization slug", - optional: true, - }, json: { kind: "boolean", brief: "Output as JSON", @@ -237,24 +277,55 @@ export const viewCommand = buildCommand({ async func( this: SentryContext, flags: ViewFlags, - projectSlug?: string + targetArg?: string ): Promise { const { stdout, cwd } = this; - // Resolve targets (may find multiple in monorepos) - const { - targets: resolvedTargets, - footer, - skippedSelfHosted, - } = await resolveAllTargets({ - org: flags.org, - project: projectSlug, - cwd, - usageHint: "sentry project view --org ", - }); - - if (resolvedTargets.length === 0) { - throw buildContextError(skippedSelfHosted); + const parsed = parseOrgProjectArg(targetArg); + + let resolvedTargets: ResolvedTarget[]; + let footer: string | undefined; + + switch (parsed.type) { + case ProjectSpecificationType.Explicit: + // Direct org/project - single target, no multi-target resolution + resolvedTargets = [ + { + org: parsed.org, + project: parsed.project, + orgDisplay: parsed.org, + projectDisplay: parsed.project, + }, + ]; + break; + + case ProjectSpecificationType.ProjectSearch: + // Search for project across all orgs - single target + resolvedTargets = [await resolveFromProjectSearch(parsed.projectSlug)]; + break; + + case ProjectSpecificationType.OrgAll: + throw new ContextError( + "Specific project", + `${USAGE_HINT}\n\n` + + "Specify the full org/project target, not just the organization." + ); + + case ProjectSpecificationType.AutoDetect: { + // Auto-detect supports monorepo multi-target resolution + const result = await resolveAllTargets({ cwd }); + + if (result.targets.length === 0) { + throw buildContextError(result.skippedSelfHosted); + } + + resolvedTargets = result.targets; + footer = result.footer; + break; + } + + default: + throw new ValidationError("Invalid target specification"); } if (flags.web) { diff --git a/src/lib/resolve-issue.ts b/src/lib/resolve-issue.ts index 9f252956..4d31ef17 100644 --- a/src/lib/resolve-issue.ts +++ b/src/lib/resolve-issue.ts @@ -26,9 +26,9 @@ import { resolveOrg, resolveOrgAndProject } from "./resolve-target.js"; * Options for resolving an issue ID. */ export type ResolveIssueOptions = { - /** Organization slug from --org flag */ + /** Organization slug */ org?: string; - /** Project slug from --project flag (needed for short suffix resolution) */ + /** Project slug (needed for short suffix resolution) */ project?: string; /** Current working directory for DSN detection and alias cache */ cwd: string; @@ -135,7 +135,7 @@ async function resolveWithOrgContext( * * @param input - User-provided issue ID in any supported format * @param options - Resolution options with org/project flags and cwd - * @param commandHint - Command example for error messages (e.g., "sentry issue view ID --org ") + * @param commandHint - Command example for error messages (e.g., "sentry issue view /ID") * @returns Resolved organization and issue ID * @throws {ContextError} When required context (org/project) cannot be resolved * diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index 3c503f3f..b87d4877 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -81,9 +81,9 @@ export type ResolvedOrg = { * Options for resolving org and project. */ export type ResolveOptions = { - /** Organization slug from CLI flag */ + /** Organization slug */ org?: string; - /** Project slug from CLI flag */ + /** Project slug */ project?: string; /** Current working directory for DSN detection */ cwd: string; @@ -95,7 +95,7 @@ export type ResolveOptions = { * Options for resolving org only. */ export type ResolveOrgOptions = { - /** Organization slug from CLI flag */ + /** Organization slug */ org?: string; /** Current working directory for DSN detection */ cwd: string; @@ -650,7 +650,7 @@ export async function resolveOrgAndProject( * Resolve organization only from multiple sources. * * Resolution priority: - * 1. CLI flag (--org) + * 1. Positional argument * 2. Config defaults * 3. DSN auto-detection * From 50a0bfa52221b6ddf088acc8c72c2986f4ca0f63 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:17:32 +0100 Subject: [PATCH 2/5] test(project): update tests for org/project positional syntax E2E tests now pass org/project as a single arg instead of --org flag. Renamed test cases to reflect positional args instead of flags. --- test/e2e/project.test.ts | 34 ++++++++-------------------- test/isolated/resolve-target.test.ts | 6 ++--- test/lib/errors.test.ts | 4 ++-- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/test/e2e/project.test.ts b/test/e2e/project.test.ts index 68eabe4d..15506698 100644 --- a/test/e2e/project.test.ts +++ b/test/e2e/project.test.ts @@ -189,9 +189,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, ]); expect(result.exitCode).toBe(1); @@ -207,18 +205,16 @@ describe("sentry project view", () => { expect(result.stderr + result.stdout).toMatch(/organization|project/i); }); - test("rejects partial flags (--org without project)", async () => { + test("rejects org-only target (org/)", async () => { await ctx.setAuthToken(TEST_TOKEN); - const result = await ctx.run(["project", "view", "--org", TEST_ORG]); + const result = await ctx.run(["project", "view", `${TEST_ORG}/`]); expect(result.exitCode).toBe(1); // Should show error with usage hint const output = result.stderr + result.stdout; - expect(output).toMatch(/organization and project is required/i); - expect(output).toContain( - "sentry project view --org " - ); + expect(output).toMatch(/specific project is required/i); + expect(output).toContain("sentry project view /"); }); test( @@ -229,9 +225,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, ]); expect(result.exitCode).toBe(0); @@ -249,9 +243,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, ]); expect(result.exitCode).toBe(0); @@ -269,9 +261,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, "--json", ]); @@ -290,9 +280,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - TEST_PROJECT, - "--org", - TEST_ORG, + `${TEST_ORG}/${TEST_PROJECT}`, "--json", ]); @@ -311,9 +299,7 @@ describe("sentry project view", () => { const result = await ctx.run([ "project", "view", - "nonexistent-project-12345", - "--org", - TEST_ORG, + `${TEST_ORG}/nonexistent-project-12345`, ]); expect(result.exitCode).toBe(1); diff --git a/test/isolated/resolve-target.test.ts b/test/isolated/resolve-target.test.ts index 35f1ef21..07d94cbf 100644 --- a/test/isolated/resolve-target.test.ts +++ b/test/isolated/resolve-target.test.ts @@ -405,13 +405,13 @@ describe("resolveOrgAndProject", () => { expect(mockDetectDsn).not.toHaveBeenCalled(); }); - test("throws ContextError when only org flag provided", async () => { + test("throws ContextError when only org provided", async () => { await expect( resolveOrgAndProject({ org: "my-org", cwd: "/test" }) ).rejects.toThrow(ContextError); }); - test("throws ContextError when only project flag provided", async () => { + test("throws ContextError when only project provided", async () => { await expect( resolveOrgAndProject({ project: "my-project", cwd: "/test" }) ).rejects.toThrow(ContextError); @@ -519,7 +519,7 @@ describe("resolveAllTargets", () => { expect(result.targets[0].project).toBe("my-project"); }); - test("throws ContextError when only org flag provided", async () => { + test("throws ContextError when only org provided", async () => { await expect( resolveAllTargets({ org: "my-org", cwd: "/test" }) ).rejects.toThrow(ContextError); diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index 27f69c0f..8f13e515 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -112,11 +112,11 @@ describe("ContextError", () => { test("format() includes custom alternatives", () => { const err = new ContextError("Project", "sentry project list", [ - "Specify --project flag", + "Specify project in / format", ]); const formatted = err.format(); expect(formatted).toContain("Project is required."); - expect(formatted).toContain("Specify --project flag"); + expect(formatted).toContain("Specify project in / format"); expect(formatted).not.toContain("SENTRY_DSN"); }); From cace0295b1786f6f5fc7688c5fa91b93b90aaa9b Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:17:35 +0100 Subject: [PATCH 3/5] docs(project): update project view docs for org/project syntax --- docs/src/content/docs/commands/project.md | 16 +++++++++++----- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 15 ++++++++++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/src/content/docs/commands/project.md b/docs/src/content/docs/commands/project.md index b43729a5..e8e37a66 100644 --- a/docs/src/content/docs/commands/project.md +++ b/docs/src/content/docs/commands/project.md @@ -49,27 +49,33 @@ my-org mobile-ios cocoa mobile-team View details of a specific project. ```bash -sentry project view +# Auto-detect from DSN or config +sentry project view + +# Explicit org and project +sentry project view / + +# Find project across all orgs +sentry project view ``` **Arguments:** | Argument | Description | |----------|-------------| -| `` | The project slug | +| `[target]` | Optional: `/`, ``, or omit for auto-detect | **Options:** | Option | Description | |--------|-------------| -| `--org ` | Organization slug (if not specified, uses default) | | `-w, --web` | Open in browser | | `--json` | Output as JSON | **Example:** ```bash -sentry project view frontend --org my-org +sentry project view my-org/frontend ``` ``` @@ -83,5 +89,5 @@ DSN: https://abc123@sentry.io/123456 **Open in browser:** ```bash -sentry project view frontend -w +sentry project view my-org/frontend -w ``` diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index f4022d3b..7e62eda4 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -158,23 +158,28 @@ sentry project list sentry project list --platform javascript ``` -#### `sentry project view ` +#### `sentry project view [/]` View details of a project **Flags:** -- `--org - Organization slug` - `--json - Output as JSON` - `-w, --web - Open in browser` **Examples:** ```bash -sentry project view +# Auto-detect from DSN or config +sentry project view + +# Explicit org and project +sentry project view my-org/frontend -sentry project view frontend --org my-org +# Find project across all orgs +sentry project view frontend -sentry project view frontend -w +# Open in browser +sentry project view my-org/frontend -w ``` ### Issue From 9da8df4158bcf9e1236bba7d3cff9152c902d101 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 16:30:43 +0100 Subject: [PATCH 4/5] refactor: extract resolveProjectBySlug into shared utility Deduplicate resolveFromProjectSearch() which was identically implemented in event/view, trace/view, log/view, and project/view. The shared resolveProjectBySlug() in resolve-target.ts handles project slug lookup with consistent error messages and disambiguation prompts. --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 9 +-- src/commands/event/view.ts | 67 ++++++------------- src/commands/log/view.ts | 53 +++------------ src/commands/project/view.ts | 65 +++++------------- src/commands/trace/view.ts | 53 +++------------ src/lib/resolve-target.ts | 45 ++++++++++++- test/commands/event/view.test.ts | 47 +++++++------ test/commands/log/view.test.ts | 43 +++++++----- test/commands/trace/view.test.ts | 33 ++++----- 9 files changed, 174 insertions(+), 241 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 7e62eda4..0a00b3c8 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -158,7 +158,7 @@ sentry project list sentry project list --platform javascript ``` -#### `sentry project view [/]` +#### `sentry project view ` View details of a project @@ -173,12 +173,13 @@ View details of a project sentry project view # Explicit org and project -sentry project view my-org/frontend +sentry project view / # Find project across all orgs -sentry project view frontend +sentry project view + +sentry project view my-org/frontend -# Open in browser sentry project view my-org/frontend -w ``` diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index be8b273e..c257b5f6 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getEvent } from "../../lib/api-client.js"; +import { getEvent } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, @@ -13,9 +13,12 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ContextError, ValidationError } from "../../lib/errors.js"; +import { ContextError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildEventSearchUrl } from "../../lib/sentry-urls.js"; import { getSpanTreeLines } from "../../lib/span-tree.js"; import type { SentryEvent, Writer } from "../../types/index.js"; @@ -105,48 +108,6 @@ export type ResolvedEventTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param eventId - Event ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - eventId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry event view /${projectSlug} ${eventId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - orgDisplay: foundProject.orgSlug, - projectDisplay: foundProject.slug, - }; -} - export const viewCommand = buildCommand({ docs: { brief: "View details of a specific event", @@ -205,9 +166,19 @@ export const viewCommand = buildCommand({ }; break; - case ProjectSpecificationType.ProjectSearch: - target = await resolveFromProjectSearch(parsed.projectSlug, eventId); + case ProjectSpecificationType.ProjectSearch: { + const resolved = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry event view /${parsed.projectSlug} ${eventId}` + ); + target = { + ...resolved, + orgDisplay: resolved.org, + projectDisplay: resolved.project, + }; break; + } case ProjectSpecificationType.OrgAll: throw new ContextError("Specific project", USAGE_HINT); @@ -218,7 +189,7 @@ export const viewCommand = buildCommand({ default: // Exhaustive check - should never reach here - throw new ValidationError("Invalid target specification"); + throw new ContextError("Organization and project", USAGE_HINT); } if (!target) { diff --git a/src/commands/log/view.ts b/src/commands/log/view.ts index 3720a804..36430ccd 100644 --- a/src/commands/log/view.ts +++ b/src/commands/log/view.ts @@ -6,12 +6,15 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getLog } from "../../lib/api-client.js"; +import { getLog } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatLogDetails, writeJson } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildLogsUrl } from "../../lib/sentry-urls.js"; import type { DetailedSentryLog, Writer } from "../../types/index.js"; @@ -68,46 +71,6 @@ export type ResolvedLogTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param logId - Log ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - logId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry log view /${projectSlug} ${logId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - }; -} - /** * Write human-readable log output to stdout. * @@ -187,7 +150,11 @@ export const viewCommand = buildCommand({ break; case "project-search": - target = await resolveFromProjectSearch(parsed.projectSlug, logId); + target = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry log view /${parsed.projectSlug} ${logId}` + ); break; case "org-all": diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 6f1c4be2..8d24db38 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -6,18 +6,14 @@ */ import type { SentryContext } from "../../context.js"; -import { - findProjectsBySlug, - getProject, - getProjectKeys, -} from "../../lib/api-client.js"; +import { getProject, getProjectKeys } from "../../lib/api-client.js"; import { ProjectSpecificationType, parseOrgProjectArg, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { AuthError, ContextError, ValidationError } from "../../lib/errors.js"; +import { AuthError, ContextError } from "../../lib/errors.js"; import { divider, formatProjectDetails, @@ -27,6 +23,7 @@ import { import { type ResolvedTarget, resolveAllTargets, + resolveProjectBySlug, } from "../../lib/resolve-target.js"; import { buildProjectUrl } from "../../lib/sentry-urls.js"; import type { ProjectKey, SentryProject } from "../../types/index.js"; @@ -199,44 +196,6 @@ function writeMultipleProjects( } } -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - */ -async function resolveFromProjectSearch( - projectSlug: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry project view /${projectSlug}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - orgDisplay: foundProject.orgSlug, - projectDisplay: foundProject.slug, - }; -} - export const viewCommand = buildCommand({ docs: { brief: "View details of a project", @@ -299,10 +258,22 @@ export const viewCommand = buildCommand({ ]; break; - case ProjectSpecificationType.ProjectSearch: + case ProjectSpecificationType.ProjectSearch: { // Search for project across all orgs - single target - resolvedTargets = [await resolveFromProjectSearch(parsed.projectSlug)]; + const resolved = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry project view /${parsed.projectSlug}` + ); + resolvedTargets = [ + { + ...resolved, + orgDisplay: resolved.org, + projectDisplay: resolved.project, + }, + ]; break; + } case ProjectSpecificationType.OrgAll: throw new ContextError( @@ -325,7 +296,7 @@ export const viewCommand = buildCommand({ } default: - throw new ValidationError("Invalid target specification"); + throw new ContextError("Organization and project", USAGE_HINT); } if (flags.web) { diff --git a/src/commands/trace/view.ts b/src/commands/trace/view.ts index 238db359..f9ca92b2 100644 --- a/src/commands/trace/view.ts +++ b/src/commands/trace/view.ts @@ -6,7 +6,7 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; -import { findProjectsBySlug, getDetailedTrace } from "../../lib/api-client.js"; +import { getDetailedTrace } from "../../lib/api-client.js"; import { parseOrgProjectArg, spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; @@ -17,7 +17,10 @@ import { writeFooter, writeJson, } from "../../lib/formatters/index.js"; -import { resolveOrgAndProject } from "../../lib/resolve-target.js"; +import { + resolveOrgAndProject, + resolveProjectBySlug, +} from "../../lib/resolve-target.js"; import { buildTraceUrl } from "../../lib/sentry-urls.js"; import type { Writer } from "../../types/index.js"; @@ -75,46 +78,6 @@ export type ResolvedTraceTarget = { detectedFrom?: string; }; -/** - * Resolve target from a project search result. - * - * Searches for a project by slug across all accessible organizations. - * Throws if no project found or if multiple projects found in different orgs. - * - * @param projectSlug - Project slug to search for - * @param traceId - Trace ID (used in error messages) - * @returns Resolved target with org and project info - * @throws {ContextError} If no project found - * @throws {ValidationError} If project exists in multiple organizations - * - * @internal Exported for testing - */ -export async function resolveFromProjectSearch( - projectSlug: string, - traceId: string -): Promise { - const found = await findProjectsBySlug(projectSlug); - if (found.length === 0) { - throw new ContextError(`Project "${projectSlug}"`, USAGE_HINT, [ - "Check that you have access to a project with this slug", - ]); - } - if (found.length > 1) { - const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); - throw new ValidationError( - `Project "${projectSlug}" exists in multiple organizations.\n\n` + - `Specify the organization:\n${orgList}\n\n` + - `Example: sentry trace view /${projectSlug} ${traceId}` - ); - } - // Safe assertion: length is exactly 1 after the checks above - const foundProject = found[0] as (typeof found)[0]; - return { - org: foundProject.orgSlug, - project: foundProject.slug, - }; -} - /** * Write human-readable trace output to stdout. * @@ -196,7 +159,11 @@ export const viewCommand = buildCommand({ break; case "project-search": - target = await resolveFromProjectSearch(parsed.projectSlug, traceId); + target = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry trace view /${parsed.projectSlug} ${traceId}` + ); break; case "org-all": diff --git a/src/lib/resolve-target.ts b/src/lib/resolve-target.ts index b87d4877..310e9871 100644 --- a/src/lib/resolve-target.ts +++ b/src/lib/resolve-target.ts @@ -15,6 +15,7 @@ import { basename } from "node:path"; import { findProjectByDsnKey, findProjectsByPattern, + findProjectsBySlug, getProject, } from "./api-client.js"; import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js"; @@ -33,7 +34,7 @@ import { formatMultipleProjectsFooter, getDsnSourceDescription, } from "./dsn/index.js"; -import { AuthError, ContextError } from "./errors.js"; +import { AuthError, ContextError, ValidationError } from "./errors.js"; /** * Resolved organization and project target for API calls. @@ -680,3 +681,45 @@ export async function resolveOrg( return null; } } + +/** + * Search for a project by slug across all accessible organizations. + * + * Common resolution step used by commands that accept a bare project slug + * (e.g., `sentry event view frontend `). Throws helpful errors when + * the project isn't found or exists in multiple orgs. + * + * @param projectSlug - Project slug to search for + * @param usageHint - Usage example shown in error messages + * @param disambiguationExample - Example command for multi-org disambiguation (e.g., "sentry event view /frontend abc123") + * @returns Resolved org and project slugs + * @throws {ContextError} If no project found + * @throws {ValidationError} If project exists in multiple organizations + */ +export async function resolveProjectBySlug( + projectSlug: string, + usageHint: string, + disambiguationExample?: string +): Promise<{ org: string; project: string }> { + const found = await findProjectsBySlug(projectSlug); + if (found.length === 0) { + throw new ContextError(`Project "${projectSlug}"`, usageHint, [ + "Check that you have access to a project with this slug", + ]); + } + if (found.length > 1) { + const orgList = found.map((p) => ` ${p.orgSlug}/${p.slug}`).join("\n"); + const example = disambiguationExample + ? `\n\nExample: ${disambiguationExample}` + : ""; + throw new ValidationError( + `Project "${projectSlug}" exists in multiple organizations.\n\n` + + `Specify the organization:\n${orgList}${example}` + ); + } + const foundProject = found[0] as (typeof found)[0]; + return { + org: foundProject.orgSlug, + project: foundProject.slug, + }; +} diff --git a/test/commands/event/view.test.ts b/test/commands/event/view.test.ts index 2ce6a362..fb894d75 100644 --- a/test/commands/event/view.test.ts +++ b/test/commands/event/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/event/view.js"; +import { parsePositionalArgs } from "../../../src/commands/event/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (event ID only)", () => { @@ -93,7 +91,8 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { + const HINT = "sentry event view / "; let findProjectsBySlugSpy: ReturnType; beforeEach(() => { @@ -108,16 +107,16 @@ describe("resolveFromProjectSearch", () => { test("throws ContextError when project not found", async () => { findProjectsBySlugSpy.mockResolvedValue([]); - await expect( - resolveFromProjectSearch("my-project", "event-123") - ).rejects.toThrow(ContextError); + await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow( + ContextError + ); }); test("includes project name in error message", async () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "event-123"); + await resolveProjectBySlug("frontend", HINT); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -136,9 +135,9 @@ describe("resolveFromProjectSearch", () => { { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, ] as ProjectWithOrg[]); - await expect( - resolveFromProjectSearch("frontend", "event-123") - ).rejects.toThrow(ValidationError); + await expect(resolveProjectBySlug("frontend", HINT)).rejects.toThrow( + ValidationError + ); }); test("includes all orgs in error message", async () => { @@ -148,7 +147,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "event-456"); + await resolveProjectBySlug( + "frontend", + HINT, + "sentry event view /frontend event-456" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -156,7 +159,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("event-456"); // Event ID in example + expect(message).toContain("event-456"); } }); @@ -168,7 +171,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug( + "api", + HINT, + "sentry event view /api abc123" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -186,13 +193,11 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "event-xyz"); + const result = await resolveProjectBySlug("backend", HINT); expect(result).toEqual({ org: "my-company", project: "backend", - orgDisplay: "my-company", - projectDisplay: "backend", }); }); @@ -206,10 +211,9 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "evt-001"); + const result = await resolveProjectBySlug("mobile-app", HINT); expect(result.org).toBe("acme-industries"); - expect(result.orgDisplay).toBe("acme-industries"); }); test("preserves project slug in result", async () => { @@ -217,10 +221,9 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "e123"); + const result = await resolveProjectBySlug("web-frontend", HINT); expect(result.project).toBe("web-frontend"); - expect(result.projectDisplay).toBe("web-frontend"); }); }); }); diff --git a/test/commands/log/view.test.ts b/test/commands/log/view.test.ts index 92d4ac4d..db3b8c2f 100644 --- a/test/commands/log/view.test.ts +++ b/test/commands/log/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/log/view.js"; +import { parsePositionalArgs } from "../../../src/commands/log/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (log ID only)", () => { @@ -91,7 +89,8 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { + const HINT = "sentry log view / "; let findProjectsBySlugSpy: ReturnType; beforeEach(() => { @@ -106,16 +105,16 @@ describe("resolveFromProjectSearch", () => { test("throws ContextError when project not found", async () => { findProjectsBySlugSpy.mockResolvedValue([]); - await expect( - resolveFromProjectSearch("my-project", "log-123") - ).rejects.toThrow(ContextError); + await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow( + ContextError + ); }); test("includes project name in error message", async () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "log-123"); + await resolveProjectBySlug("frontend", HINT); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -134,9 +133,9 @@ describe("resolveFromProjectSearch", () => { { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, ] as ProjectWithOrg[]); - await expect( - resolveFromProjectSearch("frontend", "log-123") - ).rejects.toThrow(ValidationError); + await expect(resolveProjectBySlug("frontend", HINT)).rejects.toThrow( + ValidationError + ); }); test("includes all orgs in error message", async () => { @@ -146,7 +145,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "log-456"); + await resolveProjectBySlug( + "frontend", + HINT, + "sentry log view /frontend log-456" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -154,7 +157,7 @@ describe("resolveFromProjectSearch", () => { expect(message).toContain("exists in multiple organizations"); expect(message).toContain("acme-corp/frontend"); expect(message).toContain("beta-inc/frontend"); - expect(message).toContain("log-456"); // Log ID in example + expect(message).toContain("log-456"); } }); @@ -166,7 +169,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("api", "abc123"); + await resolveProjectBySlug( + "api", + HINT, + "sentry log view /api abc123" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -182,7 +189,7 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "log-xyz"); + const result = await resolveProjectBySlug("backend", HINT); expect(result).toEqual({ org: "my-company", @@ -200,7 +207,7 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "log-001"); + const result = await resolveProjectBySlug("mobile-app", HINT); expect(result.org).toBe("acme-industries"); }); @@ -210,7 +217,7 @@ describe("resolveFromProjectSearch", () => { { slug: "web-frontend", orgSlug: "org", id: "1", name: "Web Frontend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("web-frontend", "log123"); + const result = await resolveProjectBySlug("web-frontend", HINT); expect(result.project).toBe("web-frontend"); }); diff --git a/test/commands/trace/view.test.ts b/test/commands/trace/view.test.ts index 3bb7e43e..071b701c 100644 --- a/test/commands/trace/view.test.ts +++ b/test/commands/trace/view.test.ts @@ -6,14 +6,12 @@ */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { - parsePositionalArgs, - resolveFromProjectSearch, -} from "../../../src/commands/trace/view.js"; +import { parsePositionalArgs } from "../../../src/commands/trace/view.js"; import type { ProjectWithOrg } from "../../../src/lib/api-client.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as apiClient from "../../../src/lib/api-client.js"; import { ContextError, ValidationError } from "../../../src/lib/errors.js"; +import { resolveProjectBySlug } from "../../../src/lib/resolve-target.js"; describe("parsePositionalArgs", () => { describe("single argument (trace ID only)", () => { @@ -79,7 +77,8 @@ describe("parsePositionalArgs", () => { }); }); -describe("resolveFromProjectSearch", () => { +describe("resolveProjectBySlug", () => { + const HINT = "sentry trace view / "; let findProjectsBySlugSpy: ReturnType; beforeEach(() => { @@ -94,16 +93,16 @@ describe("resolveFromProjectSearch", () => { test("throws ContextError when project not found", async () => { findProjectsBySlugSpy.mockResolvedValue([]); - await expect( - resolveFromProjectSearch("my-project", "trace-123") - ).rejects.toThrow(ContextError); + await expect(resolveProjectBySlug("my-project", HINT)).rejects.toThrow( + ContextError + ); }); test("includes project name in error message", async () => { findProjectsBySlugSpy.mockResolvedValue([]); try { - await resolveFromProjectSearch("frontend", "trace-123"); + await resolveProjectBySlug("frontend", HINT); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ContextError); @@ -122,9 +121,9 @@ describe("resolveFromProjectSearch", () => { { slug: "frontend", orgSlug: "org-b", id: "2", name: "Frontend" }, ] as ProjectWithOrg[]); - await expect( - resolveFromProjectSearch("frontend", "trace-123") - ).rejects.toThrow(ValidationError); + await expect(resolveProjectBySlug("frontend", HINT)).rejects.toThrow( + ValidationError + ); }); test("includes all orgs and trace ID in error message", async () => { @@ -134,7 +133,11 @@ describe("resolveFromProjectSearch", () => { ] as ProjectWithOrg[]); try { - await resolveFromProjectSearch("frontend", "trace-456"); + await resolveProjectBySlug( + "frontend", + HINT, + "sentry trace view /frontend trace-456" + ); expect.unreachable("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(ValidationError); @@ -153,7 +156,7 @@ describe("resolveFromProjectSearch", () => { { slug: "backend", orgSlug: "my-company", id: "42", name: "Backend" }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("backend", "trace-xyz"); + const result = await resolveProjectBySlug("backend", HINT); expect(result).toEqual({ org: "my-company", @@ -171,7 +174,7 @@ describe("resolveFromProjectSearch", () => { }, ] as ProjectWithOrg[]); - const result = await resolveFromProjectSearch("mobile-app", "trace-001"); + const result = await resolveProjectBySlug("mobile-app", HINT); expect(result.org).toBe("acme-industries"); expect(result.project).toBe("mobile-app"); From e81a85deda0ba85417aae2676e7ebd66c50baa2b Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 10 Feb 2026 21:26:14 +0100 Subject: [PATCH 5/5] test(project): add func tests for project view command --- test/commands/project/view.func.test.ts | 282 ++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 test/commands/project/view.func.test.ts diff --git a/test/commands/project/view.func.test.ts b/test/commands/project/view.func.test.ts new file mode 100644 index 00000000..4183706c --- /dev/null +++ b/test/commands/project/view.func.test.ts @@ -0,0 +1,282 @@ +/** + * Project View Command Func Tests + * + * Tests for the viewCommand func() body in src/commands/project/view.ts. + * Uses spyOn to mock api-client, resolve-target, and browser to test + * the func() body without real HTTP calls or database access. + */ + +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; +import { viewCommand } from "../../../src/commands/project/view.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as apiClient from "../../../src/lib/api-client.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as browser from "../../../src/lib/browser.js"; +import { AuthError, ContextError } from "../../../src/lib/errors.js"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as resolveTarget from "../../../src/lib/resolve-target.js"; +import type { ProjectKey, SentryProject } from "../../../src/types/sentry.js"; + +const sampleProject: SentryProject = { + id: "42", + slug: "test-project", + name: "Test Project", + platform: "javascript", + dateCreated: "2025-01-01T00:00:00.000Z", + status: "active", +}; + +const sampleKeys: ProjectKey[] = [ + { + id: "key-1", + name: "Default", + dsn: { public: "https://abc123@o1.ingest.sentry.io/42" }, + isActive: true, + }, +]; + +function createMockContext() { + const stdoutWrite = mock(() => true); + return { + context: { + stdout: { write: stdoutWrite }, + stderr: { write: mock(() => true) }, + cwd: "/tmp", + setContext: mock(() => { + // no-op for test + }), + }, + stdoutWrite, + }; +} + +describe("viewCommand.func", () => { + let getProjectSpy: ReturnType; + let getProjectKeysSpy: ReturnType; + let resolveAllTargetsSpy: ReturnType; + let resolveProjectBySlugSpy: ReturnType; + let openInBrowserSpy: ReturnType; + + beforeEach(() => { + getProjectSpy = spyOn(apiClient, "getProject"); + getProjectKeysSpy = spyOn(apiClient, "getProjectKeys"); + resolveAllTargetsSpy = spyOn(resolveTarget, "resolveAllTargets"); + resolveProjectBySlugSpy = spyOn(resolveTarget, "resolveProjectBySlug"); + openInBrowserSpy = spyOn(browser, "openInBrowser"); + }); + + afterEach(() => { + getProjectSpy.mockRestore(); + getProjectKeysSpy.mockRestore(); + resolveAllTargetsSpy.mockRestore(); + resolveProjectBySlugSpy.mockRestore(); + openInBrowserSpy.mockRestore(); + }); + + test("explicit org/project outputs JSON with DSN", 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.slug).toBe("test-project"); + expect(parsed.dsn).toBe("https://abc123@o1.ingest.sentry.io/42"); + }); + + test("explicit org/project outputs human-readable details", async () => { + getProjectSpy.mockResolvedValue(sampleProject); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call( + context, + { json: false, web: false }, + "my-org/test-project" + ); + + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("test-project"); + expect(output).toContain("Slug:"); + }); + + test("explicit org/project with --web opens browser", async () => { + openInBrowserSpy.mockResolvedValue(undefined); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: false, web: true }, "my-org/test-project"); + + expect(openInBrowserSpy).toHaveBeenCalled(); + // Should NOT fetch project details when using --web + expect(getProjectSpy).not.toHaveBeenCalled(); + }); + + test("--web with multiple auto-detected targets throws ContextError", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "org-a", + project: "proj-1", + orgDisplay: "org-a", + projectDisplay: "proj-1", + }, + { + org: "org-b", + project: "proj-2", + orgDisplay: "org-b", + projectDisplay: "proj-2", + }, + ], + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + try { + // No target arg triggers AutoDetect + await func.call(context, { json: false, web: true }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Single project"); + } + }); + + test("project search resolves and fetches project", async () => { + resolveProjectBySlugSpy.mockResolvedValue({ + org: "acme", + project: "frontend", + }); + getProjectSpy.mockResolvedValue({ ...sampleProject, slug: "frontend" }); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + await func.call(context, { json: true, web: false }, "frontend"); + + expect(resolveProjectBySlugSpy).toHaveBeenCalledWith( + "frontend", + "sentry project view /", + "sentry project view /frontend" + ); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + const parsed = JSON.parse(output); + expect(parsed.slug).toBe("frontend"); + }); + + test("org-only target (org/) throws ContextError", async () => { + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + try { + await func.call(context, { json: false, web: false }, "my-org/"); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + expect((error as ContextError).message).toContain("Specific project"); + expect((error as ContextError).message).toContain( + "not just the organization" + ); + } + }); + + test("auto-detect uses resolveAllTargets and writes footer", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "my-org", + project: "backend", + orgDisplay: "my-org", + projectDisplay: "backend", + detectedFrom: ".env", + }, + ], + footer: "Detected 1 project from .env", + }); + getProjectSpy.mockResolvedValue({ ...sampleProject, slug: "backend" }); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context, stdoutWrite } = createMockContext(); + const func = await viewCommand.loader(); + // No target arg triggers AutoDetect + await func.call(context, { json: false, web: false }); + + expect(resolveAllTargetsSpy).toHaveBeenCalled(); + const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); + expect(output).toContain("backend"); + expect(output).toContain("Detected 1 project from .env"); + }); + + test("auto-detect with 0 targets throws ContextError", async () => { + resolveAllTargetsSpy.mockResolvedValue({ targets: [] }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false }) + ).rejects.toThrow(ContextError); + }); + + test("auto-detect with skippedSelfHosted includes DSN hint in error", async () => { + resolveAllTargetsSpy.mockResolvedValue({ + targets: [], + skippedSelfHosted: 3, + }); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + try { + await func.call(context, { json: false, web: false }); + expect.unreachable("Should have thrown"); + } catch (error) { + expect(error).toBeInstanceOf(ContextError); + const msg = (error as ContextError).message; + expect(msg).toContain("3 DSN(s)"); + expect(msg).toContain("could not be resolved"); + } + }); + + test("non-auth API error is skipped silently", async () => { + getProjectSpy.mockRejectedValue(new Error("404 Not Found")); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + // The project fetch fails with a non-auth error, so it's filtered out. + // With no successful results, buildContextError is thrown. + await expect( + func.call(context, { json: false, web: false }, "my-org/bad-project") + ).rejects.toThrow(ContextError); + + // getProject was called (it just failed) + expect(getProjectSpy).toHaveBeenCalledWith("my-org", "bad-project"); + }); + + test("auth error from API is rethrown", async () => { + getProjectSpy.mockRejectedValue(new AuthError("not_authenticated")); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false }, "my-org/test-project") + ).rejects.toThrow(AuthError); + }); +});