@gpc-cli/core 0.9.21 → 0.9.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -242,11 +242,29 @@ function buildTestCase(item, commandName, index = 0) {
242
242
  };
243
243
  }
244
244
  const record = item;
245
- const name = escapeXml(
246
- String(
247
- record["name"] ?? record["title"] ?? record["sku"] ?? record["id"] ?? record["productId"] ?? record["packageName"] ?? record["trackId"] ?? record["region"] ?? record["languageCode"] ?? `item-${index + 1}`
248
- )
249
- );
245
+ const CANDIDATE_KEYS = [
246
+ "name",
247
+ "title",
248
+ "sku",
249
+ "id",
250
+ "reviewId",
251
+ "productId",
252
+ "packageName",
253
+ "track",
254
+ "trackId",
255
+ "versionCode",
256
+ "region",
257
+ "languageCode"
258
+ ];
259
+ let resolvedName = `item-${index + 1}`;
260
+ for (const key of CANDIDATE_KEYS) {
261
+ const val = record[key];
262
+ if (val != null && val !== "" && val !== "-") {
263
+ resolvedName = String(val);
264
+ break;
265
+ }
266
+ }
267
+ const name = escapeXml(resolvedName);
250
268
  const classname = `gpc.${escapeXml(commandName)}`;
251
269
  const breached = record["breached"];
