// Play changes committed with changesNotSentForReview=true (pitfall 72): Play takes the edit but does not send it for // review, the owner presses "Send changes for review" in Publishing overview. The Play Developer API has no read-only // way to see that changes wait to be sent (no publishing-state endpoint), so storebound records its own commit in // .storebound/play-review-pending.json (gitignored) and `play status` shows it. A later commit that Play sent for // review by itself (no refusal) clears it: Play then sends every pending change along. `play status --review-sent` // forgets it after the owner pressed the button. import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { consoleUrl } from '../lib/console-links'; export interface ReviewPending { packageName: string; editId: string; committedAt: string; summary: string; forced: boolean } /** Play's refusal of the automatic send for review (after a rejection, pending changes, managed publishing). */ export const REVIEW_REFUSED = /changesNotSentForReview|cannot be sent for review automatically/i; /** Exactly that 400: the message names it, and a reason, when present, is a bad request / failed precondition. */ export function isReviewRefused(err: unknown): boolean { const s = String(err instanceof Error ? err.message : err); const m = /-> 400 (.*)$/s.exec(s); if (!m || !REVIEW_REFUSED.test(m[1]!)) return false; let reasons: string[] = []; try { const e = (JSON.parse(m[1]!) as { error?: { errors?: { reason?: string }[] } }).error; reasons = (e?.errors ?? []).map(x => x.reason ?? '').filter(Boolean); } catch { /* plain text body: the message alone decides */ } return reasons.every(r => /badRequest|invalid|failedPrecondition|precondition/i.test(r)); } /** The query string with changesNotSentForReview=true added to `base` ("?a=1" or ''). */ export function withNotSent(base: string | undefined, on: boolean): string { const parts = [(base ?? '').replace(/^\?/, ''), on ? 'changesNotSentForReview=true' : ''].filter(Boolean); return parts.length ? `?${parts.join('&')}` : ''; } export const publishingUrl = (): string => consoleUrl('play.publishing'); export const notSentMessage = (): string => `committed; Play did not send it for review: press Send changes for review in Publishing overview: ${publishingUrl()}`; const file = (root: string): string => join(root, '.storebound', 'play-review-pending.json'); type Store = Record; const readAll = (root: string): Store => { try { return existsSync(file(root)) ? JSON.parse(readFileSync(file(root), 'utf8')) as Store : {}; } catch { return {}; } }; const writeAll = (root: string, s: Store): void => { if (!Object.keys(s).length) { rmSync(file(root), { force: true }); return; } mkdirSync(join(root, '.storebound'), { recursive: true }); writeFileSync(file(root), JSON.stringify(s, null, 2) + '\n'); }; export function recordPending(root: string, p: ReviewPending): void { writeAll(root, { ...readAll(root), [p.packageName]: p }); } export function readPending(root: string, packageName: string): ReviewPending | null { return readAll(root)[packageName] ?? null; } export function clearPending(root: string, packageName: string): boolean { const s = readAll(root); if (!s[packageName]) return false; delete s[packageName]; writeAll(root, s); return true; } /** The status line: "changes committed