Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ inputs:
changelog_ignore:
description: Labels for issues and pull requests to be ignored (comma-delimited)
required: false
changelog_format:
description: "Changelog format: custom (default), github (auto-generated), or conventional (conventional commits)"
required: false
default: custom
runs:
using: docker
image: docker://ghcr.io/juliaregistries/tagbot:1.23.5
Expand Down
11 changes: 11 additions & 0 deletions tagbot/action/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ def get_input(key: str, default: str = "") -> str:
else:
ignore = Changelog.DEFAULT_IGNORE

changelog_format = get_input("changelog_format", "custom").lower()
# Validate changelog_format
valid_formats = ["custom", "github", "conventional"]
if changelog_format not in valid_formats:
logger.warning(
f"Invalid changelog_format '{changelog_format}', using 'custom'. "
f"Valid formats: {', '.join(valid_formats)}"
)
changelog_format = "custom"

repo = Repo(
repo=os.getenv("GITHUB_REPOSITORY", ""),
registry=get_input("registry"),
Expand All @@ -57,6 +67,7 @@ def get_input(key: str, default: str = "") -> str:
token=token,
changelog=get_input("changelog"),
changelog_ignore=ignore,
changelog_format=changelog_format,
ssh=bool(ssh),
gpg=bool(gpg),
draft=get_input("draft").lower() in ["true", "yes"],
Expand Down
151 changes: 149 additions & 2 deletions tagbot/action/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def __init__(
token: str,
changelog: str,
changelog_ignore: List[str],
changelog_format: str,
ssh: bool,
gpg: bool,
draft: bool,
Expand Down Expand Up @@ -190,7 +191,13 @@ def __init__(
self._clone_registry = False
self._token = token
self.__versions_toml_cache: Optional[Dict[str, Any]] = None
self._changelog = Changelog(self, changelog, changelog_ignore)
self._changelog_format = changelog_format
# Only initialize Changelog if using custom format
self._changelog = (
None
if changelog_format in ["github", "conventional"]
else Changelog(self, changelog, changelog_ignore)
)
self._ssh = ssh
self._gpg = gpg
self._draft = draft
Expand Down Expand Up @@ -983,6 +990,122 @@ def _tag_exists(self, version: str) -> bool:
# If we can't check, assume it doesn't exist
return False

def _generate_conventional_changelog(
self, version_tag: str, sha: str, previous_tag: Optional[str] = None
) -> str:
"""Generate changelog from conventional commits.

Args:
version_tag: The version tag being released
sha: Commit SHA for the release
previous_tag: Previous release tag to generate changelog from

Returns:
Formatted changelog based on conventional commits
"""
# Determine commit range
if previous_tag:
commit_range = f"{previous_tag}..{sha}"
else:
# For first release, get all commits up to this one
commit_range = sha

# Get commit messages
try:
log_output = self._git.command(
"log",
commit_range,
"--format=%s|%h|%an",
"--no-merges",
)
except Exception as e:
logger.warning(f"Could not get commits for conventional changelog: {e}")
return f"## {version_tag}\n\nRelease created.\n"

# Parse commits into categories based on conventional commit format
# Format: type(scope): description
categories = {
"breaking": [], # BREAKING CHANGE or !
"feat": [], # Features
"fix": [], # Bug fixes
"perf": [], # Performance improvements
"refactor": [], # Refactoring
"docs": [], # Documentation
"test": [], # Tests
"build": [], # Build system
"ci": [], # CI/CD
"chore": [], # Chores
"style": [], # Code style
"revert": [], # Reverts
"other": [], # Non-conventional commits
}

for line in log_output.strip().split("\n"):
if not line:
continue
parts = line.split("|")
if len(parts) != 3:
continue
message, commit_hash, author = parts

# Check for breaking change
if "BREAKING CHANGE" in message or "!:" in message:
categories["breaking"].append((message, commit_hash, author))
continue

# Parse conventional commit format
match = re.match(r"^(\w+)(\(.+\))?: (.+)$", message)
if match:
commit_type = match.group(1).lower()
description = match.group(3)
if commit_type in categories:
categories[commit_type].append((message, commit_hash, author))
else:
categories["other"].append((message, commit_hash, author))
else:
categories["other"].append((message, commit_hash, author))

# Build changelog
changelog = f"## {version_tag}\n\n"

# Section configurations (type: title)
sections = [
("breaking", "Breaking Changes"),
("feat", "Features"),
("fix", "Bug Fixes"),
("perf", "Performance Improvements"),
("refactor", "Code Refactoring"),
("docs", "Documentation"),
("test", "Tests"),
("build", "Build System"),
("ci", "CI/CD"),
("style", "Code Style"),
("chore", "Chores"),
("revert", "Reverts"),
]

for cat_key, title in sections:
commits = categories[cat_key]
if commits:
changelog += f"### {title}\n\n"
for message, commit_hash, author in commits:
changelog += f"- {message} ([`{commit_hash}`](../../commit/{commit_hash})) - @{author}\n"
changelog += "\n"

# Add other commits if any
if categories["other"]:
changelog += "### Other Changes\n\n"
for message, commit_hash, author in categories["other"]:
changelog += f"- {message} ([`{commit_hash}`](../../commit/{commit_hash})) - @{author}\n"
changelog += "\n"

# Add compare link if we have a previous tag
if previous_tag:
repo_url = f"{self._gh_url}/{self._repo.full_name}"
changelog += f"**Full Changelog**: {repo_url}/compare/{previous_tag}...{version_tag}\n"

return changelog

def create_issue_for_manual_tag(self, failures: list[tuple[str, str, str]]) -> None:
"""Create an issue requesting manual intervention for failed releases.

Expand Down Expand Up @@ -1295,7 +1418,30 @@ def create_release(self, version: str, sha: str, is_latest: bool = True) -> None
return
except GithubException as e:
logger.warning(f"Could not check for existing releases: {e}")
log = self._changelog.get(version_tag, sha)

# Generate release notes based on format
if self._changelog_format == "github":
log = "" # Empty body triggers GitHub to auto-generate notes
logger.info("Using GitHub auto-generated release notes")
elif self._changelog_format == "conventional":
# Find previous release for conventional changelog
previous_tag = None
try:
releases = list(self._repo.get_releases())
if releases:
# Find the most recent release before this one
for release in releases:
if release.tag_name != version_tag:
previous_tag = release.tag_name
break
except Exception as e:
logger.debug(f"Could not fetch previous releases: {e}")

logger.info("Generating conventional commits changelog")
log = self._generate_conventional_changelog(version_tag, sha, previous_tag)
else: # custom format
log = self._changelog.get(version_tag, sha)

if not self._draft:
# Always create tags via the CLI as the GitHub API has a bug which
# only allows tags to be created for SHAs which are the the HEAD
Expand All @@ -1313,6 +1459,7 @@ def create_release(self, version: str, sha: str, is_latest: bool = True) -> None
target_commitish=target,
draft=self._draft,
make_latest=make_latest_str,
generate_release_notes=(self._changelog_format == "github"),
)
logger.info(f"GitHub release {version_tag} created successfully")

Expand Down
1 change: 1 addition & 0 deletions tagbot/local/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def main(
token=token,
changelog=changelog,
changelog_ignore=[],
changelog_format="custom",
ssh=False,
gpg=False,
draft=draft,
Expand Down
147 changes: 147 additions & 0 deletions test/action/test_auto_changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Tests for auto-generated changelog feature (issue #216)."""

from unittest.mock import Mock, patch

import pytest

from tagbot.action.repo import Repo


def _repo(
*,
repo="",
registry="",
github="",
github_api="",
token="x",
changelog="",
ignore=None,
auto_changelog=False,
ssh=False,
gpg=False,
draft=False,
registry_ssh="",
user="",
email="",
branch=None,
subdir=None,
tag_prefix=None,
):
return Repo(
repo=repo,
registry=registry,
github=github,
github_api=github_api,
token=token,
changelog=changelog,
changelog_ignore=ignore if ignore is not None else [],
auto_changelog=auto_changelog,
ssh=ssh,
gpg=gpg,
draft=draft,
registry_ssh=registry_ssh,
user=user,
email=email,
branch=branch,
subdir=subdir,
tag_prefix=tag_prefix,
)


@patch("tagbot.action.repo.Github")
def test_auto_changelog_disabled_by_default(mock_github):
"""Test that auto_changelog is False by default."""
mock_gh_instance = Mock()
mock_github.return_value = mock_gh_instance
mock_gh_instance.get_repo.return_value = Mock()

r = _repo(repo="test/repo", registry="test/registry")
assert r._auto_changelog is False
assert r._changelog is not None


@patch("tagbot.action.repo.Github")
def test_auto_changelog_enabled(mock_github):
"""Test that auto_changelog can be enabled."""
mock_gh_instance = Mock()
mock_github.return_value = mock_gh_instance
mock_gh_instance.get_repo.return_value = Mock()

r = _repo(repo="test/repo", registry="test/registry", auto_changelog=True)
assert r._auto_changelog is True
assert r._changelog is None


@patch("tagbot.action.repo.Github")
def test_create_release_with_auto_changelog(mock_github, logger):
"""Test that create_release uses GitHub's auto-generated notes when enabled."""
mock_gh_instance = Mock()
mock_github.return_value = mock_gh_instance

mock_repo = Mock()
mock_gh_instance.get_repo.return_value = mock_repo
mock_repo.get_releases.return_value = []

r = _repo(repo="test/repo", registry="test/registry", auto_changelog=True)
r._git = Mock()
r._git.create_tag = Mock()

r.create_release("v1.0.0", "abc123")

# Verify create_git_release was called with generate_release_notes=True
mock_repo.create_git_release.assert_called_once()
call_args = mock_repo.create_git_release.call_args
assert call_args.kwargs["generate_release_notes"] is True
# Body should be empty when using auto-generated notes
assert call_args.args[2] == ""


@patch("tagbot.action.repo.Github")
def test_create_release_with_custom_changelog(mock_github, logger):
"""Test that create_release uses custom changelog when auto_changelog is False."""
mock_gh_instance = Mock()
mock_github.return_value = mock_gh_instance

mock_repo = Mock()
mock_gh_instance.get_repo.return_value = mock_repo
mock_repo.get_releases.return_value = []

r = _repo(repo="test/repo", registry="test/registry", auto_changelog=False)
r._git = Mock()
r._git.create_tag = Mock()

# Mock the changelog generation
r._changelog = Mock()
r._changelog.get = Mock(return_value="Custom changelog content")

r.create_release("v1.0.0", "abc123")

# Verify changelog.get was called
r._changelog.get.assert_called_once_with("v1.0.0", "abc123")

# Verify create_git_release was called with generate_release_notes=False
mock_repo.create_git_release.assert_called_once()
call_args = mock_repo.create_git_release.call_args
assert call_args.kwargs["generate_release_notes"] is False
# Body should contain custom changelog
assert call_args.args[2] == "Custom changelog content"


@patch("tagbot.action.repo.Github")
def test_auto_changelog_logging(mock_github, logger, caplog):
"""Test that auto_changelog logs appropriate message."""
mock_gh_instance = Mock()
mock_github.return_value = mock_gh_instance

mock_repo = Mock()
mock_gh_instance.get_repo.return_value = mock_repo
mock_repo.get_releases.return_value = []

r = _repo(repo="test/repo", registry="test/registry", auto_changelog=True)
r._git = Mock()
r._git.create_tag = Mock()

r.create_release("v1.0.0", "abc123")

# Check that the log message about auto-generated notes was logged
assert "Using GitHub auto-generated release notes" in caplog.text
Loading
Loading