- apple testflight wait --build N (progress, timeout, exit 0/1/7) and expire --keep N | --older-than D (dry run, asks) - apple iap-submit: IAP / subscription / group versions into a review submission; first of a type only with the app version (--with-version), --no-submit keeps the draft; read-only MCP plan - apple listing pull, play listing pull (diff, --write), catalog pull (config proposal, never rewritten) - whats-new --languages / check / apply: one file per language, agent or hook translation, per-store limits - apple offer-codes, win-back, promoted, preorder; Play pre-registration as a guide (no API) - apple cpp and apple events: list, create, upload, submit (asks) - reports: Apple sales / finance, Play earnings / sales into .storebound/reports with summary and CSV - triage: TestFlight crash / feedback and Play error reports grouped by signature, device, OS - aso rank: iTunes Search API ranks and competitors, rate limited - ads scan: AdAttributionKit / SKAdNetwork Info.plist and source checks - tests against recorded, sanitized answers; skills, README, CHANGELOG
122 lines
9.2 KiB
TypeScript
122 lines
9.2 KiB
TypeScript
// Crash and feedback triage, as pure parts: crash signatures from TestFlight crash logs, and clusters by signature,
|
|
// device and OS version for TestFlight feedback and Google Play vitals error reports. The command (cli/triage.ts)
|
|
// reads (GET only), groups and prints a table or --json. Tester emails are never read into the output (the feedback
|
|
// requests ask only for the fields listed below).
|
|
// Sources (checked October 2026):
|
|
// TestFlight feedback (App Store Connect API 4.0+, OpenAPI 4.5.1): GET /v1/apps/{id}/betaFeedbackCrashSubmissions,
|
|
// GET /v1/apps/{id}/betaFeedbackScreenshotSubmissions (createdDate, comment, deviceModel, osVersion, locale,
|
|
// appPlatform, deviceFamily, build relationship; filter[build], sort=-createdDate, fields[...]),
|
|
// GET /v1/betaFeedbackCrashSubmissions/{id}/crashLog -> BetaCrashLog.logText.
|
|
// https://developer.apple.com/documentation/appstoreconnectapi/prerelease-versions-and-beta-testers
|
|
// Google Play Developer Reporting API v1beta1: errorIssues:search (issues = Play's own clusters: type, cause,
|
|
// location, errorReportCount, distinctUsers) and errorReports:search (filter "errorIssueId = <id>", interval
|
|
// hour-aligned UTC, pageSize <= 100; ErrorReport: name, type, reportText, issue, eventTime, deviceModel
|
|
// {deviceId {buildBrand, buildDevice}, marketingName}, osVersion {apiLevel}, appVersion {versionCode}).
|
|
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.reports/search
|
|
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.reports
|
|
|
|
/** Fields read for feedback (no email, no tester relationship). */
|
|
export const FEEDBACK_FIELDS = 'createdDate,comment,deviceModel,osVersion,locale,appPlatform,deviceFamily,build';
|
|
export const CRASH_FIELDS = 'createdDate,comment,deviceModel,osVersion,locale,appPlatform,deviceFamily,build,crashLog';
|
|
|
|
const cleanFrame = (s: string): string => s.replace(/\s+\+\s+\d+$/, '').replace(/0x[0-9a-f]+/gi, '').replace(/\s+/g, ' ').trim();
|
|
|
|
/**
|
|
* One crash signature from a crash log: "<exception type> @ <first frame of the crashed thread in the app>" (else the
|
|
* first frame). Handles the text crash report format and the JSON .ips format (header line + body). Pure.
|
|
*/
|
|
export function crashSignature(log: string): string {
|
|
const text = log.trim();
|
|
// .ips: first line is a JSON header, the rest a JSON body
|
|
const nl = text.indexOf('\n');
|
|
if (text.startsWith('{') && nl > 0) {
|
|
try {
|
|
const head = JSON.parse(text.slice(0, nl)) as { app_name?: string; name?: string };
|
|
const body = JSON.parse(text.slice(nl + 1)) as { exception?: { type?: string; signal?: string }; faultingThread?: number; threads?: { triggered?: boolean; frames?: { imageIndex?: number; symbol?: string; imageOffset?: number }[] }[]; usedImages?: { name?: string }[]; procName?: string };
|
|
const ex = [body.exception?.type, body.exception?.signal].filter(Boolean).join(' ') || 'crash';
|
|
const t = body.threads?.[body.faultingThread ?? -1] ?? body.threads?.find(x => x.triggered);
|
|
const app = body.procName ?? head.app_name ?? head.name;
|
|
const frames = (t?.frames ?? []).map(f => ({ image: body.usedImages?.[f.imageIndex ?? -1]?.name ?? '?', symbol: f.symbol ?? `+${f.imageOffset ?? '?'}` }));
|
|
const f = frames.find(x => app && x.image === app) ?? frames[0];
|
|
return f ? `${ex} @ ${f.image} ${cleanFrame(f.symbol)}` : ex;
|
|
} catch { /* fall through to the text format */ }
|
|
}
|
|
const ex = /^Exception Type:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? /^Termination Reason:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? 'crash';
|
|
const proc = /^Process:\s*([^\s\[]+)/m.exec(text)?.[1];
|
|
const crashed = /^Crashed Thread:\s*(\d+)/m.exec(text)?.[1];
|
|
const lines = text.split(/\r?\n/);
|
|
const at = lines.findIndex(l => (crashed !== undefined ? new RegExp(`^Thread ${crashed} Crashed`) : /^Thread \d+ Crashed/).test(l));
|
|
const block: string[] = [];
|
|
if (at >= 0) for (const l of lines.slice(at + 1)) { if (!l.trim()) break; block.push(l); }
|
|
const frames = block.map(l => /^\d+\s+(\S+)\s+0x[0-9a-f]+\s+(.+)$/i.exec(l.trim())).filter((m): m is RegExpExecArray => !!m).map(m => ({ image: m[1]!, symbol: m[2]! }));
|
|
const f = frames.find(x => proc && x.image === proc) ?? frames[0];
|
|
return f ? `${ex} @ ${f.image} ${cleanFrame(f.symbol)}` : ex;
|
|
}
|
|
|
|
export interface TriageItem { signature: string; device: string; os: string; build?: string; date?: string; comment?: string }
|
|
export interface Cluster { signature: string; count: number; devices: [string, number][]; os: [string, number][]; builds: [string, number][]; first?: string; last?: string; comments?: string[] }
|
|
|
|
const top = (xs: string[], n = 3): [string, number][] => {
|
|
const m = new Map<string, number>();
|
|
for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1);
|
|
return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n);
|
|
};
|
|
|
|
/** Items -> clusters by signature, most frequent first, each with its top devices, OS versions and builds. Pure. */
|
|
export function cluster(items: TriageItem[], o: { comments?: number } = {}): Cluster[] {
|
|
const by = new Map<string, TriageItem[]>();
|
|
for (const i of items) by.set(i.signature, [...(by.get(i.signature) ?? []), i]);
|
|
return [...by.entries()].map(([signature, xs]) => {
|
|
const dates = xs.map(x => x.date ?? '').filter(Boolean).sort();
|
|
const comments = o.comments ? xs.map(x => (x.comment ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, o.comments).map(c => c.slice(0, 120)) : undefined;
|
|
return { signature, count: xs.length, devices: top(xs.map(x => x.device)), os: top(xs.map(x => x.os)), builds: top(xs.map(x => x.build ?? '?')),
|
|
...(dates.length ? { first: dates[0], last: dates.at(-1) } : {}), ...(comments?.length ? { comments } : {}) };
|
|
}).sort((a, b) => b.count - a.count || a.signature.localeCompare(b.signature));
|
|
}
|
|
|
|
/** TestFlight feedback (crash or screenshot submissions) -> items. Screenshot feedback has no crash: its signature is
|
|
* "feedback". Pure. */
|
|
export function feedbackItems(subs: { id: string; attributes?: Record<string, unknown>; relationships?: { build?: { data?: { id: string } | null } } }[], builds: Map<string, string>, signatures: Map<string, string> = new Map()): TriageItem[] {
|
|
return subs.map(s => {
|
|
const a = s.attributes ?? {};
|
|
return { signature: signatures.get(s.id) ?? 'feedback', device: String(a.deviceModel ?? '?'), os: String(a.osVersion ?? '?'),
|
|
build: builds.get(s.relationships?.build?.data?.id ?? '') ?? undefined, date: typeof a.createdDate === 'string' ? a.createdDate : undefined,
|
|
comment: typeof a.comment === 'string' ? a.comment : undefined };
|
|
});
|
|
}
|
|
|
|
export interface PlayIssueRef { id: string; type: string; cause: string; location: string; reports: number; users: number }
|
|
|
|
/** errorIssues:search -> issue refs with their id (the last part of `name`). Pure. */
|
|
export function playIssues(resp: { errorIssues?: { name?: string; type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string }[] }): PlayIssueRef[] {
|
|
return (resp.errorIssues ?? []).map(i => ({ id: (i.name ?? '').split('/').pop() ?? '', type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '',
|
|
location: i.location ?? '', reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0) })).filter(i => i.id);
|
|
}
|
|
|
|
/** errorReports:search -> items with the signature of their issue. Pure. */
|
|
export function playReportItems(resp: { errorReports?: { issue?: string; type?: string; eventTime?: string; deviceModel?: { marketingName?: string; deviceId?: { buildBrand?: string; buildDevice?: string } }; osVersion?: { apiLevel?: string }; appVersion?: { versionCode?: string } }[] }, issues: PlayIssueRef[]): TriageItem[] {
|
|
const sig = new Map(issues.map(i => [i.id, `${i.type} ${i.cause}${i.location ? ` @ ${i.location}` : ''}`] as const));
|
|
return (resp.errorReports ?? []).map(r => {
|
|
const id = (r.issue ?? '').split('/').pop() ?? '';
|
|
const dev = r.deviceModel?.marketingName || [r.deviceModel?.deviceId?.buildBrand, r.deviceModel?.deviceId?.buildDevice].filter(Boolean).join('/') || '?';
|
|
return { signature: sig.get(id) ?? `${r.type ?? '?'} issue ${id}`, device: dev, os: r.osVersion?.apiLevel ? `API ${r.osVersion.apiLevel}` : '?', build: r.appVersion?.versionCode, date: r.eventTime };
|
|
});
|
|
}
|
|
|
|
/** errorReports:search query string for one issue (UTC, hour-aligned interval, newest window). Pure. */
|
|
export function reportsQuery(issueId: string, start: Date, end: Date, pageSize = 100): string {
|
|
const p = new URLSearchParams();
|
|
for (const [k, d] of [['start', start], ['end', end]] as const) {
|
|
p.set(`interval.${k}Time.year`, String(d.getUTCFullYear())); p.set(`interval.${k}Time.month`, String(d.getUTCMonth() + 1)); p.set(`interval.${k}Time.day`, String(d.getUTCDate()));
|
|
p.set(`interval.${k}Time.hours`, '0'); p.set(`interval.${k}Time.timeZone.id`, 'UTC');
|
|
}
|
|
p.set('filter', `errorIssueId = ${issueId}`);
|
|
p.set('pageSize', String(Math.min(100, pageSize)));
|
|
return p.toString();
|
|
}
|
|
|
|
/** Table rows for a cluster list. Pure. */
|
|
export function clusterRows(cs: Cluster[]): (string | number)[][] {
|
|
const fmt = (xs: [string, number][]) => xs.map(([k, n]) => `${k} (${n})`).join(', ');
|
|
return cs.map(c => [c.count, c.signature.slice(0, 70), fmt(c.devices), fmt(c.os), fmt(c.builds)]);
|
|
}
|