mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
20b78eafb4
Fixes #5516
365 lines
11 KiB
JavaScript
365 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
const DEFAULT_REPO = "earendil-works/pi";
|
|
const DEFAULT_BASE_PATH = "packages/coding-agent";
|
|
const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md";
|
|
const DEFAULT_FIX_SINCE_TAG = "v0.74.0";
|
|
const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/;
|
|
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
|
const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g;
|
|
|
|
function printUsage() {
|
|
console.log(`Usage: node scripts/release-notes.mjs <command> [options]
|
|
|
|
Commands:
|
|
extract Extract release notes from the coding-agent changelog
|
|
fix-github-releases Rewrite existing GitHub release note links in place
|
|
|
|
extract options:
|
|
--version <x.y.z> Version to extract
|
|
--tag <vX.Y.Z> Release tag used for repository links (defaults to v<version>)
|
|
--changelog <path> Changelog path (default: ${DEFAULT_CHANGELOG})
|
|
--out <path> Output file (default: stdout)
|
|
--repo <owner/repo> GitHub repository for generated links (default: ${DEFAULT_REPO})
|
|
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
|
|
|
|
fix-github-releases options:
|
|
--repo <owner/repo> GitHub repository to patch (default: ${DEFAULT_REPO})
|
|
--tag <vX.Y.Z> Patch only one release tag
|
|
--since-tag <vX.Y.Z> Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG})
|
|
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
|
|
--dry-run Print releases that would change without updating GitHub
|
|
`);
|
|
}
|
|
|
|
function commandForPlatform(command) {
|
|
return process.platform === "win32" ? `${command}.cmd` : command;
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = spawnSync(commandForPlatform(command), args, {
|
|
cwd: options.cwd,
|
|
encoding: "utf8",
|
|
maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024,
|
|
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
|
|
});
|
|
|
|
if (result.status !== 0) {
|
|
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`);
|
|
}
|
|
|
|
return result.stdout ?? "";
|
|
}
|
|
|
|
function parseOptions(args) {
|
|
const options = {
|
|
basePath: DEFAULT_BASE_PATH,
|
|
changelog: DEFAULT_CHANGELOG,
|
|
dryRun: false,
|
|
out: undefined,
|
|
repo: DEFAULT_REPO,
|
|
sinceTag: DEFAULT_FIX_SINCE_TAG,
|
|
tag: undefined,
|
|
version: undefined,
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === "--help") {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
if (arg === "--dry-run") {
|
|
options.dryRun = true;
|
|
continue;
|
|
}
|
|
|
|
const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]);
|
|
if (!optionNames.has(arg)) {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
|
|
const value = args[++i];
|
|
if (!value) {
|
|
throw new Error(`${arg} requires a value`);
|
|
}
|
|
|
|
if (arg === "--base-path") options.basePath = value;
|
|
if (arg === "--changelog") options.changelog = value;
|
|
if (arg === "--out") options.out = value;
|
|
if (arg === "--repo") options.repo = value;
|
|
if (arg === "--since-tag") options.sinceTag = value;
|
|
if (arg === "--tag") options.tag = value;
|
|
if (arg === "--version") options.version = value;
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function normalizeTag(tagOrVersion) {
|
|
if (!tagOrVersion) {
|
|
return undefined;
|
|
}
|
|
return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`;
|
|
}
|
|
|
|
function versionFromTag(tag) {
|
|
return tag.startsWith("v") ? tag.slice(1) : tag;
|
|
}
|
|
|
|
function compareVersions(a, b) {
|
|
const aParts = versionFromTag(a).split(".").map(Number);
|
|
const bParts = versionFromTag(b).split(".").map(Number);
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const diff = (aParts[i] || 0) - (bParts[i] || 0);
|
|
if (diff !== 0) {
|
|
return diff;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function extractChangelogSection(changelog, version) {
|
|
const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m");
|
|
const heading = headingRe.exec(changelog);
|
|
|
|
if (!heading) {
|
|
return "";
|
|
}
|
|
|
|
const sectionStart = heading.index + heading[0].length;
|
|
const rest = changelog.slice(sectionStart);
|
|
const nextHeading = rest.search(/^## \[/m);
|
|
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
|
|
return section.trim();
|
|
}
|
|
|
|
function splitLocalTarget(target) {
|
|
const hashIndex = target.indexOf("#");
|
|
const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex);
|
|
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex);
|
|
const queryIndex = beforeHash.indexOf("?");
|
|
|
|
if (queryIndex === -1) {
|
|
return { fragment, pathPart: beforeHash, query: "" };
|
|
}
|
|
|
|
return {
|
|
fragment,
|
|
pathPart: beforeHash.slice(0, queryIndex),
|
|
query: beforeHash.slice(queryIndex),
|
|
};
|
|
}
|
|
|
|
function normalizePathPart(value) {
|
|
return value.replaceAll("\\", "/");
|
|
}
|
|
|
|
function normalizeBasePath(basePath) {
|
|
const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, "");
|
|
return normalized === "." ? "" : normalized;
|
|
}
|
|
|
|
function resolveRepositoryPath(targetPath, basePath) {
|
|
const normalizedTarget = normalizePathPart(targetPath);
|
|
const joined = normalizedTarget.startsWith("/")
|
|
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
|
|
: path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget));
|
|
|
|
if (joined === "." || joined.startsWith("../") || joined === "..") {
|
|
return undefined;
|
|
}
|
|
|
|
return joined;
|
|
}
|
|
|
|
function isDirectoryTarget(originalPath, repositoryPath) {
|
|
if (originalPath.endsWith("/")) {
|
|
return true;
|
|
}
|
|
|
|
const basename = path.posix.basename(repositoryPath);
|
|
return !basename.includes(".");
|
|
}
|
|
|
|
function normalizeLinkTarget(target, options) {
|
|
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`);
|
|
const repoUrl = `https://github.com/${options.repo}`;
|
|
|
|
for (const route of ["blob", "tree"]) {
|
|
for (const branch of ["main", "master"]) {
|
|
const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`;
|
|
if (canonicalTarget.startsWith(floatingRefPrefix)) {
|
|
canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) {
|
|
return canonicalTarget;
|
|
}
|
|
|
|
const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget);
|
|
if (!pathPart) {
|
|
return canonicalTarget;
|
|
}
|
|
|
|
const repositoryPath = resolveRepositoryPath(pathPart, options.basePath);
|
|
if (!repositoryPath) {
|
|
return canonicalTarget;
|
|
}
|
|
|
|
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
|
|
return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
|
|
}
|
|
|
|
function normalizeReleaseNoteLinks(markdown, options) {
|
|
const changes = [];
|
|
const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => {
|
|
const normalizedTarget = normalizeLinkTarget(target, options);
|
|
if (normalizedTarget !== target) {
|
|
changes.push({ from: target, to: normalizedTarget });
|
|
}
|
|
return `${prefix}${normalizedTarget}${suffix}`;
|
|
});
|
|
|
|
return { changes, markdown: normalized };
|
|
}
|
|
|
|
function writeOutput(content, outPath) {
|
|
if (outPath) {
|
|
writeFileSync(outPath, content);
|
|
return;
|
|
}
|
|
|
|
process.stdout.write(content);
|
|
}
|
|
|
|
function extractReleaseNotes(options) {
|
|
const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined);
|
|
if (!version) {
|
|
throw new Error("extract requires --version or --tag");
|
|
}
|
|
|
|
if (!existsSync(options.changelog)) {
|
|
throw new Error(`Changelog does not exist: ${options.changelog}`);
|
|
}
|
|
|
|
const tag = normalizeTag(options.tag ?? version);
|
|
const changelog = readFileSync(options.changelog, "utf8");
|
|
const section = extractChangelogSection(changelog, version);
|
|
const rawNotes = section ? `${section}\n` : `Release ${version}\n`;
|
|
const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag });
|
|
writeOutput(markdown, options.out);
|
|
}
|
|
|
|
function listGithubReleases(repo) {
|
|
const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], {
|
|
capture: true,
|
|
});
|
|
return output
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line));
|
|
}
|
|
|
|
function uniqueChanges(changes) {
|
|
const seen = new Set();
|
|
const unique = [];
|
|
for (const change of changes) {
|
|
const key = `${change.from}\n${change.to}`;
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
unique.push(change);
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
function updateGithubRelease(repo, tag, body) {
|
|
const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-"));
|
|
try {
|
|
const notesPath = path.join(tempDir, "notes.md");
|
|
writeFileSync(notesPath, body);
|
|
run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true });
|
|
} finally {
|
|
rmSync(tempDir, { force: true, recursive: true });
|
|
}
|
|
}
|
|
|
|
function fixGithubReleases(options) {
|
|
const tagFilter = normalizeTag(options.tag);
|
|
const sinceTag = normalizeTag(options.sinceTag);
|
|
const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter);
|
|
|
|
if (tagFilter && matchingReleases.length === 0) {
|
|
throw new Error(`Release not found: ${tagFilter}`);
|
|
}
|
|
|
|
const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0);
|
|
if (tagFilter && releases.length === 0) {
|
|
console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`);
|
|
console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`);
|
|
return;
|
|
}
|
|
|
|
let changedCount = 0;
|
|
for (const release of releases) {
|
|
const tag = release.tag_name;
|
|
const body = release.body ?? "";
|
|
const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag });
|
|
if (result.markdown === body) {
|
|
continue;
|
|
}
|
|
|
|
changedCount++;
|
|
const unique = uniqueChanges(result.changes);
|
|
console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`);
|
|
for (const change of unique) {
|
|
console.log(` ${change.from}`);
|
|
console.log(` -> ${change.to}`);
|
|
}
|
|
|
|
if (!options.dryRun) {
|
|
updateGithubRelease(options.repo, tag, result.markdown);
|
|
}
|
|
}
|
|
|
|
const prefix = options.dryRun ? "Would update" : "Updated";
|
|
console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`);
|
|
}
|
|
|
|
try {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
if (!command || command === "--help") {
|
|
printUsage();
|
|
process.exit(command ? 0 : 1);
|
|
}
|
|
|
|
const options = parseOptions(args);
|
|
if (command === "extract") {
|
|
extractReleaseNotes(options);
|
|
} else if (command === "fix-github-releases") {
|
|
fixGithubReleases(options);
|
|
} else {
|
|
throw new Error(`Unknown command: ${command}`);
|
|
}
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
}
|