Ctrl K
ComunidadGuías…

scripts/write-changelog.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Changesets also writes its own per-package CHANGELOG.md files (one per
// workspace package, cross-referencing internal "Updated dependencies"
// bumps) — `changesets/action` reads those to build the "Version
// Packages" PR's own description, so they have to keep existing even
// though this is a single self-hosted app, not a set of independently-
// consumed libraries. This script instead gives *people* a single
// source of truth: one consolidated entry in the root CHANGELOG.md,
// written from the pending changeset files *before* `changeset version`
// deletes them. That's what the release workflow pulls from for actual
// release notes — the scattered per-package files are internal
// bookkeeping a self-hoster never needs to look at.
import { execFileSync } from "node:child_process";
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getCommitInfo } from "@changesets/get-github-info";

const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const changesetDir = path.join(rootDir, ".changeset");
const changelogPath = path.join(rootDir, "CHANGELOG.md");
const rootPkgPath = path.join(rootDir, "package.json");
const changesetConfigPath = path.join(changesetDir, "config.json");

// Matches the category tags CONTRIBUTING.md's "Writing a changeset"
// section asks every changeset to start with. A changeset that doesn't
// follow the convention (e.g. one written before this convention existed)
// still renders — it just lands in "Other" with no lede/details split —
// rather than being silently dropped.
const CATEGORY_ORDER = ["New", "Improved", "Fixed", "Other"];

