453 lines
14 KiB
JavaScript
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);
|
|
});
|