diff --git a/src/api/data_types/code_mappings.rs b/src/api/data_types/code_mappings.rs new file mode 100644 index 0000000000..21a8aedd55 --- /dev/null +++ b/src/api/data_types/code_mappings.rs @@ -0,0 +1,37 @@ +//! Data types for the bulk code mappings API. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkCodeMappingsRequest { + pub project: String, + pub repository: String, + pub default_branch: String, + pub mappings: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkCodeMapping { + pub stack_root: String, + pub source_root: String, +} + +#[derive(Debug, Deserialize)] +pub struct BulkCodeMappingsResponse { + pub created: u64, + pub updated: u64, + pub errors: u64, + pub mappings: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BulkCodeMappingResult { + pub stack_root: String, + pub source_root: String, + pub status: String, + #[serde(default)] + pub detail: Option, +} diff --git a/src/api/data_types/mod.rs b/src/api/data_types/mod.rs index 8f7d5dc661..899dcccf60 100644 --- a/src/api/data_types/mod.rs +++ b/src/api/data_types/mod.rs @@ -1,9 +1,11 @@ //! Data types used in the api module mod chunking; +mod code_mappings; mod deploy; mod snapshots; pub use self::chunking::*; +pub use self::code_mappings::*; pub use self::deploy::*; pub use self::snapshots::*; diff --git a/src/api/mod.rs b/src/api/mod.rs index df57300b16..5810e3c7de 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -978,6 +978,17 @@ impl AuthenticatedApi<'_> { Ok(rv) } + /// Bulk uploads code mappings for an organization. + pub fn bulk_upload_code_mappings( + &self, + org: &str, + body: &BulkCodeMappingsRequest, + ) -> ApiResult { + let path = format!("/organizations/{}/code-mappings/bulk/", PathArg(org)); + self.post(&path, body)? + .convert_rnf(ApiErrorKind::OrganizationNotFound) + } + /// Creates a preprod snapshot artifact for the given project. pub fn create_preprod_snapshot( &self, diff --git a/src/commands/code_mappings/mod.rs b/src/commands/code_mappings/mod.rs new file mode 100644 index 0000000000..47511a8e5a --- /dev/null +++ b/src/commands/code_mappings/mod.rs @@ -0,0 +1,45 @@ +use anyhow::Result; +use clap::{ArgMatches, Command}; + +use crate::utils::args::ArgExt as _; + +pub mod upload; + +macro_rules! each_subcommand { + ($mac:ident) => { + $mac!(upload); + }; +} + +pub fn make_command(mut command: Command) -> Command { + macro_rules! add_subcommand { + ($name:ident) => {{ + command = command.subcommand(crate::commands::code_mappings::$name::make_command( + Command::new(stringify!($name).replace('_', "-")), + )); + }}; + } + + command = command + .about("Manage code mappings for Sentry. Code mappings link stack trace paths to source code paths in your repository, enabling source context and code linking in Sentry.") + .subcommand_required(true) + .arg_required_else_help(true) + .org_arg() + .project_arg(false); + each_subcommand!(add_subcommand); + command +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + macro_rules! execute_subcommand { + ($name:ident) => {{ + if let Some(sub_matches) = + matches.subcommand_matches(&stringify!($name).replace('_', "-")) + { + return crate::commands::code_mappings::$name::execute(&sub_matches); + } + }}; + } + each_subcommand!(execute_subcommand); + unreachable!(); +} diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs new file mode 100644 index 0000000000..78d05f8c0e --- /dev/null +++ b/src/commands/code_mappings/upload.rs @@ -0,0 +1,199 @@ +use std::fs; + +use anyhow::{bail, Context as _, Result}; +use clap::{Arg, ArgMatches, Command}; +use log::debug; + +use crate::api::{Api, BulkCodeMapping, BulkCodeMappingResult, BulkCodeMappingsRequest}; +use crate::config::Config; +use crate::utils::formatting::Table; +use crate::utils::vcs; + +pub fn make_command(command: Command) -> Command { + command + .about("Upload code mappings for a project from a JSON file. Each mapping pairs a stack trace root (e.g. com/example/module) with the corresponding source path in your repository (e.g. modules/module/src/main/java/com/example/module).") + .arg( + Arg::new("path") + .value_name("PATH") + .required(true) + .help("Path to a JSON file containing code mappings."), + ) + .arg( + Arg::new("repo") + .long("repo") + .value_name("REPO") + .help("The repository name (e.g. owner/repo). Defaults to the git remote."), + ) + .arg( + Arg::new("default_branch") + .long("default-branch") + .value_name("BRANCH") + .help("The default branch name. Defaults to the git remote HEAD or 'main'."), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let project = config.get_project(matches)?; + + let path = matches + .get_one::("path") + .expect("path is a required argument"); + let data = fs::read(path).with_context(|| format!("Failed to read mappings file '{path}'"))?; + + let mappings: Vec = + serde_json::from_slice(&data).context("Failed to parse mappings JSON")?; + + if mappings.is_empty() { + bail!("Mappings file contains an empty array. Nothing to upload."); + } + + for (i, mapping) in mappings.iter().enumerate() { + if mapping.stack_root.is_empty() { + bail!("Mapping at index {i} has an empty stackRoot."); + } + if mapping.source_root.is_empty() { + bail!("Mapping at index {i} has an empty sourceRoot."); + } + } + + // Resolve repo name and default branch + let explicit_repo = matches.get_one::("repo"); + let explicit_branch = matches.get_one::("default_branch"); + + let (repo_name, default_branch) = match (explicit_repo, explicit_branch) { + (Some(r), Some(b)) => (r.to_owned(), b.to_owned()), + _ => { + let git_repo = git2::Repository::open_from_env(); + + // Resolve the best remote name when we have a git repo. + // Prefer explicit config (SENTRY_VCS_REMOTE / ini), then inspect + // the repo for the best remote (upstream > origin > first). + let remote_name = git_repo.as_ref().ok().and_then(|repo| { + let configured_remote = config.get_cached_vcs_remote(); + if vcs::git_repo_remote_url(repo, &configured_remote).is_ok() { + debug!("Using configured VCS remote: {configured_remote}"); + Some(configured_remote) + } else { + match vcs::find_best_remote(repo) { + Ok(Some(best)) => { + debug!( + "Configured remote '{configured_remote}' not found, using: {best}" + ); + Some(best) + } + _ => None, + } + } + }); + + let repo_name = match explicit_repo { + Some(r) => r.to_owned(), + None => { + let git_repo = git_repo.as_ref().map_err(|e| { + anyhow::anyhow!( + "Could not open git repository: {e}. \ + Use --repo to specify manually." + ) + })?; + let remote_name = remote_name.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "No remotes found in the git repository. \ + Use --repo to specify manually." + ) + })?; + let remote_url = vcs::git_repo_remote_url(git_repo, remote_name)?; + debug!("Found remote '{remote_name}': {remote_url}"); + let inferred = vcs::get_repo_from_remote_preserve_case(&remote_url); + if inferred.is_empty() { + bail!("Could not parse repository name from remote URL: {remote_url}"); + } + println!("Inferred repository: {inferred}"); + inferred + } + }; + + let default_branch = match explicit_branch { + Some(b) => b.to_owned(), + None => { + let inferred = git_repo + .as_ref() + .ok() + .and_then(|repo| { + remote_name.as_deref().and_then(|name| { + vcs::git_repo_base_ref(repo, name) + .map(Some) + .unwrap_or_else(|e| { + debug!("Could not infer default branch from remote: {e}"); + None + }) + }) + }) + .unwrap_or_else(|| { + debug!("No git repo or remote available, falling back to 'main'"); + "main".to_owned() + }); + println!("Inferred default branch: {inferred}"); + inferred + } + }; + + (repo_name, default_branch) + } + }; + + let mapping_count = mappings.len(); + let request = BulkCodeMappingsRequest { + project, + repository: repo_name, + default_branch, + mappings, + }; + + println!("Uploading {mapping_count} code mapping(s)..."); + + let api = Api::current(); + let response = api + .authenticated()? + .bulk_upload_code_mappings(&org, &request)?; + + print_results_table(response.mappings); + println!( + "Created: {}, Updated: {}, Errors: {}", + response.created, response.updated, response.errors + ); + + if response.errors > 0 { + bail!( + "{} mapping(s) failed to upload. See errors above.", + response.errors + ); + } + + Ok(()) +} + +fn print_results_table(mappings: Vec) { + let mut table = Table::new(); + table + .title_row() + .add("Stack Root") + .add("Source Root") + .add("Status"); + + for result in mappings { + let status = match result.detail { + Some(detail) if result.status == "error" => format!("error: {detail}"), + _ => result.status, + }; + table + .add_row() + .add(&result.stack_root) + .add(&result.source_root) + .add(&status); + } + + table.print(); + println!(); +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index bf74f76d42..65ca7573b4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -21,6 +21,7 @@ use crate::utils::value_parsers::auth_token_parser; mod bash_hook; mod build; +mod code_mappings; mod dart_symbol_map; mod debug_files; mod deploys; @@ -52,6 +53,7 @@ macro_rules! each_subcommand { ($mac:ident) => { $mac!(bash_hook); $mac!(build); + $mac!(code_mappings); $mac!(debug_files); $mac!(deploys); $mac!(events); diff --git a/src/utils/vcs.rs b/src/utils/vcs.rs index 948c35a0f9..2fd89b372d 100644 --- a/src/utils/vcs.rs +++ b/src/utils/vcs.rs @@ -301,19 +301,17 @@ pub fn git_repo_base_ref(repo: &git2::Repository, remote_name: &str) -> Result Result> { +/// Finds the best remote in a git repository. +/// Prefers "upstream" if it exists, then "origin", otherwise uses the first remote. +pub fn find_best_remote(repo: &git2::Repository) -> Result> { let remotes = repo.remotes()?; let remote_names: Vec<&str> = remotes.iter().flatten().collect(); if remote_names.is_empty() { - warn!("No remotes found in repository"); return Ok(None); } - // Prefer "upstream" if it exists, then "origin", otherwise use the first one - let chosen_remote = if remote_names.contains(&"upstream") { + let chosen = if remote_names.contains(&"upstream") { "upstream" } else if remote_names.contains(&"origin") { "origin" @@ -321,7 +319,21 @@ pub fn git_repo_base_repo_name_preserve_case(repo: &git2::Repository) -> Result< remote_names[0] }; - match git_repo_remote_url(repo, chosen_remote) { + Ok(Some(chosen.to_owned())) +} + +/// Like git_repo_base_repo_name but preserves the original case of the repository name. +/// This is used specifically for build upload where case preservation is important. +pub fn git_repo_base_repo_name_preserve_case(repo: &git2::Repository) -> Result> { + let chosen_remote = match find_best_remote(repo)? { + Some(remote) => remote, + None => { + warn!("No remotes found in repository"); + return Ok(None); + } + }; + + match git_repo_remote_url(repo, &chosen_remote) { Ok(remote_url) => { debug!("Found remote '{chosen_remote}': {remote_url}"); let repo_name = get_repo_from_remote_preserve_case(&remote_url); diff --git a/tests/integration/_cases/code_mappings/code-mappings-help.trycmd b/tests/integration/_cases/code_mappings/code-mappings-help.trycmd new file mode 100644 index 0000000000..759ddda529 --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-help.trycmd @@ -0,0 +1,28 @@ +``` +$ sentry-cli code-mappings --help +? success +Manage code mappings for Sentry. Code mappings link stack trace paths to source code paths in your +repository, enabling source context and code linking in Sentry. + +Usage: sentry-cli[EXE] code-mappings [OPTIONS] + +Commands: + upload Upload code mappings for a project from a JSON file. Each mapping pairs a stack trace root + (e.g. com/example/module) with the corresponding source path in your repository (e.g. + modules/module/src/main/java/com/example/module). + help Print this message or the help of the given subcommand(s) + +Options: + -o, --org The organization ID or slug. + --header Custom headers that should be attached to all requests + in key:value format. + -p, --project The project ID or slug. + --auth-token Use the given Sentry auth token. + --log-level Set the log output verbosity. [possible values: trace, debug, info, + warn, error] + --quiet Do not print any output while preserving correct exit code. This + flag is currently implemented only for selected subcommands. + [aliases: --silent] + -h, --help Print help + +``` diff --git a/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd b/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd new file mode 100644 index 0000000000..64f98301c4 --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd @@ -0,0 +1,28 @@ +``` +$ sentry-cli code-mappings +? failed +Manage code mappings for Sentry. Code mappings link stack trace paths to source code paths in your +repository, enabling source context and code linking in Sentry. + +Usage: sentry-cli[EXE] code-mappings [OPTIONS] + +Commands: + upload Upload code mappings for a project from a JSON file. Each mapping pairs a stack trace root + (e.g. com/example/module) with the corresponding source path in your repository (e.g. + modules/module/src/main/java/com/example/module). + help Print this message or the help of the given subcommand(s) + +Options: + -o, --org The organization ID or slug. + --header Custom headers that should be attached to all requests + in key:value format. + -p, --project The project ID or slug. + --auth-token Use the given Sentry auth token. + --log-level Set the log output verbosity. [possible values: trace, debug, info, + warn, error] + --quiet Do not print any output while preserving correct exit code. This + flag is currently implemented only for selected subcommands. + [aliases: --silent] + -h, --help Print help + +``` diff --git a/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd new file mode 100644 index 0000000000..7eec72d0c2 --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd @@ -0,0 +1,28 @@ +``` +$ sentry-cli code-mappings upload --help +? success +Upload code mappings for a project from a JSON file. Each mapping pairs a stack trace root (e.g. +com/example/module) with the corresponding source path in your repository (e.g. +modules/module/src/main/java/com/example/module). + +Usage: sentry-cli[EXE] code-mappings upload [OPTIONS] + +Arguments: + Path to a JSON file containing code mappings. + +Options: + -o, --org The organization ID or slug. + --repo The repository name (e.g. owner/repo). Defaults to the git remote. + --default-branch The default branch name. Defaults to the git remote HEAD or 'main'. + --header Custom headers that should be attached to all requests + in key:value format. + -p, --project The project ID or slug. + --auth-token Use the given Sentry auth token. + --log-level Set the log output verbosity. [possible values: trace, debug, info, + warn, error] + --quiet Do not print any output while preserving correct exit code. This + flag is currently implemented only for selected subcommands. + [aliases: --silent] + -h, --help Print help + +``` diff --git a/tests/integration/_cases/code_mappings/code-mappings-upload.trycmd b/tests/integration/_cases/code_mappings/code-mappings-upload.trycmd new file mode 100644 index 0000000000..72c35d9d19 --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-upload.trycmd @@ -0,0 +1,14 @@ +``` +$ sentry-cli code-mappings upload tests/integration/_fixtures/code_mappings/mappings.json --org wat-org --project wat-project --repo owner/repo --default-branch main +? success +Uploading 2 code mapping(s)... ++------------------+---------------------------------------------+---------+ +| Stack Root | Source Root | Status | ++------------------+---------------------------------------------+---------+ +| com/example/core | modules/core/src/main/java/com/example/core | created | +| com/example/maps | modules/maps/src/main/java/com/example/maps | created | ++------------------+---------------------------------------------+---------+ + +Created: 2, Updated: 0, Errors: 0 + +``` diff --git a/tests/integration/_cases/help/help-windows.trycmd b/tests/integration/_cases/help/help-windows.trycmd index e634561ea7..71fc427292 100644 --- a/tests/integration/_cases/help/help-windows.trycmd +++ b/tests/integration/_cases/help/help-windows.trycmd @@ -12,6 +12,9 @@ Usage: sentry-cli[EXE] [OPTIONS] Commands: completions Generate completions for the specified shell. build Manage builds. + code-mappings Manage code mappings for Sentry. Code mappings link stack trace paths to source + code paths in your repository, enabling source context and code linking in + Sentry. debug-files Locate, analyze or upload debug information files. [aliases: dif] deploys Manage deployments for Sentry releases. events Manage events on Sentry. diff --git a/tests/integration/_cases/help/help.trycmd b/tests/integration/_cases/help/help.trycmd index aa51cd0222..41537a2fa9 100644 --- a/tests/integration/_cases/help/help.trycmd +++ b/tests/integration/_cases/help/help.trycmd @@ -12,6 +12,9 @@ Usage: sentry-cli[EXE] [OPTIONS] Commands: completions Generate completions for the specified shell. build Manage builds. + code-mappings Manage code mappings for Sentry. Code mappings link stack trace paths to source + code paths in your repository, enabling source context and code linking in + Sentry. debug-files Locate, analyze or upload debug information files. [aliases: dif] deploys Manage deployments for Sentry releases. events Manage events on Sentry. diff --git a/tests/integration/_fixtures/code_mappings/mappings.json b/tests/integration/_fixtures/code_mappings/mappings.json new file mode 100644 index 0000000000..d03581bf7e --- /dev/null +++ b/tests/integration/_fixtures/code_mappings/mappings.json @@ -0,0 +1,4 @@ +[ + {"stackRoot": "com/example/core", "sourceRoot": "modules/core/src/main/java/com/example/core"}, + {"stackRoot": "com/example/maps", "sourceRoot": "modules/maps/src/main/java/com/example/maps"} +] diff --git a/tests/integration/_responses/code_mappings/post-bulk.json b/tests/integration/_responses/code_mappings/post-bulk.json new file mode 100644 index 0000000000..4d30478f44 --- /dev/null +++ b/tests/integration/_responses/code_mappings/post-bulk.json @@ -0,0 +1,9 @@ +{ + "created": 2, + "updated": 0, + "errors": 0, + "mappings": [ + {"stackRoot": "com/example/core", "sourceRoot": "modules/core/src/main/java/com/example/core", "status": "created"}, + {"stackRoot": "com/example/maps", "sourceRoot": "modules/maps/src/main/java/com/example/maps", "status": "created"} + ] +} diff --git a/tests/integration/code_mappings/mod.rs b/tests/integration/code_mappings/mod.rs new file mode 100644 index 0000000000..1869e71805 --- /dev/null +++ b/tests/integration/code_mappings/mod.rs @@ -0,0 +1,18 @@ +use crate::integration::TestManager; + +mod upload; + +#[test] +fn command_code_mappings_help() { + TestManager::new().register_trycmd_test("code_mappings/code-mappings-help.trycmd"); +} + +#[test] +fn command_code_mappings_no_subcommand() { + TestManager::new().register_trycmd_test("code_mappings/code-mappings-no-subcommand.trycmd"); +} + +#[test] +fn command_code_mappings_upload_help() { + TestManager::new().register_trycmd_test("code_mappings/code-mappings-upload-help.trycmd"); +} diff --git a/tests/integration/code_mappings/upload.rs b/tests/integration/code_mappings/upload.rs new file mode 100644 index 0000000000..861ed199a9 --- /dev/null +++ b/tests/integration/code_mappings/upload.rs @@ -0,0 +1,12 @@ +use crate::integration::{MockEndpointBuilder, TestManager}; + +#[test] +fn command_code_mappings_upload() { + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/code-mappings/bulk/") + .with_response_file("code_mappings/post-bulk.json"), + ) + .register_trycmd_test("code_mappings/code-mappings-upload.trycmd") + .with_default_token(); +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index fde0647603..ba5828049d 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -1,5 +1,6 @@ mod bash_hook; mod build; +mod code_mappings; mod debug_files; mod deploys; mod events;