Skip to content

Commit

Permalink
Merge pull request #12 from uclahs-cds/nwiltsie-regression-tests
Browse files Browse the repository at this point in the history
Add regression tests for changelog text manipulation
  • Loading branch information
nwiltsie authored Sep 24, 2024
2 parents 5973bd6 + 0efd76b commit abbe8ec
Show file tree
Hide file tree
Showing 15 changed files with 305 additions and 35 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
fail-fast: false
matrix:
python-version:
- "3.9"
- "3.10"
- "3.12"

steps:
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- Documentation that versions must begin with a digit
- Documentation that git tags must begin with a `v`

### Fixed

- Strip leading `v`s from versions in the CHANGELOG files

## [0.0.2] - 2024-08-19

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

This pair of reusable workflows manage the complexity of creating and tagging new software releases on GitHub.

## Versioning Standards

These workflows make the following assumptions:

* Versions begin with a digit. This applies whether the project is using [semantic versioning](https://semver.org/) or not.
* Git tags associated with a version begin with a `v`.

`1.2.3`, `1.alpha`, and `4.2` are all acceptable versions, and will be tagged in git as `v1.2.3`, `v1.alpha`, and `v4.2` respectively.

`alpha`, `one.two`, and `v1.2.3` are **not** acceptable versions.

## Usage

`wf-prepare-release.yaml` is triggered manually (via a `workflow_dispatch`) and takes the following actions:

1. Compute the target version number based on existing tags and user input for `major`/`minor`/`patch`/`prerelease`.
Expand Down
8 changes: 5 additions & 3 deletions bumpchanges/bump.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
from pathlib import Path

from .changelog import Changelog, ChangelogError
from .logging import setup_logging
from .logging import setup_logging, NOTICE


def update_changelog(
changelog_file: Path, repo_url: str, version: str, date: datetime.date
):
"Rewrite a CHANGELOG file for a new release."
"""Rewrite a CHANGELOG file for a new release."""

try:
changelog = Changelog(changelog_file, repo_url)
Expand Down Expand Up @@ -103,7 +103,9 @@ def entrypoint():
)
tzinfo = datetime.timezone.utc
except KeyError:
logging.getLogger(__name__).notice("No time zone provided, defaulting to UTC")
logging.getLogger(__name__).log(
NOTICE, "No time zone provided, defaulting to UTC"
)
tzinfo = datetime.timezone.utc

now_date = datetime.datetime.now(tzinfo).date()
Expand Down
82 changes: 67 additions & 15 deletions bumpchanges/changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from markdown_it import MarkdownIt
from markdown_it.token import Token

from .logging import NOTICE


class ChangelogError(Exception):
"""Indicate a fundamental problem with the CHANGELOG structure."""
Expand Down Expand Up @@ -54,8 +56,10 @@ def parse_bullet_list(tokens: list[Token]) -> list[Token]:
if nesting == 0:
break

if list_tokens[0].type != "bullet_list_open" or \
list_tokens[-1].type != "bullet_list_close":
if (
list_tokens[0].type != "bullet_list_open"
or list_tokens[-1].type != "bullet_list_close"
):
raise ChangelogError("Bullet list is malformed!")

# Strip off the bullet list so that we can assert our own style and merge
Expand All @@ -78,21 +82,53 @@ def heading(level: int, children: list):

HEADING_REPLACEMENTS = {
"updated": "changed",
"change": "changed",
"add": "added",
"fix": "fixed",
}


@dataclass
class Version:
"""Class to help manage individual releases within CHANGELOG.md files."""

# Regex to match versions with embedded links, with or without dates
# Will match:
# [v1.2.3](https://foo.bar) - 2020-01-01
# [1.2.3](https://foo.bar) - 2020-01-01
# [1.2.3](https://foo.bar)
# [badversion](https://foo.bar)
link_heading_re: ClassVar = re.compile(
r"^\[(?P<version>.+?)\]\((?P<link>.+?)\)(?:\s+-\s+(?P<date>.*))?$"
r"^\[(?P<version>.+?)\]\((?:.+?)\)(?:\s+-\s+(?P<date>.*))?$"
)

