From 029aeaf330652dc5896e5784c4648862b1770814 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 11 Mar 2026 23:04:18 +0100 Subject: [PATCH 01/13] feat(cli): Add `code-mappings upload` command scaffold with file parsing Add a new `code-mappings` subcommand group with an `upload` subcommand that reads and validates a JSON file of code mappings. This is the first step toward CLI support for bulk code mapping uploads to Sentry. --- src/commands/code_mappings/mod.rs | 45 ++++++++++++++ src/commands/code_mappings/upload.rs | 62 +++++++++++++++++++ src/commands/mod.rs | 2 + .../code_mappings/code-mappings-help.trycmd | 25 ++++++++ .../code-mappings-no-subcommand.trycmd | 25 ++++++++ .../code-mappings-upload-help.trycmd | 26 ++++++++ tests/integration/code_mappings/mod.rs | 16 +++++ tests/integration/mod.rs | 1 + 8 files changed, 202 insertions(+) create mode 100644 src/commands/code_mappings/mod.rs create mode 100644 src/commands/code_mappings/upload.rs create mode 100644 tests/integration/_cases/code_mappings/code-mappings-help.trycmd create mode 100644 tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd create mode 100644 tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd create mode 100644 tests/integration/code_mappings/mod.rs diff --git a/src/commands/code_mappings/mod.rs b/src/commands/code_mappings/mod.rs new file mode 100644 index 0000000000..4c2583bd61 --- /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.") + .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..f0141c7d42 --- /dev/null +++ b/src/commands/code_mappings/upload.rs @@ -0,0 +1,62 @@ +use std::fs; + +use anyhow::{bail, Context as _, Result}; +use clap::{Arg, ArgMatches, Command}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CodeMapping { + stack_root: String, + source_root: String, +} + +pub fn make_command(command: Command) -> Command { + command + .about("Upload code mappings for a project from a JSON file.") + .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") + .default_value("main") + .help("The default branch name."), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + #[expect(clippy::unwrap_used, reason = "path is a required argument")] + let path = matches.get_one::("path").unwrap(); + 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."); + } + } + + println!("Found {} code mapping(s) in {path}", mappings.len()); + + Ok(()) +} 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/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..0d29b3dd63 --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-help.trycmd @@ -0,0 +1,25 @@ +``` +$ sentry-cli code-mappings --help +? success +Manage code mappings for Sentry. + +Usage: sentry-cli[EXE] code-mappings [OPTIONS] + +Commands: + upload Upload code mappings for a project from a JSON file. + 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..36df45fa6f --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd @@ -0,0 +1,25 @@ +``` +$ sentry-cli code-mappings +? failed +Manage code mappings for Sentry. + +Usage: sentry-cli[EXE] code-mappings [OPTIONS] + +Commands: + upload Upload code mappings for a project from a JSON file. + 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..cf46abe2c2 --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd @@ -0,0 +1,26 @@ +``` +$ sentry-cli code-mappings upload --help +? success +Upload code mappings for a project from a JSON file. + +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. [default: 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/code_mappings/mod.rs b/tests/integration/code_mappings/mod.rs new file mode 100644 index 0000000000..bcfc6ec6a5 --- /dev/null +++ b/tests/integration/code_mappings/mod.rs @@ -0,0 +1,16 @@ +use crate::integration::TestManager; + +#[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/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; From 072fa6b480d1eae74b18d4f0044a348c196329a6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 12 Mar 2026 12:05:54 +0100 Subject: [PATCH 02/13] fix(test): Update help snapshots with code-mappings subcommand --- tests/integration/_cases/help/help-windows.trycmd | 1 + tests/integration/_cases/help/help.trycmd | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/integration/_cases/help/help-windows.trycmd b/tests/integration/_cases/help/help-windows.trycmd index e634561ea7..34213e9438 100644 --- a/tests/integration/_cases/help/help-windows.trycmd +++ b/tests/integration/_cases/help/help-windows.trycmd @@ -12,6 +12,7 @@ Usage: sentry-cli[EXE] [OPTIONS] Commands: completions Generate completions for the specified shell. build Manage builds. + code-mappings Manage code mappings for 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..fcc5302ea7 100644 --- a/tests/integration/_cases/help/help.trycmd +++ b/tests/integration/_cases/help/help.trycmd @@ -12,6 +12,7 @@ Usage: sentry-cli[EXE] [OPTIONS] Commands: completions Generate completions for the specified shell. build Manage builds. + code-mappings Manage code mappings for Sentry. debug-files Locate, analyze or upload debug information files. [aliases: dif] deploys Manage deployments for Sentry releases. events Manage events on Sentry. From 8acfdbe7e9c64bef0a45e4c977a4eeb6039f745e Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Mar 2026 17:52:30 +0100 Subject: [PATCH 03/13] fix(code-mappings): Address PR feedback on help text and unwrap usage Add descriptive help text explaining what code mappings are and how they work. Replace unwrap with expect for the required path argument. Co-Authored-By: Claude Opus 4.6 --- src/commands/code_mappings/mod.rs | 2 +- src/commands/code_mappings/upload.rs | 7 ++++--- .../_cases/code_mappings/code-mappings-help.trycmd | 7 +++++-- .../code_mappings/code-mappings-no-subcommand.trycmd | 7 +++++-- .../_cases/code_mappings/code-mappings-upload-help.trycmd | 4 +++- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/commands/code_mappings/mod.rs b/src/commands/code_mappings/mod.rs index 4c2583bd61..47511a8e5a 100644 --- a/src/commands/code_mappings/mod.rs +++ b/src/commands/code_mappings/mod.rs @@ -21,7 +21,7 @@ pub fn make_command(mut command: Command) -> Command { } command = command - .about("Manage code mappings for Sentry.") + .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() diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index f0141c7d42..3e6582f27e 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -13,7 +13,7 @@ struct CodeMapping { pub fn make_command(command: Command) -> Command { command - .about("Upload code mappings for a project from a JSON file.") + .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") @@ -36,8 +36,9 @@ pub fn make_command(command: Command) -> Command { } pub fn execute(matches: &ArgMatches) -> Result<()> { - #[expect(clippy::unwrap_used, reason = "path is a required argument")] - let path = matches.get_one::("path").unwrap(); + 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 = diff --git a/tests/integration/_cases/code_mappings/code-mappings-help.trycmd b/tests/integration/_cases/code_mappings/code-mappings-help.trycmd index 0d29b3dd63..759ddda529 100644 --- a/tests/integration/_cases/code_mappings/code-mappings-help.trycmd +++ b/tests/integration/_cases/code_mappings/code-mappings-help.trycmd @@ -1,12 +1,15 @@ ``` $ sentry-cli code-mappings --help ? success -Manage code mappings for Sentry. +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. + 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: diff --git a/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd b/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd index 36df45fa6f..64f98301c4 100644 --- a/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd +++ b/tests/integration/_cases/code_mappings/code-mappings-no-subcommand.trycmd @@ -1,12 +1,15 @@ ``` $ sentry-cli code-mappings ? failed -Manage code mappings for Sentry. +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. + 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: diff --git a/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd index cf46abe2c2..01033d6915 100644 --- a/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd +++ b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd @@ -1,7 +1,9 @@ ``` $ sentry-cli code-mappings upload --help ? success -Upload code mappings for a project from a JSON file. +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] From 1015af15b5d43b7fe5e1ffe43ed47e01ee9d266a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Mar 2026 17:57:41 +0100 Subject: [PATCH 04/13] fix(code-mappings): Update top-level help snapshot for new description Co-Authored-By: Claude Opus 4.6 --- tests/integration/_cases/help/help.trycmd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/_cases/help/help.trycmd b/tests/integration/_cases/help/help.trycmd index fcc5302ea7..41537a2fa9 100644 --- a/tests/integration/_cases/help/help.trycmd +++ b/tests/integration/_cases/help/help.trycmd @@ -12,7 +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 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. From 811bb94f5a17ec45f5dca65e3e94342f67ad8360 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Mar 2026 18:06:22 +0100 Subject: [PATCH 05/13] fix(code-mappings): Update Windows help snapshot for new description Co-Authored-By: Claude Opus 4.6 --- tests/integration/_cases/help/help-windows.trycmd | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/_cases/help/help-windows.trycmd b/tests/integration/_cases/help/help-windows.trycmd index 34213e9438..71fc427292 100644 --- a/tests/integration/_cases/help/help-windows.trycmd +++ b/tests/integration/_cases/help/help-windows.trycmd @@ -12,7 +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 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. From 8b836135feed74c29222492a48d3f74bf934c5b1 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 11 Mar 2026 23:35:04 +0100 Subject: [PATCH 06/13] feat(cli): Add git inference for repo and branch in code-mappings upload Automatically detect repository name and default branch from the local git repo when --repo or --default-branch are not provided. Respects SENTRY_VCS_REMOTE config, falling back to best-effort remote detection. Extract find_best_remote() into vcs.rs to deduplicate remote selection logic shared with git_repo_base_repo_name_preserve_case(). --- src/commands/code_mappings/upload.rs | 71 ++++++++++++++++++- src/utils/vcs.rs | 26 +++++-- .../code-mappings-upload-help.trycmd | 2 +- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index 3e6582f27e..402d62beee 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -2,8 +2,12 @@ use std::fs; use anyhow::{bail, Context as _, Result}; use clap::{Arg, ArgMatches, Command}; +use log::debug; use serde::{Deserialize, Serialize}; +use crate::config::Config; +use crate::utils::vcs; + #[derive(Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct CodeMapping { @@ -30,8 +34,7 @@ pub fn make_command(command: Command) -> Command { Arg::new("default_branch") .long("default-branch") .value_name("BRANCH") - .default_value("main") - .help("The default branch name."), + .help("The default branch name. Defaults to the git remote HEAD or 'main'."), ) } @@ -57,7 +60,71 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { } } + // 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().map_err(|e| { + anyhow::anyhow!( + "Could not open git repository: {e}. \ + Use --repo and --default-branch to specify manually." + ) + })?; + // Prefer explicit config (SENTRY_VCS_REMOTE / ini), then inspect + // the repo for the best remote (upstream > origin > first). + let config = Config::current(); + let configured_remote = config.get_cached_vcs_remote(); + let remote_name = + if vcs::git_repo_remote_url(&git_repo, &configured_remote).is_ok() { + debug!("Using configured VCS remote: {configured_remote}"); + configured_remote + } else if let Some(best) = vcs::find_best_remote(&git_repo)? { + debug!("Configured remote '{configured_remote}' not found, using: {best}"); + best + } else { + bail!( + "No remotes found in the git repository. \ + Use --repo and --default-branch to specify manually." + ); + }; + + let repo_name = match explicit_repo { + Some(r) => r.to_owned(), + None => { + 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(&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 = + vcs::git_repo_base_ref(&git_repo, &remote_name).unwrap_or_else(|e| { + debug!("Could not infer default branch, falling back to 'main': {e}"); + "main".to_owned() + }); + println!("Inferred default branch: {inferred}"); + inferred + } + }; + + (repo_name, default_branch) + } + }; + println!("Found {} code mapping(s) in {path}", mappings.len()); + println!("Repository: {repo_name}"); + println!("Default branch: {default_branch}"); Ok(()) } 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-upload-help.trycmd b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd index 01033d6915..7eec72d0c2 100644 --- a/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd +++ b/tests/integration/_cases/code_mappings/code-mappings-upload-help.trycmd @@ -13,7 +13,7 @@ Arguments: 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. [default: main] + --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. From 154502bcc5a587cf933ba66436d4ad1e06e7d0a6 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 12 Mar 2026 12:25:25 +0100 Subject: [PATCH 07/13] style: Fix formatting in code-mappings upload --- src/commands/code_mappings/upload.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index 402d62beee..321debbb02 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -77,19 +77,18 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { // the repo for the best remote (upstream > origin > first). let config = Config::current(); let configured_remote = config.get_cached_vcs_remote(); - let remote_name = - if vcs::git_repo_remote_url(&git_repo, &configured_remote).is_ok() { - debug!("Using configured VCS remote: {configured_remote}"); - configured_remote - } else if let Some(best) = vcs::find_best_remote(&git_repo)? { - debug!("Configured remote '{configured_remote}' not found, using: {best}"); - best - } else { - bail!( - "No remotes found in the git repository. \ + let remote_name = if vcs::git_repo_remote_url(&git_repo, &configured_remote).is_ok() { + debug!("Using configured VCS remote: {configured_remote}"); + configured_remote + } else if let Some(best) = vcs::find_best_remote(&git_repo)? { + debug!("Configured remote '{configured_remote}' not found, using: {best}"); + best + } else { + bail!( + "No remotes found in the git repository. \ Use --repo and --default-branch to specify manually." - ); - }; + ); + }; let repo_name = match explicit_repo { Some(r) => r.to_owned(), From 8f9babeec7d03de4a47daa31afc32de42d5fbae3 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Mar 2026 18:02:45 +0100 Subject: [PATCH 08/13] fix(code-mappings): Allow branch fallback without git remotes When --repo is provided but --default-branch is not, the code no longer requires a git remote to be present. Branch inference gracefully falls back to 'main' when no git repo or remote is available. Co-Authored-By: Claude Opus 4.6 --- src/commands/code_mappings/upload.rs | 73 +++++++++++++++++++--------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index 321debbb02..01a219db62 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -67,33 +67,46 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { 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().map_err(|e| { - anyhow::anyhow!( - "Could not open git repository: {e}. \ - Use --repo and --default-branch to specify manually." - ) - })?; + 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 config = Config::current(); - let configured_remote = config.get_cached_vcs_remote(); - let remote_name = if vcs::git_repo_remote_url(&git_repo, &configured_remote).is_ok() { - debug!("Using configured VCS remote: {configured_remote}"); - configured_remote - } else if let Some(best) = vcs::find_best_remote(&git_repo)? { - debug!("Configured remote '{configured_remote}' not found, using: {best}"); - best - } else { - bail!( - "No remotes found in the git repository. \ - Use --repo and --default-branch to specify manually." - ); - }; + let remote_name = git_repo.as_ref().ok().and_then(|repo| { + let config = Config::current(); + 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 remote_url = vcs::git_repo_remote_url(&git_repo, &remote_name)?; + 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(&remote_url); if inferred.is_empty() { @@ -107,9 +120,21 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let default_branch = match explicit_branch { Some(b) => b.to_owned(), None => { - let inferred = - vcs::git_repo_base_ref(&git_repo, &remote_name).unwrap_or_else(|e| { - debug!("Could not infer default branch, falling back to 'main': {e}"); + 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}"); From 70ddd867a7e351ed7973e1b78d1fbd79bd8c1f50 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Mar 2026 18:41:27 +0100 Subject: [PATCH 09/13] fix(code-mappings): Preserve case when inferring repo name from remote Use get_repo_from_remote_preserve_case instead of get_repo_from_remote to avoid lowercasing the repository name, which would cause mismatches with Sentry's case-sensitive API. Co-Authored-By: Claude Opus 4.6 --- src/commands/code_mappings/upload.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index 01a219db62..7daed0154c 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -108,7 +108,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { })?; 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(&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}"); } From 06d724eb5bd3759ae1b88fdf413231591a71d125 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 11 Mar 2026 23:59:20 +0100 Subject: [PATCH 10/13] feat(cli): Wire up API integration for code-mappings upload Add bulk_upload_code_mappings() API method and BulkCodeMappingsRequest/ Response data types. Wire up the upload command to authenticate, call the bulk endpoint, and display results in a table with summary counts. Exit with code 1 if any mappings fail. --- src/api/data_types/code_mappings.rs | 37 ++++++++++ src/api/data_types/mod.rs | 2 + src/api/mod.rs | 11 +++ src/commands/code_mappings/upload.rs | 71 +++++++++++++++---- .../code_mappings/code-mappings-upload.trycmd | 14 ++++ .../_fixtures/code_mappings/mappings.json | 4 ++ .../_responses/code_mappings/post-bulk.json | 9 +++ tests/integration/code_mappings/mod.rs | 2 + tests/integration/code_mappings/upload.rs | 15 ++++ 9 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 src/api/data_types/code_mappings.rs create mode 100644 tests/integration/_cases/code_mappings/code-mappings-upload.trycmd create mode 100644 tests/integration/_fixtures/code_mappings/mappings.json create mode 100644 tests/integration/_responses/code_mappings/post-bulk.json create mode 100644 tests/integration/code_mappings/upload.rs 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..8420c2aa0e 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::ResourceNotFound) + } + /// Creates a preprod snapshot artifact for the given project. pub fn create_preprod_snapshot( &self, diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index 7daed0154c..fe94f420be 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -3,18 +3,12 @@ use std::fs; use anyhow::{bail, Context as _, Result}; use clap::{Arg, ArgMatches, Command}; use log::debug; -use serde::{Deserialize, Serialize}; +use crate::api::{Api, BulkCodeMapping, BulkCodeMappingsRequest}; use crate::config::Config; +use crate::utils::formatting::Table; use crate::utils::vcs; -#[derive(Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -struct CodeMapping { - stack_root: String, - source_root: String, -} - 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).") @@ -39,12 +33,16 @@ pub fn make_command(command: Command) -> Command { } 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 = + let mappings: Vec = serde_json::from_slice(&data).context("Failed to parse mappings JSON")?; if mappings.is_empty() { @@ -73,7 +71,6 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { // 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 config = Config::current(); 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}"); @@ -146,9 +143,57 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { } }; - println!("Found {} code mapping(s) in {path}", mappings.len()); - println!("Repository: {repo_name}"); - println!("Default branch: {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)?; + + // Display results + let mut table = Table::new(); + table + .title_row() + .add("Stack Root") + .add("Source Root") + .add("Status"); + + for result in &response.mappings { + let status = match result.status.as_str() { + "error" => match &result.detail { + Some(detail) => format!("error: {detail}"), + None => "error".to_owned(), + }, + s => s.to_owned(), + }; + table + .add_row() + .add(&result.stack_root) + .add(&result.source_root) + .add(&status); + } + + table.print(); + println!(); + 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(()) } 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/_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 index bcfc6ec6a5..1869e71805 100644 --- a/tests/integration/code_mappings/mod.rs +++ b/tests/integration/code_mappings/mod.rs @@ -1,5 +1,7 @@ use crate::integration::TestManager; +mod upload; + #[test] fn command_code_mappings_help() { TestManager::new().register_trycmd_test("code_mappings/code-mappings-help.trycmd"); diff --git a/tests/integration/code_mappings/upload.rs b/tests/integration/code_mappings/upload.rs new file mode 100644 index 0000000000..776433cce4 --- /dev/null +++ b/tests/integration/code_mappings/upload.rs @@ -0,0 +1,15 @@ +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(); +} From e288070e9ff893c179ae10218e1a3f2b43ec732a Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Thu, 12 Mar 2026 12:39:24 +0100 Subject: [PATCH 11/13] style: Fix formatting in upload test --- tests/integration/code_mappings/upload.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/integration/code_mappings/upload.rs b/tests/integration/code_mappings/upload.rs index 776433cce4..861ed199a9 100644 --- a/tests/integration/code_mappings/upload.rs +++ b/tests/integration/code_mappings/upload.rs @@ -4,11 +4,8 @@ use crate::integration::{MockEndpointBuilder, TestManager}; 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"), + 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(); From b3d783189447086f09edae0612c202c0ce6494d2 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Mar 2026 21:04:44 +0100 Subject: [PATCH 12/13] fix(code-mappings): Use OrganizationNotFound for org-scoped endpoint The bulk code-mappings endpoint is scoped to an organization, so a 404 should report "organization not found" rather than generic "resource not found", consistent with other org-scoped endpoints. Co-Authored-By: Claude Opus 4.6 --- src/api/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 8420c2aa0e..5810e3c7de 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -986,7 +986,7 @@ impl AuthenticatedApi<'_> { ) -> ApiResult { let path = format!("/organizations/{}/code-mappings/bulk/", PathArg(org)); self.post(&path, body)? - .convert_rnf(ApiErrorKind::ResourceNotFound) + .convert_rnf(ApiErrorKind::OrganizationNotFound) } /// Creates a preprod snapshot artifact for the given project. From a00890107aae0e8bdadd7828d483725303d08200 Mon Sep 17 00:00:00 2001 From: Roman Zavarnitsyn Date: Wed, 18 Mar 2026 21:07:11 +0100 Subject: [PATCH 13/13] ref(code-mappings): Simplify result display and extract table helper Simplify the status match per review feedback and extract the table building logic into a print_results_table function. Co-Authored-By: Claude Opus 4.6 --- src/commands/code_mappings/upload.rs | 44 ++++++++++++++-------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index fe94f420be..78d05f8c0e 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -4,7 +4,7 @@ use anyhow::{bail, Context as _, Result}; use clap::{Arg, ArgMatches, Command}; use log::debug; -use crate::api::{Api, BulkCodeMapping, BulkCodeMappingsRequest}; +use crate::api::{Api, BulkCodeMapping, BulkCodeMappingResult, BulkCodeMappingsRequest}; use crate::config::Config; use crate::utils::formatting::Table; use crate::utils::vcs; @@ -158,7 +158,23 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { .authenticated()? .bulk_upload_code_mappings(&org, &request)?; - // Display results + 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() @@ -166,13 +182,10 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { .add("Source Root") .add("Status"); - for result in &response.mappings { - let status = match result.status.as_str() { - "error" => match &result.detail { - Some(detail) => format!("error: {detail}"), - None => "error".to_owned(), - }, - s => s.to_owned(), + for result in mappings { + let status = match result.detail { + Some(detail) if result.status == "error" => format!("error: {detail}"), + _ => result.status, }; table .add_row() @@ -183,17 +196,4 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { table.print(); println!(); - 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(()) }