feat(cli): add testflight build-localizations for "What to Test" (#37)

* feat(cli): add testflight build-localizations for "What to Test" The aso crate already has BetaBuildLocalizationsApi (list, create and update a per-locale "What to Test" note on a build), but the CLI never exposed it, so setting the notes testers see meant writing Rust against the library. Add "asc testflight build-localizations" with list <build-id>, create <build-id> --locale <l> --whats-new <text>, and update <id> --whats-new <text>. The two writes honour --dry-run and print the body they would send. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * fix(cli): reject over-length --whats-new before sending `asc testflight build-localizations create` and `update` sent `--whats-new` to App Store Connect whatever the length, so a note past the 4000-character cap came back as an opaque 400. Validate the length locally — before the dry-run print, so `--dry-run` catches it too — and name the flag in the message. Counts characters, not bytes, to match ASC's semantics. Co-Authored-By: OndeCode v1.0.3-acp <noreply@ondeinference.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: OndeCode v1.0.3-acp <noreply@ondeinference.com>

Seto Elkahfi committed Oct 13, 2026 at 19:24 UTC 0d4f24f5ebc969b0e0f3da8af46a1cfaeb4e89e3
1 file changed +133
crates/cli/src/main.rs
+133
@@ -185,6 +185,43 @@ enum TestflightCommand {
185 #[command(subcommand)]
186 command: BetaAppReviewSubmissionsCommand,
187 },
188 + /// Manage the per-locale "What to Test" notes testers see on a build.
189 + BuildLocalizations {
190 + #[command(subcommand)]
191 + command: BetaBuildLocalizationsCommand,
192 + },
193 +}
194 +
195 +#[derive(Subcommand)]
196 +enum BetaBuildLocalizationsCommand {
197 + /// `GET /v1/betaBuildLocalizations?filter[build]={build_id}` — every
198 + /// locale's note for a build (find the build with `apps builds`).
199 + List {
200 + /// The ASC build ID.
201 + build_id: String,
202 + },
203 + /// `POST /v1/betaBuildLocalizations` — add a locale's note to a build.
204 + /// A build holds one note per locale; use `update` if the locale already
205 + /// has one.
206 + Create {
207 + /// The ASC build ID.
208 + build_id: String,
209 + /// BCP 47 locale tag, e.g. `en-US` or `id`.
210 + #[arg(long)]
211 + locale: String,
212 + /// The "What to Test" text (up to 4000 characters).
213 + #[arg(long)]
214 + whats_new: String,
215 + },
216 + /// `PATCH /v1/betaBuildLocalizations/{id}` — replace the text of an
217 + /// existing note (the id comes from `list`).
218 + Update {
219 + /// The ASC beta build localization ID.
220 + id: String,
221 + /// The "What to Test" text (up to 4000 characters).
222 + #[arg(long)]
223 + whats_new: String,
224 + },
225 }
226
227 #[derive(Subcommand)]
@@ -880,6 +917,29 @@ fn dry_run_guard(dry_run: bool, value: &impl serde::Serialize) -> Result<bool> {
917 Ok(dry_run)
918 }
919
920 +/// Maximum length App Store Connect accepts for a "What to Test" note
921 +/// (`whatsNew` on `betaBuildLocalizations`). Apple's answer for anything
922 +/// longer is an opaque 400, so the CLI rejects locally and names the
923 +/// flag in the message.
924 +const WHATS_NEW_MAX_LEN: usize = 4000;
925 +
926 +/// Validate a `--whats-new` text before sending it. App Store Connect caps
927 +/// the field at [`WHATS_NEW_MAX_LEN`] characters and answers with an opaque
928 +/// error otherwise, so the length is checked here where the message can
929 +/// name the offending flag. Shared by `build-localizations create` and
930 +/// `update` because both write the same attribute, and called before the
931 +/// dry-run print so `--dry-run` catches the limit too.
932 +fn validate_whats_new(whats_new: &str) -> Result<()> {
933 + let len = whats_new.chars().count();
934 + if len > WHATS_NEW_MAX_LEN {
935 + anyhow::bail!(
936 + "--whats-new is {len} characters; App Store Connect accepts at most \
937 + {WHATS_NEW_MAX_LEN}. Shorten the note and try again."
938 + );
939 + }
940 + Ok(())
941 +}
942 +
943 /// Convert Apple's gzip-compressed TSV report to JSON without normalizing
944 /// columns: report layouts vary by report type and Apple can add columns.
945 fn sales_report_as_json(gzip: &[u8]) -> Result<Vec<serde_json::Value>> {
@@ -1156,6 +1216,40 @@ async fn run_testflight(client: &Client, command: TestflightCommand, dry_run: bo
1216 print_json(&submission)
1217 }
1218 },
1219 + TestflightCommand::BuildLocalizations { command } => match command {
1220 + BetaBuildLocalizationsCommand::List { build_id } => {
1221 + print_json(&client.list_beta_build_localizations(&build_id).await?)
1222 + }
1223 + BetaBuildLocalizationsCommand::Create {
1224 + build_id,
1225 + locale,
1226 + whats_new,
1227 + } => {
1228 + validate_whats_new(&whats_new)?;
1229 + if dry_run {
1230 + println!(
1231 + "[dry-run] would create the {locale} \"What to Test\" note on build {build_id}:"
1232 + );
1233 + return print_json(&serde_json::json!({ "whatsNew": whats_new }));
1234 + }
1235 + let created = client
1236 + .create_beta_build_localization(&build_id, &locale, Some(&whats_new))
1237 + .await?;
1238 + print_json(&created)
1239 + }
1240 + BetaBuildLocalizationsCommand::Update { id, whats_new } => {
1241 + validate_whats_new(&whats_new)?;
1242 + if dry_run {
1243 + println!("[dry-run] would update \"What to Test\" note {id}:");
1244 + return print_json(&serde_json::json!({ "whatsNew": whats_new }));
1245 + }
1246 + print_json(
1247 + &client
1248 + .update_beta_build_localization(&id, &whats_new)
1249 + .await?,
1250 + )
1251 + }
1252 + },
1253 }
1254 }
1255
@@ -1954,6 +2048,8 @@ fn base64_decode(input: &str) -> Result<Vec<u8>> {
2048 #[cfg(test)]
2049 mod tests {
2050 use super::sales_report_as_json;
2051 + use super::validate_whats_new;
2052 + use super::WHATS_NEW_MAX_LEN;
2053 use flate2::write::GzEncoder;
2054 use flate2::Compression;
2055 use std::io::Write;
@@ -2019,4 +2115,41 @@ mod tests {
2115 })]
2116 );
2117 }
2118 +
2119 + #[test]
2120 + fn whats_new_accepts_empty_and_up_to_the_limit() {
2121 + // Apple accepts the field at create time as null/empty, and a
2122 + // note exactly at the cap is the boundary that must pass.
2123 + assert!(validate_whats_new("").is_ok());
2124 + let at_limit = "a".repeat(WHATS_NEW_MAX_LEN);
2125 + assert!(validate_whats_new(&at_limit).is_ok(), "at-limit must pass");
2126 + }
2127 +
2128 + #[test]
2129 + fn whats_new_rejects_one_char_over_the_limit() {
2130 + let too_long = "a".repeat(WHATS_NEW_MAX_LEN + 1);
2131 + let error = validate_whats_new(&too_long).expect_err("over-limit must be rejected");
2132 + let message = error.to_string();
2133 + assert!(
2134 + message.contains("--whats-new"),
2135 + "should name the flag: {message}"
2136 + );
2137 + assert!(
2138 + message.contains(&WHATS_NEW_MAX_LEN.to_string()),
2139 + "should quote the limit: {message}"
2140 + );
2141 + }
2142 +
2143 + #[test]
2144 + fn whats_new_counts_characters_not_bytes() {
2145 + // "あ" is 3 bytes in UTF-8 but 1 character, so 4000 of them fits
2146 + // the character-based limit even though it would blow a byte
2147 + // budget. Make sure the validator agrees with ASC's character
2148 + // semantics.
2149 + let multibyte = "あ".repeat(WHATS_NEW_MAX_LEN);
2150 + assert!(
2151 + validate_whats_new(&multibyte).is_ok(),
2152 + "char count, not byte count"
2153 + );
2154 + }
2155 }