mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
@@ -51,41 +51,37 @@ jobs:
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG}"
|
||||
VERSION="${VERSION#v}" # Remove 'v' prefix
|
||||
|
||||
# Extract changelog section for this version
|
||||
cd packages/coding-agent
|
||||
awk "/^## \[${VERSION}\]/{flag=1; next} /^## \[/{flag=0} flag" CHANGELOG.md > /tmp/release-notes.md
|
||||
|
||||
# If empty, use a default message
|
||||
if [ ! -s /tmp/release-notes.md ]; then
|
||||
echo "Release ${VERSION}" > /tmp/release-notes.md
|
||||
fi
|
||||
node scripts/release-notes.mjs extract --version "${VERSION}" --tag "${RELEASE_TAG}" --out /tmp/release-notes.md
|
||||
|
||||
- name: Create GitHub Release and upload binaries
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd packages/coding-agent/binaries
|
||||
|
||||
# Create release with changelog notes (or update if exists)
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
pi-windows-arm64.zip \
|
||||
2>/dev/null || \
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
pi-windows-arm64.zip \
|
||||
--clobber
|
||||
|
||||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
gh release edit "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md
|
||||
gh release upload "${RELEASE_TAG}" \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
pi-windows-arm64.zip \
|
||||
--clobber
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" \
|
||||
--title "${RELEASE_TAG}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
pi-darwin-arm64.tar.gz \
|
||||
pi-darwin-x64.tar.gz \
|
||||
pi-linux-x64.tar.gz \
|
||||
pi-linux-arm64.tar.gz \
|
||||
pi-windows-x64.zip \
|
||||
pi-windows-arm64.zip
|
||||
fi
|
||||
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"release:patch": "node scripts/release.mjs patch",
|
||||
"release:minor": "node scripts/release.mjs minor",
|
||||
"release:major": "node scripts/release.mjs major",
|
||||
"release:fix-links": "node scripts/release-notes.mjs fix-github-releases",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed GitHub release notes and interactive changelog links to resolve package-relative documentation URLs correctly ([#5516](https://github.com/earendil-works/pi/issues/5516)).
|
||||
|
||||
## [0.79.0] - 2026-06-08
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -87,7 +87,7 @@ import type { SourceInfo } from "../../core/source-info.ts";
|
||||
import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||
import { hasProjectConfigDir, hasProjectTrustInputs, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { copyToClipboard } from "../../utils/clipboard.ts";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
import { parseGitUrl } from "../../utils/git.ts";
|
||||
@@ -909,7 +909,7 @@ export class InteractiveMode {
|
||||
if (newEntries.length > 0) {
|
||||
this.settingsManager.setLastChangelogVersion(VERSION);
|
||||
this.reportInstallTelemetry(VERSION);
|
||||
return newEntries.map((e) => e.content).join("\n\n");
|
||||
return newEntries.map((e) => normalizeChangelogLinks(e.content, e)).join("\n\n");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -5380,7 +5380,7 @@ export class InteractiveMode {
|
||||
allEntries.length > 0
|
||||
? allEntries
|
||||
.reverse()
|
||||
.map((e) => e.content)
|
||||
.map((e) => normalizeChangelogLinks(e.content, e))
|
||||
.join("\n\n")
|
||||
: "No changelog entries found.";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
@@ -7,6 +8,102 @@ export interface ChangelogEntry {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const GITHUB_REPO = "earendil-works/pi";
|
||||
const CHANGELOG_LINK_BASE_PATH = "packages/coding-agent";
|
||||
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 entryVersion(entry: ChangelogEntry): string {
|
||||
return `${entry.major}.${entry.minor}.${entry.patch}`;
|
||||
}
|
||||
|
||||
function normalizeTag(version: string | ChangelogEntry): string {
|
||||
const versionString = typeof version === "string" ? version : entryVersion(version);
|
||||
return versionString.startsWith("v") ? versionString : `v${versionString}`;
|
||||
}
|
||||
|
||||
function splitLocalTarget(target: string): { fragment: string; pathPart: string; query: string } {
|
||||
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: string): string {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function resolveRepositoryPath(targetPath: string): string | undefined {
|
||||
const normalizedTarget = normalizePathPart(targetPath);
|
||||
const joined = normalizedTarget.startsWith("/")
|
||||
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
|
||||
: path.posix.normalize(path.posix.join(CHANGELOG_LINK_BASE_PATH, normalizedTarget));
|
||||
|
||||
if (joined === "." || joined.startsWith("../") || joined === "..") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
function isDirectoryTarget(originalPath: string, repositoryPath: string): boolean {
|
||||
if (originalPath.endsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const basename = path.posix.basename(repositoryPath);
|
||||
return !basename.includes(".");
|
||||
}
|
||||
|
||||
function normalizeChangelogLinkTarget(target: string, tag: string): string {
|
||||
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${GITHUB_REPO}`);
|
||||
const repoUrl = `https://github.com/${GITHUB_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}/${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);
|
||||
if (!repositoryPath) {
|
||||
return canonicalTarget;
|
||||
}
|
||||
|
||||
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
|
||||
return `https://github.com/${GITHUB_REPO}/${route}/${tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
|
||||
}
|
||||
|
||||
export function normalizeChangelogLinks(markdown: string, version: string | ChangelogEntry): string {
|
||||
const tag = normalizeTag(version);
|
||||
return markdown.replace(INLINE_MARKDOWN_LINK_RE, (_match, prefix, target, suffix) => {
|
||||
return `${prefix}${normalizeChangelogLinkTarget(target, tag)}${suffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse changelog entries from CHANGELOG.md
|
||||
* Scans for ## lines and collects content until next ## or EOF
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type ChangelogEntry, normalizeChangelogLinks } from "../src/utils/changelog.ts";
|
||||
|
||||
const entry: ChangelogEntry = {
|
||||
major: 0,
|
||||
minor: 79,
|
||||
patch: 0,
|
||||
content: "",
|
||||
};
|
||||
|
||||
describe("normalizeChangelogLinks", () => {
|
||||
test("rewrites package-relative changelog links to tag-pinned GitHub source links", () => {
|
||||
const markdown = [
|
||||
"[Project Trust](README.md#project-trust)",
|
||||
"[Extensions](docs/extensions.md#project_trust)",
|
||||
"[Examples](examples/extensions/)",
|
||||
"[Root README](../../README.md#supply-chain-hardening)",
|
||||
].join("\n");
|
||||
|
||||
expect(normalizeChangelogLinks(markdown, entry)).toBe(
|
||||
[
|
||||
"[Project Trust](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/README.md#project-trust)",
|
||||
"[Extensions](https://github.com/earendil-works/pi/blob/v0.79.0/packages/coding-agent/docs/extensions.md#project_trust)",
|
||||
"[Examples](https://github.com/earendil-works/pi/tree/v0.79.0/packages/coding-agent/examples/extensions/)",
|
||||
"[Root README](https://github.com/earendil-works/pi/blob/v0.79.0/README.md#supply-chain-hardening)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
test("canonicalizes old repository URLs without changing external links", () => {
|
||||
const markdown = [
|
||||
"[#5167](https://github.com/earendil-works/pi-mono/pull/5167)",
|
||||
"[#4163](https://github.com/badlogic/pi-mono/issues/4163)",
|
||||
"[Agent README](https://github.com/badlogic/pi-mono/blob/main/packages/agent/README.md)",
|
||||
"[External](https://example.com/docs)",
|
||||
"[Local anchor](#settings)",
|
||||
].join("\n");
|
||||
|
||||
expect(normalizeChangelogLinks(markdown, "0.79.0")).toBe(
|
||||
[
|
||||
"[#5167](https://github.com/earendil-works/pi/pull/5167)",
|
||||
"[#4163](https://github.com/earendil-works/pi/issues/4163)",
|
||||
"[Agent README](https://github.com/earendil-works/pi/blob/v0.79.0/packages/agent/README.md)",
|
||||
"[External](https://example.com/docs)",
|
||||
"[Local anchor](#settings)",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user