From 0970403a87d154748f9a92fa7723c9b481be82fe Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 29 Apr 2026 05:24:50 +0000 Subject: [PATCH] feat: sourcemap upload parity flags and issue archive command Add missing sourcemap flags for old sentry-cli parity: - --dist: distribution identifier for builds within a release - --ext: custom file extensions (passthrough from inject to upload) - --no-rewrite: upload files without injecting debug IDs - --ignore/--ignore-file: gitignore-style file exclusion - --strip-prefix/--strip-common-prefix: path prefix removal Add sourceMappingURL directive following in inject: - Reads last 512 bytes of JS files to find //# sourceMappingURL= - Resolves relative paths when convention naming (foo.js.map) fails - Gracefully skips data: and http: URLs Add issue archive command (sentry issue archive, alias: ignore): - Maps to Sentry 'ignored' status via existing updateIssueStatus() - Supports --duration, --count, --window, --users, --user-window for conditional archiving Closes #600 (sourcemap and issue mutation gaps) --- docs/src/content/docs/contributing.md | 2 +- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 1 + .../skills/sentry-cli/references/issue.md | 11 + .../skills/sentry-cli/references/sourcemap.md | 9 + src/commands/issue/archive.ts | 126 ++++++++ src/commands/issue/index.ts | 11 +- src/commands/sourcemap/inject.ts | 32 +- src/commands/sourcemap/upload.ts | 161 +++++++++- src/lib/api-client.ts | 1 + src/lib/api/issues.ts | 17 +- src/lib/api/sourcemaps.ts | 24 +- src/lib/complete.ts | 1 + src/lib/sourcemap/inject.ts | 170 +++++++++- test/commands/sourcemap/upload.test.ts | 299 ++++++++++++++++++ 14 files changed, 827 insertions(+), 38 deletions(-) create mode 100644 src/commands/issue/archive.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index c4ee61408..a4adc683c 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -55,7 +55,7 @@ cli/ │ │ ├── cli/ # defaults, feedback, fix, setup, upgrade │ │ ├── dashboard/ # list, view, create, add, edit, delete │ │ ├── event/ # view, list -│ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, merge +│ │ ├── issue/ # list, events, explain, plan, view, resolve, unresolve, archive, merge │ │ ├── log/ # list, view │ │ ├── org/ # list, view │ │ ├── project/ # create, delete, list, view diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 72724f31d..29e5b71b5 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -300,6 +300,7 @@ Manage Sentry issues - `sentry issue view ` — View details of a specific issue - `sentry issue resolve ` — Mark an issue as resolved - `sentry issue unresolve ` — Reopen a resolved issue +- `sentry issue archive ` — Archive (ignore) an issue - `sentry issue merge ` — Merge 2+ issues into a single canonical group → Full flags and examples: `references/issue.md` diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index db59e33b7..e353b2abc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -205,6 +205,17 @@ sentry issue reopen CLI-G5 # alias Reopen a resolved issue +### `sentry issue archive ` + +Archive (ignore) an issue + +**Flags:** +- `--duration - Ignore for this many minutes` +- `--count - Ignore until this many more events occur` +- `--window - Time window in minutes for --count (events must occur within this window)` +- `--users - Ignore until this many more users are affected` +- `--user-window - Time window in minutes for --users (users must be affected within this window)` + ### `sentry issue merge ` Merge 2+ issues into a single canonical group diff --git a/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md b/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md index f8e991268..426e62889 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/sourcemap.md @@ -17,6 +17,8 @@ Inject debug IDs into JavaScript files and sourcemaps **Flags:** - `--ext - Comma-separated file extensions to process (default: .js,.cjs,.mjs)` +- `--ignore - Glob pattern to exclude (gitignore-style, repeatable)` +- `--ignore-file - Path to a file with gitignore-style patterns to exclude` - `--dry-run - Show what would be modified without writing` - `--allow-empty - Exit successfully when no JS + sourcemap pairs are found (default: error out to catch silent build misconfigurations)` @@ -39,7 +41,14 @@ Upload sourcemaps to Sentry **Flags:** - `--release - Release version to associate with the upload` +- `--dist - Distribution identifier to disambiguate builds within a release` - `--url-prefix - URL prefix for uploaded files (default: ~/) - (default: "~/")` +- `--ext - Comma-separated file extensions to process (default: .js,.cjs,.mjs)` +- `--ignore - Glob pattern to exclude (gitignore-style, repeatable)` +- `--ignore-file - Path to a file with gitignore-style patterns to exclude` +- `--strip-prefix - Strip a prefix from uploaded file paths (e.g. 'build/')` +- `--strip-common-prefix - Automatically strip the longest common path prefix from all files` +- `--no-rewrite - Upload files as-is without injecting debug IDs` - `--allow-empty - Exit successfully when no JS + sourcemap pairs are found (default: error out to catch silent build misconfigurations)` **Examples:** diff --git a/src/commands/issue/archive.ts b/src/commands/issue/archive.ts new file mode 100644 index 000000000..d2b8317d6 --- /dev/null +++ b/src/commands/issue/archive.ts @@ -0,0 +1,126 @@ +/** + * sentry issue archive (aliased: ignore) + * + * Archive (ignore) an issue, suppressing alerts until an optional + * condition is met. This maps to the "ignored" status in the Sentry API. + */ + +import type { SentryContext } from "../../context.js"; +import { + type IgnoreStatusDetails, + updateIssueStatus, +} from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; +import { formatIssueDetails, muted } from "../../lib/formatters/index.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger } from "../../lib/logger.js"; +import type { SentryIssue } from "../../types/index.js"; +import { issueIdPositional, resolveIssue } from "./utils.js"; + +const log = logger.withTag("issue.archive"); + +const COMMAND = "archive"; + +type ArchiveFlags = { + readonly json: boolean; + readonly fields?: string[]; + readonly duration?: number; + readonly count?: number; + readonly window?: number; + readonly users?: number; + readonly "user-window"?: number; +}; + +function formatArchived(issue: SentryIssue): string { + return `${muted("Archived")}\n\n${formatIssueDetails(issue)}`; +} + +export const archiveCommand = buildCommand({ + docs: { + brief: "Archive (ignore) an issue", + fullDescription: + "Archive an issue, suppressing alerts until an optional condition is met.\n" + + "Without any duration/count flags, the issue is archived indefinitely.\n\n" + + "Examples:\n" + + " sentry issue archive CLI-12Z\n" + + " sentry issue archive CLI-12Z --duration 60\n" + + " sentry issue archive CLI-12Z --count 100 --window 60\n" + + " sentry issue archive CLI-12Z --users 10", + }, + output: { + human: formatArchived, + }, + parameters: { + positional: issueIdPositional, + flags: { + duration: { + kind: "parsed", + parse: Number, + brief: "Ignore for this many minutes", + optional: true, + }, + count: { + kind: "parsed", + parse: Number, + brief: "Ignore until this many more events occur", + optional: true, + }, + window: { + kind: "parsed", + parse: Number, + brief: + "Time window in minutes for --count (events must occur within this window)", + optional: true, + }, + users: { + kind: "parsed", + parse: Number, + brief: "Ignore until this many more users are affected", + optional: true, + }, + "user-window": { + kind: "parsed", + parse: Number, + brief: + "Time window in minutes for --users (users must be affected within this window)", + optional: true, + }, + }, + }, + async *func(this: SentryContext, flags: ArchiveFlags, issueArg: string) { + const { cwd } = this; + + const { org, issue } = await resolveIssue({ + issueArg, + cwd, + command: COMMAND, + }); + + const statusDetails: IgnoreStatusDetails = {}; + if (flags.duration !== undefined) { + statusDetails.ignoreDuration = flags.duration; + } + if (flags.count !== undefined) { + statusDetails.ignoreCount = flags.count; + } + if (flags.window !== undefined) { + statusDetails.ignoreWindow = flags.window; + } + if (flags.users !== undefined) { + statusDetails.ignoreUserCount = flags.users; + } + if (flags["user-window"] !== undefined) { + statusDetails.ignoreUserWindow = flags["user-window"]; + } + + const hasDetails = Object.keys(statusDetails).length > 0; + + const updated = await updateIssueStatus(issue.id, "ignored", { + ...(hasDetails ? { statusDetails } : {}), + orgSlug: org, + }); + + log.debug(`Archived ${updated.shortId}`); + yield new CommandOutput(updated); + }, +}); diff --git a/src/commands/issue/index.ts b/src/commands/issue/index.ts index 4c87591df..34c688944 100644 --- a/src/commands/issue/index.ts +++ b/src/commands/issue/index.ts @@ -1,4 +1,5 @@ import { buildRouteMap } from "../../lib/route-map.js"; +import { archiveCommand } from "./archive.js"; import { eventsCommand } from "./events.js"; import { explainCommand } from "./explain.js"; import { listCommand } from "./list.js"; @@ -17,11 +18,11 @@ export const issueRoute = buildRouteMap({ view: viewCommand, resolve: resolveCommand, unresolve: unresolveCommand, + archive: archiveCommand, merge: mergeCommand, }, - // `reopen` is a friendlier synonym for `unresolve` — shipped as an alias - // so either command works identically. - aliases: { reopen: "unresolve" }, + // `reopen` is a friendlier synonym for `unresolve`, `ignore` for `archive`. + aliases: { reopen: "unresolve", ignore: "archive" }, defaultCommand: "view", docs: { brief: "Manage Sentry issues", @@ -35,14 +36,16 @@ export const issueRoute = buildRouteMap({ " plan Generate a solution plan using Seer AI\n" + " resolve Mark an issue as resolved (optionally in a release)\n" + " unresolve Reopen a resolved issue (alias: reopen)\n" + + " archive Archive/ignore an issue (alias: ignore)\n" + " merge Merge 2+ issues into a single group\n\n" + - "Magic selectors (available for view, events, explain, plan, resolve, unresolve):\n" + + "Magic selectors (available for view, events, explain, plan, resolve, unresolve, archive):\n" + " @latest Most recent unresolved issue\n" + " @most_frequent Issue with the highest event frequency\n\n" + "Examples:\n" + " sentry issue view @latest\n" + " sentry issue events CLI-G\n" + " sentry issue resolve CLI-12Z --in 0.26.1\n" + + " sentry issue archive CLI-AB --duration 60\n" + " sentry issue merge CLI-K9 CLI-15H CLI-15N\n" + " sentry issue explain @most_frequent\n" + " sentry issue plan my-org/@latest\n\n" + diff --git a/src/commands/sourcemap/inject.ts b/src/commands/sourcemap/inject.ts index a7f2ff623..f4b10d20d 100644 --- a/src/commands/sourcemap/inject.ts +++ b/src/commands/sourcemap/inject.ts @@ -16,6 +16,7 @@ import { CommandOutput } from "../../lib/formatters/output.js"; import { assertDirectoryReadable, buildEmptyDiscoveryError, + buildIgnoreMatcher, diagnoseEmptyDiscovery, discoverFilePairs, type InjectResult, @@ -91,6 +92,18 @@ export const injectCommand = buildCommand({ "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", optional: true, }, + ignore: { + kind: "parsed", + parse: String, + brief: "Glob pattern to exclude (gitignore-style, repeatable)", + optional: true, + }, + "ignore-file": { + kind: "parsed", + parse: String, + brief: "Path to a file with gitignore-style patterns to exclude", + optional: true, + }, "dry-run": { kind: "boolean", brief: "Show what would be modified without writing", @@ -109,7 +122,13 @@ export const injectCommand = buildCommand({ }, async *func( this: SentryContext, - flags: { ext?: string; "dry-run"?: boolean; "allow-empty"?: boolean }, + flags: { + ext?: string; + ignore?: string; + "ignore-file"?: string; + "dry-run"?: boolean; + "allow-empty"?: boolean; + }, dir: string ) { // Discover pairs read-only first so we don't error after partially @@ -123,7 +142,15 @@ export const injectCommand = buildCommand({ ? new Set(extensions.map((e) => (e.startsWith(".") ? e : `.${e}`))) : undefined; - const pairs = await discoverFilePairs(dir, extSet); + const ignorePatterns = flags.ignore + ? flags.ignore.split(",").map((p) => p.trim()) + : undefined; + const ignoreMatcher = await buildIgnoreMatcher( + ignorePatterns, + flags["ignore-file"] + ); + + const pairs = await discoverFilePairs(dir, extSet, ignoreMatcher); if (pairs.length === 0 && !flags["allow-empty"]) { const diag = await diagnoseEmptyDiscovery(dir, { extensions }); throw buildEmptyDiscoveryError(dir, diag); @@ -131,6 +158,7 @@ export const injectCommand = buildCommand({ const results = await injectDirectory(dir, { extensions, + ignorePatterns, dryRun: flags["dry-run"], }); diff --git a/src/commands/sourcemap/upload.ts b/src/commands/sourcemap/upload.ts index 853e31620..52b4ab896 100644 --- a/src/commands/sourcemap/upload.ts +++ b/src/commands/sourcemap/upload.ts @@ -20,6 +20,7 @@ import { resolveOrgAndProject } from "../../lib/resolve-target.js"; import { assertDirectoryReadable, buildEmptyDiscoveryError, + buildIgnoreMatcher, diagnoseEmptyDiscovery, discoverFilePairs, injectDirectory, @@ -34,6 +35,8 @@ type UploadCommandResult = { project?: string; /** Release version, if provided. */ release?: string; + /** Distribution identifier, if provided. */ + dist?: string; /** Number of file pairs uploaded. */ filesUploaded: number; }; @@ -51,11 +54,54 @@ function formatUploadResult(data: UploadCommandResult): string { if (data.release) { rows.push(["Release", data.release]); } + if (data.dist) { + rows.push(["Dist", data.dist]); + } return renderMarkdown(mdKvTable(rows)); } const USAGE_HINT = "sentry sourcemap upload "; +/** + * Compute the longest common directory prefix across a list of paths. + * + * Only strips at directory boundaries (slashes), so + * `["a/b/c.js", "a/b/d.js"]` yields `"a/b/"` rather than `"a/b/"`. + * Returns `""` when paths share no common directory prefix. + */ +function computeCommonPrefix(paths: string[]): string { + if (paths.length === 0) { + return ""; + } + const sorted = [...paths].sort(); + const first = sorted[0]; + const last = sorted.at(-1); + if (!(first && last)) { + return ""; + } + let common = 0; + for (let i = 0; i < first.length && i < last.length; i += 1) { + if (first[i] !== last[i]) { + break; + } + if (first[i] === "/") { + common = i + 1; + } + } + return first.slice(0, common); +} + +/** + * Strip a prefix from a file path. If the path doesn't start with + * the prefix, returns it unchanged. + */ +function stripPrefix(path: string, prefix: string): string { + if (prefix && path.startsWith(prefix)) { + return path.slice(prefix.length); + } + return path; +} + export const uploadCommand = buildCommand({ docs: { brief: "Upload sourcemaps to Sentry", @@ -71,7 +117,10 @@ export const uploadCommand = buildCommand({ "Usage:\n" + " sentry sourcemap upload ./dist\n" + " sentry sourcemap upload ./dist --release 1.0.0\n" + + " sentry sourcemap upload ./dist --release 1.0.0 --dist 12345\n" + " sentry sourcemap upload ./dist --url-prefix '~/static/js/'\n" + + " sentry sourcemap upload ./dist --no-rewrite\n" + + " sentry sourcemap upload ./dist --ext .js,.mjs\n" + " sentry sourcemap upload ./maybe-empty --allow-empty", }, output: { @@ -95,6 +144,13 @@ export const uploadCommand = buildCommand({ brief: "Release version to associate with the upload", optional: true, }, + dist: { + kind: "parsed", + parse: String, + brief: + "Distribution identifier to disambiguate builds within a release", + optional: true, + }, "url-prefix": { kind: "parsed", parse: String, @@ -102,6 +158,44 @@ export const uploadCommand = buildCommand({ optional: true, default: "~/", }, + ext: { + kind: "parsed", + parse: String, + brief: + "Comma-separated file extensions to process (default: .js,.cjs,.mjs)", + optional: true, + }, + ignore: { + kind: "parsed", + parse: String, + brief: "Glob pattern to exclude (gitignore-style, repeatable)", + optional: true, + }, + "ignore-file": { + kind: "parsed", + parse: String, + brief: "Path to a file with gitignore-style patterns to exclude", + optional: true, + }, + "strip-prefix": { + kind: "parsed", + parse: String, + brief: "Strip a prefix from uploaded file paths (e.g. 'build/')", + optional: true, + }, + "strip-common-prefix": { + kind: "boolean", + brief: + "Automatically strip the longest common path prefix from all files", + optional: true, + default: false, + }, + "no-rewrite": { + kind: "boolean", + brief: "Upload files as-is without injecting debug IDs", + optional: true, + default: false, + }, "allow-empty": { kind: "boolean", brief: @@ -116,7 +210,14 @@ export const uploadCommand = buildCommand({ this: SentryContext, flags: { release?: string; + dist?: string; "url-prefix"?: string; + ext?: string; + ignore?: string; + "ignore-file"?: string; + "strip-prefix"?: string; + "strip-common-prefix"?: boolean; + "no-rewrite"?: boolean; "allow-empty"?: boolean; }, dir: string @@ -125,11 +226,25 @@ export const uploadCommand = buildCommand({ // don't write debug IDs when the upload won't proceed (empty dir, // typoed path, missing credentials). await assertDirectoryReadable(dir); - const pairs = await discoverFilePairs(dir); + + const extensions = flags.ext?.split(",").map((e) => e.trim()); + const extSet = extensions + ? new Set(extensions.map((e) => (e.startsWith(".") ? e : `.${e}`))) + : undefined; + + const ignorePatterns = flags.ignore + ? flags.ignore.split(",").map((p) => p.trim()) + : undefined; + const ignoreMatcher = await buildIgnoreMatcher( + ignorePatterns, + flags["ignore-file"] + ); + + const pairs = await discoverFilePairs(dir, extSet, ignoreMatcher); if (pairs.length === 0) { if (!flags["allow-empty"]) { - const diag = await diagnoseEmptyDiscovery(dir); + const diag = await diagnoseEmptyDiscovery(dir, { extensions }); throw buildEmptyDiscoveryError(dir, diag); } // --allow-empty: nothing to upload, so don't require Sentry @@ -137,6 +252,7 @@ export const uploadCommand = buildCommand({ // library-only / conditional-release-skip cases the docs name. yield new CommandOutput({ release: flags.release, + dist: flags.dist, filesUploaded: 0, }); return { @@ -155,36 +271,57 @@ export const uploadCommand = buildCommand({ } const { org, project } = resolved; - const results = await injectDirectory(dir); + const results = flags["no-rewrite"] + ? pairs.map((p) => ({ ...p, injected: false, debugId: "" })) + : await injectDirectory(dir, { + extensions, + ignoreMatcher, + }); const urlPrefix = flags["url-prefix"] ?? "~/"; // Build artifact file list with paths relative to the upload directory const resolvedDir = resolve(dir); + + // Compute prefix to strip from relative paths + let pathPrefixToStrip = flags["strip-prefix"] ?? ""; + if (flags["strip-common-prefix"]) { + const allRelative = results.flatMap(({ jsPath, mapPath }) => [ + relative(resolvedDir, jsPath).replaceAll("\\", "/"), + relative(resolvedDir, mapPath).replaceAll("\\", "/"), + ]); + pathPrefixToStrip = computeCommonPrefix(allRelative); + } + const artifactFiles: ArtifactFile[] = results.flatMap( ({ jsPath, mapPath, debugId }) => { // Normalize to forward slashes for URLs (handles Windows backslashes) - const jsRelative = relative(resolvedDir, jsPath).replaceAll("\\", "/"); - const mapRelative = relative(resolvedDir, mapPath).replaceAll( - "\\", - "/" - ); + let jsRelative = relative(resolvedDir, jsPath).replaceAll("\\", "/"); + let mapRelative = relative(resolvedDir, mapPath).replaceAll("\\", "/"); + + if (pathPrefixToStrip) { + jsRelative = stripPrefix(jsRelative, pathPrefixToStrip); + mapRelative = stripPrefix(mapRelative, pathPrefixToStrip); + } + const mapBasename = basename(mapPath); return [ { path: jsPath, - debugId, + // Empty debugId when --no-rewrite: files uploaded without debug IDs, + // relying on release/URL-based matching instead. + ...(debugId ? { debugId } : {}), type: "minified_source" as const, url: `${urlPrefix}${jsRelative}`, sourcemapFilename: mapBasename, }, { path: mapPath, - debugId, + ...(debugId ? { debugId } : {}), type: "source_map" as const, url: `${urlPrefix}${mapRelative}`, }, - ]; + ] satisfies ArtifactFile[]; } ); @@ -192,6 +329,7 @@ export const uploadCommand = buildCommand({ org, project, release: flags.release, + dist: flags.dist, files: artifactFiles, }); @@ -199,6 +337,7 @@ export const uploadCommand = buildCommand({ org, project, release: flags.release, + dist: flags.dist, filesUploaded: results.length, }); }, diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index afde9ba38..afc24a479 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -52,6 +52,7 @@ export { getIssueByShortId, getIssueInOrg, getSharedIssue, + type IgnoreStatusDetails, ISSUE_DETAIL_COLLAPSE, type IssueCollapseField, type IssueSort, diff --git a/src/lib/api/issues.ts b/src/lib/api/issues.ts index 679ee4370..512e05eb6 100644 --- a/src/lib/api/issues.ts +++ b/src/lib/api/issues.ts @@ -546,11 +546,26 @@ export function parseResolveSpec( * region via the org-scoped endpoint. Without it, falls back to the legacy * global `/issues/{id}/` endpoint (works but not region-aware). */ +/** + * Ignore/archive conditions for {@link updateIssueStatus}. + * + * - `ignoreDuration` — ignore for N minutes + * - `ignoreCount` / `ignoreWindow` — ignore until N events in M minutes + * - `ignoreUserCount` / `ignoreUserWindow` — ignore until N users in M minutes + */ +export type IgnoreStatusDetails = { + ignoreDuration?: number; + ignoreCount?: number; + ignoreWindow?: number; + ignoreUserCount?: number; + ignoreUserWindow?: number; +}; + export async function updateIssueStatus( issueId: string, status: "resolved" | "unresolved" | "ignored", options?: { - statusDetails?: ResolveStatusDetails; + statusDetails?: ResolveStatusDetails | IgnoreStatusDetails; orgSlug?: string; } ): Promise { diff --git a/src/lib/api/sourcemaps.ts b/src/lib/api/sourcemaps.ts index f345aa226..68ddf491e 100644 --- a/src/lib/api/sourcemaps.ts +++ b/src/lib/api/sourcemaps.ts @@ -74,8 +74,8 @@ export type AssembleResponse = z.infer; export type ArtifactFile = { /** Filesystem path to the file. */ path: string; - /** Debug ID injected into this file (from {@link injectDebugId}). */ - debugId: string; + /** Debug ID injected into this file (from {@link injectDebugId}). Omitted when uploading without rewriting. */ + debugId?: string; /** * File type for the manifest. * `"minified_source"` for JS files, `"source_map"` for .map files. @@ -101,6 +101,8 @@ export type UploadOptions = { project: string; /** Release version (optional — debug IDs can work without releases). */ release?: string; + /** Distribution identifier (optional — disambiguates builds within a release). */ + dist?: string; /** Files to upload (must already have debug IDs injected). */ files: ArtifactFile[]; }; @@ -302,6 +304,7 @@ export async function buildArtifactBundle( org: string; project: string; release?: string; + dist?: string; compression?: ZipCompression; } ): Promise { @@ -317,9 +320,10 @@ export async function buildArtifactBundle( for (const file of files) { const bundlePath = urlToBundlePath(file.url); - const headers: Record = { - "debug-id": file.debugId, - }; + const headers: Record = {}; + if (file.debugId) { + headers["debug-id"] = file.debugId; + } if (file.sourcemapFilename) { headers.Sourcemap = file.sourcemapFilename; } @@ -335,6 +339,7 @@ export async function buildArtifactBundle( org: options.org, project: options.project, ...(options.release ? { release: options.release } : {}), + ...(options.dist ? { dist: options.dist } : {}), files: filesManifest, }); @@ -403,7 +408,7 @@ export async function hashChunks( * @throws {ApiError} If the upload or assembly fails */ export async function uploadSourcemaps(options: UploadOptions): Promise { - const { org, project, release, files } = options; + const { org, project, release, dist, files } = options; // Step 1: Get chunk upload configuration const serverOptions = await getChunkUploadOptions(org); @@ -424,6 +429,7 @@ export async function uploadSourcemaps(options: UploadOptions): Promise { org, project, release, + dist, compression: zipCompression, }); await uploadArtifactBundle({ @@ -431,6 +437,7 @@ export async function uploadSourcemaps(options: UploadOptions): Promise { org, project, release, + dist, serverOptions, encoding, }); @@ -454,10 +461,12 @@ async function uploadArtifactBundle(opts: { org: string; project: string; release: string | undefined; + dist: string | undefined; serverOptions: ChunkServerOptions; encoding: UploadEncoding | undefined; }): Promise { - const { tmpZipPath, org, project, release, serverOptions, encoding } = opts; + const { tmpZipPath, org, project, release, dist, serverOptions, encoding } = + opts; // Step 3: Split into chunks, hash each chunk + compute overall checksum const { chunks, overallChecksum } = await hashChunks( tmpZipPath, @@ -472,6 +481,7 @@ async function uploadArtifactBundle(opts: { chunks: chunks.map((c: ChunkInfo) => c.sha1), projects: [project], ...(release ? { version: release } : {}), + ...(dist ? { dist } : {}), }; const assembleEndpoint = `organizations/${org}/artifactbundle/assemble/`; diff --git a/src/lib/complete.ts b/src/lib/complete.ts index f0b947f14..746d0cdf5 100644 --- a/src/lib/complete.ts +++ b/src/lib/complete.ts @@ -95,6 +95,7 @@ export const ORG_PROJECT_COMMANDS = new Set([ "issue plan", "issue resolve", "issue unresolve", + "issue archive", "issue merge", "project list", "project view", diff --git a/src/lib/sourcemap/inject.ts b/src/lib/sourcemap/inject.ts index 592daa7d0..774c0665c 100644 --- a/src/lib/sourcemap/inject.ts +++ b/src/lib/sourcemap/inject.ts @@ -5,13 +5,17 @@ * then injects Sentry debug IDs into each pair. */ -import { readFile, stat } from "node:fs/promises"; -import { resolve as resolvePath } from "node:path"; +import { open, readFile, stat } from "node:fs/promises"; +import { dirname, relative, resolve as resolvePath } from "node:path"; +import ignore from "ignore"; import { NODE_MODULES_DIRNAME } from "../constants.js"; import { ValidationError } from "../errors.js"; +import { logger } from "../logger.js"; import { walkFiles } from "../scan/index.js"; import { EXISTING_DEBUGID_RE, injectDebugId } from "./debug-id.js"; +const log = logger.withTag("sourcemap.inject"); + /** Default JavaScript file extensions to scan. */ const DEFAULT_EXTENSIONS = new Set([".js", ".cjs", ".mjs"]); @@ -33,6 +37,10 @@ export type InjectDirectoryOptions = { extensions?: string[]; /** If true, report what would be modified without writing. */ dryRun?: boolean; + /** Glob patterns (gitignore-style) to exclude from processing. */ + ignorePatterns?: string[]; + /** Pre-built ignore matcher (takes precedence over ignorePatterns). */ + ignoreMatcher?: ReturnType; }; /** @@ -53,7 +61,9 @@ export async function injectDirectory( ? new Set(options.extensions.map((e) => (e.startsWith(".") ? e : `.${e}`))) : DEFAULT_EXTENSIONS; - const filePairs = await discoverFilePairs(dir, extensions); + const ig = + options.ignoreMatcher ?? (await buildIgnoreMatcher(options.ignorePatterns)); + const filePairs = await discoverFilePairs(dir, extensions, ig); const results: InjectResult[] = []; for (const { jsPath, mapPath } of filePairs) { @@ -76,20 +86,114 @@ export async function injectDirectory( export type FilePair = { jsPath: string; mapPath: string }; /** - * Check if a path has a companion .map file. + * Regex matching `//# sourceMappingURL=` or `//@ sourceMappingURL=`. * - * @returns The map path if the companion exists, undefined otherwise. + * Must be the last non-empty line in the file. We search from the tail + * to avoid false positives from string literals in bundled code (e.g. + * terser/babel template literals that contain the directive as text). */ -async function findCompanionMap(jsPath: string): Promise { - const mapPath = `${jsPath}.map`; +const SOURCE_MAPPING_URL_RE = /\/\/[#@]\s*sourceMappingURL\s*=\s*(\S+)\s*$/m; + +/** + * Read the last ~512 bytes of a file efficiently. + * + * We only need the very end of the JS file to find the + * `sourceMappingURL` directive. Reading just the tail avoids + * loading multi-megabyte bundles into memory. + */ +async function readFileTail(filePath: string, maxBytes = 512): Promise { + const fh = await open(filePath, "r"); try { - const mapStat = await stat(mapPath); - if (mapStat.isFile()) { - return mapPath; + const fstat = await fh.stat(); + const fileSize = fstat.size; + if (fileSize === 0) { + return ""; } + const readSize = Math.min(maxBytes, fileSize); + const offset = fileSize - readSize; + const buf = Buffer.alloc(readSize); + await fh.read(buf, 0, readSize, offset); + return buf.toString("utf-8"); + } finally { + await fh.close(); + } +} + +/** + * Extract the `sourceMappingURL` value from the tail of a JS file. + * + * Returns `undefined` if no directive is found. + */ +async function extractSourceMappingUrl( + jsPath: string +): Promise { + try { + const tail = await readFileTail(jsPath); + const match = tail.match(SOURCE_MAPPING_URL_RE); + return match?.[1]; + } catch { + return; + } +} + +/** + * Check if a file exists and is a regular file. + */ +async function fileExists(path: string): Promise { + try { + const s = await stat(path); + return s.isFile(); } catch { - // No companion .map file — skip + return false; + } +} + +/** + * Find the companion sourcemap for a JS file. + * + * Resolution order: + * 1. Convention: `.map` on disk + * 2. `//# sourceMappingURL=` directive in the JS file + * (only external file references — `data:` URLs are logged and skipped) + * + * @returns The map path if a companion exists, undefined otherwise. + */ +async function findCompanionMap(jsPath: string): Promise { + // Fast path: convention-based naming (most bundlers use this) + const conventionPath = `${jsPath}.map`; + if (await fileExists(conventionPath)) { + return conventionPath; + } + + // Slow path: parse sourceMappingURL from the file tail + const url = await extractSourceMappingUrl(jsPath); + if (!url) { + return; + } + + // Skip data: URLs (inline sourcemaps) — we can't inject debug IDs + // into inline sourcemaps without re-encoding the entire base64 blob + // back into the JS file. Log and move on. + if (url.startsWith("data:")) { + log.debug( + `skipping inline sourcemap in ${jsPath} (data: URL not supported for injection)` + ); + return; + } + + // Skip absolute URLs (http/https) — can't inject into remote maps + if (url.startsWith("http://") || url.startsWith("https://")) { + log.debug(`skipping remote sourcemap URL in ${jsPath}: ${url}`); + return; + } + + // Resolve relative path against the JS file's directory + const jsDir = dirname(jsPath); + const resolvedMapPath = resolvePath(jsDir, url); + if (await fileExists(resolvedMapPath)) { + return resolvedMapPath; } + return; } @@ -114,6 +218,40 @@ async function findCompanionMap(jsPath: string): Promise { */ const SOURCEMAP_SKIP_DIRS: readonly string[] = [NODE_MODULES_DIRNAME]; +/** + * Build an `ignore` matcher from user-provided patterns and/or an + * ignore-file path. Returns `undefined` when no patterns are active. + */ +export async function buildIgnoreMatcher( + patterns?: string[], + ignoreFilePath?: string +): Promise | undefined> { + const hasPatterns = patterns && patterns.length > 0; + if (!(hasPatterns || ignoreFilePath)) { + return; + } + const ig = ignore(); + if (hasPatterns) { + ig.add(patterns); + } + if (ignoreFilePath) { + try { + const content = await readFile(ignoreFilePath, "utf-8"); + ig.add(content); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError( + `Ignore file '${ignoreFilePath}' does not exist.`, + "ignore-file" + ); + } + throw err; + } + } + return ig; +} + /** * Read-only discovery pass — returns the list of JS + sourcemap pairs * without injecting debug IDs. Used as a pre-check by the upload @@ -122,7 +260,8 @@ const SOURCEMAP_SKIP_DIRS: readonly string[] = [NODE_MODULES_DIRNAME]; */ export async function discoverFilePairs( dir: string, - extensions: Set = DEFAULT_EXTENSIONS + extensions: Set = DEFAULT_EXTENSIONS, + ignoreMatcher?: ReturnType ): Promise { // `walkFiles` requires an absolute cwd. CLI callers pass // user-supplied positional args like `./dist` directly through to @@ -138,6 +277,13 @@ export async function discoverFilePairs( respectGitignore: false, maxFileSize: Number.POSITIVE_INFINITY, })) { + if (ignoreMatcher) { + // Use POSIX relative path for gitignore-style matching + const rel = relative(absDir, entry.absolutePath).replaceAll("\\", "/"); + if (ignoreMatcher.ignores(rel)) { + continue; + } + } const mapPath = await findCompanionMap(entry.absolutePath); if (mapPath) { pairs.push({ jsPath: entry.absolutePath, mapPath }); diff --git a/test/commands/sourcemap/upload.test.ts b/test/commands/sourcemap/upload.test.ts index b453ad646..8b2fe7af4 100644 --- a/test/commands/sourcemap/upload.test.ts +++ b/test/commands/sourcemap/upload.test.ts @@ -29,7 +29,14 @@ type InjectFuncArgs = { }; type UploadFuncArgs = { release?: string; + dist?: string; "url-prefix"?: string; + ext?: string; + ignore?: string; + "ignore-file"?: string; + "strip-prefix"?: string; + "strip-common-prefix"?: boolean; + "no-rewrite"?: boolean; "allow-empty"?: boolean; }; type CmdFunc = (this: unknown, flags: A, dir: string) => Promise; @@ -172,6 +179,54 @@ describe("sourcemap inject command — --allow-empty behavior", () => { func.call(ctx, { "dry-run": true, "allow-empty": true }, dir) ).resolves.toBeUndefined(); }); + + test("sourceMappingURL: follows external map reference when convention fails", async () => { + // JS file with sourceMappingURL pointing to a differently-named map + writeFileSync( + join(dir, "bundle.js"), + "console.log(1)\n//# sourceMappingURL=bundle.abc123.js.map\n" + ); + // Map file with non-convention name (no bundle.js.map exists) + writeFileSync( + join(dir, "bundle.abc123.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + const ctx = makeContext(); + await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); + }); + + test("sourceMappingURL: prefers convention naming over directive", async () => { + // JS file with sourceMappingURL pointing to a different file + writeFileSync( + join(dir, "app.js"), + "console.log(1)\n//# sourceMappingURL=other.js.map\n" + ); + // Convention map exists — should be used + writeFileSync( + join(dir, "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + // The directive target also exists + writeFileSync( + join(dir, "other.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + const ctx = makeContext(); + // Should succeed (convention-based match found first) + await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); + }); + + test("sourceMappingURL: skips data: URLs gracefully", async () => { + writeFileSync( + join(dir, "inline.js"), + "console.log(1)\n//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozfQ==\n" + ); + // No convention map either — should fail with zero pairs + const ctx = makeContext(); + await expect(func.call(ctx, {}, dir)).rejects.toBeInstanceOf( + ValidationError + ); + }); }); describe("sourcemap upload command — --allow-empty behavior", () => { @@ -310,4 +365,248 @@ describe("sourcemap upload command — --allow-empty behavior", () => { uploadSpy.mockRestore(); } }); + + test("--dist flag: passes dist to uploadSourcemaps", async () => { + mkdirSync(join(dir, "_astro")); + writeFileSync(join(dir, "_astro", "app.js"), "console.log(1)\n"); + writeFileSync( + join(dir, "_astro", "app.js.map"), + JSON.stringify({ + version: 3, + sources: ["app.ts"], + names: [], + mappings: "", + }) + ); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { release: "1.0.0", dist: "12345" }, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + expect(callArgs?.dist).toBe("12345"); + expect(callArgs?.release).toBe("1.0.0"); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--no-rewrite: uploads without injecting debug IDs", async () => { + mkdirSync(join(dir, "_astro")); + const jsPath = join(dir, "_astro", "app.js"); + const mapPath = join(dir, "_astro", "app.js.map"); + const originalJs = "console.log(1)\n"; + writeFileSync(jsPath, originalJs); + writeFileSync( + mapPath, + JSON.stringify({ + version: 3, + sources: ["app.ts"], + names: [], + mappings: "", + }) + ); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { "no-rewrite": true }, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + // Files should have no debugId when --no-rewrite is used + for (const file of callArgs?.files ?? []) { + expect(file.debugId).toBeUndefined(); + } + // JS file should not have been modified + const afterJs = await Bun.file(jsPath).text(); + expect(afterJs).toBe(originalJs); + expect(afterJs).not.toContain("_sentryDebugIds"); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--ext: discovers files with custom extensions", async () => { + writeFileSync(join(dir, "app.ts"), "console.log(1)\n"); + writeFileSync( + join(dir, "app.ts.map"), + JSON.stringify({ + version: 3, + sources: ["app.ts"], + names: [], + mappings: "", + }) + ); + // A .js file that should NOT be discovered when --ext is .ts + writeFileSync(join(dir, "other.js"), "console.log(2)\n"); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { ext: ".ts" }, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + expect(callArgs?.files).toHaveLength(2); + const urls = callArgs?.files.map((f) => f.url); + expect(urls?.some((u) => u?.includes("app.ts"))).toBe(true); + expect(urls?.some((u) => u?.includes("other.js"))).toBe(false); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--ignore: excludes matching files from upload", async () => { + mkdirSync(join(dir, "vendor")); + // File that should be excluded + writeFileSync(join(dir, "vendor", "lib.js"), "console.log(1)\n"); + writeFileSync( + join(dir, "vendor", "lib.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + // File that should be included + writeFileSync(join(dir, "app.js"), "console.log(2)\n"); + writeFileSync( + join(dir, "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { ignore: "vendor/**" }, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + // Only app.js pair should be uploaded (2 files: .js + .map) + expect(callArgs?.files).toHaveLength(2); + const urls = callArgs?.files.map((f) => f.url); + expect(urls?.some((u) => u?.includes("app.js"))).toBe(true); + expect(urls?.some((u) => u?.includes("vendor"))).toBe(false); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--ignore-file: reads patterns from a file", async () => { + mkdirSync(join(dir, "vendor")); + writeFileSync(join(dir, "vendor", "lib.js"), "console.log(1)\n"); + writeFileSync( + join(dir, "vendor", "lib.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + writeFileSync(join(dir, "app.js"), "console.log(2)\n"); + writeFileSync( + join(dir, "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + // Write an ignore file + const ignoreFilePath = join(dir, ".sourcemapignore"); + writeFileSync(ignoreFilePath, "vendor/\n"); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { "ignore-file": ignoreFilePath }, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + expect(callArgs?.files).toHaveLength(2); + const urls = callArgs?.files.map((f) => f.url); + expect(urls?.some((u) => u?.includes("app.js"))).toBe(true); + expect(urls?.some((u) => u?.includes("vendor"))).toBe(false); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--ignore-file with non-existent file: throws ValidationError", async () => { + writeFileSync(join(dir, "app.js"), "console.log(1)\n"); + writeFileSync( + join(dir, "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + const ctx = makeContext(); + try { + await func.call(ctx, { "ignore-file": join(dir, "nonexistent") }, dir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + expect((err as Error).message).toContain("does not exist"); + } + }); + + test("--strip-prefix: removes explicit prefix from uploaded URLs", async () => { + mkdirSync(join(dir, "static", "js"), { recursive: true }); + writeFileSync(join(dir, "static", "js", "app.js"), "console.log(1)\n"); + writeFileSync( + join(dir, "static", "js", "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { "strip-prefix": "static/js/" }, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + const urls = callArgs?.files.map((f) => f.url); + // Prefix stripped: "~/app.js" instead of "~/static/js/app.js" + expect(urls).toContain("~/app.js"); + expect(urls).toContain("~/app.js.map"); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--strip-common-prefix: auto-strips shared directory prefix", async () => { + mkdirSync(join(dir, "build", "output"), { recursive: true }); + writeFileSync(join(dir, "build", "output", "main.js"), "console.log(1)\n"); + writeFileSync( + join(dir, "build", "output", "main.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + writeFileSync( + join(dir, "build", "output", "vendor.js"), + "console.log(2)\n" + ); + writeFileSync( + join(dir, "build", "output", "vendor.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + + const uploadSpy = spyOn( + sourcemapsApi, + "uploadSourcemaps" + ).mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { "strip-common-prefix": true }, dir); + expect(uploadSpy).toHaveBeenCalledTimes(1); + const callArgs = uploadSpy.mock.calls[0]?.[0]; + const urls = callArgs?.files.map((f) => f.url); + // Common prefix "build/output/" stripped: "~/main.js", "~/vendor.js" + expect(urls).toContain("~/main.js"); + expect(urls).toContain("~/vendor.js"); + expect(urls?.some((u) => u?.includes("build"))).toBe(false); + } finally { + uploadSpy.mockRestore(); + } + }); });