diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml new file mode 100644 index 00000000..b13e32cf --- /dev/null +++ b/.github/workflows/update-changelog.yml @@ -0,0 +1,52 @@ +name: Update Changelog + +on: + pull_request: + types: [opened, edited] + +jobs: + update-changelog: + # Only run if this is not a PR from a fork to avoid permission issues + # and not a commit made by GitHub Action to avoid infinite loops + if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Checkout the PR head ref + ref: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Update changelog + run: | + python modify_changelog.py update_changelog \ + "${{ github.event.pull_request.number }}" \ + "${{ github.event.pull_request.title }}" + + - name: Check for changes + id: changes + run: | + if git diff --quiet docs/changelog.md; then + echo "changed=false" >> $GITHUB_OUTPUT + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Commit and push changes + if: steps.changes.outputs.changed == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add docs/changelog.md + git commit -m "Add changelog entry for PR #${{ github.event.pull_request.number }}" + git push \ No newline at end of file diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index 2fda944c..7ab0ae62 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -39,7 +39,7 @@ jobs: - run: | git config user.name github-actions[bot] git config user.email 41898282+github-actions[bot]@users.noreply.github.com - VERSION=$(python increment_version.py $TYPE) + VERSION=$(python modify_changelog.py bump_version $TYPE) git checkout -b "version-$VERSION" git commit -am "Version $VERSION" git push -u origin HEAD diff --git a/docs/changelog.md b/docs/changelog.md index ddb82166..1792f2a9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,7 @@ _This project uses semantic versioning_ ## UNRELEASED +- Automatically Create Changelog Entry for PRs [#313](https://github.com/egraphs-good/egglog-python/pull/313) - Upgrade egglog which includes new backend. - Fixes implementation of the Python Object sort to work with objects with dupliating hashes but the same value. Also changes the representation to be an index into a list instead of the ID, making egglog programs more deterministic. diff --git a/docs/reference/contributing.md b/docs/reference/contributing.md index 8108fbc0..11556759 100644 --- a/docs/reference/contributing.md +++ b/docs/reference/contributing.md @@ -84,6 +84,10 @@ or bug fixes. When you are ready to submit your changes, please open a pull request. The CI will run the tests and check the code style. +#### Changelog Automation + +When you open a pull request, a GitHub Action automatically adds an entry to the UNRELEASED section of the changelog using your PR title and number. This ensures the changelog stays up-to-date without manual intervention. + ## Documentation We use the [Diátaxis framework](https://diataxis.fr/) to organize our documentation. The "explanation" section has diff --git a/increment_version.py b/increment_version.py deleted file mode 100644 index ce722c7b..00000000 --- a/increment_version.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Version Bumper for Cargo.toml and Changelog.md - -This script automates the process of version bumping for Rust projects managed with Cargo. It reads the version -from the cargo.toml file, increments it based on the specified component (major, minor, or patch), and updates -both the cargo.toml and changelog.md files accordingly. - -It will also print out the new version number. - -Usage: - Run the script from the command line, specifying the type of version increment as an argument: - $ python bump_version.py [major|minor|patch] - -Arguments: ---------- - major - Increments the major component of the version, sets minor and patch to 0 - minor - Increments the minor component of the version, sets patch to 0 - patch - Increments the patch component of the version - -From https://chat.openai.com/share/6b08906d-23a3-4193-9f4e-87076ce56ddb - - -""" - -import datetime -import re -import sys -from pathlib import Path - - -def bump_version(major: int, minor: int, patch: int, part: str) -> str: - if part == "major": - major += 1 - minor = 0 - patch = 0 - elif part == "minor": - minor += 1 - patch = 0 - elif part == "patch": - patch += 1 - return f"{major}.{minor}.{patch}" - - -def update_cargo_toml(file_path: Path, new_version: str) -> None: - content = file_path.read_text() - content = re.sub(r'version = "(\d+\.\d+\.\d+)"', f'version = "{new_version}"', content, count=1) - file_path.write_text(content) - - -def update_changelog(file_path: Path, new_version: str) -> None: - today = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d") - content = file_path.read_text() - new_section = f"## UNRELEASED\n\n## {new_version} ({today})" - content = content.replace("## UNRELEASED", new_section, 1) - file_path.write_text(content) - - -if __name__ == "__main__": - if len(sys.argv) != 2 or sys.argv[1] not in ("major", "minor", "patch"): - print("Usage: python bump_version.py [major|minor|patch]") - sys.exit(1) - - part = sys.argv[1] - cargo_path = Path("Cargo.toml") - changelog_path = Path("docs/changelog.md") - - cargo_content = cargo_path.read_text() - version_match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', cargo_content) - if version_match: - major, minor, patch = map(int, version_match.groups()) - else: - print("Current version not found in cargo.toml.") - sys.exit(1) - - new_version = bump_version(major, minor, patch, part) - old_version = f"{major}.{minor}.{patch}" - update_cargo_toml(cargo_path, new_version) - update_changelog(changelog_path, new_version) - print(new_version) diff --git a/modify_changelog.py b/modify_changelog.py new file mode 100644 index 00000000..2cdaa959 --- /dev/null +++ b/modify_changelog.py @@ -0,0 +1,208 @@ +""" +Changelog Modifier and Version Bumper for Cargo.toml and Changelog.md + +This script automates the process of version bumping and changelog updates for Rust projects managed with Cargo. +It reads the version from the cargo.toml file, increments it based on the specified component (major, minor, or patch), +and updates both the cargo.toml and changelog.md files accordingly. + +It can also add PR entries to the UNRELEASED section of the changelog. + +Usage: + Version bumping: + $ python modify_changelog.py bump_version [major|minor|patch] + + Adding PR entry: + $ python modify_changelog.py update_changelog + +Subcommands: +----------- + bump_version - Increments version and updates changelog + major - Increments the major component of the version, sets minor and patch to 0 + minor - Increments the minor component of the version, sets patch to 0 + patch - Increments the patch component of the version + + update_changelog - Add a PR entry to the UNRELEASED section + number - PR number + title - PR title + +From https://chat.openai.com/share/6b08906d-23a3-4193-9f4e-87076ce56ddb + +""" + +import argparse +import datetime +import re +import sys +from pathlib import Path + + +def bump_version(major: int, minor: int, patch: int, part: str) -> str: + if part == "major": + major += 1 + minor = 0 + patch = 0 + elif part == "minor": + minor += 1 + patch = 0 + elif part == "patch": + patch += 1 + return f"{major}.{minor}.{patch}" + + +def update_cargo_toml(file_path: Path, new_version: str) -> None: + content = file_path.read_text() + content = re.sub(r'version = "(\d+\.\d+\.\d+)"', f'version = "{new_version}"', content, count=1) + file_path.write_text(content) + + +def find_unreleased_section(lines): + """Find the line number where UNRELEASED section starts and ends.""" + unreleased_start = None + content_start = None + + for i, line in enumerate(lines): + if line.strip() == "## UNRELEASED": + unreleased_start = i + continue + + if unreleased_start is not None and content_start is None: + # Skip empty lines after ## UNRELEASED + if line.strip() == "": + continue + content_start = i + break + + return unreleased_start, content_start + + +def update_changelog_version(file_path: Path, new_version: str) -> None: + """Update changelog for version bump - replaces UNRELEASED with versioned section.""" + today = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d") + content = file_path.read_text() + new_section = f"## UNRELEASED\n\n## {new_version} ({today})" + content = content.replace("## UNRELEASED", new_section, 1) + file_path.write_text(content) + + +def update_changelog_pr(file_path: Path, pr_number: str, pr_title: str, pr_url: str) -> bool: + """Update the changelog with the new PR entry. If entry exists, update it; otherwise add new entry.""" + # Read the current changelog + with open(file_path, encoding="utf-8") as f: + lines = f.readlines() + + # Find the UNRELEASED section + unreleased_start, content_start = find_unreleased_section(lines) + + if unreleased_start is None: + print("ERROR: Could not find '## UNRELEASED' section in changelog") + return False + + if content_start is None: + print("ERROR: Could not find content start after UNRELEASED section") + return False + + # Create the new entry + new_entry = f"- {pr_title} [#{pr_number}]({pr_url})\n" + + # Check if this PR entry already exists and update it if so + existing_entry_index = None + for i, line in enumerate(lines[content_start:], start=content_start): + if f"[#{pr_number}]" in line: + existing_entry_index = i + break + # Stop checking when we reach the next section + if line.startswith("## ") and line.strip() != "## UNRELEASED": + break + + if existing_entry_index is not None: + # Update existing entry + lines[existing_entry_index] = new_entry + print(f"Updated changelog entry for PR #{pr_number}: {pr_title}") + else: + # Insert the new entry at the beginning of the unreleased content + lines.insert(content_start, new_entry) + print(f"Added changelog entry for PR #{pr_number}: {pr_title}") + + # Write the updated changelog + with open(file_path, "w", encoding="utf-8") as f: + f.writelines(lines) + + return True + + +def handle_bump_version(args): + """Handle version bump subcommand.""" + part = args.bump_type + cargo_path = Path("Cargo.toml") + changelog_path = Path("docs/changelog.md") + + if not cargo_path.exists(): + print("ERROR: Cargo.toml not found.") + sys.exit(1) + + if not changelog_path.exists(): + print("ERROR: Changelog file not found.") + sys.exit(1) + + cargo_content = cargo_path.read_text() + version_match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', cargo_content) + if version_match: + major, minor, patch = map(int, version_match.groups()) + else: + print("Current version not found in cargo.toml.") + sys.exit(1) + + new_version = bump_version(major, minor, patch, part) + update_cargo_toml(cargo_path, new_version) + update_changelog_version(changelog_path, new_version) + print(new_version) + + +def handle_update_changelog(args): + """Handle update changelog subcommand.""" + pr_number = args.number + pr_title = args.title + + # Construct PR URL from repository info and PR number + # Default to the egglog-python repository + pr_url = f"https://github.com/egraphs-good/egglog-python/pull/{pr_number}" + + changelog_path = Path(getattr(args, "changelog_path", "docs/changelog.md")) + + if not changelog_path.exists(): + print(f"ERROR: Changelog file not found: {changelog_path}") + sys.exit(1) + + success = update_changelog_pr(changelog_path, pr_number, pr_title, pr_url) + if not success: + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="Changelog modifier and version bumper") + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Bump version subcommand + bump_parser = subparsers.add_parser("bump_version", help="Bump version and update changelog") + bump_parser.add_argument("bump_type", choices=["major", "minor", "patch"], help="Type of version bump") + + # Update changelog subcommand + changelog_parser = subparsers.add_parser("update_changelog", help="Add PR entry to changelog") + changelog_parser.add_argument("number", help="Pull request number") + changelog_parser.add_argument("title", help="Pull request title") + changelog_parser.add_argument("--changelog-path", default="docs/changelog.md", help="Path to changelog file") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + if args.command == "bump_version": + handle_bump_version(args) + elif args.command == "update_changelog": + handle_update_changelog(args) + + +if __name__ == "__main__": + main() diff --git a/python/tests/test_modify_changelog.py b/python/tests/test_modify_changelog.py new file mode 100644 index 00000000..7682e829 --- /dev/null +++ b/python/tests/test_modify_changelog.py @@ -0,0 +1,297 @@ +"""Tests for modify_changelog.py script.""" + +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + + +def test_modify_changelog_help(): + """Test that the script shows help correctly.""" + result = subprocess.run( + [sys.executable, "modify_changelog.py", "--help"], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent.parent, + check=False, + ) + assert result.returncode == 0 + assert "Changelog modifier and version bumper" in result.stdout + assert "bump_version" in result.stdout + assert "update_changelog" in result.stdout + + +def test_bump_version_subcommand_help(): + """Test that bump_version subcommand shows help correctly.""" + result = subprocess.run( + [sys.executable, "modify_changelog.py", "bump_version", "--help"], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent.parent, + check=False, + ) + assert result.returncode == 0 + assert "Type of version bump" in result.stdout + assert "major" in result.stdout + assert "minor" in result.stdout + assert "patch" in result.stdout + + +def test_update_changelog_subcommand_help(): + """Test that update_changelog subcommand shows help correctly.""" + result = subprocess.run( + [sys.executable, "modify_changelog.py", "update_changelog", "--help"], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent.parent, + check=False, + ) + assert result.returncode == 0 + assert "Pull request number" in result.stdout + assert "Pull request title" in result.stdout + + +@pytest.mark.parametrize( + ("start_version", "bump_type", "expected_version"), + [ + pytest.param("1.2.3", "patch", "1.2.4", id="patch_bump"), + pytest.param("1.2.3", "minor", "1.3.0", id="minor_bump"), + pytest.param("1.2.3", "major", "2.0.0", id="major_bump"), + ], +) +def test_bump_version(start_version, bump_type, expected_version): + """Test version bumping with different increment types.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create mock Cargo.toml + cargo_content = f'''[package] +name = "test-package" +version = "{start_version}" +edition = "2021" +''' + cargo_path = temp_path / "Cargo.toml" + cargo_path.write_text(cargo_content) + + # Create mock changelog + changelog_content = f"""# Changelog + +## UNRELEASED + +## {start_version} (2024-01-01) + +- Some old change +""" + docs_dir = temp_path / "docs" + docs_dir.mkdir() + changelog_path = docs_dir / "changelog.md" + changelog_path.write_text(changelog_content) + + # Run the script + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).parent.parent.parent / "modify_changelog.py"), + "bump_version", + bump_type, + ], + capture_output=True, + text=True, + cwd=temp_path, + check=False, + ) + + assert result.returncode == 0 + assert result.stdout.strip() == expected_version + + # Check Cargo.toml was updated + updated_cargo = cargo_path.read_text() + assert f'version = "{expected_version}"' in updated_cargo + + # Check changelog was updated + updated_changelog = changelog_path.read_text() + assert "## UNRELEASED" in updated_changelog + assert f"## {expected_version} (" in updated_changelog + + +def test_update_changelog_new_entry(): + """Test adding a new PR entry to changelog.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create mock changelog + changelog_content = """# Changelog + +## UNRELEASED + +## 1.2.3 (2024-01-01) + +- Some old change +""" + docs_dir = temp_path / "docs" + docs_dir.mkdir() + changelog_path = docs_dir / "changelog.md" + changelog_path.write_text(changelog_content) + + # Run the script + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).parent.parent.parent / "modify_changelog.py"), + "update_changelog", + "123", + "Fix important bug", + ], + capture_output=True, + text=True, + cwd=temp_path, + check=False, + ) + + assert result.returncode == 0 + assert "Added changelog entry for PR #123: Fix important bug" in result.stdout + + # Check changelog was updated + updated_changelog = changelog_path.read_text() + assert "- Fix important bug [#123](https://github.com/egraphs-good/egglog-python/pull/123)" in updated_changelog + + +def test_update_changelog_duplicate_entry(): + """Test that modifying PR title updates the existing changelog entry instead of making a new one.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create mock changelog with existing entry + changelog_content = """# Changelog + +## UNRELEASED + +- Fix important bug [#123](https://github.com/egraphs-good/egglog-python/pull/123) + +## 1.2.3 (2024-01-01) + +- Some old change +""" + docs_dir = temp_path / "docs" + docs_dir.mkdir() + changelog_path = docs_dir / "changelog.md" + changelog_path.write_text(changelog_content) + + # Run the script with updated title for same PR + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).parent.parent.parent / "modify_changelog.py"), + "update_changelog", + "123", + "Fix critical security bug", + ], + capture_output=True, + text=True, + cwd=temp_path, + check=False, + ) + + assert result.returncode == 0 + assert "Updated changelog entry for PR #123: Fix critical security bug" in result.stdout + + # Check that the changelog was updated, not duplicated + updated_changelog = changelog_path.read_text() + assert ( + "- Fix critical security bug [#123](https://github.com/egraphs-good/egglog-python/pull/123)" + in updated_changelog + ) + assert "- Fix important bug [#123]" not in updated_changelog # Old entry should be gone + assert updated_changelog.count("[#123]") == 1 # Should only have one entry for PR 123 + + +def test_update_changelog_missing_file(): + """Test error handling when changelog file is missing.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Run the script without creating changelog file + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).parent.parent.parent / "modify_changelog.py"), + "update_changelog", + "123", + "Fix important bug", + ], + capture_output=True, + text=True, + cwd=temp_path, + check=False, + ) + + assert result.returncode == 1 + assert "ERROR: Changelog file not found" in result.stdout + + +def test_bump_version_missing_cargo(): + """Test error handling when Cargo.toml file is missing.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create changelog but no Cargo.toml + docs_dir = temp_path / "docs" + docs_dir.mkdir() + changelog_path = docs_dir / "changelog.md" + changelog_path.write_text("# Changelog\n\n## UNRELEASED\n") + + # Run the script + result = subprocess.run( + [sys.executable, str(Path(__file__).parent.parent.parent / "modify_changelog.py"), "bump_version", "patch"], + capture_output=True, + text=True, + cwd=temp_path, + check=False, + ) + + assert result.returncode == 1 + assert "ERROR: Cargo.toml not found" in result.stdout + + +def test_custom_changelog_path(): + """Test using custom changelog path.""" + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create mock changelog in custom location + changelog_content = """# Changelog + +## UNRELEASED + +## 1.2.3 (2024-01-01) + +- Some old change +""" + custom_changelog_path = temp_path / "CHANGELOG.md" + custom_changelog_path.write_text(changelog_content) + + # Run the script with custom path + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).parent.parent.parent / "modify_changelog.py"), + "update_changelog", + "456", + "Add new feature", + "--changelog-path", + "CHANGELOG.md", + ], + capture_output=True, + text=True, + cwd=temp_path, + check=False, + ) + + assert result.returncode == 0 + assert "Added changelog entry for PR #456: Add new feature" in result.stdout + + # Check changelog was updated + updated_changelog = custom_changelog_path.read_text() + assert "- Add new feature [#456](https://github.com/egraphs-good/egglog-python/pull/456)" in updated_changelog