feat(cli): add testflight subcommand tree

Add `asc testflight` with three sub-trees: - `testflight groups` — list, get, and add-build (CLI-only, writable) - `testflight app-review-detail` — get and update - `testflight app-review-submissions` — list and submit (CLI-only, irreversible) Adds the supporting ASC domain modules in `crates/aso`: - `beta_group`: list groups, get by id, add builds (body-serialisation tests) - `beta_app_review_detail`: get + PATCH update (deserialise + skip-None tests) - `beta_app_review_submission`: list + POST create (filter-key + body tests) - `beta_build_localization`: list + POST create + PATCH update (round-trip tests) Exposes the read-only operations as MCP tools: - `testflight_beta_groups_list`, `testflight_beta_groups_get` - `testflight_beta_app_review_detail_get` - `testflight_beta_app_review_submissions_list` Writable / irreversible operations (add-build, submit, localization writes) are CLI-only per the project's destructive-op policy. All CI gates pass: fmt, clippy -D warnings, 92 tests, rustdoc -D warnings. Co-Authored-By: siGit Code v1.6.7-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 11, 2026 at 23:40 UTC 0820c57b2fbe49424509adb81693838f68919e34
9 files changed +1106 -3
crates/aso/src/beta_app_review_detail.rs new
+181
@@ -0,0 +1,181 @@
1 +//! `BetaAppReviewDetail` — contact and demo info that Apple's Beta App Review
2 +//! team uses when reviewing an app for external TestFlight distribution.
3 +//!
4 +//! Every app has exactly one `BetaAppReviewDetail`. It does not need to be
5 +//! created — it exists by default. The caller `GET`s it to see the current
6 +//! state, and `PATCH`es it to update contact information before submitting
7 +//! a build for Beta App Review.
8 +
9 +use async_trait::async_trait;
10 +use reqwest::Method;
11 +use serde::{Deserialize, Serialize};
12 +use smbcloud_ascapi_core::jsonapi::{Document, Resource, UpdateBody, UpdateData};
13 +use smbcloud_ascapi_core::Client;
14 +use smbcloud_ascapi_core::Result;
15 +
16 +pub const RESOURCE_TYPE: &str = "betaAppReviewDetails";
17 +
18 +/// Contact and demo-account information used by the Beta App Review team.
19 +#[derive(Debug, Clone, Serialize, Deserialize)]
20 +#[serde(rename_all = "camelCase")]
21 +pub struct BetaAppReviewDetailAttributes {
22 + /// Contact email for Beta App Review questions.
23 + pub contact_email: Option<String>,
24 + /// Contact first name.
25 + pub contact_first_name: Option<String>,
26 + /// Contact last name.
27 + pub contact_last_name: Option<String>,
28 + /// Contact phone number (international format recommended).
29 + pub contact_phone: Option<String>,
30 + /// Username for a demo account, if the app requires login to review.
31 + pub demo_account_name: Option<String>,
32 + /// Password for the demo account.
33 + pub demo_account_password: Option<String>,
34 + /// Whether a demo account is required to review the app.
35 + pub demo_account_required: Option<bool>,
36 + /// Free-form notes for the reviewer (max 4000 chars).
37 + pub notes: Option<String>,
38 +}
39 +
40 +/// A beta app review detail resource.
41 +pub type BetaAppReviewDetail = Resource<BetaAppReviewDetailAttributes>;
42 +
43 +// ── update helper ─────────────────────────────────────────────────────────────
44 +
45 +/// Partial update attributes — all fields are optional so a caller can
46 +/// update only what has changed.
47 +#[derive(Debug, Clone, Default, Serialize)]
48 +#[serde(rename_all = "camelCase")]
49 +pub struct BetaAppReviewDetailUpdate {
50 + #[serde(skip_serializing_if = "Option::is_none")]
51 + pub contact_email: Option<String>,
52 + #[serde(skip_serializing_if = "Option::is_none")]
53 + pub contact_first_name: Option<String>,
54 + #[serde(skip_serializing_if = "Option::is_none")]
55 + pub contact_last_name: Option<String>,
56 + #[serde(skip_serializing_if = "Option::is_none")]
57 + pub contact_phone: Option<String>,
58 + #[serde(skip_serializing_if = "Option::is_none")]
59 + pub demo_account_name: Option<String>,
60 + #[serde(skip_serializing_if = "Option::is_none")]
61 + pub demo_account_password: Option<String>,
62 + #[serde(skip_serializing_if = "Option::is_none")]
63 + pub demo_account_required: Option<bool>,
64 + #[serde(skip_serializing_if = "Option::is_none")]
65 + pub notes: Option<String>,
66 +}
67 +
68 +// ── trait ─────────────────────────────────────────────────────────────────────
69 +
70 +/// Beta app review detail operations on App Store Connect.
71 +///
72 +/// An extension trait rather than inherent methods, because `Client`
73 +/// lives in the core crate and Rust only allows inherent impls in the
74 +/// crate that defines the type. Import it, or the crate's `prelude`,
75 +/// to call these on a `Client`.
76 +#[async_trait]
77 +pub trait BetaAppReviewDetailApi {
78 + /// `GET /v1/apps/{app_id}/betaAppReviewDetail` — fetch the review detail
79 + /// for an app via the relationship sub-resource. This is the canonical
80 + /// way to get the detail without knowing its opaque id up front.
81 + async fn get_beta_app_review_detail(&self, app_id: &str) -> Result<BetaAppReviewDetail>;
82 +
83 + /// `PATCH /v1/betaAppReviewDetails/{id}` — update contact / demo-account
84 + /// fields. The id comes from a prior `get_beta_app_review_detail` call.
85 + ///
86 + /// Only fields set (non-`None`) in `update` are sent; other fields are
87 + /// left unchanged on Apple's side.
88 + async fn update_beta_app_review_detail(
89 + &self,
90 + id: &str,
91 + update: BetaAppReviewDetailUpdate,
92 + ) -> Result<BetaAppReviewDetail>;
93 +}
94 +
95 +#[async_trait]
96 +impl BetaAppReviewDetailApi for Client {
97 + async fn get_beta_app_review_detail(&self, app_id: &str) -> Result<BetaAppReviewDetail> {
98 + let path = format!("/v1/apps/{app_id}/betaAppReviewDetail");
99 + let doc: Document<BetaAppReviewDetailAttributes> =
100 + self.request(Method::GET, &path, &[], None::<&()>).await?;
101 + Ok(doc.data)
102 + }
103 +
104 + async fn update_beta_app_review_detail(
105 + &self,
106 + id: &str,
107 + update: BetaAppReviewDetailUpdate,
108 + ) -> Result<BetaAppReviewDetail> {
109 + let path = format!("/v1/betaAppReviewDetails/{id}");
110 + let body = UpdateBody {
111 + data: UpdateData {
112 + resource_type: RESOURCE_TYPE,
113 + id: id.to_string(),
114 + attributes: update,
115 + },
116 + };
117 + let doc: Document<BetaAppReviewDetailAttributes> =
118 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
119 + Ok(doc.data)
120 + }
121 +}
122 +
123 +#[cfg(test)]
124 +mod tests {
125 + use super::*;
126 +
127 + #[test]
128 + fn deserializes_a_beta_app_review_detail() {
129 + let body = serde_json::json!({
130 + "data": {
131 + "id": "DETAIL1",
132 + "type": "betaAppReviewDetails",
133 + "attributes": {
134 + "contactEmail": "dev@example.com",
135 + "contactFirstName": "Jane",
136 + "contactLastName": "Developer",
137 + "contactPhone": "+1-555-0100",
138 + "demoAccountName": "demo@example.com",
139 + "demoAccountPassword": "hunter2",
140 + "demoAccountRequired": true,
141 + "notes": "Tap the red button to reach the main feature."
142 + }
143 + }
144 + });
145 +
146 + let doc: Document<BetaAppReviewDetailAttributes> =
147 + serde_json::from_value(body).expect("review detail must deserialize");
148 + let attrs = &doc.data.attributes;
149 + assert_eq!(doc.data.id, "DETAIL1");
150 + assert_eq!(attrs.contact_email.as_deref(), Some("dev@example.com"));
151 + assert_eq!(attrs.contact_first_name.as_deref(), Some("Jane"));
152 + assert_eq!(attrs.demo_account_required, Some(true));
153 + assert_eq!(
154 + attrs.notes.as_deref(),
155 + Some("Tap the red button to reach the main feature.")
156 + );
157 + }
158 +
159 + #[test]
160 + fn update_body_skips_none_fields() {
161 + let update = BetaAppReviewDetailUpdate {
162 + contact_email: Some("new@example.com".into()),
163 + notes: Some("Updated notes.".into()),
164 + ..Default::default()
165 + };
166 + let body = UpdateBody {
167 + data: UpdateData {
168 + resource_type: RESOURCE_TYPE,
169 + id: "DETAIL1".into(),
170 + attributes: update,
171 + },
172 + };
173 + let v = serde_json::to_value(&body).unwrap();
174 + let attrs = &v["data"]["attributes"];
175 + assert_eq!(attrs["contactEmail"], "new@example.com");
176 + assert_eq!(attrs["notes"], "Updated notes.");
177 + // Fields not set must be absent (not null), so the PATCH is truly partial.
178 + assert!(attrs.get("contactFirstName").is_none() || attrs["contactFirstName"].is_null());
179 + assert!(attrs.get("demoAccountRequired").is_none());
180 + }
181 +}
crates/aso/src/beta_app_review_submission.rs new
+212
@@ -0,0 +1,212 @@
1 +//! `BetaAppReviewSubmission` — submit a build to Apple's Beta App Review
2 +//! for external TestFlight distribution approval.
3 +//!
4 +//! Before external testers can install a build, Apple must approve it via
5 +//! Beta App Review. This module lets a caller:
6 +//!
7 +//! 1. Submit a build (`POST /v1/betaAppReviewSubmissions`) — creates a
8 +//! submission and immediately sends the build to Apple for review.
9 +//! 2. Query the current submission state (`GET /v1/betaAppReviewSubmissions?filter[build]={id}`).
10 +//!
11 +//! The submission itself only ever has the relationship to a `build`;
12 +//! contact / demo details come from [`crate::beta_app_review_detail`].
13 +
14 +use async_trait::async_trait;
15 +use reqwest::Method;
16 +use serde::{Deserialize, Serialize};
17 +use smbcloud_ascapi_core::jsonapi::{
18 + CreateBody, CreateData, Document, ListDocument, Resource, ResourceId, ToOne,
19 +};
20 +use smbcloud_ascapi_core::Client;
21 +use smbcloud_ascapi_core::Result;
22 +
23 +pub const RESOURCE_TYPE: &str = "betaAppReviewSubmissions";
24 +
25 +/// The review state Apple reports for a beta submission.
26 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27 +#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
28 +pub enum BetaReviewState {
29 + /// The submission is waiting for App Review to pick it up.
30 + WaitingForReview,
31 + /// Apple is actively reviewing the build.
32 + InReview,
33 + /// Beta App Review rejected the build.
34 + Rejected,
35 + /// Beta App Review approved the build; external testing can begin.
36 + Approved,
37 +}
38 +
39 +/// Attributes of a beta app review submission.
40 +#[derive(Debug, Clone, Serialize, Deserialize)]
41 +#[serde(rename_all = "camelCase")]
42 +pub struct BetaAppReviewSubmissionAttributes {
43 + /// Current state of the review.
44 + pub beta_review_state: Option<BetaReviewState>,
45 + /// Submission timestamp (ISO 8601).
46 + pub submitted_date: Option<String>,
47 +}
48 +
49 +/// A beta app review submission resource.
50 +pub type BetaAppReviewSubmission = Resource<BetaAppReviewSubmissionAttributes>;
51 +
52 +// ── internal create helper ────────────────────────────────────────────────────
53 +
54 +#[derive(Debug, Clone, Serialize)]
55 +struct CreateRelationships {
56 + build: ToOne,
57 +}
58 +
59 +// ── trait ─────────────────────────────────────────────────────────────────────
60 +
61 +/// Beta app review submission operations on App Store Connect.
62 +///
63 +/// An extension trait rather than inherent methods, because `Client`
64 +/// lives in the core crate and Rust only allows inherent impls in the
65 +/// crate that defines the type. Import it, or the crate's `prelude`,
66 +/// to call these on a `Client`.
67 +#[async_trait]
68 +pub trait BetaAppReviewSubmissionsApi {
69 + /// `POST /v1/betaAppReviewSubmissions` — submit a build to Beta App Review.
70 + ///
71 + /// **CLI-only** — this is a destructive, irreversible operation that
72 + /// triggers Apple's review queue. It must not be called from an MCP tool
73 + /// without explicit user confirmation, and it is intentionally not
74 + /// exposed as an MCP tool here.
75 + ///
76 + /// `build_id` is the opaque ASC id of the build (not the build number).
77 + /// On success the returned submission's `beta_review_state` is typically
78 + /// `WAITING_FOR_REVIEW`.
79 + async fn submit_build_for_beta_review(&self, build_id: &str)
80 + -> Result<BetaAppReviewSubmission>;
81 +
82 + /// `GET /v1/betaAppReviewSubmissions?filter[build]={build_id}` — find
83 + /// the current submission record for a build.
84 + ///
85 + /// Returns at most one item in practice (one build → one submission), but
86 + /// the API is a list endpoint so we return `Vec`.
87 + async fn list_beta_app_review_submissions(
88 + &self,
89 + build_id: &str,
90 + ) -> Result<Vec<BetaAppReviewSubmission>>;
91 +}
92 +
93 +#[async_trait]
94 +impl BetaAppReviewSubmissionsApi for Client {
95 + async fn submit_build_for_beta_review(
96 + &self,
97 + build_id: &str,
98 + ) -> Result<BetaAppReviewSubmission> {
99 + let body = CreateBody {
100 + data: CreateData {
101 + resource_type: RESOURCE_TYPE,
102 + attributes: (),
103 + relationships: Some(CreateRelationships {
104 + build: ToOne {
105 + data: ResourceId {
106 + resource_type: crate::build::RESOURCE_TYPE,
107 + id: build_id.to_string(),
108 + },
109 + },
110 + }),
111 + },
112 + };
113 + let doc: Document<BetaAppReviewSubmissionAttributes> = self
114 + .request(
115 + Method::POST,
116 + "/v1/betaAppReviewSubmissions",
117 + &[],
118 + Some(&body),
119 + )
120 + .await?;
121 + Ok(doc.data)
122 + }
123 +
124 + async fn list_beta_app_review_submissions(
125 + &self,
126 + build_id: &str,
127 + ) -> Result<Vec<BetaAppReviewSubmission>> {
128 + let path = "/v1/betaAppReviewSubmissions";
129 + let query = &[("filter[build]", build_id)];
130 + let doc: ListDocument<BetaAppReviewSubmissionAttributes> =
131 + self.request(Method::GET, path, query, None::<&()>).await?;
132 + Ok(doc.data)
133 + }
134 +}
135 +
136 +#[cfg(test)]
137 +mod tests {
138 + use super::*;
139 +
140 + #[test]
141 + fn deserializes_a_submission_list() {
142 + let body = serde_json::json!({
143 + "data": [
144 + {
145 + "id": "SUB1",
146 + "type": "betaAppReviewSubmissions",
147 + "attributes": {
148 + "betaReviewState": "WAITING_FOR_REVIEW",
149 + "submittedDate": "2025-01-01T00:00:00.000+0000"
150 + }
151 + }
152 + ]
153 + });
154 +
155 + let doc: ListDocument<BetaAppReviewSubmissionAttributes> =
156 + serde_json::from_value(body).expect("submission list must deserialize");
157 + assert_eq!(doc.data.len(), 1);
158 + let sub = &doc.data[0];
159 + assert_eq!(sub.id, "SUB1");
160 + assert_eq!(
161 + sub.attributes.beta_review_state,
162 + Some(BetaReviewState::WaitingForReview)
163 + );
164 + assert_eq!(
165 + sub.attributes.submitted_date.as_deref(),
166 + Some("2025-01-01T00:00:00.000+0000")
167 + );
168 + }
169 +
170 + #[test]
171 + fn deserializes_approved_state() {
172 + let body = serde_json::json!({
173 + "data": {
174 + "id": "SUB2",
175 + "type": "betaAppReviewSubmissions",
176 + "attributes": {
177 + "betaReviewState": "APPROVED",
178 + "submittedDate": "2025-02-01T10:00:00.000+0000"
179 + }
180 + }
181 + });
182 +
183 + let doc: Document<BetaAppReviewSubmissionAttributes> =
184 + serde_json::from_value(body).expect("single submission must deserialize");
185 + assert_eq!(
186 + doc.data.attributes.beta_review_state,
187 + Some(BetaReviewState::Approved)
188 + );
189 + }
190 +
191 + #[test]
192 + fn filter_key_is_exact() {
193 + // ASC is case-sensitive about filter keys; confirm the exact string.
194 + let query: Vec<(&str, &str)> = vec![("filter[build]", "BUILD_ID")];
195 + assert_eq!(query[0].0, "filter[build]");
196 + }
197 +
198 + #[test]
199 + fn create_body_attributes_unit_serializes() {
200 + // The create body has no attributes — verify `()` round-trips cleanly.
201 + let body = CreateBody {
202 + data: CreateData {
203 + resource_type: RESOURCE_TYPE,
204 + attributes: (),
205 + relationships: None::<()>,
206 + },
207 + };
208 + let v = serde_json::to_value(&body).unwrap();
209 + // `()` serialises as `null`; the key is present in the object.
210 + assert!(v["data"]["attributes"].is_null());
211 + }
212 +}
crates/aso/src/beta_build_localization.rs new
+234
@@ -0,0 +1,234 @@
1 +//! `BetaBuildLocalization` — per-locale "What to Test" notes on a TestFlight build.
2 +//!
3 +//! Each build can carry one `BetaBuildLocalization` per locale (e.g.
4 +//! `"en-US"`, `"ja"`). These notes appear to testers in the TestFlight app
5 +//! beneath the build entry. Creating or updating them is optional; if absent
6 +//! the build simply shows no notes.
7 +
8 +use async_trait::async_trait;
9 +use reqwest::Method;
10 +use serde::{Deserialize, Serialize};
11 +use smbcloud_ascapi_core::jsonapi::{
12 + CreateBody, CreateData, Document, Resource, ResourceId, ToOne, UpdateBody, UpdateData,
13 +};
14 +use smbcloud_ascapi_core::Client;
15 +use smbcloud_ascapi_core::Result;
16 +
17 +pub const RESOURCE_TYPE: &str = "betaBuildLocalizations";
18 +
19 +/// Attributes of a per-locale beta build note.
20 +#[derive(Debug, Clone, Serialize, Deserialize)]
21 +#[serde(rename_all = "camelCase")]
22 +pub struct BetaBuildLocalizationAttributes {
23 + /// BCP 47 locale tag, e.g. `"en-US"` or `"ja"`.
24 + pub locale: Option<String>,
25 + /// Free-form "What to Test" note shown to testers (max 4000 chars).
26 + pub whats_new: Option<String>,
27 +}
28 +
29 +/// A beta build localization resource.
30 +pub type BetaBuildLocalization = Resource<BetaBuildLocalizationAttributes>;
31 +
32 +// ── internal create/update helpers ───────────────────────────────────────────
33 +
34 +#[derive(Debug, Clone, Serialize)]
35 +#[serde(rename_all = "camelCase")]
36 +struct CreateAttributes {
37 + locale: String,
38 + whats_new: Option<String>,
39 +}
40 +
41 +#[derive(Debug, Clone, Serialize)]
42 +struct CreateRelationships {
43 + build: ToOne,
44 +}
45 +
46 +#[derive(Debug, Clone, Serialize)]
47 +#[serde(rename_all = "camelCase")]
48 +struct UpdateAttributes {
49 + whats_new: String,
50 +}
51 +
52 +// ── trait ─────────────────────────────────────────────────────────────────────
53 +
54 +/// Beta build localization operations on App Store Connect.
55 +///
56 +/// An extension trait rather than inherent methods, because `Client`
57 +/// lives in the core crate and Rust only allows inherent impls in the
58 +/// crate that defines the type. Import it, or the crate's `prelude`,
59 +/// to call these on a `Client`.
60 +#[async_trait]
61 +pub trait BetaBuildLocalizationsApi {
62 + /// `GET /v1/betaBuildLocalizations?filter[build]={build_id}` — all
63 + /// locale notes for a build.
64 + async fn list_beta_build_localizations(
65 + &self,
66 + build_id: &str,
67 + ) -> Result<Vec<BetaBuildLocalization>>;
68 +
69 + /// `GET /v1/betaBuildLocalizations/{id}` — a single note by its ASC id.
70 + async fn get_beta_build_localization(&self, id: &str) -> Result<BetaBuildLocalization>;
71 +
72 + /// `POST /v1/betaBuildLocalizations` — create a locale note for a build.
73 + ///
74 + /// `locale` is a BCP 47 tag (e.g. `"en-US"`). `whats_new` is optional
75 + /// at creation; App Store Connect accepts an empty record.
76 + async fn create_beta_build_localization(
77 + &self,
78 + build_id: &str,
79 + locale: &str,
80 + whats_new: Option<&str>,
81 + ) -> Result<BetaBuildLocalization>;
82 +
83 + /// `PATCH /v1/betaBuildLocalizations/{id}` — update the "What to Test"
84 + /// text for an existing localization.
85 + async fn update_beta_build_localization(
86 + &self,
87 + id: &str,
88 + whats_new: &str,
89 + ) -> Result<BetaBuildLocalization>;
90 +}
91 +
92 +#[async_trait]
93 +impl BetaBuildLocalizationsApi for Client {
94 + async fn list_beta_build_localizations(
95 + &self,
96 + build_id: &str,
97 + ) -> Result<Vec<BetaBuildLocalization>> {
98 + self.list_all::<BetaBuildLocalizationAttributes>(
99 + "/v1/betaBuildLocalizations",
100 + &[("filter[build]", build_id), ("limit", "200")],
101 + )
102 + .await
103 + }
104 +
105 + async fn get_beta_build_localization(&self, id: &str) -> Result<BetaBuildLocalization> {
106 + let path = format!("/v1/betaBuildLocalizations/{id}");
107 + let doc: Document<BetaBuildLocalizationAttributes> =
108 + self.request(Method::GET, &path, &[], None::<&()>).await?;
109 + Ok(doc.data)
110 + }
111 +
112 + async fn create_beta_build_localization(
113 + &self,
114 + build_id: &str,
115 + locale: &str,
116 + whats_new: Option<&str>,
117 + ) -> Result<BetaBuildLocalization> {
118 + let body = CreateBody {
119 + data: CreateData {
120 + resource_type: RESOURCE_TYPE,
121 + attributes: CreateAttributes {
122 + locale: locale.to_string(),
123 + whats_new: whats_new.map(str::to_string),
124 + },
125 + relationships: Some(CreateRelationships {
126 + build: ToOne {
127 + data: ResourceId {
128 + resource_type: crate::build::RESOURCE_TYPE,
129 + id: build_id.to_string(),
130 + },
131 + },
132 + }),
133 + },
134 + };
135 + let doc: Document<BetaBuildLocalizationAttributes> = self
136 + .request(Method::POST, "/v1/betaBuildLocalizations", &[], Some(&body))
137 + .await?;
138 + Ok(doc.data)
139 + }
140 +
141 + async fn update_beta_build_localization(
142 + &self,
143 + id: &str,
144 + whats_new: &str,
145 + ) -> Result<BetaBuildLocalization> {
146 + let path = format!("/v1/betaBuildLocalizations/{id}");
147 + let body = UpdateBody {
148 + data: UpdateData {
149 + resource_type: RESOURCE_TYPE,
150 + id: id.to_string(),
151 + attributes: UpdateAttributes {
152 + whats_new: whats_new.to_string(),
153 + },
154 + },
155 + };
156 + let doc: Document<BetaBuildLocalizationAttributes> =
157 + self.request(Method::PATCH, &path, &[], Some(&body)).await?;
158 + Ok(doc.data)
159 + }
160 +}
161 +
162 +#[cfg(test)]
163 +mod tests {
164 + use super::*;
165 + use smbcloud_ascapi_core::jsonapi::ListDocument;
166 +
167 + #[test]
168 + fn deserializes_a_localization_list() {
169 + let body = serde_json::json!({
170 + "data": [
171 + {
172 + "id": "LOC1",
173 + "type": "betaBuildLocalizations",
174 + "attributes": {
175 + "locale": "en-US",
176 + "whatsNew": "Bug fixes and performance improvements."
177 + }
178 + },
179 + {
180 + "id": "LOC2",
181 + "type": "betaBuildLocalizations",
182 + "attributes": {
183 + "locale": "ja",
184 + "whatsNew": "バグ修正とパフォーマンス改善。"
185 + }
186 + }
187 + ]
188 + });
189 +
190 + let doc: ListDocument<BetaBuildLocalizationAttributes> =
191 + serde_json::from_value(body).expect("localization list must deserialize");
192 + assert_eq!(doc.data.len(), 2);
193 +
194 + let en = &doc.data[0];
195 + assert_eq!(en.id, "LOC1");
196 + assert_eq!(en.attributes.locale.as_deref(), Some("en-US"));
197 + assert_eq!(
198 + en.attributes.whats_new.as_deref(),
199 + Some("Bug fixes and performance improvements.")
200 + );
201 +
202 + let ja = &doc.data[1];
203 + assert_eq!(ja.attributes.locale.as_deref(), Some("ja"));
204 + }
205 +
206 + #[test]
207 + fn create_body_serializes_filter_key() {
208 + // The filter key for the list endpoint must match exactly.
209 + let query: Vec<(&str, &str)> = vec![("filter[build]", "BUILD1"), ("limit", "200")];
210 + assert_eq!(query[0].0, "filter[build]");
211 + assert_eq!(query[0].1, "BUILD1");
212 + }
213 +
214 + #[test]
215 + fn create_body_without_whats_new_omits_the_field() {
216 + // whatsNew = None → field should be null in JSON (we serialize as
217 + // `Option`, so it is present as null rather than absent, which ASC
218 + // accepts as "no notes yet").
219 + let body = CreateBody {
220 + data: CreateData {
221 + resource_type: RESOURCE_TYPE,
222 + attributes: CreateAttributes {
223 + locale: "en-US".into(),
224 + whats_new: None,
225 + },
226 + relationships: None::<()>,
227 + },
228 + };
229 + let v = serde_json::to_value(&body).unwrap();
230 + let attrs = &v["data"]["attributes"];
231 + assert_eq!(attrs["locale"], "en-US");
232 + assert!(attrs["whatsNew"].is_null());
233 + }
234 +}
crates/aso/src/beta_group.rs new
+208
@@ -0,0 +1,208 @@
1 +//! `BetaGroup` — a TestFlight distribution group that can receive builds.
2 +//!
3 +//! A beta group is either an *internal* tester group (only team members with
4 +//! an Apple Developer account role) or an *external* group (anyone with an
5 +//! email invite). Adding a build to an external group triggers the optional
6 +//! "Beta App Review" gate; internal groups skip it.
7 +//!
8 +//! The writable operation here — adding a build to a group — is deliberate
9 +//! and potentially public-facing, so it stays CLI-only in the front end.
10 +
11 +use async_trait::async_trait;
12 +use reqwest::Method;
13 +use serde::{Deserialize, Serialize};
14 +use smbcloud_ascapi_core::jsonapi::{Resource, ResourceId};
15 +use smbcloud_ascapi_core::Client;
16 +use smbcloud_ascapi_core::Result;
17 +
18 +pub const RESOURCE_TYPE: &str = "betaGroups";
19 +
20 +/// Attributes of a TestFlight beta group.
21 +#[derive(Debug, Clone, Serialize, Deserialize)]
22 +#[serde(rename_all = "camelCase")]
23 +pub struct BetaGroupAttributes {
24 + /// Display name of the group, e.g. `"Internal Testers"`.
25 + pub name: Option<String>,
26 + /// Whether this is an internal-only group (team members only).
27 + pub is_internal_group: Option<bool>,
28 + /// Whether new builds are automatically distributed to this group.
29 + pub has_access_to_all_builds: Option<bool>,
30 + /// ISO 8601 timestamp of when the group was created.
31 + pub created_date: Option<String>,
32 + /// Whether a public invite link is enabled for this group.
33 + pub public_link_enabled: Option<bool>,
34 + /// The public invite link URL, if enabled.
35 + pub public_link: Option<String>,
36 + /// Current number of testers who have accepted through the public link.
37 + pub public_link_limit: Option<i64>,
38 +}
39 +
40 +/// A TestFlight beta group resource.
41 +pub type BetaGroup = Resource<BetaGroupAttributes>;
42 +
43 +/// Body used to add (or replace) the builds relationship on a beta group.
44 +///
45 +/// `POST /v1/betaGroups/{id}/relationships/builds` appends builds;
46 +/// `PATCH /v1/betaGroups/{id}/relationships/builds` replaces the list.
47 +/// We only need the append form here.
48 +#[derive(Debug, Clone, Serialize)]
49 +struct AddBuildsBody {
50 + data: Vec<ResourceId>,
51 +}
52 +
53 +/// Beta group operations on App Store Connect.
54 +///
55 +/// An extension trait rather than inherent methods, because `Client`
56 +/// lives in the core crate and Rust only allows inherent impls in the
57 +/// crate that defines the type. Import it, or the crate's `prelude`,
58 +/// to call these on a `Client`.
59 +#[async_trait]
60 +pub trait BetaGroupsApi {
61 + /// `GET /v1/betaGroups?filter[app]={app_id}` — all beta groups for
62 + /// an app, sorted alphabetically by `name` client-side so internal
63 + /// and external groups are interleaved predictably.
64 + async fn list_beta_groups(&self, app_id: &str) -> Result<Vec<BetaGroup>>;
65 +
66 + /// `GET /v1/betaGroups/{id}` — a single group by its ASC id.
67 + async fn get_beta_group(&self, id: &str) -> Result<BetaGroup>;
68 +
69 + /// `POST /v1/betaGroups/{group_id}/relationships/builds` — add one or
70 + /// more builds to a beta group by their ASC build ids.
71 + ///
72 + /// This is an *append* operation: existing builds in the group are
73 + /// unaffected. Adding a build to an external group may trigger Beta
74 + /// App Review if the app has not received approval for that group yet.
75 + async fn add_builds_to_beta_group(&self, group_id: &str, build_ids: &[&str]) -> Result<()>;
76 +}
77 +
78 +#[async_trait]
79 +impl BetaGroupsApi for Client {
80 + async fn list_beta_groups(&self, app_id: &str) -> Result<Vec<BetaGroup>> {
81 + let mut groups = self
82 + .list_all::<BetaGroupAttributes>(
83 + "/v1/betaGroups",
84 + &[("filter[app]", app_id), ("limit", "200")],
85 + )
86 + .await?;
87 + groups.sort_by(|a, b| a.attributes.name.cmp(&b.attributes.name));
88 + Ok(groups)
89 + }
90 +
91 + async fn get_beta_group(&self, id: &str) -> Result<BetaGroup> {
92 + use smbcloud_ascapi_core::jsonapi::Document;
93 + let path = format!("/v1/betaGroups/{id}");
94 + let doc: Document<BetaGroupAttributes> =
95 + self.request(Method::GET, &path, &[], None::<&()>).await?;
96 + Ok(doc.data)
97 + }
98 +
99 + async fn add_builds_to_beta_group(&self, group_id: &str, build_ids: &[&str]) -> Result<()> {
100 + let path = format!("/v1/betaGroups/{group_id}/relationships/builds");
101 + let body = AddBuildsBody {
102 + data: build_ids
103 + .iter()
104 + .map(|id| ResourceId {
105 + resource_type: crate::build::RESOURCE_TYPE,
106 + id: id.to_string(),
107 + })
108 + .collect(),
109 + };
110 + // A successful POST to a relationships endpoint returns 204 No Content.
111 + // `request_no_content` (if available) or discard the empty body.
112 + self.request_no_content(Method::POST, &path, &[], Some(&body))
113 + .await
114 + }
115 +}
116 +
117 +#[cfg(test)]
118 +mod tests {
119 + use super::*;
120 + use smbcloud_ascapi_core::jsonapi::ListDocument;
121 +
122 + #[test]
123 + fn deserializes_a_beta_groups_list() {
124 + let body = serde_json::json!({
125 + "data": [
126 + {
127 + "id": "BG1",
128 + "type": "betaGroups",
129 + "attributes": {
130 + "name": "Internal Testers",
131 + "isInternalGroup": true,
132 + "hasAccessToAllBuilds": true,
133 + "createdDate": "2024-01-01T00:00:00.000+0000",
134 + "publicLinkEnabled": false,
135 + "publicLink": null,
136 + "publicLinkLimit": null
137 + }
138 + },
139 + {
140 + "id": "BG2",
141 + "type": "betaGroups",
142 + "attributes": {
143 + "name": "External Beta",
144 + "isInternalGroup": false,
145 + "hasAccessToAllBuilds": false,
146 + "createdDate": "2024-02-01T00:00:00.000+0000",
147 + "publicLinkEnabled": true,
148 + "publicLink": "https://testflight.apple.com/join/abc123",
149 + "publicLinkLimit": 1000
150 + }
151 + }
152 + ]
153 + });
154 +
155 + let doc: ListDocument<BetaGroupAttributes> =
156 + serde_json::from_value(body).expect("beta groups list must deserialize");
157 + assert_eq!(doc.data.len(), 2);
158 +
159 + let internal = &doc.data[0];
160 + assert_eq!(internal.id, "BG1");
161 + assert_eq!(
162 + internal.attributes.name.as_deref(),
163 + Some("Internal Testers")
164 + );
165 + assert_eq!(internal.attributes.is_internal_group, Some(true));
166 + assert_eq!(internal.attributes.has_access_to_all_builds, Some(true));
167 + assert_eq!(internal.attributes.public_link_enabled, Some(false));
168 +
169 + let external = &doc.data[1];
170 + assert_eq!(external.id, "BG2");
171 + assert_eq!(external.attributes.name.as_deref(), Some("External Beta"));
172 + assert_eq!(external.attributes.is_internal_group, Some(false));
173 + assert_eq!(
174 + external.attributes.public_link.as_deref(),
175 + Some("https://testflight.apple.com/join/abc123")
176 + );
177 + assert_eq!(external.attributes.public_link_limit, Some(1000));
178 + }
179 +
180 + #[test]
181 + fn add_builds_body_serializes_correctly() {
182 + let body = AddBuildsBody {
183 + data: vec![
184 + ResourceId {
185 + resource_type: "builds",
186 + id: "BUILD1".into(),
187 + },
188 + ResourceId {
189 + resource_type: "builds",
190 + id: "BUILD2".into(),
191 + },
192 + ],
193 + };
194 + let v = serde_json::to_value(&body).unwrap();
195 + let data = v["data"].as_array().unwrap();
196 + assert_eq!(data.len(), 2);
197 + assert_eq!(data[0]["type"], "builds");
198 + assert_eq!(data[0]["id"], "BUILD1");
199 + assert_eq!(data[1]["id"], "BUILD2");
200 + }
201 +
202 + #[test]
203 + fn empty_build_ids_produces_empty_data_array() {
204 + let body = AddBuildsBody { data: vec![] };
205 + let v = serde_json::to_value(&body).unwrap();
206 + assert!(v["data"].as_array().unwrap().is_empty());
207 + }
208 +}
crates/aso/src/lib.rs
+8
@@ -27,6 +27,10 @@ pub mod app_screenshot;
27 pub mod app_screenshot_set;
28 pub mod app_store_version;
29 pub mod app_store_version_localization;
30 +pub mod beta_app_review_detail;
31 +pub mod beta_app_review_submission;
32 +pub mod beta_build_localization;
33 +pub mod beta_group;
34 pub mod build;
35 pub mod bundle_id;
36 pub mod review_submission;
@@ -40,6 +44,10 @@ pub mod prelude {
44 pub use crate::app_screenshot_set::AppScreenshotSetsApi;
45 pub use crate::app_store_version::AppStoreVersionsApi;
46 pub use crate::app_store_version_localization::AppStoreVersionLocalizationsApi;
47 + pub use crate::beta_app_review_detail::BetaAppReviewDetailApi;
48 + pub use crate::beta_app_review_submission::BetaAppReviewSubmissionsApi;
49 + pub use crate::beta_build_localization::BetaBuildLocalizationsApi;
50 + pub use crate::beta_group::BetaGroupsApi;
51 pub use crate::build::BuildsApi;
52 pub use crate::bundle_id::BundleIdsApi;
53 pub use crate::review_submission::ReviewSubmissionsApi;
crates/cli/src/main.rs
+195
@@ -11,6 +11,7 @@ use smbcloud_ascapi_aso::app_store_version::{AppStoreVersionCreateAttributes, Pl
11 use smbcloud_ascapi_aso::app_store_version_localization::{
12 AppStoreVersionLocalizationCreateAttributes, AppStoreVersionLocalizationFields,
13 };
14 +use smbcloud_ascapi_aso::beta_app_review_detail::BetaAppReviewDetailUpdate;
15 use smbcloud_ascapi_aso::build::BuildFilter;
16 use smbcloud_ascapi_aso::bundle_id::{BundleIdCreateAttributes, BundleIdPlatform};
17 use smbcloud_ascapi_aso::prelude::*;
@@ -138,6 +139,116 @@ enum Command {
139 #[command(subcommand)]
140 command: SalesReportsCommand,
141 },
142 + /// Manage TestFlight beta testing: groups, review details, and review submissions.
143 + Testflight {
144 + #[command(subcommand)]
145 + command: TestflightCommand,
146 + },
147 +}
148 +
149 +#[derive(Subcommand)]
150 +enum TestflightCommand {
151 + /// Manage TestFlight beta groups.
152 + Groups {
153 + #[command(subcommand)]
154 + command: BetaGroupsCommand,
155 + },
156 + /// Manage the Beta App Review contact details for an app.
157 + AppReviewDetail {
158 + #[command(subcommand)]
159 + command: BetaAppReviewDetailCommand,
160 + },
161 + /// Manage Beta App Review submissions for a build.
162 + AppReviewSubmissions {
163 + #[command(subcommand)]
164 + command: BetaAppReviewSubmissionsCommand,
165 + },
166 +}
167 +
168 +#[derive(Subcommand)]
169 +enum BetaGroupsCommand {
170 + /// List all beta groups for an app.
171 + List {
172 + /// The ASC app ID to list groups for.
173 + app_id: String,
174 + },
175 + /// Fetch a single beta group by its ASC ID.
176 + Get {
177 + /// The ASC beta group ID.
178 + group_id: String,
179 + },
180 + /// Add one or more builds to a beta group.
181 + ///
182 + /// Adding a build to an external group may trigger Beta App Review if the
183 + /// app has not received approval for that group yet. This command is
184 + /// CLI-only for that reason.
185 + AddBuild {
186 + /// The ASC beta group ID.
187 + group_id: String,
188 + /// One or more ASC build IDs to add to the group.
189 + #[arg(long = "build-id", required = true, num_args = 1..)]
190 + build_ids: Vec<String>,
191 + },
192 +}
193 +
194 +#[derive(Subcommand)]
195 +enum BetaAppReviewDetailCommand {
196 + /// Fetch the Beta App Review contact details for an app.
197 + Get {
198 + /// The ASC app ID.
199 + app_id: String,
200 + },
201 + /// Update the Beta App Review contact and demo-account details for an app.
202 + ///
203 + /// Only supplied flags are updated; omitted fields are left unchanged.
204 + Update {
205 + /// The ASC app ID.
206 + app_id: String,
207 + /// Contact email address for Beta App Review questions.
208 + #[arg(long)]
209 + contact_email: Option<String>,
210 + /// Contact first name.
211 + #[arg(long)]
212 + contact_first_name: Option<String>,
213 + /// Contact last name.
214 + #[arg(long)]
215 + contact_last_name: Option<String>,
216 + /// Contact phone number (international format recommended).
217 + #[arg(long)]
218 + contact_phone: Option<String>,
219 + /// Demo account username (if the app requires login to review).
220 + #[arg(long)]
221 + demo_account_name: Option<String>,
222 + /// Demo account password.
223 + #[arg(long)]
224 + demo_account_password: Option<String>,
225 + /// Whether a demo account is required to review the app.
226 + #[arg(long)]
227 + demo_account_required: Option<bool>,
228 + /// Free-form notes for the reviewer (max 4000 chars).
229 + #[arg(long)]
230 + notes: Option<String>,
231 + },
232 +}
233 +
234 +#[derive(Subcommand)]
235 +enum BetaAppReviewSubmissionsCommand {
236 + /// List Beta App Review submissions for a build.
237 + List {
238 + /// The ASC build ID whose submission record to retrieve.
239 + #[arg(long)]
240 + build_id: String,
241 + },
242 + /// Submit a build to Beta App Review.
243 + ///
244 + /// This is irreversible — it immediately sends the build to Apple's
245 + /// review queue. Use `--dry-run` to confirm which build would be submitted
246 + /// before committing.
247 + Submit {
248 + /// The ASC build ID to submit for review.
249 + #[arg(long)]
250 + build_id: String,
251 + },
252 }
253
254 #[derive(Subcommand)]
@@ -860,6 +971,7 @@ async fn main() -> Result<()> {
971 run_review_submissions(&client, command, cli.dry_run).await
972 }
973 Command::SalesReports { command } => run_sales_reports(&client, command).await,
974 + Command::Testflight { command } => run_testflight(&client, command, cli.dry_run).await,
975 }
976 }
977
@@ -905,6 +1017,89 @@ async fn run_sales_reports(client: &Client, command: SalesReportsCommand) -> Res
1017 }
1018 }
1019
1020 +async fn run_testflight(client: &Client, command: TestflightCommand, dry_run: bool) -> Result<()> {
1021 + match command {
1022 + TestflightCommand::Groups { command } => match command {
1023 + BetaGroupsCommand::List { app_id } => {
1024 + let groups = client.list_beta_groups(&app_id).await?;
1025 + print_json(&groups)
1026 + }
1027 + BetaGroupsCommand::Get { group_id } => {
1028 + let group = client.get_beta_group(&group_id).await?;
1029 + print_json(&group)
1030 + }
1031 + BetaGroupsCommand::AddBuild {
1032 + group_id,
1033 + build_ids,
1034 + } => {
1035 + if dry_run {
1036 + println!(
1037 + "[dry-run] would add {} build(s) to beta group {group_id}: {}",
1038 + build_ids.len(),
1039 + build_ids.join(", ")
1040 + );
1041 + return Ok(());
1042 + }
1043 + let ids: Vec<&str> = build_ids.iter().map(String::as_str).collect();
1044 + client.add_builds_to_beta_group(&group_id, &ids).await?;
1045 + println!("Added {} build(s) to group {group_id}.", build_ids.len());
1046 + Ok(())
1047 + }
1048 + },
1049 + TestflightCommand::AppReviewDetail { command } => match command {
1050 + BetaAppReviewDetailCommand::Get { app_id } => {
1051 + let detail = client.get_beta_app_review_detail(&app_id).await?;
1052 + print_json(&detail)
1053 + }
1054 + BetaAppReviewDetailCommand::Update {
1055 + app_id,
1056 + contact_email,
1057 + contact_first_name,
1058 + contact_last_name,
1059 + contact_phone,
1060 + demo_account_name,
1061 + demo_account_password,
1062 + demo_account_required,
1063 + notes,
1064 + } => {
1065 + let update = BetaAppReviewDetailUpdate {
1066 + contact_email,
1067 + contact_first_name,
1068 + contact_last_name,
1069 + contact_phone,
1070 + demo_account_name,
1071 + demo_account_password,
1072 + demo_account_required,
1073 + notes,
1074 + };
1075 + if dry_run {
1076 + println!("[dry-run] would update Beta App Review detail for app {app_id}:");
1077 + print_json(&update)?;
1078 + return Ok(());
1079 + }
1080 + let detail = client
1081 + .update_beta_app_review_detail(&app_id, update)
1082 + .await?;
1083 + print_json(&detail)
1084 + }
1085 + },
1086 + TestflightCommand::AppReviewSubmissions { command } => match command {
1087 + BetaAppReviewSubmissionsCommand::List { build_id } => {
1088 + let submissions = client.list_beta_app_review_submissions(&build_id).await?;
1089 + print_json(&submissions)
1090 + }
1091 + BetaAppReviewSubmissionsCommand::Submit { build_id } => {
1092 + if dry_run {
1093 + println!("[dry-run] would submit build {build_id} to Beta App Review.");
1094 + return Ok(());
1095 + }
1096 + let submission = client.submit_build_for_beta_review(&build_id).await?;
1097 + print_json(&submission)
1098 + }
1099 + },
1100 + }
1101 +}
1102 +
1103 async fn run_apps(
1104 client: &Client,
1105 altool_auth: &AltoolAuth,
crates/mcp/src/lib.rs
+49 -1
@@ -66,7 +66,9 @@ macro_rules! ascapi_mcp_tools {
66 $app_screenshot_upload_name:literal,
67 $app_screenshot_delete_name:literal,
68 $certificate_list_name:literal,
69 - $certificate_create_name:literal
69 + $certificate_create_name:literal,
70 + $beta_group_list_name:literal,
71 + $beta_app_review_detail_get_name:literal
72 ) => {
73 #[::rmcp::tool_router(router = ascapi_tool_router, vis = "pub(crate)")]
74 impl $server {
@@ -881,6 +883,52 @@ macro_rules! ascapi_mcp_tools {
883 ::rmcp::model::ContentBlock::json(&outcome)?,
884 ]))
885 }
886 +
887 + #[::rmcp::tool(
888 + name = $beta_group_list_name,
889 + title = "List beta groups",
890 + annotations(title = "List beta groups", read_only_hint = true, idempotent_hint = true),
891 + description = "Purpose: list every TestFlight beta group (internal and external) for an app, so an agent knows which group ids are available before adding builds or testers. When to use vs siblings: call this first to discover group ids; use beta_app_review_detail_get to retrieve the contact info Apple's beta review team will see. Behavior: returns each group's id, name, whether it is an internal group, whether it requires a public link, the public link URL (when enabled), and tester counts where available. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: tester emails are not included; use the App Store Connect portal or the CLI to enumerate individual testers."
892 + )]
893 + async fn beta_group_list(
894 + &self,
895 + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::BetaGroupListRequest>,
896 + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> {
897 + #[allow(unused_imports)]
898 + use ::smbcloud_ascapi_aso::prelude::*;
899 + let client = Self::client()?;
900 + let request = parameters.0;
901 + let groups = client
902 + .list_beta_groups(&request.app_id)
903 + .await
904 + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
905 + Ok(::rmcp::model::CallToolResult::success(vec![
906 + ::rmcp::model::ContentBlock::json(&groups)?,
907 + ]))
908 + }
909 +
910 + #[::rmcp::tool(
911 + name = $beta_app_review_detail_get_name,
912 + title = "Get beta app review contact detail",
913 + annotations(title = "Get beta app review contact detail", read_only_hint = true, idempotent_hint = true),
914 + description = "Purpose: retrieve the contact information Apple's TestFlight review team will use when evaluating a beta submission, such as the first name, last name, phone number, email, and any demo account credentials. When to use vs siblings: call this to verify or surface the review contact before submitting a build for beta review; use beta_group_list to discover available groups. Behavior: returns the beta app review detail resource for the given app id. Prerequisites: ASC_API_KEY, ASC_ISSUER_ID, and a readable .p8 key at ASC_PRIVATE_KEY_PATH or ~/.appstoreconnect/private_keys/AuthKey_<key-id>.p8. Failure modes: missing or unreadable credentials return an invalid-request error naming what is absent; App Store Connect errors are returned verbatim with their status. Limitations: updating the contact detail is a CLI-only operation."
915 + )]
916 + async fn beta_app_review_detail_get(
917 + &self,
918 + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::BetaAppReviewDetailGetRequest>,
919 + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> {
920 + #[allow(unused_imports)]
921 + use ::smbcloud_ascapi_aso::prelude::*;
922 + let client = Self::client()?;
923 + let request = parameters.0;
924 + let detail = client
925 + .get_beta_app_review_detail(&request.app_id)
926 + .await
927 + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error.to_string(), None))?;
928 + Ok(::rmcp::model::CallToolResult::success(vec![
929 + ::rmcp::model::ContentBlock::json(&detail)?,
930 + ]))
931 + }
932 }
933 };
934 }
crates/mcp/src/requests.rs
+13
@@ -262,3 +262,16 @@ pub struct CertificateCreateRequest {
262 #[serde(default)]
263 pub common_name: Option<String>,
264 }
265 +
266 +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
267 +pub struct BetaGroupListRequest {
268 + /// App Store Connect app id whose beta groups to list.
269 + pub app_id: String,
270 +}
271 +
272 +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
273 +pub struct BetaAppReviewDetailGetRequest {
274 + /// App Store Connect app id whose beta app review contact detail to
275 + /// retrieve.
276 + pub app_id: String,
277 +}
crates/mcp/src/server.rs
+6 -2
@@ -56,7 +56,9 @@ ascapi_mcp_tools!(
56 "app_screenshot_upload",
57 "app_screenshot_delete",
58 "certificate_list",
59 - "certificate_create"
59 + "certificate_create",
60 + "beta_group_list",
61 + "beta_app_review_detail_get"
62 );
63
64 #[rmcp::tool_handler(router = Self::ascapi_tool_router())]
@@ -105,7 +107,7 @@ mod tests {
107 /// The canonical tool names. Adding or renaming one is a
108 /// public-contract change, and this list is what makes that
109 /// deliberate rather than accidental.
108 - const CANONICAL_TOOL_NAMES: [&str; 28] = [
110 + const CANONICAL_TOOL_NAMES: [&str; 30] = [
111 "app_list",
112 "app_get",
113 "app_update",
@@ -134,6 +136,8 @@ mod tests {
136 "app_screenshot_delete",
137 "certificate_list",
138 "certificate_create",
139 + "beta_group_list",
140 + "beta_app_review_detail_get",
141 ];
142
143 /// Never a tool, at any point, for any caller.