diff --git a/lib/devtools/src/crewai_devtools/cli.py b/lib/devtools/src/crewai_devtools/cli.py index 5235d59c4..070e22e7d 100644 --- a/lib/devtools/src/crewai_devtools/cli.py +++ b/lib/devtools/src/crewai_devtools/cli.py @@ -744,18 +744,23 @@ def _is_prerelease(version: str) -> bool: return any(indicator in v for indicator in _PRERELEASE_INDICATORS) -def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]: +def get_commits_from_last_tag( + tag_name: str, version: str, cwd: Path | None = None +) -> 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"). + cwd: Directory to run git commands in (defaults to current). Returns: Tuple of (commit_range, commits) where commits is newline-separated. """ try: - all_tags = run_command(["git", "tag", "--sort=-version:refname"]).split("\n") + all_tags = run_command( + ["git", "tag", "--sort=-version:refname"], cwd=cwd + ).split("\n") prev_tags = [t for t in all_tags if t and t != tag_name and t != f"v{version}"] if not _is_prerelease(version): @@ -764,22 +769,30 @@ def get_commits_from_last_tag(tag_name: str, version: str) -> tuple[str, str]: if prev_tags: last_tag = prev_tags[0] commit_range = f"{last_tag}..HEAD" - commits = run_command(["git", "log", commit_range, "--pretty=format:%s"]) + commits = run_command( + ["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd + ) else: commit_range = "HEAD" - commits = run_command(["git", "log", "--pretty=format:%s"]) + commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd) except subprocess.CalledProcessError: commit_range = "HEAD" - commits = run_command(["git", "log", "--pretty=format:%s"]) + commits = run_command(["git", "log", "--pretty=format:%s"], cwd=cwd) return commit_range, commits -def get_github_contributors(commit_range: str) -> list[str]: +def get_github_contributors( + commit_range: str, + repo: str = "crewAIInc/crewAI", + cwd: Path | None = None, +) -> list[str]: """Get GitHub usernames from commit range using GitHub API. Args: commit_range: Git commit range (e.g., "abc123..HEAD"). + repo: GitHub repo in ``owner/name`` form to resolve commits against. + cwd: Directory to run git commands in (defaults to current). Returns: List of GitHub usernames sorted alphabetically. @@ -791,10 +804,10 @@ def get_github_contributors(commit_range: str) -> list[str]: gh_token = None g = Github(login_or_token=gh_token) if gh_token else Github() - github_repo = g.get_repo("crewAIInc/crewAI") + github_repo = g.get_repo(repo) commit_shas = run_command( - ["git", "log", commit_range, "--pretty=format:%H"] + ["git", "log", commit_range, "--pretty=format:%H"], cwd=cwd ).split("\n") contributors = set() @@ -934,9 +947,26 @@ def _generate_release_notes( version: str, tag_name: str, no_edit: bool, + cwd: Path | None = None, + gh_repo: str = "crewAIInc/crewAI", + openai_client: OpenAI | None = None, + bump_already_done: bool = True, ) -> tuple[str, OpenAI, bool]: """Generate, display, and optionally edit release notes. + Args: + version: Version being released. + tag_name: Tag name for the release. + no_edit: Skip the interactive edit prompt. + cwd: Directory to run git commands in (defaults to current). + gh_repo: GitHub repo (``owner/name``) for resolving contributors. + openai_client: Reuse an existing OpenAI client if provided. + bump_already_done: True when the ``feat: bump versions to `` + commit for the current release is already in history (the real + release path). False in previews where no bump exists yet — the + most recent bump commit is the *previous* version and must be + used as the range start. + Returns: Tuple of (release_notes, openai_client, is_prerelease). """ @@ -951,7 +981,8 @@ def _generate_release_notes( "log", "--grep=^feat: bump versions to", "--format=%H %s", - ] + ], + cwd=cwd, ) bump_entries = [ line for line in prev_bump_output.strip().split("\n") if line.strip() @@ -959,7 +990,8 @@ def _generate_release_notes( is_stable = not _is_prerelease(version) prev_commit = None - for entry in bump_entries[1:]: + scan_entries = bump_entries[1:] if bump_already_done else bump_entries + for entry in scan_entries: bump_ver = entry.split("feat: bump versions to", 1)[-1].strip() if is_stable and _is_prerelease(bump_ver): continue @@ -969,7 +1001,7 @@ def _generate_release_notes( if prev_commit: commit_range = f"{prev_commit}..HEAD" commits = run_command( - ["git", "log", commit_range, "--pretty=format:%s"] + ["git", "log", commit_range, "--pretty=format:%s"], cwd=cwd ) commit_lines = [ @@ -979,14 +1011,21 @@ def _generate_release_notes( ] commits = "\n".join(commit_lines) else: - commit_range, commits = get_commits_from_last_tag(tag_name, version) + commit_range, commits = get_commits_from_last_tag( + tag_name, version, cwd=cwd + ) except subprocess.CalledProcessError: - commit_range, commits = get_commits_from_last_tag(tag_name, version) + commit_range, commits = get_commits_from_last_tag( + tag_name, version, cwd=cwd + ) - github_contributors = get_github_contributors(commit_range) + github_contributors = get_github_contributors( + commit_range, repo=gh_repo, cwd=cwd + ) - openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + if openai_client is None: + openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) if commits.strip(): contributors_section = "" @@ -1544,7 +1583,13 @@ def _wait_for_pr_merged(branch: str, cwd: Path) -> None: time.sleep(_PR_MERGE_POLL_INTERVAL) -def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> None: +def _release_enterprise( + version: str, + is_prerelease: bool, + dry_run: bool, + no_edit: bool = False, + openai_client: OpenAI | None = None, +) -> None: """Clone the enterprise repo, bump versions, and create a release PR. Expects ENTERPRISE_REPO, ENTERPRISE_VERSION_DIRS, and @@ -1554,6 +1599,8 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non version: New version string. is_prerelease: Whether this is a pre-release version. dry_run: Show what would be done without making changes. + no_edit: Skip the interactive release-notes edit prompt. + openai_client: Reuse OpenAI client from earlier phases if available. """ if ( not _ENTERPRISE_REPO @@ -1571,7 +1618,6 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non ) if dry_run: - console.print(f"[dim][DRY RUN][/dim] Would clone {enterprise_repo}") for d in _ENTERPRISE_VERSION_DIRS: console.print(f"[dim][DRY RUN][/dim] Would update versions in {d}") console.print( @@ -1582,6 +1628,26 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non "[dim][DRY RUN][/dim] Would create bump PR, wait for merge, " "then tag and release" ) + + with tempfile.TemporaryDirectory() as tmp: + repo_dir = Path(tmp) / enterprise_repo.split("/")[-1] + console.print(f"\nCloning {enterprise_repo} (read-only preview)...") + run_command(["gh", "repo", "clone", enterprise_repo, str(repo_dir)]) + console.print(f"[green]✓[/green] Cloned {enterprise_repo}") + + _generate_release_notes( + version, + version, + no_edit, + cwd=repo_dir, + gh_repo=enterprise_repo, + openai_client=openai_client, + bump_already_done=False, + ) + console.print( + "[dim][DRY RUN][/dim] Would tag and create GitHub release " + "with the notes above" + ) return with tempfile.TemporaryDirectory() as tmp: @@ -1694,8 +1760,18 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non run_command(["git", "pull"], cwd=repo_dir) tag_name = version + + release_notes, _, _ = _generate_release_notes( + version, + tag_name, + no_edit, + cwd=repo_dir, + gh_repo=enterprise_repo, + openai_client=openai_client, + ) + run_command( - ["git", "tag", "-a", tag_name, "-m", f"Release {version}"], + ["git", "tag", "-a", tag_name, "-m", release_notes], cwd=repo_dir, ) run_command(["git", "push", "origin", tag_name], cwd=repo_dir) @@ -1711,7 +1787,7 @@ def _release_enterprise(version: str, is_prerelease: bool, dry_run: bool) -> Non "--title", tag_name, "--notes", - f"Release {version}", + release_notes, ] if is_prerelease: gh_cmd.append("--prerelease") @@ -2010,7 +2086,7 @@ def tag(dry_run: bool, no_edit: bool) -> None: console.print("[green]✓[/green] main branch up to date") release_notes, openai_client, is_prerelease = _generate_release_notes( - version, tag_name, no_edit + version, tag_name, no_edit, bump_already_done=not dry_run ) docs_branch = _update_docs_and_create_pr( @@ -2121,7 +2197,7 @@ def release( if skip_to_enterprise: try: - _release_enterprise(version, is_prerelease, dry_run) + _release_enterprise(version, is_prerelease, dry_run, no_edit=no_edit) except BaseException as e: _print_release_error(e) _resume_hint( @@ -2217,7 +2293,7 @@ def release( console.print("[green]✓[/green] main branch up to date") release_notes, openai_client, is_prerelease = _generate_release_notes( - version, tag_name, no_edit + version, tag_name, no_edit, bump_already_done=not dry_run ) docs_branch = _update_docs_and_create_pr( @@ -2271,7 +2347,13 @@ def release( if not skip_enterprise: try: - _release_enterprise(version, is_prerelease, dry_run) + _release_enterprise( + version, + is_prerelease, + dry_run, + no_edit=no_edit, + openai_client=openai_client, + ) except BaseException as e: _print_release_error(e) _resume_hint(