- 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
96 lines
6.7 KiB
TypeScript
96 lines
6.7 KiB
TypeScript
// Crash and feedback triage: TestFlight crash and screenshot feedback, and Google Play vitals error reports, grouped
|
|
// by signature with their top devices, OS versions and builds. Read-only on both stores.
|
|
// storebound triage [--apple] [--play] [--days 14] [--build N] [--issues 10] [--comments 3] [--dry] [--json]
|
|
// Apple: crash feedback -> the crash log of each (signature = exception + first app frame of the crashed thread);
|
|
// screenshot feedback grouped by device / OS / build (--comments N shows up to N tester comments per group, cut to
|
|
// 120 characters; emails are never read). Play: the top --issues issues of the window (Play's own clusters), then
|
|
// their error reports for the device / API level / versionCode spread. --dry lists the requests without sending.
|
|
// Pure parts and sources: cli/lib/triage.ts.
|
|
import { ascClient, type J } from './apple/asc';
|
|
import type { Ctx } from './lib/ctx';
|
|
import { REPORTING_SCOPE } from './lib/google-auth';
|
|
import { issuesQuery, ymd } from './lib/health';
|
|
import { done, log, printJson, warn } from './lib/log';
|
|
import { textTable } from './lib/table';
|
|
import { CRASH_FIELDS, cluster, type Cluster, clusterRows, crashSignature, FEEDBACK_FIELDS, feedbackItems, playIssues, playReportItems, reportsQuery } from './lib/triage';
|
|
import { googleClient, REPORTING_API } from './play/google';
|
|
|
|
const TAG = 'triage';
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
const a = ctx.args;
|
|
const both = !a.has('apple') && !a.has('play');
|
|
const days = a.num('days', 14);
|
|
if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90');
|
|
const dry = a.has('dry');
|
|
const since = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
const result: { apple?: { crashes: Cluster[]; feedback: Cluster[] } | { error: string }; play?: { issues: Cluster[] } | { error: string } } = {};
|
|
|
|
if ((both || a.has('apple')) && config.apple) {
|
|
try {
|
|
const c = await ascClient(root, config.apple.bundleId, config.apple.appId);
|
|
const appId = dry ? '<app>' : await c.app();
|
|
const buildFilter = a.get('build');
|
|
let buildIds: string[] = [];
|
|
const builds = new Map<string, string>();
|
|
if (!dry) for (const b of (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=200${buildFilter ? `&filter[version]=${encodeURIComponent(buildFilter)}` : ''}`)).data as J[]) builds.set(b.id, b.attributes.version);
|
|
buildIds = [...builds.keys()];
|
|
if (buildFilter && !dry && !buildIds.length) throw new Error(`no build ${buildFilter}`);
|
|
const q = (fields: string, kind: string) => `/v1/apps/${appId}/${kind}?sort=-createdDate&limit=200&include=build&fields[builds]=version&fields[${kind}]=${fields}${buildFilter && buildIds.length ? `&filter[build]=${buildIds.join(',')}` : ''}`;
|
|
if (dry) {
|
|
log(TAG, `would GET ${q(CRASH_FIELDS, 'betaFeedbackCrashSubmissions')} + each crashLog`);
|
|
log(TAG, `would GET ${q(FEEDBACK_FIELDS, 'betaFeedbackScreenshotSubmissions')}`);
|
|
} else {
|
|
const recent = (xs: J[]) => xs.filter(x => (x.attributes?.createdDate ?? '') >= since);
|
|
const crashes = recent(await c.all(q(CRASH_FIELDS, 'betaFeedbackCrashSubmissions')));
|
|
log(TAG, `apple: ${crashes.length} TestFlight crash report(s) in ${days} days`);
|
|
const sigs = new Map<string, string>();
|
|
for (const s of crashes) {
|
|
const logText = (await c.maybe(`/v1/betaFeedbackCrashSubmissions/${s.id}/crashLog`))?.data?.attributes?.logText as string | undefined;
|
|
sigs.set(s.id, logText ? crashSignature(logText) : 'crash (no log)');
|
|
}
|
|
const shots = recent(await c.all(q(FEEDBACK_FIELDS, 'betaFeedbackScreenshotSubmissions')));
|
|
log(TAG, `apple: ${shots.length} TestFlight screenshot feedback(s)`);
|
|
const comments = a.num('comments', 0);
|
|
const fb = feedbackItems(shots, builds).map(i => ({ ...i, signature: `feedback on ${i.device}` }));
|
|
result.apple = { crashes: cluster(feedbackItems(crashes, builds, sigs)), feedback: cluster(fb, { comments }) };
|
|
}
|
|
} catch (e) { result.apple = { error: (e as Error).message }; warn(TAG, `apple: ${(e as Error).message.slice(0, 300)}`); }
|
|
}
|
|
|
|
if ((both || a.has('play')) && config.play) {
|
|
try {
|
|
const app = `${REPORTING_API}/apps/${encodeURIComponent(config.play.packageName)}`;
|
|
const end = ymd(new Date(), 0);
|
|
const iq = issuesQuery(end, days, { pageSize: a.num('issues', 10) });
|
|
if (dry) log(TAG, `would GET ${app}/errorIssues:search?${iq} + errorReports:search per issue`);
|
|
else {
|
|
const g = googleClient(root, REPORTING_SCOPE);
|
|
const issues = playIssues(await g.json('GET', `${app}/errorIssues:search?${iq}`));
|
|
log(TAG, `play: ${issues.length} top issue(s) in ${days} days`);
|
|
const items = [];
|
|
const startD = new Date(Date.UTC(end.year, end.month - 1, end.day) - days * 86_400_000), endD = new Date(Date.UTC(end.year, end.month - 1, end.day));
|
|
for (const i of issues) items.push(...playReportItems(await g.json('GET', `${app}/errorReports:search?${reportsQuery(i.id, startD, endD)}`), issues));
|
|
const cs = cluster(items);
|
|
// issues with no sampled reports still show with Play's own counts
|
|
for (const i of issues) {
|
|
const s = `${i.type} ${i.cause}${i.location ? ` @ ${i.location}` : ''}`;
|
|
if (!cs.some(c => c.signature === s)) cs.push({ signature: s, count: 0, devices: [], os: [], builds: [] });
|
|
}
|
|
result.play = { issues: cs.map(c => ({ ...c, ...(() => { const i = issues.find(x => c.signature.startsWith(`${x.type} ${x.cause}`)); return i ? { reports: i.reports, users: i.users } : {}; })() })) };
|
|
}
|
|
} catch (e) { result.play = { error: (e as Error).message }; warn(TAG, `play: ${(e as Error).message.slice(0, 300)}`); }
|
|
}
|
|
|
|
if (dry) { if (a.has('json')) printJson({ dryRun: true }); done(TAG, 'dry run: nothing read'); return; }
|
|
if (a.has('json')) { printJson(result); return; }
|
|
const head = ['n', 'signature', 'devices', 'os', 'builds'];
|
|
if (result.apple && !('error' in result.apple)) {
|
|
console.log(`\nTestFlight crashes (${days} days)\n` + (result.apple.crashes.length ? textTable(head, clusterRows(result.apple.crashes)) : 'none'));
|
|
console.log(`\nTestFlight feedback (${days} days)\n` + (result.apple.feedback.length ? textTable(head, clusterRows(result.apple.feedback)) : 'none'));
|
|
for (const c of result.apple.feedback) for (const t of c.comments ?? []) console.log(` ${c.signature}: "${t}"`);
|
|
}
|
|
if (result.play && !('error' in result.play)) console.log(`\nGoogle Play crashes and ANRs (${days} days; n = sampled reports)\n` + (result.play.issues.length ? textTable(head, clusterRows(result.play.issues)) : 'none'));
|
|
done(TAG, 'triage done (fix the top signature first; storebound health for the rates)');
|
|
}
|