storebound/cli/triage.ts
CRT b63f2fdfbc Store operations: TestFlight wait / expire, IAP review submissions, listing pulls, release notes per language, offers, custom product pages, in-app events, reports, triage, keyword ranks, AdAttributionKit scan
- 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
2026-10-11 23:58:17 +08:00

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)');
}