// 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; 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; 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 = { WAITING_FOR_REVIEW: 'waiting', IN_REVIEW: 'inReview', UNRESOLVED_ISSUES: 'unresolved', READY_FOR_REVIEW: 'draft', COMPLETE: 'done', CANCELING: 'other' }; const ITEM_NAME: Record = { 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(); 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(); 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);