|
| 1 | +#!/usr/bin/env uv run |
| 2 | +# /// script |
| 3 | +# dependencies = [ |
| 4 | +# "rich>=13.0.0", |
| 5 | +# ] |
| 6 | +# /// |
| 7 | + |
| 8 | +import asyncio |
| 9 | +import re |
| 10 | +import shlex |
| 11 | +import subprocess |
| 12 | +import sys |
| 13 | + |
| 14 | +from rich import box |
| 15 | +from rich.console import Console |
| 16 | +from rich.live import Live |
| 17 | +from rich.progress import Progress, SpinnerColumn, TextColumn |
| 18 | +from rich.table import Table |
| 19 | + |
| 20 | +console = Console() |
| 21 | + |
| 22 | + |
| 23 | +def print_error(message: str) -> None: |
| 24 | + """Print an error message.""" |
| 25 | + console.print(f"❌ {message}", style="bold red") |
| 26 | + |
| 27 | + |
| 28 | +def run_git_command(cmd: str) -> str: |
| 29 | + """Run a git command and return the output.""" |
| 30 | + try: |
| 31 | + result = subprocess.run(shlex.split(cmd), capture_output=True, text=True, check=True) # noqa: S603 |
| 32 | + return result.stdout.strip() |
| 33 | + except subprocess.CalledProcessError as e: |
| 34 | + print_error(f"Git command failed: {cmd}") |
| 35 | + print_error(f"Error: {e.stderr}") |
| 36 | + return "" |
| 37 | + |
| 38 | + |
| 39 | +def get_changed_packages(base_branch: str) -> list[str]: |
| 40 | + """Get list of packages that have changes compared to base branch.""" |
| 41 | + with Progress( |
| 42 | + SpinnerColumn(), |
| 43 | + TextColumn("[progress.description]{task.description}"), |
| 44 | + console=console, |
| 45 | + ) as progress: |
| 46 | + task1 = progress.add_task(f"🔄 Fetching {base_branch} branch...", total=1) |
| 47 | + run_git_command(f"git fetch origin {base_branch}") |
| 48 | + progress.update(task1, advance=1) |
| 49 | + |
| 50 | + task2 = progress.add_task(f"📊 Analyzing changes vs {base_branch}...", total=1) |
| 51 | + changed_files = run_git_command(f"git diff --name-only origin/{base_branch}") |
| 52 | + progress.update(task2, advance=1) |
| 53 | + |
| 54 | + if not changed_files: |
| 55 | + console.print("[yellow]ℹ️ No changes detected.[/yellow] [dim]No changelog entries to generate[/dim]") |
| 56 | + return [] |
| 57 | + |
| 58 | + # Extract package names from changed files |
| 59 | + changed_packages = set() |
| 60 | + for file in changed_files.split("\n"): |
| 61 | + if file.startswith("packages/") and "/src" in file: |
| 62 | + package = file.split("/")[1] |
| 63 | + changed_packages.add(package) |
| 64 | + |
| 65 | + # Treat changes in `typescript` directory as `ragbits-chat` package |
| 66 | + if any("typescript/" in file for file in changed_files.split("\n")): |
| 67 | + changed_packages.add("ragbits-chat") |
| 68 | + |
| 69 | + return sorted(changed_packages) |
| 70 | + |
| 71 | + |
| 72 | +def get_ignored_packages(base_branch: str) -> set[str]: |
| 73 | + """Get packages that should be ignored based on commit messages.""" |
| 74 | + commit_messages = run_git_command(f"git log --pretty=format:%B origin/{base_branch}..HEAD") |
| 75 | + ignored_packages = set() |
| 76 | + |
| 77 | + for line in commit_messages.split("\n"): |
| 78 | + match = re.match(r"^Changelog-ignore: (.+)$", line.strip()) |
| 79 | + if match: |
| 80 | + ignored_packages.add(match.group(1)) |
| 81 | + |
| 82 | + return ignored_packages |
| 83 | + |
| 84 | + |
| 85 | +async def generate_changelog_entry(package: str, base_branch: str) -> str | None: |
| 86 | + """Generate changelog entry using Claude.""" |
| 87 | + # Get commit messages |
| 88 | + commit_messages = run_git_command(f"git log --pretty=format:'%s' origin/{base_branch}..HEAD") |
| 89 | + |
| 90 | + # Get package-specific changed files |
| 91 | + changed_files = run_git_command(f"git diff --name-only origin/{base_branch} -- packages/{package}/") |
| 92 | + package_changed_files = "\n".join(changed_files.split("\n")[:10]) # Limit to 10 files |
| 93 | + |
| 94 | + # Get git diff for the package |
| 95 | + package_diff = run_git_command(f"git diff origin/{base_branch} -- packages/{package}/ | head -50") |
| 96 | + |
| 97 | + # Create context for Claude |
| 98 | + context = f"""Package: {package} |
| 99 | +Base branch: {base_branch} |
| 100 | +
|
| 101 | +Recent commit messages: |
| 102 | +{commit_messages} |
| 103 | +
|
| 104 | +Changed files in this package: |
| 105 | +{package_changed_files} |
| 106 | +
|
| 107 | +Git diff excerpt: |
| 108 | +{package_diff} |
| 109 | +
|
| 110 | +Please generate a concise changelog entry for the "## Unreleased" section. |
| 111 | +The entry should: |
| 112 | +1. Start with a category prefix: "feat:", "fix:", "refactor:", "docs:", "test:", "chore:", etc. |
| 113 | +2. Be a single line describing the main change |
| 114 | +3. Focus on user-facing changes rather than internal implementation details |
| 115 | +4. Do NOT include any bullet points, dashes, asterisks, or other formatting characters |
| 116 | +5. Do NOT include markdown formatting |
| 117 | +
|
| 118 | +Respond with ONLY the changelog entry text, nothing else. Example format: |
| 119 | +feat: add new user authentication system""" |
| 120 | + |
| 121 | + try: |
| 122 | + process = await asyncio.create_subprocess_exec( |
| 123 | + "claude", |
| 124 | + "-p", |
| 125 | + stdin=asyncio.subprocess.PIPE, |
| 126 | + stdout=asyncio.subprocess.PIPE, |
| 127 | + stderr=asyncio.subprocess.PIPE, |
| 128 | + ) |
| 129 | + |
| 130 | + stdout, _ = await process.communicate(input=context.encode()) |
| 131 | + |
| 132 | + if process.returncode == 0: |
| 133 | + changelog_entry = stdout.decode().strip().split("\n")[-1] # Get last line |
| 134 | + # Clean up any unwanted characters that might prefix the entry |
| 135 | + changelog_entry = re.sub(r"^[-*\s•]+", "", changelog_entry).strip() |
| 136 | + return changelog_entry |
| 137 | + else: |
| 138 | + return None |
| 139 | + |
| 140 | + except Exception: |
| 141 | + return None |
| 142 | + |
| 143 | + |
| 144 | +def add_changelog_entry(package: str, entry: str) -> bool: |
| 145 | + """Add entry to package changelog.""" |
| 146 | + changelog_path = f"packages/{package}/CHANGELOG.md" |
| 147 | + |
| 148 | + try: |
| 149 | + with open(changelog_path) as f: |
| 150 | + content = f.read() |
| 151 | + |
| 152 | + # Add entry after "## Unreleased" line |
| 153 | + updated_content = re.sub(r"(## Unreleased\n)", f"\\1\n- {entry}\n", content) |
| 154 | + |
| 155 | + with open(changelog_path, "w") as f: |
| 156 | + f.write(updated_content) |
| 157 | + |
| 158 | + return True |
| 159 | + |
| 160 | + except Exception as e: |
| 161 | + print_error(f"Error updating changelog for {package}: {e}") |
| 162 | + return False |
| 163 | + |
| 164 | + |
| 165 | +async def process_package(package: str, base_branch: str, package_data: dict) -> None: |
| 166 | + """Process a package and generate its changelog entry.""" |
| 167 | + package_data[package]["status"] = "processing" |
| 168 | + |
| 169 | + entry = await generate_changelog_entry(package, base_branch) |
| 170 | + |
| 171 | + if entry: |
| 172 | + package_data[package]["entry"] = entry |
| 173 | + package_data[package]["status"] = "success" |
| 174 | + add_changelog_entry(package, entry) |
| 175 | + else: |
| 176 | + package_data[package]["status"] = "failed" |
| 177 | + |
| 178 | + |
| 179 | +def initialize_package_data(packages_to_process: list[str], base_branch: str) -> dict: |
| 180 | + """Initialize package data with file counts and status.""" |
| 181 | + package_data = {} |
| 182 | + for package in packages_to_process: |
| 183 | + changed_files = run_git_command(f"git diff --name-only origin/{base_branch} -- packages/{package}/") |
| 184 | + file_count = len([f for f in changed_files.split("\n") if f.strip()]) |
| 185 | + package_data[package] = {"file_count": file_count, "entry": None, "status": "pending"} |
| 186 | + return package_data |
| 187 | + |
| 188 | + |
| 189 | +def create_status_table(packages_to_process: list[str], package_data: dict) -> Table: |
| 190 | + """Create updated table with current status.""" |
| 191 | + table = Table(box=box.ROUNDED) |
| 192 | + table.add_column("Package", style="cyan", no_wrap=True) |
| 193 | + table.add_column("Changed Files", style="dim", width=15) |
| 194 | + table.add_column("Changelog Entry", style="green", width=60) |
| 195 | + |
| 196 | + for package in packages_to_process: |
| 197 | + data = package_data[package] |
| 198 | + |
| 199 | + if data["status"] == "pending": |
| 200 | + status_text = "[yellow]⏳ Generating...[/yellow]" |
| 201 | + elif data["status"] == "processing": |
| 202 | + status_text = "[blue]🔄 Processing...[/blue]" |
| 203 | + elif data["status"] == "success": |
| 204 | + status_text = f"[green]✅ {data['entry']}[/green]" |
| 205 | + else: # failed |
| 206 | + status_text = "[red]❌ Failed[/red]" |
| 207 | + |
| 208 | + table.add_row(package, f"{data['file_count']} files", status_text) |
| 209 | + |
| 210 | + return table |
| 211 | + |
| 212 | + |
| 213 | +async def process_packages_with_display(packages_to_process: list[str], base_branch: str, package_data: dict) -> None: |
| 214 | + """Process packages with live display updates.""" |
| 215 | + |
| 216 | + async def update_display_periodically(live: Live, stop_event: asyncio.Event) -> None: |
| 217 | + """Update the display periodically while processing.""" |
| 218 | + while not stop_event.is_set(): |
| 219 | + live.update(create_status_table(packages_to_process, package_data)) |
| 220 | + await asyncio.sleep(0.25) # Update every 250ms |
| 221 | + |
| 222 | + with Live(create_status_table(packages_to_process, package_data), console=console, refresh_per_second=4) as live: |
| 223 | + stop_event = asyncio.Event() |
| 224 | + display_task = asyncio.create_task(update_display_periodically(live, stop_event)) |
| 225 | + await asyncio.gather(*[process_package(package, base_branch, package_data) for package in packages_to_process]) |
| 226 | + stop_event.set() |
| 227 | + await display_task |
| 228 | + live.update(create_status_table(packages_to_process, package_data)) |
| 229 | + |
| 230 | + |
| 231 | +def print_summary(packages_to_process: list[str], package_data: dict) -> None: |
| 232 | + """Print the final summary.""" |
| 233 | + success_count = sum(1 for package in packages_to_process if package_data[package]["status"] == "success") |
| 234 | + console.print() |
| 235 | + |
| 236 | + if success_count == len(packages_to_process): |
| 237 | + summary = ( |
| 238 | + "[bold green]🎉 All Done![/bold green] " |
| 239 | + f"[dim]Successfully generated {success_count} changelog entries[/dim]" |
| 240 | + ) |
| 241 | + else: |
| 242 | + failed_count = len(packages_to_process) - success_count |
| 243 | + summary = ( |
| 244 | + "[bold yellow]⚠️ Partial Success[/bold yellow] " |
| 245 | + f"[dim]Generated {success_count} entries, {failed_count} failed[/dim]" |
| 246 | + ) |
| 247 | + |
| 248 | + console.print(summary) |
| 249 | + |
| 250 | + |
| 251 | +async def main() -> None: |
| 252 | + """Generate changelog entries for changed packages.""" |
| 253 | + base_branch = sys.argv[1] if len(sys.argv) > 1 else "develop" |
| 254 | + |
| 255 | + console.print( |
| 256 | + "[bold cyan]🚀 Changelog Generator[/bold cyan] " |
| 257 | + f"[dim]Comparing against [bold]{base_branch}[/bold] branch[/dim]" |
| 258 | + ) |
| 259 | + |
| 260 | + # Get changed packages |
| 261 | + changed_packages = get_changed_packages(base_branch) |
| 262 | + if not changed_packages: |
| 263 | + console.print("[yellow]ℹ️ No package changes detected[/yellow] [dim]No changelog entries to generate[/dim]") |
| 264 | + return |
| 265 | + |
| 266 | + # Get ignored packages and filter |
| 267 | + ignored_packages = get_ignored_packages(base_branch) |
| 268 | + packages_to_process = [pkg for pkg in changed_packages if pkg not in ignored_packages] |
| 269 | + |
| 270 | + if not packages_to_process: |
| 271 | + console.print("[yellow]ℹ️ All packages are ignored[/yellow] [dim]No changelog entries to generate[/dim]") |
| 272 | + return |
| 273 | + |
| 274 | + # Process packages |
| 275 | + package_data = initialize_package_data(packages_to_process, base_branch) |
| 276 | + await process_packages_with_display(packages_to_process, base_branch, package_data) |
| 277 | + print_summary(packages_to_process, package_data) |
| 278 | + |
| 279 | + |
| 280 | +if __name__ == "__main__": |
| 281 | + asyncio.run(main()) |
0 commit comments