diff --git a/src/api/data_types/code_mappings.rs b/src/api/data_types/code_mappings.rs index 21a8aedd55..e53b04161a 100644 --- a/src/api/data_types/code_mappings.rs +++ b/src/api/data_types/code_mappings.rs @@ -11,7 +11,7 @@ pub struct BulkCodeMappingsRequest { pub mappings: Vec, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct BulkCodeMapping { pub stack_root: String, diff --git a/src/commands/code_mappings/upload.rs b/src/commands/code_mappings/upload.rs index 6e1036477b..2cc7025d46 100644 --- a/src/commands/code_mappings/upload.rs +++ b/src/commands/code_mappings/upload.rs @@ -4,11 +4,16 @@ 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, BulkCodeMappingsResponse, +}; use crate::config::Config; use crate::utils::formatting::Table; use crate::utils::vcs; +/// Maximum number of mappings the backend accepts per request. +const BATCH_SIZE: usize = 300; + pub fn make_command(command: Command) -> Command { command .about("Upload code mappings for a project from a JSON file.") @@ -118,56 +123,95 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { }; let mapping_count = mappings.len(); - let request = BulkCodeMappingsRequest { - project, - repository: repo_name, - default_branch, - mappings, - }; + let batches: Vec<&[BulkCodeMapping]> = mappings.chunks(BATCH_SIZE).collect(); + let total_batches = batches.len(); 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(), + let authenticated = api.authenticated()?; + + let mut merged = MergedResponse::default(); + + for (i, batch) in batches.iter().enumerate() { + if total_batches > 1 { + println!("Sending batch {}/{total_batches}...", i + 1); + } + let request = BulkCodeMappingsRequest { + project: project.clone(), + repository: repo_name.clone(), + default_branch: default_branch.clone(), + mappings: batch.to_vec(), }; + match authenticated.bulk_upload_code_mappings(&org, &request) { + Ok(response) => merged.add(response), + Err(err) => { + merged + .batch_errors + .push(format!("Batch {}/{total_batches} failed: {err}", i + 1)); + } + } + } + + // Display error details (successful mappings are summarized in counts only). + let error_mappings: Vec<_> = merged + .mappings + .iter() + .filter(|r| r.status == "error") + .collect(); + + if !error_mappings.is_empty() { + let mut table = Table::new(); table - .add_row() - .add(&result.stack_root) - .add(&result.source_root) - .add(&status); + .title_row() + .add("Stack Root") + .add("Source Root") + .add("Detail"); + + for result in &error_mappings { + let detail = result.detail.as_deref().unwrap_or("unknown error"); + table + .add_row() + .add(&result.stack_root) + .add(&result.source_root) + .add(detail); + } + + table.print(); + println!(); + } + + for err in &merged.batch_errors { + println!("{err}"); } - table.print(); - println!(); println!( "Created: {}, Updated: {}, Errors: {}", - response.created, response.updated, response.errors + merged.created, merged.updated, merged.errors ); - if response.errors > 0 { - bail!( - "{} mapping(s) failed to upload. See errors above.", - response.errors - ); + if merged.errors > 0 || !merged.batch_errors.is_empty() { + let total_errors = merged.errors + merged.batch_errors.len() as u64; + bail!("{total_errors} error(s) during upload. See details above."); } Ok(()) } + +#[derive(Default)] +struct MergedResponse { + created: u64, + updated: u64, + errors: u64, + mappings: Vec, + batch_errors: Vec, +} + +impl MergedResponse { + fn add(&mut self, response: BulkCodeMappingsResponse) { + self.created += response.created; + self.updated += response.updated; + self.errors += response.errors; + self.mappings.extend(response.mappings); + } +} diff --git a/tests/integration/_cases/code_mappings/code-mappings-upload-partial-error.trycmd b/tests/integration/_cases/code_mappings/code-mappings-upload-partial-error.trycmd new file mode 100644 index 0000000000..252d7a1866 --- /dev/null +++ b/tests/integration/_cases/code_mappings/code-mappings-upload-partial-error.trycmd @@ -0,0 +1,17 @@ +``` +$ sentry-cli code-mappings upload tests/integration/_fixtures/code_mappings/mappings.json --org wat-org --project wat-project --repo owner/repo --default-branch main +? failed +Uploading 2 code mapping(s)... ++------------------+---------------------------------------------+-------------------+ +| Stack Root | Source Root | Detail | ++------------------+---------------------------------------------+-------------------+ +| com/example/maps | modules/maps/src/main/java/com/example/maps | duplicate mapping | ++------------------+---------------------------------------------+-------------------+ + +Created: 1, Updated: 0, Errors: 1 +error: 1 error(s) during upload. See details above. + +Add --log-level=[info|debug] or export SENTRY_LOG_LEVEL=[info|debug] to see more output. +Please attach the full debug log to all bug reports. + +``` diff --git a/tests/integration/_cases/code_mappings/code-mappings-upload.trycmd b/tests/integration/_cases/code_mappings/code-mappings-upload.trycmd index 72c35d9d19..779db9b650 100644 --- a/tests/integration/_cases/code_mappings/code-mappings-upload.trycmd +++ b/tests/integration/_cases/code_mappings/code-mappings-upload.trycmd @@ -2,13 +2,6 @@ $ 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/_responses/code_mappings/post-bulk-partial-error.json b/tests/integration/_responses/code_mappings/post-bulk-partial-error.json new file mode 100644 index 0000000000..f44f466634 --- /dev/null +++ b/tests/integration/_responses/code_mappings/post-bulk-partial-error.json @@ -0,0 +1,9 @@ +{ + "created": 1, + "updated": 0, + "errors": 1, + "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": "error", "detail": "duplicate mapping"} + ] +} diff --git a/tests/integration/code_mappings/upload.rs b/tests/integration/code_mappings/upload.rs index 861ed199a9..3580c45837 100644 --- a/tests/integration/code_mappings/upload.rs +++ b/tests/integration/code_mappings/upload.rs @@ -1,4 +1,6 @@ -use crate::integration::{MockEndpointBuilder, TestManager}; +use std::sync::atomic::{AtomicU16, Ordering}; + +use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager}; #[test] fn command_code_mappings_upload() { @@ -10,3 +12,72 @@ fn command_code_mappings_upload() { .register_trycmd_test("code_mappings/code-mappings-upload.trycmd") .with_default_token(); } + +#[test] +fn command_code_mappings_upload_partial_error() { + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/code-mappings/bulk/") + .with_response_file("code_mappings/post-bulk-partial-error.json"), + ) + .register_trycmd_test("code_mappings/code-mappings-upload-partial-error.trycmd") + .with_default_token(); +} + +#[test] +fn command_code_mappings_upload_batches() { + // Generate a fixture with 301 mappings to force 2 batches (300 + 1). + let mut mappings = Vec::with_capacity(301); + for i in 0..301 { + mappings.push(serde_json::json!({ + "stackRoot": format!("com/example/m{i}"), + "sourceRoot": format!("modules/m{i}/src/main/java/com/example/m{i}"), + })); + } + let fixture = tempfile::NamedTempFile::new().expect("failed to create temp file"); + serde_json::to_writer(&fixture, &mappings).expect("failed to write fixture"); + + let call_count = AtomicU16::new(0); + + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/code-mappings/bulk/") + .expect(2) + .with_response_fn(move |_request| { + let n = call_count.fetch_add(1, Ordering::Relaxed); + // Return appropriate counts per batch + let (created, mapping_count) = if n == 0 { (300, 300) } else { (1, 1) }; + let mut batch_mappings = Vec::new(); + for i in 0..mapping_count { + let idx = n as usize * 300 + i; + batch_mappings.push(serde_json::json!({ + "stackRoot": format!("com/example/m{idx}"), + "sourceRoot": format!("modules/m{idx}/src/main/java/com/example/m{idx}"), + "status": "created", + })); + } + serde_json::to_vec(&serde_json::json!({ + "created": created, + "updated": 0, + "errors": 0, + "mappings": batch_mappings, + })) + .expect("failed to serialize response") + }), + ) + .assert_cmd([ + "code-mappings", + "upload", + fixture.path().to_str().expect("valid utf-8 path"), + "--org", + "wat-org", + "--project", + "wat-project", + "--repo", + "owner/repo", + "--default-branch", + "main", + ]) + .with_default_token() + .run_and_assert(AssertCommand::Success); +}