Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

547 lines
16 KiB
TypeScript

#!/usr/bin/env npx tsx
/**
* Changelog Generator
*
* Generates changelog MDX files for each SDK by parsing GitHub releases.
* Fetches all releases, cleans up noisy auto-generated body content, and
* produces clean per-SDK changelogs grouped by major.minor version.
*
* Usage:
* npx tsx scripts/docs-generators/generate-changelog.ts # Generate all
* npx tsx scripts/docs-generators/generate-changelog.ts --check # Check for drift
* npx tsx scripts/docs-generators/generate-changelog.ts --sdk=computer # Specific SDK
*/
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// ============================================================================
// Types
// ============================================================================
interface GitHubRelease {
tagName: string;
name: string;
body: string;
publishedAt: string;
isDraft: boolean;
isPrerelease: boolean;
}
interface SDKConfig {
name: string;
displayName: string;
tagPrefix: string;
outputPath: string;
}
// ============================================================================
// Configuration
// ============================================================================
const ROOT_DIR = path.resolve(__dirname, '../..');
const DOCS_BASE = path.join(ROOT_DIR, 'docs/content/docs');
const GITHUB_REPO = 'trycua/cua';
const SDK_CONFIGS: SDKConfig[] = [
{
name: 'computer',
displayName: 'Computer SDK',
tagPrefix: 'computer-v',
outputPath: 'cua/reference/computer-sdk/changelog.mdx',
},
{
name: 'agent',
displayName: 'Agent SDK',
tagPrefix: 'agent-v',
outputPath: 'cua/reference/agent-sdk/changelog.mdx',
},
{
name: 'computer-server',
displayName: 'Desktop Sandbox',
tagPrefix: 'computer-server-v',
outputPath: 'cua/reference/desktop-sandbox/changelog.mdx',
},
{
name: 'lume',
displayName: 'Lume',
tagPrefix: 'lume-v',
outputPath: 'reference/lume/changelog.mdx',
},
{
name: 'cuabot',
displayName: 'Cua-Bot',
tagPrefix: 'cuabot-v',
outputPath: 'cuabot/reference/changelog.mdx',
},
];
// ============================================================================
// Main
// ============================================================================
async function main() {
const args = process.argv.slice(2);
const checkOnly = args.includes('--check');
const sdkArg = args.find((a) => a.startsWith('--sdk='));
const targetSdk = sdkArg?.split('=')[1];
console.log('Changelog Generator');
console.log('======================\n');
// Fetch ALL releases once (across all SDKs)
console.log('Fetching all GitHub releases...');
const allReleases = fetchAllReleases();
console.log(`Found ${allReleases.length} total releases\n`);
let hasErrors = false;
for (const config of SDK_CONFIGS) {
if (targetSdk && targetSdk !== config.name) {
continue;
}
console.log(`Processing ${config.displayName}...`);
// Filter releases for this SDK
const sdkReleases = allReleases.filter(
(r) => r.tagName.startsWith(config.tagPrefix) && !r.isDraft
);
// Sort by version descending
sdkReleases.sort((a, b) => {
const versionA = a.tagName.replace(config.tagPrefix, '');
const versionB = b.tagName.replace(config.tagPrefix, '');
return compareVersions(versionB, versionA);
});
console.log(` Found ${sdkReleases.length} releases`);
if (sdkReleases.length === 0) {
console.log(` No releases found for ${config.displayName}`);
continue;
}
// Fetch body for each release
const releasesWithBody = fetchBodies(sdkReleases);
// Generate MDX
const mdx = generateChangelogMDX(config, releasesWithBody);
// Output path
const outputPath = path.join(DOCS_BASE, config.outputPath);
const outputDir = path.dirname(outputPath);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
if (checkOnly) {
if (fs.existsSync(outputPath)) {
const existing = fs.readFileSync(outputPath, 'utf-8');
if (existing !== mdx) {
console.error(` ${config.name} changelog is out of sync`);
hasErrors = true;
} else {
console.log(` ${config.name} changelog is up to date`);
}
} else {
console.error(` ${config.name} changelog does not exist`);
hasErrors = true;
}
} else {
fs.writeFileSync(outputPath, mdx);
console.log(` Generated ${path.relative(ROOT_DIR, outputPath)}`);
}
}
if (hasErrors) {
if (checkOnly) {
console.error("\nRun 'npx tsx scripts/docs-generators/generate-changelog.ts' to update");
}
process.exit(1);
}
console.log('\nChangelog generation complete!');
}
// ============================================================================
// GitHub Release Fetching
// ============================================================================
/**
* Fetch all releases at once (without body) to avoid per-SDK limit issues.
* Uses --limit 500 to get all releases across the entire repo.
*/
function fetchAllReleases(): GitHubRelease[] {
try {
const output = execSync(
'gh release list --limit 500 --json tagName,name,publishedAt,isDraft,isPrerelease',
{ encoding: 'utf-8', cwd: ROOT_DIR, maxBuffer: 10 * 1024 * 1024 }
);
return JSON.parse(output);
} catch (error) {
console.error(`Failed to fetch releases: ${error}`);
return [];
}
}
/**
* Fetch release bodies individually for a filtered set of releases.
*/
function fetchBodies(releases: GitHubRelease[]): GitHubRelease[] {
const results: GitHubRelease[] = [];
for (const release of releases) {
try {
const output = execSync(`gh release view "${release.tagName}" --json body`, {
encoding: 'utf-8',
cwd: ROOT_DIR,
maxBuffer: 1024 * 1024,
});
const data = JSON.parse(output);
results.push({
...release,
body: data.body || '',
});
} catch {
results.push({ ...release, body: '' });
}
}
return results;
}
function compareVersions(a: string, b: string): number {
const partsA = a.split('.').map((x) => parseInt(x, 10) || 0);
const partsB = b.split('.').map((x) => parseInt(x, 10) || 0);
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
const partA = partsA[i] || 0;
const partB = partsB[i] || 0;
if (partA !== partB) {
return partA - partB;
}
}
return 0;
}
// ============================================================================
// MDX Generation
// ============================================================================
function generateChangelogMDX(config: SDKConfig, releases: GitHubRelease[]): string {
const lines: string[] = [];
// Frontmatter
lines.push('---');
lines.push(`title: Changelog`);
lines.push(`description: Release history for ${config.displayName}`);
lines.push('---');
lines.push('');
// Auto-generated notice
lines.push(`{/*`);
lines.push(` AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY`);
lines.push(` Generated by: npx tsx scripts/docs-generators/generate-changelog.ts`);
lines.push(` Last updated: ${new Date().toISOString().split('T')[0]}`);
lines.push(`*/}`);
lines.push('');
// Introduction
lines.push(`# ${config.displayName} Changelog`);
lines.push('');
lines.push(`All notable changes to the ${config.displayName} are documented here.`);
lines.push('');
// Group releases by major.minor version
const grouped = groupByMajorMinor(releases, config.tagPrefix);
for (const [majorMinor, versionReleases] of Object.entries(grouped)) {
lines.push(`## ${majorMinor}.x`);
lines.push('');
for (const release of versionReleases) {
const version = release.tagName.replace(config.tagPrefix, '');
const date = formatDate(release.publishedAt);
lines.push(`### v${version} (${date})`);
lines.push('');
// Process release body
const body = processReleaseBody(release.body);
if (body) {
lines.push(body);
lines.push('');
} else {
lines.push('Maintenance release.');
lines.push('');
}
}
}
return lines.join('\n');
}
function groupByMajorMinor(
releases: GitHubRelease[],
tagPrefix: string
): Record<string, GitHubRelease[]> {
const grouped: Record<string, GitHubRelease[]> = {};
for (const release of releases) {
const version = release.tagName.replace(tagPrefix, '');
const parts = version.split('.');
const majorMinor = `${parts[0]}.${parts[1] || '0'}`;
if (!grouped[majorMinor]) {
grouped[majorMinor] = [];
}
grouped[majorMinor].push(release);
}
// Sort keys by version descending
const sortedKeys = Object.keys(grouped).sort((a, b) => compareVersions(b, a));
const sortedGrouped: Record<string, GitHubRelease[]> = {};
for (const key of sortedKeys) {
sortedGrouped[key] = grouped[key];
}
return sortedGrouped;
}
function formatDate(isoDate: string): string {
const date = new Date(isoDate);
return date.toISOString().split('T')[0];
}
// ============================================================================
// Release Body Processing
// ============================================================================
/**
* Clean up a GitHub release body to produce focused changelog content.
*
* Strips:
* - "## What's Changed" header
* - "## Installation" / "## Installation Options" sections
* - "## Usage" / "## Quick Start" / "## Claude Desktop Integration" sections
* - "## Dependencies" section (condensed to one line if present)
* - Package description boilerplate
* - "**Full Changelog**" links
* - Heading markers ## / ### (to avoid hierarchy conflicts)
* - Commit SHAs like (abc1234)
*
* Adds:
* - Linked PR numbers: (#789) -> ([#789](https://github.com/trycua/cua/pull/789))
*
* Detects:
* - Bot-only releases (all entries are "Bump cua-X") -> "Maintenance release."
*/
function processReleaseBody(body: string): string {
if (!body || body.trim() === '') {
return '';
}
let processed = body;
// Remove "**Full Changelog**" links
processed = processed.replace(/\*\*Full Changelog\*\*:.*$/gm, '');
// Remove package description boilerplate headers
processed = processed.replace(
/^##\s*(Computer control library|Computer Server|Base package|MCP Server|Computer Vision|Toolkit for computer-use|Lightweight webUI|Unified CLI).*$/gm,
''
);
// Remove standalone description lines that match boilerplate patterns
processed = processed.replace(
/^(A FastAPI-based server implementation|This package provides (MCP|enhanced UI)|Manage cloud sandboxes).*$/gm,
''
);
// Remove raw checksum lines (sha256 hex + filename)
processed = processed.replace(/^[a-f0-9]{64}\s+\S+.*$/gm, '');
// Remove markdown table rows that look like checksum tables
processed = processed.replace(/^\|?\s*`?[a-f0-9]{64}`?\s*\|.*$/gm, '');
// Remove checksum table headers
processed = processed.replace(/^\|?\s*SHA256\s*\|.*$/gim, '');
processed = processed.replace(/^\|?\s*-+\s*\|.*$/gm, '');
// Extract dependencies from "## Dependencies" section before removing sections
let depsLine = '';
const depsContent = extractSection(processed, 'Dependencies');
if (depsContent !== null) {
const items = depsContent
.split('\n')
.filter((l) => l.trim().startsWith('*'))
.map((l) => l.replace(/^\*\s*/, '').trim());
if (items.length > 0) {
depsLine = `**Dependencies:** ${items.join(', ')}`;
}
processed = removeSection(processed, 'Dependencies');
}
// Remove boilerplate sections (BEFORE removing "What's Changed" heading,
// so section boundaries are intact for removeSection to work correctly)
const sectionsToRemove = [
'Installation Options',
'Installation with script',
'Installation',
'Usage',
'Claude Desktop Integration',
'Quick Start',
];
for (const section of sectionsToRemove) {
processed = removeSection(processed, section);
}
// NOW remove "What's Changed" / "Whats Changed" header line (after sections are removed)
processed = processed.replace(/^#{1,3}\s*What'?s Changed\s*$/gm, '');
// Handle single-line "**Dependencies:**" already in the body (from new workflow format)
const inlineDeps = processed.match(/^\*\*Dependencies:\*\*.*$/m);
if (inlineDeps) {
depsLine = inlineDeps[0].trim();
processed = processed.replace(/^\*\*Dependencies:\*\*.*$/m, '');
}
// Remove release title headings like "# cua-computer v0.4.11"
processed = processed.replace(/^#{1,3}\s*cua-\S+\s+v[\d.]+\s*$/gm, '');
// Strip heading markers (## / ###) from remaining content to avoid hierarchy issues
processed = processed.replace(/^#{1,3}\s+/gm, '');
// Link PR numbers: (#123) -> ([#123](https://github.com/trycua/cua/pull/123))
processed = processed.replace(
/\(#(\d+)\)/g,
`([#$1](https://github.com/${GITHUB_REPO}/pull/$1))`
);
// Strip commit SHAs: (7-8 char hex in parens) from entries
processed = processed.replace(/\s*\([a-f0-9]{7,8}\)/g, '');
// Clean up excessive blank lines
processed = processed.replace(/\n{3,}/g, '\n\n').trim();
// Check if all remaining bullet lines are bot bumps
const contentLines = processed
.split('\n')
.filter((l) => l.trim().startsWith('*') || l.trim().startsWith('-'));
const allBotBumps =
contentLines.length > 0 && contentLines.every((l) => /Bump (cua-|lume)/i.test(l));
if (allBotBumps && !depsLine) {
return 'Maintenance release.';
}
// Re-add dependencies line at the top if present
if (depsLine) {
processed = depsLine + (processed ? '\n\n' + processed : '');
}
// Final check: if nothing meaningful remains
if (!processed.trim()) {
return '';
}
// Escape curly braces outside of code blocks for MDX compatibility
processed = escapeMDXOutsideCode(processed);
return processed;
}
/**
* Extract the content of a markdown section (any heading level) up to the next
* same-or-higher-level heading or end of text.
* Returns null if the section is not found.
*/
function extractSection(text: string, sectionTitle: string): string | null {
const lines = text.split('\n');
let inSection = false;
let sectionLevel = 0;
let content: string[] = [];
for (const line of lines) {
const headerMatch = line.match(
new RegExp(`^(#{1,6})\\s*${escapeRegex(sectionTitle)}\\s*$`, 'i')
);
if (!inSection && headerMatch) {
inSection = true;
sectionLevel = headerMatch[1].length;
continue;
}
if (inSection) {
// Stop at next heading of same or higher level
const headingMatch = line.match(/^(#{1,6})\s/);
if (headingMatch && headingMatch[1].length <= sectionLevel) {
break;
}
content.push(line);
}
}
return inSection ? content.join('\n') : null;
}
/**
* Remove a markdown section (any heading level) and everything until the next
* same-or-higher-level heading or end of text.
*/
function removeSection(text: string, sectionTitle: string): string {
const lines = text.split('\n');
const result: string[] = [];
let inSection = false;
let sectionLevel = 0;
for (const line of lines) {
const headerMatch =
!inSection && line.match(new RegExp(`^(#{1,6})\\s*${escapeRegex(sectionTitle)}\\s*$`, 'i'));
if (headerMatch) {
inSection = true;
sectionLevel = headerMatch[1].length;
continue;
}
if (inSection) {
const headingMatch = line.match(/^(#{1,6})\s/);
if (headingMatch && headingMatch[1].length <= sectionLevel) {
inSection = false;
result.push(line);
}
continue;
}
result.push(line);
}
return result.join('\n');
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeMDXOutsideCode(text: string): string {
const codeBlockRegex = /(```[\s\S]*?```|`[^`]+`)/g;
const parts = text.split(codeBlockRegex);
return parts
.map((part) => {
if (part.startsWith('```') || part.startsWith('`')) {
return part;
}
return part.replace(/\{/g, '\\{').replace(/\}/g, '\\}');
})
.join('');
}
// ============================================================================
// Run
// ============================================================================
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});