Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

test: changelog merges & squashes #902

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
155 changes: 96 additions & 59 deletions semantic_release/changelog/release_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,24 @@
import logging
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, TypedDict
from typing import TYPE_CHECKING

from git.objects.tag import TagObject
from git.util import Actor
from pydantic import BaseModel, ConfigDict

from semantic_release.commit_parser import ParseError
from semantic_release.commit_parser import ParseError, ParseResult
from semantic_release.errors import InvalidVersion
from semantic_release.version.algorithm import tags_and_versions

if TYPE_CHECKING:
from re import Pattern
from typing import Iterable, Iterator

from git import Tag
from git.repo.base import Repo
from git.util import Actor

from semantic_release.commit_parser import (
CommitParser,
ParseResult,
ParserOptions,
)
from semantic_release.commit_parser import CommitParser, ParserOptions
from semantic_release.version.translator import VersionTranslator
from semantic_release.version.version import Version

Expand All @@ -41,6 +40,13 @@ def from_git_history(
unreleased: dict[str, list[ParseResult]] = defaultdict(list)
released: dict[Version, Release] = {}

# Performance optimization: create a mapping of tag sha to version
# so we can quickly look up the version for a given commit based on sha
tag_sha_2_version_lookup = {
tag.commit.hexsha: (tag, version)
for tag, version in all_git_tags_and_versions
}

# Strategy:
# Loop through commits in history, parsing as we go.
# Add these commits to `unreleased` as a key-value mapping
Expand All @@ -54,7 +60,7 @@ def from_git_history(
is_commit_released = False
the_version: Version | None = None

for commit in repo.iter_commits():
for commit in repo.iter_commits("HEAD", topo_order=True):
# mypy will be happy if we make this an explicit string
commit_message = str(commit.message)

Expand All @@ -64,46 +70,21 @@ def from_git_history(
)
log.debug("commit has type %s", commit_type)

for tag, version in all_git_tags_and_versions:
if tag.commit == commit:
# we have found the latest commit introduced by this tag
# so we create a new Release entry
log.debug("found commit %s for tag %s", commit.hexsha, tag.name)
is_commit_released = True
the_version = version

# tag.object is a Commit if the tag is lightweight, otherwise
# it is a TagObject with additional metadata about the tag
if isinstance(tag.object, TagObject):
tagger = tag.object.tagger
committer = tag.object.tagger.committer()
_tz = timezone(
timedelta(seconds=-1 * tag.object.tagger_tz_offset)
)
tagged_date = datetime.fromtimestamp(
tag.object.tagged_date, tz=_tz
)
else:
# For some reason, sometimes tag.object is a Commit
tagger = tag.object.author
committer = tag.object.author
_tz = timezone(
timedelta(seconds=-1 * tag.object.author_tz_offset)
)
tagged_date = datetime.fromtimestamp(
tag.object.committed_date, tz=_tz
)

release = Release(
tagger=tagger,
committer=committer,
tagged_date=tagged_date,
elements=defaultdict(list),
version=the_version,
)

released.setdefault(the_version, release)
break
log.debug("checking if commit %s matches any tags", commit.hexsha)
t_v = tag_sha_2_version_lookup.get(commit.hexsha, None)

if t_v is None:
log.debug("no tags correspond to commit %s", commit.hexsha)
else:
# Unpack the tuple (overriding the current version)
tag, the_version = t_v
# we have found the latest commit introduced by this tag
# so we create a new Release entry
log.debug("found commit %s for tag %s", commit.hexsha, tag.name)
is_commit_released = True

release = Release.from_git_tag(tag, translator)
released.setdefault(the_version, release)

if any(pat.match(commit_message) for pat in exclude_commit_patterns):
log.debug(
Expand All @@ -128,7 +109,7 @@ def from_git_history(
the_version,
)

released[the_version]["elements"][commit_type].append(parse_result)
released[the_version].elements[commit_type].append(parse_result)

return cls(unreleased=unreleased, released=released)

Expand Down Expand Up @@ -161,13 +142,15 @@ def release(
return ReleaseHistory(
unreleased={},
released={
version: {
"tagger": tagger,
"committer": committer,
"tagged_date": tagged_date,
"elements": self.unreleased,
"version": version,
},
version: Release.model_validate(
{
"tagger": tagger,
"committer": committer,
"tagged_date": tagged_date,
"elements": self.unreleased,
"version": version,
}
),
**self.released,
},
)
Expand All @@ -180,9 +163,63 @@ def __repr__(self) -> str:
)


class Release(TypedDict):
class Release(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)

tagger: Actor
committer: Actor
tagged_date: datetime
elements: dict[str, list[ParseResult]]
elements: defaultdict[str, list[ParseResult]]
version: Version

@staticmethod
def from_git_tag(tag: Tag, translator: VersionTranslator) -> Release:
"""

Raises
------
InvalidVersion: If the tag name does not match the tag format

"""
version = translator.from_tag(tag.name)
if version is None:
raise InvalidVersion(f"Tag {tag.name} does not match the tag format")

# Common Args
release_args = {
"elements": defaultdict(list),
"version": version,
}

# tag.object is a Commit if the tag is lightweight, otherwise
# it is a TagObject with additional metadata about the tag
if not isinstance(tag.object, TagObject):
# Grab details from lightweight tag
release_args.update(
{
"committer": tag.object.author,
"tagger": tag.object.author,
"tagged_date": datetime.fromtimestamp(
tag.object.committed_date,
tz=timezone(
timedelta(seconds=-1 * tag.object.author_tz_offset)
),
),
}
)
else:
# Grab details from annotated tag
release_args.update(
{
"committer": tag.object.tagger.committer(),
"tagger": tag.object.tagger,
"tagged_date": datetime.fromtimestamp(
tag.object.tagged_date,
tz=timezone(
timedelta(seconds=-1 * tag.object.tagger_tz_offset)
),
),
}
)

return Release(**release_args)
9 changes: 5 additions & 4 deletions semantic_release/commit_parser/token.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from __future__ import annotations

from typing import TYPE_CHECKING, NamedTuple, NoReturn, TypeVar, Union
from typing import TYPE_CHECKING, NamedTuple, TypeVar, Union

from git.objects.commit import Commit

from semantic_release.enums import LevelBump
from semantic_release.errors import CommitParseError

if TYPE_CHECKING:
from git.objects.commit import Commit

from semantic_release.enums import LevelBump
from typing import NoReturn


class ParsedCommit(NamedTuple):
Expand Down
8 changes: 8 additions & 0 deletions tests/command_line/test_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
MAIN_PROG_NAME,
)
from tests.fixtures.repos import (
repo_w_github_flow_w_default_release_channel_angular_commits,
repo_w_github_flow_w_default_release_channel_emoji_commits,
repo_w_github_flow_w_default_release_channel_scipy_commits,
repo_w_github_flow_w_default_release_channel_tag_commits,
repo_w_github_flow_w_feature_release_channel_angular_commits,
repo_w_github_flow_w_feature_release_channel_emoji_commits,
repo_w_github_flow_w_feature_release_channel_scipy_commits,
Expand Down Expand Up @@ -136,7 +140,7 @@
differing_files = flatten_dircmp(dcmp)

# Evaluate
assert_successful_exit_code(result, cli_cmd)

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.10 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_angular_commits-v0.1.1] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.11 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_angular_commits-v0.1.1] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.10 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_and_prereleases_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.11 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_and_prereleases_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.10 tests

test_changelog_noop_is_noop[None-repo_w_github_flow_w_feature_release_channel_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.11 tests

test_changelog_noop_is_noop[None-repo_w_github_flow_w_feature_release_channel_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.10 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_angular_commits-v1.0.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.10 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_and_release_channels_angular_commits-v1.1.0-alpha.3] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.11 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_angular_commits-v1.0.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.11 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_and_release_channels_angular_commits-v1.1.0-alpha.3] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.9 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_angular_commits-v0.1.1] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.9 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_and_prereleases_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.9 tests

