Files
2026-07-13 13:32:57 +08:00

453 lines
14 KiB
JavaScript

#!/usr/bin/env node
/**
* Enhances the changeset release PR with a well-written, deduplicated summary.
*
* Reads:
* - The raw changeset PR body (via CHANGESET_PR_BODY env var or stdin)
* - .server-changes/*.md files for server-only changes
*
* Outputs a formatted PR body to stdout that includes:
* - A clean summary with categories
* - Server changes section
* - The raw changeset output in a collapsed <details> section
*
* Usage:
* CHANGESET_PR_BODY="..." node scripts/enhance-release-pr.mjs <version>
* echo "$PR_BODY" | node scripts/enhance-release-pr.mjs <version>
*/
import { promises as fs } from "fs";
import { execFile } from "child_process";
import { join } from "path";
const version = process.argv[2];
if (!version) {
console.error("Usage: node scripts/enhance-release-pr.mjs <version>");
process.exit(1);
}
const ROOT_DIR = join(import.meta.dirname, "..");
// --- Parse changeset PR body ---
function parsePrBody(body) {
const entries = [];
if (!body) return entries;
// Deduplicate by entry content. A single changeset that targets multiple
// packages is rendered once per package section, so the same text repeats and
// we collapse it. But several distinct changesets from one PR have distinct
// text (and each still carries that PR's link), so keying on content keeps
// them all instead of dropping every entry after the first for that PR.
const seen = new Set();
// A standalone dependency-bump list item, e.g. "`@trigger.dev/core@4.5.0-rc.7`"
// or "trigger.dev@4.5.0-rc.7". These normally appear nested under
// "Updated dependencies:" (and so get swallowed into that item below), but we
// guard against them showing up on their own too. Crucially this only matches
// a line that is *entirely* a package bump, so a real changeset that merely
// begins with a package name (e.g. "`@trigger.dev/sdk` now bundles ...") is
// kept.
const depBumpPattern = /^`?(?:@trigger\.dev\/[\w-]+|trigger\.dev)@[\w.+-]+`?$/;
// Group lines into top-level list items. A top-level item starts with a bullet
// at column 0 ("- " / "* "); every indented or blank line below it (sub-bullets,
// fenced code blocks, continuation paragraphs) belongs to that same item.
const items = [];
let current = null;
const flush = () => {
if (!current) return;
while (current.length > 1 && current[current.length - 1].trim() === "") {
current.pop();
}
items.push(current);
current = null;
};
for (const line of body.split("\n")) {
const isTopLevelBullet = /^[-*]\s+/.test(line);
if (isTopLevelBullet) {
flush();
current = [line];
} else if (current) {
if (line.trim() === "" || /^\s/.test(line)) {
current.push(line);
} else {
// A non-indented, non-blank, non-bullet line (heading or prose) ends the item
flush();
}
}
}
flush();
for (const itemLines of items) {
const headLine = itemLines[0].replace(/^[-*]\s+/, "").trim();
if (!headLine) continue;
// Skip dependency-only updates
if (headLine.startsWith("Updated dependencies")) continue;
if (depBumpPattern.test(headLine)) continue;
// Reconstruct the full item: head line + dedented continuation lines, so
// code blocks and sub-bullets survive. Continuation under a "- " item is
// indented 4 spaces; strip up to 4 to bring it back to the base level.
const continuation = itemLines.slice(1).map((l) => l.replace(/^ {1,4}/, ""));
const text = [headLine, ...continuation].join("\n").replace(/\s+$/, "");
// Deduplicate on the full entry text (which embeds the PR link). The same
// changeset echoed across package sections collapses to one, while multiple
// distinct changesets from a single PR are each preserved.
const dedupeKey = text.replace(/\s+/g, " ").trim();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
// Categorize off the head line
const lower = headLine.toLowerCase();
let type = "improvement";
if (lower.startsWith("fix") || lower.includes("bug fix")) {
type = "fix";
} else if (
lower.startsWith("feat") ||
lower.includes("new feature") ||
lower.includes("add support") ||
lower.includes("added support") ||
lower.includes("expose") ||
lower.includes("allow")
) {
type = "feature";
} else if (lower.includes("breaking")) {
type = "breaking";
}
entries.push({ text, type });
}
return entries;
}
// --- Git + GitHub helpers for finding PR numbers ---
const REPO = "triggerdotdev/trigger.dev";
function gitExec(args) {
return new Promise((resolve, reject) => {
execFile("git", args, { cwd: ROOT_DIR, maxBuffer: 1024 * 1024 }, (err, stdout) => {
if (err) reject(err);
else resolve(stdout.trim());
});
});
}
async function getCommitForFile(filePath) {
try {
// Find the commit that added this file
const sha = await gitExec(["log", "--diff-filter=A", "--format=%H", "--", filePath]);
return sha.split("\n")[0] || null;
} catch {
return null;
}
}
async function getPrForCommit(commitSha) {
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
if (!token || !commitSha) return null;
try {
const res = await fetch(`https://api.github.com/repos/${REPO}/commits/${commitSha}/pulls`, {
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github.v3+json",
},
});
if (!res.ok) return null;
const pulls = await res.json();
if (!pulls.length) return null;
// Prefer merged PRs, earliest merge first (same logic as @changesets/get-github-info)
const sorted = pulls.sort((a, b) => {
if (!a.merged_at && !b.merged_at) return 0;
if (!a.merged_at) return 1;
if (!b.merged_at) return -1;
return new Date(a.merged_at) - new Date(b.merged_at);
});
return sorted[0].number;
} catch {
return null;
}
}
// --- Parse .server-changes/ files ---
async function parseServerChanges() {
const entries = [];
const fileData = await getServerChangeFileData();
// Look up commits for all files in parallel
const commits = await Promise.all(fileData.map((f) => getCommitForFile(f.filePath)));
// Look up PRs for all commits in parallel
const prNumbers = await Promise.all(commits.map((sha) => getPrForCommit(sha)));
for (let i = 0; i < fileData.length; i++) {
const { parsed } = fileData[i];
let text = parsed.body.trim();
const pr = prNumbers[i];
// Append PR link if we found one and it's not already in the text
if (pr && !text.includes(`#${pr}`)) {
text += ` ([#${pr}](https://github.com/${REPO}/pull/${pr}))`;
}
entries.push({
text,
type: parsed.frontmatter.type || "improvement",
area: parsed.frontmatter.area || "webapp",
});
}
return entries;
}
async function getServerChangeFileData() {
// The changesets version command deletes .server-changes before this script
// enhances the release PR body. We combine files still live on disk with the
// ones recovered from the release branch diff, deduped by filename, rather
// than picking one source or the other. This is additive so a partial cleanup
// (some files deleted, some still live) can't silently drop entries. Live
// files win on collision since they are the current on-disk truth.
const [live, deleted] = await Promise.all([
getLiveServerChangeFileData(),
getDeletedServerChangeFileDataFromReleaseBranch(),
]);
const byName = new Map();
for (const fileData of deleted) {
byName.set(fileData.filePath.split("/").pop(), fileData);
}
for (const fileData of live) {
byName.set(fileData.filePath.split("/").pop(), fileData);
}
return [...byName.values()].sort((a, b) => a.filePath.localeCompare(b.filePath));
}
async function getLiveServerChangeFileData() {
const dir = join(ROOT_DIR, ".server-changes");
let files;
try {
files = await fs.readdir(dir);
} catch {
return [];
}
const fileData = [];
for (const file of files.sort()) {
if (!file.endsWith(".md") || file === "README.md") continue;
const filePath = join(".server-changes", file);
const content = await fs.readFile(join(dir, file), "utf-8");
const parsed = parseFrontmatter(content);
if (!parsed.body.trim()) continue;
fileData.push({ filePath, parsed });
}
return fileData;
}
async function getDeletedServerChangeFileDataFromReleaseBranch() {
const baseRef = process.env.SERVER_CHANGES_BASE_REF || "origin/main";
const releaseRef = process.env.SERVER_CHANGES_RELEASE_REF || "origin/changeset-release/main";
let mergeBase;
let deletedFiles;
try {
mergeBase = await gitExec(["merge-base", baseRef, releaseRef]);
deletedFiles = await gitExec([
"diff",
"--name-only",
"--diff-filter=D",
`${mergeBase}..${releaseRef}`,
"--",
".server-changes",
]);
} catch (err) {
console.error(
"[enhance-release-pr] failed to recover deleted server-changes from release branch:",
err
);
return [];
}
const fileData = [];
for (const filePath of deletedFiles.split("\n").filter(Boolean).sort()) {
const file = filePath.split("/").pop();
if (!file?.endsWith(".md") || file === "README.md") continue;
try {
const content = await gitExec(["show", `${mergeBase}:${filePath}`]);
const parsed = parseFrontmatter(content);
if (!parsed.body.trim()) continue;
fileData.push({ filePath, parsed });
} catch (err) {
// If an individual file cannot be recovered, skip it rather than hiding
// all other server changes.
console.error(`[enhance-release-pr] failed to read deleted server-change ${filePath}:`, err);
}
}
return fileData;
}
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter = {};
for (const line of match[1].split("\n")) {
const [key, ...rest] = line.split(":");
if (key && rest.length) {
frontmatter[key.trim()] = rest.join(":").trim();
}
}
return { frontmatter, body: match[2] };
}
// --- Format the enhanced PR body ---
// Render an entry as a list item, re-indenting continuation lines (code blocks,
// sub-bullets, paragraphs) by 2 spaces so they stay inside the "- " bullet.
function renderEntry(text) {
return `- ${text.replace(/\n/g, "\n ")}`;
}
function formatPrBody({ version, packageEntries, serverEntries, rawBody }) {
const lines = [];
const features = packageEntries.filter((e) => e.type === "feature");
const fixes = packageEntries.filter((e) => e.type === "fix");
const improvements = packageEntries.filter((e) => e.type === "improvement" || e.type === "other");
const breaking = packageEntries.filter((e) => e.type === "breaking");
const serverFeatures = serverEntries.filter((e) => e.type === "feature");
const serverFixes = serverEntries.filter((e) => e.type === "fix");
const serverImprovements = serverEntries.filter((e) => e.type === "improvement");
const serverBreaking = serverEntries.filter((e) => e.type === "breaking");
const totalFeatures = features.length + serverFeatures.length;
const totalFixes = fixes.length + serverFixes.length;
const totalImprovements = improvements.length + serverImprovements.length;
// Summary line
const parts = [];
if (totalFeatures > 0) parts.push(`${totalFeatures} new feature${totalFeatures > 1 ? "s" : ""}`);
if (totalImprovements > 0)
parts.push(`${totalImprovements} improvement${totalImprovements > 1 ? "s" : ""}`);
if (totalFixes > 0) parts.push(`${totalFixes} bug fix${totalFixes > 1 ? "es" : ""}`);
if (parts.length > 0) {
lines.push(`## Summary`);
lines.push(`${parts.join(", ")}.`);
lines.push("");
}
// Breaking changes
if (breaking.length > 0 || serverBreaking.length > 0) {
lines.push("## Breaking changes");
for (const entry of [...breaking, ...serverBreaking]) lines.push(renderEntry(entry.text));
lines.push("");
}
// Highlights (features)
if (features.length > 0) {
lines.push("## Highlights");
lines.push("");
for (const entry of features) {
lines.push(renderEntry(entry.text));
}
lines.push("");
}
// Improvements
if (improvements.length > 0) {
lines.push("## Improvements");
for (const entry of improvements) lines.push(renderEntry(entry.text));
lines.push("");
}
// Bug fixes
if (fixes.length > 0) {
lines.push("## Bug fixes");
for (const entry of fixes) lines.push(renderEntry(entry.text));
lines.push("");
}
// Server changes
const allServer = [...serverFeatures, ...serverImprovements, ...serverFixes];
if (allServer.length > 0) {
lines.push("## Server changes");
lines.push("");
lines.push("These changes affect the self-hosted Docker image and Trigger.dev Cloud:");
lines.push("");
for (const entry of allServer) {
lines.push(renderEntry(entry.text));
}
lines.push("");
}
// Raw changeset output in collapsed section
if (rawBody) {
// Strip the Changesets action boilerplate from the raw body
const cleanedBody = rawBody
.replace(
/This PR was opened by the \[Changesets release\].*?If you're not ready to do a release yet.*?\n/gs,
""
)
.trim();
if (cleanedBody) {
lines.push("<details>");
lines.push("<summary>Raw changeset output</summary>");
lines.push("");
lines.push(cleanedBody);
lines.push("");
lines.push("</details>");
}
}
return lines.join("\n");
}
// --- Main ---
async function main() {
let rawBody = process.env.CHANGESET_PR_BODY || "";
if (!rawBody && !process.stdin.isTTY) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
rawBody = Buffer.concat(chunks).toString("utf-8");
}
const packageEntries = parsePrBody(rawBody);
const serverEntries = await parseServerChanges();
const body = formatPrBody({
version,
packageEntries,
serverEntries,
rawBody,
});
process.stdout.write(body);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});