@gpc-cli/core 0.9.50 → 0.9.52

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
@@ -3723,10 +3723,10 @@ ${bullets}`);
3723
3723
  }
3724
3724
  return { text, truncated };
3725
3725
  }
3726
- async function gitExec(args) {
3726
+ async function gitExec(args, opts) {
3727
3727
  try {
3728
- const { stdout } = await execFile("git", args);
3729
- return stdout.trim();
3728
+ const result = opts?.cwd ? await execFile("git", args, { encoding: "utf8", cwd: opts.cwd }) : await execFile("git", args);
3729
+ return result.stdout.trim();
3730
3730
  } catch (error) {
3731
3731
  const err = error;
3732
3732
  if (err.code === "ENOENT") {
@@ -7361,6 +7361,753 @@ function formatChangelogEntry(entry) {
7361
7361
  return lines.join("\n");
7362
7362
  }
7363
7363
 
7364
+ // src/commands/changelog-generate.ts
7365
+ var KNOWN_TYPES = /* @__PURE__ */ new Set([
7366
+ "feat",
7367
+ "fix",
7368
+ "perf",
7369
+ "breaking",
7370
+ "docs",
7371
+ "ci",
7372
+ "chore",
7373
+ "refactor",
7374
+ "test",
7375
+ "build",
7376
+ "style",
7377
+ "release"
7378
+ ]);
7379
+ var FILTERED_TYPES = /* @__PURE__ */ new Set(["chore", "refactor", "test", "build", "style", "merge"]);
7380
+ var SECTION_ORDER = ["breaking", "feat", "fix", "perf", "docs", "ci", "release", "other"];
7381
+ var FIXUP_PATTERNS = [
7382
+ /^wip\b/i,
7383
+ /^fix\s+typo\b/i,
7384
+ /^fix\s+typos\b/i,
7385
+ /^address\s+review\b/i,
7386
+ /^review\s+fixes\b/i,
7387
+ /^fixup!/i,
7388
+ /^squash!/i
7389
+ ];
7390
+ var REVERT_PATTERN = /^Revert\s+"(.+?)"\s*$/i;
7391
+ var VERB_CANONICALIZATIONS = [
7392
+ [/^Added\b/, "add"],
7393
+ [/^Adds\b/, "add"],
7394
+ [/^Add\b/, "add"],
7395
+ [/^Fixed\b/, "fix"],
7396
+ [/^Fixes\b/, "fix"],
7397
+ [/^Fix\b/, "fix"],
7398
+ [/^Updated\b/, "update"],
7399
+ [/^Updates\b/, "update"],
7400
+ [/^Update\b/, "update"],
7401
+ [/^Removed\b/, "remove"],
7402
+ [/^Removes\b/, "remove"],
7403
+ [/^Remove\b/, "remove"]
7404
+ ];
7405
+ var EMOJI_PREFIX = /^(?:[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*)+/u;
7406
+ var INTERNAL_JARGON = [
7407
+ "mutex",
7408
+ "token bucket",
7409
+ "barrel export",
7410
+ "barrel exports",
7411
+ "homedir",
7412
+ "at module level",
7413
+ "ESM",
7414
+ "tsup",
7415
+ "vi.stubGlobal",
7416
+ "execFile",
7417
+ "lazy-import"
7418
+ ];
7419
+ var RECORD_START = "";
7420
+ var FIELD_SEP = "";
7421
+ var COMMIT_FORMAT = `${RECORD_START}%H${FIELD_SEP}%s${FIELD_SEP}%aI${FIELD_SEP}%b`;
7422
+ function parseRawCommits(stdout) {
7423
+ if (!stdout.trim()) return [];
7424
+ const blocks = stdout.split(RECORD_START).filter((b) => b.trim());
7425
+ const commits = [];
7426
+ for (const block of blocks) {
7427
+ const headerEnd = block.indexOf("\n");
7428
+ const header = headerEnd === -1 ? block : block.slice(0, headerEnd);
7429
+ const rest = headerEnd === -1 ? "" : block.slice(headerEnd + 1);
7430
+ const parts = header.split(FIELD_SEP);
7431
+ const sha = parts[0]?.trim() ?? "";
7432
+ const subject = parts[1]?.trim() ?? "";
7433
+ const authorDate = parts[2]?.trim() ?? "";
7434
+ const body = (parts[3] ?? "").trim();
7435
+ if (!sha) continue;
7436
+ const files = [];
7437
+ let additions = 0;
7438
+ let deletions = 0;
7439
+ for (const line of rest.split("\n")) {
7440
+ const trimmed = line.trim();
7441
+ if (!trimmed) continue;
7442
+ const match = trimmed.match(/^(\d+|-)\s+(\d+|-)\s+(.+)$/);
7443
+ if (match) {
7444
+ const a = match[1] === "-" ? 0 : Number(match[1]);
7445
+ const d = match[2] === "-" ? 0 : Number(match[2]);
7446
+ additions += a;
7447
+ deletions += d;
7448
+ files.push(match[3] ?? "");
7449
+ }
7450
+ }
7451
+ commits.push({ sha, subject, body, files, additions, deletions, authorDate });
7452
+ }
7453
+ return commits;
7454
+ }
7455
+ var defaultGitRunner = {
7456
+ async log({ from, to, cwd }) {
7457
+ const stdout = await gitExec(
7458
+ [
7459
+ "log",
7460
+ "--no-merges",
7461
+ `--format=${COMMIT_FORMAT}`,
7462
+ "--numstat",
7463
+ "--end-of-options",
7464
+ `${from}..${to}`
7465
+ ],
7466
+ { cwd }
7467
+ );
7468
+ return parseRawCommits(stdout);
7469
+ },
7470
+ async describeLatestTag(cwd) {
7471
+ try {
7472
+ const out = await gitExec(["describe", "--tags", "--match", "v*", "--abbrev=0"], { cwd });
7473
+ return out.trim() || null;
7474
+ } catch {
7475
+ return null;
7476
+ }
7477
+ },
7478
+ async verifyRef(ref, cwd) {
7479
+ try {
7480
+ await gitExec(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], { cwd });
7481
+ return true;
7482
+ } catch {
7483
+ return false;
7484
+ }
7485
+ },
7486
+ async remoteUrl(cwd) {
7487
+ try {
7488
+ const out = await gitExec(["remote", "get-url", "origin"], { cwd });
7489
+ return out.trim() || null;
7490
+ } catch {
7491
+ return null;
7492
+ }
7493
+ }
7494
+ };
7495
+ function parseRemoteUrl(url) {
7496
+ if (!url) return null;
7497
+ const https = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/i);
7498
+ if (https) return `${https[1]}/${https[2]}`;
7499
+ const ssh = url.match(/^git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/i);
7500
+ if (ssh) return `${ssh[1]}/${ssh[2]}`;
7501
+ return null;
7502
+ }
7503
+ function stripEmojiAndCanonicalize(subject) {
7504
+ let s = subject.replace(EMOJI_PREFIX, "");
7505
+ for (const [pattern, replacement] of VERB_CANONICALIZATIONS) {
7506
+ if (pattern.test(s)) {
7507
+ s = s.replace(pattern, replacement);
7508
+ break;
7509
+ }
7510
+ }
7511
+ return s.trim();
7512
+ }
7513
+ function isFixupSubject(subject) {
7514
+ return FIXUP_PATTERNS.some((p) => p.test(subject));
7515
+ }
7516
+ var CONVENTIONAL_RE = /^(?<type>\w+)(?:\((?<scope>[^)]+)\))?(?<bang>!?):\s*(?<rest>.+)$/;
7517
+ var PR_REF_RE = /\s*\(#(\d+)\)\s*$/;
7518
+ function parseCommit(raw) {
7519
+ const subject = raw.subject;
7520
+ const revertMatch = subject.match(REVERT_PATTERN);
7521
+ const isRevert = !!revertMatch;
7522
+ const effectiveSubject = revertMatch?.[1] ?? subject;
7523
+ const match = effectiveSubject.match(CONVENTIONAL_RE);
7524
+ let type = "other";
7525
+ let scope;
7526
+ let rest = effectiveSubject;
7527
+ if (match?.groups) {
7528
+ const rawType = match.groups["type"]?.toLowerCase() ?? "other";
7529
+ type = KNOWN_TYPES.has(rawType) ? rawType : "other";
7530
+ scope = match.groups["scope"];
7531
+ rest = match.groups["rest"] ?? effectiveSubject;
7532
+ if (match.groups["bang"]) type = "breaking";
7533
+ }
7534
+ let prRef;
7535
+ const prMatch = rest.match(PR_REF_RE);
7536
+ if (prMatch) {
7537
+ prRef = `#${prMatch[1]}`;
7538
+ rest = rest.replace(PR_REF_RE, "").trim();
7539
+ }
7540
+ const cleaned = stripEmojiAndCanonicalize(rest);
7541
+ const finalSubject = prRef ? `${cleaned} (${prRef})` : cleaned;
7542
+ return {
7543
+ sha: raw.sha,
7544
+ type,
7545
+ scope,
7546
+ subject: finalSubject,
7547
+ prRef,
7548
+ files: raw.files,
7549
+ weight: raw.additions + raw.deletions,
7550
+ isRevert,
7551
+ isFixup: isFixupSubject(rest) || isFixupSubject(subject),
7552
+ authorDate: raw.authorDate
7553
+ };
7554
+ }
7555
+ function dedupRevertPairs(commits) {
7556
+ const reverted = /* @__PURE__ */ new Set();
7557
+ for (const c of commits) {
7558
+ if (c.isRevert) reverted.add(c.subject);
7559
+ }
7560
+ return commits.filter((c) => {
7561
+ if (c.isRevert) return false;
7562
+ if (reverted.has(c.subject)) return false;
7563
+ return true;
7564
+ });
7565
+ }
7566
+ function topPathPrefix(file, depth = 2) {
7567
+ return file.split("/").slice(0, depth).join("/");
7568
+ }
7569
+ function tokenize2(subject) {
7570
+ const STOP = /* @__PURE__ */ new Set([
7571
+ "the",
7572
+ "a",
7573
+ "an",
7574
+ "and",
7575
+ "or",
7576
+ "of",
7577
+ "to",
7578
+ "in",
7579
+ "on",
7580
+ "for",
7581
+ "with",
7582
+ "is",
7583
+ "be",
7584
+ "by",
7585
+ "at"
7586
+ ]);
7587
+ return new Set(
7588
+ subject.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP.has(w))
7589
+ );
7590
+ }
7591
+ function jaccard(a, b) {
7592
+ if (a.size === 0 && b.size === 0) return 0;
7593
+ let inter = 0;
7594
+ for (const t of a) if (b.has(t)) inter++;
7595
+ const union = a.size + b.size - inter;
7596
+ return union === 0 ? 0 : inter / union;
7597
+ }
7598
+ function shouldCluster(a, b, ta, tb) {
7599
+ const aPrefixes = new Set(a.files.map((f) => topPathPrefix(f)));
7600
+ for (const f of b.files) {
7601
+ if (aPrefixes.has(topPathPrefix(f))) return true;
7602
+ }
7603
+ if (jaccard(ta, tb) > 0.4) return true;
7604
+ const aDate = Date.parse(a.authorDate);
7605
+ const bDate = Date.parse(b.authorDate);
7606
+ if (!Number.isNaN(aDate) && !Number.isNaN(bDate)) {
7607
+ const dayDiff = Math.abs(aDate - bDate) / 864e5;
7608
+ if (dayDiff <= 2) {
7609
+ const aFiles = new Set(a.files);
7610
+ for (const f of b.files) if (aFiles.has(f)) return true;
7611
+ }
7612
+ }
7613
+ return false;
7614
+ }
7615
+ var TYPE_PRIORITY = {
7616
+ breaking: 0,
7617
+ feat: 1,
7618
+ fix: 2,
7619
+ perf: 3,
7620
+ docs: 4,
7621
+ ci: 5,
7622
+ release: 6,
7623
+ other: 7
7624
+ };
7625
+ function clusterCommits(commits) {
7626
+ const n = commits.length;
7627
+ const parent = Array.from({ length: n }, (_, i) => i);
7628
+ const find = (i) => {
7629
+ while (parent[i] !== i) {
7630
+ parent[i] = parent[parent[i]];
7631
+ i = parent[i];
7632
+ }
7633
+ return i;
7634
+ };
7635
+ const union = (i, j) => {
7636
+ const ri = find(i);
7637
+ const rj = find(j);
7638
+ if (ri !== rj) parent[ri] = rj;
7639
+ };
7640
+ const tokens = commits.map((c) => tokenize2(c.subject));
7641
+ for (let i = 0; i < n; i++) {
7642
+ for (let j = i + 1; j < n; j++) {
7643
+ if (shouldCluster(commits[i], commits[j], tokens[i], tokens[j])) union(i, j);
7644
+ }
7645
+ }
7646
+ const groups = /* @__PURE__ */ new Map();
7647
+ for (let i = 0; i < n; i++) {
7648
+ const root = find(i);
7649
+ if (!groups.has(root)) groups.set(root, []);
7650
+ groups.get(root).push(commits[i]);
7651
+ }
7652
+ const clusters = [];
7653
+ for (const [, members] of groups) {
7654
+ const weight = members.reduce((s, m) => s + m.weight, 0);
7655
+ const primaryType = members.map((m) => m.type).sort((a, b) => (TYPE_PRIORITY[a] ?? 99) - (TYPE_PRIORITY[b] ?? 99))[0];
7656
+ const label = clusterLabel(members);
7657
+ clusters.push({
7658
+ id: label.toLowerCase().replace(/\s+/g, "-"),
7659
+ label,
7660
+ commits: members,
7661
+ weight,
7662
+ primaryType
7663
+ });
7664
+ }
7665
+ return clusters;
7666
+ }
7667
+ function clusterLabel(members) {
7668
+ const pathCounts = /* @__PURE__ */ new Map();
7669
+ for (const m of members) {
7670
+ for (const f of m.files) {
7671
+ const top = topPathPrefix(f, 2);
7672
+ pathCounts.set(top, (pathCounts.get(top) ?? 0) + 1);
7673
+ }
7674
+ }
7675
+ let bestPath = null;
7676
+ let bestPathCount = 0;
7677
+ for (const [p, c] of pathCounts) {
7678
+ if (c > bestPathCount) {
7679
+ bestPath = p;
7680
+ bestPathCount = c;
7681
+ }
7682
+ }
7683
+ if (bestPath) return bestPath;
7684
+ const tokenCounts = /* @__PURE__ */ new Map();
7685
+ for (const m of members) {
7686
+ for (const t of tokenize2(m.subject)) {
7687
+ tokenCounts.set(t, (tokenCounts.get(t) ?? 0) + 1);
7688
+ }
7689
+ }
7690
+ let bestToken = null;
7691
+ let bestTokenCount = 0;
7692
+ for (const [t, c] of tokenCounts) {
7693
+ if (c > bestTokenCount) {
7694
+ bestToken = t;
7695
+ bestTokenCount = c;
7696
+ }
7697
+ }
7698
+ return bestToken ?? members[0].subject.slice(0, 30);
7699
+ }
7700
+ function scoreHeadlines(clusters) {
7701
+ return [...clusters].sort((a, b) => {
7702
+ if (b.weight !== a.weight) return b.weight - a.weight;
7703
+ return (TYPE_PRIORITY[a.primaryType] ?? 99) - (TYPE_PRIORITY[b.primaryType] ?? 99);
7704
+ }).slice(0, 3);
7705
+ }
7706
+ function lintJargon(commits) {
7707
+ const warnings = [];
7708
+ for (const c of commits) {
7709
+ const lower = c.subject.toLowerCase();
7710
+ for (const word of INTERNAL_JARGON) {
7711
+ if (lower.includes(word.toLowerCase())) {
7712
+ warnings.push(`jargon: "${word}" in subject "${c.subject}" (${c.sha.slice(0, 7)})`);
7713
+ }
7714
+ }
7715
+ }
7716
+ return warnings;
7717
+ }
7718
+ async function generateChangelog(opts = {}, runner = defaultGitRunner) {
7719
+ const cwd = opts.cwd ?? process.cwd();
7720
+ let from = opts.from;
7721
+ if (!from) {
7722
+ from = await runner.describeLatestTag(cwd) ?? void 0;
7723
+ if (!from) {
7724
+ throw new GpcError(
7725
+ "No git tags found and --from was not provided",
7726
+ "CHANGELOG_NO_TAG",
7727
+ 1,
7728
+ "Pass --from <ref> explicitly, or create an initial tag (e.g., git tag v0.0.1)."
7729
+ );
7730
+ }
7731
+ }
7732
+ const to = opts.to ?? "HEAD";
7733
+ if (!await runner.verifyRef(from, cwd)) {
7734
+ throw new GpcError(
7735
+ `Invalid --from ref: "${from}"`,
7736
+ "CHANGELOG_BAD_REF",
7737
+ 1,
7738
+ "Verify the ref exists with: git rev-parse --verify <ref>"
7739
+ );
7740
+ }
7741
+ if (!await runner.verifyRef(to, cwd)) {
7742
+ throw new GpcError(
7743
+ `Invalid --to ref: "${to}"`,
7744
+ "CHANGELOG_BAD_REF",
7745
+ 1,
7746
+ "Verify the ref exists with: git rev-parse --verify <ref>"
7747
+ );
7748
+ }
7749
+ const repo = opts.repo ?? parseRemoteUrl(await runner.remoteUrl(cwd));
7750
+ const raw = await runner.log({ from, to, cwd });
7751
+ const rawCommitCount = raw.length;
7752
+ const parsed = raw.map(parseCommit);
7753
+ const warnings = [];
7754
+ const scopeLeak = parsed.find((c) => c.scope);
7755
+ if (scopeLeak) {
7756
+ warnings.push(
7757
+ `scope: dropped per project convention (e.g., "${scopeLeak.scope}" in ${scopeLeak.sha.slice(0, 7)})`
7758
+ );
7759
+ }
7760
+ const afterRevert = dedupRevertPairs(parsed);
7761
+ const nonFixup = afterRevert.filter((c) => !c.isFixup);
7762
+ const visible = nonFixup.filter((c) => !FILTERED_TYPES.has(c.type));
7763
+ const clusters = clusterCommits(visible);
7764
+ const headlineCandidates = scoreHeadlines(clusters);
7765
+ const grouped = {};
7766
+ for (const c of visible) {
7767
+ if (!grouped[c.type]) grouped[c.type] = [];
7768
+ grouped[c.type].push(c);
7769
+ }
7770
+ warnings.push(...lintJargon(visible));
7771
+ return {
7772
+ from,
7773
+ to,
7774
+ repo,
7775
+ rawCommitCount,
7776
+ commits: parsed,
7777
+ clusters,
7778
+ grouped,
7779
+ headlineCandidates,
7780
+ warnings
7781
+ };
7782
+ }
7783
+
7784
+ // src/commands/changelog-renderers/markdown.ts
7785
+ function safeSubject(s) {
7786
+ return s.replace(/[\r\n]+/g, " ").trim();
7787
+ }
7788
+ function renderMarkdown(g) {
7789
+ if (g.commits.length === 0) {
7790
+ const compare2 = g.repo ? `
7791
+ **Full Changelog**: https://github.com/${g.repo}/compare/${g.from}...${g.to}` : "";
7792
+ return `## What's Changed
7793
+
7794
+ _No notable changes._
7795
+ ${compare2}`.trim();
7796
+ }
7797
+ const lines = ["## What's Changed", ""];
7798
+ let emittedAny = false;
7799
+ for (const type of SECTION_ORDER) {
7800
+ const commits = g.grouped[type] ?? [];
7801
+ if (commits.length === 0) continue;
7802
+ for (const commit of commits) {
7803
+ lines.push(`- ${type}: ${safeSubject(commit.subject)}`);
7804
+ emittedAny = true;
7805
+ }
7806
+ }
7807
+ if (!emittedAny) {
7808
+ lines.push("_No notable changes._");
7809
+ }
7810
+ if (g.repo) {
7811
+ lines.push("");
7812
+ lines.push(`**Full Changelog**: https://github.com/${g.repo}/compare/${g.from}...${g.to}`);
7813
+ }
7814
+ return lines.join("\n");
7815
+ }
7816
+
7817
+ // src/commands/changelog-renderers/json.ts
7818
+ function renderJson(g) {
7819
+ return JSON.stringify(g, null, 2);
7820
+ }
7821
+
7822
+ // src/commands/changelog-renderers/prompt.ts
7823
+ function safeLine(s) {
7824
+ return s.replace(/[\r\n]+/g, " ").trim();
7825
+ }
7826
+ function renderPrompt(g) {
7827
+ const lines = [];
7828
+ lines.push("You are writing a draft of GitHub Release notes from clustered git commits.");
7829
+ lines.push("");
7830
+ lines.push("VOICE RULES (from project conventions):");
7831
+ lines.push("- Terse, present tense, user-facing language");
7832
+ lines.push('- No internal jargon (no "mutex", "token bucket", "barrel exports", "homedir")');
7833
+ lines.push("- One bullet per feature/fix, not one per commit");
7834
+ lines.push("- Drop conventional-commit scopes (e.g., feat(cli) \u2192 feat:)");
7835
+ lines.push("- Open with a single-sentence highlight describing the release theme");
7836
+ lines.push("");
7837
+ lines.push(`RANGE: ${g.from}..${g.to}`);
7838
+ if (g.repo) lines.push(`REPO: ${g.repo}`);
7839
+ lines.push(`COMMITS: ${g.rawCommitCount} raw, ${g.clusters.length} clusters after dedup`);
7840
+ lines.push("");
7841
+ if (g.headlineCandidates.length > 0) {
7842
+ lines.push("HEADLINE CANDIDATES (largest first):");
7843
+ for (const c of g.headlineCandidates) {
7844
+ lines.push(` ${c.label} (weight ${c.weight}, ${c.commits.length} commits, primary ${c.primaryType})`);
7845
+ }
7846
+ lines.push("");
7847
+ }
7848
+ lines.push("CLUSTERED COMMITS:");
7849
+ lines.push("");
7850
+ for (const cluster of g.clusters) {
7851
+ lines.push(`[cluster: ${cluster.label}, weight ${cluster.weight}, ${cluster.commits.length} commits, primary ${cluster.primaryType}]`);
7852
+ for (const commit of cluster.commits) {
7853
+ lines.push(`- ${commit.type}: ${safeLine(commit.subject)} (${commit.sha.slice(0, 7)})`);
7854
+ if (commit.files.length > 0) {
7855
+ const fileSummary = commit.files.slice(0, 3).join(", ");
7856
+ const more = commit.files.length > 3 ? ` (+${commit.files.length - 3} more)` : "";
7857
+ lines.push(` files: ${fileSummary}${more}`);
7858
+ }
7859
+ }
7860
+ lines.push("");
7861
+ }
7862
+ if (g.warnings.length > 0) {
7863
+ lines.push("LINTER WARNINGS (review before publishing):");
7864
+ for (const w of g.warnings) lines.push(` - ${w}`);
7865
+ lines.push("");
7866
+ }
7867
+ const compare2 = g.repo ? `https://github.com/${g.repo}/compare/${g.from}...${g.to}` : `${g.from}..${g.to}`;
7868
+ lines.push("OUTPUT FORMAT (match exactly):");
7869
+ lines.push("```markdown");
7870
+ lines.push("<one-sentence highlight>");
7871
+ lines.push("");
7872
+ lines.push("## What's Changed");
7873
+ lines.push("");
7874
+ lines.push("- breaking: ...");
7875
+ lines.push("- feat: ...");
7876
+ lines.push("- fix: ...");
7877
+ lines.push("- perf: ...");
7878
+ lines.push("");
7879
+ lines.push(`**Full Changelog**: ${compare2}`);
7880
+ lines.push("```");
7881
+ return lines.join("\n");
7882
+ }
7883
+
7884
+ // src/commands/changelog-renderers/play-store.ts
7885
+ var PLAY_STORE_LIMIT = 500;
7886
+ var PLACEHOLDER_TEXT = "[needs translation \u2014 pass --ai once v0.9.63 ships, or paste the prompt emitted by --format prompt]";
7887
+ function safeLine2(s) {
7888
+ return s.replace(/[\r\n]+/g, " ").trim();
7889
+ }
7890
+ function countChars(s) {
7891
+ return [...s].length;
7892
+ }
7893
+ function truncateToLimit(text, limit) {
7894
+ if (countChars(text) <= limit) return text;
7895
+ const chars = [...text];
7896
+ return chars.slice(0, limit - 1).join("") + "\u2026";
7897
+ }
7898
+ function renderEnglishBullets(g) {
7899
+ const lines = [];
7900
+ for (const type of SECTION_ORDER) {
7901
+ const commits = g.grouped[type] ?? [];
7902
+ for (const commit of commits) {
7903
+ lines.push(`- ${type}: ${safeLine2(commit.subject)}`);
7904
+ }
7905
+ }
7906
+ return lines.join("\n");
7907
+ }
7908
+ function buildLocaleBundle(g, opts) {
7909
+ const sourceLanguage = opts.sourceLanguage ?? "en-US";
7910
+ const sourceText = renderEnglishBullets(g);
7911
+ const isEmpty = sourceText.length === 0;
7912
+ const overflows = [];
7913
+ const entries = opts.locales.map((language) => {
7914
+ if (language === sourceLanguage) {
7915
+ if (isEmpty) {
7916
+ const emptyText = "No notable changes.";
7917
+ return {
7918
+ language,
7919
+ text: emptyText,
7920
+ chars: countChars(emptyText),
7921
+ limit: PLAY_STORE_LIMIT,
7922
+ status: "empty"
7923
+ };
7924
+ }
7925
+ const chars = countChars(sourceText);
7926
+ const status = chars > PLAY_STORE_LIMIT ? "over" : "ok";
7927
+ if (status === "over") overflows.push(language);
7928
+ const text = status === "over" ? truncateToLimit(sourceText, PLAY_STORE_LIMIT) : sourceText;
7929
+ return {
7930
+ language,
7931
+ text,
7932
+ chars,
7933
+ limit: PLAY_STORE_LIMIT,
7934
+ status
7935
+ };
7936
+ }
7937
+ return {
7938
+ language,
7939
+ text: PLACEHOLDER_TEXT,
7940
+ chars: countChars(PLACEHOLDER_TEXT),
7941
+ limit: PLAY_STORE_LIMIT,
7942
+ status: "placeholder"
7943
+ };
7944
+ });
7945
+ return {
7946
+ from: g.from,
7947
+ to: g.to,
7948
+ limit: PLAY_STORE_LIMIT,
7949
+ sourceLanguage,
7950
+ locales: entries,
7951
+ overflows
7952
+ };
7953
+ }
7954
+ function renderPlayStoreMd(bundle) {
7955
+ const lines = [];
7956
+ lines.push(`# Play Store release notes (${bundle.from} \u2192 ${bundle.to})`);
7957
+ lines.push("");
7958
+ for (const entry of bundle.locales) {
7959
+ let heading;
7960
+ if (entry.status === "placeholder") {
7961
+ heading = `## ${entry.language} (needs translation)`;
7962
+ } else if (entry.status === "empty") {
7963
+ heading = `## ${entry.language} (empty)`;
7964
+ } else {
7965
+ const suffix = entry.status === "over" ? ` \u26A0 truncated` : "";
7966
+ heading = `## ${entry.language} (${entry.chars}/${entry.limit})${suffix}`;
7967
+ }
7968
+ lines.push(heading);
7969
+ lines.push(entry.text);
7970
+ lines.push("");
7971
+ }
7972
+ lines.push("## Summary");
7973
+ for (const entry of bundle.locales) {
7974
+ if (entry.status === "placeholder") {
7975
+ lines.push(`- ${entry.language}: placeholder`);
7976
+ } else if (entry.status === "empty") {
7977
+ lines.push(`- ${entry.language}: empty`);
7978
+ } else {
7979
+ const mark = entry.status === "over" ? "\u2717 over limit" : "\u2713";
7980
+ lines.push(`- ${entry.language}: ${entry.chars}/${entry.limit} ${mark}`);
7981
+ }
7982
+ }
7983
+ return lines.join("\n");
7984
+ }
7985
+ function renderPlayStoreJson(bundle) {
7986
+ return JSON.stringify(bundle, null, 2);
7987
+ }
7988
+ function renderPlayStorePrompt(bundle, g) {
7989
+ const source = bundle.locales.find((e) => e.language === bundle.sourceLanguage);
7990
+ const targets = bundle.locales.filter((e) => e.language !== bundle.sourceLanguage);
7991
+ const lines = [];
7992
+ lines.push(`You are translating Play Store "What's new" release notes from ${bundle.sourceLanguage}.`);
7993
+ lines.push("");
7994
+ lines.push("TARGETS:");
7995
+ for (const t of targets) lines.push(` - ${t.language}`);
7996
+ if (targets.length === 0) lines.push(" (none \u2014 source-only bundle)");
7997
+ lines.push("");
7998
+ lines.push("CONSTRAINTS:");
7999
+ lines.push(`- Each translation MUST be \u2264 ${bundle.limit} Unicode code points`);
8000
+ lines.push('- Preserve the bullet format (one item per line, starts with "- ")');
8001
+ lines.push("- Keep a user-facing tone (no internal jargon)");
8002
+ lines.push('- Do not translate technical names (package names, CLI flags, "GPC")');
8003
+ lines.push("- Drop the conventional-commit prefix (feat:/fix:) if it feels unnatural in the target language");
8004
+ lines.push("");
8005
+ lines.push(`SOURCE (${bundle.sourceLanguage}, ${source?.chars ?? 0}/${bundle.limit} chars):`);
8006
+ lines.push("```");
8007
+ lines.push(source?.text ?? "(empty)");
8008
+ lines.push("```");
8009
+ lines.push("");
8010
+ if (g.headlineCandidates.length > 0) {
8011
+ lines.push("CONTEXT (clusters, for theme awareness):");
8012
+ for (const c of g.headlineCandidates) {
8013
+ lines.push(` - ${c.label} (${c.commits.length} commits, primary ${c.primaryType})`);
8014
+ }
8015
+ lines.push("");
8016
+ }
8017
+ lines.push("OUTPUT FORMAT (one heading + body per target language):");
8018
+ lines.push("```markdown");
8019
+ for (const t of targets) {
8020
+ lines.push(`## ${t.language}`);
8021
+ lines.push("<translation>");
8022
+ lines.push("");
8023
+ }
8024
+ if (targets.length === 0) {
8025
+ lines.push("(no target locales \u2014 nothing to translate)");
8026
+ }
8027
+ lines.push("```");
8028
+ return lines.join("\n");
8029
+ }
8030
+ function renderPlayStore(g, opts) {
8031
+ const bundle = buildLocaleBundle(g, opts);
8032
+ switch (opts.format) {
8033
+ case "md":
8034
+ return { output: renderPlayStoreMd(bundle), bundle };
8035
+ case "json":
8036
+ return { output: renderPlayStoreJson(bundle), bundle };
8037
+ case "prompt":
8038
+ return { output: renderPlayStorePrompt(bundle, g), bundle };
8039
+ }
8040
+ }
8041
+
8042
+ // src/commands/changelog-renderers/index.ts
8043
+ var RENDERERS = {
8044
+ md: renderMarkdown,
8045
+ json: renderJson,
8046
+ prompt: renderPrompt
8047
+ };
8048
+
8049
+ // src/commands/changelog-locales.ts
8050
+ async function resolveLocales(input, options = {}) {
8051
+ const trimmed = input.trim();
8052
+ if (!trimmed) {
8053
+ throw new GpcError(
8054
+ "--locales is empty",
8055
+ "CHANGELOG_LOCALES_REQUIRED",
8056
+ 2,
8057
+ "Pass a comma-separated list (e.g., --locales en-US,fr-FR) or --locales auto."
8058
+ );
8059
+ }
8060
+ if (trimmed === "auto") {
8061
+ const { client, packageName } = options;
8062
+ if (!client || !packageName) {
8063
+ throw new GpcError(
8064
+ "--locales auto requires an authenticated API client and --app",
8065
+ "CHANGELOG_LOCALES_AUTO_NO_APP",
8066
+ 2,
8067
+ "Pass --app <package> or set config.app, and ensure credentials are configured."
8068
+ );
8069
+ }
8070
+ const edit = await client.edits.insert(packageName);
8071
+ try {
8072
+ const listings = await client.listings.list(packageName, edit.id);
8073
+ await client.edits.delete(packageName, edit.id);
8074
+ const langs = listings.map((l) => l.language);
8075
+ if (langs.length === 0) {
8076
+ throw new GpcError(
8077
+ `No Play Store listings found for ${packageName}`,
8078
+ "CHANGELOG_LOCALES_EMPTY",
8079
+ 1,
8080
+ "Create at least one listing in Play Console, or pass --locales explicitly."
8081
+ );
8082
+ }
8083
+ return langs;
8084
+ } catch (error) {
8085
+ await client.edits.delete(packageName, edit.id).catch(() => {
8086
+ });
8087
+ throw error;
8088
+ }
8089
+ }
8090
+ const locales = trimmed.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
8091
+ if (locales.length === 0) {
8092
+ throw new GpcError(
8093
+ "--locales parsed to an empty list",
8094
+ "CHANGELOG_LOCALES_REQUIRED",
8095
+ 2,
8096
+ "Pass a comma-separated list (e.g., --locales en-US,fr-FR) or --locales auto."
8097
+ );
8098
+ }
8099
+ const invalid = locales.filter((l) => !isValidBcp47(l));
8100
+ if (invalid.length > 0) {
8101
+ throw new GpcError(
8102
+ `Invalid locale(s): ${invalid.join(", ")}`,
8103
+ "CHANGELOG_LOCALES_INVALID",
8104
+ 2,
8105
+ "Use BCP 47 codes recognized by Google Play (e.g., en-US, fr-FR, de-DE)."
8106
+ );
8107
+ }
8108
+ return locales;
8109
+ }
8110
+
7364
8111
  // src/commands/rtdn.ts