test_changelog_noop_is_noop[None-repo_w_github_flow_w_feature_release_channel_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.9 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_angular_commits-v1.0.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.9 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_and_release_channels_angular_commits-v1.1.0-alpha.3] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.12 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_angular_commits-v0.1.1] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.12 tests

test_changelog_noop_is_noop[None-repo_with_single_branch_and_prereleases_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.12 tests

test_changelog_noop_is_noop[None-repo_w_github_flow_w_feature_release_channel_angular_commits-v0.2.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.12 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_angular_commits-v1.0.0] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied

Check failure on line 143 in tests/command_line/test_changelog.py

View workflow job for this annotation

GitHub Actions / Python 3.12 tests

test_changelog_noop_is_noop[None-repo_with_git_flow_and_release_channels_angular_commits-v1.1.0-alpha.3] AssertionError: 1 != 0 (actual != expected) Unexpected exit code from command: 'semantic-release --noop changelog' stdout: stderr: 🛡 You are running in no-operation mode, because the '--noop' flag was supplied
assert not differing_files
if args:
assert not mocker.called
Expand All @@ -160,6 +164,10 @@
repo_with_single_branch_and_prereleases_emoji_commits.__name__,
repo_with_single_branch_and_prereleases_scipy_commits.__name__,
repo_with_single_branch_and_prereleases_tag_commits.__name__,
repo_w_github_flow_w_default_release_channel_angular_commits.__name__,
repo_w_github_flow_w_default_release_channel_emoji_commits.__name__,
repo_w_github_flow_w_default_release_channel_scipy_commits.__name__,
repo_w_github_flow_w_default_release_channel_tag_commits.__name__,
repo_w_github_flow_w_feature_release_channel_angular_commits.__name__,
repo_w_github_flow_w_feature_release_channel_emoji_commits.__name__,
repo_w_github_flow_w_feature_release_channel_scipy_commits.__name__,
Expand Down
36 changes: 35 additions & 1 deletion tests/fixtures/git_repo.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from functools import reduce
from pathlib import Path
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -50,6 +51,11 @@ class RepoVersionDef(TypedDict):
changelog_sections: list[ChangelogTypeHeadingDef]
commits: list[CommitMsg]

class BaseAccumulatorVersionReduction(TypedDict):
limit_value: str
limit_found: bool
repo_def: dict[VersionStr, RepoVersionDef]

class ChangelogTypeHeadingDef(TypedDict):
section: ChangelogTypeHeading
i_commits: list[int]
Expand Down Expand Up @@ -107,6 +113,7 @@ def __call__(
self,
repo_definition: RepoDefinition,
dest_file: Path | None = None,
max_version: str | None = None,
) -> str: ...


Expand Down Expand Up @@ -326,6 +333,18 @@ def _build_configured_base_repo( # noqa: C901

@pytest.fixture(scope="session")
def simulate_default_changelog_creation() -> SimulateDefaultChangelogCreationFn:
def reduce_repo_def(
acc: BaseAccumulatorVersionReduction, ver_2_def: tuple[str, RepoVersionDef]
) -> BaseAccumulatorVersionReduction:
if acc["limit_found"]:
return acc

if ver_2_def[0] == acc["limit_value"]:
acc["limit_found"] = True

acc["repo_def"][ver_2_def[0]] = ver_2_def[1]
return acc

def build_version_entry(version: VersionStr, version_def: RepoVersionDef) -> str:
version_entry = []
if version == "Unreleased":
Expand All @@ -344,11 +363,26 @@ def build_version_entry(version: VersionStr, version_def: RepoVersionDef) -> str
def _mimic_semantic_release_default_changelog(
repo_definition: RepoDefinition,
dest_file: Path | None = None,
max_version: str | None = None,
) -> str:
header = "# CHANGELOG"
version_entries = []

for version, version_def in repo_definition.items():
repo_def = (
repo_definition
if max_version is None
else reduce(
reduce_repo_def,
repo_definition.items(),
{
"limit_value": max_version,
"limit_found": False,
"repo_def": {},
},
)["repo_def"]
)

for version, version_def in repo_def.items():
# prepend entries to force reverse ordering
version_entries.insert(0, build_version_entry(version, version_def))

Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/repos/github_flow/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from tests.fixtures.repos.github_flow.repo_w_default_release import *
from tests.fixtures.repos.github_flow.repo_w_release_channels import *
Loading
Loading