|
| 1 | +""" |
| 2 | +Changelog Modifier and Version Bumper for Cargo.toml and Changelog.md |
| 3 | +
|
| 4 | +This script automates the process of version bumping and changelog updates for Rust projects managed with Cargo. |
| 5 | +It reads the version from the cargo.toml file, increments it based on the specified component (major, minor, or patch), |
| 6 | +and updates both the cargo.toml and changelog.md files accordingly. |
| 7 | +
|
| 8 | +It can also add PR entries to the UNRELEASED section of the changelog. |
| 9 | +
|
| 10 | +Usage: |
| 11 | + Version bumping: |
| 12 | + $ python modify_changelog.py bump_version [major|minor|patch] |
| 13 | +
|
| 14 | + Adding PR entry: |
| 15 | + $ python modify_changelog.py update_changelog <number> <title> |
| 16 | +
|
| 17 | +Subcommands: |
| 18 | +----------- |
| 19 | + bump_version - Increments version and updates changelog |
| 20 | + major - Increments the major component of the version, sets minor and patch to 0 |
| 21 | + minor - Increments the minor component of the version, sets patch to 0 |
| 22 | + patch - Increments the patch component of the version |
| 23 | +
|
| 24 | + update_changelog - Add a PR entry to the UNRELEASED section |
| 25 | + number - PR number |
| 26 | + title - PR title |
| 27 | +
|
| 28 | +From https://chat.openai.com/share/6b08906d-23a3-4193-9f4e-87076ce56ddb |
| 29 | +
|
| 30 | +""" |
| 31 | + |
| 32 | +import argparse |
| 33 | +import datetime |
| 34 | +import re |
| 35 | +import sys |
| 36 | +from pathlib import Path |
| 37 | + |
| 38 | + |
| 39 | +def bump_version(major: int, minor: int, patch: int, part: str) -> str: |
| 40 | + if part == "major": |
| 41 | + major += 1 |
| 42 | + minor = 0 |
| 43 | + patch = 0 |
| 44 | + elif part == "minor": |
| 45 | + minor += 1 |
| 46 | + patch = 0 |
| 47 | + elif part == "patch": |
| 48 | + patch += 1 |
| 49 | + return f"{major}.{minor}.{patch}" |
| 50 | + |
| 51 | + |
| 52 | +def update_cargo_toml(file_path: Path, new_version: str) -> None: |
| 53 | + content = file_path.read_text() |
| 54 | + content = re.sub(r'version = "(\d+\.\d+\.\d+)"', f'version = "{new_version}"', content, count=1) |
| 55 | + file_path.write_text(content) |
| 56 | + |
| 57 | + |
| 58 | +def find_unreleased_section(lines): |
| 59 | + """Find the line number where UNRELEASED section starts and ends.""" |
| 60 | + unreleased_start = None |
| 61 | + content_start = None |
| 62 | + |
| 63 | + for i, line in enumerate(lines): |
| 64 | + if line.strip() == "## UNRELEASED": |
| 65 | + unreleased_start = i |
| 66 | + continue |
| 67 | + |
| 68 | + if unreleased_start is not None and content_start is None: |
| 69 | + # Skip empty lines after ## UNRELEASED |
| 70 | + if line.strip() == "": |
| 71 | + continue |
| 72 | + content_start = i |
| 73 | + break |
| 74 | + |
| 75 | + return unreleased_start, content_start |
| 76 | + |
| 77 | + |
| 78 | +def update_changelog_version(file_path: Path, new_version: str) -> None: |
| 79 | + """Update changelog for version bump - replaces UNRELEASED with versioned section.""" |
| 80 | + today = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d") |
| 81 | + content = file_path.read_text() |
| 82 | + new_section = f"## UNRELEASED\n\n## {new_version} ({today})" |
| 83 | + content = content.replace("## UNRELEASED", new_section, 1) |
| 84 | + file_path.write_text(content) |
| 85 | + |
| 86 | + |
| 87 | +def update_changelog_pr(file_path: Path, pr_number: str, pr_title: str, pr_url: str) -> bool: |
| 88 | + """Update the changelog with the new PR entry. If entry exists, update it; otherwise add new entry.""" |
| 89 | + # Read the current changelog |
| 90 | + with open(file_path, encoding="utf-8") as f: |
| 91 | + lines = f.readlines() |
| 92 | + |
| 93 | + # Find the UNRELEASED section |
| 94 | + unreleased_start, content_start = find_unreleased_section(lines) |
| 95 | + |
| 96 | + if unreleased_start is None: |
| 97 | + print("ERROR: Could not find '## UNRELEASED' section in changelog") |
| 98 | + return False |
| 99 | + |
| 100 | + if content_start is None: |
| 101 | + print("ERROR: Could not find content start after UNRELEASED section") |
| 102 | + return False |
| 103 | + |
| 104 | + # Create the new entry |
| 105 | + new_entry = f"- {pr_title} [#{pr_number}]({pr_url})\n" |
| 106 | + |
| 107 | + # Check if this PR entry already exists and update it if so |
| 108 | + existing_entry_index = None |
| 109 | + for i, line in enumerate(lines[content_start:], start=content_start): |
| 110 | + if f"[#{pr_number}]" in line: |
| 111 | + existing_entry_index = i |
| 112 | + break |
| 113 | + # Stop checking when we reach the next section |
| 114 | + if line.startswith("## ") and line.strip() != "## UNRELEASED": |
| 115 | + break |
| 116 | + |
| 117 | + if existing_entry_index is not None: |
| 118 | + # Update existing entry |
| 119 | + lines[existing_entry_index] = new_entry |
| 120 | + print(f"Updated changelog entry for PR #{pr_number}: {pr_title}") |
| 121 | + else: |
| 122 | + # Insert the new entry at the beginning of the unreleased content |
| 123 | + lines.insert(content_start, new_entry) |
| 124 | + print(f"Added changelog entry for PR #{pr_number}: {pr_title}") |
| 125 | + |
| 126 | + # Write the updated changelog |
| 127 | + with open(file_path, "w", encoding="utf-8") as f: |
| 128 | + f.writelines(lines) |
| 129 | + |
| 130 | + return True |
| 131 | + |
| 132 | + |
| 133 | +def handle_bump_version(args): |
| 134 | + """Handle version bump subcommand.""" |
| 135 | + part = args.bump_type |
| 136 | + cargo_path = Path("Cargo.toml") |
| 137 | + changelog_path = Path("docs/changelog.md") |
| 138 | + |
| 139 | + if not cargo_path.exists(): |
| 140 | + print("ERROR: Cargo.toml not found.") |
| 141 | + sys.exit(1) |
| 142 | + |
| 143 | + if not changelog_path.exists(): |
| 144 | + print("ERROR: Changelog file not found.") |
| 145 | + sys.exit(1) |
| 146 | + |
| 147 | + cargo_content = cargo_path.read_text() |
| 148 | + version_match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', cargo_content) |
| 149 | + if version_match: |
| 150 | + major, minor, patch = map(int, version_match.groups()) |
| 151 | + else: |
| 152 | + print("Current version not found in cargo.toml.") |
| 153 | + sys.exit(1) |
| 154 | + |
| 155 | + new_version = bump_version(major, minor, patch, part) |
| 156 | + update_cargo_toml(cargo_path, new_version) |
| 157 | + update_changelog_version(changelog_path, new_version) |
| 158 | + print(new_version) |
| 159 | + |
| 160 | + |
| 161 | +def handle_update_changelog(args): |
| 162 | + """Handle update changelog subcommand.""" |
| 163 | + pr_number = args.number |
| 164 | + pr_title = args.title |
| 165 | + |
| 166 | + # Construct PR URL from repository info and PR number |
| 167 | + # Default to the egglog-python repository |
| 168 | + pr_url = f"https://github.com/egraphs-good/egglog-python/pull/{pr_number}" |
| 169 | + |
| 170 | + changelog_path = Path(getattr(args, "changelog_path", "docs/changelog.md")) |
| 171 | + |
| 172 | + if not changelog_path.exists(): |
| 173 | + print(f"ERROR: Changelog file not found: {changelog_path}") |
| 174 | + sys.exit(1) |
| 175 | + |
| 176 | + success = update_changelog_pr(changelog_path, pr_number, pr_title, pr_url) |
| 177 | + if not success: |
| 178 | + sys.exit(1) |
| 179 | + |
| 180 | + |
| 181 | +def main(): |
| 182 | + parser = argparse.ArgumentParser(description="Changelog modifier and version bumper") |
| 183 | + subparsers = parser.add_subparsers(dest="command", help="Available commands") |
| 184 | + |
| 185 | + # Bump version subcommand |
| 186 | + bump_parser = subparsers.add_parser("bump_version", help="Bump version and update changelog") |
| 187 | + bump_parser.add_argument("bump_type", choices=["major", "minor", "patch"], help="Type of version bump") |
| 188 | + |
| 189 | + # Update changelog subcommand |
| 190 | + changelog_parser = subparsers.add_parser("update_changelog", help="Add PR entry to changelog") |
| 191 | + changelog_parser.add_argument("number", help="Pull request number") |
| 192 | + changelog_parser.add_argument("title", help="Pull request title") |
| 193 | + changelog_parser.add_argument("--changelog-path", default="docs/changelog.md", help="Path to changelog file") |
| 194 | + |
| 195 | + args = parser.parse_args() |
| 196 | + |
| 197 | + if not args.command: |
| 198 | + parser.print_help() |
| 199 | + sys.exit(1) |
| 200 | + |
| 201 | + if args.command == "bump_version": |
| 202 | + handle_bump_version(args) |
| 203 | + elif args.command == "update_changelog": |
| 204 | + handle_update_changelog(args) |
| 205 | + |
| 206 | + |
| 207 | +if __name__ == "__main__": |
| 208 | + main() |
0 commit comments