|
| 1 | +"""Update `CHANGELOG.md` based on commits since the latest release tag. |
| 2 | + |
| 3 | +Commit type conventions: https://mbercx.github.io/python-copier/dev-standards/#commit-messages |
| 4 | +""" |
| 5 | + |
| 6 | +# ruff: noqa: S603, S607 |
| 7 | + |
| 8 | +import re |
| 9 | +import subprocess |
| 10 | +from pathlib import Path |
| 11 | + |
| 12 | +from {{ package_name.lower().replace('-', '_') }}.__about__ import __version__ |
| 13 | + |
| 14 | +ROOT = Path(__file__).resolve().parent.parent |
| 15 | +GIT_REMOTE = "origin" |
| 16 | + |
| 17 | +CHANGELOG_SECTIONS: dict[str, str] = { |
| 18 | + "💥": "Breaking changes", |
| 19 | + "📦": "Dependency updates", |
| 20 | + "❌": "Deprecations", |
| 21 | + "✨": "New features", |
| 22 | + "👌": "Improvements", |
| 23 | + "🐛": "Bug fixes", |
| 24 | +} |
| 25 | + |
| 26 | +DEVELOPER_SECTIONS: dict[str, str] = { |
| 27 | + "📚": "Documentation", |
| 28 | + "🔄": "Refactor", |
| 29 | + "🧪": "Tests", |
| 30 | + "⏪": "Reverts", |
| 31 | + "🔧": "DevOps", |
| 32 | + "🧹": "Cleanup", |
| 33 | +} |
| 34 | + |
| 35 | +ALL_SECTIONS = CHANGELOG_SECTIONS | DEVELOPER_SECTIONS |
| 36 | + |
| 37 | +EXCLUDED_EMOJIS: set[str] = {"🚀", "🐭", "❓"} |
| 38 | + |
| 39 | + |
| 40 | +def get_github_url() -> str | None: |
| 41 | + """Derive `https://github.com/org/repo` from the git remote origin, or `None`.""" |
| 42 | + try: |
| 43 | + url = subprocess.run( |
| 44 | + ["git", "remote", "get-url", GIT_REMOTE], |
| 45 | + capture_output=True, check=True, encoding="utf-8", cwd=ROOT, |
| 46 | + ).stdout.strip() |
| 47 | + except subprocess.CalledProcessError: |
| 48 | + return None |
| 49 | + |
| 50 | + match = re.match(r"(?:https://github\.com/|git@github\.com:)(.+?)(?:\.git)?$", url) |
| 51 | + return f"https://github.com/{match.group(1)}" if match else None |
| 52 | + |
| 53 | + |
| 54 | +def get_latest_tag() -> str | None: |
| 55 | + """Return the latest `vX.Y.Z` tag, or `None` if no tags exist.""" |
| 56 | + result = subprocess.run( |
| 57 | + ["git", "tag", "--sort=v:refname"], |
| 58 | + capture_output=True, check=True, encoding="utf-8", cwd=ROOT, |
| 59 | + ) |
| 60 | + tags = [t for t in result.stdout.splitlines() if re.fullmatch(r"v\d+\.\d+\.\d+\S*", t)] |
| 61 | + return tags[-1] if tags else None |
| 62 | + |
| 63 | + |
| 64 | +def get_commits(since_tag: str | None) -> str: |
| 65 | + """Return the `git log` output since the given tag, or all commits if `None`.""" |
| 66 | + cmd = ["git", "log", "--pretty=format:%h|%H|%s"] |
| 67 | + if since_tag: |
| 68 | + cmd.append(f"{since_tag}..HEAD") |
| 69 | + return subprocess.run( |
| 70 | + cmd, capture_output=True, check=True, encoding="utf-8", cwd=ROOT, |
| 71 | + ).stdout |
| 72 | + |
| 73 | + |
| 74 | +def classify_commit(message: str) -> tuple[str | None, str]: |
| 75 | + """Return `(emoji, stripped_message)` or `(None, message)` if not a changelog type.""" |
| 76 | + for emoji in ALL_SECTIONS: |
| 77 | + if message.startswith(emoji): |
| 78 | + return emoji, message[len(emoji):].lstrip() |
| 79 | + return None, message |
| 80 | + |
| 81 | + |
| 82 | +def update_changelog() -> None: |
| 83 | + """Update `CHANGELOG.md` for a first draft of the release.""" |
| 84 | + version = __version__ |
| 85 | + |
| 86 | + changelog_path = ROOT / "CHANGELOG.md" |
| 87 | + current = changelog_path.read_text(encoding="utf-8") if changelog_path.exists() else "" |
| 88 | + |
| 89 | + if f"## v{version}" in current: |
| 90 | + print(f"🔄 Version v{version} already in CHANGELOG.md. Skipping.") |
| 91 | + return |
| 92 | + |
| 93 | + github_url = get_github_url() |
| 94 | + if github_url is None: |
| 95 | + print(f"⚠️ Could not derive GitHub URL from remote '{GIT_REMOTE}'. Commit links will use plain hashes.") |
| 96 | + |
| 97 | + latest_tag = get_latest_tag() |
| 98 | + commits_raw = get_commits(latest_tag) |
| 99 | + |
| 100 | + if not commits_raw.strip(): |
| 101 | + print("🤷 No commits found since last tag. Skipping.") |
| 102 | + return |
| 103 | + |
| 104 | + pr_pattern = re.compile(r"\s*\(#\d+\)$") |
| 105 | + |
| 106 | + sections: dict[str, list[str]] = {emoji: [] for emoji in ALL_SECTIONS} |
| 107 | + uncategorized: list[str] = [] |
| 108 | + |
| 109 | + for line in commits_raw.splitlines(): |
| 110 | + if not line: |
| 111 | + continue |
| 112 | + hash_short, hash_long, message = line.split("|", maxsplit=2) |
| 113 | + |
| 114 | + # Strip PR number from the message |
| 115 | + message = pr_pattern.sub("", message) |
| 116 | + |
| 117 | + # Classify by leading emoji |
| 118 | + emoji, stripped_msg = classify_commit(message) |
| 119 | + |
| 120 | + if emoji is None and any(message.startswith(e) for e in EXCLUDED_EMOJIS): |
| 121 | + continue |
| 122 | + |
| 123 | + if github_url: |
| 124 | + entry = f"* {stripped_msg} [[{hash_short}]({github_url}/commit/{hash_long})]" |
| 125 | + else: |
| 126 | + entry = f"* {stripped_msg} [{hash_short}]" |
| 127 | + |
| 128 | + if emoji is None: |
| 129 | + uncategorized.append(entry) |
| 130 | + print(f"⚠️ Uncategorized commit: {hash_short} {message}") |
| 131 | + else: |
| 132 | + sections[emoji].append(entry) |
| 133 | + |
| 134 | + # Build changelog: uncategorized first to improve visibility |
| 135 | + section_text = "" |
| 136 | + if uncategorized: |
| 137 | + section_text += "\n### ❓ Uncategorized\n\n" |
| 138 | + section_text += "\n".join(uncategorized) + "\n" |
| 139 | + |
| 140 | + # Main changelog sections -> User oriented |
| 141 | + for emoji, section_name in CHANGELOG_SECTIONS.items(): |
| 142 | + if sections[emoji]: |
| 143 | + section_text += f"\n### {emoji} {section_name}\n\n" |
| 144 | + section_text += "\n".join(sections[emoji]) + "\n" |
| 145 | + |
| 146 | + # Developer section with H4 subsections |
| 147 | + dev_text = "" |
| 148 | + for emoji, section_name in DEVELOPER_SECTIONS.items(): |
| 149 | + if sections[emoji]: |
| 150 | + dev_text += f"\n#### {emoji} {section_name}\n\n" |
| 151 | + dev_text += "\n".join(sections[emoji]) + "\n" |
| 152 | + |
| 153 | + if dev_text: |
| 154 | + section_text += f"\n### Developer\n{dev_text}" |
| 155 | + |
| 156 | + header = "# Changelog\n\n" |
| 157 | + body = current.removeprefix("# Changelog").lstrip("\n") |
| 158 | + new_entry = f"## v{version}\n{section_text}" |
| 159 | + changelog_path.write_text(header + new_entry + "\n" + body, encoding="utf-8") |
| 160 | + print(f"✨ Updated CHANGELOG.md for v{version}.") |
| 161 | + |
| 162 | + |
| 163 | +if __name__ == "__main__": |
| 164 | + update_changelog() |
0 commit comments