Skip to content

Commit 7bbe174

Browse files
authored
chore: changelog branch comparision and autogen (#807)
1 parent 0740719 commit 7bbe174

File tree

1 file changed

+281
-0
lines changed

1 file changed

+281
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
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

Comments
 (0)