apple: macOS (tvOS, visionOS) versions are no longer invisible: every version and review submission lookup is per platform; status, listing, listing pull, resubmit, submit, release and screenshots take --platform and otherwise pick the platform that has the version they need (refuse naming both when several do); status, validate, release status, the report and the desktop label versions with their platform; MCP and owner actions get platform; pitfall 77
This commit is contained in:
parent
1736300784
commit
47dfd372fe
30 changed files with 423 additions and 127 deletions
|
|
@ -140,8 +140,9 @@ export async function ascClient(root: string, bundleId: string, appIdHint?: stri
|
|||
return appId;
|
||||
}
|
||||
|
||||
async function editableVersion(): Promise<J | null> {
|
||||
const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||
/** The editable version of one platform (iOS unless named: an app's macOS version has its own state). */
|
||||
async function editableVersion(platform = 'IOS'): Promise<J | null> {
|
||||
const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=${platform}&limit=50`);
|
||||
return vs.find(v => EDITABLE.has(v.attributes.appVersionState ?? v.attributes.appStoreState)) ?? null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ import { addItem, draftJournalKey, ensureDraft, leaveUnusedDraft, openSubmission
|
|||
|
||||
const TAG = 'iap-submit';
|
||||
|
||||
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string } = {}): Promise<SubmitState> {
|
||||
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
|
||||
export async function readSubmitState(c: Asc, appId: string, want: string[], o: { appVersionId?: string; also?: string; platform?: string } = {}): Promise<SubmitState> {
|
||||
const platform = o.platform ?? 'IOS';
|
||||
const appVersions = (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=50`)).map((v: J) => ({ id: v.id, versionString: v.attributes.versionString,
|
||||
state: v.attributes.appVersionState ?? v.attributes.appStoreState }));
|
||||
const iaps: PIap[] = [];
|
||||
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
||||
|
|
@ -45,7 +46,7 @@ export async function readSubmitState(c: Asc, appId: string, want: string[], o:
|
|||
versions: want.includes(s.attributes.productId) ? versionsOf(await c.all(`/v1/subscriptions/${s.id}/versions?limit=200`)) : [] });
|
||||
}
|
||||
// every open review submission: a draft or one back from App Review is reused, never a second one next to it
|
||||
const open = await openSubmissions(c, appId, o.also);
|
||||
const open = await openSubmissions(c, appId, o.also, platform);
|
||||
const choice = chooseSubmission(open, { appVersionId: o.appVersionId ?? appVersions.find((v: { state: string }) => /REJECTED|INVALID_BINARY/.test(v.state))?.id });
|
||||
const draft: SubmitState['draft'] = choice.action === 'reuse' ? { id: choice.sub.id, state: choice.sub.state, itemVersionIds: choice.sub.items.map(i => i.versionId) } : null;
|
||||
return { appVersions, iaps, subs, groups, draft, open, choice };
|
||||
|
|
@ -96,6 +97,8 @@ export interface SubmitOpts {
|
|||
appId: string; ids: string[]; state: SubmitState; tag: string; withVersion?: boolean;
|
||||
/** the app version that must be in the submission (resubmit) */
|
||||
appVersionId?: string;
|
||||
/** the App Store platform of the version and the submission (default iOS) */
|
||||
platform?: string;
|
||||
/** the caller printed its own plan (resubmit) */
|
||||
quiet?: boolean;
|
||||
/** REJECTED items marked resolved before the submit (resubmit: the version item; --resolve-iaps: the rest) */
|
||||
|
|
@ -116,7 +119,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
|
|||
const a = ctx.args;
|
||||
const dry = a.has('dry');
|
||||
const { state, tag } = o;
|
||||
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId });
|
||||
const plan = planIapSubmission(state, o.ids, { withVersion: o.withVersion, appVersionId: o.appVersionId, platform: o.platform });
|
||||
const prepare = plan.prepare.map(r => bindIds(r, { app: o.appId }));
|
||||
const submitting = willSubmit(state, plan);
|
||||
const busy = state.choice?.action === 'busy';
|
||||
|
|
@ -155,7 +158,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
|
|||
// already in it, and an item the store says is in there already (409) is not added twice
|
||||
let sid = state.draft?.id;
|
||||
let created = false;
|
||||
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag));
|
||||
if (!sid) ({ id: sid, created } = await ensureDraft(c, o.appId, root, tag, o.platform));
|
||||
try {
|
||||
for (const r of prepare) {
|
||||
if (r.path === '/v1/reviewSubmissions') continue;
|
||||
|
|
@ -175,7 +178,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise<vo
|
|||
if (o.retried || e.otherId === '?') throw e;
|
||||
// use the submission the 409 named: read the state again with it and go once more
|
||||
log(tag, `using review submission ${e.otherId} instead`);
|
||||
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId });
|
||||
const again = await readSubmitState(c, o.appId, o.ids, { appVersionId: o.appVersionId ?? plan.appVersion?.id, also: e.otherId, platform: o.platform });
|
||||
for (const l of choiceLines(again)) log(tag, l);
|
||||
await submitPlanned(ctx, c, { ...o, state: again, retried: true, quiet: true });
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// App Store version + listing text (idempotent; re-run after editing the App Store listing file).
|
||||
// storebound apple listing --version 1.0.0 [--build 3] [--include-drafts] [--dry] [--json]
|
||||
// storebound apple listing --version 1.0.0 [--platform ios|macos|tvos|visionos] [--build 3] [--include-drafts] [--dry] [--json]
|
||||
// The platform: --platform, else the one where that version is editable (both: asks), else the only one / iOS.
|
||||
// Every run first reads App Store Connect and prints the plan: the version (keep, update the editable one, create, or
|
||||
// refuse with the reason), the build, and per locale create / update with old -> new per field. --dry stops there
|
||||
// (read-only); the real run asks in a terminal and applies exactly that plan. Locales marked status=draft or
|
||||
|
|
@ -24,6 +25,7 @@ import { confirm, need } from '../lib/ctx';
|
|||
import { done, log, printJson, warn } from '../lib/log';
|
||||
import { ascClient, EDITABLE, type J, noAppInfo } from './asc';
|
||||
import { stdinIsTTY } from '../lib/tty';
|
||||
import { allVersions, type AscPlatform, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||
|
||||
export function readAppleCopy(ctx: Ctx) {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
|
|
@ -38,11 +40,14 @@ async function pull(ctx: Ctx): Promise<void> {
|
|||
const a = ctx.args;
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||
const want = a.get('version');
|
||||
const flag = parsePlatform(a.get('platform'));
|
||||
const platform = flag ?? pickPlatform(await allVersions(c, appId), want ? [(x: J) => x.attributes.versionString === want && EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), (x: J) => x.attributes.versionString === want] : (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState), undefined, want ? `version ${want}` : 'an editable version');
|
||||
const versions = await c.all(versionsPath(appId, platform));
|
||||
const sorted = [...versions].sort((x: J, y: J) => String(y.attributes.createdDate ?? '').localeCompare(String(x.attributes.createdDate ?? '')));
|
||||
const v = want ? sorted.find((x: J) => x.attributes.versionString === want) : (await c.editableVersion()) ?? sorted[0];
|
||||
if (!v) throw new Error(want ? `no iOS version ${want} on App Store Connect` : 'no iOS version on App Store Connect yet');
|
||||
const v = want ? sorted.find((x: J) => x.attributes.versionString === want) : (await c.editableVersion(platform)) ?? sorted[0];
|
||||
if (!v) throw new Error(want ? `no ${platformLabel(platform)} version ${want} on App Store Connect` : `no ${platformLabel(platform)} version on App Store Connect yet`);
|
||||
log('apple', `pulling version ${v.attributes.versionString} (${platformLabel(platform)})`);
|
||||
const infos = await c.all(`/v1/apps/${appId}/appInfos`);
|
||||
const live = v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
// the app info that belongs to the version: the editable one while a version is being prepared, else the live one
|
||||
|
|
@ -77,6 +82,8 @@ async function pull(ctx: Ctx): Promise<void> {
|
|||
/** The plan of one `apple listing --version` run, from read-only GETs. */
|
||||
export interface AppleListingPlan {
|
||||
version: string;
|
||||
/** the App Store platform of the version (IOS, MAC_OS, ...) */
|
||||
platform: AscPlatform;
|
||||
versionPlan: VersionPlan;
|
||||
build: { number: string; id: string | null; current: string | null; action: 'attach' | 'same' | 'refuse'; reason: string } | null;
|
||||
appInfo: { id: string; state: string; locales: ItemPlan[] };
|
||||
|
|
@ -90,7 +97,7 @@ export interface AppleListingPlan {
|
|||
|
||||
type Asc = Awaited<ReturnType<typeof ascClient>>;
|
||||
|
||||
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Promise<AppleListingPlan> {
|
||||
async function planListing(ctx: Ctx, c: Asc, appId: string, version: string, platform: AscPlatform): Promise<AppleListingPlan> {
|
||||
const { config, resolve } = ctx.loaded;
|
||||
const app = config.app;
|
||||
const listing = readAppleCopy(ctx);
|
||||
|
|
@ -106,13 +113,13 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Pr
|
|||
else { skipped.push({ key: l, reason: draftReason(st) }); delete listing[l]; }
|
||||
}
|
||||
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
|
||||
const rows = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' });
|
||||
const rows = await c.all(versionsPath(appId, platform));
|
||||
const versionPlan = planVersion(rows, version, { copyright, releaseType: 'MANUAL' }, platform);
|
||||
|
||||
let build: AppleListingPlan['build'] = null;
|
||||
const number = ctx.args.get('build');
|
||||
if (number) {
|
||||
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=IOS`))[0];
|
||||
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=${platform}`))[0];
|
||||
const cur = versionPlan.id ? (await c.maybe(`/v1/appStoreVersions/${versionPlan.id}/build`))?.data : null;
|
||||
const current = cur ? String(cur.attributes?.version ?? cur.id) : null;
|
||||
build = !b ? { number, id: null, current, action: 'refuse', reason: `build ${version} (${number}) not found (still uploading? check TestFlight)` }
|
||||
|
|
@ -133,14 +140,14 @@ async function planListing(ctx: Ctx, c: Asc, appId: string, version: string): Pr
|
|||
|
||||
const changes = (versionPlan.action === 'create' || versionPlan.action === 'update' ? 1 : 0) + (build?.action === 'attach' ? 1 : 0)
|
||||
+ infoLocs.filter(x => x.action !== 'same').length + (versionLocales ? versionLocales.filter(x => x.action !== 'same').length : Object.keys(wanted).length);
|
||||
return { version, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
|
||||
return { version, platform, versionPlan, build, appInfo: { id: info.id, state: String(info.attributes?.state ?? info.attributes?.appStoreState ?? ''), locales: infoLocs },
|
||||
versionLocales, wanted, skipped, notes, changes };
|
||||
}
|
||||
|
||||
/** The plan as text lines. Pure. */
|
||||
export function listingPlanLines(p: AppleListingPlan): string[] {
|
||||
const v = p.versionPlan;
|
||||
const out = [`Version (App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${x.platform}`).join(', ') : 'no iOS version yet'})`];
|
||||
const out = [`Version ${p.version} (${platformLabel(p.platform)}; App Store Connect has: ${v.existing.length ? v.existing.map(x => `${x.versionString} ${x.state} ${platformLabel(x.platform)}`).join(', ') : `no ${platformLabel(p.platform)} version yet`})`];
|
||||
out.push(` ${v.action === 'refuse' ? 'REFUSE' : v.action.toUpperCase()}: ${v.reason}`);
|
||||
for (const ch of v.changes) out.push(` ${ch.field}: ${v.action === 'create' ? short(ch.to) : `${short(ch.from)} -> ${short(ch.to)}`}`);
|
||||
if (p.build) out.push(`Build: ${p.build.action === 'refuse' ? 'REFUSE: ' : ''}${p.build.reason}`);
|
||||
|
|
@ -186,7 +193,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const dry = a.has('dry');
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const p = await planListing(ctx, c, appId, version);
|
||||
// the platform: --platform, else the one where this version string is editable, else the one with an editable
|
||||
// version; several -> ask (an app's iOS 0.8.7 can be live while its macOS 0.8.7 is rejected)
|
||||
const isEd = (x: J) => EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState);
|
||||
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), [(x: J) => x.attributes.versionString === version && isEd(x), isEd], undefined, `an editable version (${version})`);
|
||||
const p = await planListing(ctx, c, appId, version, platform);
|
||||
const refused = [p.versionPlan.action === 'refuse' ? p.versionPlan.reason : '', p.build?.action === 'refuse' ? p.build.reason : ''].filter(Boolean);
|
||||
if (a.has('json')) printJson({ dry, ...p, refused });
|
||||
else for (const line of listingPlanLines(p)) console.log(line);
|
||||
|
|
@ -200,9 +211,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (vp.action === 'create') {
|
||||
// look again right before the create (another run, or a lost answer), then the journal for the list lag
|
||||
const made = await findOrCreate<J>({ what: `App Store version ${version}`, tag: 'apple', unique: true,
|
||||
find: async () => (await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`))
|
||||
find: async () => (await c.all(versionsPath(appId, p.platform)))
|
||||
.find((x: J) => x.attributes.versionString === version && EDITABLE.has(x.attributes.appVersionState ?? x.attributes.appStoreState)) ?? null,
|
||||
journal: { root, key: `apple:${appId}:version:IOS:${version}`, byId: id => getById(c.asc, `/v1/appStoreVersions/${id}`) as Promise<J | null>, idOf: v => v.id },
|
||||
journal: { root, key: `apple:${appId}:version:${p.platform}:${version}`, byId: id => getById(c.asc, `/v1/appStoreVersions/${id}`) as Promise<J | null>, idOf: v => v.id },
|
||||
create: async () => (await c.asc('POST', '/v1/appStoreVersions', { data: { type: 'appStoreVersions',
|
||||
attributes: Object.fromEntries(Object.entries(vp.attributes)), relationships: { app: { data: { type: 'apps', id: appId } } } } })).data });
|
||||
versionId = made.value.id;
|
||||
|
|
|
|||
61
cli/apple/platform.ts
Normal file
61
cli/apple/platform.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// App Store platforms of one app record. An app can have an iOS and a macOS (tvOS, visionOS) version at the same time,
|
||||
// each with its own state and its own review submission (pitfall 77): every version lookup filters by platform, and a
|
||||
// command that needs one version picks the platform that has it, or asks for --platform when several do.
|
||||
import type { Asc, J } from './asc';
|
||||
|
||||
export type AscPlatform = 'IOS' | 'MAC_OS' | 'TV_OS' | 'VISION_OS';
|
||||
export const ASC_PLATFORMS: AscPlatform[] = ['IOS', 'MAC_OS', 'TV_OS', 'VISION_OS'];
|
||||
const FLAG: Record<string, AscPlatform> = { ios: 'IOS', macos: 'MAC_OS', tvos: 'TV_OS', visionos: 'VISION_OS' };
|
||||
const LABEL: Record<AscPlatform, string> = { IOS: 'iOS', MAC_OS: 'macOS', TV_OS: 'tvOS', VISION_OS: 'visionOS' };
|
||||
/** The console's URL segment of a platform's version page. */
|
||||
export const CONSOLE_SEGMENT: Record<AscPlatform, string> = { IOS: 'ios', MAC_OS: 'macos', TV_OS: 'appletvos', VISION_OS: 'visionos' };
|
||||
|
||||
/** --platform ios|macos|tvos|visionos (also the API names IOS, MAC_OS, ...) -> the API's platform; undefined when not
|
||||
* given; throws on anything else. Pure. */
|
||||
export function parsePlatform(v: string | undefined): AscPlatform | undefined {
|
||||
if (v == null || v === '') return undefined;
|
||||
const k = v.toLowerCase().replace(/[_\s-]/g, '');
|
||||
const p = FLAG[k] ?? FLAG[k.replace(/^appletv/, 'tv')];
|
||||
if (!p) throw new Error(`--platform: ios | macos | tvos | visionos (got "${v}")`);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** "iOS", "macOS", ... for any platform string (unknown ones as they are). Pure. */
|
||||
export const platformLabel = (p: string | undefined | null): string => LABEL[(p ?? 'IOS') as AscPlatform] ?? String(p);
|
||||
|
||||
export const platformOf = (v: J): AscPlatform => (v?.attributes?.platform ?? 'IOS') as AscPlatform;
|
||||
export const stateOf = (v: J): string => v?.attributes?.appVersionState ?? v?.attributes?.appStoreState ?? '';
|
||||
/** "0.8.7 (macOS)": a version as people read it. */
|
||||
export const versionLabel = (v: J): string => `${v?.attributes?.versionString ?? '?'} (${platformLabel(platformOf(v))})`;
|
||||
|
||||
/** The appStoreVersions list path of one platform. */
|
||||
export const versionsPath = (appId: string, platform: AscPlatform, limit = 50): string =>
|
||||
`/v1/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=${limit}`;
|
||||
|
||||
/**
|
||||
* The platform a command works on. `flag` (--platform) wins. Else the platforms that have a version `needs` accepts:
|
||||
* one -> that one; several -> an error naming them all (ask for --platform); none -> the only platform the app has
|
||||
* versions on, else iOS. Pure.
|
||||
*/
|
||||
export function pickPlatform(versions: J[], needs: ((v: J) => boolean) | ((v: J) => boolean)[], flag: AscPlatform | undefined, what: string): AscPlatform {
|
||||
if (flag) return flag;
|
||||
// several tests: the first one any version passes decides (e.g. "this version string, editable", then "editable")
|
||||
if (Array.isArray(needs)) {
|
||||
for (const n of needs) if (versions.some(n)) return pickPlatform(versions, n, undefined, what);
|
||||
return pickPlatform(versions, () => false, undefined, what);
|
||||
}
|
||||
const hits = [...new Set(versions.filter(needs).map(platformOf))];
|
||||
if (hits.length === 1) return hits[0]!;
|
||||
if (hits.length > 1) {
|
||||
const which = versions.filter(needs).map(v => `${versionLabel(v)} ${stateOf(v)}`).join(', ');
|
||||
throw new Error(`${what} on more than one platform (${which}): run it again with --platform ${hits.map(p => Object.keys(FLAG).find(k => FLAG[k] === p)).join(' or --platform ')}`);
|
||||
}
|
||||
const all = [...new Set(versions.map(platformOf))];
|
||||
return all.length === 1 ? all[0]! : 'IOS';
|
||||
}
|
||||
|
||||
/** Every App Store version of the app on every platform (only to pick the platform; the command then reads its
|
||||
* platform's list with filter[platform]). */
|
||||
export async function allVersions(c: Asc, appId: string): Promise<J[]> {
|
||||
return c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=${ASC_PLATFORMS.join(',')}&limit=200`);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Release management for an App Store version.
|
||||
// storebound apple release --phased on|pause|resume|complete [--version 1.2.0] [--dry] [--yes]
|
||||
// storebound apple release --manual-release [--version 1.2.0] [--yes]
|
||||
// Both take --platform ios|macos|tvos|visionos; without it the platform that has the version this needs is used (both: asks).
|
||||
// Phased release: 7-day automatic rollout to users with automatic updates (1, 2, 5, 10, 20, 50, 100 %); anyone can
|
||||
// still update by hand from the App Store. Pause holds it (up to 30 days in total), it is NOT a rollback: users who
|
||||
// updated keep the build. "complete" releases to everyone at once.
|
||||
|
|
@ -12,6 +13,7 @@ import { done, log } from '../lib/log';
|
|||
import { type PhasedAction, phasedRequest } from '../lib/release';
|
||||
import { createOnce } from '../lib/upsert';
|
||||
import { ascClient, type J } from './asc';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, versionsPath } from './platform';
|
||||
|
||||
export async function run(ctx: Ctx): Promise<void> {
|
||||
const { config, root } = ctx.loaded;
|
||||
|
|
@ -23,15 +25,20 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const dry = a.has('dry');
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const appId = await c.app();
|
||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=20`);
|
||||
const want = a.get('version');
|
||||
const state = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
// the platform: --platform, else the one with the version this needs (both: asks)
|
||||
const needs = (x: J): boolean => (!want || x.attributes.versionString === want) && (manual ? state(x) === 'PENDING_DEVELOPER_RELEASE'
|
||||
: phased === 'on' ? !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x))
|
||||
: ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE'].includes(state(x)));
|
||||
const platform = parsePlatform(a.get('platform')) ?? pickPlatform(await allVersions(c, appId), want ? [needs, (x: J) => x.attributes.versionString === want] : needs, undefined, manual ? 'a version waiting for developer release' : `a version for phased release ${phased}`);
|
||||
const versions = await c.all(versionsPath(appId, platform, 20));
|
||||
const v: J | undefined = want ? versions.find(x => x.attributes.versionString === want)
|
||||
: manual ? versions.find(x => state(x) === 'PENDING_DEVELOPER_RELEASE')
|
||||
: phased === 'on' ? versions.find(x => !['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'REPLACED_WITH_NEW_VERSION', 'REMOVED_FROM_SALE'].includes(state(x)))
|
||||
: versions.find(x => ['READY_FOR_DISTRIBUTION', 'READY_FOR_SALE'].includes(state(x))) ?? versions[0];
|
||||
if (!v) throw new Error(want ? `no version ${want}` : manual ? 'no version waiting for developer release (PENDING_DEVELOPER_RELEASE)' : 'no matching version');
|
||||
const tag = `${v.attributes.versionString} (${state(v)})`;
|
||||
if (!v) throw new Error(`${platformLabel(platform)}: ${want ? `no version ${want}` : manual ? 'no version waiting for developer release (PENDING_DEVELOPER_RELEASE)' : 'no matching version'}`);
|
||||
const tag = `${v.attributes.versionString} ${platformLabel(platform)} (${state(v)})`;
|
||||
|
||||
if (manual) {
|
||||
if (state(v) !== 'PENDING_DEVELOPER_RELEASE') throw new Error(`${tag}: only an approved version waiting for developer release can be released`);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Resubmit an App Store version after a rejection (Invalid Binary, Guideline 2.1, ...) with a new build.
|
||||
// storebound apple resubmit --build <n> [--version 1.0.0] [--resolve-iaps] [--dry] [--yes] [--json]
|
||||
// storebound apple resubmit --build <n> [--version 1.0.0] [--platform ios|macos|tvos|visionos] [--resolve-iaps] [--dry] [--yes] [--json]
|
||||
// The platform: --platform, else the one with a rejected version or an UNRESOLVED_ISSUES submission (both: asks).
|
||||
// 1. The rejected version (--version, else the one in the submission back from App Review, else the one in a
|
||||
// rejected state) gets build n attached, by explicit number (never "the latest"); it must be VALID.
|
||||
// 2. The open review submission is reused: the one with UNRESOLVED_ISSUES (it holds the version and its in-app
|
||||
|
|
@ -25,6 +26,7 @@ import { allSubscriptions } from '../lib/subscriptions';
|
|||
import { ascClient, type J } from './asc';
|
||||
import { readSubmitState, submitPlanned } from './iap-submit';
|
||||
import { openSubmissions } from './submission';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionsPath } from './platform';
|
||||
|
||||
/** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected
|
||||
* one, or one already Ready for Review in a draft). */
|
||||
|
|
@ -43,23 +45,33 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
const appId = await c.app();
|
||||
const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)];
|
||||
|
||||
// the version to resubmit
|
||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||
// the platform: --platform, else the one whose version (or UNRESOLVED_ISSUES submission) this needs; both -> ask
|
||||
const stateOf = (v: J): string => v.attributes.appVersionState ?? v.attributes.appStoreState;
|
||||
const open = await openSubmissions(c, appId);
|
||||
const held = new Set(open.filter(s => s.state === 'UNRESOLVED_ISSUES').flatMap(s => s.items.filter(i => i.kind === 'appStoreVersion').map(i => i.versionId)));
|
||||
const want = a.get('version');
|
||||
const flag = parsePlatform(a.get('platform'));
|
||||
let platform = flag;
|
||||
if (!platform) {
|
||||
const every = await allVersions(c, appId);
|
||||
const back = new Set((await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[state]=UNRESOLVED_ISSUES&limit=50`)).map((s: J) => String(s.attributes?.platform ?? 'IOS')));
|
||||
const fits = (x: J) => (!want || x.attributes.versionString === want)
|
||||
&& ((mode === 'submit' ? SUBMITTABLE_APP : REJECTED_APP).has(stateOf(x)) || back.has(platformOf(x)));
|
||||
platform = pickPlatform(every, fits, undefined, mode === 'submit' ? `a version to submit${want ? ` (${want})` : ''}` : `a rejected version${want ? ` (${want})` : ''}`);
|
||||
}
|
||||
const versions = await c.all(versionsPath(appId, platform));
|
||||
const open = await openSubmissions(c, appId, undefined, platform);
|
||||
const held = new Set(open.filter(s => s.state === 'UNRESOLVED_ISSUES').flatMap(s => s.items.filter(i => i.kind === 'appStoreVersion').map(i => i.versionId)));
|
||||
const pl = platformLabel(platform);
|
||||
const v: J | undefined = want ? versions.find((x: J) => x.attributes.versionString === want)
|
||||
: versions.find((x: J) => held.has(x.id)) ?? versions.find((x: J) => (mode === 'submit' ? SUBMITTABLE_APP : REJECTED_APP).has(stateOf(x)));
|
||||
if (!v) throw new Error(want ? `no App Store version ${want} on iOS`
|
||||
: mode === 'submit' ? 'no App Store version to submit (none in Prepare for Submission or rejected): storebound apple listing --version x.y.z first'
|
||||
: 'no rejected App Store version and no review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status)');
|
||||
if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
|
||||
if (!v) throw new Error(want ? `no App Store version ${want} on ${pl}`
|
||||
: mode === 'submit' ? `no ${pl} App Store version to submit (none in Prepare for Submission or rejected): storebound apple listing --version x.y.z first`
|
||||
: `no rejected ${pl} App Store version and no ${pl} review submission with UNRESOLVED_ISSUES: nothing to resubmit (storebound apple status; another platform: --platform)`);
|
||||
if (mode === 'submit' && !SUBMITTABLE_APP.has(stateOf(v)) && !held.has(v.id)) throw new Error(`App Store version ${String(v.attributes.versionString)} (${pl}) is ${stateOf(v)}: only a version in Prepare for Submission (or a rejected one) can be submitted`);
|
||||
const versionString = String(v.attributes.versionString);
|
||||
if (!a.has('json')) log(TAG, `app version ${versionString}: ${stateOf(v)}`);
|
||||
if (!a.has('json')) log(TAG, `app version ${versionString} (${pl}): ${stateOf(v)}`);
|
||||
|
||||
// the build, by its number
|
||||
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${versionString}&filter[preReleaseVersion.platform]=IOS`))[0];
|
||||
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${number}&filter[preReleaseVersion.version]=${versionString}&filter[preReleaseVersion.platform]=${platform}`))[0];
|
||||
const cur = (await c.maybe(`/v1/appStoreVersions/${v.id}/build`))?.data;
|
||||
const current = cur ? String(cur.attributes?.version ?? cur.id) : null;
|
||||
const build: BuildStep = !b ? { number, id: null, current, action: 'refuse', reason: `build ${versionString} (${number}) not found (still uploading or processing? storebound apple status)` }
|
||||
|
|
@ -68,8 +80,8 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
: { number, id: b.id, current, action: 'attach', reason: `attach build ${number}${current ? ` instead of ${current}` : ''}` };
|
||||
if (b && b.attributes.usesNonExemptEncryption == null) warn(TAG, `build ${number} has no export compliance answer yet: storebound apple encryption (Apple refuses the submission without it)`);
|
||||
|
||||
const state = await readSubmitState(c, appId, ids, { appVersionId: v.id });
|
||||
const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id });
|
||||
const state = await readSubmitState(c, appId, ids, { appVersionId: v.id, platform });
|
||||
const plan = planIapSubmission(state, ids, { withVersion: true, appVersionId: v.id, platform });
|
||||
// --build is a fixed build for the version: its REJECTED item is marked resolved; IAP items only on --resolve-iaps
|
||||
const resolve: ResolveScope = { version: true, iaps: a.has('resolve-iaps') };
|
||||
const rows = resubmitRows(state, plan, build, resolve);
|
||||
|
|
@ -77,7 +89,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
const blockers = [...(build.action === 'refuse' ? [build.reason] : []), ...plan.blockers];
|
||||
const dry = a.has('dry') || !a.has('yes');
|
||||
if (a.has('json')) {
|
||||
printJson({ dryRun: dry, version: { id: v.id, versionString, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
printJson({ dryRun: dry, version: { id: v.id, versionString, platform, state: stateOf(v) }, build, submission: state.choice ?? null, resolve: marks, rows, skipped: plan.skipped, blockers });
|
||||
// the desktop's owner dialog shows the output text: the plan table goes with the result there
|
||||
if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); }
|
||||
if (blockers.length) process.exitCode = 1;
|
||||
|
|
@ -98,5 +110,5 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
|
|||
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } });
|
||||
log(TAG, `build ${number} attached to ${versionString}`);
|
||||
}
|
||||
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, quiet: true, resolve });
|
||||
await submitPlanned(ctx, c, { appId, ids, state, tag: TAG, withVersion: true, appVersionId: v.id, platform, quiet: true, resolve });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import { findOrCreate } from '../lib/upsert';
|
|||
import { pulledChecksum } from '../lib/art-pull';
|
||||
import { consoleLink } from '../lib/console-links';
|
||||
import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
|
||||
import { type Asc, ascClient, keptPrefix, md5, setUpToDate, type J } from './asc';
|
||||
import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform';
|
||||
|
||||
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
|
||||
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
|
||||
|
|
@ -83,8 +84,11 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const only = ctx.args.list('only');
|
||||
const jobs = ctx.args.num('jobs', 4);
|
||||
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||
const version = await c.editableVersion();
|
||||
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
|
||||
// the platform: --platform, else the one with an editable version (both: asks)
|
||||
const platform = parsePlatform(ctx.args.get('platform')) ?? pickPlatform(await allVersions(c, await c.app()), x => EDITABLE.has(stateOf(x)), undefined, 'an editable version');
|
||||
const version = await c.editableVersion(platform);
|
||||
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`);
|
||||
log('apple', `screenshots for version ${versionLabel(version)}`);
|
||||
const duoSkip = skippedDuoSets(config.apple);
|
||||
if (duoSkip) warn('apple', duoSkip.msg);
|
||||
const langOf = byAppleLocale(config.languages);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Read-only overview: app record, versions + attached build, recent builds, in-app purchases, review submissions.
|
||||
// storebound apple status [--json]
|
||||
// storebound apple status [--platform ios|macos|tvos|visionos] [--json]
|
||||
// Versions and review submissions of every platform, each labelled (iOS, macOS, ...); --platform shows one.
|
||||
// And the one write it offers: cancel a review submission (to re-add first IAPs to it from the web UI):
|
||||
// storebound apple status --cancel-submission <id> [--yes]
|
||||
// An empty draft (READY_FOR_REVIEW, 0 items) cannot be canceled (409 "Resource is not in cancellable state"); it is
|
||||
|
|
@ -23,6 +24,7 @@ import { type Asc, ascClient, type J } from './asc';
|
|||
import { notCancellable, readItems, type SubItem } from './submission';
|
||||
import { capabilityReport, type CapabilityReport, printCapabilityReport } from './signing';
|
||||
import * as rt from '../lib/rt';
|
||||
import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionLabel, versionsPath } from './platform';
|
||||
|
||||
async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
||||
const want = ctx.args.get('version');
|
||||
|
|
@ -31,12 +33,14 @@ async function waitReview(ctx: Ctx, c: Asc, appId: string): Promise<never> {
|
|||
let delay: number | null = null;
|
||||
let last = '';
|
||||
for (;;) {
|
||||
const vs = await c.all(`/v1/apps/${appId}/appStoreVersions?filter[platform]=IOS&limit=10`);
|
||||
const flag = parsePlatform(ctx.args.get('platform'));
|
||||
const platform = flag ?? pickPlatform(await allVersions(c, appId), x => (!want || x.attributes.versionString === want) && classifyReviewState(state(x)) === 'pending', undefined, 'a version in review');
|
||||
const vs = await c.all(versionsPath(appId, platform, 10));
|
||||
const v = want ? vs.find(x => x.attributes.versionString === want) : vs.find(x => classifyReviewState(state(x)) === 'pending') ?? vs[0];
|
||||
if (!v) { log('apple', `no version ${want ?? ''} found`); process.exit(2); }
|
||||
const st = state(v);
|
||||
const outcome = classifyReviewState(st);
|
||||
if (st !== last) { log('apple', `${v.attributes.versionString}: ${st}`); last = st; }
|
||||
if (st !== last) { log('apple', `${versionLabel(v)}: ${st}`); last = st; }
|
||||
if (outcome === 'approved') { log('apple', 'approved (release it yourself, or it goes out automatically if the release type says so)'); process.exit(WAIT_EXIT.approved); }
|
||||
if (outcome === 'rejected') { log('apple', `rejected: read the message in ${consoleLink('asc.app-review')}, then the review-rejections skill`); process.exit(WAIT_EXIT.rejected); }
|
||||
if (outcome === 'not-submitted') { log('apple', 'nothing is waiting for review'); process.exit(2); }
|
||||
|
|
@ -70,15 +74,17 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
|
||||
const app = (await c.asc('GET', `/v1/apps/${appId}`)).data;
|
||||
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?limit=10&include=build`);
|
||||
// every platform's versions (an app's macOS version has its own state), or one with --platform
|
||||
const only = parsePlatform(ctx.args.get('platform'));
|
||||
const versions = await c.all(only ? `${versionsPath(appId, only, 20)}&include=build` : `/v1/apps/${appId}/appStoreVersions?limit=20&include=build`);
|
||||
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
|
||||
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
||||
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
|
||||
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}${only ? `&filter[platform]=${only}` : ''}&limit=20`);
|
||||
let caps: CapabilityReport | { error: string };
|
||||
try { caps = await capabilityReport(c, config, root); } catch (e) { caps = { error: (e as Error).message.slice(0, 300) }; }
|
||||
// a submission back from App Review: its items and their states, and how to resubmit it
|
||||
const unresolved: { id: string; items: SubItem[] }[] = [];
|
||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, items: await readItems(c, s.id) });
|
||||
const unresolved: { id: string; platform: string; items: SubItem[] }[] = [];
|
||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'UNRESOLVED_ISSUES')) unresolved.push({ id: s.id, platform: String(s.attributes?.platform ?? 'IOS'), items: await readItems(c, s.id) });
|
||||
// an empty draft: App Store Connect keeps it and refuses to cancel it; harmless
|
||||
const empty = new Set<string>();
|
||||
for (const s of subs.filter((x: J) => x.attributes?.state === 'READY_FOR_REVIEW')) if (!(await readItems(c, s.id)).length) empty.add(s.id);
|
||||
|
|
@ -89,7 +95,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try { gaps = await firstProductReport(c, appId, { iaps, versionState: reviewed ? vstate(reviewed) : undefined }); } catch (e) { log('apple', `first in-app purchases not compared with the review: ${(e as Error).message.slice(0, 200)}`); }
|
||||
const itemLabel = (kind: string, id: string): string => {
|
||||
const v = versions.find((x: J) => x.id === id);
|
||||
return v ? `app version ${v.attributes.versionString}` : `${kind} ${id}`;
|
||||
return v ? `app version ${versionLabel(v)}` : `${kind} ${id}`;
|
||||
};
|
||||
if (ctx.args.has('json')) {
|
||||
// phased release of the newest versions that are live or close to it (rollout share on the desktop timeline)
|
||||
|
|
@ -115,16 +121,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
|
||||
console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`);
|
||||
for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`);
|
||||
for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${platformLabel(platformOf(v)).padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`);
|
||||
const pre = new Map((builds.included ?? []).map((p: { id: string; attributes: { version: string } }) => [p.id, p.attributes.version]));
|
||||
for (const b of builds.data) console.log(` build ${String(pre.get(b.relationships?.preReleaseVersion?.data?.id) ?? '?')} (${b.attributes.version}) ${b.attributes.processingState} ${b.attributes.uploadedDate?.slice(0, 10) ?? ''}`);
|
||||
for (const p of iaps) console.log(` iap ${p.attributes.productId.padEnd(40)} ${p.attributes.inAppPurchaseType} ${p.attributes.state}`);
|
||||
for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state}${empty.has(s.id) ? `: ${EMPTY_DRAFT}` : ''} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`.trimEnd());
|
||||
for (const s of subs) console.log(` review submission ${s.id} ${platformLabel(s.attributes.platform)} ${s.attributes.state}${empty.has(s.id) ? `: ${EMPTY_DRAFT}` : ''} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`.trimEnd());
|
||||
for (const u of unresolved) {
|
||||
console.log(` review submission ${u.id} is back from App Review with UNRESOLVED_ISSUES; its items:`);
|
||||
console.log(` review submission ${u.id} (${platformLabel(u.platform)}) is back from App Review with UNRESOLVED_ISSUES; its items:`);
|
||||
for (const it of u.items) console.log(` ${itemLabel(it.kind, it.versionId).padEnd(36)} ${it.state}${it.state === 'REJECTED' ? ' (not resolved: Apple takes no resubmit until it is; apple resubmit marks it resolved)' : ''}`);
|
||||
const newest = builds.data[0]?.attributes?.version;
|
||||
console.log(` resubmit with: storebound apple resubmit --build <n>${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
||||
console.log(` resubmit with: storebound apple resubmit --build <n>${u.platform === 'IOS' ? '' : ` --platform ${platformLabel(u.platform).toLowerCase()}`}${newest ? ` (newest uploaded build: ${newest})` : ''}`);
|
||||
}
|
||||
for (const g of gaps) warn('apple', `${g.msg}; ${g.fix}`);
|
||||
if ('error' in caps) log('apple', `capabilities not compared: ${caps.error}`);
|
||||
|
|
|
|||
|
|
@ -9,26 +9,26 @@ import type { Asc, J } from './asc';
|
|||
import { one } from './jsonapi';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
/** The draft review submission of the app (READY_FOR_REVIEW, iOS), or null. */
|
||||
export async function findDraft(c: Asc, appId: string): Promise<string | null> {
|
||||
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=IOS&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
|
||||
/** The draft review submission of the app (READY_FOR_REVIEW) on one platform (iOS unless named), or null. */
|
||||
export async function findDraft(c: Asc, appId: string, platform = 'IOS'): Promise<string | null> {
|
||||
return (await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&filter[platform]=${platform}&filter[state]=READY_FOR_REVIEW&limit=10`))[0]?.id ?? null;
|
||||
}
|
||||
|
||||
/** The draft's id: the existing one, else a new draft created once. */
|
||||
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string): Promise<string> {
|
||||
return (await ensureDraft(c, appId, root, tag)).id;
|
||||
export async function draftSubmission(c: Asc, appId: string, root: string, tag: string, platform = 'IOS'): Promise<string> {
|
||||
return (await ensureDraft(c, appId, root, tag, platform)).id;
|
||||
}
|
||||
|
||||
/** The journal key of the draft storebound created (cli/lib/upsert.ts). */
|
||||
export const draftJournalKey = (appId: string): string => `apple:${appId}:reviewSubmission:draft`;
|
||||
export const draftJournalKey = (appId: string, platform = 'IOS'): string => `apple:${appId}:reviewSubmission:draft${platform === 'IOS' ? '' : `:${platform}`}`;
|
||||
|
||||
/** Like draftSubmission, and says whether this run created the draft (an unused empty one is left: not cancellable). */
|
||||
export async function ensureDraft(c: Asc, appId: string, root: string, tag: string): Promise<{ id: string; created: boolean }> {
|
||||
export async function ensureDraft(c: Asc, appId: string, root: string, tag: string, platform = 'IOS'): Promise<{ id: string; created: boolean }> {
|
||||
const r = await findOrCreate<string>({ what: 'draft review submission', tag, unique: true,
|
||||
find: () => findDraft(c, appId),
|
||||
journal: { root, key: draftJournalKey(appId), idOf: id => id,
|
||||
find: () => findDraft(c, appId, platform),
|
||||
journal: { root, key: draftJournalKey(appId, platform), idOf: id => id,
|
||||
byId: async id => { const d = (await c.maybe(`/v1/reviewSubmissions/${id}`))?.data; return d?.attributes?.state === 'READY_FOR_REVIEW' ? id : null; } },
|
||||
create: async () => (await c.asc('POST', '/v1/reviewSubmissions', { data: { type: 'reviewSubmissions', attributes: { platform: 'IOS' }, relationships: { app: one('apps', appId) } } })).data.id as string });
|
||||
create: async () => (await c.asc('POST', '/v1/reviewSubmissions', { data: { type: 'reviewSubmissions', attributes: { platform }, relationships: { app: one('apps', appId) } } })).data.id as string });
|
||||
if (r.created) log(tag, `draft review submission ${r.value} created`);
|
||||
return { id: r.value, created: r.created };
|
||||
}
|
||||
|
|
@ -81,10 +81,10 @@ export async function readItems(c: Asc, sid: string): Promise<SubItem[]> {
|
|||
});
|
||||
}
|
||||
|
||||
/** Every open review submission of the app on iOS (OPEN_STATES), each with its items. `also`: a submission id a 409
|
||||
* named, read directly (a list may lag behind, or its state may be outside the filter). */
|
||||
export async function openSubmissions(c: Asc, appId: string, also?: string): Promise<OpenSubmission[]> {
|
||||
const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=IOS&filter[state]=${OPEN_STATES.join(',')}&include=items&limit=50`);
|
||||
/** Every open review submission of the app on one platform (iOS unless named; OPEN_STATES), each with its items.
|
||||
* `also`: a submission id a 409 named, read directly (a list may lag behind, or its state may be outside the filter). */
|
||||
export async function openSubmissions(c: Asc, appId: string, also?: string, platform = 'IOS'): Promise<OpenSubmission[]> {
|
||||
const subs = await c.all(`/v1/apps/${appId}/reviewSubmissions?filter[platform]=${platform}&filter[state]=${OPEN_STATES.join(',')}&include=items&limit=50`);
|
||||
if (also && !subs.some((x: J) => x.id === also)) { const d = (await c.maybe(`/v1/reviewSubmissions/${also}`))?.data; if (d) subs.push(d); }
|
||||
const out: OpenSubmission[] = [];
|
||||
for (const x of subs) out.push({ id: x.id, state: x.attributes?.state ?? '?', submittedDate: x.attributes?.submittedDate ?? undefined, items: await readItems(c, x.id) });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue