storebound/cli/play/review-pending.ts

62 lines
3.8 KiB
TypeScript

// 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<string, ReviewPending>;
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 <time>, waiting for Send changes for review: <link>". */
export function pendingLine(p: ReviewPending): string {
return `changes committed ${p.committedAt.slice(0, 16).replace('T', ' ')} UTC (${p.summary}), waiting for Send changes for review: ${publishingUrl()}`
+ ' (recorded by storebound; the API cannot read whether it was pressed since: `storebound play status --review-sent` forgets it)';
}