feat: consolidated and automate changelog management
- Added `rewrite-changelog.ts` and `fix-changelogs.ts` utilities to automate the consolidation of release notes using LLM-assisted processing. - Updated multiple internal changelog files by consolidating redundant entries and improving phrasing for readability. - Implemented `previewLine` utility in `coding-agent` to prevent visual spillover in status rows by managing text truncation and whitespace. - Updated `package.json` with new workflow scripts for managing package-level change histories and documentation indexes.
This commit is contained in:
@@ -96,11 +96,11 @@ async function runCommand(command: string[], cwd: string, env: NodeJS.ProcessEnv
|
||||
|
||||
async function embedNative(target: BinaryTarget): Promise<void> {
|
||||
if (isDryRun) {
|
||||
console.log(`DRY RUN bun --cwd=packages/natives run embed:native [${target.platform}/${target.arch}]`);
|
||||
console.log(`DRY RUN bun run gen:native [${target.platform}/${target.arch}]`);
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand(["bun", "--cwd=packages/natives", "run", "embed:native"], repoRoot, {
|
||||
await runCommand(["bun", "run", "gen:native"], repoRoot, {
|
||||
...Bun.env,
|
||||
TARGET_PLATFORM: target.platform,
|
||||
TARGET_ARCH: target.arch,
|
||||
@@ -151,28 +151,28 @@ async function buildBinary(target: BinaryTarget): Promise<void> {
|
||||
|
||||
async function generateBundle(): Promise<void> {
|
||||
if (isDryRun) {
|
||||
console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --generate");
|
||||
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --generate");
|
||||
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/embed-mupdf-wasm.ts --generate");
|
||||
console.log("DRY RUN bun run gen:stats");
|
||||
console.log("DRY RUN bun run gen:docs");
|
||||
console.log("DRY RUN bun run gen:mupdf");
|
||||
return;
|
||||
}
|
||||
await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--generate"], repoRoot);
|
||||
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--generate"], repoRoot);
|
||||
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/embed-mupdf-wasm.ts", "--generate"], repoRoot);
|
||||
await runCommand(["bun", "run", "gen:stats"], repoRoot);
|
||||
await runCommand(["bun", "run", "gen:docs"], repoRoot);
|
||||
await runCommand(["bun", "run", "gen:mupdf"], repoRoot);
|
||||
}
|
||||
|
||||
async function resetArtifacts(): Promise<void> {
|
||||
if (isDryRun) {
|
||||
console.log("DRY RUN bun --cwd=packages/natives run embed:native --reset");
|
||||
console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset");
|
||||
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --reset");
|
||||
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/embed-mupdf-wasm.ts --reset");
|
||||
console.log("DRY RUN bun run gen:native:reset");
|
||||
console.log("DRY RUN bun run gen:stats:reset");
|
||||
console.log("DRY RUN bun run gen:docs:reset");
|
||||
console.log("DRY RUN bun run gen:mupdf:reset");
|
||||
return;
|
||||
}
|
||||
await runCommand(["bun", "--cwd=packages/natives", "run", "embed:native", "--reset"], repoRoot);
|
||||
await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--reset"], repoRoot);
|
||||
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--reset"], repoRoot);
|
||||
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/embed-mupdf-wasm.ts", "--reset"], repoRoot);
|
||||
await runCommand(["bun", "run", "gen:native:reset"], repoRoot);
|
||||
await runCommand(["bun", "run", "gen:stats:reset"], repoRoot);
|
||||
await runCommand(["bun", "run", "gen:docs:reset"], repoRoot);
|
||||
await runCommand(["bun", "run", "gen:mupdf:reset"], repoRoot);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
+11
-11
@@ -8,29 +8,29 @@ const ORDERED_SECTION_TITLES = ["Breaking Changes", "Added", "Changed", "Fixed",
|
||||
const CHANGELOG_BASELINE_REF = "refs/clog";
|
||||
const CHANGELOG_BASELINE_NAME = "clog";
|
||||
|
||||
interface NumberedLine {
|
||||
export interface NumberedLine {
|
||||
text: string;
|
||||
lineNumber: number;
|
||||
}
|
||||
|
||||
interface Subsection {
|
||||
export interface Subsection {
|
||||
title: string;
|
||||
lines: NumberedLine[];
|
||||
}
|
||||
|
||||
interface ReleaseSection {
|
||||
export interface ReleaseSection {
|
||||
heading: string;
|
||||
title: string;
|
||||
leadingLines: NumberedLine[];
|
||||
subsections: Subsection[];
|
||||
}
|
||||
|
||||
interface ChangelogDocument {
|
||||
export interface ChangelogDocument {
|
||||
prefixLines: NumberedLine[];
|
||||
sections: ReleaseSection[];
|
||||
}
|
||||
|
||||
interface ParsedItem {
|
||||
export interface ParsedItem {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
lines: string[];
|
||||
@@ -135,7 +135,7 @@ function createNumberedLine(text: string, lineNumber: number): NumberedLine {
|
||||
return { text, lineNumber };
|
||||
}
|
||||
|
||||
function parseChangelog(content: string): ChangelogDocument {
|
||||
export function parseChangelog(content: string): ChangelogDocument {
|
||||
const lines = splitContentLines(content);
|
||||
const numberedLines = lines.map((text, index) => createNumberedLine(text, index + 1));
|
||||
const prefixLines: NumberedLine[] = [];
|
||||
@@ -232,7 +232,7 @@ function appendSubsectionLines(target: Subsection, sourceLines: readonly string[
|
||||
target.lines = syntheticLines([...existing, ...separator, ...trimmedSource]);
|
||||
}
|
||||
|
||||
function parseItems(lines: readonly NumberedLine[]): ParsedItem[] {
|
||||
export function parseItems(lines: readonly NumberedLine[]): ParsedItem[] {
|
||||
const items: ParsedItem[] = [];
|
||||
let index = 0;
|
||||
|
||||
@@ -264,7 +264,7 @@ function parseItems(lines: readonly NumberedLine[]): ParsedItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
function lineRangeSet(items: readonly ParsedItem[]): Set<number> {
|
||||
export function lineRangeSet(items: readonly ParsedItem[]): Set<number> {
|
||||
const lines = new Set<number>();
|
||||
for (const item of items) {
|
||||
for (let line = item.startLine; line <= item.endLine; line++) {
|
||||
@@ -503,7 +503,7 @@ function rebuildReleasedSectionsFromHistory(
|
||||
}
|
||||
|
||||
|
||||
function renderChangelog(document: ChangelogDocument): string {
|
||||
export function renderChangelog(document: ChangelogDocument): string {
|
||||
const output: string[] = [];
|
||||
const prefix = trimBlankLines(numberedText(document.prefixLines));
|
||||
if (prefix.length > 0) {
|
||||
@@ -720,7 +720,7 @@ async function git(args: readonly string[], cwd: string): Promise<string> {
|
||||
return result.text();
|
||||
}
|
||||
|
||||
async function resolveRepoRoot(repoRoot: string | undefined): Promise<string> {
|
||||
export async function resolveRepoRoot(repoRoot: string | undefined): Promise<string> {
|
||||
if (repoRoot) return path.resolve(repoRoot);
|
||||
return (await git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
|
||||
}
|
||||
@@ -827,7 +827,7 @@ async function collectHistoricalReleaseRecovery(
|
||||
}
|
||||
|
||||
|
||||
async function changelogPaths(repoRoot: string): Promise<string[]> {
|
||||
export async function changelogPaths(repoRoot: string): Promise<string[]> {
|
||||
const glob = new Glob(CHANGELOG_GLOB);
|
||||
const paths: string[] = [];
|
||||
for await (const changelogPath of glob.scan(repoRoot)) {
|
||||
|
||||
Executable
+450
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Rewrite each package's `[Unreleased]` changelog section for release notes.
|
||||
*
|
||||
* A release cycle accumulates noisy implementation notes: a feature is added,
|
||||
* then internal bugs in that same not-yet-released feature are fixed, transport
|
||||
* plumbing is refactored, and behavior is renamed before anyone uses it. Only
|
||||
* the final shipped behavior belongs in release notes.
|
||||
*
|
||||
* For every non-empty `[Unreleased]` section this script hands the whole section
|
||||
* to a small model (default `google-vertex/gemini-3.5-flash` via `@oh-my-pi/pi-ai`)
|
||||
* and asks for a complete replacement grouped by changelog category. The model
|
||||
* returns structured sections/items; markdown is rendered locally so only the
|
||||
* Unreleased section changes and formatting stays deterministic.
|
||||
*
|
||||
* The prompt defines "user-visible" for package consumers broadly: public
|
||||
* exports/API, provider behavior, auth/errors, config, performance, and
|
||||
* breaking changes are visible; pure implementation/test/refactor/internal
|
||||
* protocol churn is not.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/rewrite-changelog.ts # rewrite + write
|
||||
* bun scripts/rewrite-changelog.ts --dry-run # report only
|
||||
* bun scripts/rewrite-changelog.ts --check # exit 1 if any would change
|
||||
* bun scripts/rewrite-changelog.ts --package coding-agent
|
||||
* bun scripts/rewrite-changelog.ts --model google/gemini-3.5-flash
|
||||
*
|
||||
* Auth: resolves the provider API key through omp's auth storage
|
||||
* (~/.omp/agent/agent.db: stored key, OAuth, or env var fallback).
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
import { type Api, AuthStorage, completeSimple, Effort, type Model, SqliteAuthCredentialStore, type Tool, type ToolCall } from "@oh-my-pi/pi-ai";
|
||||
import { type GeneratedProvider, getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
||||
import { getAgentDbPath } from "@oh-my-pi/pi-utils";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
changelogPaths,
|
||||
type ChangelogDocument,
|
||||
type NumberedLine,
|
||||
parseChangelog,
|
||||
parseItems,
|
||||
type ReleaseSection,
|
||||
renderChangelog,
|
||||
resolveRepoRoot,
|
||||
} from "./fix-changelogs";
|
||||
|
||||
const DEFAULT_MODEL = "google-vertex/gemini-3.5-flash";
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Prompts
|
||||
|
||||
const SYSTEM_PROMPT = `You audit and consolidate the \`[Unreleased]\` section of a package's changelog, rewriting it into high-quality, user-facing release notes before a new release.
|
||||
|
||||
Your goal is to transform technical developer bullets into concise, user-facing release notes by:
|
||||
1. Dropping non-user-visible internal implementation/test/refactor/infrastructure details.
|
||||
2. Eliminating intermediate developer churn (e.g. fixes or changes made to features or systems that were *newly introduced in this same batch*).
|
||||
3. Merging or consolidating multiple related internal milestones into single, unified, high-quality feature bullets.
|
||||
4. Rewriting technical jargon to be clear, professional, and useful for the package's consumers (audience).
|
||||
|
||||
Call the \`rewrite\` tool with the rewritten release note sections.
|
||||
|
||||
---
|
||||
|
||||
## 1. What to Drop (Do Not Include)
|
||||
|
||||
- **Intermediate Churn/Fixes**: A bug fix or additional adjustment made to a feature, provider, command, or API that was itself added or introduced *in this same unreleased batch*. Users only ever see the final shipped state, so a line like "Fixed crash in new feature X" is redundant because the feature X they get will already be stable.
|
||||
- **Pure Implementation Details**: Internal protocol messages/helpers, private exports, local WebSocket routing, transport-metadata handling, logging/tracing modifications, intermediate retry/recovery strategies, cache/session storage internal mechanics, serialization/parsing adjustments, or renamed internal variables.
|
||||
- ** Bring-Up Spam**: If this batch introduces a completely new provider or major subsystem, do not list 20 separate lines detailing how different parts of that subsystem were wired up. Consolidate them into a single clean summary of the new system's capabilities.
|
||||
- **Obsolete/Canceled Changes**: If a feature was added and then removed in this same batch, omit both.
|
||||
|
||||
## 2. What to Keep and Consolidate
|
||||
|
||||
- **User-Visible Capabilities**: New features, updated provider capabilities, authentication flow changes, config settings, and CLI commands.
|
||||
- **Genuine Bug Fixes**: Fixes to issues that existed in a *previously released* version (this is vital, user-facing value!).
|
||||
- **Breaking Changes**: Any actual backward-incompatible modifications to public exports, configuration, behavior, or API contracts.
|
||||
- **External Behavior Parity**: Important performance enhancements, support for new model providers/features, and resilience/error handling improvements that developers calling the SDK/CLI will experience.
|
||||
|
||||
## 3. How to Rewrite and Consolidate
|
||||
|
||||
- **Merge Related Bullets**: Instead of listing five technical bullets for different GitLab Duo Workflow features (OAuth callbacks, workspace project auto-discovery, namespace enablement), merge them into one:
|
||||
> Added GitLab Duo Workflow provider support including official OAuth callback verification, workspace project auto-discovery, and automatic login-time namespace Duo enablement.
|
||||
- **Be Concise and User-Facing**: Turn developer jargon (e.g., "replayed thinking blocks without context-management.keep") into description of the actual benefit (e.g., "Fixed preserving multi-turn thinking/reasoning context for Anthropic-compatible models").
|
||||
- **Remove Leading Symbols**: Write the item as a clean text string without prepending "- " or "* ". The harness will handle bullet formatting locally.`;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Model + auth
|
||||
|
||||
interface RewriteModel {
|
||||
model: Model<Api>;
|
||||
apiKey: string;
|
||||
spec: string;
|
||||
}
|
||||
|
||||
async function openModel(modelSpec: string): Promise<RewriteModel> {
|
||||
const slash = modelSpec.indexOf("/");
|
||||
if (slash <= 0) throw new Error(`--model must be <provider>/<model-id>, got "${modelSpec}"`);
|
||||
const provider = modelSpec.slice(0, slash);
|
||||
const modelId = modelSpec.slice(slash + 1);
|
||||
const model = getBundledModel(provider as GeneratedProvider, modelId);
|
||||
if (!model) throw new Error(`unknown model "${modelSpec}" (not in bundled catalog)`);
|
||||
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
const storage = new AuthStorage(store);
|
||||
await storage.reload();
|
||||
const apiKey = await storage.getApiKey(provider);
|
||||
if (!apiKey) {
|
||||
throw new Error(`no credentials for provider "${provider}" (run \`omp login\` or set the provider env var)`);
|
||||
}
|
||||
return { model, apiKey, spec: modelSpec };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Unreleased entries
|
||||
|
||||
interface UnreleasedEntry {
|
||||
index: number;
|
||||
category: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function unreleasedSection(document: ChangelogDocument): ReleaseSection | undefined {
|
||||
return document.sections.find(section => section.title === "Unreleased");
|
||||
}
|
||||
|
||||
function collectEntries(section: ReleaseSection): UnreleasedEntry[] {
|
||||
const entries: UnreleasedEntry[] = [];
|
||||
let index = 1;
|
||||
for (const subsection of section.subsections) {
|
||||
for (const item of parseItems(subsection.lines)) {
|
||||
entries.push({ index: index++, category: subsection.title, text: item.lines.join("\n") });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// LLM call
|
||||
|
||||
interface RewrittenSection {
|
||||
category: string;
|
||||
items: string[];
|
||||
}
|
||||
|
||||
const REWRITE_RESPONSE = z.object({
|
||||
sections: z.array(
|
||||
z.object({
|
||||
category: z.enum(["Breaking Changes", "Added", "Changed", "Fixed", "Removed"]),
|
||||
items: z.array(z.string()),
|
||||
})
|
||||
).default([]),
|
||||
});
|
||||
|
||||
const REWRITE_PARAMETERS = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
sections: {
|
||||
type: "array",
|
||||
description: "Rewritten release note sections grouped by changelog category.",
|
||||
items: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
category: {
|
||||
type: "string",
|
||||
enum: ["Breaking Changes", "Added", "Changed", "Fixed", "Removed"],
|
||||
},
|
||||
items: {
|
||||
type: "array",
|
||||
description: "Consolidated, user-facing release note items for this category.",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["category", "items"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["sections"],
|
||||
} as unknown as Tool["parameters"];
|
||||
|
||||
const REWRITE_TOOL: Tool = {
|
||||
name: "rewrite",
|
||||
description: "Return the rewritten, consolidated release sections.",
|
||||
parameters: REWRITE_PARAMETERS,
|
||||
strict: false,
|
||||
};
|
||||
|
||||
function validateRewrite(args: Record<string, unknown>): RewrittenSection[] {
|
||||
const parsed = REWRITE_RESPONSE.safeParse(args);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`invalid tool arguments: ${parsed.error.issues.map(issue => issue.message).join("; ")}`);
|
||||
}
|
||||
return parsed.data.sections
|
||||
.map(sec => ({
|
||||
category: sec.category,
|
||||
items: sec.items.map(item => item.trim()).filter(Boolean),
|
||||
}))
|
||||
.filter(sec => sec.items.length > 0);
|
||||
}
|
||||
|
||||
function normalizeRewriteItem(text: string): string[] {
|
||||
const lines = text.trim().split("\n").map(l => l.trimEnd());
|
||||
if (lines.length === 0) return [];
|
||||
const first = lines[0] ?? "";
|
||||
const content = first.startsWith("- ") ? first.slice(2) : first.startsWith("* ") ? first.slice(2) : first;
|
||||
const out = [`- ${content}`];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
out.push(line.startsWith(" ") ? line : ` ${line}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function requestRewrite(model: RewriteModel, packageName: string, unreleasedBody: string): Promise<RewrittenSection[]> {
|
||||
const userText = `Package: \`${packageName}\`
|
||||
|
||||
Original \`[Unreleased]\` section body:
|
||||
\`\`\`markdown
|
||||
${unreleasedBody}
|
||||
\`\`\`
|
||||
|
||||
Consolidate and rewrite this content into user-visible release notes. Keep all public API/config/auth/billing behavior, but drop intermediate churn and implementation-only details. Return the structured sections using the \`rewrite\` tool.`;
|
||||
let lastError = "";
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const response = await completeSimple(
|
||||
model.model,
|
||||
{
|
||||
systemPrompt: [SYSTEM_PROMPT],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }],
|
||||
tools: [REWRITE_TOOL],
|
||||
},
|
||||
{ apiKey: model.apiKey, toolChoice: { type: "tool", name: "rewrite" }, reasoning: Effort.Low, temperature: 0 },
|
||||
);
|
||||
|
||||
if (response.stopReason === "error" || response.stopReason === "aborted") {
|
||||
lastError = response.errorMessage ?? response.stopReason;
|
||||
await Bun.sleep(1500 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
|
||||
const call = response.content.find((content): content is ToolCall => content.type === "toolCall" && content.name === "rewrite");
|
||||
if (!call) {
|
||||
lastError = "model returned no structured tool call";
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return validateRewrite(call.arguments);
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`rewrite call failed for ${packageName}: ${lastError}`);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Run
|
||||
|
||||
interface RewrittenFile {
|
||||
path: string;
|
||||
originalCount: number;
|
||||
rewrittenCount: number;
|
||||
sections: RewrittenSection[];
|
||||
}
|
||||
|
||||
interface RunOptions {
|
||||
repoRoot?: string;
|
||||
model: string;
|
||||
write: boolean;
|
||||
packageFilter?: string;
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
model: string;
|
||||
changed: RewrittenFile[];
|
||||
}
|
||||
|
||||
function applyRewrite(section: ReleaseSection, sections: RewrittenSection[]): void {
|
||||
section.subsections = sections.map(sec => {
|
||||
const rawLines = sec.items.flatMap(normalizeRewriteItem);
|
||||
const lines: NumberedLine[] = rawLines.map(text => ({ text, lineNumber: 0 }));
|
||||
return { title: sec.category, lines };
|
||||
}).filter(sub => sub.lines.length > 0);
|
||||
}
|
||||
|
||||
async function run(options: RunOptions): Promise<RunResult> {
|
||||
const repoRoot = await resolveRepoRoot(options.repoRoot);
|
||||
const paths = (await changelogPaths(repoRoot)).filter(
|
||||
changelogPath => !options.packageFilter || changelogPath.includes(options.packageFilter),
|
||||
);
|
||||
const model = await openModel(options.model);
|
||||
const concurrency = options.concurrency ?? 4;
|
||||
const results: Array<RewrittenFile | undefined> = new Array(paths.length);
|
||||
|
||||
let pathIndex = 0;
|
||||
async function worker() {
|
||||
while (pathIndex < paths.length) {
|
||||
const i = pathIndex++;
|
||||
const changelogPath = paths[i];
|
||||
if (!changelogPath) continue;
|
||||
|
||||
try {
|
||||
const absolutePath = path.join(repoRoot, changelogPath);
|
||||
const content = await Bun.file(absolutePath).text();
|
||||
const document = parseChangelog(content);
|
||||
const section = unreleasedSection(document);
|
||||
if (!section) continue;
|
||||
|
||||
const originalCount = section.subsections.reduce((sum, sub) => sum + parseItems(sub.lines).length, 0);
|
||||
if (originalCount === 0) continue;
|
||||
|
||||
const unreleasedBody = renderChangelog({ prefixLines: [], sections: [section] })
|
||||
.replace(/^## \[Unreleased\]\n?/, "")
|
||||
.trim();
|
||||
|
||||
const rewritten = await requestRewrite(model, changelogPath, unreleasedBody);
|
||||
applyRewrite(section, rewritten);
|
||||
const next = renderChangelog(document);
|
||||
if (next === content) continue;
|
||||
|
||||
const rewrittenCount = rewritten.reduce((sum, sec) => sum + sec.items.length, 0);
|
||||
if (options.write) {
|
||||
await Bun.write(absolutePath, next);
|
||||
}
|
||||
|
||||
results[i] = {
|
||||
path: changelogPath,
|
||||
originalCount,
|
||||
rewrittenCount,
|
||||
sections: rewritten,
|
||||
};
|
||||
} catch (error) {
|
||||
// Bubble errors from workers
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, paths.length) }, worker);
|
||||
await Promise.all(workers);
|
||||
|
||||
const changed: RewrittenFile[] = [];
|
||||
for (const res of results) {
|
||||
if (res !== undefined) changed.push(res);
|
||||
}
|
||||
|
||||
return { model: model.spec, changed };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CLI
|
||||
|
||||
interface CliOptions {
|
||||
mode: "write" | "dry-run" | "check";
|
||||
model: string;
|
||||
repoRoot?: string;
|
||||
packageFilter?: string;
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
function parseCli(argv: string[]): CliOptions | "help" {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
"dry-run": { type: "boolean", default: false },
|
||||
check: { type: "boolean", default: false },
|
||||
model: { type: "string", default: DEFAULT_MODEL },
|
||||
package: { type: "string" },
|
||||
"repo-root": { type: "string" },
|
||||
concurrency: { type: "string", default: "4" },
|
||||
help: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
if (values.help) return "help";
|
||||
return {
|
||||
mode: values.check ? "check" : values["dry-run"] ? "dry-run" : "write",
|
||||
model: values.model,
|
||||
repoRoot: values["repo-root"],
|
||||
packageFilter: values.package,
|
||||
concurrency: Number.parseInt(values.concurrency ?? "4", 10),
|
||||
};
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage: bun scripts/rewrite-changelog.ts [--dry-run|--check] [--model <prov/id>] [--package <substr>] [--concurrency <n>]",
|
||||
"",
|
||||
"Hands each non-empty [Unreleased] changelog section to a small model and rewrites the entries",
|
||||
"into user-facing release notes, dropping intermediate developer churn and implementation-only details",
|
||||
"while preserving public contract, exports, API, config, auth, and billing behavior.",
|
||||
"",
|
||||
"Options:",
|
||||
` --model <prov/id> Classifier model (default ${DEFAULT_MODEL}).`,
|
||||
" --package <substr> Only changelogs whose path contains this substring.",
|
||||
" --concurrency <n> Max concurrent changelogs to process in parallel (default 4).",
|
||||
" --dry-run Report what would be dropped without writing files.",
|
||||
" --check Exit 1 if any changelog would change.",
|
||||
" --repo-root <dir> Run against an explicit repository root.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function printSummary(result: RunResult, mode: CliOptions["mode"]): void {
|
||||
if (result.changed.length === 0) {
|
||||
console.log(`No redundant or non-user-visible [Unreleased] entries to rewrite (model ${result.model}).`);
|
||||
return;
|
||||
}
|
||||
const suffix = mode === "write" ? "" : ` (${mode}, not written)`;
|
||||
console.log(`Rewrote [Unreleased] sections across ${result.changed.length} changelog(s)${suffix}:`);
|
||||
for (const file of result.changed) {
|
||||
console.log(`\n ${file.path} (${file.originalCount} items -> ${file.rewrittenCount} items):`);
|
||||
for (const sec of file.sections) {
|
||||
console.log(` ### ${sec.category}`);
|
||||
for (const item of sec.items) {
|
||||
console.log(` - ${item}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
const cli = parseCli(process.argv.slice(2));
|
||||
if (cli === "help") {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
const result = await run({
|
||||
repoRoot: cli.repoRoot,
|
||||
model: cli.model,
|
||||
write: cli.mode === "write",
|
||||
packageFilter: cli.packageFilter,
|
||||
concurrency: cli.concurrency,
|
||||
});
|
||||
printSummary(result, cli.mode);
|
||||
if (cli.mode === "check" && result.changed.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await main();
|
||||
}
|
||||
|
||||
export { applyRewrite, collectEntries, run, type RunResult, unreleasedSection, validateRewrite };
|
||||
Reference in New Issue
Block a user