102 lines
7.3 KiB
TypeScript
102 lines
7.3 KiB
TypeScript
// Submission state for the Releases tab, from `apple status --json` (apple.status) and `play status --json`
|
|
// (play.status): open review submissions with their items (UNRESOLVED_ISSUES, waiting, empty drafts that are
|
|
// harmless), the uploaded builds / versionCodes the owner picks by number (never "the latest"), and the editable
|
|
// version. Pure, so tests/submit.test.ts pins it; every field is read defensively (older sidecars, odd answers).
|
|
|
|
type J = Record<string, unknown>;
|
|
const obj = (x: unknown): J => (x && typeof x === 'object' && !Array.isArray(x) ? x as J : {});
|
|
const arr = (x: unknown): unknown[] => (Array.isArray(x) ? x : []);
|
|
const str = (x: unknown): string => (typeof x === 'string' ? x : typeof x === 'number' ? String(x) : '');
|
|
|
|
/** `apple status --json`, the parts used here. */
|
|
export interface AppleStatusData {
|
|
versions?: unknown[]; builds?: unknown[]; attachedBuilds?: unknown[]; buildVersions?: Record<string, string>;
|
|
subs?: unknown[]; unresolved?: { id: string; items: { id?: string; kind: string; versionId: string; state: string }[] }[]; emptyDrafts?: string[];
|
|
}
|
|
|
|
export type SubmissionKind = 'waiting' | 'inReview' | 'unresolved' | 'draft' | 'emptyDraft' | 'done' | 'other';
|
|
export interface SubmissionItem { id: string; kind: string; label: string; state: string; rejected: boolean }
|
|
export interface SubmissionView { id: string; state: string; kind: SubmissionKind; date: string | null; items: SubmissionItem[] }
|
|
export interface BuildChoice { id: string; number: string; version: string | null; state: string; uploaded: string | null; valid: boolean; attachedTo: string | null }
|
|
export interface AppleVersion { id: string; version: string; state: string; build: string | null }
|
|
export interface AppleSubmitState {
|
|
submissions: SubmissionView[];
|
|
/** The first submission back from App Review (UNRESOLVED_ISSUES), with its items. */
|
|
unresolved: SubmissionView | null;
|
|
/** A submission waiting for or in review: nothing can be sent next to it. */
|
|
busy: SubmissionView | null;
|
|
builds: BuildChoice[];
|
|
/** The version a submit sends: the one an open submission holds, else the editable one. */
|
|
editable: AppleVersion | null;
|
|
versions: AppleVersion[];
|
|
}
|
|
|
|
/** App Store version states a first submission may send (cli/apple/resubmit.ts SUBMITTABLE_APP). */
|
|
export const SUBMITTABLE_APP = ['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY', 'DEVELOPER_REJECTED'];
|
|
const KIND: Record<string, SubmissionKind> = { WAITING_FOR_REVIEW: 'waiting', IN_REVIEW: 'inReview', UNRESOLVED_ISSUES: 'unresolved', READY_FOR_REVIEW: 'draft', COMPLETE: 'done', CANCELING: 'other' };
|
|
const ITEM_NAME: Record<string, string> = { inAppPurchaseVersion: 'in-app purchase', subscriptionVersion: 'subscription', subscriptionGroupVersion: 'subscription group', appStoreVersion: 'app version' };
|
|
|
|
export function appleSubmitState(raw: unknown): AppleSubmitState {
|
|
const d = obj(raw) as AppleStatusData & J;
|
|
const versions: AppleVersion[] = arr(d.versions).map(v => {
|
|
const o = obj(v), a = obj(o.attributes);
|
|
return { id: str(o.id), version: str(a.versionString), state: str(a.appVersionState) || str(a.appStoreState), build: str(obj(obj(obj(o.relationships).build).data).id) || null };
|
|
}).filter(v => v.id && v.version);
|
|
const label = (kind: string, versionId: string): string => {
|
|
const v = versions.find(x => x.id === versionId);
|
|
return v && kind === 'appStoreVersion' ? `app version ${v.version}` : `${ITEM_NAME[kind] ?? kind} ${versionId}`;
|
|
};
|
|
const empty = new Set(arr(d.emptyDrafts).map(str));
|
|
const unresolvedItems = new Map(arr(d.unresolved).map(u => [str(obj(u).id), arr(obj(u).items)] as const));
|
|
const submissions: SubmissionView[] = arr(d.subs).map(s => {
|
|
const o = obj(s), a = obj(o.attributes), id = str(o.id), state = str(a.state);
|
|
const items = (unresolvedItems.get(id) ?? []).map(it => {
|
|
const i = obj(it), kind = str(i.kind), st = str(i.state);
|
|
return { id: str(i.id), kind, label: label(kind, str(i.versionId)), state: st, rejected: st === 'REJECTED' };
|
|
});
|
|
const kind: SubmissionKind = state === 'READY_FOR_REVIEW' && empty.has(id) ? 'emptyDraft' : KIND[state] ?? 'other';
|
|
return { id, state, kind, date: str(a.submittedDate) || null, items };
|
|
}).filter(s => s.id);
|
|
const pre = obj(d.buildVersions);
|
|
const attached = new Map(versions.filter(v => v.build).map(v => [v.build!, v.version] as const));
|
|
const seen = new Set<string>();
|
|
const builds: BuildChoice[] = [...arr(d.builds), ...arr(d.attachedBuilds)].map(b => {
|
|
const o = obj(b), a = obj(o.attributes), id = str(o.id);
|
|
return { id, number: str(a.version), version: str(pre[id]) || null, state: str(a.processingState), uploaded: str(a.uploadedDate) || null, valid: str(a.processingState) === 'VALID', attachedTo: attached.get(id) ?? null };
|
|
}).filter(b => b.id && b.number && !seen.has(b.id) && seen.add(b.id));
|
|
const unresolved = submissions.find(s => s.kind === 'unresolved') ?? null;
|
|
const busy = submissions.find(s => s.kind === 'waiting' || s.kind === 'inReview') ?? null;
|
|
const held = new Set(unresolved?.items.filter(i => i.kind === 'appStoreVersion').map(i => i.label) ?? []);
|
|
const editable = versions.find(v => held.has(`app version ${v.version}`)) ?? versions.find(v => SUBMITTABLE_APP.includes(v.state)) ?? null;
|
|
return { submissions, unresolved, busy, builds, editable, versions };
|
|
}
|
|
|
|
/** `play status --json`, the parts used here. */
|
|
export interface PlayTrackData { track: string; releases?: { name?: string; versionCodes?: (string | number)[]; status?: string; userFraction?: number }[] }
|
|
export interface VersionCodeChoice { code: string; tracks: string[]; name: string | null; inProduction: boolean }
|
|
export interface PlaySubmitState { codes: VersionCodeChoice[]; production: { status: string; codes: string[]; name: string | null; fraction: number | null }[]; category: string | null }
|
|
|
|
export function playSubmitState(raw: unknown): PlaySubmitState {
|
|
const d = obj(raw);
|
|
const byCode = new Map<string, VersionCodeChoice>();
|
|
const production: PlaySubmitState['production'] = [];
|
|
for (const tr of arr(d.tracks)) {
|
|
const t = obj(tr), track = str(t.track);
|
|
for (const r of arr(t.releases)) {
|
|
const o = obj(r), codes = arr(o.versionCodes).map(str).filter(c => /^\d+$/.test(c));
|
|
if (track === 'production') production.push({ status: str(o.status), codes, name: str(o.name) || null, fraction: typeof o.userFraction === 'number' ? o.userFraction : null });
|
|
for (const c of codes) {
|
|
const e = byCode.get(c) ?? { code: c, tracks: [], name: str(o.name) || null, inProduction: false };
|
|
if (!e.tracks.includes(track)) e.tracks.push(track);
|
|
if (track === 'production' && str(o.status) !== 'draft') e.inProduction = true;
|
|
byCode.set(c, e);
|
|
}
|
|
}
|
|
}
|
|
const codes = [...byCode.values()].sort((a, b) => Number(b.code) - Number(a.code));
|
|
return { codes, production, category: str(obj(d.category).config) || null };
|
|
}
|
|
|
|
/** The --json result of apple submit / resubmit (dry run): blockers and what it would do. */
|
|
export interface SubmitPlanData { dryRun?: boolean; version?: { versionString?: string; state?: string }; build?: { action?: string; reason?: string }; blockers?: string[]; rows?: { step: string; what: string; now: string; action: string }[] }
|
|
export const planBlockers = (data: unknown): string[] => arr(obj(data).blockers).map(str).filter(Boolean);
|