feat: add base devtooling

This commit is contained in:
Greyson LaLonde
2025-10-03 18:23:54 -04:00
committed by GitHub
parent 126b91eab3
commit e529ebff2b
7 changed files with 919 additions and 140 deletions

0
lib/devtools/README.md Normal file
View File

View File

@@ -0,0 +1,32 @@
[project]
name = "crewai-devtools"
dynamic = ["version"]
description = "Development tools for version bumping and git automation"
readme = "README.md"
authors = [
{ name = "Greyson R. LaLonde", email = "greyson@crewai.com" },
]
requires-python = ">=3.10, <3.14"
classifiers = ["Private :: Do Not Upload"]
private = true
dependencies = [
"click>=8.3.0",
"toml>=0.10.2",
"openai>=1.0.0",
"python-dotenv>=1.1.1",
"pygithub>=1.59.1",
"rich>=13.9.4",
]
[project.scripts]
bump-version = "crewai_devtools.cli:bump"
tag = "crewai_devtools.cli:tag"
devtools = "crewai_devtools.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.version]
path = "src/crewai_devtools/__init__.py"

View File

@@ -0,0 +1,3 @@
"""CrewAI development tools."""
__version__ = "1.0.0a2"

View File

@@ -0,0 +1,622 @@
"""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()

View File

@@ -0,0 +1,45 @@
"""Prompt templates for AI-generated content."""
from string import Template
RELEASE_NOTES_PROMPT = Template(
"""Generate concise release notes for version $version based on these commits:
$commits
The commits follow the Conventional Commits standard (feat:, fix:, chore:, etc.).
Use this exact template format:
## What's Changed
### Features
- [List feat: commits here, using imperative mood like "Add X", "Implement Y"]
### Bug Fixes
- [List fix: commits here, using imperative mood like "Fix X", "Resolve Y"]
### Documentation
- [List docs: commits here, using imperative mood like "Update X", "Add Y"]
### Performance
- [List perf: commits here, using imperative mood like "Improve X", "Optimize Y"]
### Refactoring
- [List refactor: commits here, using imperative mood like "Refactor X", "Simplify Y"]
### Breaking Changes
- [List commits with BREAKING CHANGE in footer or ! after type, using imperative mood]$contributors_section
Instructions:
- Parse conventional commit format (type: description or type(scope): description)
- Only include sections that have relevant changes from the commits
- Skip chore:, ci:, test:, and style: commits unless significant
- Convert commit messages to imperative mood if needed (e.g., "adds""Add")
- Be concise but informative
- Focus on user-facing changes
- Use the exact Contributors list provided above, do not modify it
Keep it professional and clear."""
)