Skip to content

Commit 2c35d43

Browse files
romtsnclaude
andcommitted
feat(cli): Add batch splitting for code-mappings upload
Split large mapping files into batches of 300 (the backend limit) per request. Each batch is sent sequentially with progress reporting. Results are merged into a single summary table. Batch-level HTTP failures are captured without aborting remaining batches, and the final exit code reflects any errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5720ec2 commit 2c35d43

File tree

3 files changed

+145
-44
lines changed

3 files changed

+145
-44
lines changed

src/api/data_types/code_mappings.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub struct BulkCodeMappingsRequest {
1111
pub mappings: Vec<BulkCodeMapping>,
1212
}
1313

14-
#[derive(Debug, Deserialize, Serialize)]
14+
#[derive(Clone, Debug, Deserialize, Serialize)]
1515
#[serde(rename_all = "camelCase")]
1616
pub struct BulkCodeMapping {
1717
pub stack_root: String,

src/commands/code_mappings/upload.rs

Lines changed: 81 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,16 @@ use anyhow::{bail, Context as _, Result};
44
use clap::{Arg, ArgMatches, Command};
55
use log::debug;
66

7-
use crate::api::{Api, BulkCodeMapping, BulkCodeMappingsRequest};
7+
use crate::api::{
8+
Api, BulkCodeMapping, BulkCodeMappingResult, BulkCodeMappingsRequest, BulkCodeMappingsResponse,
9+
};
810
use crate::config::Config;
911
use crate::utils::formatting::Table;
1012
use crate::utils::vcs;
1113

14+
/// Maximum number of mappings the backend accepts per request.
15+
const BATCH_SIZE: usize = 300;
16+
1217
pub fn make_command(command: Command) -> Command {
1318
command
1419
.about("Upload code mappings for a project from a JSON file.")
@@ -118,56 +123,95 @@ pub fn execute(matches: &ArgMatches) -> Result<()> {
118123
};
119124

120125
let mapping_count = mappings.len();
121-
let request = BulkCodeMappingsRequest {
122-
project,
123-
repository: repo_name,
124-
default_branch,
125-
mappings,
126-
};
126+
let batches: Vec<&[BulkCodeMapping]> = mappings.chunks(BATCH_SIZE).collect();
127+
let total_batches = batches.len();
127128

128129
println!("Uploading {mapping_count} code mapping(s)...");
129130

130131
let api = Api::current();
131-
let response = api
132-
.authenticated()?
133-
.bulk_upload_code_mappings(&org, &request)?;
132+
let authenticated = api.authenticated()?;
134133

135-
// Display results
136-
let mut table = Table::new();
137-
table
138-
.title_row()
139-
.add("Stack Root")
140-
.add("Source Root")
141-
.add("Status");
142-
143-
for result in &response.mappings {
144-
let status = match result.status.as_str() {
145-
"error" => match &result.detail {
146-
Some(detail) => format!("error: {detail}"),
147-
None => "error".to_owned(),
148-
},
149-
s => s.to_owned(),
134+
let mut merged = MergedResponse::default();
135+
136+
for (i, batch) in batches.iter().enumerate() {
137+
if total_batches > 1 {
138+
println!("Sending batch {}/{total_batches}...", i + 1);
139+
}
140+
let request = BulkCodeMappingsRequest {
141+
project: project.clone(),
142+
repository: repo_name.clone(),
143+
default_branch: default_branch.clone(),
144+
mappings: batch.to_vec(),
150145
};
146+
match authenticated.bulk_upload_code_mappings(&org, &request) {
147+
Ok(response) => merged.add(response),
148+
Err(err) => {
149+
merged
150+
.batch_errors
151+
.push(format!("Batch {}/{total_batches} failed: {err}", i + 1));
152+
}
153+
}
154+
}
155+
156+
// Display results
157+
if !merged.mappings.is_empty() {
158+
let mut table = Table::new();
151159
table
152-
.add_row()
153-
.add(&result.stack_root)
154-
.add(&result.source_root)
155-
.add(&status);
160+
.title_row()
161+
.add("Stack Root")
162+
.add("Source Root")
163+
.add("Status");
164+
165+
for result in &merged.mappings {
166+
let status = match result.status.as_str() {
167+
"error" => match &result.detail {
168+
Some(detail) => format!("error: {detail}"),
169+
None => "error".to_owned(),
170+
},
171+
s => s.to_owned(),
172+
};
173+
table
174+
.add_row()
175+
.add(&result.stack_root)
176+
.add(&result.source_root)
177+
.add(&status);
178+
}
179+
180+
table.print();
181+
println!();
182+
}
183+
184+
for err in &merged.batch_errors {
185+
println!("{err}");
156186
}
157187

158-
table.print();
159-
println!();
160188
println!(
161189
"Created: {}, Updated: {}, Errors: {}",
162-
response.created, response.updated, response.errors
190+
merged.created, merged.updated, merged.errors
163191
);
164192

165-
if response.errors > 0 {
166-
bail!(
167-
"{} mapping(s) failed to upload. See errors above.",
168-
response.errors
169-
);
193+
if merged.errors > 0 || !merged.batch_errors.is_empty() {
194+
let total_errors = merged.errors + merged.batch_errors.len() as u64;
195+
bail!("{total_errors} error(s) during upload. See details above.");
170196
}
171197

172198
Ok(())
173199
}
200+
201+
#[derive(Default)]
202+
struct MergedResponse {
203+
created: u64,
204+
updated: u64,
205+
errors: u64,
206+
mappings: Vec<BulkCodeMappingResult>,
207+
batch_errors: Vec<String>,
208+
}
209+
210+
impl MergedResponse {
211+
fn add(&mut self, response: BulkCodeMappingsResponse) {
212+
self.created += response.created;
213+
self.updated += response.updated;
214+
self.errors += response.errors;
215+
self.mappings.extend(response.mappings);
216+
}
217+
}
Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,72 @@
1-
use crate::integration::{MockEndpointBuilder, TestManager};
1+
use std::sync::atomic::{AtomicU16, Ordering};
2+
3+
use crate::integration::{AssertCommand, MockEndpointBuilder, TestManager};
24

35
#[test]
46
fn command_code_mappings_upload() {
57
TestManager::new()
68
.mock_endpoint(
7-
MockEndpointBuilder::new(
8-
"POST",
9-
"/api/0/organizations/wat-org/code-mappings/bulk/",
10-
)
11-
.with_response_file("code_mappings/post-bulk.json"),
9+
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/code-mappings/bulk/")
10+
.with_response_file("code_mappings/post-bulk.json"),
1211
)
1312
.register_trycmd_test("code_mappings/code-mappings-upload.trycmd")
1413
.with_default_token();
1514
}
15+
16+
#[test]
17+
fn command_code_mappings_upload_batches() {
18+
// Generate a fixture with 301 mappings to force 2 batches (300 + 1).
19+
let mut mappings = Vec::with_capacity(301);
20+
for i in 0..301 {
21+
mappings.push(serde_json::json!({
22+
"stackRoot": format!("com/example/m{i}"),
23+
"sourceRoot": format!("modules/m{i}/src/main/java/com/example/m{i}"),
24+
}));
25+
}
26+
let fixture = tempfile::NamedTempFile::new().expect("failed to create temp file");
27+
serde_json::to_writer(&fixture, &mappings).expect("failed to write fixture");
28+
29+
let call_count = AtomicU16::new(0);
30+
31+
TestManager::new()
32+
.mock_endpoint(
33+
MockEndpointBuilder::new("POST", "/api/0/organizations/wat-org/code-mappings/bulk/")
34+
.expect(2)
35+
.with_response_fn(move |_request| {
36+
let n = call_count.fetch_add(1, Ordering::Relaxed);
37+
// Return appropriate counts per batch
38+
let (created, mapping_count) = if n == 0 { (300, 300) } else { (1, 1) };
39+
let mut batch_mappings = Vec::new();
40+
for i in 0..mapping_count {
41+
let idx = n as usize * 300 + i;
42+
batch_mappings.push(serde_json::json!({
43+
"stackRoot": format!("com/example/m{idx}"),
44+
"sourceRoot": format!("modules/m{idx}/src/main/java/com/example/m{idx}"),
45+
"status": "created",
46+
}));
47+
}
48+
serde_json::to_vec(&serde_json::json!({
49+
"created": created,
50+
"updated": 0,
51+
"errors": 0,
52+
"mappings": batch_mappings,
53+
}))
54+
.expect("failed to serialize response")
55+
}),
56+
)
57+
.assert_cmd([
58+
"code-mappings",
59+
"upload",
60+
fixture.path().to_str().expect("valid utf-8 path"),
61+
"--org",
62+
"wat-org",
63+
"--project",
64+
"wat-project",
65+
"--repo",
66+
"owner/repo",
67+
"--default-branch",
68+
"main",
69+
])
70+
.with_default_token()
71+
.run_and_assert(AssertCommand::Success);
72+
}

0 commit comments

Comments
 (0)