# Regex to match versions, with or without dates
# Will match:
# [1.2.3] - 2020-01-01
# [badversion]
# 1.2.3 - 2020-01-01
# 1.2.3
# badversion
heading_re: ClassVar = re.compile(
r"^\[?(?P<version>.+?)\]?(?:\s+-\s+(?P<date>.*))?$"
)

# Regex to match versions with leading `v`s (for removal)
leading_v_re: ClassVar = re.compile(r"^[vV]\d")

# Regex to match H1 version-like headers that should be H2s
# Will match:
# [v1...
# [1....
# Will not match:
# [ver...
wrong_h1_re: ClassVar = re.compile(r"^\[v?\d")

# Regex to match H2 category-like headers taht should be H3s
wrong_h2_re: ClassVar = re.compile(r"Add|Fix|Change|Remove", flags=re.IGNORECASE)

UNRELEASED_VERSION: ClassVar = "Unreleased"

version: str
date: Optional[str] = None
link: Optional[str] = None
Expand All @@ -110,11 +146,15 @@ class Version:
@classmethod
def blank_unreleased(cls):
"""Create a new empty Unreleased version."""
return cls(version="Unreleased")
return cls(version=cls.UNRELEASED_VERSION)

@classmethod
def from_tokens(cls, tokens):
"""Parse a Version from a token stream."""
"""
Parse a Version from a token stream.
Leading `v`s will be stripped from the version name.
"""
# pylint: disable=too-many-branches
# Open, content, close
if (
Expand All @@ -137,6 +177,14 @@ def from_tokens(cls, tokens):

logging.getLogger(__name__).info("Parsed version: %s", kwargs.get("version"))

# Strip any leading `v`s from versions, as long as they are followed by
# a digit
if cls.leading_v_re.match(kwargs["version"]):
logging.getLogger(__name__).warning(
"Stripping leading `v` from Changelog version `%s`", kwargs["version"]
)
kwargs["version"] = kwargs["version"][1:]

# The rest of the tokens should be the lists. Strip any rulers now.
tokens = [token for token in tokens[3:] if token.type != "hr"]

Expand Down Expand Up @@ -284,9 +332,11 @@ def __init__(self, changelog_file: Path, repo_url: str):
if nexttoken is None:
raise ChangelogError()

if re.match(r"^\[\d", nexttoken.content):
if Version.wrong_h1_re.match(nexttoken.content):
token.tag = "h2"
logger.notice("Changing `%s` from h1 to h2", nexttoken.content)
logger.log(
NOTICE, "Changing `%s` from h1 to h2", nexttoken.content
)

if token.tag == "h2":
# A lot of our repositories have an issue where "Added",
Expand All @@ -295,11 +345,11 @@ def __init__(self, changelog_file: Path, repo_url: str):
if nexttoken is None:
raise ChangelogError()

if re.match(
r"Add|Fix|Change|Remove", nexttoken.content, flags=re.IGNORECASE
):
if Version.wrong_h2_re.match(nexttoken.content):
token.tag = "h3"
logger.notice("Changing `%s` from h2 to h3", nexttoken.content)
logger.log(
NOTICE, "Changing `%s` from h2 to h3", nexttoken.content
)
else:
# Split split these tokens off into a new Version
groups.append([])
Expand All @@ -315,9 +365,9 @@ def __init__(self, changelog_file: Path, repo_url: str):

def update_version(self, next_version: str, date: datetime.date):
"""Move all unreleased changes under the new version."""
if not self.versions or self.versions[0].version != "Unreleased":
if not self.versions or self.versions[0].version != Version.UNRELEASED_VERSION:
logging.getLogger(__name__).warning(
"No Unreleased section - adding a new empty section"
"No %s section - adding a new empty section", Version.UNRELEASED_VERSION
)
self.versions.insert(0, Version.blank_unreleased())

Expand Down Expand Up @@ -347,10 +397,12 @@ def render(self) -> str:
prior_tag = None

for version in reversed(self.versions):
if version.version == "Unreleased":
if version.version == Version.UNRELEASED_VERSION:
this_tag = None
else:
this_tag = f"{version.version}"
# _Do_ add leading `v`s. Versions numbers never have
# leading `v`s, tags always have leading `v`s.
this_tag = f"v{version.version.lstrip('v')}"

if prior_tag:
href = f"{self.repo_url}/compare/{prior_tag}...{this_tag if this_tag else 'HEAD'}"
Expand Down
4 changes: 2 additions & 2 deletions bumpchanges/getversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import semver

from .logging import setup_logging
from .logging import setup_logging, NOTICE


def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str:
Expand Down Expand Up @@ -49,7 +49,7 @@ def get_next_version(repo_dir: Path, bump_type: str, exact_version: str) -> str:

logger.info("%s -> %s -> %s", last_version, bump_type, next_version)
next_tag = f"v{next_version}"
logger.notice("New version (tag): %s (%s)", next_version, next_tag)
logger.log(NOTICE, "New version (tag): %s (%s)", next_version, next_tag)

# Confirm that the corresponding git tag does not exist
tag_ref_proc = subprocess.run(
Expand Down
10 changes: 0 additions & 10 deletions bumpchanges/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,6 @@
NOTICE = 25


class NoticeLogger(logging.getLoggerClass()):
"""A logger subclass that has an additional NOTICE level."""

def notice(self, msg, *args, **kwargs):
"""Log the message at NOTICE level."""
self.log(NOTICE, msg, *args, **kwargs)


class GHAFilter(logging.Filter):
"""A logging filter that plays nice with GitHub Actions output."""

Expand Down Expand Up @@ -41,8 +33,6 @@ def setup_logging():

logging.addLevelName(NOTICE, "NOTICE")

logging.setLoggerClass(NoticeLogger)

handler = logging.StreamHandler()
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter("%(ghaprefix)s%(message)s"))
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ dynamic = ["version"]

keywords = ["changelog", "ci"]

requires-python = ">=3.9"
requires-python = ">=3.10"

dependencies = [
"linkify-it-py>=2.0.3",
Expand Down Expand Up @@ -38,7 +38,7 @@ version-file = "bumpchanges/_version.py"
legacy_tox_ini = """
[tox]
env_list =
py3.9
py3.10
py3.12
[testenv]
Expand Down
60 changes: 60 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Local plugin to parametrize tests from a JSON file."""

import json
import datetime

from collections import namedtuple
from pathlib import Path

import pytest


ChangelogUpdate = namedtuple(
"ChangelogUpdate", ("original", "version", "expected", "url", "date")
)

# Named stash keys for storing the ChangelogUpdate objects between hook calls
changelog_updates_key = pytest.StashKey[list[ChangelogUpdate]]()


def pytest_configure(config: pytest.Config) -> None:
"""Configure plugin by loading the Changelog data."""
resource_path = Path(__file__).resolve().parent.joinpath("resources")
changelogs_file = resource_path / "changelogs.json"
with changelogs_file.open(mode="r", encoding="utf-8") as infile:
changelog_groups = json.load(infile)

updates = []
for group in changelog_groups:
date = datetime.date.fromisoformat(group["date"])

updates.append(
ChangelogUpdate(
resource_path / group["original"],
None,
resource_path / group["formatted"],
group["url"],
date,
)
)

for version, expected in group.get("bumps", {}).items():
updates.append(
ChangelogUpdate(
resource_path / group["original"],
version,
resource_path / expected,
group["url"],
date,
)
)

config.stash[changelog_updates_key] = updates


def pytest_generate_tests(metafunc: pytest.Metafunc):
"""Inject parameters for the 'changelog_update' fixture."""
if "changelog_update" in metafunc.fixturenames:
metafunc.parametrize(
"changelog_update", metafunc.config.stash[changelog_updates_key]
)
36 changes: 36 additions & 0 deletions tests/resources/2.5.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Changelog

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.5.3] - 2024-05-05

### Security

- Security item

## [2.5.1] - 2024-02-01

### Added

- Add one
- Add two

### Fixed

- Fix one
- Fix two

## [2.5.0] - 2024-01-01

Stray text

### Changed

- Change one
- Change two

[2.5.0]: https://github.com/foo/bar/releases/tag/v2.5.0
[2.5.1]: https://github.com/foo/bar/compare/v2.5.0...v2.5.1
[2.5.3]: https://github.com/foo/bar/compare/v2.5.1...v2.5.3
Loading

0 comments on commit abbe8ec

Please sign in to comment.