Add review-submissions list, submit and cancel

asc can now send an App Store Version to App Review. Submitting takes three requests: a review submission for the app and platform, an item pointing at the version, then a PATCH setting submitted. App Store Connect keeps one open (READY_FOR_REVIEW) submission per app and platform, so submit reuses an open one instead of always creating. The work is split into a read-only plan and a submit that carries it out, so --dry-run prints exactly what would happen. A version that is already WAITING_FOR_REVIEW, IN_REVIEW or live is refused before any write. The first live dry run, against smbcloud-mailx visionOS 1.1.0, printed a plan for a version that had been submitted in the web UI minutes earlier, which is why the guard exists. Submitting stays out of MCP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 09:19 UTC 571a0104ec7521d4d25431f5725e8e2f18ee96cf
7 files changed +660 -1
CLAUDE.md
+21
@@ -81,3 +81,24 @@ The CLI needs an ASC API key: `--key-id`/`ASC_API_KEY`,
81 `--private-key-path`/`ASC_PRIVATE_KEY_PATH` (defaults to
82 `~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8`). `--mcp` resolves
83 credentials per tool call, so the server can list tools unconfigured.
84 +
85 +## Review submissions (CLI)
86 +
87 +```sh
88 +# GET /v1/reviewSubmissions?filter[app]={id}
89 +asc review-submissions list <APP_ID> [--platform <...>] [--state READY_FOR_REVIEW]
90 +
91 +# Reuse the platform's open (READY_FOR_REVIEW) submission or create one, add
92 +# the version as an item, then PATCH submitted: true. --dry-run reads and
93 +# prints the plan. Refuses a version that is already WAITING_FOR_REVIEW,
94 +# IN_REVIEW or live, before any write.
95 +asc review-submissions submit <APP_ID> --version-id <APP_STORE_VERSION_ID>
96 +
97 +# PATCH canceled: true, for a submission App Review hasn't picked up.
98 +asc review-submissions cancel <SUBMISSION_ID>
99 +```
100 +
101 +The API is `ReviewSubmissionsApi` in `crates/aso/src/review_submission.rs`;
102 +the plan/submit orchestration is `crates/frontend/src/review.rs`. Submitting is
103 +CLI-only on purpose: it sends a build to App Review, which an MCP tool call
104 +shouldn't do on a model's say-so.
README.md
+1 -1
@@ -8,7 +8,7 @@ from the other:
8 | Crate | What it is |
9 | --- | --- |
10 | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
11 -| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, builds, pre-release versions, bundle IDs, localizations, screenshots |
11 +| `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, builds, pre-release versions, review submissions, bundle IDs, localizations, screenshots |
12 | `smbcloud-ascapi-pricing` | Pricing: app price schedules, and the per-territory prices joined with their price points and currencies |
13 | `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
14 | `smbcloud-ascapi-frontend` | Operations both surfaces share, so the CLI and the MCP server agree by construction, plus the CLI's `xcrun altool` upload |
crates/aso/src/lib.rs
+2
@@ -29,6 +29,7 @@ pub mod app_store_version;
29 pub mod app_store_version_localization;
30 pub mod build;
31 pub mod bundle_id;
32 +pub mod review_submission;
33
34 /// Every extension trait in this crate, for one glob import.
35 pub mod prelude {
@@ -41,4 +42,5 @@ pub mod prelude {
42 pub use crate::app_store_version_localization::AppStoreVersionLocalizationsApi;
43 pub use crate::build::BuildsApi;
44 pub use crate::bundle_id::BundleIdsApi;
45 + pub use crate::review_submission::ReviewSubmissionsApi;
46 }
crates/aso/src/review_submission.rs new
+352
@@ -0,0 +1,352 @@
1 +//! `ReviewSubmission` — how an App Store Version reaches App Review.
2 +//!
3 +//! Submitting takes three requests: a submission for the app and platform,
4 +//! an item on it pointing at the version, then a PATCH setting `submitted`.
5 +//! App Store Connect keeps at most one unsubmitted (`READY_FOR_REVIEW`)
6 +//! submission per app and platform, and creating a second one fails, so a
7 +//! caller should reuse an open one rather than always creating. The
8 +//! frontend crate's `review` module does that orchestration.
9 +
10 +use async_trait::async_trait;
11 +use reqwest::Method;
12 +use serde::{Deserialize, Serialize};
13 +use smbcloud_ascapi_core::jsonapi::{
14 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
15 +};
16 +use smbcloud_ascapi_core::Client;
17 +use smbcloud_ascapi_core::Result;
18 +
19 +use crate::app_store_version::Platform;
20 +
21 +pub const RESOURCE_TYPE: &str = "reviewSubmissions";
22 +pub const ITEM_RESOURCE_TYPE: &str = "reviewSubmissionItems";
23 +
24 +/// A submission's `state`: `READY_FOR_REVIEW` (open, not yet submitted),
25 +/// `WAITING_FOR_REVIEW`, `IN_REVIEW`, `UNRESOLVED_ISSUES`, `CANCELING`,
26 +/// `COMPLETING`, or `COMPLETE`.
27 +pub const STATE_READY_FOR_REVIEW: &str = "READY_FOR_REVIEW";
28 +
29 +#[derive(Debug, Clone, Serialize, Deserialize)]
30 +#[serde(rename_all = "camelCase")]
31 +pub struct ReviewSubmissionAttributes {
32 + pub platform: Option<Platform>,
33 + pub state: Option<String>,
34 + pub submitted_date: Option<String>,
35 +}
36 +
37 +pub type ReviewSubmission = Resource<ReviewSubmissionAttributes>;
38 +
39 +#[derive(Debug, Clone, Serialize, Deserialize)]
40 +#[serde(rename_all = "camelCase")]
41 +pub struct ReviewSubmissionItemAttributes {
42 + pub state: Option<String>,
43 +}
44 +
45 +pub type ReviewSubmissionItem = Resource<ReviewSubmissionItemAttributes>;
46 +
47 +#[derive(Debug, Clone, Serialize)]
48 +struct CreateSubmissionAttributes {
49 + platform: Platform,
50 +}
51 +
52 +#[derive(Debug, Clone, Serialize)]
53 +struct CreateSubmissionRelationships {
54 + app: ToOne,
55 +}
56 +
57 +/// An item carries only relationships, so it gets its own body type rather
58 +/// than [`CreateBody`] with an empty `attributes` object.
59 +#[derive(Debug, Clone, Serialize)]
60 +struct CreateItemBody {
61 + data: CreateItemData,
62 +}
63 +
64 +#[derive(Debug, Clone, Serialize)]
65 +struct CreateItemData {
66 + #[serde(rename = "type")]
67 + resource_type: &'static str,
68 + relationships: CreateItemRelationships,
69 +}
70 +
71 +#[derive(Debug, Clone, Serialize)]
72 +#[serde(rename_all = "camelCase")]
73 +struct CreateItemRelationships {
74 + review_submission: ToOne,
75 + app_store_version: ToOne,
76 +}
77 +
78 +#[derive(Debug, Clone, Serialize)]
79 +struct SubmittedAttributes {
80 + submitted: bool,
81 +}
82 +
83 +#[derive(Debug, Clone, Serialize)]
84 +struct CanceledAttributes {
85 + canceled: bool,
86 +}
87 +
88 +/// Review submissions for an app.
89 +///
90 +/// An extension trait rather than inherent methods, because `Client`
91 +/// lives in the core crate and Rust only allows inherent impls in the
92 +/// crate that defines the type. Import it, or the crate's `prelude`,
93 +/// to call these on a `Client`.
94 +#[async_trait]
95 +pub trait ReviewSubmissionsApi {
96 + /// `GET /v1/reviewSubmissions?filter[app]={app_id}`, optionally narrowed
97 + /// to one platform and to submissions in one of `states`.
98 + async fn list_review_submissions(
99 + &self,
100 + app_id: &str,
101 + platform: Option<Platform>,
102 + states: &[&str],
103 + ) -> Result<Vec<ReviewSubmission>>;
104 +
105 + /// `POST /v1/reviewSubmissions` — an empty, unsubmitted submission.
106 + async fn create_review_submission(
107 + &self,
108 + app_id: &str,
109 + platform: Platform,
110 + ) -> Result<ReviewSubmission>;
111 +
112 + /// `GET /v1/reviewSubmissions/{id}/items`.
113 + async fn list_review_submission_items(
114 + &self,
115 + submission_id: &str,
116 + ) -> Result<Vec<ReviewSubmissionItem>>;
117 +
118 + /// `POST /v1/reviewSubmissionItems` — put an App Store Version on a
119 + /// submission.
120 + async fn add_app_store_version_to_review_submission(
121 + &self,
122 + submission_id: &str,
123 + app_store_version_id: &str,
124 + ) -> Result<ReviewSubmissionItem>;
125 +
126 + /// `PATCH /v1/reviewSubmissions/{id}` with `submitted: true` — sends it
127 + /// to App Review.
128 + async fn submit_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission>;
129 +
130 + /// `PATCH /v1/reviewSubmissions/{id}` with `canceled: true` — withdraws a
131 + /// submission that hasn't been reviewed yet.
132 + async fn cancel_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission>;
133 +}
134 +
135 +#[async_trait]
136 +impl ReviewSubmissionsApi for Client {
137 + async fn list_review_submissions(
138 + &self,
139 + app_id: &str,
140 + platform: Option<Platform>,
141 + states: &[&str],
142 + ) -> Result<Vec<ReviewSubmission>> {
143 + let states = states.join(",");
144 + let query = list_query(app_id, platform, &states);
145 + self.list_all::<ReviewSubmissionAttributes>("/v1/reviewSubmissions", &query)
146 + .await
147 + }
148 +
149 + async fn create_review_submission(
150 + &self,
151 + app_id: &str,
152 + platform: Platform,
153 + ) -> Result<ReviewSubmission> {
154 + let body = CreateBody {
155 + data: CreateData {
156 + resource_type: RESOURCE_TYPE,
157 + attributes: CreateSubmissionAttributes { platform },
158 + relationships: Some(CreateSubmissionRelationships {
159 + app: to_one(crate::app::RESOURCE_TYPE, app_id),
160 + }),
161 + },
162 + };
163 + let doc: Document<ReviewSubmissionAttributes> = self
164 + .request(Method::POST, "/v1/reviewSubmissions", &[], Some(&body))
165 + .await?;
166 + Ok(doc.data)
167 + }
168 +
169 + async fn list_review_submission_items(
170 + &self,
171 + submission_id: &str,
172 + ) -> Result<Vec<ReviewSubmissionItem>> {
173 + let path = format!("/v1/reviewSubmissions/{submission_id}/items");
174 + self.list_all::<ReviewSubmissionItemAttributes>(&path, &[])
175 + .await
176 + }
177 +
178 + async fn add_app_store_version_to_review_submission(
179 + &self,
180 + submission_id: &str,
181 + app_store_version_id: &str,
182 + ) -> Result<ReviewSubmissionItem> {
183 + let body = item_body(submission_id, app_store_version_id);
184 + let doc: Document<ReviewSubmissionItemAttributes> = self
185 + .request(Method::POST, "/v1/reviewSubmissionItems", &[], Some(&body))
186 + .await?;
187 + Ok(doc.data)
188 + }
189 +
190 + async fn submit_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission> {
191 + patch_submission(self, submission_id, SubmittedAttributes { submitted: true }).await
192 + }
193 +
194 + async fn cancel_review_submission(&self, submission_id: &str) -> Result<ReviewSubmission> {
195 + patch_submission(self, submission_id, CanceledAttributes { canceled: true }).await
196 + }
197 +}
198 +
199 +async fn patch_submission<A: Serialize + Send + Sync>(
200 + client: &Client,
201 + id: &str,
202 + attributes: A,
203 +) -> Result<ReviewSubmission> {
204 + let path = format!("/v1/reviewSubmissions/{id}");
205 + let body = UpdateBody {
206 + data: UpdateData {
207 + resource_type: RESOURCE_TYPE,
208 + id: id.to_string(),
209 + attributes,
210 + },
211 + };
212 + let doc: Document<ReviewSubmissionAttributes> = client
213 + .request(Method::PATCH, &path, &[], Some(&body))
214 + .await?;
215 + Ok(doc.data)
216 +}
217 +
218 +fn to_one(resource_type: &'static str, id: &str) -> ToOne {
219 + ToOne {
220 + data: ResourceId {
221 + resource_type,
222 + id: id.to_string(),
223 + },
224 + }
225 +}
226 +
227 +fn list_query<'a>(
228 + app_id: &'a str,
229 + platform: Option<Platform>,
230 + states: &'a str,
231 +) -> Vec<(&'static str, &'a str)> {
232 + let mut query = vec![("filter[app]", app_id), ("limit", "200")];
233 + if let Some(platform) = platform {
234 + query.push(("filter[platform]", platform.as_query_value()));
235 + }
236 + if !states.is_empty() {
237 + query.push(("filter[state]", states));
238 + }
239 + query
240 +}
241 +
242 +fn item_body(submission_id: &str, app_store_version_id: &str) -> CreateItemBody {
243 + CreateItemBody {
244 + data: CreateItemData {
245 + resource_type: ITEM_RESOURCE_TYPE,
246 + relationships: CreateItemRelationships {
247 + review_submission: to_one(RESOURCE_TYPE, submission_id),
248 + app_store_version: to_one(
249 + crate::app_store_version::RESOURCE_TYPE,
250 + app_store_version_id,
251 + ),
252 + },
253 + },
254 + }
255 +}
256 +
257 +#[cfg(test)]
258 +mod tests {
259 + use super::*;
260 + use serde_json::json;
261 +
262 + #[test]
263 + fn list_query_pins_the_filter_keys() {
264 + assert_eq!(
265 + list_query("6766108771", Some(Platform::VisionOs), "READY_FOR_REVIEW"),
266 + vec![
267 + ("filter[app]", "6766108771"),
268 + ("limit", "200"),
269 + ("filter[platform]", "VISION_OS"),
270 + ("filter[state]", "READY_FOR_REVIEW"),
271 + ]
272 + );
273 + assert_eq!(
274 + list_query("6766108771", None, ""),
275 + vec![("filter[app]", "6766108771"), ("limit", "200")]
276 + );
277 + }
278 +
279 + #[test]
280 + fn create_body_names_the_platform_and_app() {
281 + let body = CreateBody {
282 + data: CreateData {
283 + resource_type: RESOURCE_TYPE,
284 + attributes: CreateSubmissionAttributes {
285 + platform: Platform::VisionOs,
286 + },
287 + relationships: Some(CreateSubmissionRelationships {
288 + app: to_one(crate::app::RESOURCE_TYPE, "6766108771"),
289 + }),
290 + },
291 + };
292 + assert_eq!(
293 + serde_json::to_value(body).unwrap(),
294 + json!({ "data": {
295 + "type": "reviewSubmissions",
296 + "attributes": { "platform": "VISION_OS" },
297 + "relationships": { "app": { "data": { "type": "apps", "id": "6766108771" } } }
298 + }})
299 + );
300 + }
301 +
302 + /// App Store Connect wants camelCase relationship names and no
303 + /// `attributes` key on an item.
304 + #[test]
305 + fn item_body_has_relationships_only() {
306 + assert_eq!(
307 + serde_json::to_value(item_body("SUB1", "VER1")).unwrap(),
308 + json!({ "data": {
309 + "type": "reviewSubmissionItems",
310 + "relationships": {
311 + "reviewSubmission": { "data": { "type": "reviewSubmissions", "id": "SUB1" } },
312 + "appStoreVersion": { "data": { "type": "appStoreVersions", "id": "VER1" } }
313 + }
314 + }})
315 + );
316 + }
317 +
318 + #[test]
319 + fn submit_and_cancel_patch_one_flag() {
320 + let submit = UpdateBody {
321 + data: UpdateData {
322 + resource_type: RESOURCE_TYPE,
323 + id: "SUB1".to_string(),
324 + attributes: SubmittedAttributes { submitted: true },
325 + },
326 + };
327 + assert_eq!(
328 + serde_json::to_value(submit).unwrap(),
329 + json!({ "data": { "type": "reviewSubmissions", "id": "SUB1",
330 + "attributes": { "submitted": true } } })
331 + );
332 + assert_eq!(
333 + serde_json::to_value(CanceledAttributes { canceled: true }).unwrap(),
334 + json!({ "canceled": true })
335 + );
336 + }
337 +
338 + #[test]
339 + fn deserializes_a_submission() {
340 + let doc: Document<ReviewSubmissionAttributes> = serde_json::from_value(json!({
341 + "data": { "id": "SUB1", "type": "reviewSubmissions",
342 + "attributes": { "platform": "VISION_OS", "state": "READY_FOR_REVIEW",
343 + "submittedDate": null } }
344 + }))
345 + .unwrap();
346 + assert_eq!(doc.data.attributes.platform, Some(Platform::VisionOs));
347 + assert_eq!(
348 + doc.data.attributes.state.as_deref(),
349 + Some(STATE_READY_FOR_REVIEW)
350 + );
351 + }
352 +}
crates/cli/src/main.rs
+80
@@ -15,6 +15,7 @@ use smbcloud_ascapi_aso::build::BuildFilter;
15 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
16 use smbcloud_ascapi_aso::prelude::*;
17 use smbcloud_ascapi_core::{ApiKey, Client};
18 +use smbcloud_ascapi_frontend::review;
19 use smbcloud_ascapi_frontend::upload::{self, upload_package, AltoolAuth};
20 use smbcloud_ascapi_pricing::app_price::PriceKind;
21 use smbcloud_ascapi_pricing::prelude::*;
@@ -85,6 +86,12 @@ enum Command {
86 #[command(subcommand)]
87 command: AppStoreVersionsCommand,
88 },
89 + /// Send an App Store Version to App Review, see what's waiting, or
90 + /// withdraw a submission that hasn't been reviewed yet.
91 + ReviewSubmissions {
92 + #[command(subcommand)]
93 + command: ReviewSubmissionsCommand,
94 + },
95 /// Localized name/subtitle for an app's AppInfo.
96 AppInfoLocalizations {
97 #[command(subcommand)]
@@ -478,6 +485,33 @@ enum AppStoreVersionsCommand {
485 },
486 }
487
488 +#[derive(Subcommand)]
489 +enum ReviewSubmissionsCommand {
490 + /// `GET /v1/reviewSubmissions?filter[app]={app_id}`.
491 + List {
492 + app_id: String,
493 + #[arg(long, value_enum)]
494 + platform: Option<CliPlatform>,
495 + /// Only submissions in this state, e.g. READY_FOR_REVIEW,
496 + /// WAITING_FOR_REVIEW, IN_REVIEW, UNRESOLVED_ISSUES, COMPLETE.
497 + #[arg(long)]
498 + state: Option<String>,
499 + },
500 + /// Submit an App Store Version for review: reuse the platform's open
501 + /// submission or create one, add the version to it, then submit.
502 + /// `--dry-run` reads everything and prints the plan without writing.
503 + Submit {
504 + app_id: String,
505 + /// The App Store Version to submit (see `app-store-versions list`).
506 + /// It needs a build attached (`app-store-versions set-build`).
507 + #[arg(long)]
508 + version_id: String,
509 + },
510 + /// `PATCH /v1/reviewSubmissions/{id}` with `canceled: true` — withdraw
511 + /// a submission before App Review picks it up.
512 + Cancel { id: String },
513 +}
514 +
515 #[derive(Subcommand)]
516 enum AppInfoLocalizationsCommand {
517 List {
@@ -811,6 +845,9 @@ async fn main() -> Result<()> {
845 Command::AppPrices { command } => run_app_prices(&client, command).await,
846 Command::Certificates { command } => run_certificates(&client, command, cli.dry_run).await,
847 Command::Profiles { command } => run_profiles(&client, command, cli.dry_run).await,
848 + Command::ReviewSubmissions { command } => {
849 + run_review_submissions(&client, command, cli.dry_run).await
850 + }
851 Command::SalesReports { command } => run_sales_reports(&client, command).await,
852 }
853 }
@@ -1002,6 +1039,49 @@ async fn run_app_store_versions(
1039 }
1040 }
1041
1042 +async fn run_review_submissions(
1043 + client: &Client,
1044 + command: ReviewSubmissionsCommand,
1045 + dry_run: bool,
1046 +) -> Result<()> {
1047 + match command {
1048 + ReviewSubmissionsCommand::List {
1049 + app_id,
1050 + platform,
1051 + state,
1052 + } => {
1053 + let states: Vec<&str> = state.as_deref().into_iter().collect();
1054 + let submissions = client
1055 + .list_review_submissions(&app_id, platform.map(Into::into), &states)
1056 + .await?;
1057 + print_json(&submissions)
1058 + }
1059 + ReviewSubmissionsCommand::Submit { app_id, version_id } => {
1060 + let plan = review::plan_submission(client, &app_id, &version_id)
1061 + .await
1062 + .map_err(anyhow::Error::msg)?;
1063 + if dry_run {
1064 + match &plan.blocked {
1065 + Some(reason) => println!("# dry run — would not submit: {reason}"),
1066 + None => println!("# dry run — would submit this plan for review:"),
1067 + }
1068 + return print_json(&plan);
1069 + }
1070 + let submitted = review::submit_for_review(client, plan)
1071 + .await
1072 + .map_err(anyhow::Error::msg)?;
1073 + print_json(&submitted)
1074 + }
1075 + ReviewSubmissionsCommand::Cancel { id } => {
1076 + if dry_run {
1077 + println!("# dry run — would PATCH /v1/reviewSubmissions/{id} canceled: true");
1078 + return Ok(());
1079 + }
1080 + print_json(&client.cancel_review_submission(&id).await?)
1081 + }
1082 + }
1083 +}
1084 +
1085 async fn run_app_info_localizations(
1086 client: &Client,
1087 command: AppInfoLocalizationsCommand,
crates/frontend/src/lib.rs
+2
@@ -24,6 +24,7 @@ pub mod certificates;
24 pub mod enums;
25 pub mod env;
26 pub mod profiles;
27 +pub mod review;
28 pub mod time;
29 pub mod upload;
30
@@ -33,5 +34,6 @@ pub use certificates::{
34 pub use enums::{bundle_id_platform_from_str, display_type_from_str, platform_from_str};
35 pub use env::api_key_from_env;
36 pub use profiles::{download_profile, profile_type_from_str, DownloadedProfile, ProfileSummary};
37 +pub use review::{plan_submission, submit_for_review, SubmissionPlan, SubmittedReview};
38 pub use time::{is_expired, now_iso8601};
39 pub use upload::{upload_package, AltoolAuth, UploadOutcome};
crates/frontend/src/review.rs new
+202
@@ -0,0 +1,202 @@
1 +//! Submitting an App Store Version for App Review.
2 +//!
3 +//! Split into a read-only [`plan_submission`] and a [`submit_for_review`]
4 +//! that carries the plan out, so `--dry-run` can show exactly what would
5 +//! happen (reuse an open submission or create one, add the version or not)
6 +//! without writing anything.
7 +
8 +use serde::Serialize;
9 +use smbcloud_ascapi_aso::app_store_version::Platform;
10 +use smbcloud_ascapi_aso::prelude::*;
11 +use smbcloud_ascapi_aso::review_submission::STATE_READY_FOR_REVIEW;
12 +use smbcloud_ascapi_core::Client;
13 +
14 +/// What [`submit_for_review`] will do, worked out from reads only.
15 +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
16 +pub struct SubmissionPlan {
17 + pub app_id: String,
18 + pub app_store_version_id: String,
19 + pub version_string: Option<String>,
20 + pub platform: String,
21 + pub app_version_state: Option<String>,
22 + /// An open (`READY_FOR_REVIEW`) submission for this app and platform to
23 + /// reuse. `None` means a new one gets created.
24 + pub existing_submission_id: Option<String>,
25 + /// Whether the open submission already carries an item. App Store
26 + /// Connect allows one item per version, so when this is true the item
27 + /// step is skipped.
28 + pub existing_submission_has_items: bool,
29 + /// Why this version can't be submitted, when its state says it already
30 + /// was (or is live). [`submit_for_review`] refuses a plan that has one.
31 + pub blocked: Option<String>,
32 +}
33 +
34 +/// The result of a submission, as reported to callers.
35 +#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
36 +pub struct SubmittedReview {
37 + pub submission_id: String,
38 + pub state: Option<String>,
39 + pub submitted_date: Option<String>,
40 + pub plan: SubmissionPlan,
41 +}
42 +
43 +pub async fn plan_submission(
44 + client: &Client,
45 + app_id: &str,
46 + app_store_version_id: &str,
47 +) -> Result<SubmissionPlan, String> {
48 + let version = client
49 + .get_app_store_version(app_store_version_id)
50 + .await
51 + .map_err(|error| error.to_string())?;
52 + let platform = version.attributes.platform;
53 +
54 + let open = client
55 + .list_review_submissions(app_id, Some(platform), &[STATE_READY_FOR_REVIEW])
56 + .await
57 + .map_err(|error| error.to_string())?;
58 + let existing = open.into_iter().next();
59 +
60 + let existing_submission_has_items = match &existing {
61 + Some(submission) => !client
62 + .list_review_submission_items(&submission.id)
63 + .await
64 + .map_err(|error| error.to_string())?
65 + .is_empty(),
66 + None => false,
67 + };
68 +
69 + let blocked = version
70 + .attributes
71 + .app_version_state
72 + .as_deref()
73 + .and_then(blocked_reason);
74 +
75 + Ok(SubmissionPlan {
76 + blocked,
77 + app_id: app_id.to_string(),
78 + app_store_version_id: app_store_version_id.to_string(),
79 + version_string: version.attributes.version_string,
80 + platform: platform_name(platform).to_string(),
81 + app_version_state: version.attributes.app_version_state,
82 + existing_submission_id: existing.map(|submission| submission.id),
83 + existing_submission_has_items,
84 + })
85 +}
86 +
87 +/// Carry out a plan from [`plan_submission`]: reuse or create the
88 +/// submission, add the version unless it's already on it, then submit.
89 +///
90 +/// An open submission that already has an item is assumed to hold this
91 +/// version, because App Store Connect keeps one open submission per app
92 +/// and platform and this is the platform's only version in an editable
93 +/// state. If it holds something else, App Store Connect rejects the
94 +/// submit and the error says so.
95 +pub async fn submit_for_review(
96 + client: &Client,
97 + plan: SubmissionPlan,
98 +) -> Result<SubmittedReview, String> {
99 + if let Some(reason) = &plan.blocked {
100 + return Err(reason.clone());
101 + }
102 + let platform = platform_from_name(&plan.platform)?;
103 + let submission_id = match &plan.existing_submission_id {
104 + Some(id) => id.clone(),
105 + None => {
106 + client
107 + .create_review_submission(&plan.app_id, platform)
108 + .await
109 + .map_err(|error| error.to_string())?
110 + .id
111 + }
112 + };
113 +
114 + if !plan.existing_submission_has_items {
115 + client
116 + .add_app_store_version_to_review_submission(&submission_id, &plan.app_store_version_id)
117 + .await
118 + .map_err(|error| error.to_string())?;
119 + }
120 +
121 + let submitted = client
122 + .submit_review_submission(&submission_id)
123 + .await
124 + .map_err(|error| error.to_string())?;
125 +
126 + Ok(SubmittedReview {
127 + submission_id: submitted.id,
128 + state: submitted.attributes.state,
129 + submitted_date: submitted.attributes.submitted_date,
130 + plan,
131 + })
132 +}
133 +
134 +/// States in which a version has already gone to App Review or is past
135 +/// it. Submitting again would only fail at the API, after the submission
136 +/// and item had been created, so stop before any write.
137 +fn blocked_reason(state: &str) -> Option<String> {
138 + const SUBMITTED_OR_LIVE: [&str; 9] = [
139 + "WAITING_FOR_REVIEW",
140 + "IN_REVIEW",
141 + "PENDING_APPLE_RELEASE",
142 + "PENDING_DEVELOPER_RELEASE",
143 + "PROCESSING_FOR_DISTRIBUTION",
144 + "READY_FOR_DISTRIBUTION",
145 + "READY_FOR_SALE",
146 + "ACCEPTED",
147 + "REPLACED_WITH_NEW_VERSION",
148 + ];
149 + SUBMITTED_OR_LIVE.contains(&state).then(|| {
150 + format!(
151 + "this version is {state}, so it has already been submitted; \
152 + `review-submissions list` shows its submission, and \
153 + `review-submissions cancel` withdraws one still waiting for review"
154 + )
155 + })
156 +}
157 +
158 +fn platform_name(platform: Platform) -> &'static str {
159 + match platform {
160 + Platform::Ios => "IOS",
161 + Platform::MacOs => "MAC_OS",
162 + Platform::TvOs => "TV_OS",
163 + Platform::VisionOs => "VISION_OS",
164 + }
165 +}
166 +
167 +fn platform_from_name(name: &str) -> Result<Platform, String> {
168 + match name {
169 + "IOS" => Ok(Platform::Ios),
170 + "MAC_OS" => Ok(Platform::MacOs),
171 + "TV_OS" => Ok(Platform::TvOs),
172 + "VISION_OS" => Ok(Platform::VisionOs),
173 + other => Err(format!("unknown platform {other:?} in submission plan")),
174 + }
175 +}
176 +
177 +#[cfg(test)]
178 +mod tests {
179 + use super::*;
180 +
181 + #[test]
182 + fn blocks_a_version_that_was_already_submitted() {
183 + assert!(blocked_reason("WAITING_FOR_REVIEW").is_some());
184 + assert!(blocked_reason("READY_FOR_DISTRIBUTION").is_some());
185 + assert_eq!(blocked_reason("PREPARE_FOR_SUBMISSION"), None);
186 + assert_eq!(blocked_reason("DEVELOPER_REJECTED"), None);
187 + assert_eq!(blocked_reason("REJECTED"), None);
188 + }
189 +
190 + #[test]
191 + fn platform_names_round_trip() {
192 + for platform in [
193 + Platform::Ios,
194 + Platform::MacOs,
195 + Platform::TvOs,
196 + Platform::VisionOs,
197 + ] {
198 + assert_eq!(platform_from_name(platform_name(platform)), Ok(platform));
199 + }
200 + assert!(platform_from_name("WATCH_OS").is_err());
201 + }
202 +}