function parseChangeset(filePath) {
  // Windows checkouts may have CRLF line endings.
  const raw = readFileSync(filePath, "utf8").replace(/\r\n/g, "\n");
  const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  if (!match) return null;
  const [, frontmatter, body] = match;
  const bumps = [...frontmatter.matchAll(/"[^"]+":\s*(major|minor|patch)/g)].map((m) => m[1]);
  const summary = body.trim();
  return { bumps, summary };
}

function bumpVersion(version, bumpType) {
  const [major, minor, patch] = version.split(".").map(Number);
  if (bumpType === "major") return `${major + 1}.0.0`;
  if (bumpType === "minor") return `${major}.${minor + 1}.0`;
  return `${major}.${minor}.${patch + 1}`;
}

// Every changeset in this repo lands on main as a single squash-merge
// commit, so the commit that added its .md file (not a later edit to its
// wording) *is* the commit
// `getCommitInfo` needs to look up the PR it came from. Returns null
// (never throws) if git can't find one — e.g. a changeset file staged
// but not yet committed during local testing.
function findIntroducingCommit(filePath) {
  try {
    const out = execFileSync("git", ["log", "--diff-filter=A", "-1", "--format=%H", "--", filePath], {
      cwd: rootDir,
      encoding: "utf8",
    }).trim();
    return out || null;
  } catch {
    return null;
  }
}

// Resolves a changeset file to the PR that carried it, for an inline
// "([#123](...))" link on its CHANGELOG.md bullet. Best-effort only —
// this script runs as part of the automated release pipeline, and a
// missing link is a cosmetic issue, never a reason to fail a release:
// no GITHUB_TOKEN (e.g. a local dry run), a network hiccup, or GitHub not
// yet having indexed a very fresh commit all degrade to "no link".
async function resolvePrLink(filePath, repo) {
  if (!repo || !process.env.GITHUB_TOKEN) return null;
  const commit = findIntroducingCommit(filePath);
  if (!commit) return null;
  try {
    const info = await getCommitInfo({ repo, commit });
    return info?.pull ? { number: info.pull.number, url: info.pull.url } : null;
  } catch (err) {
    console.warn(`Couldn't resolve a PR link for ${path.basename(filePath)}: ${err.message}`);
    return null;
  }
}

// Splits a changeset body into one entry per category tag: its category,
// its plain-language lede (the first paragraph, on one line with the PR
// link), what follows it (bullets and paragraphs, kept as written), and a
// <details> technical block if there is one. A body with more than one tag
// ("**Fixed:** ... **New:** ...") becomes one entry under each heading
// instead of the second tag being buried in the first; the details block
// stays with the first.
function splitChangeset(summary) {
  const text = summary.replace(/\r\n/g, "\n").trim();
  const detailsIndex = text.indexOf("<details>");
  const ledePart = (detailsIndex === -1 ? text : text.slice(0, detailsIndex)).trim();
  const details = detailsIndex === -1 ? "" : text.slice(detailsIndex).trim();
  const tags = [...ledePart.matchAll(/\*\*(New|Improved|Fixed):\*\*\s*/g)];
  const parts =
    tags.length === 0 || tags[0].index !== 0
      ? [{ category: "Other", body: ledePart }]
      : tags.map((tag, i) => ({
          category: tag[1],
          // A tag that opens a bullet ("- **Fixed:** ...") leaves that
          // bullet's dash behind at the end of the previous part.
          body: ledePart
            .slice(tag.index + tag[0].length, tags[i + 1]?.index)
            .replace(/\n[ \t]*-[ \t]*$/, "")
            .trim(),
        }));
  return parts.map(({ category, body }, i) => {
    const lines = body.split("\n");
    // The lede runs until a blank line or the first bullet.
    const end = lines.findIndex((line, n) => n > 0 && (line.trim() === "" || /^\s*[-*] /.test(line)));
    const ledeLines = end === -1 ? lines : lines.slice(0, end);
    return {
      category,
      lede: ledeLines.map((line) => line.trim()).join(" "),
      more: end === -1 ? "" : lines.slice(end).join("\n").trim(),
      details: i === 0 ? details : "",
    };
  });
}

// Indents a block so it belongs to the list item above it.
function indent(block) {
  return block
    .split("\n")
    .map((line) => (line.trim() ? `  ${line}` : ""))
    .join("\n");
}

function formatBullet({ lede, more, details }, prLink) {
  const link = prLink ? ` ([#${prLink.number}](${prLink.url}))` : "";
  return [`- ${lede}${link}`, more && indent(more), details && indent(details)].filter(Boolean).join("\n\n");
}

const files = readdirSync(changesetDir)
  .filter((f) => f.endsWith(".md") && f !== "README.md")
  .sort();

const changesets = files
  .map((f) => ({ file: f, ...parseChangeset(path.join(changesetDir, f)) }))
  .filter((c) => c && c.summary && c.summary.length > 0);

if (changesets.length === 0) {
  console.log("No pending changesets — nothing to write to CHANGELOG.md");
  process.exit(0);
}

const highestBump = changesets.some((c) => c.bumps.includes("major"))
  ? "major"
  : changesets.some((c) => c.bumps.includes("minor"))
    ? "minor"
    : "patch";

const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf8"));
const newVersion = bumpVersion(rootPkg.version, highestBump);
const today = new Date().toISOString().slice(0, 10);

// Single source of truth for the GitHub repo slug, shared with
// changeset version's own per-package changelogs (see .changeset/config.json).
const changesetConfig = JSON.parse(readFileSync(changesetConfigPath, "utf8"));
const repo = Array.isArray(changesetConfig.changelog) ? changesetConfig.changelog[1]?.repo : undefined;

let linksResolved = 0;
const entries = [];
for (const c of changesets) {
  const prLink = await resolvePrLink(path.join(changesetDir, c.file), repo);
  if (prLink) linksResolved++;
  for (const split of splitChangeset(c.summary)) {
    entries.push({ ...split, bullet: formatBullet(split, prLink) });
  }
}

const byCategory = new Map();
for (const entry of entries) {
  if (!byCategory.has(entry.category)) byCategory.set(entry.category, []);
  byCategory.get(entry.category).push(entry.bullet);
}

const sections = CATEGORY_ORDER.filter((category) => byCategory.has(category))
  .map((category) => `### ${category}\n\n${byCategory.get(category).join("\n\n")}`)
  .join("\n\n");

const newSection = `## [${newVersion}] - ${today}\n\n${sections}\n\n`;

const changelog = readFileSync(changelogPath, "utf8");
const firstEntryIndex = changelog.search(/^## \[/m);
const insertAt = firstEntryIndex === -1 ? changelog.length : firstEntryIndex;
const updated = changelog.slice(0, insertAt) + newSection + changelog.slice(insertAt);

writeFileSync(changelogPath, updated);
console.log(
  `Wrote CHANGELOG.md entry for ${newVersion} (${changesets.length} changeset(s), ${highestBump} bump, ${linksResolved} PR link(s) resolved)`,
);