7365
8112
  var SUBSCRIPTION_NOTIFICATION_TYPES = {
7366
8113
  1: "SUBSCRIPTION_RECOVERED",
@@ -7462,7 +8209,11 @@ export {
7462
8209
  GpcError,
7463
8210
  NetworkError,
7464
8211
  PERMISSION_PROPAGATION_WARNING,
8212
+ PLACEHOLDER_TEXT,
8213
+ PLAY_STORE_LIMIT,
7465
8214
  PluginManager,
8215
+ RENDERERS,
8216
+ SECTION_ORDER,
7466
8217
  SENSITIVE_ARG_KEYS,
7467
8218
  SENSITIVE_KEYS,
7468
8219
  SEVERITY_ORDER,
@@ -7478,6 +8229,7 @@ export {
7478
8229
  analyzeReviews2 as analyzeReviews,
7479
8230
  batchGetOrders,
7480
8231
  batchSyncInAppProducts,
8232
+ buildLocaleBundle,
7481
8233
  cancelRecoveryAction,
7482
8234
  cancelSubscriptionPurchase,
7483
8235
  cancelSubscriptionV2,
@@ -7506,6 +8258,7 @@ export {
7506
8258
  deactivateBasePlan,
7507
8259
  deactivateOffer,
7508
8260
  decodeNotification,
8261
+ defaultGitRunner,
7509
8262
  deferSubscriptionPurchase,
7510
8263
  deferSubscriptionV2,
7511
8264
  deleteBasePlan,
@@ -7544,6 +8297,7 @@ export {
7544
8297
  formatStatusSummary,
7545
8298
  formatStatusTable,
7546
8299
  formatWordDiff,
8300
+ generateChangelog,
7547
8301
  generateMigrationPlan,
7548
8302
  generateNotesFromGit,
7549
8303
  getAllScannerNames,
@@ -7617,9 +8371,11 @@ export {
7617
8371
  maybePaginate,
7618
8372
  migratePrices,
7619
8373
  parseAppfile,
8374
+ parseCommit,
7620
8375
  parseFastfile,
7621
8376
  parseGrantArg,
7622
8377
  parseMonth,
8378
+ parseRemoteUrl,
7623
8379
  pauseTrain,
7624
8380
  promoteRelease,
7625
8381
  publish,
@@ -7635,7 +8391,15 @@ export {
7635
8391
  relativeTime,
7636
8392
  removeTesters,
7637
8393
  removeUser,
8394
+ renderJson,
8395
+ renderMarkdown,
8396
+ renderPlayStore,
8397
+ renderPlayStoreJson,
8398
+ renderPlayStoreMd,
8399
+ renderPlayStorePrompt,
8400
+ renderPrompt,
7638
8401
  replyToReview,
8402
+ resolveLocales,
7639
8403
  revokeSubscriptionPurchase,
7640
8404
  runPreflight,
7641
8405
  runWatchLoop,