252
270
  if (breached === true) {
@@ -1329,8 +1347,8 @@ var ALL_IMAGE_TYPES = [
1329
1347
  "tvBanner"
1330
1348
  ];
1331
1349
  async function exportImages(client, packageName, dir, options) {
1332
- const { mkdir: mkdir5, writeFile: writeFile7 } = await import("fs/promises");
1333
- const { join: join7 } = await import("path");
1350
+ const { mkdir: mkdir6, writeFile: writeFile8 } = await import("fs/promises");
1351
+ const { join: join8 } = await import("path");
1334
1352
  const edit = await client.edits.insert(packageName);
1335
1353
  try {
1336
1354
  let languages;
@@ -1361,12 +1379,12 @@ async function exportImages(client, packageName, dir, options) {
1361
1379
  const batch = tasks.slice(i, i + concurrency);
1362
1380
  const results = await Promise.all(
1363
1381
  batch.map(async (task) => {
1364
- const dirPath = join7(dir, task.language, task.imageType);
1365
- await mkdir5(dirPath, { recursive: true });
1382
+ const dirPath = join8(dir, task.language, task.imageType);
1383
+ await mkdir6(dirPath, { recursive: true });
1366
1384
  const response = await fetch(task.url);
1367
1385
  const buffer = Buffer.from(await response.arrayBuffer());
1368
- const filePath = join7(dirPath, `${task.index}.png`);
1369
- await writeFile7(filePath, buffer);
1386
+ const filePath = join8(dirPath, `${task.index}.png`);
1387
+ await writeFile8(filePath, buffer);
1370
1388
  return buffer.length;
1371
1389
  })
1372
1390
  );
@@ -1404,6 +1422,7 @@ async function updateAppDetails(client, packageName, details) {
1404
1422
  // src/commands/migrate.ts
1405
1423
  import { readdir as readdir2, readFile as readFile3, writeFile as writeFile2, mkdir as mkdir2, access } from "fs/promises";
1406
1424
  import { join as join2 } from "path";
1425
+ var COMPLEX_RUBY_RE = /\b(begin|rescue|ensure|if |unless |case |while |until |for )\b/;
1407
1426
  async function fileExists(path) {
1408
1427
  try {
1409
1428
  await access(path);
@@ -1419,7 +1438,8 @@ async function detectFastlane(cwd) {
1419
1438
  hasMetadata: false,
1420
1439
  hasGemfile: false,
1421
1440
  lanes: [],
1422
- metadataLanguages: []
1441
+ metadataLanguages: [],
1442
+ parseWarnings: []
1423
1443
  };
1424
1444
  const fastlaneDir = join2(cwd, "fastlane");
1425
1445
  const hasFastlaneDir = await fileExists(fastlaneDir);
@@ -1435,13 +1455,20 @@ async function detectFastlane(cwd) {
1435
1455
  const entries = await readdir2(metadataDir, { withFileTypes: true });
1436
1456
  result.metadataLanguages = entries.filter((e) => e.isDirectory()).map((e) => e.name);
1437
1457
  } catch {
1458
+ result.parseWarnings.push("Could not read metadata directory \u2014 check permissions");
1438
1459
  }
1439
1460
  }
1440
1461
  if (result.hasFastfile) {
1441
1462
  try {
1442
1463
  const content = await readFile3(fastfilePath, "utf-8");
1443
1464
  result.lanes = parseFastfile(content);
1465
+ if (COMPLEX_RUBY_RE.test(content)) {
1466
+ result.parseWarnings.push(
1467
+ "Fastfile contains complex Ruby constructs (begin/rescue/if/unless/case). Lane detection may be incomplete \u2014 review MIGRATION.md and adjust manually."
1468
+ );
1469
+ }
1444
1470
  } catch {
1471
+ result.parseWarnings.push("Could not read Fastfile \u2014 check permissions");
1445
1472
  }
1446
1473
  }
1447
1474
  if (result.hasAppfile) {
@@ -1451,6 +1478,7 @@ async function detectFastlane(cwd) {
1451
1478
  result.packageName = parsed.packageName;
1452
1479
  result.jsonKeyPath = parsed.jsonKeyPath;
1453
1480
  } catch {
1481
+ result.parseWarnings.push("Could not read Appfile \u2014 check permissions");
1454
1482
  }
1455
1483
  }
1456
1484
  return result;
@@ -1481,8 +1509,9 @@ function mapLaneToGpc(name, actions, body) {
1481
1509
  const trackMatch = body.match(/track\s*:\s*["'](\w+)["']/);
1482
1510
  const rolloutMatch = body.match(/rollout\s*:\s*["']?([\d.]+)["']?/);
1483
1511
  if (rolloutMatch) {
1484
- const percentage = Math.round(parseFloat(rolloutMatch[1] ?? "0") * 100);
1485
- return `gpc releases promote --rollout ${percentage}`;
1512
+ const raw = parseFloat(rolloutMatch[1] ?? "0");
1513
+ const pct = raw > 1 ? Math.round(raw) : Math.round(raw * 100);
1514
+ return `gpc releases upload --rollout ${pct}${trackMatch ? ` --track ${trackMatch[1]}` : ""}`;
1486
1515
  }
1487
1516
  if (trackMatch) {
1488
1517
  return `gpc releases upload --track ${trackMatch[1]}`;
@@ -1512,7 +1541,7 @@ function parseAppfile(content) {
1512
1541
  function generateMigrationPlan(detection) {
1513
1542
  const config = {};
1514
1543
  const checklist = [];
1515
- const warnings = [];
1544
+ const warnings = [...detection.parseWarnings];
1516
1545
  if (detection.packageName) {
1517
1546
  config["app"] = detection.packageName;
1518
1547
  } else {
@@ -1521,44 +1550,61 @@ function generateMigrationPlan(detection) {
1521
1550
  if (detection.jsonKeyPath) {
1522
1551
  config["auth"] = { serviceAccount: detection.jsonKeyPath };
1523
1552
  } else {
1524
- checklist.push("Configure authentication: gpc auth setup");
1553
+ checklist.push("Configure authentication: gpc auth login");
1525
1554
  }
1526
1555
  for (const lane of detection.lanes) {
1527
1556
  if (lane.gpcEquivalent) {
1528
- checklist.push(`Replace Fastlane lane "${lane.name}" with: ${lane.gpcEquivalent}`);
1557
+ checklist.push(
1558
+ `Replace Fastlane lane "${lane.name}" with: ${lane.gpcEquivalent} <your.aab>`
1559
+ );
1529
1560
  }
1530
1561
  if (lane.actions.includes("capture_android_screenshots")) {
1531
1562
  warnings.push(
1532
- `Lane "${lane.name}" uses capture_android_screenshots which has no GPC equivalent. You will need to continue using Fastlane for screenshot capture or use a separate tool.`
1563
+ `Lane "${lane.name}" uses capture_android_screenshots which has no GPC equivalent. Use a separate screenshot tool or check gpc plugins list for community plugins.`
1564
+ );
1565
+ }
1566
+ if (lane.actions.length === 0 || lane.gpcEquivalent === void 0 && !lane.actions.includes("capture_android_screenshots")) {
1567
+ warnings.push(
1568
+ `Lane "${lane.name}" has no automatic GPC equivalent. Check \`gpc plugins list\` or the plugin SDK docs to build a custom command.`
1533
1569
  );
1534
1570
  }
1535
1571
  }
1536
1572
  if (detection.hasMetadata && detection.metadataLanguages.length > 0) {
1573
+ const langs = detection.metadataLanguages.slice(0, 3).join(", ");
1574
+ const more = detection.metadataLanguages.length > 3 ? ` (+${detection.metadataLanguages.length - 3} more)` : "";
1537
1575
  checklist.push(
1538
- `Migrate metadata for ${detection.metadataLanguages.length} language(s): gpc listings pull --dir metadata`
1576
+ `Pull current metadata for ${detection.metadataLanguages.length} language(s) (${langs}${more}): gpc listings pull --dir fastlane/metadata/android`
1577
+ );
1578
+ checklist.push(
1579
+ "Review pulled metadata, then push back: gpc listings push --dir fastlane/metadata/android"
1539
1580
  );
1540
- checklist.push("Review and push metadata: gpc listings push --dir metadata");
1541
1581
  }
1542
1582
  checklist.push("Run gpc doctor to verify your setup");
1543
- checklist.push("Test with --dry-run before making real changes");
1583
+ checklist.push("Test each command with --dry-run before making real changes");
1544
1584
  if (detection.hasGemfile) {
1545
1585
  checklist.push("Remove Fastlane from your Gemfile once migration is complete");
1546
1586
  }
1547
- if (detection.lanes.some((l) => l.actions.includes("supply") || l.actions.includes("upload_to_play_store"))) {
1548
- checklist.push("Update CI/CD pipelines to use gpc commands instead of Fastlane lanes");
1587
+ if (detection.lanes.some(
1588
+ (l) => l.actions.includes("supply") || l.actions.includes("upload_to_play_store")
1589
+ )) {
1590
+ checklist.push("Update CI/CD pipelines to call gpc commands instead of Fastlane lanes");
1549
1591
  }
1550
1592
  return { config, checklist, warnings };
1551
1593
  }
1552
1594
  async function writeMigrationOutput(result, dir) {
1553
1595
  await mkdir2(dir, { recursive: true });
1554
1596
  const files = [];
1555
- const configPath = join2(dir, ".gpcrc.json");
1556
- await writeFile2(configPath, JSON.stringify(result.config, null, 2) + "\n", "utf-8");
1557
- files.push(configPath);
1597
+ if (Object.keys(result.config).length > 0) {
1598
+ const configPath = join2(dir, ".gpcrc.json");
1599
+ await writeFile2(configPath, JSON.stringify(result.config, null, 2) + "\n", "utf-8");
1600
+ files.push(configPath);
1601
+ }
1558
1602
  const migrationPath = join2(dir, "MIGRATION.md");
1559
1603
  const lines = [
1560
1604
  "# Fastlane to GPC Migration",
1561
1605
  "",
1606
+ "Generated by `gpc migrate fastlane`. Review and adjust before applying.",
1607
+ "",
1562
1608
  "## Migration Checklist",
1563
1609
  ""
1564
1610
  ];
@@ -1570,7 +1616,7 @@ async function writeMigrationOutput(result, dir) {
1570
1616
  lines.push("## Warnings");
1571
1617
  lines.push("");
1572
1618
  for (const warning of result.warnings) {
1573
- lines.push(`- ${warning}`);
1619
+ lines.push(`> \u26A0 ${warning}`);
1574
1620
  }
1575
1621
  }
1576
1622
  lines.push("");
@@ -1581,8 +1627,11 @@ async function writeMigrationOutput(result, dir) {
1581
1627
  lines.push("| `fastlane supply` | `gpc releases upload` / `gpc listings push` |");
1582
1628
  lines.push("| `upload_to_play_store` | `gpc releases upload` |");
1583
1629
  lines.push('| `supply(track: "internal")` | `gpc releases upload --track internal` |');
1584
- lines.push('| `supply(rollout: "0.1")` | `gpc releases promote --rollout 10` |');
1585
- lines.push("| `capture_android_screenshots` | No equivalent (use separate tool) |");
1630
+ lines.push('| `supply(rollout: "0.1")` | `gpc releases upload --rollout 10` |');
1631
+ lines.push("| `supply(skip_upload_aab: true)` | `gpc listings push` |");
1632
+ lines.push("| `capture_android_screenshots` | No equivalent \u2014 use separate tool |");
1633
+ lines.push("");
1634
+ lines.push("See the full migration guide: https://yasserstudio.github.io/gpc/migration/from-fastlane");
1586
1635
  lines.push("");
1587
1636
  await writeFile2(migrationPath, lines.join("\n"), "utf-8");
1588
1637
  files.push(migrationPath);
@@ -1729,12 +1778,16 @@ var STANDARD_TRACKS = /* @__PURE__ */ new Set([
1729
1778
  var TRACK_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_:-]*$/;
1730
1779
  async function validatePreSubmission(options) {
1731
1780
  const checks = [];
1781
+ const resultWarnings = [];
1732
1782
  const fileResult = await validateUploadFile(options.filePath);
1733
1783
  checks.push({
1734
1784
  name: "file",
1735
1785
  passed: fileResult.valid,
1736
- message: fileResult.valid ? `Valid ${fileResult.fileType} file (${formatSize3(fileResult.sizeBytes)})` : fileResult.errors.join("; ")
1786
+ message: fileResult.valid ? `Valid ${fileResult.fileType.toUpperCase()} (${formatSize3(fileResult.sizeBytes)})` : fileResult.errors.join("; ")
1737
1787
  });
1788
+ for (const w of fileResult.warnings) {
1789
+ resultWarnings.push(w);
1790
+ }
1738
1791
  if (options.mappingFile) {
1739
1792
  try {
1740
1793
  const stats = await stat5(options.mappingFile);
@@ -1786,7 +1839,8 @@ async function validatePreSubmission(options) {
1786
1839
  }
1787
1840
  return {
1788
1841
  valid: checks.every((c) => c.passed),
1789
- checks
1842
+ checks,
1843
+ warnings: resultWarnings
1790
1844
  };
1791
1845
  }
1792
1846
  function formatSize3(bytes) {
@@ -1955,23 +2009,22 @@ var METRIC_SET_METRICS = {
1955
2009
  function buildQuery(metricSet, options) {
1956
2010
  const metrics = METRIC_SET_METRICS[metricSet] ?? ["errorReportCount", "distinctUsers"];
1957
2011
  const days = options?.days ?? 30;
1958
- const end = /* @__PURE__ */ new Date();
1959
- end.setDate(end.getDate() - 1);
1960
- const start = new Date(end);
1961
- start.setDate(start.getDate() - days);
2012
+ const DAY_MS = 24 * 60 * 60 * 1e3;
2013
+ const end = new Date(Date.now() - DAY_MS);
2014
+ const start = new Date(Date.now() - DAY_MS - days * DAY_MS);
1962
2015
  const query = {
1963
2016
  metrics,
1964
2017
  timelineSpec: {
1965
2018
  aggregationPeriod: options?.aggregation ?? "DAILY",
1966
2019
  startTime: {
1967
- year: start.getFullYear(),
1968
- month: start.getMonth() + 1,
1969
- day: start.getDate()
2020
+ year: start.getUTCFullYear(),
2021
+ month: start.getUTCMonth() + 1,
2022
+ day: start.getUTCDate()
1970
2023
  },
1971
2024
  endTime: {
1972
- year: end.getFullYear(),
1973
- month: end.getMonth() + 1,
1974
- day: end.getDate()
2025
+ year: end.getUTCFullYear(),
2026
+ month: end.getUTCMonth() + 1,
2027
+ day: end.getUTCDate()
1975
2028
  }
1976
2029
  }
1977
2030
  };
@@ -2036,22 +2089,25 @@ async function searchVitalsErrors(reporting, packageName, options) {
2036
2089
  return reporting.searchErrorIssues(packageName, options?.filter, options?.maxResults);
2037
2090
  }
2038
2091
  async function compareVitalsTrend(reporting, packageName, metricSet, days = 7) {
2039
- const now = /* @__PURE__ */ new Date();
2040
- now.setDate(now.getDate() - 1);
2041
- const currentEnd = new Date(now);
2042
- const currentStart = new Date(now);
2043
- currentStart.setDate(currentStart.getDate() - days);
2044
- const previousEnd = new Date(currentStart);
2045
- previousEnd.setDate(previousEnd.getDate() - 1);
2046
- const previousStart = new Date(previousEnd);
2047
- previousStart.setDate(previousStart.getDate() - days);
2092
+ const DAY_MS = 24 * 60 * 60 * 1e3;
2093
+ const nowMs = Date.now();
2094
+ const baseMs = nowMs - 2 * DAY_MS;
2095
+ const currentEnd = new Date(baseMs);
2096
+ const currentStart = new Date(baseMs - days * DAY_MS);
2097
+ const previousEnd = new Date(baseMs - days * DAY_MS - DAY_MS);
2098
+ const previousStart = new Date(baseMs - days * DAY_MS - DAY_MS - days * DAY_MS);
2048
2099
  const metrics = METRIC_SET_METRICS[metricSet] ?? ["errorReportCount", "distinctUsers"];
2100
+ const toApiDate2 = (d) => ({
2101
+ year: d.getUTCFullYear(),
2102
+ month: d.getUTCMonth() + 1,
2103
+ day: d.getUTCDate()
2104
+ });
2049
2105
  const makeQuery = (start, end) => ({
2050
2106
  metrics,
2051
2107
  timelineSpec: {
2052
2108
  aggregationPeriod: "DAILY",
2053
- startTime: { year: start.getFullYear(), month: start.getMonth() + 1, day: start.getDate() },
2054
- endTime: { year: end.getFullYear(), month: end.getMonth() + 1, day: end.getDate() }
2109
+ startTime: toApiDate2(start),
2110
+ endTime: toApiDate2(end)
2055
2111
  }
2056
2112
  });
2057
2113
  const [currentResult, previousResult] = await Promise.all([
@@ -2798,10 +2854,12 @@ function formatNotes(commits, maxLength) {
2798
2854
  ${bullets}`);
2799
2855
  }
2800
2856
  let text = sections.join("\n\n");
2857
+ let truncated = false;
2801
2858
  if (text.length > maxLength) {
2802
2859
  text = text.slice(0, maxLength - 3) + "...";
2860
+ truncated = true;
2803
2861
  }
2804
- return text;
2862
+ return { text, truncated };
2805
2863
  }
2806
2864
  async function gitExec(args) {
2807
2865
  try {
@@ -2855,17 +2913,19 @@ async function generateNotesFromGit(options) {
2855
2913
  language,
2856
2914
  text: "No changes since last release.",
2857
2915
  commitCount: 0,
2858
- since
2916
+ since,
2917
+ truncated: false
2859
2918
  };
2860
2919
  }
2861
2920
  const subjects = logOutput.split("\n").filter((line) => line.length > 0);
2862
2921
  const commits = subjects.map(parseConventionalCommit);
2863
- const text = formatNotes(commits, maxLength);
2922
+ const { text, truncated } = formatNotes(commits, maxLength);
2864
2923
  return {
2865
2924
  language,
2866
2925
  text,
2867
2926
  commitCount: subjects.length,
2868
- since
2927
+ since,
2928
+ truncated
2869
2929
  };
2870
2930
  }
2871
2931
 
@@ -3912,6 +3972,230 @@ function compareBundles(before, after) {
3912
3972
  categoryDeltas
3913
3973
  };
3914
3974
  }
3975
+
3976
+ // src/commands/status.ts
3977
+ import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile7 } from "fs/promises";
3978
+ import { join as join7 } from "path";
3979
+ import { getCacheDir } from "@gpc-cli/config";
3980
+ var DEFAULT_TTL_SECONDS = 3600;
3981
+ function cacheFilePath(packageName) {
3982
+ return join7(getCacheDir(), `status-${packageName}.json`);
3983
+ }
3984
+ async function loadStatusCache(packageName, ttlSeconds = DEFAULT_TTL_SECONDS) {
3985
+ try {
3986
+ const raw = await readFile10(cacheFilePath(packageName), "utf-8");
3987
+ const entry = JSON.parse(raw);
3988
+ const age = (Date.now() - new Date(entry.fetchedAt).getTime()) / 1e3;
3989
+ if (age > (entry.ttl ?? ttlSeconds)) return null;
3990
+ return { ...entry.data, cached: true };
3991
+ } catch {
3992
+ return null;
3993
+ }
3994
+ }
3995
+ async function saveStatusCache(packageName, data, ttlSeconds = DEFAULT_TTL_SECONDS) {
3996
+ try {
3997
+ const dir = getCacheDir();
3998
+ await mkdir5(dir, { recursive: true });
3999
+ const entry = {
4000
+ fetchedAt: data.fetchedAt,
4001
+ ttl: ttlSeconds,
4002
+ data
4003
+ };
4004
+ await writeFile7(cacheFilePath(packageName), JSON.stringify(entry, null, 2), { encoding: "utf-8", mode: 384 });
4005
+ } catch {
4006
+ }
4007
+ }
4008
+ var METRIC_SET_METRICS2 = {
4009
+ crashRateMetricSet: ["crashRate", "userPerceivedCrashRate", "distinctUsers"],
4010
+ anrRateMetricSet: ["anrRate", "userPerceivedAnrRate", "distinctUsers"],
4011
+ slowStartRateMetricSet: ["slowStartRate", "distinctUsers"],
4012
+ slowRenderingRateMetricSet: ["slowRenderingRate", "distinctUsers"]
4013
+ };
4014
+ var DEFAULT_THRESHOLDS = {
4015
+ crashRate: 0.02,
4016
+ anrRate: 0.01,
4017
+ slowStartRate: 0.05,
4018
+ slowRenderingRate: 0.1
4019
+ };
4020
+ var WARN_MARGIN = 0.2;
4021
+ function toApiDate(d) {
4022
+ return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
4023
+ }
4024
+ async function queryVitalForStatus(reporting, packageName, metricSet, days) {
4025
+ const DAY_MS = 24 * 60 * 60 * 1e3;
4026
+ const baseMs = Date.now() - 2 * DAY_MS;
4027
+ const end = new Date(baseMs);
4028
+ const start = new Date(baseMs - days * DAY_MS);
4029
+ const metrics = METRIC_SET_METRICS2[metricSet] ?? ["distinctUsers"];
4030
+ const query = {
4031
+ metrics,
4032
+ timelineSpec: {
4033
+ aggregationPeriod: "DAILY",
4034
+ startTime: toApiDate(start),
4035
+ endTime: toApiDate(end)
4036
+ }
4037
+ };
4038
+ const result = await reporting.queryMetricSet(packageName, metricSet, query);
4039
+ if (!result.rows || result.rows.length === 0) return void 0;
4040
+ const values = result.rows.map((row) => {
4041
+ const firstKey = Object.keys(row.metrics)[0];
4042
+ return firstKey ? Number(row.metrics[firstKey]?.decimalValue?.value) : NaN;
4043
+ }).filter((v) => !isNaN(v));
4044
+ if (values.length === 0) return void 0;
4045
+ return values.reduce((a, b) => a + b, 0) / values.length;
4046
+ }
4047
+ function toVitalMetric(value, threshold) {
4048
+ if (value === void 0) {
4049
+ return { value: void 0, threshold, status: "unknown" };
4050
+ }
4051
+ if (value > threshold) return { value, threshold, status: "breach" };
4052
+ if (value > threshold * (1 - WARN_MARGIN)) return { value, threshold, status: "warn" };
4053
+ return { value, threshold, status: "ok" };
4054
+ }
4055
+ function computeReviewSentiment(reviews, windowDays) {
4056
+ const now = Date.now();
4057
+ const DAY_MS = 24 * 60 * 60 * 1e3;
4058
+ const windowMs = windowDays * DAY_MS;
4059
+ const prevWindowStart = now - 2 * windowMs;
4060
+ const curWindowStart = now - windowMs;
4061
+ const current = reviews.filter((r) => {
4062
+ const uc = r.comments?.[0]?.userComment;
4063
+ if (!uc) return false;
4064
+ const ts = Number(uc.lastModified.seconds) * 1e3;
4065
+ return ts >= curWindowStart;
4066
+ });
4067
+ const previous = reviews.filter((r) => {
4068
+ const uc = r.comments?.[0]?.userComment;
4069
+ if (!uc) return false;
4070
+ const ts = Number(uc.lastModified.seconds) * 1e3;
4071
+ return ts >= prevWindowStart && ts < curWindowStart;
4072
+ });
4073
+ const avgRating = (items) => {
4074
+ const ratings = items.map((r) => r.comments?.[0]?.userComment?.starRating).filter((v) => v !== void 0 && v > 0);
4075
+ if (ratings.length === 0) return void 0;
4076
+ return Math.round(ratings.reduce((a, b) => a + b, 0) / ratings.length * 10) / 10;
4077
+ };
4078
+ const positiveCount = current.filter(
4079
+ (r) => (r.comments?.[0]?.userComment?.starRating ?? 0) >= 4
4080
+ ).length;
4081
+ const positivePercent = current.length > 0 ? Math.round(positiveCount / current.length * 100) : void 0;
4082
+ return {
4083
+ windowDays,
4084
+ averageRating: avgRating(current),
4085
+ previousAverageRating: avgRating(previous),
4086
+ totalNew: current.length,
4087
+ positivePercent
4088
+ };
4089
+ }
4090
+ async function getAppStatus(client, reporting, packageName, options = {}) {
4091
+ const days = options.days ?? 7;
4092
+ const thresholds = {
4093
+ crashRate: options.vitalThresholds?.crashRate ?? DEFAULT_THRESHOLDS.crashRate,
4094
+ anrRate: options.vitalThresholds?.anrRate ?? DEFAULT_THRESHOLDS.anrRate,
4095
+ slowStartRate: options.vitalThresholds?.slowStartRate ?? DEFAULT_THRESHOLDS.slowStartRate,
4096
+ slowRenderingRate: options.vitalThresholds?.slowRenderingRate ?? DEFAULT_THRESHOLDS.slowRenderingRate
4097
+ };
4098
+ const [releasesResult, crashesResult, anrResult, slowStartResult, slowRenderResult, reviewsResult] = await Promise.allSettled([
4099
+ getReleasesStatus(client, packageName),
4100
+ queryVitalForStatus(reporting, packageName, "crashRateMetricSet", days),
4101
+ queryVitalForStatus(reporting, packageName, "anrRateMetricSet", days),
4102
+ queryVitalForStatus(reporting, packageName, "slowStartRateMetricSet", days),
4103
+ queryVitalForStatus(reporting, packageName, "slowRenderingRateMetricSet", days),
4104
+ listReviews(client, packageName, { maxResults: 500 })
4105
+ ]);
4106
+ const rawReleases = releasesResult.status === "fulfilled" ? releasesResult.value : [];
4107
+ const releases = rawReleases.map((r) => ({
4108
+ track: r.track,
4109
+ versionCode: r.versionCodes[r.versionCodes.length - 1] ?? "\u2014",
4110
+ status: r.status,
4111
+ userFraction: r.userFraction ?? null
4112
+ }));
4113
+ const crashValue = crashesResult.status === "fulfilled" ? crashesResult.value : void 0;
4114
+ const anrValue = anrResult.status === "fulfilled" ? anrResult.value : void 0;
4115
+ const slowStartValue = slowStartResult.status === "fulfilled" ? slowStartResult.value : void 0;
4116
+ const slowRenderValue = slowRenderResult.status === "fulfilled" ? slowRenderResult.value : void 0;
4117
+ const rawReviews = reviewsResult.status === "fulfilled" ? reviewsResult.value : [];
4118
+ const reviews = computeReviewSentiment(rawReviews, 30);
4119
+ const fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
4120
+ return {
4121
+ packageName,
4122
+ fetchedAt,
4123
+ cached: false,
4124
+ releases,
4125
+ vitals: {
4126
+ windowDays: days,
4127
+ crashes: toVitalMetric(crashValue, thresholds.crashRate),
4128
+ anr: toVitalMetric(anrValue, thresholds.anrRate),
4129
+ slowStarts: toVitalMetric(slowStartValue, thresholds.slowStartRate),
4130
+ slowRender: toVitalMetric(slowRenderValue, thresholds.slowRenderingRate)
4131
+ },
4132
+ reviews
4133
+ };
4134
+ }
4135
+ function vitalIndicator(metric) {
4136
+ if (metric.status === "unknown") return "?";
4137
+ if (metric.status === "breach") return "\u2717";
4138
+ if (metric.status === "warn") return "\u26A0";
4139
+ return "\u2713";
4140
+ }
4141
+ function formatVitalValue(metric) {
4142
+ if (metric.value === void 0) return "n/a";
4143
+ return `${(metric.value * 100).toFixed(2)}%`;
4144
+ }
4145
+ function formatFraction(fraction) {
4146
+ if (fraction === null) return "\u2014";
4147
+ return `${Math.round(fraction * 100)}%`;
4148
+ }
4149
+ function formatRating(rating) {
4150
+ if (rating === void 0) return "n/a";
4151
+ return `\u2605 ${rating.toFixed(1)}`;
4152
+ }
4153
+ function formatTrend(current, previous) {
4154
+ if (current === void 0 || previous === void 0) return "";
4155
+ if (current > previous) return ` \u2191 from ${previous.toFixed(1)}`;
4156
+ if (current < previous) return ` \u2193 from ${previous.toFixed(1)}`;
4157
+ return "";
4158
+ }
4159
+ function formatStatusTable(status) {
4160
+ const lines = [];
4161
+ const cachedLabel = status.cached ? ` (cached ${new Date(status.fetchedAt).toLocaleTimeString()})` : ` (fetched ${new Date(status.fetchedAt).toLocaleTimeString()})`;
4162
+ lines.push(`App: ${status.packageName}${cachedLabel}`);
4163
+ lines.push("");
4164
+ lines.push("RELEASES");
4165
+ if (status.releases.length === 0) {
4166
+ lines.push(" No releases found.");
4167
+ } else {
4168
+ const trackW = Math.max(10, ...status.releases.map((r) => r.track.length));
4169
+ const versionW = Math.max(7, ...status.releases.map((r) => r.versionCode.length));
4170
+ const statusW = Math.max(8, ...status.releases.map((r) => r.status.length));
4171
+ for (const r of status.releases) {
4172
+ lines.push(
4173
+ ` ${r.track.padEnd(trackW)} ${r.versionCode.padEnd(versionW)} ${r.status.padEnd(statusW)} ${formatFraction(r.userFraction)}`
4174
+ );
4175
+ }
4176
+ }
4177
+ lines.push("");
4178
+ lines.push(`VITALS (last ${status.vitals.windowDays} days)`);
4179
+ const { crashes, anr, slowStarts, slowRender } = status.vitals;
4180
+ lines.push(
4181
+ ` crashes ${formatVitalValue(crashes).padEnd(8)} ${vitalIndicator(crashes)} anr ${formatVitalValue(anr).padEnd(8)} ${vitalIndicator(anr)}`
4182
+ );
4183
+ lines.push(
4184
+ ` slow starts ${formatVitalValue(slowStarts).padEnd(8)} ${vitalIndicator(slowStarts)} slow render ${formatVitalValue(slowRender).padEnd(8)} ${vitalIndicator(slowRender)}`
4185
+ );
4186
+ lines.push("");
4187
+ lines.push(`REVIEWS (last ${status.reviews.windowDays} days)`);
4188
+ const { averageRating, previousAverageRating, totalNew, positivePercent } = status.reviews;
4189
+ const trend = formatTrend(averageRating, previousAverageRating);
4190
+ const positiveStr = positivePercent !== void 0 ? ` ${positivePercent}% positive` : "";
4191
+ lines.push(
4192
+ ` ${formatRating(averageRating)} ${totalNew} new${positiveStr}${trend}`
4193
+ );
4194
+ return lines.join("\n");
4195
+ }
4196
+ function statusHasBreach(status) {
4197
+ return status.vitals.crashes.status === "breach" || status.vitals.anr.status === "breach" || status.vitals.slowStarts.status === "breach" || status.vitals.slowRender.status === "breach";
4198
+ }
3915
4199
  export {
3916
4200
  ApiError,
3917
4201
  ConfigError,
@@ -3981,9 +4265,11 @@ export {
3981
4265
  formatJunit,
3982
4266
  formatOutput,
3983
4267
  formatSlackPayload,
4268
+ formatStatusTable,
3984
4269
  generateMigrationPlan,
3985
4270
  generateNotesFromGit,
3986
4271
  getAppInfo,
4272
+ getAppStatus,
3987
4273
  getCountryAvailability,
3988
4274
  getDataSafety,
3989
4275
  getDeviceTier,
@@ -4034,6 +4320,7 @@ export {
4034
4320
  listTracks,
4035
4321
  listUsers,
4036
4322
  listVoidedPurchases,
4323
+ loadStatusCache,
4037
4324
  migratePrices,
4038
4325
  parseAppfile,
4039
4326
  parseFastfile,
@@ -4055,11 +4342,13 @@ export {
4055
4342
  revokeSubscriptionPurchase,
4056
4343
  safePath,
4057
4344
  safePathWithin,
4345
+ saveStatusCache,
4058
4346
  scaffoldPlugin,
4059
4347
  searchAuditEvents,
4060
4348
  searchVitalsErrors,
4061
4349
  sendWebhook,
4062
4350
  sortResults,
4351
+ statusHasBreach,
4063
4352
  syncInAppProducts,
4064
4353
  updateAppDetails,
4065
4354
  updateDataSafety,