From 823803e7201aaf44de6ed44925c7ef4384c3f7b6 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 20 Apr 2026 17:06:51 +0000 Subject: [PATCH 1/4] fix(errors): surface real API errors and granular scopes in 403s (#785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UX feedback items that were both producing misleading errors: **Item #8 — `project view` swallowed real API errors.** `sentry project view /` previously caught any non-auth `ApiError` (403 forbidden, 404 not found, etc.) inside `fetchProjectDetails`, returned null, and then threw `ContextError("Could not auto-detect organization and project")` — despite the user having explicitly provided both. Now fetch errors rethrow verbatim for `explicit` and `project-search` modes so the user sees the real cause. `auto-detect` mode (which may surface multiple DSN-discovered targets) keeps the pre-fix per-target tolerance, since one inaccessible DSN shouldn't block the rest. **Item #9 — 403 hints named the wrong missing scopes.** The 403 handler in `issue list` and `listOrganizations` said the token "may lack the required scopes (org:read, project:read)" even when it had those and was actually missing `event:read` or another scope. Sentry's API response often names the real culprit; we now parse it via a new `extractRequiredScopes(detail)` helper in `src/lib/api-scope.ts` and surface the specific scope(s) in the suggestion. Falls back to the pre-fix hardcoded list when the API doesn't tell us. The scope extractor handles three common response shapes: - Plain detail strings with embedded scope identifiers. - Top-level `required` / `requiredScopes` / `scopes` arrays. - Arrays of `{scope: "..."}` objects. Only recognizes known Sentry scope resources (`org`/`project`/`team`/ `member`/`event`/`release`/`alerts`) so random `foo:bar` substrings in unrelated error text don't get surfaced as "scopes". Addresses getsentry/cli#785 items #8 and #9. --- src/commands/issue/list.ts | 32 ++++- src/commands/project/view.ts | 75 ++++++++++- src/lib/api-scope.ts | 159 ++++++++++++++++++++++++ src/lib/api/organizations.ts | 64 ++++++---- test/commands/project/view.func.test.ts | 59 ++++++++- test/lib/api-scope.test.ts | 138 ++++++++++++++++++++ 6 files changed, 494 insertions(+), 33 deletions(-) create mode 100644 src/lib/api-scope.ts create mode 100644 test/lib/api-scope.test.ts diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index e24c56515..ed337e30e 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -19,6 +19,7 @@ import { listIssuesPaginated, listProjects, } from "../../lib/api-client.js"; +import { extractRequiredScopes } from "../../lib/api-scope.js"; import { looksLikeIssueShortId, parseOrgProjectArg, @@ -1111,6 +1112,14 @@ function enrichIssueListError( throw error; } +/** + * Default scopes mentioned when the API response doesn't tell us which + * scope is missing. These are the minimum the issue-list endpoint needs + * — surfaced verbatim from the previous hardcoded message so the + * fallback behavior matches the pre-fix UX. + */ +const DEFAULT_ISSUE_LIST_SCOPES = "org:read, project:read"; + /** * Build an enriched error detail for 403 Forbidden responses. * @@ -1118,21 +1127,38 @@ function enrichIssueListError( * (SENTRY_AUTH_TOKEN / SENTRY_TOKEN) since the regular `sentry auth login` * OAuth flow always grants the required scopes. * + * When the API's detail payload names the required scope(s) explicitly + * (see {@link extractRequiredScopes}) we surface that list instead of + * the hardcoded default — this is the fix for getsentry/cli#785 item #9 + * where a token missing `event:read` was told it might be missing + * `org:read, project:read` (which it actually had). + * * @param originalDetail - The API response detail (may be undefined) * @returns Enhanced detail string with suggestions */ -function build403Detail(originalDetail: string | undefined): string { +function build403Detail(originalDetail: unknown): string { const lines: string[] = []; - if (originalDetail) { + if (typeof originalDetail === "string" && originalDetail) { lines.push(originalDetail, ""); } lines.push("Suggestions:"); if (isEnvTokenActive()) { + const scopes = extractRequiredScopes(originalDetail); + const scopeList = + scopes.length > 0 ? scopes.join(", ") : DEFAULT_ISSUE_LIST_SCOPES; + // When the API was explicit about what's missing, frame the hint + // as a definite statement ("is missing") rather than a hedged + // "may lack" — this is the user-visible payoff of parsing the + // response. + const leader = + scopes.length > 0 + ? `Your ${getActiveEnvVarName()} token is missing the required scope(s)` + : `Your ${getActiveEnvVarName()} token may lack the required scopes`; lines.push( - ` • Your ${getActiveEnvVarName()} token may lack the required scopes (org:read, project:read)`, + ` • ${leader} (${scopeList})`, " • Check token scopes at: https://sentry.io/settings/auth-tokens/" ); } else { diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 2430cbc1b..6aafd416a 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -112,6 +112,40 @@ async function fetchProjectDetails( return result.ok ? result.value : null; } +/** + * Fetch project details, rethrowing the underlying API error on failure. + * + * Used when the user has explicitly named a project (explicit or + * project-search modes). In those cases returning null and falling + * through to a generic "Could not auto-detect ..." ContextError hides + * the actual cause (403 no-access / 404 no-such-project) and forces + * the user to re-provide context they already provided + * (getsentry/cli#785 item #8). Re-fetches without the auth guard so + * any non-auth ApiError surfaces as-is. + */ +async function fetchProjectDetailsOrThrow( + target: ResolvedTarget +): Promise { + // First try with the auth guard to catch AuthError specifically (so + // the auto-login middleware in cli.ts can still fire). On non-auth + // failure, re-run without the guard to let the real API error bubble + // up to the global error handler. + const guarded = await withAuthGuard(async () => { + const [project, dsn] = await Promise.all([ + target.projectData + ? Promise.resolve(target.projectData) + : getProject(target.org, target.project), + tryGetPrimaryDsn(target.org, target.project), + ]); + return { project, dsn }; + }); + if (guarded.ok) { + return guarded.value; + } + // Non-auth failure: rethrow the captured error unchanged. + throw guarded.error; +} + /** Result of fetching project details for multiple targets */ type FetchResult = { projects: SentryProject[]; @@ -122,6 +156,11 @@ type FetchResult = { /** * Fetch project details for all targets in parallel. * Filters out failed fetches while preserving target association. + * + * Used exclusively for auto-detect mode where we may surface zero or + * more projects from DSN scanning — swallowing per-target failures is + * intentional there, since a missing/inaccessible DSN target should + * not take down the whole command. */ async function fetchAllProjectDetails( targets: ResolvedTarget[] @@ -282,9 +321,39 @@ export const viewCommand = buildCommand({ return; } - // Fetch project details for all targets in parallel - const { projects, dsns, targets } = - await fetchAllProjectDetails(resolvedTargets); + // Fetching strategy depends on how the target was specified: + // + // - Auto-detect mode surfaces zero or more DSN-discovered targets; + // per-target failures are swallowed so one inaccessible DSN + // doesn't block the rest of the results. + // + // - Explicit and project-search modes have exactly one target that + // the user asked for by name. Any failure there must surface the + // underlying API error (403/404/etc.) — swallowing it and falling + // through to "Organization and project is required" is the bug + // reported in getsentry/cli#785 item #8, since the user already + // provided both. + let projects: SentryProject[]; + let dsns: (string | null)[]; + let targets: ResolvedTarget[]; + + if (parsed.type === ProjectSpecificationType.AutoDetect) { + const fetched = await fetchAllProjectDetails(resolvedTargets); + projects = fetched.projects; + dsns = fetched.dsns; + targets = fetched.targets; + } else { + // Explicit / project-search — rethrow real errors instead of + // masking them with buildContextError(). + const firstTarget = resolvedTargets[0]; + if (!firstTarget) { + throw buildContextError(); + } + const detail = await fetchProjectDetailsOrThrow(firstTarget); + projects = [detail.project]; + dsns = [detail.dsn]; + targets = [firstTarget]; + } if (projects.length === 0) { throw buildContextError(); diff --git a/src/lib/api-scope.ts b/src/lib/api-scope.ts new file mode 100644 index 000000000..279361285 --- /dev/null +++ b/src/lib/api-scope.ts @@ -0,0 +1,159 @@ +/** + * Scope extraction helpers for Sentry 403 responses. + * + * Sentry's 403 responses occasionally include the specific permission + * scope the token is missing — either as an explicit field on the JSON + * body or embedded in the `detail` message string. This module pulls + * that information out so we can surface it to users instead of the + * hardcoded generic "org:read, project:read" list referenced in + * getsentry/cli#785 item #9. + * + * Response shapes observed in the wild: + * + * 1. `{"detail": "You do not have permission to perform this action."}` + * Plain — nothing to extract. + * + * 2. `{"detail": "You do not have the required scope to perform this + * action. Required scopes: event:read"}` + * Scope named in the detail string. + * + * 3. Top-level `required` / `requiredScopes` arrays on some endpoints: + * `{"detail": "...", "required": ["event:read"]}` + * + * This module's sole contract is: given an API-response detail value, + * return the subset of Sentry scope identifiers that appear in it, in + * source order, deduplicated. Callers decide how to render them. + */ + +/** + * Matches a Sentry scope identifier of the form `:`. + * + * The scope namespace is short and well-known — we match only the + * resources the CLI's OAuth flow requests plus the small set of + * adjacent scopes users commonly need. Unrecognized pairs stay out of + * the match list so random `foo:bar` substrings in error messages + * don't get surfaced as scopes. + */ +const KNOWN_SCOPE_RE = + /\b(?:org|project|team|member|event|release|alerts)(?::(?:read|write|admin))\b/gi; + +/** + * Extract Sentry scope identifiers from a 403 response detail value. + * + * The detail may be a plain string, a structured record with a + * `required` / `requiredScopes` / `scopes` array, or `undefined`. All + * three shapes are handled. Returns an empty array when no scopes are + * identifiable — callers should fall back to their hardcoded defaults + * in that case. + * + * @param detail - The ApiError.detail value from a 403 response + * @returns Deduplicated, source-ordered list of scope identifiers + * (e.g. `["event:read"]`) + */ +export function extractRequiredScopes(detail: unknown): string[] { + if (!detail) { + return []; + } + + // Structured shapes: look for common field names used by Sentry. + if (typeof detail === "object") { + const scopes = extractFromRecord(detail as Record); + if (scopes.length > 0) { + return scopes; + } + // Fall through to serializing the object and scanning the text + // form — still catches cases where the detail carries scope info + // under a non-standard key name. + return extractFromText(JSON.stringify(detail)); + } + + if (typeof detail === "string") { + return extractFromText(detail); + } + + return []; +} + +/** Candidate field names carrying scope arrays on Sentry API responses. */ +const SCOPE_FIELD_NAMES = ["required", "requiredScopes", "scopes"] as const; + +/** + * Look for a scope-like string array on any of the known field names. + * + * Accepts both plain arrays and arrays of `{scope: "..."}` objects — + * both shapes have appeared historically in Sentry's responses. + */ +function extractFromRecord(record: Record): string[] { + for (const field of SCOPE_FIELD_NAMES) { + const value = record[field]; + if (!Array.isArray(value)) { + continue; + } + const scopes = collectScopesFromArray(value); + if (scopes.length > 0) { + return dedupe(scopes); + } + } + return []; +} + +/** + * Normalize a heterogeneous array of scope-like entries into a flat + * lowercase scope list. Entries that aren't strings or + * `{scope: string}` objects are silently dropped. + */ +function collectScopesFromArray(entries: unknown[]): string[] { + const out: string[] = []; + for (const entry of entries) { + const scope = extractScopeCandidate(entry); + if (scope && matchesKnownScope(scope)) { + out.push(scope.toLowerCase()); + } + } + return out; +} + +/** Extract a string scope candidate from either a bare string or a `{scope}` object. */ +function extractScopeCandidate(entry: unknown): string | undefined { + if (typeof entry === "string") { + return entry; + } + if ( + entry && + typeof entry === "object" && + "scope" in entry && + typeof (entry as { scope: unknown }).scope === "string" + ) { + return (entry as { scope: string }).scope; + } + return; +} + +/** Test + reset lastIndex on the shared `g`-flagged regex. */ +function matchesKnownScope(scope: string): boolean { + const matched = KNOWN_SCOPE_RE.test(scope); + KNOWN_SCOPE_RE.lastIndex = 0; + return matched; +} + +/** Pull scope identifiers out of a free-text detail message. */ +function extractFromText(text: string): string[] { + const matches = text.match(KNOWN_SCOPE_RE); + if (!matches) { + return []; + } + return dedupe(matches.map((m) => m.toLowerCase())); +} + +/** Deduplicate while preserving insertion order. */ +function dedupe(items: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const item of items) { + if (!seen.has(item)) { + seen.add(item); + out.push(item); + } + } + return out; +} diff --git a/src/lib/api/organizations.ts b/src/lib/api/organizations.ts index a2ad343ef..778cc9d7e 100644 --- a/src/lib/api/organizations.ts +++ b/src/lib/api/organizations.ts @@ -16,6 +16,7 @@ import { UserRegionsResponseSchema, } from "../../types/index.js"; +import { extractRequiredScopes } from "../api-scope.js"; import { getActiveEnvVarName, isEnvTokenActive } from "../db/auth.js"; import { ApiError, withAuthGuard } from "../errors.js"; import { @@ -69,32 +70,53 @@ export async function listOrganizationsInRegion( // Only mention token scopes when using a custom env-var token — // the regular `sentry auth login` OAuth flow always grants org:read. if (error instanceof ApiError && error.status === 403) { - const lines: string[] = []; - if (error.detail) { - lines.push(error.detail, ""); - } - if (isEnvTokenActive()) { - lines.push( - `Your ${getActiveEnvVarName()} token may lack the required 'org:read' scope.`, - "Check token scopes at: https://sentry.io/settings/auth-tokens/" - ); - } else { - lines.push( - "You may not have access to this organization.", - "Re-authenticate with: sentry auth login" - ); - } - throw new ApiError( - error.message, - error.status, - lines.join("\n "), - error.endpoint - ); + throw enrichListOrgsForbidden(error); } throw error; } } +/** + * Enrich a 403 from the list-organizations endpoint with actionable + * hints. Prefers scope(s) named explicitly in the API response over + * the hardcoded `'org:read'` fallback (getsentry/cli#785 item #9). + */ +function enrichListOrgsForbidden(error: ApiError): ApiError { + const lines: string[] = []; + if (error.detail) { + lines.push(error.detail, ""); + } + if (isEnvTokenActive()) { + lines.push(buildEnvTokenScopeHint(error.detail)); + lines.push( + "Check token scopes at: https://sentry.io/settings/auth-tokens/" + ); + } else { + lines.push( + "You may not have access to this organization.", + "Re-authenticate with: sentry auth login" + ); + } + return new ApiError( + error.message, + error.status, + lines.join("\n "), + error.endpoint + ); +} + +/** + * Build a single-line hint mentioning the scope(s) the env-var token + * is missing, preferring the API-provided list when available. + */ +function buildEnvTokenScopeHint(detail: unknown): string { + const scopes = extractRequiredScopes(detail); + if (scopes.length > 0) { + return `Your ${getActiveEnvVarName()} token is missing the required scope(s) '${scopes.join("', '")}'.`; + } + return `Your ${getActiveEnvVarName()} token may lack the required scope 'org:read'.`; +} + /** * List all organizations, returning cached data when available. * diff --git a/test/commands/project/view.func.test.ts b/test/commands/project/view.func.test.ts index 42e46fb3d..65bd59c8c 100644 --- a/test/commands/project/view.func.test.ts +++ b/test/commands/project/view.func.test.ts @@ -252,23 +252,70 @@ describe("viewCommand.func", () => { } }); - test("non-auth API error is skipped silently", async () => { - getProjectSpy.mockRejectedValue(new Error("404 Not Found")); + test("non-auth API error on explicit target is rethrown verbatim", async () => { + // Previously the error was swallowed and a generic + // "Could not auto-detect organization and project" ContextError + // was raised — confusing because the user provided the target + // explicitly (getsentry/cli#785 item #8). The actual API error + // must bubble up so the user sees the real cause (404/403/etc.). + const apiErr = new Error("404 Not Found"); + getProjectSpy.mockRejectedValue(apiErr); 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); + ).rejects.toThrow("404 Not Found"); - // getProject was called (it just failed) expect(getProjectSpy).toHaveBeenCalledWith("my-org", "bad-project"); }); + test("non-auth API error on project-search target is rethrown verbatim", async () => { + resolveProjectBySlugSpy.mockResolvedValue({ + org: "acme", + project: "frontend", + }); + const apiErr = new Error("403 Forbidden"); + getProjectSpy.mockRejectedValue(apiErr); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false }, "frontend") + ).rejects.toThrow("403 Forbidden"); + }); + + test("non-auth API error on auto-detect target is swallowed (multi-target recovery)", async () => { + // For auto-detect — which may surface multiple DSN-discovered + // targets — per-target failures are still tolerated: one + // inaccessible DSN must not block the rest of the results. When + // all targets fail and the set ends up empty, the original + // ContextError is still the right surface. + resolveAllTargetsSpy.mockResolvedValue({ + targets: [ + { + org: "my-org", + project: "inaccessible", + orgDisplay: "my-org", + projectDisplay: "inaccessible", + }, + ], + }); + getProjectSpy.mockRejectedValue(new Error("403 Forbidden")); + getProjectKeysSpy.mockResolvedValue(sampleKeys); + + const { context } = createMockContext(); + const func = await viewCommand.loader(); + + await expect( + func.call(context, { json: false, web: false }) + ).rejects.toThrow(ContextError); + }); + test("auth error from API is rethrown", async () => { getProjectSpy.mockRejectedValue(new AuthError("not_authenticated")); getProjectKeysSpy.mockResolvedValue(sampleKeys); diff --git a/test/lib/api-scope.test.ts b/test/lib/api-scope.test.ts new file mode 100644 index 000000000..1c692f131 --- /dev/null +++ b/test/lib/api-scope.test.ts @@ -0,0 +1,138 @@ +/** + * Tests for `extractRequiredScopes` — the 403-response scope parser + * that powers the friendly "missing scope: event:read" hint in place + * of the hardcoded "(org:read, project:read)" fallback. + */ + +import { describe, expect, test } from "bun:test"; +import { extractRequiredScopes } from "../../src/lib/api-scope.js"; + +describe("extractRequiredScopes", () => { + test("returns [] for undefined or null detail", () => { + expect(extractRequiredScopes(undefined)).toEqual([]); + expect(extractRequiredScopes(null)).toEqual([]); + }); + + test("returns [] when the detail contains no recognizable scopes", () => { + expect( + extractRequiredScopes( + "You do not have permission to perform this action." + ) + ).toEqual([]); + }); + + test("extracts a single scope from a detail string", () => { + expect( + extractRequiredScopes( + "You do not have the required scope to perform this action. Required scopes: event:read" + ) + ).toEqual(["event:read"]); + }); + + test("extracts multiple scopes from a detail string preserving order", () => { + expect( + extractRequiredScopes( + "Required scopes: event:read, project:write. Got: none." + ) + ).toEqual(["event:read", "project:write"]); + }); + + test("deduplicates repeated scopes", () => { + expect( + extractRequiredScopes( + "event:read is required. Try obtaining event:read scope." + ) + ).toEqual(["event:read"]); + }); + + test("ignores random foo:bar substrings that aren't real scopes", () => { + // The regex is anchored to the known resource namespace so that + // response text mentioning things like `http:localhost` or + // `timestamp:now` doesn't accidentally match. + expect( + extractRequiredScopes("http:localhost timestamp:now category:billing") + ).toEqual([]); + }); + + test("lowercases the matched scope identifier", () => { + expect(extractRequiredScopes("Required: EVENT:READ")).toEqual([ + "event:read", + ]); + }); + + test("pulls scopes from a structured `required` field", () => { + expect( + extractRequiredScopes({ + detail: "You do not have permission to perform this action.", + required: ["event:read"], + }) + ).toEqual(["event:read"]); + }); + + test("pulls scopes from a structured `requiredScopes` field", () => { + expect( + extractRequiredScopes({ + detail: "Missing scopes.", + requiredScopes: ["project:admin", "team:write"], + }) + ).toEqual(["project:admin", "team:write"]); + }); + + test("pulls scopes from a `scopes` field of {scope} objects", () => { + expect( + extractRequiredScopes({ + detail: "Missing scopes.", + scopes: [{ scope: "org:read" }, { scope: "org:write" }], + }) + ).toEqual(["org:read", "org:write"]); + }); + + test("falls back to text scanning when the structured fields are absent", () => { + // An object without the known field names gets serialized and + // scanned — catches responses that carry scope info under a + // non-standard key. + expect( + extractRequiredScopes({ + message: "Your token lacks project:read.", + }) + ).toEqual(["project:read"]); + }); + + test("ignores non-string entries in the required array", () => { + expect( + extractRequiredScopes({ + required: [42, null, "event:read", { unrelated: true }], + }) + ).toEqual(["event:read"]); + }); + + test("supports all CLI OAuth namespaces", () => { + // Round-trip check that the namespaces the OAuth flow requests + // (plus `alerts` / `member`) all pass the regex. + const allScopes = [ + "org:read", + "org:write", + "org:admin", + "project:read", + "project:write", + "project:admin", + "team:read", + "team:write", + "team:admin", + "member:read", + "member:write", + "member:admin", + "event:read", + "event:write", + "event:admin", + "release:read", + "release:write", + "release:admin", + "alerts:read", + "alerts:write", + ]; + expect( + extractRequiredScopes(`Required scopes: ${allScopes.join(", ")}`) + ).toEqual(allScopes); + }); +}); From d3f88f51a8ea473048334e38cfd12e5414f2049f Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 21 Apr 2026 09:15:45 +0000 Subject: [PATCH 2/4] refactor(api-scope): align regex with Sentry's real scope list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements from review on #789: 1. DRY up the project/view fetch helpers. `fetchProjectDetails` and `fetchProjectDetailsOrThrow` had identical `withAuthGuard` + `Promise.all(getProject, tryGetPrimaryDsn)` bodies that would drift if only one was updated. Extracted to a single `fetchProjectAndDsn` used by both. (Bot review.) 2. Verify the scope regex against the real Sentry codebase (`getsentry/sentry` src/sentry/conf/server.py `SENTRY_SCOPES`) and fix three drifts: - `release:read/write/admin` — **not a real namespace**. Release scopes are `project:releases` and `project:distribution`. My regex was accepting nonexistent scopes. - Missing `org:integrations`, `org:ci`, `member:invite`, `project:releases`, `project:distribution` — real scopes that would have been silently dropped. - `alerts:admin` — Sentry has no admin tier on alerts; my regex would have accepted it. Also audited Sentry's 403 emission (`src/sentry/api/bases/ organization.py`, `src/sentry/api/exceptions.py`) and confirmed the standard path is a DRF `PermissionDenied` with no structured scope field. Updated the module-level docstring to be honest about this — the structured-field extraction stays as defensive future-proofing (zero cost when absent), but the common hit is extraction from free-text `detail` strings in custom raises. The canonical list is now a const array fed to `new RegExp(...)`, so adding a scope in the future is a one-line change and tests flag drifts directly. --- src/commands/project/view.ts | 74 ++++++++++++++--------- src/lib/api-scope.ts | 110 ++++++++++++++++++++++++----------- test/lib/api-scope.test.ts | 39 +++++++++---- 3 files changed, 149 insertions(+), 74 deletions(-) diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 6aafd416a..7b27323d0 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -13,7 +13,7 @@ import { } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; import { buildCommand } from "../../lib/command.js"; -import { ContextError, withAuthGuard } from "../../lib/errors.js"; +import { AuthError, ContextError, withAuthGuard } from "../../lib/errors.js"; import { divider, formatProjectDetails } from "../../lib/formatters/index.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { @@ -92,15 +92,21 @@ type ProjectWithDsn = { }; /** - * Fetch project details and keys for a single target. - * Returns null on non-auth errors (e.g., no access to project). - * Rethrows auth errors so they propagate to the user. + * Perform the parallel project + DSN fetch for a single target. + * + * Shared by both `fetchProjectDetails` (which swallows non-auth + * failures) and `fetchProjectDetailsOrThrow` (which rethrows them). + * Extracted so the two callers stay in lockstep — if a future change + * adds another parallel call or swaps the DSN lookup, both paths + * benefit automatically. + * + * `AuthError` (from `withAuthGuard`) always propagates so the + * auto-login middleware in `cli.ts` can fire. */ -async function fetchProjectDetails( - target: ResolvedTarget -): Promise { - const result = await withAuthGuard(async () => { - // Fetch project (skip if already fetched during resolution) and DSN in parallel +function fetchProjectAndDsn(target: ResolvedTarget): Promise { + return withAuthGuard(async () => { + // Fetch project (skip if already fetched during resolution) and + // its DSN in parallel. const [project, dsn] = await Promise.all([ target.projectData ? Promise.resolve(target.projectData) @@ -108,8 +114,35 @@ async function fetchProjectDetails( tryGetPrimaryDsn(target.org, target.project), ]); return { project, dsn }; + }).then((result) => { + if (result.ok) { + return result.value; + } + // Non-auth failure: rethrow the captured error so the caller can + // decide whether to swallow it or surface it. + throw result.error; }); - return result.ok ? result.value : null; +} + +/** + * Fetch project details and keys for a single target. + * Returns null on non-auth errors (e.g., no access to project). + * Rethrows auth errors so they propagate to the user. + */ +async function fetchProjectDetails( + target: ResolvedTarget +): Promise { + try { + return await fetchProjectAndDsn(target); + } catch (error) { + // AuthError is surfaced by `fetchProjectAndDsn` via `withAuthGuard` — + // it bypasses the catch because it's re-thrown inside the guarded + // block. Re-inspect here so the auto-login middleware still fires. + if (error instanceof AuthError) { + throw error; + } + return null; + } } /** @@ -123,27 +156,10 @@ async function fetchProjectDetails( * (getsentry/cli#785 item #8). Re-fetches without the auth guard so * any non-auth ApiError surfaces as-is. */ -async function fetchProjectDetailsOrThrow( +function fetchProjectDetailsOrThrow( target: ResolvedTarget ): Promise { - // First try with the auth guard to catch AuthError specifically (so - // the auto-login middleware in cli.ts can still fire). On non-auth - // failure, re-run without the guard to let the real API error bubble - // up to the global error handler. - const guarded = await withAuthGuard(async () => { - const [project, dsn] = await Promise.all([ - target.projectData - ? Promise.resolve(target.projectData) - : getProject(target.org, target.project), - tryGetPrimaryDsn(target.org, target.project), - ]); - return { project, dsn }; - }); - if (guarded.ok) { - return guarded.value; - } - // Non-auth failure: rethrow the captured error unchanged. - throw guarded.error; + return fetchProjectAndDsn(target); } /** Result of fetching project details for multiple targets */ diff --git a/src/lib/api-scope.ts b/src/lib/api-scope.ts index 279361285..4c691ff25 100644 --- a/src/lib/api-scope.ts +++ b/src/lib/api-scope.ts @@ -1,54 +1,98 @@ /** * Scope extraction helpers for Sentry 403 responses. * - * Sentry's 403 responses occasionally include the specific permission - * scope the token is missing — either as an explicit field on the JSON - * body or embedded in the `detail` message string. This module pulls - * that information out so we can surface it to users instead of the - * hardcoded generic "org:read, project:read" list referenced in - * getsentry/cli#785 item #9. + * The primary goal is to surface the specific permission scope a token + * is missing, instead of the hardcoded generic "org:read, project:read" + * list referenced in getsentry/cli#785 item #9. * - * Response shapes observed in the wild: + * Reality check against the Sentry codebase (getsentry/sentry + * `src/sentry/api/bases/organization.py` and `src/sentry/api/base.py`): + * the standard 403 path is a DRF `PermissionDenied` with the default + * `"You do not have permission to perform this action."` string — no + * structured scope field, no scope identifier in the text. A handful + * of sites pass a custom `detail` string (for example + * `src/sentry/api/helpers/teams.py` and `src/sentry/api/endpoints/ + * rule_snooze.py`), and those strings are free-form but sometimes + * mention scope identifiers verbatim. * - * 1. `{"detail": "You do not have permission to perform this action."}` - * Plain — nothing to extract. + * This module therefore: * - * 2. `{"detail": "You do not have the required scope to perform this - * action. Required scopes: event:read"}` - * Scope named in the detail string. + * - Scans the detail text for exact scope identifiers from the + * canonical {@link SENTRY_SCOPES} set. Matches are only real + * identifiers; arbitrary `foo:bar` substrings in error text never + * get surfaced as scopes. + * - Also peeks at a few structured field names + * (`required` / `requiredScopes` / `scopes`) that Sentry could + * reasonably start emitting in the future. These paths are zero-cost + * when absent and future-proof the CLI against a backend change that + * adds them. * - * 3. Top-level `required` / `requiredScopes` arrays on some endpoints: - * `{"detail": "...", "required": ["event:read"]}` + * Callers that receive an empty array should fall back to their own + * hardcoded defaults (mirrors the pre-fix behavior). + */ + +/** + * Canonical Sentry scope identifiers, mirrored from + * `src/sentry/conf/server.py` `SENTRY_SCOPES` (and its hierarchy + * mapping). Kept as a single source of truth so the regex and tests + * agree on what is and isn't a real scope. * - * This module's sole contract is: given an API-response detail value, - * return the subset of Sentry scope identifiers that appear in it, in - * source order, deduplicated. Callers decide how to render them. + * Deliberately excluded: + * - `openid` / `profile` / `email` — OIDC scopes, never part of a + * CLI 403 response. + * - `org:superuser` — internal-only, never returned to clients. */ +const SENTRY_SCOPES = [ + "org:read", + "org:write", + "org:admin", + "org:integrations", + "org:ci", + "member:invite", + "member:read", + "member:write", + "member:admin", + "team:read", + "team:write", + "team:admin", + "project:read", + "project:write", + "project:admin", + "project:releases", + "project:distribution", + "event:read", + "event:write", + "event:admin", + "alerts:read", + "alerts:write", +] as const; /** - * Matches a Sentry scope identifier of the form `:`. + * Build a word-bounded alternation regex from {@link SENTRY_SCOPES}. * - * The scope namespace is short and well-known — we match only the - * resources the CLI's OAuth flow requests plus the small set of - * adjacent scopes users commonly need. Unrecognized pairs stay out of - * the match list so random `foo:bar` substrings in error messages - * don't get surfaced as scopes. + * Using an explicit alternation (rather than a `:` product) + * avoids matching nonexistent combinations like `release:write` or + * `alerts:admin`, which `SENTRY_SCOPES` doesn't list. */ -const KNOWN_SCOPE_RE = - /\b(?:org|project|team|member|event|release|alerts)(?::(?:read|write|admin))\b/gi; +const KNOWN_SCOPE_RE = new RegExp( + `\\b(?:${SENTRY_SCOPES.map((s) => s.replace(":", ":")).join("|")})\\b`, + "gi" +); /** * Extract Sentry scope identifiers from a 403 response detail value. * - * The detail may be a plain string, a structured record with a - * `required` / `requiredScopes` / `scopes` array, or `undefined`. All - * three shapes are handled. Returns an empty array when no scopes are - * identifiable — callers should fall back to their hardcoded defaults - * in that case. + * Current Sentry API responses rarely name the missing scope (see the + * module-level notes), so this function usually returns `[]` and + * callers fall back to their hardcoded default hint. It DOES fire + * correctly when the scope appears in a custom DRF `PermissionDenied` + * detail string, and remains future-proof for structured response + * shapes that could be added later. * - * @param detail - The ApiError.detail value from a 403 response - * @returns Deduplicated, source-ordered list of scope identifiers - * (e.g. `["event:read"]`) + * @param detail - The ApiError.detail value from a 403 response. + * May be a plain string, a structured record, or `undefined`. + * @returns Deduplicated, source-ordered list of known Sentry scope + * identifiers (e.g. `["event:read"]`). Empty when none found. */ export function extractRequiredScopes(detail: unknown): string[] { if (!detail) { diff --git a/test/lib/api-scope.test.ts b/test/lib/api-scope.test.ts index 1c692f131..0c9fb4b71 100644 --- a/test/lib/api-scope.test.ts +++ b/test/lib/api-scope.test.ts @@ -106,28 +106,31 @@ describe("extractRequiredScopes", () => { ).toEqual(["event:read"]); }); - test("supports all CLI OAuth namespaces", () => { - // Round-trip check that the namespaces the OAuth flow requests - // (plus `alerts` / `member`) all pass the regex. + test("matches every scope in the canonical Sentry SENTRY_SCOPES list", () => { + // Canonical list mirrored from getsentry/sentry + // `src/sentry/conf/server.py` SENTRY_SCOPES. Keeping this test here + // makes it easy to spot when the backend adds or removes a scope. const allScopes = [ "org:read", "org:write", "org:admin", - "project:read", - "project:write", - "project:admin", - "team:read", - "team:write", - "team:admin", + "org:integrations", + "org:ci", + "member:invite", "member:read", "member:write", "member:admin", + "team:read", + "team:write", + "team:admin", + "project:read", + "project:write", + "project:admin", + "project:releases", + "project:distribution", "event:read", "event:write", "event:admin", - "release:read", - "release:write", - "release:admin", "alerts:read", "alerts:write", ]; @@ -135,4 +138,16 @@ describe("extractRequiredScopes", () => { extractRequiredScopes(`Required scopes: ${allScopes.join(", ")}`) ).toEqual(allScopes); }); + + test("rejects scope-shaped strings that are not real Sentry scopes", () => { + // Catches regressions where the regex accidentally widens to + // `:` products that Sentry doesn't actually define — + // e.g. `release:read` (no `release` namespace; the real scope is + // `project:releases`) or `alerts:admin` (no admin tier). + expect( + extractRequiredScopes( + "Not real: release:read release:write alerts:admin team:superuser" + ) + ).toEqual([]); + }); }); From 9931385e34222510201f5567b0084a888848053e Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 21 Apr 2026 09:44:16 +0000 Subject: [PATCH 3/4] fix(api-scope): drop no-op `:`->`:` substitution in scope regex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged the identity replacement `s.replace(":", ":")` on line 78 — leftover from when I was mid-thought about whether the scope strings needed regex escaping. They don't: `:` isn't a regex metacharacter, so the scope strings are literal alternation branches. Dropped the no-op `.map()`. Tests still pass unchanged. --- src/lib/api-scope.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/lib/api-scope.ts b/src/lib/api-scope.ts index 4c691ff25..7ec4b2f93 100644 --- a/src/lib/api-scope.ts +++ b/src/lib/api-scope.ts @@ -72,12 +72,10 @@ const SENTRY_SCOPES = [ * * Using an explicit alternation (rather than a `:` product) * avoids matching nonexistent combinations like `release:write` or - * `alerts:admin`, which `SENTRY_SCOPES` doesn't list. + * `alerts:admin`, which `SENTRY_SCOPES` doesn't list. `:` is not a + * regex metacharacter so the scope strings need no escaping. */ -const KNOWN_SCOPE_RE = new RegExp( - `\\b(?:${SENTRY_SCOPES.map((s) => s.replace(":", ":")).join("|")})\\b`, - "gi" -); +const KNOWN_SCOPE_RE = new RegExp(`\\b(?:${SENTRY_SCOPES.join("|")})\\b`, "gi"); /** * Extract Sentry scope identifiers from a 403 response detail value. From 8a970af8ee8ee75f55018967b0cc82a1fbb56b97 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 21 Apr 2026 10:29:16 +0000 Subject: [PATCH 4/4] refactor(errors): trim #789 patch per review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `fetchProjectAndDsn`: convert to plain `async` function so errors flow through native try/catch instead of a `.then` handler that re-throws. Equivalent semantics, clearer read. - `dedupe` helper removed — `[...new Set(items)]` preserves insertion order since ES2015 and is what every JS reader expects. - Trim over-explained JSDoc and inline commentary across api-scope.ts and project/view.ts. The function names and types already carry most of the intent; kept only the non-obvious bits (SENTRY_SCOPES provenance note, the regex-alternation rationale). Net: -155 / +50 lines. Tests unchanged. --- src/commands/project/view.ts | 78 +++++++-------------- src/lib/api-scope.ts | 127 ++++++++--------------------------- 2 files changed, 50 insertions(+), 155 deletions(-) diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index 7b27323d0..623c5f9d0 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -92,21 +92,16 @@ type ProjectWithDsn = { }; /** - * Perform the parallel project + DSN fetch for a single target. + * Parallel project + DSN fetch for a single target. * - * Shared by both `fetchProjectDetails` (which swallows non-auth - * failures) and `fetchProjectDetailsOrThrow` (which rethrows them). - * Extracted so the two callers stay in lockstep — if a future change - * adds another parallel call or swaps the DSN lookup, both paths - * benefit automatically. - * - * `AuthError` (from `withAuthGuard`) always propagates so the - * auto-login middleware in `cli.ts` can fire. + * `AuthError` always propagates so the auto-login middleware fires. + * Other API failures rethrow so callers can choose to swallow + * (auto-detect) or surface (explicit/search) them. */ -function fetchProjectAndDsn(target: ResolvedTarget): Promise { - return withAuthGuard(async () => { - // Fetch project (skip if already fetched during resolution) and - // its DSN in parallel. +async function fetchProjectAndDsn( + target: ResolvedTarget +): Promise { + const result = await withAuthGuard(async () => { const [project, dsn] = await Promise.all([ target.projectData ? Promise.resolve(target.projectData) @@ -114,20 +109,16 @@ function fetchProjectAndDsn(target: ResolvedTarget): Promise { tryGetPrimaryDsn(target.org, target.project), ]); return { project, dsn }; - }).then((result) => { - if (result.ok) { - return result.value; - } - // Non-auth failure: rethrow the captured error so the caller can - // decide whether to swallow it or surface it. - throw result.error; }); + if (result.ok) { + return result.value; + } + throw result.error; } /** - * Fetch project details and keys for a single target. - * Returns null on non-auth errors (e.g., no access to project). - * Rethrows auth errors so they propagate to the user. + * Fetch details, swallowing non-auth failures (auto-detect mode). + * `AuthError` still propagates for the auto-login middleware. */ async function fetchProjectDetails( target: ResolvedTarget @@ -135,9 +126,6 @@ async function fetchProjectDetails( try { return await fetchProjectAndDsn(target); } catch (error) { - // AuthError is surfaced by `fetchProjectAndDsn` via `withAuthGuard` — - // it bypasses the catch because it's re-thrown inside the guarded - // block. Re-inspect here so the auto-login middleware still fires. if (error instanceof AuthError) { throw error; } @@ -146,15 +134,11 @@ async function fetchProjectDetails( } /** - * Fetch project details, rethrowing the underlying API error on failure. + * Fetch details, rethrowing API errors verbatim. * - * Used when the user has explicitly named a project (explicit or - * project-search modes). In those cases returning null and falling - * through to a generic "Could not auto-detect ..." ContextError hides - * the actual cause (403 no-access / 404 no-such-project) and forces - * the user to re-provide context they already provided - * (getsentry/cli#785 item #8). Re-fetches without the auth guard so - * any non-auth ApiError surfaces as-is. + * Used for explicit/project-search targets: the user named the + * project, so surfacing the real 403/404 is more useful than the + * generic "Could not auto-detect" fallback (getsentry/cli#785 #8). */ function fetchProjectDetailsOrThrow( target: ResolvedTarget @@ -170,13 +154,8 @@ type FetchResult = { }; /** - * Fetch project details for all targets in parallel. - * Filters out failed fetches while preserving target association. - * - * Used exclusively for auto-detect mode where we may surface zero or - * more projects from DSN scanning — swallowing per-target failures is - * intentional there, since a missing/inaccessible DSN target should - * not take down the whole command. + * Fetch details for every auto-detected target in parallel, filtering + * out failures while preserving target association. */ async function fetchAllProjectDetails( targets: ResolvedTarget[] @@ -337,18 +316,9 @@ export const viewCommand = buildCommand({ return; } - // Fetching strategy depends on how the target was specified: - // - // - Auto-detect mode surfaces zero or more DSN-discovered targets; - // per-target failures are swallowed so one inaccessible DSN - // doesn't block the rest of the results. - // - // - Explicit and project-search modes have exactly one target that - // the user asked for by name. Any failure there must surface the - // underlying API error (403/404/etc.) — swallowing it and falling - // through to "Organization and project is required" is the bug - // reported in getsentry/cli#785 item #8, since the user already - // provided both. + // Auto-detect tolerates per-target failures (DSN scans may yield + // inaccessible targets); explicit/search rethrows so the real + // 403/404 surfaces instead of a misleading "not provided" error. let projects: SentryProject[]; let dsns: (string | null)[]; let targets: ResolvedTarget[]; @@ -359,8 +329,6 @@ export const viewCommand = buildCommand({ dsns = fetched.dsns; targets = fetched.targets; } else { - // Explicit / project-search — rethrow real errors instead of - // masking them with buildContextError(). const firstTarget = resolvedTargets[0]; if (!firstTarget) { throw buildContextError(); diff --git a/src/lib/api-scope.ts b/src/lib/api-scope.ts index 7ec4b2f93..9a59f7a38 100644 --- a/src/lib/api-scope.ts +++ b/src/lib/api-scope.ts @@ -1,46 +1,19 @@ /** - * Scope extraction helpers for Sentry 403 responses. + * Extract Sentry scope identifiers from a 403 response, so we can hint + * at the specific missing scope instead of a hardcoded default + * (getsentry/cli#785 #9). * - * The primary goal is to surface the specific permission scope a token - * is missing, instead of the hardcoded generic "org:read, project:read" - * list referenced in getsentry/cli#785 item #9. - * - * Reality check against the Sentry codebase (getsentry/sentry - * `src/sentry/api/bases/organization.py` and `src/sentry/api/base.py`): - * the standard 403 path is a DRF `PermissionDenied` with the default - * `"You do not have permission to perform this action."` string — no - * structured scope field, no scope identifier in the text. A handful - * of sites pass a custom `detail` string (for example - * `src/sentry/api/helpers/teams.py` and `src/sentry/api/endpoints/ - * rule_snooze.py`), and those strings are free-form but sometimes - * mention scope identifiers verbatim. - * - * This module therefore: - * - * - Scans the detail text for exact scope identifiers from the - * canonical {@link SENTRY_SCOPES} set. Matches are only real - * identifiers; arbitrary `foo:bar` substrings in error text never - * get surfaced as scopes. - * - Also peeks at a few structured field names - * (`required` / `requiredScopes` / `scopes`) that Sentry could - * reasonably start emitting in the future. These paths are zero-cost - * when absent and future-proof the CLI against a backend change that - * adds them. - * - * Callers that receive an empty array should fall back to their own - * hardcoded defaults (mirrors the pre-fix behavior). + * Sentry's standard 403 path is a DRF `PermissionDenied` with no + * structured scope info, but some endpoints include the scope in the + * free-text `detail`. We also peek at a few plausible structured field + * names (`required` / `requiredScopes` / `scopes`) in case they're + * added later. Empty result → callers fall back to their defaults. */ /** - * Canonical Sentry scope identifiers, mirrored from - * `src/sentry/conf/server.py` `SENTRY_SCOPES` (and its hierarchy - * mapping). Kept as a single source of truth so the regex and tests - * agree on what is and isn't a real scope. - * - * Deliberately excluded: - * - `openid` / `profile` / `email` — OIDC scopes, never part of a - * CLI 403 response. - * - `org:superuser` — internal-only, never returned to clients. + * Canonical Sentry scopes, mirrored from getsentry/sentry + * `src/sentry/conf/server.py` SENTRY_SCOPES. Excludes OIDC scopes + * (`openid`/`profile`/`email`) and internal-only `org:superuser`. */ const SENTRY_SCOPES = [ "org:read", @@ -67,64 +40,37 @@ const SENTRY_SCOPES = [ "alerts:write", ] as const; -/** - * Build a word-bounded alternation regex from {@link SENTRY_SCOPES}. - * - * Using an explicit alternation (rather than a `:` product) - * avoids matching nonexistent combinations like `release:write` or - * `alerts:admin`, which `SENTRY_SCOPES` doesn't list. `:` is not a - * regex metacharacter so the scope strings need no escaping. - */ +// Explicit alternation (not `:` product) rejects nonexistent +// combinations like `release:write` or `alerts:admin`. `:` is not a +// regex metachar so no escaping needed. const KNOWN_SCOPE_RE = new RegExp(`\\b(?:${SENTRY_SCOPES.join("|")})\\b`, "gi"); +const SCOPE_FIELD_NAMES = ["required", "requiredScopes", "scopes"] as const; + /** - * Extract Sentry scope identifiers from a 403 response detail value. - * - * Current Sentry API responses rarely name the missing scope (see the - * module-level notes), so this function usually returns `[]` and - * callers fall back to their hardcoded default hint. It DOES fire - * correctly when the scope appears in a custom DRF `PermissionDenied` - * detail string, and remains future-proof for structured response - * shapes that could be added later. + * Extract Sentry scope identifiers from a 403 response detail. * - * @param detail - The ApiError.detail value from a 403 response. - * May be a plain string, a structured record, or `undefined`. - * @returns Deduplicated, source-ordered list of known Sentry scope - * identifiers (e.g. `["event:read"]`). Empty when none found. + * @param detail - ApiError.detail value; string, object, or undefined + * @returns Deduplicated, source-ordered scope identifiers. Empty when none found. */ export function extractRequiredScopes(detail: unknown): string[] { if (!detail) { return []; } - - // Structured shapes: look for common field names used by Sentry. if (typeof detail === "object") { - const scopes = extractFromRecord(detail as Record); - if (scopes.length > 0) { - return scopes; + const fromFields = extractFromRecord(detail as Record); + if (fromFields.length > 0) { + return fromFields; } - // Fall through to serializing the object and scanning the text - // form — still catches cases where the detail carries scope info - // under a non-standard key name. + // Fall back to scanning the serialized form to catch non-standard keys. return extractFromText(JSON.stringify(detail)); } - if (typeof detail === "string") { return extractFromText(detail); } - return []; } -/** Candidate field names carrying scope arrays on Sentry API responses. */ -const SCOPE_FIELD_NAMES = ["required", "requiredScopes", "scopes"] as const; - -/** - * Look for a scope-like string array on any of the known field names. - * - * Accepts both plain arrays and arrays of `{scope: "..."}` objects — - * both shapes have appeared historically in Sentry's responses. - */ function extractFromRecord(record: Record): string[] { for (const field of SCOPE_FIELD_NAMES) { const value = record[field]; @@ -133,17 +79,13 @@ function extractFromRecord(record: Record): string[] { } const scopes = collectScopesFromArray(value); if (scopes.length > 0) { - return dedupe(scopes); + return [...new Set(scopes)]; } } return []; } -/** - * Normalize a heterogeneous array of scope-like entries into a flat - * lowercase scope list. Entries that aren't strings or - * `{scope: string}` objects are silently dropped. - */ +/** Accepts both bare strings and `{scope: "..."}` objects. */ function collectScopesFromArray(entries: unknown[]): string[] { const out: string[] = []; for (const entry of entries) { @@ -155,7 +97,6 @@ function collectScopesFromArray(entries: unknown[]): string[] { return out; } -/** Extract a string scope candidate from either a bare string or a `{scope}` object. */ function extractScopeCandidate(entry: unknown): string | undefined { if (typeof entry === "string") { return entry; @@ -171,31 +112,17 @@ function extractScopeCandidate(entry: unknown): string | undefined { return; } -/** Test + reset lastIndex on the shared `g`-flagged regex. */ +/** Tests + resets the shared `g`-flagged regex. */ function matchesKnownScope(scope: string): boolean { const matched = KNOWN_SCOPE_RE.test(scope); KNOWN_SCOPE_RE.lastIndex = 0; return matched; } -/** Pull scope identifiers out of a free-text detail message. */ function extractFromText(text: string): string[] { const matches = text.match(KNOWN_SCOPE_RE); if (!matches) { return []; } - return dedupe(matches.map((m) => m.toLowerCase())); -} - -/** Deduplicate while preserving insertion order. */ -function dedupe(items: string[]): string[] { - const seen = new Set(); - const out: string[] = []; - for (const item of items) { - if (!seen.has(item)) { - seen.add(item); - out.push(item); - } - } - return out; + return [...new Set(matches.map((m) => m.toLowerCase()))]; }