"""Development tools for version bumping and git automation.""" import os from pathlib import Path import subprocess import sys import click from dotenv import load_dotenv from github import Github from openai import OpenAI from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel from rich.prompt import Confirm from crewai_devtools.prompts import RELEASE_NOTES_PROMPT load_dotenv() console = Console() def run_command(cmd: list[str], cwd: Path | None = None) -> str: """Run a shell command and return output. Args: cmd: Command to run as list of strings. cwd: Working directory for command. Returns: Command output as string. Raises: subprocess.CalledProcessError: If command fails. """ result = subprocess.run( # noqa: S603 cmd, cwd=cwd, capture_output=True, text=True, check=True, ) return result.stdout.strip() def check_gh_installed() -> None: """Check if GitHub CLI is installed and offer to install it. Raises: SystemExit: If gh is not installed and user declines installation. """ try: run_command(["gh", "--version"]) except (subprocess.CalledProcessError, FileNotFoundError): console.print("[yellow]Warning:[/yellow] GitHub CLI (gh) is not installed") import platform if platform.system() == "Darwin": try: run_command(["brew", "--version"]) from rich.prompt import Confirm if Confirm.ask( "\n[bold]Would you like to install GitHub CLI via Homebrew?[/bold]", default=True, ): try: console.print("\nInstalling GitHub CLI...") subprocess.run( ["brew", "install", "gh"], # noqa: S607 check=True, ) console.print( "[green]✓[/green] GitHub CLI installed successfully" ) console.print("\nAuthenticating with GitHub...") subprocess.run( ["gh", "auth", "login"], # noqa: S607 check=True, ) console.print("[green]✓[/green] GitHub authentication complete") return except subprocess.CalledProcessError as e: console.print( f"[red]Error:[/red] Failed to install or authenticate gh: {e}" ) console.print( "\nYou can try running [bold]gh auth login[/bold] manually" ) except (subprocess.CalledProcessError, FileNotFoundError): pass console.print("\nPlease install GitHub CLI from: https://cli.github.com/") console.print("\nInstallation instructions:") console.print(" macOS: brew install gh") console.print( " Linux: https://github.com/cli/cli/blob/trunk/docs/install_linux.md" ) console.print(" Windows: winget install --id GitHub.cli") sys.exit(1) def check_git_clean() -> None: """Check if git working directory is clean. Raises: SystemExit: If there are uncommitted changes. """ try: status = run_command(["git", "status", "--porcelain"]) if status: console.print( "[red]Error:[/red] You have uncommitted changes. Please commit or stash them first." ) sys.exit(1) except subprocess.CalledProcessError as e: console.print(f"[red]Error checking git status:[/red] {e}") sys.exit(1) def update_version_in_file(file_path: Path, new_version: str) -> bool: """Update __version__ attribute in a Python file. Args: file_path: Path to Python file. new_version: New version string. Returns: True if version was updated, False otherwise. """ if not file_path.exists(): return False content = file_path.read_text() lines = content.splitlines() updated = False for i, line in enumerate(lines): if line.strip().startswith("__version__"): lines[i] = f'__version__ = "{new_version}"' updated = True break if updated: file_path.write_text("\n".join(lines) + "\n") return True return False def find_version_files(base_path: Path) -> list[Path]: """Find all __init__.py files that contain __version__. Args: base_path: Base directory to search in. Returns: List of paths to files containing __version__. """ return [ init_file for init_file in base_path.rglob("__init__.py") if "__version__" in init_file.read_text() ] def get_packages(lib_dir: Path) -> list[Path]: """Get all packages from lib/ directory. Args: lib_dir: Path to lib/ directory. Returns: List of package directory paths. Raises: SystemExit: If lib/ doesn't exist or no packages found. """ if not lib_dir.exists(): console.print("[red]Error:[/red] lib/ directory not found") sys.exit(1) packages = [p for p in lib_dir.iterdir() if p.is_dir()] if not packages: console.print("[red]Error:[/red] No packages found in lib/") sys.exit(1) return packages def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]: """Get commits from the last tag, excluding current version. Args: tag_name: Current tag name (e.g., "v1.0.0"). version: Current version (e.g., "1.0.0"). Returns: Tuple of (commit_range, commits) where commits is newline-separated. """ try: all_tags = run_command(["git", "tag", "--sort=-version:refname"]).split("\n") prev_tags = [t for t in all_tags if t and t != tag_name and t != f"v{version}"] if prev_tags: last_tag = prev_tags[0] commit_range = f"{last_tag}..HEAD" commits = run_command(["git", "log", commit_range, "--pretty=format:%s"]) else: commit_range = "HEAD" commits = run_command(["git", "log", "--pretty=format:%s"]) except subprocess.CalledProcessError: commit_range = "HEAD" commits = run_command(["git", "log", "--pretty=format:%s"]) return commit_range, commits def get_github_contributors(commit_range: str) -> list[str]: """Get GitHub usernames from commit range using GitHub API. Args: commit_range: Git commit range (e.g., "abc123..HEAD"). Returns: List of GitHub usernames sorted alphabetically. """ try: # Get GitHub token from gh CLI try: gh_token = run_command(["gh", "auth", "token"]) except subprocess.CalledProcessError: gh_token = None g = Github(login_or_token=gh_token) if gh_token else Github() github_repo = g.get_repo("crewAIInc/crewAI") commit_shas = run_command( ["git", "log", commit_range, "--pretty=format:%H"] ).split("\n") contributors = set() for sha in commit_shas: if not sha: continue try: commit = github_repo.get_commit(sha) if commit.author and commit.author.login: contributors.add(commit.author.login) if commit.commit.message: for line in commit.commit.message.split("\n"): if line.strip().startswith("Co-authored-by:"): if "<" in line and ">" in line: email_part = line.split("<")[1].split(">")[0] if "@users.noreply.github.com" in email_part: username = email_part.split("+")[-1].split("@")[0] contributors.add(username) except Exception: # noqa: S112 continue return sorted(list(contributors)) except Exception as e: console.print( f"[yellow]Warning:[/yellow] Could not fetch GitHub contributors: {e}" ) return [] @click.group() def cli() -> None: """Development tools for version bumping and git automation.""" @click.command() @click.argument("version") @click.option( "--dry-run", is_flag=True, help="Show what would be done without making changes" ) @click.option("--no-push", is_flag=True, help="Don't push changes to remote") def bump(version: str, dry_run: bool, no_push: bool) -> None: """Bump version across all packages in lib/. Args: version: New version to set (e.g., 1.0.0, 1.0.0a1). dry_run: Show what would be done without making changes. no_push: Don't push changes to remote. """ try: # Check prerequisites check_gh_installed() cwd = Path.cwd() lib_dir = cwd / "lib" if not dry_run: console.print("Checking git status...") check_git_clean() console.print("[green]✓[/green] Working directory is clean") else: console.print("[dim][DRY RUN][/dim] Would check git status") packages = get_packages(lib_dir) console.print(f"\nFound {len(packages)} package(s) to update:") for pkg in packages: console.print(f" - {pkg.name}") console.print(f"\nUpdating version to {version}...") updated_files = [] for pkg in packages: version_files = find_version_files(pkg) for vfile in version_files: if dry_run: console.print( f"[dim][DRY RUN][/dim] Would update: {vfile.relative_to(cwd)}" ) else: if update_version_in_file(vfile, version): console.print( f"[green]✓[/green] Updated: {vfile.relative_to(cwd)}" ) updated_files.append(vfile) else: console.print( f"[red]✗[/red] Failed to update: {vfile.relative_to(cwd)}" ) if not updated_files and not dry_run: console.print( "[yellow]Warning:[/yellow] No __version__ attributes found to update" ) if not dry_run: console.print("\nUpdating uv.lock...") run_command(["uv", "lock"]) console.print("[green]✓[/green] Lock file updated") else: console.print("[dim][DRY RUN][/dim] Would run: uv lock") branch_name = f"feat/bump-version-{version}" if not dry_run: console.print(f"\nCreating branch {branch_name}...") run_command(["git", "checkout", "-b", branch_name]) console.print("[green]✓[/green] Branch created") console.print("\nCommitting changes...") run_command(["git", "add", "."]) run_command(["git", "commit", "-m", f"feat: bump versions to {version}"]) console.print("[green]✓[/green] Changes committed") if not no_push: console.print("\nPushing branch...") run_command(["git", "push", "-u", "origin", branch_name]) console.print("[green]✓[/green] Branch pushed") else: console.print(f"[dim][DRY RUN][/dim] Would create branch: {branch_name}") console.print( f"[dim][DRY RUN][/dim] Would commit: feat: bump versions to {version}" ) if not no_push: console.print(f"[dim][DRY RUN][/dim] Would push branch: {branch_name}") if not dry_run and not no_push: console.print("\nCreating pull request...") run_command( [ "gh", "pr", "create", "--base", "release/v1.0.0", "--title", f"feat: bump versions to {version}", "--body", "", ] ) console.print("[green]✓[/green] Pull request created") elif dry_run: console.print( f"[dim][DRY RUN][/dim] Would create PR: feat: bump versions to {version}" ) else: console.print("\nSkipping PR creation (--no-push flag set)") console.print(f"\n[green]✓[/green] Version bump to {version} complete!") except subprocess.CalledProcessError as e: console.print(f"[red]Error running command:[/red] {e}") if e.stderr: console.print(e.stderr) sys.exit(1) except Exception as e: console.print(f"[red]Error:[/red] {e}") sys.exit(1) @click.command() @click.option( "--dry-run", is_flag=True, help="Show what would be done without making changes" ) @click.option("--no-edit", is_flag=True, help="Skip editing release notes") def tag(dry_run: bool, no_edit: bool) -> None: """Create and push a version tag on main branch. Run this after the version bump PR has been merged. Automatically detects version from __version__ in packages. Args: dry_run: Show what would be done without making changes. no_edit: Skip editing release notes. """ try: cwd = Path.cwd() lib_dir = cwd / "lib" packages = get_packages(lib_dir) with console.status("[cyan]Validating package versions..."): versions = {} for pkg in packages: version_files = find_version_files(pkg) for vfile in version_files: content = vfile.read_text() for line in content.splitlines(): if line.strip().startswith("__version__"): ver = line.split("=")[1].strip().strip('"').strip("'") versions[vfile.relative_to(cwd)] = ver break if not versions: console.print( "[red]✗[/red] Validated package versions: Could not find __version__ in any package" ) sys.exit(1) unique_versions = set(versions.values()) if len(unique_versions) > 1: console.print( "[red]✗[/red] Validated package versions: Version mismatch detected" ) for file, ver in versions.items(): console.print(f" {file}: {ver}") sys.exit(1) version = unique_versions.pop() console.print(f"[green]✓[/green] Validated packages @ [bold]{version}[/bold]") tag_name = f"v{version}" if not dry_run: with console.status("[cyan]Checking out release/v1.0.0 branch..."): try: run_command(["git", "checkout", "release/v1.0.0"]) except subprocess.CalledProcessError as e: console.print( f"[red]✗[/red] Checked out release/v1.0.0 branch: {e}" ) sys.exit(1) console.print("[green]✓[/green] On release/v1.0.0 branch") with console.status("[cyan]Pulling latest changes..."): try: run_command(["git", "pull"]) except subprocess.CalledProcessError as e: console.print(f"[red]✗[/red] Pulled latest changes: {e}") sys.exit(1) console.print("[green]✓[/green] release/v1.0.0 branch up to date") release_notes = f"Release {version}" commits = "" with console.status("[cyan]Generating release notes..."): try: prev_bump_commit = run_command( [ "git", "log", "--grep=^feat: bump versions to", "--format=%H", "-n", "2", ] ) commits_list = prev_bump_commit.strip().split("\n") if len(commits_list) > 1: prev_commit = commits_list[1] commit_range = f"{prev_commit}..HEAD" commits = run_command( ["git", "log", commit_range, "--pretty=format:%s"] ) commit_lines = [ line for line in commits.split("\n") if not line.startswith("feat: bump versions to") ] commits = "\n".join(commit_lines) else: commit_range, commits = get_commits_from_last_tag(tag_name, version) except subprocess.CalledProcessError: commit_range, commits = get_commits_from_last_tag(tag_name, version) github_contributors = get_github_contributors(commit_range) if commits.strip(): client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) contributors_section = "" if github_contributors: contributors_section = f"\n\n## Contributors\n\n{', '.join([f'@{u}' for u in github_contributors])}" prompt = RELEASE_NOTES_PROMPT.substitute( version=version, commits=commits, contributors_section=contributors_section, ) response = client.chat.completions.create( model="gpt-4o-mini", messages=[ { "role": "system", "content": "You are a helpful assistant that generates clear, concise release notes.", }, {"role": "user", "content": prompt}, ], temperature=0.7, ) release_notes = ( response.choices[0].message.content or f"Release {version}" ) console.print("[green]✓[/green] Generated release notes") if commits.strip(): try: console.print() md = Markdown(release_notes, justify="left") console.print( Panel( md, title="[bold cyan]Generated Release Notes[/bold cyan]", border_style="cyan", padding=(1, 2), ) ) except Exception as e: console.print( f"[yellow]Warning:[/yellow] Could not generate release notes with OpenAI: {e}" ) console.print("Using default release notes") if not no_edit: if Confirm.ask( "\n[bold]Would you like to edit the release notes?[/bold]", default=True ): edited_notes = click.edit(release_notes) if edited_notes is not None: release_notes = edited_notes.strip() console.print("\n[green]✓[/green] Release notes updated") else: console.print("\n[green]✓[/green] Using original release notes") else: console.print( "\n[green]✓[/green] Using generated release notes without editing" ) else: console.print( "\n[green]✓[/green] Using generated release notes without editing" ) if not dry_run: with console.status(f"[cyan]Creating tag {tag_name}..."): try: run_command(["git", "tag", "-a", tag_name, "-m", release_notes]) except subprocess.CalledProcessError as e: console.print(f"[red]✗[/red] Created tag {tag_name}: {e}") sys.exit(1) console.print(f"[green]✓[/green] Created tag {tag_name}") with console.status(f"[cyan]Pushing tag {tag_name}..."): try: run_command(["git", "push", "origin", tag_name]) except subprocess.CalledProcessError as e: console.print(f"[red]✗[/red] Pushed tag {tag_name}: {e}") sys.exit(1) console.print(f"[green]✓[/green] Pushed tag {tag_name}") console.print( f"\n[green]✓[/green] Packages @ [bold]{version}[/bold] tagged successfully!" ) except subprocess.CalledProcessError as e: console.print(f"[red]Error running command:[/red] {e}") if e.stderr: console.print(e.stderr) sys.exit(1) except Exception as e: console.print(f"[red]Error:[/red] {e}") sys.exit(1) cli.add_command(bump) cli.add_command(tag) def main() -> None: """Entry point for the CLI.""" cli() if __name__ == "__main__": main()