apple testflight groups: bare lists the groups and what the config would create (read-only); groups sync (or --yes) creates; flows: bare = read, sync = write; MCP tool runs groups sync

This commit is contained in:
CRTkafa 2026-10-12 10:48:59 +08:00
parent 1724296e59
commit 41d2ffcc24
12 changed files with 44 additions and 13 deletions

View file

@ -1,6 +1,7 @@
// TestFlight through the App Store Connect API.
// storebound apple testflight status [--json] groups, testers per group, recent builds + beta states
// storebound apple testflight groups [--dry] create the groups of apple.testflight.groups that are missing
// storebound apple testflight groups list the groups and what the config would create (read-only)
// storebound apple testflight groups sync [--dry] | groups --yes create the groups of apple.testflight.groups that are missing
// storebound apple testflight testers [--csv f] [--group a,b] [--remove] [--invite] [--dry] [--yes]
// storebound apple testflight what-to-test --build N [--version x.y.z] [--locale en-US,tr] [--text "..."] [--dry]
// storebound apple testflight info [--dry] Test Information + Beta App Review contact / notes
@ -32,7 +33,7 @@ import { describeReq, execute } from './jsonapi';
import { findOrCreate, getById } from '../lib/upsert';
const TAG = 'testflight';
const USAGE = 'usage: storebound apple testflight status | groups | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
const USAGE = 'usage: storebound apple testflight status | groups [sync] | testers | what-to-test --build N | info | distribute --build N --group <names> | wait --build N | expire --keep N | --older-than D';
/** The state of build `number` (of `marketing` version) right now; found: false while App Store Connect does not list it. */
async function seeBuild(c: Asc, appId: string, number: string, marketing?: string): Promise<BuildSeen & { version?: string }> {
@ -134,12 +135,22 @@ export async function run(ctx: Ctx): Promise<void> {
const d = details.get((b as J).relationships?.buildBetaDetail?.data?.id) as J;
return [b.marketing ?? '', b.attributes.version ?? '', b.attributes.processingState ?? '', d?.internalBuildState ?? '', d?.externalBuildState ?? '', (b.attributes.uploadedDate ?? '').slice(0, 10)];
})));
if (missingGroups.length) warn(TAG, `configured but not on App Store Connect: ${missingGroups.join(', ')} (storebound apple testflight groups)`);
if (missingGroups.length) warn(TAG, `configured but not on App Store Connect: ${missingGroups.join(', ')} (storebound apple testflight groups sync)`);
return;
}
if (sub === 'groups') {
const plan = planGroups(appId, await groups(c, appId), tf?.groups ?? []);
const gs = await groups(c, appId);
const plan = planGroups(appId, gs, tf?.groups ?? []);
// bare `groups` only lists: creating is `groups sync` (or `groups --yes`); a listing-sounding command never writes
const write = a._[1] === 'sync' || a.has('yes');
if (!write) {
for (const g of gs) log(TAG, `group "${g.name}": ${g.internal ? 'internal' : 'external'}${tf?.groups?.some(w => w.name === g.name) ? '' : ' (not in apple.testflight.groups)'}`);
if (!gs.length) log(TAG, 'no beta groups on App Store Connect yet');
const names = plan.create.map(r => `"${(r.body as J).data.attributes.name}"`);
done(TAG, names.length ? `would create ${names.join(', ')} (from apple.testflight.groups): storebound apple testflight groups sync` : 'every configured group exists');
return;
}
if (!tf?.groups?.length) warn(TAG, 'apple.testflight.groups is empty: nothing to create');
for (const n of plan.existing) log(TAG, `group "${n}": exists`);
if (dry) await execute(c.asc, plan.create, { dry, tag: TAG });
@ -166,7 +177,7 @@ export async function run(ctx: Ctx): Promise<void> {
const resp = await c.all(`/v1/betaTesters?filter[apps]=${appId}&include=betaGroups&limit=200`);
for (const t of resp) existing.set(String(t.attributes.email ?? '').toLowerCase(), { id: t.id, groupIds: (t.relationships?.betaGroups?.data ?? []).map((g: J) => g.id), state: t.attributes.state });
const plan = planTesters(gs, existing, want, { remove: a.has('remove'), only });
if (plan.unknownGroups.length) throw new Error(`unknown group(s): ${plan.unknownGroups.join(', ')} (storebound apple testflight groups creates the configured ones)`);
if (plan.unknownGroups.length) throw new Error(`unknown group(s): ${plan.unknownGroups.join(', ')} (storebound apple testflight groups sync creates the configured ones)`);
const internal = gs.filter(g => g.internal && want.some(t => t.groups.includes(g.name)));
if (internal.length) warn(TAG, `internal groups (${internal.map(g => g.name).join(', ')}) only take App Store Connect users of your team (Users and Access)`);
log(TAG, `${want.length} testers in the CSV: ${plan.summary.join(', ')}`);