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:
can1357
2026-06-27 02:55:31 +02:00
parent 3345085290
commit 5a044dc0da
49 changed files with 2219 additions and 1823 deletions
+16 -16
View File
@@ -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
View File
@@ -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)) {
+450
View File
@@ -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 };