From 33b75421d5c8896c80661fea27a9296b44529d2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 14:09:20 +0000 Subject: [PATCH 01/20] feat: add a changelog package Lifts `parse_release_notes`, `format_release_notes`, `format_changes` and the commit-type-to-category mapping out of `canonical/operator`'s `release.py`, which is being replaced by three workflows. The formatting is the part that isn't specific to one repository; the version arithmetic, the file rewriting and the GitHub calls stay behind in operator. The one behaviour change while lifting is that `format_changes` takes the date as an argument rather than calling `datetime.datetime.now()` itself, which is what made it impossible to assert on. The package does no I/O at all now, so a conftest fixture blocks the clock to keep it that way. The tests use two real operator releases as fixtures, reconstructed from the repository's own history: 3.8.2 for the ordinary case (eleven of its twenty-three pull requests are `chore`, and none of them reach the changelog) and a trimmed 3.8.0 for the one breaking change merged into a 3.x release. There's no console script, because what a command line should look like depends on the workflow that calls it, and that workflow hasn't been written yet. Refs canonical/operator#2224 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DC6P7uEu7Qw7kh2tDBuJyB --- .github/workflows/ci.yaml | 2 +- README.md | 1 + changelog/README.md | 46 ++ changelog/pyproject.toml | 34 ++ .../src/charm_tech_code/changelog/__init__.py | 43 ++ .../charm_tech_code/changelog/_constants.py | 98 ++++ .../src/charm_tech_code/changelog/_format.py | 104 ++++ .../src/charm_tech_code/changelog/_parse.py | 74 +++ changelog/tests/conftest.py | 53 ++ changelog/tests/test_changelog.py | 492 ++++++++++++++++++ changelog/uv.lock | 156 ++++++ 11 files changed, 1102 insertions(+), 1 deletion(-) create mode 100644 changelog/README.md create mode 100644 changelog/pyproject.toml create mode 100644 changelog/src/charm_tech_code/changelog/__init__.py create mode 100644 changelog/src/charm_tech_code/changelog/_constants.py create mode 100644 changelog/src/charm_tech_code/changelog/_format.py create mode 100644 changelog/src/charm_tech_code/changelog/_parse.py create mode 100644 changelog/tests/conftest.py create mode 100644 changelog/tests/test_changelog.py create mode 100644 changelog/uv.lock diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 01451d9..e751df8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: # One entry per tool. Add a directory here when you add a package. - package: [ai-failure-notifier] + package: [ai-failure-notifier, changelog] python-version: ['3.10', '3.12', '3.14'] defaults: run: diff --git a/README.md b/README.md index 91ba797..ba0de99 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Each tool is its own package in its own top-level directory, with its own `pypro | directory | what it does | |---|---| | [`ai-failure-notifier`](ai-failure-notifier) | Triages and enriches the issue opened when a scheduled workflow fails. | +| [`changelog`](changelog) | Turns GitHub's generated release notes into our changelog format. | Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA: diff --git a/changelog/README.md b/changelog/README.md new file mode 100644 index 0000000..04167f2 --- /dev/null +++ b/changelog/README.md @@ -0,0 +1,46 @@ +# changelog + +Turns GitHub's generated release notes into our changelog format. + +Lifted out of `canonical/operator`'s `release.py`, which is being replaced by three workflows ([canonical/operator#2224](https://github.com/canonical/operator/issues/2224)). The formatting is the part that isn't specific to one repository, so it lives here and the version arithmetic, the file rewriting and the GitHub calls stay behind in operator. + +## Using it + +```python +import datetime +from charm_tech_code.changelog import format_changes, format_release_notes, parse_release_notes + +categories, full_changelog = parse_release_notes(notes_text) +notes = format_release_notes(categories, full_changelog) +entry = format_changes(categories, '3.8.2', datetime.date.today()) +``` + +`notes_text` is GitHub's *generated* release-notes text, not a `git log`. GitHub builds it from the titles of the pull requests merged in the range, which is why the conventional-commit types come off PR titles. A release already has that text in its body; a workflow running before any release exists can ask for a preview of it with `POST /repos/{owner}/{repo}/releases/generate-notes`. Either way, getting hold of it is the caller's job: nothing here touches the network, git, the filesystem or the clock, and `format_changes` takes the date as an argument for the same reason. + +There's no console script, because what a command line would need to look like depends on the workflow calling it, and that workflow hasn't been written yet. + +## The format + +`format_release_notes` produces the body of a GitHub release, and `format_changes` produces one `CHANGES.md` entry: + +```markdown +# 3.8.2 - 31 August 2026 + +## Fixes + +* Compare full event paths when skipping duplicate notices (#2684) +``` + +Neither shape is injectable, and neither is the map of commit type to heading. The format is common across our repositories and the set of types is enforced by a shared PR-title check, so there is no second format for an adopting repository to supply, and a template system here would exist for a caller that doesn't. + +Two things about that map are worth knowing before you decide it's wrong: + +* `chore` is a type but not a category, so `chore` commits are dropped. That's deliberate. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and in a typical operator release they're a third to a half of the commits in the range. +* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` deliberately doesn't infer a major version bump, so that calling-out is the only thing marking it. + +## Developing + +```shell +uv sync --group unit +uv run pytest +``` diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml new file mode 100644 index 0000000..bc7490f --- /dev/null +++ b/changelog/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "charm-tech-code-changelog" +version = "0.1.0" +description = "Turn GitHub's generated release notes into our changelog format." +readme = "README.md" +requires-python = ">=3.10" +authors = [ + {name = "The Charm Tech team at Canonical Ltd."}, +] +license = "Apache-2.0" +# No runtime dependencies, and that is worth keeping. The package is pure +# text-to-text: it does not talk to GitHub, run git, or read the clock, so +# there is nothing for a dependency to do. `release.py`, which this is lifted +# out of, needs `pygithub`, `packaging` and `rich` -- all three belong to the +# parts that stayed behind in canonical/operator. +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/charm_tech_code"] + +[dependency-groups] +unit = ["pytest"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +# The real ruff configuration is at the root of the monorepo; extending it +# means a setting added here overrides one key rather than the whole config. +[tool.ruff] +extend = "../pyproject.toml" diff --git a/changelog/src/charm_tech_code/changelog/__init__.py b/changelog/src/charm_tech_code/changelog/__init__.py new file mode 100644 index 0000000..82a13cb --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/__init__.py @@ -0,0 +1,43 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Turn GitHub's generated release-notes text into our changelog format. + +Text in, structured data and formatted strings out. Nothing here touches the +network, git, the filesystem or the clock, so a caller supplies the notes +text and the date and decides what to do with what comes back:: + + categories, full_changelog = parse_release_notes(notes_text) + notes = format_release_notes(categories, full_changelog) + entry = format_changes(categories, '3.8.2', datetime.date.today()) + +The format is the package's own, not a parameter -- see `_constants` for +what that means and why `chore` commits do not appear in a changelog. +""" + +from __future__ import annotations + +from ._constants import CATEGORIES, CATEGORY_HEADINGS +from ._format import commit_type_to_category, format_changes, format_release_notes +from ._parse import parse_release_notes + +__all__ = [ + 'CATEGORIES', + 'CATEGORY_HEADINGS', + 'commit_type_to_category', + 'format_changes', + 'format_release_notes', + 'parse_release_notes', +] diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py new file mode 100644 index 0000000..dff0083 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -0,0 +1,98 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""The changelog format, as constants. + +None of this is injectable, and that is the point. The format is the same +across the Charm Tech repositories, and the set of conventional-commit types +is enforced by a shared CI check, so there is no second format for an +adopting repository to supply. A hook or a template system here would exist +for a caller that does not exist. +""" + +from __future__ import annotations + +import re + +#: The bullet format of GitHub's generated release notes: +#: ``* type!: summary by @user in https://github.com/owner/repo/pull/123``. +#: The ``!`` is optional and marks a breaking change. +CHANGE_LINE_REGEX = re.compile( + r'^\* (?P\w+)(?P!?): (?P.*) by [^ ]+ in (?P.*)' +) + +#: The PR link in a bullet, from which the ``(#123)`` in a ``CHANGES.md`` +#: entry is taken. +PR_LINK_REGEX = re.compile(r'https?://[^ ]+/pull/(\d+)') + +#: GitHub appends a section of first-time contributors to its generated +#: notes. It is not part of the changelog, so it is stripped before parsing. +NEW_CONTRIBUTORS_REGEX = re.compile(r'(## New Contributors.*?)(\n|$)', flags=re.DOTALL) + +#: The line GitHub ends its generated notes with, carrying a compare link. +#: It is passed through to the release notes unchanged. +FULL_CHANGELOG_PREFIX = '**Full Changelog**' + +#: The categories a changelog has, in the order they are rendered. +#: +#: This is also the filter. A conventional-commit type that is not a key here +#: is dropped from the changelog entirely, and `chore` is the type that makes +#: that matter: it is a real type, accepted by the PR-title check, but it is +#: deliberately not a category. Dependency bumps, charm-pin updates and the +#: release's own version-bump commit are all `chore`, and none of them is +#: something a reader of a changelog is looking for. In a typical operator +#: release that is a third to a half of the commits in the range. Dropping +#: them is the intended behaviour, not an oversight in the type list, so +#: please do not "fix" it by adding a `chore` key. +#: +#: `breaking` goes the other way round: it is a key here but is not a +#: conventional-commit type, so nothing ever parses into it directly. A `!` +#: after the real type moves an entry into it instead, keeping its real type +#: as a prefix (`Feat: ...`), and it renders first. +CATEGORIES: tuple[str, ...] = ( + 'breaking', + 'feat', + 'fix', + 'docs', + 'test', + 'refactor', + 'perf', + 'ci', + 'revert', +) + +#: The meta category breaking changes are collected into. +BREAKING = 'breaking' + +#: Commit type to the heading it is rendered under. A type with no entry +#: here is capitalised instead, which is what makes an unrecognised type +#: degrade to something readable rather than to a KeyError. +CATEGORY_HEADINGS = { + 'feat': 'Features', + 'fix': 'Fixes', + 'docs': 'Documentation', + 'test': 'Tests', + 'ci': 'CI', + 'perf': 'Performance', + 'refactor': 'Refactoring', + 'revert': 'Reverted', + 'breaking': 'Breaking Changes', +} + +#: The sentence under the release notes' `### Breaking Changes` heading. A +#: `!` deliberately does not infer a major version bump -- a breaking change +#: sometimes rides in a minor release -- so this calling-out is what the bent +#: rule relies on. +BREAKING_PREAMBLE = 'There are breaking changes in this release. Please review them carefully:' diff --git a/changelog/src/charm_tech_code/changelog/_format.py b/changelog/src/charm_tech_code/changelog/_format.py new file mode 100644 index 0000000..27184f8 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_format.py @@ -0,0 +1,104 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Rendering parsed categories as release notes and as a changelog entry.""" + +from __future__ import annotations + +import datetime +import logging +from collections.abc import Mapping + +from ._constants import ( + BREAKING, + BREAKING_PREAMBLE, + CATEGORY_HEADINGS, + PR_LINK_REGEX, +) + +logger = logging.getLogger(__name__) + + +def commit_type_to_category(commit_type: str) -> str: + """Map a commit type to a human-readable category heading. + + If the commit type is not recognised, it returns the capitalised commit type. + """ + return CATEGORY_HEADINGS.get(commit_type, commit_type.capitalize()) + + +def format_release_notes( + categories: Mapping[str, list[tuple[str, str]]], full_changelog: str | None +) -> str: + """Format for release notes. + + Results in a Markdown formatted string with sections for each commit type. + If `full_changelog` is provided, it is appended at the end. + + Breaking changes are rendered first, under their own heading and a + sentence asking the reader to review them. `categories` is expected to + be what `parse_release_notes` returned: every category present, in the + order they are rendered in. + """ + lines = ["## What's Changed", ''] + if categories[BREAKING]: + lines.append(f'### {commit_type_to_category(BREAKING)}') + lines.append(f'{BREAKING_PREAMBLE}\n') + for description, pr_link in categories[BREAKING]: + lines.append(f'* {description} in {pr_link}') + lines.append('') + logger.info( + 'Breaking changes detected in the release notes. ' + 'Please ensure there are sufficient instructions for users to handle them.' + ) + for commit_type, items in categories.items(): + if commit_type == BREAKING: + continue + if items: + lines.append(f'### {commit_type_to_category(commit_type)}') + for description, pr_link in items: + lines.append(f'* {description} in {pr_link}') + lines.append('') + if full_changelog: + lines.append(full_changelog) + return '\n'.join(lines) + + +def format_changes( + categories: Mapping[str, list[tuple[str, str]]], tag: str, date: datetime.date +) -> str: + """Format for CHANGES.md. + + The header is formatted as a top-level heading with the tag and date. + The content is a Markdown formatted string with sections for each commit type. + Each item is formatted as a bullet point with the description and PR number in parentheses. + + `date` is passed in rather than read from the clock. This module does no + I/O of any kind, and "what day is it" is I/O: the caller knows whether it + means the runner's today, the date on the tag, or a date under test. + """ + day = date.strftime('%d %B %Y') + lines = [f'# {tag} - {day}\n'] + for commit_type, items in categories.items(): + if items: + lines.append(f'## {commit_type_to_category(commit_type)}\n') + for description, pr_link in items: + pr_num = '?' + match = PR_LINK_REGEX.match(pr_link) + if match: + pr_num = match.group(1) + lines.append(f'* {description} (#{pr_num})') + lines.append('') + return '\n'.join(lines) + '\n' diff --git a/changelog/src/charm_tech_code/changelog/_parse.py b/changelog/src/charm_tech_code/changelog/_parse.py new file mode 100644 index 0000000..dbe5805 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_parse.py @@ -0,0 +1,74 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Parsing GitHub's generated release-notes text into categories.""" + +from __future__ import annotations + +from ._constants import ( + BREAKING, + CATEGORIES, + CHANGE_LINE_REGEX, + FULL_CHANGELOG_PREFIX, + NEW_CONTRIBUTORS_REGEX, +) + + +def parse_release_notes(release_notes: str) -> tuple[dict[str, list[tuple[str, str]]], str | None]: + """Parse auto-generated release notes into categories. + + The input is GitHub's *generated* release-notes text, not a ``git log``. + GitHub builds it from the titles of the pull requests merged in the + range, one ``* type!: summary by @user in `` bullet each, which is + why this reads conventional-commit types off PR titles rather than off + commit subjects. How a caller obtains that text is the caller's problem: + a release already has it in its body, and a workflow running before any + release exists can ask for a preview of it. Nothing here does I/O. + + The "New Contributors" section is removed. Bullets whose type is not a + changelog category -- `chore`, most of all -- are dropped; see + ``_constants.CATEGORIES`` for why that is deliberate. The full-changelog + line is returned separately rather than categorised. + + Returns: + A tuple containing: + - A dict with conventional commit types as keys and lists of tuples + (description, PR link) as values. Every category is present, even + when empty, in the order they are rendered in. + - The full changelog line if present, or ``None`` if not found. + """ + release_notes = NEW_CONTRIBUTORS_REGEX.sub(r'\2', release_notes) + categories: dict[str, list[tuple[str, str]]] = {category: [] for category in CATEGORIES} + full_changelog_line = None + + for line in release_notes.splitlines(): + if match := CHANGE_LINE_REGEX.match(line.strip()): + category = match.group('category').strip() + if category in categories: + description = match.group('summary').strip() + description = description[0].upper() + description[1:] + pr_link = match.group('pr').strip() + if match.group('breaking') == '!': + categories[BREAKING].append(( + f'{category.capitalize()}: {description}', + pr_link, + )) + else: + categories[category].append((description, pr_link)) + + elif line.startswith(FULL_CHANGELOG_PREFIX): + full_changelog_line = line + + return categories, full_changelog_line diff --git a/changelog/tests/conftest.py b/changelog/tests/conftest.py new file mode 100644 index 0000000..93d2bae --- /dev/null +++ b/changelog/tests/conftest.py @@ -0,0 +1,53 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Nothing in this package may read the clock. + +`format_changes` used to call `datetime.datetime.now()` itself, which is what +made the output of a release depend on which day CI happened to run and made +the function impossible to assert on. The date is an argument now, and this +fixture is what keeps it one: it replaces the `datetime` module as `_format` +sees it, so a reinstated `now()` or `today()` call fails the whole suite +rather than quietly passing on every day except the one that matters. +""" + +from __future__ import annotations + +import pytest + +from charm_tech_code.changelog import _format + + +class _NoClock: + """Stands in for `datetime.datetime` and `datetime.date`.""" + + @staticmethod + def now(*args: object, **kwargs: object): + raise AssertionError( + 'The clock was read by the package itself. `format_changes` takes ' + 'a date argument precisely so that it does not do this.' + ) + + today = now + utcnow = now + + +class _NoClockModule: + datetime = _NoClock + date = _NoClock + + +@pytest.fixture(autouse=True) +def no_clock(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_format, 'datetime', _NoClockModule) diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py new file mode 100644 index 0000000..bad86d3 --- /dev/null +++ b/changelog/tests/test_changelog.py @@ -0,0 +1,492 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the changelog package. + +The specification here is what `canonical/operator`'s `release.py` does +against real input, so the two large fixtures are real operator releases +rather than invented ones. Both are reconstructed from the repository's own +history: `git log --reverse 3.8.1..3.8.2` gives the merged pull requests in +the range, in merge order, and GitHub's generated notes are one +`* by @ in ` bullet each, in that same order, with the +PR title being the squash-commit subject minus its ` (#NNNN)` suffix. +""" + +from __future__ import annotations + +import datetime +import unittest + +from charm_tech_code.changelog import ( + CATEGORIES, + CATEGORY_HEADINGS, + commit_type_to_category, + format_changes, + format_release_notes, + parse_release_notes, +) + +# canonical/operator 3.8.2, released 31 August 2026. Twenty-three merged pull +# requests, eleven of them `chore`. Ali-932's was genuinely their first +# contribution to the repository, so the "New Contributors" section is real +# too. The two bot handles (`@dependabot`, `@prints-charming-bot`) are the +# only part of this not taken straight from the commits; nothing depends on +# them, since the parser only requires a single unspaced token after `by`. +OPERATOR_3_8_2_NOTES = """\ +## What's Changed +* chore: adjust versions after release by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2670 +* docs: give each best-practice admonition a stable :name: anchor by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2524 +* ci: point DB charm CI at the moved mysql-operators repo by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2551 +* chore: bump cryptography from 48.0.1 to 50.0.0 by @dependabot in https://github.com/canonical/operator/pull/2682 +* fix: compare full event paths when skipping duplicate notices by @Ali-932 in https://github.com/canonical/operator/pull/2684 +* chore: bump the actions group across 1 directory with 8 updates by @dependabot in https://github.com/canonical/operator/pull/2674 +* chore: bump the runtime group across 1 directory with 4 updates by @dependabot in https://github.com/canonical/operator/pull/2691 +* docs: reword text that vale 3.17 flags as misspelled by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2695 +* chore: update charm pins by @prints-charming-bot in https://github.com/canonical/operator/pull/2582 +* chore: bump the dev-tooling group in /examples/httpbin-demo with 2 updates by @dependabot in https://github.com/canonical/operator/pull/2675 +* chore: bump the charm-tech group across 1 directory with 3 updates by @dependabot in https://github.com/canonical/operator/pull/2697 +* docs: stop styling page references as blockquotes by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2666 +* ci: switch example charm integration tests to Concierge `k8s` preset by @dwilding in https://github.com/canonical/operator/pull/2696 +* docs: make the custom-endpoint-name sample test actually test something by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2664 +* ci: use the upstream concierge presets again by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2699 +* docs: replace `requests` by `urllib` in K8s tutorial integration tests by @dwilding in https://github.com/canonical/operator/pull/2687 +* fix: don't pass a message when converting an unknown status by name by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2700 +* chore: bump the dev-tooling group with 4 updates by @dependabot in https://github.com/canonical/operator/pull/2676 +* chore: update charm pins by @prints-charming-bot in https://github.com/canonical/operator/pull/2701 +* chore: adopt ruff 0.16's new lint conventions by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2698 +* docs: recommend spread directly, rather than charmcraft test by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2706 +* docs: extract sections in how to write integration tests to their own howto guide by @tromai in https://github.com/canonical/operator/pull/2662 +* chore: update changelog and versions for 3.8.2 release by @dwilding in https://github.com/canonical/operator/pull/2716 + +## New Contributors +* @Ali-932 made their first contribution in https://github.com/canonical/operator/pull/2684 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2 +""" + +# The PR numbers of the eleven `chore` pull requests in that release. None of +# them may appear anywhere in either rendered output. +OPERATOR_3_8_2_CHORE_PRS = ( + '2670', + '2682', + '2674', + '2691', + '2582', + '2675', + '2697', + '2676', + '2701', + '2698', + '2716', +) + +# Four real pull requests from the 3.7.1..3.8.0 range, in merge order, one of +# them the only `!` pull request operator has merged into a 3.x release +# (#2585). Trimmed to four bullets because the full range is fifty; the point +# of this fixture is the `!`, not the volume. +OPERATOR_BREAKING_NOTES = """\ +## What's Changed +* refactor: replace jsonpatch with an inline dict-diff by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2578 +* refactor!: move the otlp-json package to be a regular ops-tracing module by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2585 +* feat: note the socket path in Pebble tracing spans by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2555 +* fix: tear down `Runtime.exec()` when the charm raises by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2581 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0 +""" + + +class RealReleaseTests(unittest.TestCase): + """The 3.8.2 fixture, end to end.""" + + def setUp(self): + self.categories, self.full_changelog = parse_release_notes(OPERATOR_3_8_2_NOTES) + + def test_categories(self): + # Twelve of the twenty-three pull requests survive, in merge order. + assert self.categories == { + 'breaking': [], + 'feat': [], + 'fix': [ + ( + 'Compare full event paths when skipping duplicate notices', + 'https://github.com/canonical/operator/pull/2684', + ), + ( + "Don't pass a message when converting an unknown status by name", + 'https://github.com/canonical/operator/pull/2700', + ), + ], + 'docs': [ + ( + 'Give each best-practice admonition a stable :name: anchor', + 'https://github.com/canonical/operator/pull/2524', + ), + ( + 'Reword text that vale 3.17 flags as misspelled', + 'https://github.com/canonical/operator/pull/2695', + ), + ( + 'Stop styling page references as blockquotes', + 'https://github.com/canonical/operator/pull/2666', + ), + ( + 'Make the custom-endpoint-name sample test actually test something', + 'https://github.com/canonical/operator/pull/2664', + ), + ( + 'Replace `requests` by `urllib` in K8s tutorial integration tests', + 'https://github.com/canonical/operator/pull/2687', + ), + ( + 'Recommend spread directly, rather than charmcraft test', + 'https://github.com/canonical/operator/pull/2706', + ), + ( + 'Extract sections in how to write integration tests to their own howto guide', + 'https://github.com/canonical/operator/pull/2662', + ), + ], + 'test': [], + 'refactor': [], + 'perf': [], + 'ci': [ + ( + 'Point DB charm CI at the moved mysql-operators repo', + 'https://github.com/canonical/operator/pull/2551', + ), + ( + 'Switch example charm integration tests to Concierge `k8s` preset', + 'https://github.com/canonical/operator/pull/2696', + ), + ( + 'Use the upstream concierge presets again', + 'https://github.com/canonical/operator/pull/2699', + ), + ], + 'revert': [], + } + + def test_full_changelog_line(self): + assert self.full_changelog == ( + '**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2' + ) + + def test_chore_is_dropped(self): + # Eleven of the twenty-three pull requests are `chore`, and none of + # them reaches the output. Deliberate: dependency bumps, charm-pin + # updates and the release's own version bump are not changelog + # material. + notes = format_release_notes(self.categories, self.full_changelog) + entry = format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) + assert 'chore' not in notes.lower() + assert 'chore' not in entry.lower() + for pr in OPERATOR_3_8_2_CHORE_PRS: + assert pr not in notes, f'chore PR #{pr} leaked into the release notes' + assert pr not in entry, f'chore PR #{pr} leaked into the changelog entry' + + def test_new_contributors_section_is_dropped(self): + notes = format_release_notes(self.categories, self.full_changelog) + assert 'New Contributors' not in notes + assert 'made their first contribution' not in notes + + def test_changes_entry(self): + # This is the 3.8.2 entry as it appears in operator's CHANGES.md, + # except for three summaries where the committed CHANGES.md was + # edited by hand afterwards (#2700 gained an "In `ops.testing`," + # prefix, #2524 lost its ":name:", and #2662 was reworded). The + # section order, the bullet order within each section and the + # blank-line layout are all exactly what shipped. + assert ( + format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) + == """\ +# 3.8.2 - 31 August 2026 + +## Fixes + +* Compare full event paths when skipping duplicate notices (#2684) +* Don't pass a message when converting an unknown status by name (#2700) + +## Documentation + +* Give each best-practice admonition a stable :name: anchor (#2524) +* Reword text that vale 3.17 flags as misspelled (#2695) +* Stop styling page references as blockquotes (#2666) +* Make the custom-endpoint-name sample test actually test something (#2664) +* Replace `requests` by `urllib` in K8s tutorial integration tests (#2687) +* Recommend spread directly, rather than charmcraft test (#2706) +* Extract sections in how to write integration tests to their own howto guide (#2662) + +## CI + +* Point DB charm CI at the moved mysql-operators repo (#2551) +* Switch example charm integration tests to Concierge `k8s` preset (#2696) +* Use the upstream concierge presets again (#2699) + +""" + ) + + def test_release_notes(self): + assert ( + format_release_notes(self.categories, self.full_changelog) + == """\ +## What's Changed + +### Fixes +* Compare full event paths when skipping duplicate notices in https://github.com/canonical/operator/pull/2684 +* Don't pass a message when converting an unknown status by name in https://github.com/canonical/operator/pull/2700 + +### Documentation +* Give each best-practice admonition a stable :name: anchor in https://github.com/canonical/operator/pull/2524 +* Reword text that vale 3.17 flags as misspelled in https://github.com/canonical/operator/pull/2695 +* Stop styling page references as blockquotes in https://github.com/canonical/operator/pull/2666 +* Make the custom-endpoint-name sample test actually test something in https://github.com/canonical/operator/pull/2664 +* Replace `requests` by `urllib` in K8s tutorial integration tests in https://github.com/canonical/operator/pull/2687 +* Recommend spread directly, rather than charmcraft test in https://github.com/canonical/operator/pull/2706 +* Extract sections in how to write integration tests to their own howto guide in https://github.com/canonical/operator/pull/2662 + +### CI +* Point DB charm CI at the moved mysql-operators repo in https://github.com/canonical/operator/pull/2551 +* Switch example charm integration tests to Concierge `k8s` preset in https://github.com/canonical/operator/pull/2696 +* Use the upstream concierge presets again in https://github.com/canonical/operator/pull/2699 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2""" + ) + + def test_the_date_is_the_one_it_is_given(self): + entry = format_changes(self.categories, '3.8.2', datetime.date(2020, 1, 2)) + assert entry.startswith('# 3.8.2 - 02 January 2020\n') + + +class BreakingChangeTests(unittest.TestCase): + """A `!` moves an entry into its own category, keeping its real type.""" + + def setUp(self): + self.categories, self.full_changelog = parse_release_notes(OPERATOR_BREAKING_NOTES) + + def test_breaking_entry_keeps_its_real_type_as_a_prefix(self): + assert self.categories['breaking'] == [ + ( + 'Refactor: Move the otlp-json package to be a regular ops-tracing module', + 'https://github.com/canonical/operator/pull/2585', + ) + ] + + def test_breaking_entry_is_not_also_in_its_own_type(self): + # The `!` moves the entry rather than copying it, so #2585 leaves + # `refactor` and #2578, which has no `!`, is all that is left there. + assert self.categories['refactor'] == [ + ( + 'Replace jsonpatch with an inline dict-diff', + 'https://github.com/canonical/operator/pull/2578', + ) + ] + + def test_release_notes_put_breaking_first_with_a_warning(self): + assert ( + format_release_notes(self.categories, self.full_changelog) + == """\ +## What's Changed + +### Breaking Changes +There are breaking changes in this release. Please review them carefully: + +* Refactor: Move the otlp-json package to be a regular ops-tracing module in https://github.com/canonical/operator/pull/2585 + +### Features +* Note the socket path in Pebble tracing spans in https://github.com/canonical/operator/pull/2555 + +### Fixes +* Tear down `Runtime.exec()` when the charm raises in https://github.com/canonical/operator/pull/2581 + +### Refactoring +* Replace jsonpatch with an inline dict-diff in https://github.com/canonical/operator/pull/2578 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0""" + ) + + def test_changes_entry_puts_breaking_first_without_the_warning(self): + # The warning sentence belongs to the release notes only. A + # `CHANGES.md` entry is a list, and gets the heading alone. + assert ( + format_changes(self.categories, '3.8.0', datetime.date(2026, 6, 30)) + == """\ +# 3.8.0 - 30 June 2026 + +## Breaking Changes + +* Refactor: Move the otlp-json package to be a regular ops-tracing module (#2585) + +## Features + +* Note the socket path in Pebble tracing spans (#2555) + +## Fixes + +* Tear down `Runtime.exec()` when the charm raises (#2581) + +## Refactoring + +* Replace jsonpatch with an inline dict-diff (#2578) + +""" + ) + + +class ParseTests(unittest.TestCase): + """The bullet format, in detail.""" + + def parse(self, *bullets: str) -> dict[str, list[tuple[str, str]]]: + categories, _ = parse_release_notes('\n'.join(bullets)) + return categories + + def test_summary_is_capitalised(self): + # PR titles are lowercase after the conventional-commit type, and + # changelog bullets are sentence case. + categories = self.parse('* fix: do the thing by @someone in https://example.com/pull/1') + assert categories['fix'] == [('Do the thing', 'https://example.com/pull/1')] + + def test_summary_that_starts_with_a_backtick_is_left_alone(self): + categories = self.parse( + '* fix: `Runtime.exec()` tears down by @someone in https://example.com/pull/1' + ) + assert categories['fix'] == [('`Runtime.exec()` tears down', 'https://example.com/pull/1')] + + def test_unrecognised_type_is_dropped(self): + # `build` and `style` are conventional-commit types the PR-title + # check accepts, but they are not changelog categories, so they go + # the same way `chore` does. + categories = self.parse( + '* build: bump the wheel by @someone in https://example.com/pull/1', + '* style: reformat by @someone in https://example.com/pull/2', + '* nonsense: whatever by @someone in https://example.com/pull/3', + ) + assert all(not items for items in categories.values()) + + def test_breaking_on_a_dropped_type_is_still_dropped(self): + # The `!` is only honoured for a type that has a category, so a + # `chore!` does not sneak into the changelog through the breaking + # bucket. + categories = self.parse( + '* chore!: drop python 3.8 by @someone in https://example.com/pull/1' + ) + assert categories['breaking'] == [] + + def test_every_category_is_present_even_when_empty(self): + # Callers index `categories['breaking']` directly, and iterate the + # dict for the rendering order, so the shape does not depend on what + # happened to be in the release. + categories = self.parse('') + assert list(categories) == list(CATEGORIES) + + def test_lines_that_are_not_bullets_are_ignored(self): + categories, full_changelog = parse_release_notes( + "## What's Changed\n" + 'Some prose about the release.\n' + '* not a conventional commit title by @someone in https://example.com/pull/1\n' + '* fix: a real one by @someone in https://example.com/pull/2\n' + ) + assert categories['fix'] == [('A real one', 'https://example.com/pull/2')] + assert full_changelog is None + + def test_indented_bullets_are_parsed(self): + assert self.parse(' * fix: indented by @someone in https://example.com/pull/1')['fix'] + + def test_new_contributors_section_is_stripped_before_parsing(self): + # It is stripped rather than skipped, because its bullets are the + # same shape and would otherwise have to be excluded by luck. + categories, _ = parse_release_notes( + '* fix: a real one by @someone in https://example.com/pull/1\n' + '\n' + '## New Contributors\n' + '* @someone made their first contribution in https://example.com/pull/1\n' + ) + assert categories['fix'] == [('A real one', 'https://example.com/pull/1')] + + +class FormatReleaseNotesTests(unittest.TestCase): + def empty(self) -> dict[str, list[tuple[str, str]]]: + return {category: [] for category in CATEGORIES} + + def test_empty_release(self): + assert format_release_notes(self.empty(), None) == "## What's Changed\n" + + def test_categories_render_in_the_declared_order(self): + categories = self.empty() + for category in ('revert', 'ci', 'feat', 'fix'): + categories[category] = [(f'A {category} change', 'https://example.com/pull/1')] + headings = [ + line + for line in format_release_notes(categories, None).splitlines() + if line.startswith('###') + ] + assert headings == ['### Features', '### Fixes', '### CI', '### Reverted'] + + def test_full_changelog_is_appended_when_given(self): + notes = format_release_notes(self.empty(), '**Full Changelog**: https://example.com/x') + assert notes.endswith('**Full Changelog**: https://example.com/x') + + +class FormatChangesTests(unittest.TestCase): + def entry(self, pr_link: str) -> str: + categories = {category: [] for category in CATEGORIES} + categories['fix'] = [('A fix', pr_link)] + return format_changes(categories, '1.2.3', datetime.date(2026, 9, 10)) + + def test_pr_number_comes_from_the_link(self): + assert '* A fix (#2684)' in self.entry('https://github.com/canonical/operator/pull/2684') + + def test_an_unrecognisable_link_gets_a_question_mark(self): + # An entry with no PR to point at still belongs in the changelog, so + # this is a placeholder rather than a failure. + assert '* A fix (#?)' in self.entry('https://github.com/canonical/operator/commit/abc123') + + def test_empty_release(self): + empty = {category: [] for category in CATEGORIES} + assert format_changes(empty, '1.2.3', datetime.date(2026, 9, 10)) == ( + '# 1.2.3 - 10 September 2026\n\n' + ) + + def test_the_tag_is_used_verbatim(self): + empty = {category: [] for category in CATEGORIES} + assert format_changes(empty, '3.4.0b1', datetime.date(2026, 9, 10)).startswith( + '# 3.4.0b1 - ' + ) + + +class CommitTypeToCategoryTests(unittest.TestCase): + def test_known_types(self): + assert commit_type_to_category('feat') == 'Features' + assert commit_type_to_category('fix') == 'Fixes' + assert commit_type_to_category('docs') == 'Documentation' + assert commit_type_to_category('test') == 'Tests' + assert commit_type_to_category('ci') == 'CI' + assert commit_type_to_category('perf') == 'Performance' + assert commit_type_to_category('refactor') == 'Refactoring' + assert commit_type_to_category('revert') == 'Reverted' + assert commit_type_to_category('breaking') == 'Breaking Changes' + + def test_unknown_type_is_capitalised(self): + assert commit_type_to_category('whatever') == 'Whatever' + + def test_chore_has_no_category(self): + # It has no heading because it is not a category. That it still + # returns something readable is a property of the fallback, not an + # invitation to render it. + assert 'chore' not in CATEGORY_HEADINGS + assert 'chore' not in CATEGORIES + + def test_every_category_has_a_heading(self): + # Otherwise a category would render under a capitalised version of + # its own key, which is only ever right by accident. + assert set(CATEGORIES) <= set(CATEGORY_HEADINGS) diff --git a/changelog/uv.lock b/changelog/uv.lock new file mode 100644 index 0000000..b6f3feb --- /dev/null +++ b/changelog/uv.lock @@ -0,0 +1,156 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "charm-tech-code-changelog" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +unit = [ + { name = "pytest" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +unit = [{ name = "pytest" }] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 8fc68e3e532ba51a8a73d64923ffa25a82912a8c Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 11 Sep 2026 10:51:54 +1200 Subject: [PATCH 02/20] Apply suggestion from @tonyandrewmeyer --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index 04167f2..814fa59 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -2,7 +2,7 @@ Turns GitHub's generated release notes into our changelog format. -Lifted out of `canonical/operator`'s `release.py`, which is being replaced by three workflows ([canonical/operator#2224](https://github.com/canonical/operator/issues/2224)). The formatting is the part that isn't specific to one repository, so it lives here and the version arithmetic, the file rewriting and the GitHub calls stay behind in operator. +The Charm Tech repositories have different release processes, but aim for a consistent changelog style. The formatting and the version arithmetic are centralised here, the file rewriting and the GitHub calls are in each repository. ## Using it From 1240b170953ff20d37969ea48726ccbc34fc88cb Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 11 Sep 2026 10:54:51 +1200 Subject: [PATCH 03/20] Apply suggestion from @tonyandrewmeyer --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index 814fa59..668de67 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -35,7 +35,7 @@ Neither shape is injectable, and neither is the map of commit type to heading. T Two things about that map are worth knowing before you decide it's wrong: -* `chore` is a type but not a category, so `chore` commits are dropped. That's deliberate. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and in a typical operator release they're a third to a half of the commits in the range. +* `chore` is a type but not a category, so `chore` commits are deliberately dropped. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and these sorts of changes are not interesting to our users, and they are available via `git log` if anyone does want them. * `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` deliberately doesn't infer a major version bump, so that calling-out is the only thing marking it. ## Developing From e7d2f11d1f6ab71d10495fee673551e8dce71af9 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Fri, 11 Sep 2026 10:57:04 +1200 Subject: [PATCH 04/20] Apply suggestion from @tonyandrewmeyer --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index 668de67..7b01643 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -36,7 +36,7 @@ Neither shape is injectable, and neither is the map of commit type to heading. T Two things about that map are worth knowing before you decide it's wrong: * `chore` is a type but not a category, so `chore` commits are deliberately dropped. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and these sorts of changes are not interesting to our users, and they are available via `git log` if anyone does want them. -* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` deliberately doesn't infer a major version bump, so that calling-out is the only thing marking it. +* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` should be a major version bump, but if it's appearing here then we have decided to cheat the semver rules and allow a breaking change in a minor release. This should be rare. We will have carefully checked the impact before this decision, but want to make sure the change is particularly noticeable in the changelog. ## Developing From ad123fbc1323ee1291004c231d7b2e6e3a9d63c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 19:08:15 +0000 Subject: [PATCH 05/20] feat: infer the bump size and add a console script to changelog The two things the package was missing before a release workflow can call it. `infer_bump_size` reads the size off the same parse the changelog comes from, `next_version` applies it, and a `changelog` console script wraps both of those and the two formatters for a workflow step that can't `import`. The version arithmetic came here rather than staying in operator. That is the split the README on this branch already describes ("the formatting and the version arithmetic are centralised here") but PLAN.md doesn't, so it is worth saying why: a bump size is not usable without the arithmetic that applies it, and leaving `bump_minor_version` and `bump_patch_version` behind would have every adopting repository rewrite the same four lines. What stays behind is what actually varies between repositories - which version to count from, whether a `.dev0` goes on afterwards, the ops-scenario +5 rule, and knowing which branches a `feat` has no business appearing on at all. `next_version` raises on anything that isn't a plain `X.Y.Z` rather than guess, which is how it declines to have an opinion about the rest. A `!` counts towards a minor bump. The plan says a `!` doesn't infer a major one, which leaves open what it does infer, and `parse_release_notes` moves a `feat!` out of `feat` and into `breaking` - so a rule that read `feat` alone would call a release whose only feature is a `feat!` a patch, and ship a breaking change in a patch release. That is a bigger bend of the rules than the one we have actually agreed to. operator's 3.8.0 shipped a `refactor!` in a minor release, which is the fixture this is tested against. The console script is the package's I/O boundary and the only part of it that reads the clock, for `--date`'s default. The conftest fixture that keeps the library clock-free now covers every library module instead of `_format` alone, and `_cli` is deliberately outside it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012fHEMV23WkCiU8YoLdF6iW --- changelog/README.md | 46 +++- changelog/pyproject.toml | 6 + .../src/charm_tech_code/changelog/__init__.py | 19 +- .../src/charm_tech_code/changelog/_cli.py | 172 ++++++++++++++ .../charm_tech_code/changelog/_constants.py | 25 ++ .../src/charm_tech_code/changelog/_version.py | 103 ++++++++ changelog/tests/conftest.py | 26 ++- changelog/tests/test_changelog.py | 219 ++++++++++++++++++ 8 files changed, 604 insertions(+), 12 deletions(-) create mode 100644 changelog/src/charm_tech_code/changelog/_cli.py create mode 100644 changelog/src/charm_tech_code/changelog/_version.py diff --git a/changelog/README.md b/changelog/README.md index 7b01643..4f983b6 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -8,16 +8,54 @@ The Charm Tech repositories have different release processes, but aim for a cons ```python import datetime -from charm_tech_code.changelog import format_changes, format_release_notes, parse_release_notes +from charm_tech_code.changelog import ( + format_changes, + format_release_notes, + infer_bump_size, + next_version, + parse_release_notes, +) categories, full_changelog = parse_release_notes(notes_text) notes = format_release_notes(categories, full_changelog) -entry = format_changes(categories, '3.8.2', datetime.date.today()) +version = next_version('3.8.1', infer_bump_size(categories)) +entry = format_changes(categories, version, datetime.date.today()) ``` -`notes_text` is GitHub's *generated* release-notes text, not a `git log`. GitHub builds it from the titles of the pull requests merged in the range, which is why the conventional-commit types come off PR titles. A release already has that text in its body; a workflow running before any release exists can ask for a preview of it with `POST /repos/{owner}/{repo}/releases/generate-notes`. Either way, getting hold of it is the caller's job: nothing here touches the network, git, the filesystem or the clock, and `format_changes` takes the date as an argument for the same reason. +`notes_text` is GitHub's *generated* release-notes text, not a `git log`. GitHub builds it from the titles of the pull requests merged in the range, which is why the conventional-commit types come off PR titles. A release already has that text in its body; a workflow running before any release exists can ask for a preview of it with `POST /repos/{owner}/{repo}/releases/generate-notes`. Either way, getting hold of it is the caller's job: nothing in the library touches the network, git, the filesystem or the clock, and `format_changes` takes the date as an argument for the same reason. -There's no console script, because what a command line would need to look like depends on the workflow calling it, and that workflow hasn't been written yet. +## From a workflow step + +The console script is the same thing for a caller that can't `import`. It reads the notes on stdin and prints one answer: + +```shell +gh api "repos/$REPO/releases/generate-notes" -f tag_name="$TAG" -f target_commitish="$BRANCH" --jq .body > notes.md +SIZE=$(changelog bump-size < notes.md) +VERSION=$(changelog next-version --previous "$LAST_TAG" < notes.md) +changelog release-notes < notes.md > release-notes.md +changelog changes-entry --tag "$VERSION" < notes.md > changes-entry.md +``` + +That shape comes from how an Actions step consumes a result. A `$GITHUB_OUTPUT` line takes a scalar comfortably and a multi-line document only through a heredoc delimiter the document itself must not contain, so the two commands that produce Markdown print it on stdout for the step to redirect into a file, and the two that produce a scalar print a single bare word, with no label and no JSON to unwrap. Nothing here writes to `$GITHUB_OUTPUT` itself, which keeps the script useful outside Actions. + +Four invocations re-parse the same text four times. That costs nothing worth counting, and it is the reason each step's output needs no reshaping. + +`--date` defaults to today (UTC), and `_cli` is the only module in the package that reads the clock. The library stays clock-free, and a fixture in the test suite fails the whole run if that stops being true. + +Run it from a workflow the way `ai-failure-notifier` is run, pinned to a commit: + +```shell +uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=changelog" changelog bump-size < notes.md +``` + +## Versions + +`infer_bump_size` answers "how big a release is this range", and `next_version` applies that answer to a plain `X.Y.Z`. The rule is that a `feat` in the range means minor and anything else means patch, with two things worth saying out loud: + +* **A breaking change counts as a feature.** A `!` moves an entry out of its real type and into `breaking`, so a range whose only feature is a `feat!` has an empty `feat` list, and a rule that read `feat` alone would call it a patch. A `!` doesn't infer a *major* bump, for the reason in "The format" below, but a breaking change riding in a patch release isn't the bend of the rules anyone agreed to. operator's 3.8.0 shipped a `refactor!` in a minor release, which is the case this matches. +* **A major bump is never inferred.** Nor is a pre-release, and `next_version` raises rather than guess at anything that isn't a plain `X.Y.Z`: `3.9.0.dev0` is the one to watch, since that's what sits in the version file between releases and it's a guess made by the last post-release bump rather than a version anyone shipped. A release that isn't an ordinary next one is a deliberate act, and the workflow's explicit version input is how to say so. + +Where the line falls: the package says what size the range is and does the semver arithmetic, and the repository decides what to count from, whether a `.dev0` goes on the end afterwards, and what any of it implies for the other packages it ships. A `minor` on a branch where a feature has no business appearing, such as operator's `2.23-maintenance`, is an error rather than a patch, but it's the repository that knows which branches those are, so that check belongs there too. ## The format diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml index bc7490f..2bd8450 100644 --- a/changelog/pyproject.toml +++ b/changelog/pyproject.toml @@ -15,6 +15,12 @@ license = "Apache-2.0" # parts that stayed behind in canonical/operator. dependencies = [] +# The importable API is the package; this is the same thing wrapped for a +# workflow step, which cannot `import`. `argparse` is stdlib, so it stays a +# dependency-free package. +[project.scripts] +changelog = "charm_tech_code.changelog._cli:main" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" diff --git a/changelog/src/charm_tech_code/changelog/__init__.py b/changelog/src/charm_tech_code/changelog/__init__.py index 82a13cb..032ba47 100644 --- a/changelog/src/charm_tech_code/changelog/__init__.py +++ b/changelog/src/charm_tech_code/changelog/__init__.py @@ -23,21 +23,36 @@ notes = format_release_notes(categories, full_changelog) entry = format_changes(categories, '3.8.2', datetime.date.today()) +The same parse also answers how big a release the range adds up to, and what +that makes the version after `previous`:: + + size = infer_bump_size(categories) + version = next_version(previous, size) + The format is the package's own, not a parameter -- see `_constants` for -what that means and why `chore` commits do not appear in a changelog. +what that means and why `chore` commits do not appear in a changelog. The +`changelog` console script (`_cli`) wraps all of the above for a workflow +step, and is the one place in the package that does any I/O. """ from __future__ import annotations -from ._constants import CATEGORIES, CATEGORY_HEADINGS +from ._constants import CATEGORIES, CATEGORY_HEADINGS, MINOR_BUMP_CATEGORIES from ._format import commit_type_to_category, format_changes, format_release_notes from ._parse import parse_release_notes +from ._version import MINOR, PATCH, BumpSize, infer_bump_size, next_version __all__ = [ 'CATEGORIES', 'CATEGORY_HEADINGS', + 'MINOR', + 'MINOR_BUMP_CATEGORIES', + 'PATCH', + 'BumpSize', 'commit_type_to_category', 'format_changes', 'format_release_notes', + 'infer_bump_size', + 'next_version', 'parse_release_notes', ] diff --git a/changelog/src/charm_tech_code/changelog/_cli.py b/changelog/src/charm_tech_code/changelog/_cli.py new file mode 100644 index 0000000..82d7518 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_cli.py @@ -0,0 +1,172 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""The console script: notes text on stdin, one answer on stdout. + +**This module is the package's I/O boundary, and the only one.** Everything +under it is text in, text out -- no network, no git, no filesystem, no clock +-- and the test suite's `no_clock` fixture holds that line by making a +`datetime` call from the library modules fail. This file is deliberately +outside that fixture, because `--date` has to default to something and +"today" is the only sensible default for a workflow that runs on the day it +releases. If you find yourself wanting the clock, or a file, or an API call, +in any other module of this package: it goes here instead. + +The shape is driven by how a GitHub Actions step consumes a result, which is +either as a `$GITHUB_OUTPUT` line or as a file. A `$GITHUB_OUTPUT` line takes +a scalar comfortably and a multi-line document only through a heredoc +delimiter that the document itself must not contain -- so the two commands +that emit Markdown emit it on stdout, for the step to redirect into a file, +and the two that emit a scalar emit a single bare word with no decoration, so +that `size=$(changelog bump-size < notes.md)` is the whole of the plumbing:: + + gh api "repos/$REPO/releases/generate-notes" ... --jq .body > notes.md + SIZE=$(changelog bump-size < notes.md) + VERSION=$(changelog next-version --previous "$LAST_TAG" < notes.md) + changelog release-notes < notes.md > release-notes.md + changelog changes-entry --tag "$VERSION" < notes.md > changes-entry.md + +Four invocations re-parse the same text four times, which costs nothing and +buys each step an output that goes where it belongs without any reshaping. +""" + +from __future__ import annotations + +import argparse +import datetime +import sys +from collections.abc import Sequence + +from ._format import format_changes, format_release_notes +from ._parse import parse_release_notes +from ._version import infer_bump_size, next_version + + +def _today() -> datetime.date: + """The default for `--date`, and the package's only reading of the clock. + + UTC rather than local time: the runner is UTC, and a release's date + should not depend on who ran it from where. + """ + return datetime.datetime.now(datetime.timezone.utc).date() + + +def _emit(text: str) -> None: + """Write one answer to stdout, newline-terminated. + + The text is otherwise passed through exactly as the library produced it. + `format_changes` output is prepended to a `CHANGES.md` verbatim, so the + blank lines at its end are part of the answer rather than padding to be + tidied up here. + """ + sys.stdout.write(text if text.endswith('\n') else text + '\n') + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog='changelog', + description=( + "Turn GitHub's generated release-notes text, read from stdin, into " + 'our changelog format or into a version decision.' + ), + ) + subparsers = parser.add_subparsers(dest='command', required=True) + + subparsers.add_parser( + 'bump-size', + help="Print 'minor' or 'patch' for the changes in the notes.", + description=( + "Print 'minor' if the notes contain a feature or a breaking change, " + "and 'patch' otherwise. Never 'major': that is a deliberate act, not " + 'something to infer. On a branch where a feature should not appear at ' + 'all, such as a maintenance branch, treat a "minor" here as an error ' + 'rather than releasing from it.' + ), + ) + + next_version_parser = subparsers.add_parser( + 'next-version', + help='Print the version that follows --previous, given the changes in the notes.', + description=( + 'Apply the inferred bump size to --previous and print the result. ' + 'Only a plain X.Y.Z is accepted; whether the answer then gains a ' + 'pre-release or dev suffix, and what it implies for any other ' + 'package version in the repository, is for the caller to decide.' + ), + ) + next_version_parser.add_argument( + '--previous', + required=True, + metavar='X.Y.Z', + help='The version this release follows, normally the last tag on the branch.', + ) + + subparsers.add_parser( + 'release-notes', + help='Print the release body, as Markdown.', + description=( + 'Print the body of a GitHub release: the changes by category, ' + 'breaking ones first, and the full-changelog link if the notes ' + 'carried one.' + ), + ) + + changes_entry_parser = subparsers.add_parser( + 'changes-entry', + help='Print one CHANGES.md entry, as Markdown.', + description=( + 'Print a single CHANGES.md entry for the release, to be prepended ' + 'to the existing file.' + ), + ) + changes_entry_parser.add_argument( + '--tag', + required=True, + help='The version being released, used verbatim in the entry heading.', + ) + changes_entry_parser.add_argument( + '--date', + type=datetime.date.fromisoformat, + default=None, + metavar='YYYY-MM-DD', + help="The release date. Defaults to today's date, in UTC.", + ) + + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Parse the notes on stdin and print the answer the subcommand asks for.""" + args = _build_parser().parse_args(argv) + categories, full_changelog = parse_release_notes(sys.stdin.read()) + + if args.command == 'bump-size': + _emit(infer_bump_size(categories)) + elif args.command == 'next-version': + try: + _emit(next_version(args.previous, infer_bump_size(categories))) + except ValueError as exc: + print(f'changelog: {exc}', file=sys.stderr) + return 2 + elif args.command == 'release-notes': + _emit(format_release_notes(categories, full_changelog)) + else: + _emit(format_changes(categories, args.tag, args.date or _today())) + + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py index dff0083..d688017 100644 --- a/changelog/src/charm_tech_code/changelog/_constants.py +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -76,6 +76,31 @@ #: The meta category breaking changes are collected into. BREAKING = 'breaking' +#: The one real conventional-commit type the bump-size rule cares about. +FEATURE = 'feat' + +#: The categories whose presence in a range makes the release a minor one. +#: Everything else -- and an empty range -- is a patch. +#: +#: `feat` is the rule as it is usually stated. `breaking` is here because a +#: `!` moves an entry *out* of its real type and into the meta category, so +#: a range whose only feature is a `feat!` has an empty `feat` list, and a +#: rule that only looked at `feat` would call that release a patch. It is +#: also the right answer in its own right: a `!` does not infer a major bump +#: (see `BREAKING_PREAMBLE`), but a breaking change riding in a *patch* +#: release is a worse bend of the rules than one riding in a minor, which is +#: the bend we have actually decided to allow. operator's own 3.8.0 shipped +#: a `refactor!` in a minor release. +#: +#: A major bump is never inferred, from this or anything else. That is what +#: the release workflow's explicit version input is for. +MINOR_BUMP_CATEGORIES: tuple[str, ...] = (BREAKING, FEATURE) + +#: A plain `X.Y.Z` release version, which is the only shape the bump +#: arithmetic will touch. Pre-releases, dev versions and anything else are +#: the caller's own policy: see `next_version`. +RELEASE_VERSION_REGEX = re.compile(r'(\d+)\.(\d+)\.(\d+)') + #: Commit type to the heading it is rendered under. A type with no entry #: here is capitalised instead, which is what makes an unrecognised type #: degrade to something readable rather than to a KeyError. diff --git a/changelog/src/charm_tech_code/changelog/_version.py b/changelog/src/charm_tech_code/changelog/_version.py new file mode 100644 index 0000000..3966324 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_version.py @@ -0,0 +1,103 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""How big a release the commits in a range add up to, and what that makes +the next version. + +Two functions, and the split between them is the interesting part. The +Charm Tech repositories share a changelog format and a set of +conventional-commit types, so "is this range a minor or a patch" has one +answer everywhere and lives here. What the *next version* of a particular +repository is does not: which version to count from, whether a `.dev0` gets +appended afterwards, what a pre-release looks like, and canonical/operator's +rule that the ops-scenario major is the ops major plus five, are all things +the adopting repository decides. `next_version` is the generic middle: it +applies a size to a plain `X.Y.Z` and refuses anything else, so a repository +with a version shape of its own has to say what it means rather than get a +silently wrong answer. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Literal + +from ._constants import MINOR_BUMP_CATEGORIES, RELEASE_VERSION_REGEX + +#: The two sizes a release can be inferred to be. A major bump is never +#: inferred -- see `MINOR_BUMP_CATEGORIES` -- so it is not one of these. +BumpSize = Literal['minor', 'patch'] + +MINOR: BumpSize = 'minor' +PATCH: BumpSize = 'patch' + + +def infer_bump_size(categories: Mapping[str, list[tuple[str, str]]]) -> BumpSize: + """Work out whether a range of changes is a minor release or a patch one. + + `categories` is what `parse_release_notes` returned. That matters rather + more than it looks: the parse is where a `!` moves an entry out of its + real type and into `breaking`, so the categories this reads have already + had that routing applied, and passing a dict built some other way will + get a different answer. + + A `minor` result means "there is a feature, or a breaking change, in this + range". A caller that has branches on which neither may appear -- a + maintenance branch carrying only cherry-picked fixes, say -- should treat + `minor` as the error it is for that branch, rather than quietly bumping + the patch instead. This function will not do that for you: which branches + those are is repository policy, and nothing here knows what branch it is + on. + + Returns: + `'minor'` or `'patch'`. Never `'major'`: a major release is a + deliberate act, not something to infer from a commit range. + """ + if any(categories.get(category) for category in MINOR_BUMP_CATEGORIES): + return MINOR + return PATCH + + +def next_version(previous: str, size: BumpSize) -> str: + """Apply a bump size to a released version. + + `previous` is the version this release follows, which for a workflow + proposing a release is normally the last tag on the branch being released + -- not whatever is currently in the repository's version file, which by + then is usually a `.dev0` of a version that was only ever a guess. + + Only a plain `X.Y.Z` is accepted. A pre-release, a dev version or a local + version means the caller is doing something this cannot infer -- cutting + `3.9.0b1`, or resuming a pre-release series -- and the release workflow's + explicit version input is the way to say so. Raising is the point: the + alternative is quietly dropping a suffix and tagging the wrong thing. + + Raises: + ValueError: if `previous` is not a plain `X.Y.Z`, or `size` is not + one of the two sizes `infer_bump_size` returns. + """ + match = RELEASE_VERSION_REGEX.fullmatch(previous.strip()) + if not match: + raise ValueError( + f'{previous!r} is not a plain X.Y.Z release version. Pre-releases, ' + f'dev versions and anything else are not inferred from: pass the ' + f'version you want explicitly.' + ) + major, minor, patch = (int(part) for part in match.groups()) + if size == MINOR: + return f'{major}.{minor + 1}.0' + if size == PATCH: + return f'{major}.{minor}.{patch + 1}' + raise ValueError(f'{size!r} is not a bump size. Expected {MINOR!r} or {PATCH!r}.') diff --git a/changelog/tests/conftest.py b/changelog/tests/conftest.py index 93d2bae..53576de 100644 --- a/changelog/tests/conftest.py +++ b/changelog/tests/conftest.py @@ -12,21 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Nothing in this package may read the clock. +"""Nothing in this package's library may read the clock. `format_changes` used to call `datetime.datetime.now()` itself, which is what made the output of a release depend on which day CI happened to run and made the function impossible to assert on. The date is an argument now, and this -fixture is what keeps it one: it replaces the `datetime` module as `_format` -sees it, so a reinstated `now()` or `today()` call fails the whole suite -rather than quietly passing on every day except the one that matters. +fixture is what keeps it one: it replaces the `datetime` module as each +library module sees it, so a reinstated `now()` or `today()` call fails the +whole suite rather than quietly passing on every day except the one that +matters. + +`_cli` is deliberately not in the list. A console script has to get a date +from somewhere for `--date` to be optional, so it is the package's I/O +boundary and the one module allowed to look: see its `_today`. Everything it +calls is still inside the fixture, so the boundary cannot drift inwards +without a test failing. """ from __future__ import annotations import pytest -from charm_tech_code.changelog import _format +from charm_tech_code.changelog import _constants, _format, _parse, _version class _NoClock: @@ -48,6 +55,13 @@ class _NoClockModule: date = _NoClock +#: Every module of the library, whether or not it imports `datetime` today. +#: `raising=False` below means a module that does not import it is covered in +#: advance rather than having to be remembered when it does. +LIBRARY_MODULES = (_constants, _format, _parse, _version) + + @pytest.fixture(autouse=True) def no_clock(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(_format, 'datetime', _NoClockModule) + for module in LIBRARY_MODULES: + monkeypatch.setattr(module, 'datetime', _NoClockModule, raising=False) diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py index bad86d3..5a7e411 100644 --- a/changelog/tests/test_changelog.py +++ b/changelog/tests/test_changelog.py @@ -25,15 +25,25 @@ from __future__ import annotations +import contextlib import datetime +import io +import pathlib import unittest +from unittest import mock from charm_tech_code.changelog import ( CATEGORIES, CATEGORY_HEADINGS, + MINOR, + MINOR_BUMP_CATEGORIES, + PATCH, + _cli, commit_type_to_category, format_changes, format_release_notes, + infer_bump_size, + next_version, parse_release_notes, ) @@ -490,3 +500,212 @@ def test_every_category_has_a_heading(self): # Otherwise a category would render under a capitalised version of # its own key, which is only ever right by accident. assert set(CATEGORIES) <= set(CATEGORY_HEADINGS) + + +# The `chore` half of the 3.8.2 fixture on its own: eleven real pull requests, +# nothing else. A release with nothing in it but dependency bumps and charm +# pins is not hypothetical, and it is a patch. +OPERATOR_CHORE_ONLY_NOTES = '\n'.join( + line + for line in OPERATOR_3_8_2_NOTES.splitlines() + if line.startswith('* chore') or not line.startswith('*') +) + +# The one `!` pull request operator has merged into a 3.x release, by itself. +# The rest of the 3.7.1..3.8.0 range is what makes that release obviously a +# minor one; without it, the `!` has to carry the decision alone. +OPERATOR_BREAKING_ONLY_NOTES = """\ +## What's Changed +* refactor!: move the otlp-json package to be a regular ops-tracing module by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2585 + +**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0 +""" + + +def categories_of(notes: str) -> dict[str, list[tuple[str, str]]]: + return parse_release_notes(notes)[0] + + +class BumpSizeTests(unittest.TestCase): + """The rule -- a `feat` in the range means minor, otherwise patch -- against real releases.""" + + def test_a_release_with_no_features_is_a_patch(self): + # 3.8.1 -> 3.8.2: two fixes, seven docs, three CI, eleven chore. It + # shipped as a patch. + assert infer_bump_size(categories_of(OPERATOR_3_8_2_NOTES)) == PATCH + + def test_a_release_with_a_feature_is_a_minor(self): + # 3.7.1 -> 3.8.0, trimmed: one `feat` (#2555) among four pull + # requests. It shipped as a minor. + assert infer_bump_size(categories_of(OPERATOR_BREAKING_NOTES)) == MINOR + + def test_a_breaking_change_on_its_own_is_a_minor(self): + # #2585 is a `refactor!`, so on the plain reading of the rule -- "a + # `feat` in the range means minor" -- a release containing only it + # would be a patch, and a breaking change would ship in a patch + # release. A `!` does not infer a major bump, because we have decided + # to let a breaking change ride in a minor when the impact has been + # checked. Riding in a patch is not the same decision, and is not one + # anyone has made. So a `!` means at least minor. + assert infer_bump_size(categories_of(OPERATOR_BREAKING_ONLY_NOTES)) == MINOR + + def test_a_breaking_feature_is_still_a_minor(self): + # The regression this guards against: `parse_release_notes` *moves* a + # `!` entry out of its real type, so a range whose only feature is a + # `feat!` has an empty `feat` list. A rule that read `feat` alone + # would call this a patch. operator has not merged a `feat!` into a + # 3.x release, so this bullet is made up rather than lifted. + categories = categories_of( + '* feat!: replace the framework API by @someone in https://example.com/pull/1' + ) + assert categories['feat'] == [] + assert infer_bump_size(categories) == MINOR + + def test_a_release_of_nothing_but_chores_is_a_patch(self): + assert infer_bump_size(categories_of(OPERATOR_CHORE_ONLY_NOTES)) == PATCH + + def test_an_empty_range_is_a_patch(self): + assert infer_bump_size(categories_of('')) == PATCH + + def test_major_is_never_inferred(self): + # Even with every category populated, including breaking. A major + # release is the explicit version input's job. + categories = { + category: [(f'A {category} change', 'https://example.com/pull/1')] + for category in CATEGORIES + } + assert infer_bump_size(categories) == MINOR + + def test_the_minor_categories_are_categories(self): + # Otherwise this would be a second type list quietly diverging from + # the first: a renamed category would stop being a minor bump without + # anything saying so. + assert set(MINOR_BUMP_CATEGORIES) <= set(CATEGORIES) + + +class NextVersionTests(unittest.TestCase): + """Applying a size to a version. Generic semver, and nothing beyond it.""" + + def test_real_history(self): + # The two releases the fixtures above are taken from. + assert next_version('3.7.1', MINOR) == '3.8.0' + assert next_version('3.8.1', PATCH) == '3.8.2' + + def test_a_minor_bump_zeroes_the_patch(self): + assert next_version('3.8.2', MINOR) == '3.9.0' + + def test_components_are_numbers_not_digits(self): + assert next_version('3.9.9', PATCH) == '3.9.10' + assert next_version('2.23.16', PATCH) == '2.23.17' + assert next_version('3.9.1', MINOR) == '3.10.0' + + def test_surrounding_whitespace_is_tolerated(self): + # `--previous "$(git describe --tags --abbrev=0)"` arrives with a + # newline on it often enough to be worth not failing over. + assert next_version(' 3.8.1\n', PATCH) == '3.8.2' + + def test_a_dev_version_is_rejected(self): + # The one that matters. Between releases `ops/version.py` holds + # something like 3.9.0.dev0, and reaching for it as the previous + # version is the easy mistake: it is a guess made by the last + # post-release bump, not a version that was ever released. Bumping it + # would skip a version, and stripping the suffix silently would + # release whatever that guess happened to be. + with self.assertRaises(ValueError): + next_version('3.9.0.dev0', MINOR) + + def test_a_pre_release_is_rejected(self): + for version in ('3.8.0b1', '3.8.0rc1', '3.8.0a1'): + with self.assertRaises(ValueError): + next_version(version, MINOR) + + def test_a_tag_that_is_not_a_version_is_rejected(self): + for previous in ('v3.8.1', '3.8', '', 'main'): + with self.assertRaises(ValueError): + next_version(previous, PATCH) + + def test_an_unknown_size_is_rejected(self): + # Including 'major', which is not a size this package produces. + with self.assertRaises(ValueError): + next_version('3.8.1', 'major') # type: ignore[arg-type] + + def test_the_error_points_at_the_way_out(self): + with self.assertRaises(ValueError) as raised: + next_version('3.9.0.dev0', MINOR) + assert 'explicitly' in str(raised.exception) + + +class ConsoleScriptTests(unittest.TestCase): + """The `changelog` console script: notes on stdin, one answer on stdout.""" + + def run_cli(self, *argv: str, stdin: str = OPERATOR_3_8_2_NOTES) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with ( + mock.patch('sys.stdin', io.StringIO(stdin)), + contextlib.redirect_stdout(out), + contextlib.redirect_stderr(err), + ): + returncode = _cli.main(argv) + return returncode, out.getvalue(), err.getvalue() + + def test_bump_size_prints_one_bare_word(self): + # `SIZE=$(changelog bump-size < notes.md)` is the whole of the + # plumbing, so anything else on stdout -- a label, a prefix, JSON -- + # would have to be stripped back off in the workflow. + assert self.run_cli('bump-size') == (0, 'patch\n', '') + assert self.run_cli('bump-size', stdin=OPERATOR_BREAKING_NOTES) == (0, 'minor\n', '') + + def test_next_version_prints_one_bare_word(self): + assert self.run_cli('next-version', '--previous', '3.8.1') == (0, '3.8.2\n', '') + minor = self.run_cli('next-version', '--previous', '3.7.1', stdin=OPERATOR_BREAKING_NOTES) + assert minor == (0, '3.8.0\n', '') + + def test_next_version_fails_rather_than_guessing(self): + returncode, out, err = self.run_cli('next-version', '--previous', '3.9.0.dev0') + assert returncode == 2 + # Nothing on stdout: a workflow capturing this into a variable gets + # an empty one and a non-zero step, not a plausible wrong version. + assert out == '' + assert '3.9.0.dev0' in err + + def test_release_notes_is_the_library_output(self): + categories, full_changelog = parse_release_notes(OPERATOR_3_8_2_NOTES) + _, out, _ = self.run_cli('release-notes') + # The library does not end its notes with a newline; a file should. + assert out == format_release_notes(categories, full_changelog) + '\n' + + def test_changes_entry_is_the_library_output_byte_for_byte(self): + categories, _ = parse_release_notes(OPERATOR_3_8_2_NOTES) + _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2', '--date', '2026-08-31') + # Including the blank line it ends with: this text is prepended to + # CHANGES.md verbatim, so the trailing layout is part of the answer. + assert out == format_changes(categories, '3.8.2', datetime.date(2026, 8, 31)) + assert out.endswith('(#2699)\n\n') + + def test_changes_entry_defaults_to_today(self): + with mock.patch.object(_cli, '_today', return_value=datetime.date(2026, 9, 11)): + _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2') + assert out.startswith('# 3.8.2 - 11 September 2026\n') + + def test_the_clock_is_read_here_and_only_here(self): + # The `no_clock` fixture is active for this test, as it is for every + # other, and `_cli` is deliberately outside it. If this starts + # failing, the boundary has moved: something in the library is + # reading the clock, or `_today` has been moved in with it. + assert isinstance(_cli._today(), datetime.date) + + def test_an_unparseable_date_is_rejected(self): + with self.assertRaises(SystemExit): + self.run_cli('changes-entry', '--tag', '3.8.2', '--date', 'yesterday') + + def test_a_subcommand_is_required(self): + with self.assertRaises(SystemExit): + self.run_cli() + + def test_the_entry_point_names_something_that_exists(self): + # A renamed `main` breaks the console script without breaking a + # single test that calls `_cli.main` directly, so pin the string in + # pyproject.toml against the module. + pyproject = (pathlib.Path(__file__).parent.parent / 'pyproject.toml').read_text() + assert 'changelog = "charm_tech_code.changelog._cli:main"' in pyproject + assert callable(_cli.main) From b701580ec12acf811b8713d753708a2de2ee3b37 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 20:22:56 +0000 Subject: [PATCH 06/20] feat: read the changelog off the git log, not off the release notes The package took GitHub's generated release-notes text, which meant it read conventional-commit types off pull request titles. A title is written once, when the PR is opened; the convention governs the commits, and the squashed subject is what lands on the branch. It also meant a POST to releases/generate-notes, so the package that was meant to replace release.py's dependency on GitHub had quietly inherited it. `parse_git_log` is the primary input now, and the console script defaults to it. `parse_release_notes` stays, for a caller that has a release body in hand already. Three things come with it: * The pull request number comes from the `(#N)` a squash merge appends to the subject, and a change carries the number rather than a URL. `format_release_notes` builds the link back up from the number and a caller-supplied repo, so the no-I/O rule holds. A commit with no `(#N)` carries `None` and renders with no reference at all, rather than the `(#?)` that used to hide it. * A contributor from outside the team maintaining the repository is credited in the bullet, which is what operator's CHANGES.md has always done by hand. The team is a parameter, since it drifts and differs per repository, and an empty team credits everyone - over-crediting is visible in the draft release, and crediting nobody is not. * A revert of something in the same range cancels with it and neither appears. A revert of something already released is called out under Reverted, and a revert of a released `feat` goes to Breaking Changes, since taking away behaviour people may be relying on is a breaking change whatever the revert commit's own type says. operator's 3.8.1..3.8.2 and 3.7.1..3.8.0 render byte-for-byte identically from either input, which the suite checks. 125 tests, up from 57. The new commit fixtures are real operator history; the revert cases beyond the one real revert (#2568, of a chore) are constructed, and say so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018YMpsxxo5JJQMa8FGdMGwf --- README.md | 2 +- changelog/README.md | 75 +- changelog/pyproject.toml | 2 +- .../src/charm_tech_code/changelog/__init__.py | 38 +- .../src/charm_tech_code/changelog/_authors.py | 121 +++ .../src/charm_tech_code/changelog/_cli.py | 166 +++- .../charm_tech_code/changelog/_constants.py | 96 +- .../src/charm_tech_code/changelog/_format.py | 69 +- .../src/charm_tech_code/changelog/_models.py | 51 + .../src/charm_tech_code/changelog/_parse.py | 294 +++++- .../src/charm_tech_code/changelog/_version.py | 21 +- changelog/tests/conftest.py | 4 +- changelog/tests/test_changelog.py | 925 ++++++++++++++++-- 13 files changed, 1663 insertions(+), 201 deletions(-) create mode 100644 changelog/src/charm_tech_code/changelog/_authors.py create mode 100644 changelog/src/charm_tech_code/changelog/_models.py diff --git a/README.md b/README.md index ba0de99..429e50e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Each tool is its own package in its own top-level directory, with its own `pypro | directory | what it does | |---|---| | [`ai-failure-notifier`](ai-failure-notifier) | Triages and enriches the issue opened when a scheduled workflow fails. | -| [`changelog`](changelog) | Turns GitHub's generated release notes into our changelog format. | +| [`changelog`](changelog) | Turns a range of commits into our changelog format. | Code here is consumed by workflow YAML in the repository that runs it, pinned by commit SHA: diff --git a/changelog/README.md b/changelog/README.md index 4f983b6..d1603fe 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -1,6 +1,6 @@ # changelog -Turns GitHub's generated release notes into our changelog format. +Turns a range of commits into our changelog format. The Charm Tech repositories have different release processes, but aim for a consistent changelog style. The formatting and the version arithmetic are centralised here, the file rewriting and the GitHub calls are in each repository. @@ -8,44 +8,93 @@ The Charm Tech repositories have different release processes, but aim for a cons ```python import datetime +import subprocess from charm_tech_code.changelog import ( + GIT_LOG_FORMAT, format_changes, format_release_notes, infer_bump_size, next_version, - parse_release_notes, + parse_git_log, ) -categories, full_changelog = parse_release_notes(notes_text) -notes = format_release_notes(categories, full_changelog) +log = subprocess.run( + ['git', 'log', '--reverse', '--no-merges', f'--format={GIT_LOG_FORMAT}', '3.8.1..3.8.2'], + capture_output=True, text=True, check=True, +).stdout +categories = parse_git_log(log, team=MAINTAINERS, repo='canonical/operator') +notes = format_release_notes(categories, None, repo='canonical/operator') version = next_version('3.8.1', infer_bump_size(categories)) entry = format_changes(categories, version, datetime.date.today()) ``` -`notes_text` is GitHub's *generated* release-notes text, not a `git log`. GitHub builds it from the titles of the pull requests merged in the range, which is why the conventional-commit types come off PR titles. A release already has that text in its body; a workflow running before any release exists can ask for a preview of it with `POST /repos/{owner}/{repo}/releases/generate-notes`. Either way, getting hold of it is the caller's job: nothing in the library touches the network, git, the filesystem or the clock, and `format_changes` takes the date as an argument for the same reason. +Running `git log` is the caller's job, as is getting hold of the date: nothing in the library touches the network, git, the filesystem or the clock. That is what lets the tests pin the real behaviour rather than approximate it. + +### Why the commits, and not the release notes + +`parse_release_notes` is still here, and reads GitHub's *generated* release-notes text - which GitHub builds from the titles of the pull requests merged in the range. A caller that has a release body in hand should not have to go and fetch a git log to use it. But it is the weaker input, and for a new caller it is the wrong one: + +* **A pull-request title is not a commit subject.** The convention governs commits; a title is written once, when the PR is opened, and can drift from the subject its squash merge lands. When they disagree, the commits are what the repository actually contains. +* **A revert can only be resolved from a git log.** Whether a revert cancels something in the same range is in the revert commit's *body*, and the generated notes are one line per PR with no bodies in them at all. +* **It needs GitHub.** `POST /repos/{owner}/{repo}/releases/generate-notes` is a call, a token and a network. `git log` is neither. + +The two paths otherwise agree: `canonical/operator`'s 3.8.1..3.8.2 and 3.7.1..3.8.0 render byte-for-byte identically from either, which the test suite checks. + +### The pull-request number, and the link + +A change carries the *number*, taken from the `(#N)` a squash merge appends to the subject, and never a URL. `format_changes` only ever wanted the number, and `format_release_notes` builds the link back up from the number and the `repo` you give it - a string operation, so the no-I/O rule holds. + +A commit with no `(#N)` - one pushed straight to the branch - carries `None`, and renders with no reference at all rather than with a `(#?)` standing in for one. It is a real change; what it has not got is a pull request to point anyone at. + +### Credit + +A contributor from outside the team maintaining the repository is named in the bullet: `* Fix typos in code snippets by @MattiaSarti (#1750)`, which is what operator's own `CHANGES.md` has always done by hand. A member of that team is not - a maintainer is not a guest, and a changelog whose every line ends in the same three handles has stopped carrying information. + +Pass the team as `team=`, a collection of email addresses and/or GitHub handles. It is a parameter rather than a constant because it drifts, and it differs per repository. **An empty team credits everyone**, which is the right way for this to fail: over-crediting is visible in the draft release and takes one edit, while crediting nobody is invisible until a contributor notices. + +Two things about who is outside: + +* **"Outside the team" is not "outside Canonical".** Someone from another Canonical team has an `@canonical.com` address and every bit as much claim to the credit. +* **A handle is only sometimes recoverable.** `46688206+Ali-932@users.noreply.github.com` gives `@Ali-932`, which is GitHub's default for an account with a private email and so the usual case for a drive-by contributor. Where there is no handle in the log, the person is credited by name, because dropping them and rendering a broken `@` are both worse. + +### Reverts + +Handled explicitly, on the git-log path: + +* **A revert of something in the same range cancels with it**, and neither appears. A change that landed and was taken back out before anything shipped did not happen as far as a reader is concerned. +* **A revert of something already released is called out**, under its own `Reverted` heading rather than filed under the type it undoes - the reader wants to see that something was withdrawn, not a fix that looks new. It counts at least as a patch, and a revert of a released *feature* is routed to `Breaking Changes`, because taking away behaviour people may be relying on is a breaking change whatever the revert commit's type says. + +The key is the pull-request number in `Reverts owner/repo#N`, not a SHA. Under squash merging the reverted commit's SHA on the default branch bears no relation to anything a contributor would cite. ## From a workflow step -The console script is the same thing for a caller that can't `import`. It reads the notes on stdin and prints one answer: +The console script is the same thing for a caller that can't `import`. It reads the range on stdin and prints one answer: ```shell -gh api "repos/$REPO/releases/generate-notes" -f tag_name="$TAG" -f target_commitish="$BRANCH" --jq .body > notes.md -SIZE=$(changelog bump-size < notes.md) -VERSION=$(changelog next-version --previous "$LAST_TAG" < notes.md) -changelog release-notes < notes.md > release-notes.md -changelog changes-entry --tag "$VERSION" < notes.md > changes-entry.md +git log --reverse --no-merges --format="$(changelog git-log-format)" "$LAST_TAG..$BRANCH" > log.txt +SIZE=$(changelog bump-size --team "$TEAM" < log.txt) +VERSION=$(changelog next-version --previous "$LAST_TAG" --team "$TEAM" < log.txt) +changelog release-notes --repo "$REPO" --team "$TEAM" \ + --compare-url "https://github.com/$REPO/compare/$LAST_TAG...$VERSION" < log.txt > release-notes.md +changelog changes-entry --tag "$VERSION" --team "$TEAM" < log.txt > changes-entry.md ``` That shape comes from how an Actions step consumes a result. A `$GITHUB_OUTPUT` line takes a scalar comfortably and a multi-line document only through a heredoc delimiter the document itself must not contain, so the two commands that produce Markdown print it on stdout for the step to redirect into a file, and the two that produce a scalar print a single bare word, with no label and no JSON to unwrap. Nothing here writes to `$GITHUB_OUTPUT` itself, which keeps the script useful outside Actions. Four invocations re-parse the same text four times. That costs nothing worth counting, and it is the reason each step's output needs no reshaping. +`git-log-format` is the fifth and the odd one out: it reads nothing, and prints the `--format` string the others expect. Copying that string into the workflow instead would work until someone dropped a separator out of it, and a log that does not parse yields an empty changelog rather than an error. + +`--compare-url` is there because a git log has no equivalent of the line GitHub's generated notes end with, and the tags at either end of the range are the workflow's to know. Leave it off for no link. + +`--input release-notes` switches all four back to the older input. + `--date` defaults to today (UTC), and `_cli` is the only module in the package that reads the clock. The library stays clock-free, and a fixture in the test suite fails the whole run if that stops being true. Run it from a workflow the way `ai-failure-notifier` is run, pinned to a commit: ```shell -uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=changelog" changelog bump-size < notes.md +uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=changelog" changelog bump-size < log.txt ``` ## Versions @@ -74,7 +123,7 @@ Neither shape is injectable, and neither is the map of commit type to heading. T Two things about that map are worth knowing before you decide it's wrong: * `chore` is a type but not a category, so `chore` commits are deliberately dropped. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and these sorts of changes are not interesting to our users, and they are available via `git log` if anyone does want them. -* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A `!` should be a major version bump, but if it's appearing here then we have decided to cheat the semver rules and allow a breaking change in a minor release. This should be rare. We will have carefully checked the impact before this decision, but want to make sure the change is particularly noticeable in the changelog. +* `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A revert of a released `feat` lands there too, for the reason in "Reverts" above. A `!` should be a major version bump, but if it's appearing here then we have decided to cheat the semver rules and allow a breaking change in a minor release. This should be rare. We will have carefully checked the impact before this decision, but want to make sure the change is particularly noticeable in the changelog. ## Developing diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml index 2bd8450..c15cf86 100644 --- a/changelog/pyproject.toml +++ b/changelog/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "charm-tech-code-changelog" version = "0.1.0" -description = "Turn GitHub's generated release notes into our changelog format." +description = "Turn a range of commits into our changelog format." readme = "README.md" requires-python = ">=3.10" authors = [ diff --git a/changelog/src/charm_tech_code/changelog/__init__.py b/changelog/src/charm_tech_code/changelog/__init__.py index 032ba47..5d65218 100644 --- a/changelog/src/charm_tech_code/changelog/__init__.py +++ b/changelog/src/charm_tech_code/changelog/__init__.py @@ -13,22 +13,33 @@ # limitations under the License. -"""Turn GitHub's generated release-notes text into our changelog format. +"""Turn a range of commits into our changelog format. Text in, structured data and formatted strings out. Nothing here touches the -network, git, the filesystem or the clock, so a caller supplies the notes -text and the date and decides what to do with what comes back:: +network, git, the filesystem or the clock, so a caller supplies the log text +and the date and decides what to do with what comes back:: - categories, full_changelog = parse_release_notes(notes_text) - notes = format_release_notes(categories, full_changelog) + log = subprocess.run( + ['git', 'log', '--reverse', '--no-merges', f'--format={GIT_LOG_FORMAT}', '3.8.1..3.8.2'], + capture_output=True, text=True, check=True, + ).stdout + categories = parse_git_log(log, team=MAINTAINERS, repo='canonical/operator') + notes = format_release_notes(categories, None, repo='canonical/operator') entry = format_changes(categories, '3.8.2', datetime.date.today()) -The same parse also answers how big a release the range adds up to, and what -that makes the version after `previous`:: +The same parse answers how big a release the range adds up to, and what that +makes the version after `previous`:: size = infer_bump_size(categories) version = next_version(previous, size) +`parse_release_notes` is the other door in, reading GitHub's generated +release-notes text instead of the commits. It describes the same pull +requests by their *titles*, which is a weaker source -- a title is written +once, at open time, while the convention governs the commits -- and it +cannot resolve a revert at all, because that needs a commit body. Prefer +`parse_git_log`; see `_parse` for the full list of differences. + The format is the package's own, not a parameter -- see `_constants` for what that means and why `chore` commits do not appear in a changelog. The `changelog` console script (`_cli`) wraps all of the above for a workflow @@ -37,22 +48,31 @@ from __future__ import annotations -from ._constants import CATEGORIES, CATEGORY_HEADINGS, MINOR_BUMP_CATEGORIES +from ._constants import ( + CATEGORIES, + CATEGORY_HEADINGS, + GIT_LOG_FORMAT, + MINOR_BUMP_CATEGORIES, +) from ._format import commit_type_to_category, format_changes, format_release_notes -from ._parse import parse_release_notes +from ._models import Change +from ._parse import parse_git_log, parse_release_notes from ._version import MINOR, PATCH, BumpSize, infer_bump_size, next_version __all__ = [ 'CATEGORIES', 'CATEGORY_HEADINGS', + 'GIT_LOG_FORMAT', 'MINOR', 'MINOR_BUMP_CATEGORIES', 'PATCH', 'BumpSize', + 'Change', 'commit_type_to_category', 'format_changes', 'format_release_notes', 'infer_bump_size', 'next_version', + 'parse_git_log', 'parse_release_notes', ] diff --git a/changelog/src/charm_tech_code/changelog/_authors.py b/changelog/src/charm_tech_code/changelog/_authors.py new file mode 100644 index 0000000..1f9f94e --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_authors.py @@ -0,0 +1,121 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Who gets credited in a changelog, and how. + +The rule is that a contributor from outside the team that maintains the +repository is credited by name in the entry, and a member of that team is +not. Someone maintaining a project is not a guest in it, and a changelog +where every line ends in the same three handles has stopped carrying any +information; a line that names someone who turned up once and fixed +something is the one worth reading. `canonical/operator`'s own `CHANGES.md` +already does this by hand -- `* Fix typos in code snippets by @MattiaSarti +(#1750)` -- which is the shape this reproduces. + +**"Outside the team" is not "outside Canonical".** A contributor from +another Canonical team has an `@canonical.com` address, no GitHub handle +anyone can derive from it, and every bit as much claim to the credit as a +stranger does. They are credited by name. + +**The team is a parameter, not a constant.** It drifts -- people join and +leave -- and it differs per repository, so a list baked in here would be +wrong somewhere from the day it was written. An empty team credits everyone, +which is the right way for this to fail: over-crediting is visible in a +draft release and takes one edit to fix, while quietly crediting nobody is +invisible until a contributor notices they were left out. + +The git log gives a name and an email, never a handle, and only some of that +is recoverable: see `NOREPLY_EMAIL_REGEX`. Where it is not, the person is +credited by their name, because the alternatives are to drop them or to +render a handle that does not exist. +""" + +from __future__ import annotations + +from collections.abc import Collection + +from ._constants import NOREPLY_EMAIL_REGEX + + +def normalise_team(team: Collection[str]) -> frozenset[str]: + """Fold a caller's team list into something to compare against. + + Entries may be email addresses or GitHub handles, with or without a + leading `@`, in any mixture: a caller assembling the list from a team + page has both to hand and should not have to decide which kind each one + is. Comparison is case-insensitive, since neither an email address nor a + GitHub handle distinguishes case. + """ + return frozenset(member.strip().lstrip('@').casefold() for member in team if member.strip()) + + +def derive_handle(email: str) -> str | None: + """Recover a GitHub handle from an author email, if it is in there. + + Only a `users.noreply.github.com` address carries one. That is not the + narrow case it sounds like: it is what GitHub commits as by default when + an account keeps its email private, so it is the usual form for a + drive-by contributor, which is the author this most needs to name. + + Returns: + The handle without its `@`, or `None` for any other address. + """ + match = NOREPLY_EMAIL_REGEX.match(email.strip()) + return match.group('handle') if match else None + + +def credit_for(name: str, email: str, team: Collection[str]) -> str | None: + """Work out how to credit the author of a commit, from a git log. + + Args: + name: The author name, as `%an` gives it. + email: The author email, as `%ae` gives it. + team: The maintainers, as emails and/or handles. See `normalise_team`. + + Returns: + `@handle` when a handle can be recovered from the email, the name + when it cannot, or `None` when this author is one of `team` and so + is not a guest to be thanked. + """ + members = normalise_team(team) + handle = derive_handle(email) + if email.strip().casefold() in members: + return None + if handle is not None and handle.casefold() in members: + return None + return f'@{handle}' if handle is not None else name.strip() or None + + +def credit_for_handle(handle: str, team: Collection[str]) -> str | None: + """The same decision, for a source that gives a handle and nothing else. + + GitHub's generated release notes name the author as `@handle` and say + nothing about who that is, so this is all that path has to go on. It is + also why the two input paths can disagree: a contributor with no + `users.noreply.github.com` address is credited by name from the git log + and by handle from the notes, and no amount of parsing fixes that -- the + handle simply is not in the git log. + + Args: + handle: The author as the notes name them, `@` optional. + team: The maintainers, as emails and/or handles. + + Returns: + `@handle`, or `None` when this author is one of `team`. + """ + bare = handle.strip().lstrip('@') + if not bare or bare.casefold() in normalise_team(team): + return None + return f'@{bare}' diff --git a/changelog/src/charm_tech_code/changelog/_cli.py b/changelog/src/charm_tech_code/changelog/_cli.py index 82d7518..19f7bd5 100644 --- a/changelog/src/charm_tech_code/changelog/_cli.py +++ b/changelog/src/charm_tech_code/changelog/_cli.py @@ -13,7 +13,7 @@ # limitations under the License. -"""The console script: notes text on stdin, one answer on stdout. +"""The console script: a range of changes on stdin, one answer on stdout. **This module is the package's I/O boundary, and the only one.** Everything under it is text in, text out -- no network, no git, no filesystem, no clock @@ -24,22 +24,36 @@ releases. If you find yourself wanting the clock, or a file, or an API call, in any other module of this package: it goes here instead. +Note what is *not* here: running `git log`. The log text arrives on stdin +exactly as the notes text does, so the boundary this module draws is around +the clock and the standard streams, and the caller keeps the subprocess. + The shape is driven by how a GitHub Actions step consumes a result, which is either as a `$GITHUB_OUTPUT` line or as a file. A `$GITHUB_OUTPUT` line takes a scalar comfortably and a multi-line document only through a heredoc delimiter that the document itself must not contain -- so the two commands that emit Markdown emit it on stdout, for the step to redirect into a file, and the two that emit a scalar emit a single bare word with no decoration, so -that `size=$(changelog bump-size < notes.md)` is the whole of the plumbing:: +that `size=$(changelog bump-size < log.txt)` is the whole of the plumbing:: - gh api "repos/$REPO/releases/generate-notes" ... --jq .body > notes.md - SIZE=$(changelog bump-size < notes.md) - VERSION=$(changelog next-version --previous "$LAST_TAG" < notes.md) - changelog release-notes < notes.md > release-notes.md - changelog changes-entry --tag "$VERSION" < notes.md > changes-entry.md + git log --reverse --no-merges --format="$(changelog git-log-format)" \\ + "$LAST_TAG..$BRANCH" > log.txt + SIZE=$(changelog bump-size --team "$TEAM" < log.txt) + VERSION=$(changelog next-version --previous "$LAST_TAG" --team "$TEAM" < log.txt) + changelog release-notes --repo "$REPO" --team "$TEAM" < log.txt > release-notes.md + changelog changes-entry --tag "$VERSION" --team "$TEAM" < log.txt > changes-entry.md -Four invocations re-parse the same text four times, which costs nothing and +Five invocations re-parse the same text four times, which costs nothing and buys each step an output that goes where it belongs without any reshaping. +`git-log-format` is the odd one out: it reads nothing and prints the +`--format` string the other four expect, so that the exact sequence of +`%x1e` and `%x1f` lives in one place rather than being copied into every +workflow that calls this. + +`--input release-notes` switches the four back to GitHub's generated +release-notes text. That is the older path and the weaker one -- it reads +pull-request titles rather than commit subjects, and cannot resolve a revert +-- so it is the flag rather than the default. """ from __future__ import annotations @@ -49,10 +63,17 @@ import sys from collections.abc import Sequence +from ._constants import FULL_CHANGELOG_PREFIX, GIT_LOG_FORMAT from ._format import format_changes, format_release_notes -from ._parse import parse_release_notes +from ._models import Change +from ._parse import parse_git_log, parse_release_notes from ._version import infer_bump_size, next_version +#: The two things stdin may be. `git-log` is the default: see the module +#: docstring, and `_parse`. +GIT_LOG_INPUT = 'git-log' +RELEASE_NOTES_INPUT = 'release-notes' + def _today() -> datetime.date: """The default for `--date`, and the package's only reading of the clock. @@ -74,21 +95,69 @@ def _emit(text: str) -> None: sys.stdout.write(text if text.endswith('\n') else text + '\n') +def _input_options() -> argparse.ArgumentParser: + """The options that say what is arriving on stdin. + + Shared by the four subcommands that read it, as a parent parser, so that + a caller switching input path changes one flag on every command rather + than learning four spellings of it. + """ + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument( + '--input', + choices=(GIT_LOG_INPUT, RELEASE_NOTES_INPUT), + default=GIT_LOG_INPUT, + help=( + "What is on stdin: a git log in this tool's format (the default, " + "and the better source), or GitHub's generated release-notes text." + ), + ) + parser.add_argument( + '--team', + action='append', + default=None, + metavar='EMAIL-OR-HANDLE,...', + help=( + 'Authors not to credit, comma-separated, as email addresses ' + 'and/or GitHub handles. Repeatable. These are the people who ' + 'maintain the repository; everyone else is credited by handle, ' + 'or by name where no handle can be worked out. The default ' + 'credits everyone.' + ), + ) + return parser + + +def _repo_option(parser: argparse.ArgumentParser, *, required: bool) -> None: + parser.add_argument( + '--repo', + required=required, + metavar='OWNER/NAME', + help=( + 'The repository the pull-request links point into. A change ' + 'carries a number rather than a URL -- a number is all a git log ' + 'has -- so the links are built from this.' + ), + ) + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog='changelog', description=( - "Turn GitHub's generated release-notes text, read from stdin, into " - 'our changelog format or into a version decision.' + 'Turn a range of changes, read from stdin, into our changelog ' + 'format or into a version decision.' ), ) subparsers = parser.add_subparsers(dest='command', required=True) + shared = [_input_options()] subparsers.add_parser( 'bump-size', - help="Print 'minor' or 'patch' for the changes in the notes.", + parents=shared, + help="Print 'minor' or 'patch' for the changes on stdin.", description=( - "Print 'minor' if the notes contain a feature or a breaking change, " + "Print 'minor' if the range contains a feature or a breaking change, " "and 'patch' otherwise. Never 'major': that is a deliberate act, not " 'something to infer. On a branch where a feature should not appear at ' 'all, such as a maintenance branch, treat a "minor" here as an error ' @@ -98,7 +167,8 @@ def _build_parser() -> argparse.ArgumentParser: next_version_parser = subparsers.add_parser( 'next-version', - help='Print the version that follows --previous, given the changes in the notes.', + parents=shared, + help='Print the version that follows --previous, given the changes on stdin.', description=( 'Apply the inferred bump size to --previous and print the result. ' 'Only a plain X.Y.Z is accepted; whether the answer then gains a ' @@ -113,24 +183,39 @@ def _build_parser() -> argparse.ArgumentParser: help='The version this release follows, normally the last tag on the branch.', ) - subparsers.add_parser( + release_notes_parser = subparsers.add_parser( 'release-notes', + parents=shared, help='Print the release body, as Markdown.', description=( 'Print the body of a GitHub release: the changes by category, ' - 'breaking ones first, and the full-changelog link if the notes ' - 'carried one.' + 'breaking ones first, and a compare link at the end if there is ' + 'one to print.' + ), + ) + _repo_option(release_notes_parser, required=True) + release_notes_parser.add_argument( + '--compare-url', + default=None, + metavar='URL', + help=( + 'The compare link to end on. A git log has no equivalent of the ' + "line GitHub's generated notes end with, so on that path this is " + 'how to keep one; it overrides the line in the notes on the other. ' + 'Omit it for no link at all.' ), ) changes_entry_parser = subparsers.add_parser( 'changes-entry', + parents=shared, help='Print one CHANGES.md entry, as Markdown.', description=( 'Print a single CHANGES.md entry for the release, to be prepended ' 'to the existing file.' ), ) + _repo_option(changes_entry_parser, required=False) changes_entry_parser.add_argument( '--tag', required=True, @@ -144,13 +229,52 @@ def _build_parser() -> argparse.ArgumentParser: help="The release date. Defaults to today's date, in UTC.", ) + subparsers.add_parser( + 'git-log-format', + help='Print the git log --format string the other commands expect.', + description=( + 'Print the --format string to pass to `git log`, and nothing else: ' + '`git log --format="$(changelog git-log-format)"`. Copying the ' + 'string into a workflow instead would work until someone dropped a ' + 'separator out of it, at which point the parse yields an empty ' + 'changelog rather than an error, and a release goes out with ' + 'nothing in its notes.' + ), + ) + return parser +def _team(args: argparse.Namespace) -> list[str]: + """`--team a,b --team c` as one flat list. + + Both spellings, because a workflow passing a repository variable has one + string with commas in it and a human typing the command has neither. + """ + return [member for group in (args.team or ()) for member in group.split(',')] + + +def _categories(args: argparse.Namespace, text: str) -> tuple[dict[str, list[Change]], str | None]: + """Parse stdin by whichever door `--input` names. + + The second half of the pair is the compare line, which only the notes + path can produce for itself. + """ + if args.input == RELEASE_NOTES_INPUT: + return parse_release_notes(text, team=_team(args)) + repo = getattr(args, 'repo', None) + return parse_git_log(text, team=_team(args), repo=repo), None + + def main(argv: Sequence[str] | None = None) -> int: - """Parse the notes on stdin and print the answer the subcommand asks for.""" + """Parse the changes on stdin and print the answer the subcommand asks for.""" args = _build_parser().parse_args(argv) - categories, full_changelog = parse_release_notes(sys.stdin.read()) + + if args.command == 'git-log-format': + _emit(GIT_LOG_FORMAT) + return 0 + + categories, full_changelog = _categories(args, sys.stdin.read()) if args.command == 'bump-size': _emit(infer_bump_size(categories)) @@ -161,7 +285,9 @@ def main(argv: Sequence[str] | None = None) -> int: print(f'changelog: {exc}', file=sys.stderr) return 2 elif args.command == 'release-notes': - _emit(format_release_notes(categories, full_changelog)) + if args.compare_url: + full_changelog = f'{FULL_CHANGELOG_PREFIX}: {args.compare_url}' + _emit(format_release_notes(categories, full_changelog, repo=args.repo)) else: _emit(format_changes(categories, args.tag, args.date or _today())) diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py index d688017..f0147db 100644 --- a/changelog/src/charm_tech_code/changelog/_constants.py +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -30,13 +30,19 @@ #: ``* type!: summary by @user in https://github.com/owner/repo/pull/123``. #: The ``!`` is optional and marks a breaking change. CHANGE_LINE_REGEX = re.compile( - r'^\* (?P\w+)(?P!?): (?P.*) by [^ ]+ in (?P.*)' + r'^\* (?P\w+)(?P!?): (?P.*) by (?P[^ ]+) in (?P.*)' ) -#: The PR link in a bullet, from which the ``(#123)`` in a ``CHANGES.md`` -#: entry is taken. +#: The PR link in a bullet, from which the pull-request number is taken. PR_LINK_REGEX = re.compile(r'https?://[^ ]+/pull/(\d+)') +#: How a pull-request link is rebuilt from a number. Both parsers reduce a +#: change to its *number*, because that is all the git log carries and all a +#: ``CHANGES.md`` entry renders, so the URL the release notes want is built +#: back up from the number and the repository the caller names. That is a +#: string operation, which is what keeps the package free of I/O. +PULL_REQUEST_URL_TEMPLATE = 'https://github.com/{repo}/pull/{number}' + #: GitHub appends a section of first-time contributors to its generated #: notes. It is not part of the changelog, so it is stripped before parsing. NEW_CONTRIBUTORS_REGEX = re.compile(r'(## New Contributors.*?)(\n|$)', flags=re.DOTALL) @@ -45,6 +51,75 @@ #: It is passed through to the release notes unchanged. FULL_CHANGELOG_PREFIX = '**Full Changelog**' +#: The ``git log --format=`` string `parse_git_log` expects, and the two +#: control characters it is built out of. +#: +#: The fields are the *author* name and email, the subject, and the body. +#: Author rather than committer: a squash merge records the contributor as +#: the author and GitHub itself as the committer, so the committer is never +#: the person to credit. There is no SHA, deliberately -- a squashed commit's +#: SHA on the default branch has no relation to anything a contributor would +#: cite, and the pull-request number in the subject is the key that does. +#: +#: The separators are ASCII 0x1e (record) and 0x1f (unit), which is what they +#: are for. A commit message may contain anything else, newlines and blank +#: lines very much included, so a line-oriented or blank-line-delimited format +#: would be guessing at where one commit stops and the next starts:: +#: +#: git log --reverse --no-merges --format="$FORMAT" 3.8.1..3.8.2 +#: +#: ``--reverse`` because a changelog reads oldest first, which is also the +#: order GitHub's generated notes come in. ``--no-merges`` because a merge +#: commit's subject is not a conventional-commit one; such a subject is +#: dropped anyway, so this is tidiness rather than correctness. +GIT_LOG_RECORD_SEPARATOR = '\x1e' +GIT_LOG_FIELD_SEPARATOR = '\x1f' +GIT_LOG_FORMAT = '%x1e%an%x1f%ae%x1f%s%x1f%b' + +#: A conventional-commit subject, as the shared `check-conventional-pr-title` +#: script defines it: a type, an optional scope, an optional ``!``, then the +#: summary. The scope is captured and ignored -- no repository in the estate +#: uses one today, but the checker accepts one, and the two should not +#: disagree about what a valid subject looks like. +COMMIT_SUBJECT_REGEX = re.compile( + r'^(?P[A-Za-z]+)' + r'(?:\((?P[^()]+)\))?' + r'(?P!?)' + r': (?P.+)$' +) + +#: The ``(#123)`` that a squash merge appends to the subject, and the only +#: place the pull-request number comes from on the git-log path. Over +#: `canonical/operator`'s last 300 commits every subject carries one; the +#: commits that do not are older, from before the squash-merge policy, and a +#: commit pushed straight to the default branch would not have one either. +#: Such a change is real and belongs in the changelog, so it is carried with +#: no number rather than with a placeholder that hides it. +PR_SUFFIX_REGEX = re.compile(r'\s*\(#(\d+)\)$') + +#: What GitHub's "Revert" button writes into the body of the revert pull +#: request: ``Reverts canonical/operator#2538``. It names the *pull request* +#: rather than a commit, which is the robust key under squash merging, since +#: the reverted commit's SHA on the default branch is not something anyone +#: cites. The owner/repo part is optional because a body written by hand +#: often leaves it off. +REVERTS_REGEX = re.compile( + r'^[ \t]*Reverts[ \t]+(?:(?P[\w.-]+/[\w.-]+))?#(?P\d+)[ \t]*$', + flags=re.MULTILINE | re.IGNORECASE, +) + +#: A GitHub no-reply address, which is the one author email a handle can be +#: recovered from: ``46688206+Ali-932@users.noreply.github.com`` is +#: ``@Ali-932``. It is the default for a GitHub account with a private email, +#: so it is the common form for exactly the drive-by external contributor +#: this is here to credit. The older suffix-free form is accepted too, and so +#: is the ``[bot]`` a GitHub App's address carries. +NOREPLY_EMAIL_REGEX = re.compile( + r'^(?:\d+\+)?(?P[A-Za-z\d](?:[A-Za-z\d]|-(?=[A-Za-z\d]))*(?:\[bot\])?)' + r'@users\.noreply\.github\.com$', + flags=re.IGNORECASE, +) + #: The categories a changelog has, in the order they are rendered. #: #: This is also the filter. A conventional-commit type that is not a key here @@ -79,6 +154,21 @@ #: The one real conventional-commit type the bump-size rule cares about. FEATURE = 'feat' +#: The type of a commit that undoes another one. +REVERT = 'revert' + +#: Reverting one of these is a removal of behaviour people may already be +#: relying on, so the revert is routed to `BREAKING` rather than left under +#: `REVERT`. Only a revert of something *already released* gets this far: a +#: revert of a change in the same range cancels with it and neither appears. +#: +#: `feat` is the case that matters, and it is the reason this exists at all: +#: a released feature that is taken away again is a breaking change by any +#: reading, and filing it under `Reverted` would put it below the fold of a +#: changelog that the affected reader needs to see the top of. An inner `!` +#: is treated the same way, for the same reason and more obviously. +REVERT_OF_BREAKING_TYPES: tuple[str, ...] = (FEATURE,) + #: The categories whose presence in a range makes the release a minor one. #: Everything else -- and an empty range -- is a patch. #: diff --git a/changelog/src/charm_tech_code/changelog/_format.py b/changelog/src/charm_tech_code/changelog/_format.py index 27184f8..9a5c227 100644 --- a/changelog/src/charm_tech_code/changelog/_format.py +++ b/changelog/src/charm_tech_code/changelog/_format.py @@ -25,8 +25,9 @@ BREAKING, BREAKING_PREAMBLE, CATEGORY_HEADINGS, - PR_LINK_REGEX, + PULL_REQUEST_URL_TEMPLATE, ) +from ._models import Change logger = logging.getLogger(__name__) @@ -39,8 +40,25 @@ def commit_type_to_category(commit_type: str) -> str: return CATEGORY_HEADINGS.get(commit_type, commit_type.capitalize()) +def _bullet(change: Change, reference: str | None) -> str: + """One `* ...` line: the change, who to thank, and where it came from. + + The three pieces are each optional after the first, and a missing one + takes its separator with it rather than leaving `by (#)` behind. The + credit sits before the reference because that is where + `canonical/operator`'s hand-written entries have always put it: + `* Fix typos in code snippets by @MattiaSarti (#1750)`. + """ + parts = [f'* {change.description}'] + if change.credit: + parts.append(f'by {change.credit}') + if reference: + parts.append(reference) + return ' '.join(parts) + + def format_release_notes( - categories: Mapping[str, list[tuple[str, str]]], full_changelog: str | None + categories: Mapping[str, list[Change]], full_changelog: str | None, *, repo: str ) -> str: """Format for release notes. @@ -49,15 +67,25 @@ def format_release_notes( Breaking changes are rendered first, under their own heading and a sentence asking the reader to review them. `categories` is expected to - be what `parse_release_notes` returned: every category present, in the - order they are rendered in. + be what `parse_git_log` or `parse_release_notes` returned: every + category present, in the order they are rendered in. + + Args: + categories: The parsed changes. + full_changelog: The compare line to end on, or `None`. A git log has + no equivalent of it, so a caller on that path either leaves it + out or builds one, knowing the tags at both ends. + repo: The `owner/name` the pull-request links point into. It is + needed because a `Change` carries a number and not a URL -- the + number is all a git log has, and all a `CHANGES.md` entry shows, + so the link is built here rather than carried around. A change + with no pull request renders with no link. """ lines = ["## What's Changed", ''] if categories[BREAKING]: lines.append(f'### {commit_type_to_category(BREAKING)}') lines.append(f'{BREAKING_PREAMBLE}\n') - for description, pr_link in categories[BREAKING]: - lines.append(f'* {description} in {pr_link}') + lines.extend(_bullet(change, _link(change, repo)) for change in categories[BREAKING]) lines.append('') logger.info( 'Breaking changes detected in the release notes. ' @@ -68,23 +96,33 @@ def format_release_notes( continue if items: lines.append(f'### {commit_type_to_category(commit_type)}') - for description, pr_link in items: - lines.append(f'* {description} in {pr_link}') + lines.extend(_bullet(change, _link(change, repo)) for change in items) lines.append('') if full_changelog: lines.append(full_changelog) return '\n'.join(lines) -def format_changes( - categories: Mapping[str, list[tuple[str, str]]], tag: str, date: datetime.date -) -> str: +def _link(change: Change, repo: str) -> str | None: + """The `in ` half of a release-notes bullet, or nothing.""" + if change.pr_number is None: + return None + return 'in ' + PULL_REQUEST_URL_TEMPLATE.format(repo=repo, number=change.pr_number) + + +def format_changes(categories: Mapping[str, list[Change]], tag: str, date: datetime.date) -> str: """Format for CHANGES.md. The header is formatted as a top-level heading with the tag and date. The content is a Markdown formatted string with sections for each commit type. Each item is formatted as a bullet point with the description and PR number in parentheses. + A change with no pull request behind it -- a commit pushed straight to + the branch -- gets no `(#N)` rather than a `(#?)` standing in for one. + The change is real and belongs in the list; what it does not have is a + pull request to point anyone at, and saying so plainly beats a + placeholder that reads like a parsing accident. + `date` is passed in rather than read from the clock. This module does no I/O of any kind, and "what day is it" is I/O: the caller knows whether it means the runner's today, the date on the tag, or a date under test. @@ -94,11 +132,8 @@ def format_changes( for commit_type, items in categories.items(): if items: lines.append(f'## {commit_type_to_category(commit_type)}\n') - for description, pr_link in items: - pr_num = '?' - match = PR_LINK_REGEX.match(pr_link) - if match: - pr_num = match.group(1) - lines.append(f'* {description} (#{pr_num})') + for change in items: + reference = None if change.pr_number is None else f'(#{change.pr_number})' + lines.append(_bullet(change, reference)) lines.append('') return '\n'.join(lines) + '\n' diff --git a/changelog/src/charm_tech_code/changelog/_models.py b/changelog/src/charm_tech_code/changelog/_models.py new file mode 100644 index 0000000..e03fd45 --- /dev/null +++ b/changelog/src/charm_tech_code/changelog/_models.py @@ -0,0 +1,51 @@ +# Copyright 2026 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""What a parser produces and a formatter renders: one change. + +There is one of these per bullet, and both parsers make the same thing, which +is the whole point of having it. `parse_git_log` and `parse_release_notes` +read different text about the same pull requests, and everything downstream -- +the two formatters, the bump-size rule -- works on this rather than on either +input's shape. +""" + +from __future__ import annotations + +from typing import NamedTuple + + +class Change(NamedTuple): + """One entry in a changelog. + + Attributes: + description: The summary, with its conventional-commit type stripped + and its first letter capitalised. A change routed into the + `breaking` category keeps its real type as a prefix, so this may + read `Feat: add the thing` or `Revert: "feat: add the thing"`. + pr_number: The pull request this came from, or `None` for a commit + that has none -- one pushed straight to the branch, or a release + notes bullet whose link is not a pull request. `None` is carried + rather than a placeholder because a change with no pull request + is still a real change, and rendering it as `(#?)` hides it in + plain sight. + credit: How to credit the author, already rendered: `@handle` where + one is known, the person's name where it is not, and `None` for + an author the caller named as one of its own. See `_authors`. + """ + + description: str + pr_number: int | None = None + credit: str | None = None diff --git a/changelog/src/charm_tech_code/changelog/_parse.py b/changelog/src/charm_tech_code/changelog/_parse.py index dbe5805..0407a94 100644 --- a/changelog/src/charm_tech_code/changelog/_parse.py +++ b/changelog/src/charm_tech_code/changelog/_parse.py @@ -13,62 +13,312 @@ # limitations under the License. -"""Parsing GitHub's generated release-notes text into categories.""" +"""Reading a range of changes out of text, into categories. + +Two doors in, one room behind them. `parse_git_log` is the one to use: the +conventional-commit convention governs *commits*, so the commits are what a +changelog should be read off. `parse_release_notes` reads GitHub's generated +notes instead, which describe the same pull requests by their *titles*; it +is kept because a caller that already has a release body in hand should not +have to go and fetch a git log to use it. + +Where they differ is worth knowing before picking one: + +* A pull-request title is written once, when the pull request is opened, and + is not what the conventional-commit rule is about. The squashed subject is + what lands on the branch and what everything else in the repository reads. + When the two disagree, the git log is right. +* A revert can only be resolved from a git log. Working out whether a revert + cancels something in the same range means reading the revert commit's + *body*, and GitHub's generated notes are one line per pull request with no + body anywhere in them. +* The notes carry a handle for every author; the git log carries a name and + an email, from which a handle is sometimes recoverable and sometimes not. + See `_authors`. +* The notes end with a compare link and a git log has no equivalent, so + `parse_git_log` has nothing to return in its place. +""" from __future__ import annotations +from collections.abc import Collection +from typing import NamedTuple + +from ._authors import credit_for, credit_for_handle from ._constants import ( BREAKING, CATEGORIES, CHANGE_LINE_REGEX, + COMMIT_SUBJECT_REGEX, FULL_CHANGELOG_PREFIX, + GIT_LOG_FIELD_SEPARATOR, + GIT_LOG_RECORD_SEPARATOR, NEW_CONTRIBUTORS_REGEX, + PR_LINK_REGEX, + PR_SUFFIX_REGEX, + REVERT, + REVERT_OF_BREAKING_TYPES, + REVERTS_REGEX, ) +from ._models import Change + + +def _empty_categories() -> dict[str, list[Change]]: + """Every category, in render order, whether or not the range filled it. + + Callers index `categories['breaking']` and iterate the dict for the + order, so the shape must not depend on what happened to be released. + """ + return {category: [] for category in CATEGORIES} + + +def _capitalise(summary: str) -> str: + """Sentence-case a summary without disturbing what it starts with. + + Conventional-commit summaries are lower case after the type and + changelog bullets are sentence case, but a summary starting with a + backtick or a quotation mark must come through untouched. + """ + return summary[0].upper() + summary[1:] if summary else summary -def parse_release_notes(release_notes: str) -> tuple[dict[str, list[tuple[str, str]]], str | None]: - """Parse auto-generated release notes into categories. +def parse_release_notes( + release_notes: str, *, team: Collection[str] = () +) -> tuple[dict[str, list[Change]], str | None]: + """Parse GitHub's generated release notes into categories. The input is GitHub's *generated* release-notes text, not a ``git log``. GitHub builds it from the titles of the pull requests merged in the range, one ``* type!: summary by @user in `` bullet each, which is - why this reads conventional-commit types off PR titles rather than off - commit subjects. How a caller obtains that text is the caller's problem: - a release already has it in its body, and a workflow running before any - release exists can ask for a preview of it. Nothing here does I/O. + why this reads conventional-commit types off pull-request titles rather + than off commit subjects. `parse_git_log` reads the commits, and is the + one to prefer for a new caller; this is here for a caller that has the + notes text already. How it obtained that text is its own problem: a + release has it in its body, and a workflow running before any release + exists can ask GitHub for a preview of it. Nothing here does I/O. The "New Contributors" section is removed. Bullets whose type is not a changelog category -- `chore`, most of all -- are dropped; see ``_constants.CATEGORIES`` for why that is deliberate. The full-changelog line is returned separately rather than categorised. + Reverts are *not* resolved here, and cannot be: see this module's + docstring. A revert in this range appears under `Reverted` whether or + not the thing it reverts is also in the range. + + Args: + release_notes: The generated notes text. + team: Authors not to credit, as emails and/or handles. Only handles + can match anything here, since a handle is all the notes carry. + The default credits everyone; see `_authors`. + Returns: A tuple containing: - - A dict with conventional commit types as keys and lists of tuples - (description, PR link) as values. Every category is present, even - when empty, in the order they are rendered in. + - A dict of category to `Change` list. Every category is present, + even when empty, in the order they are rendered in. - The full changelog line if present, or ``None`` if not found. """ release_notes = NEW_CONTRIBUTORS_REGEX.sub(r'\2', release_notes) - categories: dict[str, list[tuple[str, str]]] = {category: [] for category in CATEGORIES} + categories = _empty_categories() full_changelog_line = None for line in release_notes.splitlines(): if match := CHANGE_LINE_REGEX.match(line.strip()): category = match.group('category').strip() - if category in categories: - description = match.group('summary').strip() - description = description[0].upper() + description[1:] - pr_link = match.group('pr').strip() - if match.group('breaking') == '!': - categories[BREAKING].append(( - f'{category.capitalize()}: {description}', - pr_link, - )) - else: - categories[category].append((description, pr_link)) + if category not in categories: + continue + description = _capitalise(match.group('summary').strip()) + link_match = PR_LINK_REGEX.match(match.group('pr').strip()) + pr_number = int(link_match.group(1)) if link_match else None + credit = credit_for_handle(match.group('author'), team) + if match.group('breaking') == '!': + categories[BREAKING].append( + Change(f'{category.capitalize()}: {description}', pr_number, credit) + ) + else: + categories[category].append(Change(description, pr_number, credit)) elif line.startswith(FULL_CHANGELOG_PREFIX): full_changelog_line = line return categories, full_changelog_line + + +class _Commit(NamedTuple): + """One record of a `GIT_LOG_FORMAT` log, taken apart.""" + + category: str + breaking: bool + description: str + pr_number: int | None + credit: str | None + #: The pull request this commit reverts, where it says so in its body. + reverts: int | None + #: The conventional-commit type of the thing being reverted, read out of + #: the quoted subject a revert carries: `revert: "feat: ..."` is `feat`. + reverted_type: str | None + reverted_type_is_breaking: bool + + +def _parse_reverts(body: str, repo: str | None) -> int | None: + """The pull-request number a revert commit's body names, if any. + + A ``Reverts other/repo#5`` naming a different repository is not this + range's #5, and cancelling against it would drop the wrong pair, so it + is ignored when the caller has said which repository this log is from. + With no `repo` to check against there is nothing to compare, and the + reference is taken at face value. + """ + match = REVERTS_REGEX.search(body) + if not match: + return None + named_repo = match.group('repo') + if repo is not None and named_repo is not None and named_repo.casefold() != repo.casefold(): + return None + return int(match.group('number')) + + +def _parse_commit(record: str, team: Collection[str], repo: str | None) -> _Commit | None: + """One `GIT_LOG_FORMAT` record, or `None` if it is not a change. + + A subject that is not a conventional-commit one -- a merge commit, or + anything from before the convention was adopted -- is not a changelog + entry and is dropped here, the same way the notes parser drops a line + that is not a bullet. + """ + name, _, rest = record.partition(GIT_LOG_FIELD_SEPARATOR) + email, _, rest = rest.partition(GIT_LOG_FIELD_SEPARATOR) + subject, _, body = rest.partition(GIT_LOG_FIELD_SEPARATOR) + + subject = subject.strip() + pr_number = None + if suffix := PR_SUFFIX_REGEX.search(subject): + pr_number = int(suffix.group(1)) + subject = subject[: suffix.start()] + + match = COMMIT_SUBJECT_REGEX.match(subject) + if not match: + return None + + summary = match.group('summary').strip() + reverted_type = None + reverted_type_is_breaking = False + if match.group('category').casefold() == REVERT: + # `revert: "feat: add the thing"` -- the quoted subject is the one + # being undone, and its type says how much undoing it matters. The + # quotes are what GitHub's Revert button writes, but a hand-written + # revert often leaves them off, so both are read. + inner = COMMIT_SUBJECT_REGEX.match(summary.strip('"').strip()) + if inner: + reverted_type = inner.group('category').casefold() + reverted_type_is_breaking = inner.group('breaking') == '!' + + return _Commit( + category=match.group('category').casefold(), + breaking=match.group('breaking') == '!', + description=_capitalise(summary), + pr_number=pr_number, + credit=credit_for(name, email, team), + reverts=_parse_reverts(body, repo), + reverted_type=reverted_type, + reverted_type_is_breaking=reverted_type_is_breaking, + ) + + +def _cancelled(commits: list[_Commit]) -> set[int]: + """The commits that a revert in the same range takes back out of it. + + A change that landed and was undone before anything shipped did not + happen as far as a reader is concerned, so neither half appears: not the + change, and not the revert of it either. Listing both would be accurate + and useless, and listing the revert alone would describe the removal of + something the changelog never said had arrived. + + Identity is the pull-request number, because that is what a revert body + names and, under squash merging, the only stable thing it could name. + + Returns: + The indices into `commits` to leave out. + """ + numbers = {commit.pr_number: index for index, commit in enumerate(commits) if commit.pr_number} + cancelled: set[int] = set() + for index, commit in enumerate(commits): + if commit.reverts is not None and commit.reverts in numbers: + cancelled.add(index) + cancelled.add(numbers[commit.reverts]) + return cancelled + + +def parse_git_log( + log_text: str, *, team: Collection[str] = (), repo: str | None = None +) -> dict[str, list[Change]]: + """Parse a range of commits into categories. + + This is the input to prefer. The conventional-commit convention is about + commit subjects, `canonical/operator` squash-merges so that every subject + on a release branch is `type: summary (#N)`, and reading the subjects is + therefore reading the thing the convention actually governs. It also + needs nothing from GitHub: the pull-request number comes out of the + `(#N)` suffix, and the link a release body wants is built back up from + that number and `repo` when it is rendered. + + `log_text` is the output of ``git log`` with ``--format=GIT_LOG_FORMAT``, + oldest first. Getting it is the caller's job, exactly as getting the + notes text is: nothing here runs git, or anything else. + + Three things happen that the notes path cannot do: + + * **Reverts cancel.** A revert whose pull request is also in this range + removes both itself and what it reverted. See `_cancelled`. + * **A revert of something already released is called out.** It keeps its + own `Reverted` heading rather than being filed under the type it + undoes, so that it reads as a removal and not as a new fix. A revert of + a released feature is a withdrawal of behaviour, so it goes further and + is routed to `breaking`; see `REVERT_OF_BREAKING_TYPES`. + * **A commit with no `(#N)` keeps a `None`**, rather than a placeholder, + so that a change pushed straight to the branch is visible as one. + + Args: + log_text: `git log` output in `GIT_LOG_FORMAT`. + team: Authors not to credit, as emails and/or handles. The default + credits everyone; see `_authors`. + repo: The `owner/name` this log came from, used only to ignore a + `Reverts` line that names a different repository. + + Returns: + A dict of category to `Change` list, in the order they are rendered + in, with every category present even when empty. There is no second + return value: a git log carries no compare link for the formatter to + pass through, and inventing one would mean knowing the tags at both + ends, which is the caller's business. + """ + records = log_text.split(GIT_LOG_RECORD_SEPARATOR) + commits = [ + commit + for commit in (_parse_commit(record, team, repo) for record in records if record.strip()) + if commit is not None + ] + + categories = _empty_categories() + cancelled = _cancelled(commits) + for index, commit in enumerate(commits): + if index in cancelled or commit.category not in categories: + continue + change = Change(commit.description, commit.pr_number, commit.credit) + breaking = commit.breaking or ( + commit.category == REVERT + and ( + commit.reverted_type in REVERT_OF_BREAKING_TYPES + or commit.reverted_type_is_breaking + ) + ) + if breaking: + categories[BREAKING].append( + change._replace( + description=f'{commit.category.capitalize()}: {change.description}' + ) + ) + else: + categories[commit.category].append(change) + + return categories diff --git a/changelog/src/charm_tech_code/changelog/_version.py b/changelog/src/charm_tech_code/changelog/_version.py index 3966324..e918518 100644 --- a/changelog/src/charm_tech_code/changelog/_version.py +++ b/changelog/src/charm_tech_code/changelog/_version.py @@ -35,6 +35,7 @@ from typing import Literal from ._constants import MINOR_BUMP_CATEGORIES, RELEASE_VERSION_REGEX +from ._models import Change #: The two sizes a release can be inferred to be. A major bump is never #: inferred -- see `MINOR_BUMP_CATEGORIES` -- so it is not one of these. @@ -44,14 +45,22 @@ PATCH: BumpSize = 'patch' -def infer_bump_size(categories: Mapping[str, list[tuple[str, str]]]) -> BumpSize: +def infer_bump_size(categories: Mapping[str, list[Change]]) -> BumpSize: """Work out whether a range of changes is a minor release or a patch one. - `categories` is what `parse_release_notes` returned. That matters rather - more than it looks: the parse is where a `!` moves an entry out of its - real type and into `breaking`, so the categories this reads have already - had that routing applied, and passing a dict built some other way will - get a different answer. + `categories` is what `parse_git_log` or `parse_release_notes` returned. + That matters rather more than it looks: the parse is where a `!` moves an + entry out of its real type and into `breaking`, and where a revert of a + released feature is routed there too, so the categories this reads have + already had that routing applied. Passing a dict built some other way + will get a different answer. + + A revert is a change like any other here, with one thing worth saying: + a revert of something released earlier counts, at least as a patch, + because taking a change back out is itself a change that shipped. A + revert of something in this same range counts for nothing, because + `parse_git_log` has already cancelled the pair and neither is in + `categories` to be counted. A `minor` result means "there is a feature, or a breaking change, in this range". A caller that has branches on which neither may appear -- a diff --git a/changelog/tests/conftest.py b/changelog/tests/conftest.py index 53576de..e386b27 100644 --- a/changelog/tests/conftest.py +++ b/changelog/tests/conftest.py @@ -33,7 +33,7 @@ import pytest -from charm_tech_code.changelog import _constants, _format, _parse, _version +from charm_tech_code.changelog import _authors, _constants, _format, _models, _parse, _version class _NoClock: @@ -58,7 +58,7 @@ class _NoClockModule: #: Every module of the library, whether or not it imports `datetime` today. #: `raising=False` below means a module that does not import it is covered in #: advance rather than having to be remembered when it does. -LIBRARY_MODULES = (_constants, _format, _parse, _version) +LIBRARY_MODULES = (_authors, _constants, _format, _models, _parse, _version) @pytest.fixture(autouse=True) diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py index 5a7e411..16ef1cd 100644 --- a/changelog/tests/test_changelog.py +++ b/changelog/tests/test_changelog.py @@ -15,12 +15,25 @@ """Unit tests for the changelog package. The specification here is what `canonical/operator`'s `release.py` does -against real input, so the two large fixtures are real operator releases -rather than invented ones. Both are reconstructed from the repository's own -history: `git log --reverse 3.8.1..3.8.2` gives the merged pull requests in -the range, in merge order, and GitHub's generated notes are one -`* by @ in ` bullet each, in that same order, with the -PR title being the squash-commit subject minus its ` (#NNNN)` suffix. +against real input, so the large fixtures are real operator releases rather +than invented ones. The notes fixtures and the commit fixtures describe the +same pull requests from the two ends: `git log --reverse 3.8.1..3.8.2` gives +the merged pull requests in the range, in merge order, and GitHub's generated +notes are one `* by @ in ` bullet each, in that same +order, with the PR title being the squash-commit subject minus its ` (#NNNN)` +suffix. + +**Which fixtures are observed and which are constructed** matters enough to +say per fixture, and each one says so. The short version: everything taken +from `canonical/operator` is observed, down to the author emails, except the +two bot handles in the 3.8.2 notes; and the revert cases in `RevertTests` +are constructed, because operator's history contains exactly one revert and +it reverts a `chore`, which the changelog drops anyway. + +Commit *bodies* are the one thing elided from the observed fixtures. A real +`git log` body is the whole pull-request description, which is kilobytes per +commit and none of which is read except a `Reverts owner/repo#N` line, so the +fixtures carry the bodies that matter and empty strings everywhere else. """ from __future__ import annotations @@ -35,18 +48,76 @@ from charm_tech_code.changelog import ( CATEGORIES, CATEGORY_HEADINGS, + GIT_LOG_FORMAT, MINOR, MINOR_BUMP_CATEGORIES, PATCH, + Change, _cli, commit_type_to_category, format_changes, format_release_notes, infer_bump_size, next_version, + parse_git_log, parse_release_notes, ) +# The Charm Tech team as `canonical/operator` would supply it: emails, which +# is what a git log carries, and handles, which is what release notes carry. +# Observed -- the handles are the team list in the staging tree's AGENTS.md +# and the emails are the ones in operator's own history. Two bot addresses +# are in here as well, because a bot is not a contributor to thank either; +# nothing in these tests depends on that, since every bot commit in the +# fixtures is a `chore` and dropped before it could be credited. +OPERATOR_TEAM = ( + '@benhoyt', + '@dwilding', + '@hpidcock', + '@james-garner-canonical', + '@tromai', + '@tonyandrewmeyer', + 'ben.hoyt@canonical.com', + 'david.wilding@canonical.com', + 'tony.meyer@canonical.com', + 'trongnhan.mai@canonical.com', + 'ben.hoyt+prints-charming-bot@canonical.com', + '49699333+dependabot[bot]@users.noreply.github.com', +) + +#: The repository every fixture here comes from, and so the one the +#: pull-request links are built against. +REPO = 'canonical/operator' + +TONY = ('Tony Meyer', 'tony.meyer@canonical.com') +DAVID = ('Dave Wilding', 'david.wilding@canonical.com') +NHAN = ('Trong Nhan Mai', 'trongnhan.mai@canonical.com') +DEPENDABOT = ('dependabot[bot]', '49699333+dependabot[bot]@users.noreply.github.com') +PRINTS_CHARMING = ('Prints Charming', 'ben.hoyt+prints-charming-bot@canonical.com') +# Three shapes of contributor from outside the team, all observed in +# operator's history, and the reason `_authors` is not two lines long: +# Ali-932 and gcomneno commit under GitHub no-reply addresses, so a handle +# falls out of the email; Iya Mg does not, so there is no handle to be had +# and the name is the credit. +ALI = ('Ali Al-Obaidi', '46688206+Ali-932@users.noreply.github.com') +GCOMNENO = ('Giancarlo Cicellyn Comneno', '126195429+gcomneno@users.noreply.github.com') +IYA = ('Iya Mg', 'dev@iyamg.com') + + +def git_log(*commits: tuple[tuple[str, str], str, str]) -> str: + """Build `GIT_LOG_FORMAT` text out of (author, subject, body) triples. + + This is what `git log --format="$(changelog git-log-format)"` would have + written, assembled here so that a fixture can be read: the separators are + control characters, and a checked-in file full of them is not reviewable. + `test_the_fixture_format_is_the_format_git_is_asked_for` pins the two + against each other so this cannot drift into testing a private dialect. + """ + return ''.join( + f'\x1e{name}\x1f{email}\x1f{subject}\x1f{body}' for (name, email), subject, body in commits + ) + + # canonical/operator 3.8.2, released 31 August 2026. Twenty-three merged pull # requests, eleven of them `chore`. Ali-932's was genuinely their first # contribution to the repository, so the "New Contributors" section is real @@ -85,6 +156,45 @@ **Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2 """ +# The same twenty-three pull requests as they appear in `git log --reverse +# 3.8.1..3.8.2`: author name, author email and squash-commit subject, all +# verbatim. This is the other end of the fixture above, and the pair is what +# `SameRangeFromEitherInputTests` compares. +OPERATOR_3_8_2_COMMITS = ( + (TONY, 'chore: adjust versions after release (#2670)', ''), + (TONY, 'docs: give each best-practice admonition a stable :name: anchor (#2524)', ''), + (TONY, 'ci: point DB charm CI at the moved mysql-operators repo (#2551)', ''), + (DEPENDABOT, 'chore: bump cryptography from 48.0.1 to 50.0.0 (#2682)', ''), + (ALI, 'fix: compare full event paths when skipping duplicate notices (#2684)', ''), + (DEPENDABOT, 'chore: bump the actions group across 1 directory with 8 updates (#2674)', ''), + (DEPENDABOT, 'chore: bump the runtime group across 1 directory with 4 updates (#2691)', ''), + (TONY, 'docs: reword text that vale 3.17 flags as misspelled (#2695)', ''), + (PRINTS_CHARMING, 'chore: update charm pins (#2582)', ''), + ( + DEPENDABOT, + 'chore: bump the dev-tooling group in /examples/httpbin-demo with 2 updates (#2675)', + '', + ), + (DEPENDABOT, 'chore: bump the charm-tech group across 1 directory with 3 updates (#2697)', ''), + (TONY, 'docs: stop styling page references as blockquotes (#2666)', ''), + (DAVID, 'ci: switch example charm integration tests to Concierge `k8s` preset (#2696)', ''), + (TONY, 'docs: make the custom-endpoint-name sample test actually test something (#2664)', ''), + (TONY, 'ci: use the upstream concierge presets again (#2699)', ''), + (DAVID, 'docs: replace `requests` by `urllib` in K8s tutorial integration tests (#2687)', ''), + (TONY, "fix: don't pass a message when converting an unknown status by name (#2700)", ''), + (DEPENDABOT, 'chore: bump the dev-tooling group with 4 updates (#2676)', ''), + (PRINTS_CHARMING, 'chore: update charm pins (#2701)', ''), + (TONY, "chore: adopt ruff 0.16's new lint conventions (#2698)", ''), + (TONY, 'docs: recommend spread directly, rather than charmcraft test (#2706)', ''), + ( + NHAN, + 'docs: extract sections in how to write integration tests to their own howto' + ' guide (#2662)', + '', + ), + (DAVID, 'chore: update changelog and versions for 3.8.2 release (#2716)', ''), +) + # The PR numbers of the eleven `chore` pull requests in that release. None of # them may appear anywhere in either rendered output. OPERATOR_3_8_2_CHORE_PRS = ( @@ -115,74 +225,95 @@ **Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0 """ +# The same four pull requests as commits. Observed, and trimmed to the same +# four so that this pairs with the notes fixture above. +OPERATOR_BREAKING_COMMITS = ( + (TONY, 'refactor: replace jsonpatch with an inline dict-diff (#2578)', ''), + ( + TONY, + 'refactor!: move the otlp-json package to be a regular ops-tracing module (#2585)', + '', + ), + (TONY, 'feat: note the socket path in Pebble tracing spans (#2555)', ''), + (TONY, 'fix: tear down `Runtime.exec()` when the charm raises (#2581)', ''), +) + +# Six real commits from the 3.7.1..3.8.0 range, in merge order, chosen for +# what they carry rather than trimmed for length: the only revert operator +# has merged into a 3.x release (#2568) together with the pull request it +# reverts (#2538), and one of each shape of outside contributor. The body of +# #2568 is real too, down to the third line; every other body is elided. +# +# This range has no notes counterpart in these tests, deliberately. Two of +# the three contributors here have no handle in the git log at all, so the +# notes GitHub generated for 3.8.0 say something this fixture cannot be +# derived from, and inventing the handles would be inventing the answer to +# the question `AuthorCreditTests` asks. +OPERATOR_REVERT_RANGE_COMMITS = ( + (GCOMNENO, 'fix: treat remote unit zero as explicit (#2454)', ''), + (IYA, 'docs: fix small issues in K8s tutorial (#2540)', ''), + (TONY, 'docs: replace CoC with link to Ubuntu Code of Conduct (#2564)', ''), + (DEPENDABOT, 'chore: bump opentelemetry-api from 1.37.0 to 1.42.1 (#2538)', ''), + ( + TONY, + 'revert: "chore: bump opentelemetry-api from 1.37.0 to 1.42.1" (#2568)', + 'Reverts canonical/operator#2538\n\nTriggers warnings on 3.10.\n', + ), + ( + ('deusebio', 'edeusebio85@gmail.com'), + 'docs: add guidance about names of workload-less charms (#2496)', + '', + ), +) + +OPERATOR_3_8_2_LOG = git_log(*OPERATOR_3_8_2_COMMITS) +OPERATOR_BREAKING_LOG = git_log(*OPERATOR_BREAKING_COMMITS) +OPERATOR_REVERT_RANGE_LOG = git_log(*OPERATOR_REVERT_RANGE_COMMITS) + +#: `OPERATOR_TEAM` as a workflow would pass it: one repository variable. +TEAM_ARGUMENT = ','.join(OPERATOR_TEAM) + class RealReleaseTests(unittest.TestCase): """The 3.8.2 fixture, end to end.""" def setUp(self): - self.categories, self.full_changelog = parse_release_notes(OPERATOR_3_8_2_NOTES) + self.categories, self.full_changelog = parse_release_notes( + OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM + ) def test_categories(self): # Twelve of the twenty-three pull requests survive, in merge order. + # Only #2684 carries a credit: its author is the one contributor in + # this range who is not on the team. assert self.categories == { 'breaking': [], 'feat': [], 'fix': [ - ( - 'Compare full event paths when skipping duplicate notices', - 'https://github.com/canonical/operator/pull/2684', - ), - ( - "Don't pass a message when converting an unknown status by name", - 'https://github.com/canonical/operator/pull/2700', + Change( + 'Compare full event paths when skipping duplicate notices', 2684, '@Ali-932' ), + Change("Don't pass a message when converting an unknown status by name", 2700), ], 'docs': [ - ( - 'Give each best-practice admonition a stable :name: anchor', - 'https://github.com/canonical/operator/pull/2524', - ), - ( - 'Reword text that vale 3.17 flags as misspelled', - 'https://github.com/canonical/operator/pull/2695', - ), - ( - 'Stop styling page references as blockquotes', - 'https://github.com/canonical/operator/pull/2666', - ), - ( - 'Make the custom-endpoint-name sample test actually test something', - 'https://github.com/canonical/operator/pull/2664', - ), - ( - 'Replace `requests` by `urllib` in K8s tutorial integration tests', - 'https://github.com/canonical/operator/pull/2687', - ), - ( - 'Recommend spread directly, rather than charmcraft test', - 'https://github.com/canonical/operator/pull/2706', - ), - ( + Change('Give each best-practice admonition a stable :name: anchor', 2524), + Change('Reword text that vale 3.17 flags as misspelled', 2695), + Change('Stop styling page references as blockquotes', 2666), + Change('Make the custom-endpoint-name sample test actually test something', 2664), + Change('Replace `requests` by `urllib` in K8s tutorial integration tests', 2687), + Change('Recommend spread directly, rather than charmcraft test', 2706), + Change( 'Extract sections in how to write integration tests to their own howto guide', - 'https://github.com/canonical/operator/pull/2662', + 2662, ), ], 'test': [], 'refactor': [], 'perf': [], 'ci': [ - ( - 'Point DB charm CI at the moved mysql-operators repo', - 'https://github.com/canonical/operator/pull/2551', - ), - ( - 'Switch example charm integration tests to Concierge `k8s` preset', - 'https://github.com/canonical/operator/pull/2696', - ), - ( - 'Use the upstream concierge presets again', - 'https://github.com/canonical/operator/pull/2699', - ), + Change('Point DB charm CI at the moved mysql-operators repo', 2551), + Change('Switch example charm integration tests to Concierge `k8s` preset', 2696), + Change('Use the upstream concierge presets again', 2699), ], 'revert': [], } @@ -197,7 +328,7 @@ def test_chore_is_dropped(self): # them reaches the output. Deliberate: dependency bumps, charm-pin # updates and the release's own version bump are not changelog # material. - notes = format_release_notes(self.categories, self.full_changelog) + notes = format_release_notes(self.categories, self.full_changelog, repo=REPO) entry = format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) assert 'chore' not in notes.lower() assert 'chore' not in entry.lower() @@ -206,16 +337,19 @@ def test_chore_is_dropped(self): assert pr not in entry, f'chore PR #{pr} leaked into the changelog entry' def test_new_contributors_section_is_dropped(self): - notes = format_release_notes(self.categories, self.full_changelog) + notes = format_release_notes(self.categories, self.full_changelog, repo=REPO) assert 'New Contributors' not in notes assert 'made their first contribution' not in notes def test_changes_entry(self): # This is the 3.8.2 entry as it appears in operator's CHANGES.md, - # except for three summaries where the committed CHANGES.md was - # edited by hand afterwards (#2700 gained an "In `ops.testing`," - # prefix, #2524 lost its ":name:", and #2662 was reworded). The - # section order, the bullet order within each section and the + # with two differences. Three summaries were edited by hand after + # the fact (#2700 gained an "In `ops.testing`," prefix, #2524 lost + # its ":name:", and #2662 was reworded). And #2684 is credited here + # and was not there: the author is not on the team, `release.py` + # discarded the author of every entry, and nobody put this one back + # by hand -- which is the whole of the argument for doing it here. + # The section order, the bullet order within each section and the # blank-line layout are all exactly what shipped. assert ( format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) @@ -224,7 +358,7 @@ def test_changes_entry(self): ## Fixes -* Compare full event paths when skipping duplicate notices (#2684) +* Compare full event paths when skipping duplicate notices by @Ali-932 (#2684) * Don't pass a message when converting an unknown status by name (#2700) ## Documentation @@ -248,12 +382,12 @@ def test_changes_entry(self): def test_release_notes(self): assert ( - format_release_notes(self.categories, self.full_changelog) + format_release_notes(self.categories, self.full_changelog, repo=REPO) == """\ ## What's Changed ### Fixes -* Compare full event paths when skipping duplicate notices in https://github.com/canonical/operator/pull/2684 +* Compare full event paths when skipping duplicate notices by @Ali-932 in https://github.com/canonical/operator/pull/2684 * Don't pass a message when converting an unknown status by name in https://github.com/canonical/operator/pull/2700 ### Documentation @@ -282,29 +416,25 @@ class BreakingChangeTests(unittest.TestCase): """A `!` moves an entry into its own category, keeping its real type.""" def setUp(self): - self.categories, self.full_changelog = parse_release_notes(OPERATOR_BREAKING_NOTES) + self.categories, self.full_changelog = parse_release_notes( + OPERATOR_BREAKING_NOTES, team=OPERATOR_TEAM + ) def test_breaking_entry_keeps_its_real_type_as_a_prefix(self): assert self.categories['breaking'] == [ - ( - 'Refactor: Move the otlp-json package to be a regular ops-tracing module', - 'https://github.com/canonical/operator/pull/2585', - ) + Change('Refactor: Move the otlp-json package to be a regular ops-tracing module', 2585) ] def test_breaking_entry_is_not_also_in_its_own_type(self): # The `!` moves the entry rather than copying it, so #2585 leaves # `refactor` and #2578, which has no `!`, is all that is left there. assert self.categories['refactor'] == [ - ( - 'Replace jsonpatch with an inline dict-diff', - 'https://github.com/canonical/operator/pull/2578', - ) + Change('Replace jsonpatch with an inline dict-diff', 2578) ] def test_release_notes_put_breaking_first_with_a_warning(self): assert ( - format_release_notes(self.categories, self.full_changelog) + format_release_notes(self.categories, self.full_changelog, repo=REPO) == """\ ## What's Changed @@ -356,7 +486,10 @@ def test_changes_entry_puts_breaking_first_without_the_warning(self): class ParseTests(unittest.TestCase): """The bullet format, in detail.""" - def parse(self, *bullets: str) -> dict[str, list[tuple[str, str]]]: + def parse(self, *bullets: str) -> dict[str, list[Change]]: + # No team, so `@someone` is credited: an empty team credits + # everyone, which is the safe way round for a caller that has not + # said who its maintainers are. categories, _ = parse_release_notes('\n'.join(bullets)) return categories @@ -364,13 +497,13 @@ def test_summary_is_capitalised(self): # PR titles are lowercase after the conventional-commit type, and # changelog bullets are sentence case. categories = self.parse('* fix: do the thing by @someone in https://example.com/pull/1') - assert categories['fix'] == [('Do the thing', 'https://example.com/pull/1')] + assert categories['fix'] == [Change('Do the thing', 1, '@someone')] def test_summary_that_starts_with_a_backtick_is_left_alone(self): categories = self.parse( '* fix: `Runtime.exec()` tears down by @someone in https://example.com/pull/1' ) - assert categories['fix'] == [('`Runtime.exec()` tears down', 'https://example.com/pull/1')] + assert categories['fix'] == [Change('`Runtime.exec()` tears down', 1, '@someone')] def test_unrecognised_type_is_dropped(self): # `build` and `style` are conventional-commit types the PR-title @@ -406,7 +539,7 @@ def test_lines_that_are_not_bullets_are_ignored(self): '* not a conventional commit title by @someone in https://example.com/pull/1\n' '* fix: a real one by @someone in https://example.com/pull/2\n' ) - assert categories['fix'] == [('A real one', 'https://example.com/pull/2')] + assert categories['fix'] == [Change('A real one', 2, '@someone')] assert full_changelog is None def test_indented_bullets_are_parsed(self): @@ -421,45 +554,59 @@ def test_new_contributors_section_is_stripped_before_parsing(self): '## New Contributors\n' '* @someone made their first contribution in https://example.com/pull/1\n' ) - assert categories['fix'] == [('A real one', 'https://example.com/pull/1')] + assert categories['fix'] == [Change('A real one', 1, '@someone')] class FormatReleaseNotesTests(unittest.TestCase): - def empty(self) -> dict[str, list[tuple[str, str]]]: + def empty(self) -> dict[str, list[Change]]: return {category: [] for category in CATEGORIES} def test_empty_release(self): - assert format_release_notes(self.empty(), None) == "## What's Changed\n" + assert format_release_notes(self.empty(), None, repo=REPO) == "## What's Changed\n" def test_categories_render_in_the_declared_order(self): categories = self.empty() for category in ('revert', 'ci', 'feat', 'fix'): - categories[category] = [(f'A {category} change', 'https://example.com/pull/1')] + categories[category] = [Change(f'A {category} change', 1)] headings = [ line - for line in format_release_notes(categories, None).splitlines() + for line in format_release_notes(categories, None, repo=REPO).splitlines() if line.startswith('###') ] assert headings == ['### Features', '### Fixes', '### CI', '### Reverted'] def test_full_changelog_is_appended_when_given(self): - notes = format_release_notes(self.empty(), '**Full Changelog**: https://example.com/x') + notes = format_release_notes( + self.empty(), '**Full Changelog**: https://example.com/x', repo=REPO + ) assert notes.endswith('**Full Changelog**: https://example.com/x') class FormatChangesTests(unittest.TestCase): - def entry(self, pr_link: str) -> str: - categories = {category: [] for category in CATEGORIES} - categories['fix'] = [('A fix', pr_link)] + def entry(self, change: Change) -> str: + categories: dict[str, list[Change]] = {category: [] for category in CATEGORIES} + categories['fix'] = [change] return format_changes(categories, '1.2.3', datetime.date(2026, 9, 10)) - def test_pr_number_comes_from_the_link(self): - assert '* A fix (#2684)' in self.entry('https://github.com/canonical/operator/pull/2684') + def test_the_pr_number_is_rendered_in_parentheses(self): + assert '* A fix (#2684)' in self.entry(Change('A fix', 2684)) + + def test_a_change_with_no_pr_gets_no_reference(self): + # A commit pushed straight to the branch has no pull request, and is + # still a change that shipped. It is listed with nothing after it, + # rather than with the `(#?)` an earlier version of this rendered: + # a reader can act on "there is no pull request for this", and a + # question mark only reads as something having gone wrong. + assert self.entry(Change('A fix')).endswith('* A fix\n\n') - def test_an_unrecognisable_link_gets_a_question_mark(self): - # An entry with no PR to point at still belongs in the changelog, so - # this is a placeholder rather than a failure. - assert '* A fix (#?)' in self.entry('https://github.com/canonical/operator/commit/abc123') + def test_the_credit_goes_before_the_reference(self): + # `* by (#)`, which is the shape operator's own + # hand-written entries use: `* Fix typos in code snippets by + # @MattiaSarti (#1750)`. + assert '* A fix by @someone (#2684)' in self.entry(Change('A fix', 2684, '@someone')) + + def test_a_credited_change_with_no_pr_keeps_the_credit(self): + assert self.entry(Change('A fix', None, 'Iya Mg')).endswith('* A fix by Iya Mg\n\n') def test_empty_release(self): empty = {category: [] for category in CATEGORIES} @@ -522,8 +669,8 @@ def test_every_category_has_a_heading(self): """ -def categories_of(notes: str) -> dict[str, list[tuple[str, str]]]: - return parse_release_notes(notes)[0] +def categories_of(notes: str) -> dict[str, list[Change]]: + return parse_release_notes(notes, team=OPERATOR_TEAM)[0] class BumpSizeTests(unittest.TestCase): @@ -570,10 +717,7 @@ def test_an_empty_range_is_a_patch(self): def test_major_is_never_inferred(self): # Even with every category populated, including breaking. A major # release is the explicit version input's job. - categories = { - category: [(f'A {category} change', 'https://example.com/pull/1')] - for category in CATEGORIES - } + categories = {category: [Change(f'A {category} change', 1)] for category in CATEGORIES} assert infer_bump_size(categories) == MINOR def test_the_minor_categories_are_categories(self): @@ -635,10 +779,425 @@ def test_the_error_points_at_the_way_out(self): assert 'explicitly' in str(raised.exception) +class GitLogParseTests(unittest.TestCase): + """The primary input: commit subjects, which is what the convention governs.""" + + def parse(self, *commits: tuple[tuple[str, str], str, str]) -> dict[str, list[Change]]: + return parse_git_log(git_log(*commits), team=OPERATOR_TEAM, repo=REPO) + + def test_the_fixture_format_is_the_format_git_is_asked_for(self): + # `git_log` writes the separators by hand, so if the package ever + # changed what it asks `git log` for, these fixtures would go on + # passing while every real caller broke. The format string is the + # contract; this is the only place it is checked against the fixtures. + assert GIT_LOG_FORMAT == '%x1e%an%x1f%ae%x1f%s%x1f%b' + + def test_the_real_release(self): + categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) + assert categories['fix'] == [ + Change('Compare full event paths when skipping duplicate notices', 2684, '@Ali-932'), + Change("Don't pass a message when converting an unknown status by name", 2700), + ] + assert categories['ci'] == [ + Change('Point DB charm CI at the moved mysql-operators repo', 2551), + Change('Switch example charm integration tests to Concierge `k8s` preset', 2696), + Change('Use the upstream concierge presets again', 2699), + ] + + def test_the_pr_number_comes_off_the_subject(self): + # The `(#N)` a squash merge appends, and the only thing the git log + # says about the pull request. Over operator's last 300 commits every + # subject has one. + categories = self.parse((TONY, 'fix: do the thing (#1234)', '')) + assert categories['fix'] == [Change('Do the thing', 1234)] + + def test_a_commit_with_no_pr_number_carries_none(self): + # A commit pushed straight to the branch. operator has these, from + # before the squash-merge policy -- `chore: remove odd argument to + # "raise NotImplementedError" in harness.py` is one, by Ben, with no + # suffix. The change is real, so it is carried with nothing in the + # number rather than with a placeholder that reads like a bug. + categories = self.parse((TONY, 'fix: do the thing', '')) + assert categories['fix'] == [Change('Do the thing', None)] + + def test_a_number_that_is_not_the_suffix_is_not_the_pr(self): + # Only a trailing `(#N)` counts, so a summary that happens to mention + # an issue does not get mistaken for one. + categories = self.parse((TONY, 'fix: handle (#5) style input properly (#1234)', '')) + assert categories['fix'] == [Change('Handle (#5) style input properly', 1234)] + + def test_chore_is_dropped_here_too(self): + categories = self.parse((TONY, 'chore: bump something (#1)', '')) + assert all(not items for items in categories.values()) + + def test_a_breaking_commit_moves_to_breaking_with_its_type_kept(self): + categories = self.parse((TONY, 'refactor!: move the thing (#2585)', '')) + assert categories['breaking'] == [Change('Refactor: Move the thing', 2585)] + assert categories['refactor'] == [] + + def test_a_scope_is_accepted_and_ignored(self): + # No repository in the estate uses one, but the shared PR-title check + # accepts `type(scope):`, and the two should not disagree about what + # a valid subject is. + categories = self.parse((TONY, 'fix(tracing): do the thing (#1)', '')) + assert categories['fix'] == [Change('Do the thing', 1)] + + def test_a_breaking_scoped_commit_is_still_breaking(self): + categories = self.parse((TONY, 'feat(api)!: replace it (#1)', '')) + assert categories['breaking'] == [Change('Feat: Replace it', 1)] + + def test_a_subject_that_is_not_conventional_is_dropped(self): + # A merge commit, or anything from before the convention. `--no-merges` + # is the recommendation rather than the requirement because of this. + categories = self.parse( + (TONY, "Merge remote-tracking branch 'source/main' into import-ops-scenario", ''), + (TONY, 'Reorganise the scenario files.', ''), + (TONY, 'fix: a real one (#1)', ''), + ) + assert categories['fix'] == [Change('A real one', 1)] + assert sum(len(items) for items in categories.values()) == 1 + + def test_a_body_does_not_end_a_record(self): + # The reason the format uses control characters: a commit body is + # arbitrary text with blank lines in it, so anything line-oriented + # would lose track of where the next commit starts. + categories = self.parse( + (TONY, 'fix: the first (#1)', 'A body.\n\nWith a blank line.\n\n* And a bullet.\n'), + (TONY, 'fix: the second (#2)', ''), + ) + assert categories['fix'] == [Change('The first', 1), Change('The second', 2)] + + def test_an_empty_log_is_every_category_empty(self): + assert parse_git_log('') == {category: [] for category in CATEGORIES} + + def test_trailing_whitespace_between_records_is_tolerated(self): + # `git log` output arrives with a newline on the end of it. + categories = parse_git_log( + git_log((TONY, 'fix: do the thing (#1)', '')) + '\n', team=OPERATOR_TEAM + ) + assert categories['fix'] == [Change('Do the thing', 1)] + + +class AuthorCreditTests(unittest.TestCase): + """Who gets named in a bullet. All three contributors here are observed.""" + + def parse(self, author: tuple[str, str], team=OPERATOR_TEAM) -> list[Change]: + return parse_git_log(git_log((author, 'fix: do the thing (#1)', '')), team=team)['fix'] + + def test_a_team_member_is_not_credited(self): + # By email, which is all a git log gives for someone who commits + # under a real address. + assert self.parse(TONY) == [Change('Do the thing', 1)] + + def test_an_outside_contributor_is_credited_by_handle(self): + # `46688206+Ali-932@users.noreply.github.com` -> `@Ali-932`. This is + # GitHub's default commit address for an account with a private + # email, so it is the usual case for a drive-by contributor. + assert self.parse(ALI) == [Change('Do the thing', 1, '@Ali-932')] + + def test_the_older_no_reply_form_works_too(self): + assert self.parse(('Someone', 'someone@users.noreply.github.com')) == [ + Change('Do the thing', 1, '@someone') + ] + + def test_an_outside_contributor_with_no_handle_is_credited_by_name(self): + # Iya Mg's #2540 is a real documentation fix in 3.8.0, committed from + # a personal address. There is no handle to be had, and the two + # alternatives -- dropping them, or rendering an `@` in front of + # something that is not a handle -- are both worse than the name. + assert self.parse(IYA) == [Change('Do the thing', 1, 'Iya Mg')] + + def test_another_canonical_team_is_still_outside_this_one(self): + # "External to the Charm Tech team" is not "external to Canonical". + # A contributor from another team has an @canonical.com address, no + # derivable handle, and every bit as much claim to the credit. + assert self.parse(('Some One', 'some.one@canonical.com')) == [ + Change('Do the thing', 1, 'Some One') + ] + + def test_a_team_member_is_matched_by_handle_as_well_as_by_email(self): + # Someone on the team who commits from a no-reply address is matched + # on the handle the email yields, so a team list of handles alone + # still works. + assert self.parse( + ('Tony Meyer', '12345+tonyandrewmeyer@users.noreply.github.com'), + team=('@tonyandrewmeyer',), + ) == [Change('Do the thing', 1)] + + def test_matching_ignores_case_and_a_leading_at(self): + for member in ('TONY.MEYER@CANONICAL.COM', 'tony.meyer@canonical.com'): + assert self.parse(TONY, team=(member,)) == [Change('Do the thing', 1)], member + no_reply = ('Tony Meyer', '12345+tonyandrewmeyer@users.noreply.github.com') + for member in ('@TONYANDREWMEYER', 'tonyandrewmeyer', '@tonyandrewmeyer'): + assert self.parse(no_reply, team=(member,)) == [Change('Do the thing', 1)], member + + def test_a_handle_in_the_team_does_not_match_an_author_who_has_no_handle(self): + # Not a bug, and worth pinning: nothing in a git log connects + # `tony.meyer@canonical.com` to `@tonyandrewmeyer`. A team list of + # handles alone covers only the members who commit from a GitHub + # no-reply address, which is why `OPERATOR_TEAM` carries both. + assert self.parse(TONY, team=('@tonyandrewmeyer',)) == [ + Change('Do the thing', 1, 'Tony Meyer') + ] + + def test_an_empty_team_credits_everyone(self): + # The safe failure. A repository that has not said who maintains it + # over-credits, which is visible in the draft release and takes one + # edit; the other way round, a contributor is silently left out. + assert self.parse(TONY, team=()) == [Change('Do the thing', 1, 'Tony Meyer')] + + def test_the_notes_path_credits_the_same_person_from_a_handle(self): + # The generated notes name the author as `@handle` and say nothing + # else about them, so that is all that path has to match on. + categories, _ = parse_release_notes( + '* fix: do the thing by @Ali-932 in https://github.com/canonical/operator/pull/1', + team=OPERATOR_TEAM, + ) + assert categories['fix'] == [Change('Do the thing', 1, '@Ali-932')] + + def test_the_two_paths_agree_where_a_handle_is_derivable(self): + # And do not, where it is not: see + # `test_an_outside_contributor_with_no_handle_is_credited_by_name`. + # That is a difference in what the inputs know, not in what the + # parsers do. + from_log = parse_git_log( + git_log((GCOMNENO, 'fix: treat remote unit zero as explicit (#2454)', '')), + team=OPERATOR_TEAM, + ) + from_notes, _ = parse_release_notes( + '* fix: treat remote unit zero as explicit by @gcomneno' + ' in https://github.com/canonical/operator/pull/2454', + team=OPERATOR_TEAM, + ) + assert from_log['fix'] == from_notes['fix'] + assert from_log['fix'] == [Change('Treat remote unit zero as explicit', 2454, '@gcomneno')] + + +class RevertTests(unittest.TestCase): + """Reverts, which only the git-log path can resolve. + + Working out whether a revert cancels something needs the revert commit's + *body*, and GitHub's generated notes are one line per pull request with + no bodies in them anywhere. + + Only the first case here is observed. operator has merged exactly one + revert into a 3.x release -- #2568, reverting #2538 -- and it reverts a + `chore`, which the changelog drops anyway, so the cancelling is real but + the cancellation is not what makes it invisible. Every other fixture in + this class is constructed: there is no instance in recent history of a + revert of something in the changelog, and none at all of a revert of a + released feature. + """ + + def parse(self, *commits: tuple[tuple[str, str], str, str]) -> dict[str, list[Change]]: + return parse_git_log(git_log(*commits), team=OPERATOR_TEAM, repo=REPO) + + def test_the_real_revert_range(self): + # Observed: six commits from 3.7.1..3.8.0. #2538 and #2568 cancel, + # and neither is in the output -- though both are `chore`, so both + # would have been dropped regardless. + categories = self.parse(*OPERATOR_REVERT_RANGE_COMMITS) + assert categories['revert'] == [] + assert categories['fix'] == [ + Change('Treat remote unit zero as explicit', 2454, '@gcomneno') + ] + assert categories['docs'] == [ + Change('Fix small issues in K8s tutorial', 2540, 'Iya Mg'), + Change('Replace CoC with link to Ubuntu Code of Conduct', 2564), + Change('Add guidance about names of workload-less charms', 2496, 'deusebio'), + ] + + def test_a_revert_within_the_range_cancels_both_halves(self): + # Constructed: a revert of a `fix` in the same range, which has no + # instance in operator's recent history. A change that landed and was + # taken out again before anything shipped did not happen as far as a + # reader is concerned, so listing either half would be describing + # something that never reached anyone. + categories = self.parse( + (TONY, 'fix: do the thing (#100)', ''), + (TONY, 'fix: do the other thing (#101)', ''), + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts canonical/operator#100\n'), + ) + assert categories['fix'] == [Change('Do the other thing', 101)] + assert categories['revert'] == [] + assert categories['breaking'] == [] + + def test_a_revert_of_something_released_is_called_out(self): + # Constructed. #99 is not in this range, so it shipped: the reader + # needs to be told it has been taken back out. It keeps its own + # `Reverted` heading rather than being filed under the `fix` it + # undoes, which would read as a new fix rather than as a removal. + categories = self.parse( + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['revert'] == [Change('"fix: do the thing"', 102)] + assert categories['fix'] == [] + + def test_a_revert_of_something_released_is_at_least_a_patch(self): + categories = self.parse( + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert infer_bump_size(categories) == PATCH + + def test_a_revert_of_a_released_feature_is_breaking(self): + # Constructed, and the decision worth arguing with: taking away a + # feature people may already be building on is a removal of + # behaviour, whatever the commit type on the revert says. So it goes + # to `breaking`, which is both the loudest heading and -- via + # `MINOR_BUMP_CATEGORIES` -- a minor bump rather than a patch. + categories = self.parse( + (TONY, 'revert: "feat: add the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['breaking'] == [Change('Revert: "feat: add the thing"', 102)] + assert categories['revert'] == [] + assert infer_bump_size(categories) == MINOR + + def test_a_revert_of_a_released_breaking_change_is_breaking(self): + categories = self.parse( + (TONY, 'revert: "refactor!: move it" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['breaking'] == [Change('Revert: "refactor!: move it"', 102)] + + def test_a_revert_of_a_released_feature_in_range_still_cancels(self): + # The cancelling comes first: a feature that never shipped cannot be + # a breaking removal of anything. + categories = self.parse( + (TONY, 'feat: add the thing (#99)', ''), + (TONY, 'revert: "feat: add the thing" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['breaking'] == [] + assert categories['feat'] == [] + assert infer_bump_size(categories) == PATCH + + def test_an_unquoted_reverts_line_is_read(self): + # GitHub's Revert button writes `Reverts owner/repo#N`, but a + # hand-written revert often leaves the owner/repo off. + categories = self.parse( + (TONY, 'fix: do the thing (#100)', ''), + (TONY, 'revert: "fix: do the thing" (#102)', 'Reverts #100\n'), + ) + assert categories['fix'] == [] + assert categories['revert'] == [] + + def test_a_reverts_line_naming_another_repository_is_ignored(self): + # Otherwise `Reverts some/other#100` would cancel this repository's + # #100, which has nothing to do with it. + categories = self.parse( + (TONY, 'fix: do the thing (#100)', ''), + (TONY, 'revert: "fix: something else" (#102)', 'Reverts some/other#100\n'), + ) + assert categories['fix'] == [Change('Do the thing', 100)] + assert categories['revert'] == [Change('"fix: something else"', 102)] + + def test_a_revert_with_no_reverts_line_is_called_out(self): + # Nothing says what it undoes, so there is nothing to cancel it + # against. Showing it is the safe answer: the alternative is hiding a + # change that shipped. + categories = self.parse((TONY, 'revert: "fix: do the thing" (#102)', 'No idea.\n')) + assert categories['revert'] == [Change('"fix: do the thing"', 102)] + + def test_a_revert_of_a_chore_is_still_dropped_when_called_out(self): + # `revert` is a category and `chore` is not, so a revert of a chore + # that is *not* cancelled still appears. That is the right way round: + # the type of the thing reverted does not decide whether a revert is + # worth reporting, because the reader is being told about the removal + # rather than about the original. + categories = self.parse( + (TONY, 'revert: "chore: bump it" (#102)', 'Reverts canonical/operator#99\n'), + ) + assert categories['revert'] == [Change('"chore: bump it"', 102)] + + def test_the_notes_path_cannot_do_any_of_this(self): + # Not a shortcoming to fix. A revert's body is simply not in the + # generated notes, so the notes path lists the revert and the thing + # it reverts side by side, and this is the difference that makes the + # git log the input to prefer. + categories, _ = parse_release_notes( + '* fix: do the thing by @tonyandrewmeyer in https://example.com/pull/100\n' + '* revert: "fix: do the thing" by @tonyandrewmeyer in https://example.com/pull/102\n', + team=OPERATOR_TEAM, + ) + assert categories['fix'] == [Change('Do the thing', 100)] + assert categories['revert'] == [Change('"fix: do the thing"', 102)] + + +class SameRangeFromEitherInputTests(unittest.TestCase): + """The two doors, on the two ranges the rest of these tests are built on. + + This is the check that mattered when the git-log path was added: the + package already had a specification, in the form of what it produced from + GitHub's generated notes for two real operator releases, and the new path + had to reproduce it rather than replace it. It does, byte for byte, on + both ranges and in both output formats. + + A difference here would not automatically be a bug -- a pull-request + title that disagrees with the subject its squash merge landed is exactly + what reading the commits is meant to catch, and neither of these two + ranges contains one -- but it would be something to explain rather than + to adjust an expectation around. + """ + + DATE = datetime.date(2026, 8, 31) + + def both(self, notes: str, log: str) -> tuple[tuple[str, str], tuple[str, str]]: + from_notes, full_changelog = parse_release_notes(notes, team=OPERATOR_TEAM) + from_log = parse_git_log(log, team=OPERATOR_TEAM, repo=REPO) + return ( + ( + format_changes(from_notes, '3.8.2', self.DATE), + format_release_notes(from_notes, full_changelog, repo=REPO), + ), + ( + format_changes(from_log, '3.8.2', self.DATE), + format_release_notes(from_log, full_changelog, repo=REPO), + ), + ) + + def test_3_8_2_renders_identically_from_either_input(self): + from_notes, from_log = self.both(OPERATOR_3_8_2_NOTES, OPERATOR_3_8_2_LOG) + assert from_log == from_notes + + def test_3_8_0_renders_identically_from_either_input(self): + from_notes, from_log = self.both(OPERATOR_BREAKING_NOTES, OPERATOR_BREAKING_LOG) + assert from_log == from_notes + + def test_the_bump_size_is_the_same_from_either_input(self): + for notes, log in ( + (OPERATOR_3_8_2_NOTES, OPERATOR_3_8_2_LOG), + (OPERATOR_BREAKING_NOTES, OPERATOR_BREAKING_LOG), + ): + from_notes, _ = parse_release_notes(notes, team=OPERATOR_TEAM) + from_log = parse_git_log(log, team=OPERATOR_TEAM, repo=REPO) + assert infer_bump_size(from_log) == infer_bump_size(from_notes) + + def test_the_categories_are_identical_and_not_merely_the_rendering(self): + # Rendering can hide a difference -- two changes that swapped places + # inside a category, say, if the category happened to be sorted -- + # so the structures are compared as well as the text. + from_notes, _ = parse_release_notes(OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM) + from_log = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) + assert from_log == from_notes + + def test_the_two_fixtures_describe_the_same_pull_requests(self): + # Otherwise the comparison above could pass by both paths agreeing on + # the wrong thing: a bullet quietly missing from one fixture and the + # matching commit from the other. + in_notes = sorted( + int(line.rsplit('/', 1)[1]) + for line in OPERATOR_3_8_2_NOTES.splitlines() + if line.startswith('* ') and '/pull/' in line and 'first contribution' not in line + ) + in_log = sorted( + int(subject.rsplit('(#', 1)[1][:-1]) for _, subject, _ in OPERATOR_3_8_2_COMMITS + ) + assert in_notes == in_log + assert len(in_log) == 23 + + class ConsoleScriptTests(unittest.TestCase): - """The `changelog` console script: notes on stdin, one answer on stdout.""" + """The `changelog` console script: a range on stdin, one answer on stdout.""" - def run_cli(self, *argv: str, stdin: str = OPERATOR_3_8_2_NOTES) -> tuple[int, str, str]: + def run_cli(self, *argv: str, stdin: str = OPERATOR_3_8_2_LOG) -> tuple[int, str, str]: out, err = io.StringIO(), io.StringIO() with ( mock.patch('sys.stdin', io.StringIO(stdin)), @@ -649,15 +1208,15 @@ def run_cli(self, *argv: str, stdin: str = OPERATOR_3_8_2_NOTES) -> tuple[int, s return returncode, out.getvalue(), err.getvalue() def test_bump_size_prints_one_bare_word(self): - # `SIZE=$(changelog bump-size < notes.md)` is the whole of the + # `SIZE=$(changelog bump-size < log.txt)` is the whole of the # plumbing, so anything else on stdout -- a label, a prefix, JSON -- # would have to be stripped back off in the workflow. assert self.run_cli('bump-size') == (0, 'patch\n', '') - assert self.run_cli('bump-size', stdin=OPERATOR_BREAKING_NOTES) == (0, 'minor\n', '') + assert self.run_cli('bump-size', stdin=OPERATOR_BREAKING_LOG) == (0, 'minor\n', '') def test_next_version_prints_one_bare_word(self): assert self.run_cli('next-version', '--previous', '3.8.1') == (0, '3.8.2\n', '') - minor = self.run_cli('next-version', '--previous', '3.7.1', stdin=OPERATOR_BREAKING_NOTES) + minor = self.run_cli('next-version', '--previous', '3.7.1', stdin=OPERATOR_BREAKING_LOG) assert minor == (0, '3.8.0\n', '') def test_next_version_fails_rather_than_guessing(self): @@ -669,24 +1228,96 @@ def test_next_version_fails_rather_than_guessing(self): assert '3.9.0.dev0' in err def test_release_notes_is_the_library_output(self): - categories, full_changelog = parse_release_notes(OPERATOR_3_8_2_NOTES) - _, out, _ = self.run_cli('release-notes') - # The library does not end its notes with a newline; a file should. - assert out == format_release_notes(categories, full_changelog) + '\n' + categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) + _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--team', TEAM_ARGUMENT) + # No newline is added here, because the library's last line is the + # blank one after the final category. With a compare link at the end + # there is no trailing blank line, and `_emit` adds one; the + # release-notes-input case below is that one. + assert out == format_release_notes(categories, None, repo=REPO) + assert out.endswith('/pull/2699\n') + + def test_release_notes_needs_a_repo_to_build_links_from(self): + # A change carries a number, not a URL, so there is nothing to render + # a link out of without this. Failing is better than printing a body + # whose every bullet has quietly lost its link. + with self.assertRaises(SystemExit): + self.run_cli('release-notes') + + def test_release_notes_takes_a_compare_link_it_cannot_work_out(self): + # A git log has no equivalent of the line GitHub's generated notes + # end with, and the tags at either end of the range are the caller's + # to know, so this is how a workflow keeps the link. + url = 'https://github.com/canonical/operator/compare/3.8.1...3.8.2' + _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--compare-url', url) + assert out.endswith(f'**Full Changelog**: {url}\n') + + def test_release_notes_has_no_compare_link_by_default(self): + _, out, _ = self.run_cli('release-notes', '--repo', REPO) + assert 'Full Changelog' not in out def test_changes_entry_is_the_library_output_byte_for_byte(self): - categories, _ = parse_release_notes(OPERATOR_3_8_2_NOTES) - _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2', '--date', '2026-08-31') + categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM) + _, out, _ = self.run_cli( + 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31', '--team', TEAM_ARGUMENT + ) # Including the blank line it ends with: this text is prepended to # CHANGES.md verbatim, so the trailing layout is part of the answer. assert out == format_changes(categories, '3.8.2', datetime.date(2026, 8, 31)) assert out.endswith('(#2699)\n\n') + def test_changes_entry_needs_no_repo(self): + # It renders `(#2684)` and never a URL, so unlike `release-notes` it + # has nothing to build out of a repository name. + returncode, out, _ = self.run_cli( + 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31' + ) + assert returncode == 0 + assert '(#2684)' in out + + def test_the_team_is_comma_separated_and_repeatable(self): + # A workflow passes one repository variable with commas in it; a + # person types one `--team` per person. Both, and a mixture. + # A handle rather than an email, because it has to suppress the + # credit on both input paths and only the git log has an email to + # match: see `test_an_email_cannot_match_an_author_the_notes_name`. + ali = '@Ali-932' + for argv in ( + ('--team', f'{TEAM_ARGUMENT},{ali}'), + ('--team', TEAM_ARGUMENT, '--team', ali), + ('--team', TEAM_ARGUMENT, '--team', f'someone@example.com,{ali}'), + ): + _, out, _ = self.run_cli( + 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31', *argv + ) + # Everyone in this range is now accounted for, so the one + # bullet that was credited no longer is. + uncredited = '* Compare full event paths when skipping duplicate notices (#2684)' + assert uncredited in out, argv + assert '@Ali-932' not in out, argv + + def test_without_a_team_everyone_is_credited(self): + # The safe way round: over-crediting shows up in the draft release + # and takes one edit, while crediting nobody is invisible. Note that + # Tony is credited by *name* here: he commits from an @canonical.com + # address, so the git log has no handle for him. + _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2', '--date', '2026-08-31') + assert ( + '* Compare full event paths when skipping duplicate notices by @Ali-932 (#2684)' in out + ) + assert '* Stop styling page references as blockquotes by Tony Meyer (#2666)' in out + def test_changes_entry_defaults_to_today(self): with mock.patch.object(_cli, '_today', return_value=datetime.date(2026, 9, 11)): _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2') assert out.startswith('# 3.8.2 - 11 September 2026\n') + def test_git_log_format_prints_the_format_and_reads_nothing(self): + # The one subcommand that does not touch stdin. It exists so that the + # separators live in one place: a workflow that retypes them and drops + # one gets an empty changelog rather than an error. + assert self.run_cli('git-log-format', stdin='') == (0, GIT_LOG_FORMAT + '\n', '') + def test_the_clock_is_read_here_and_only_here(self): # The `no_clock` fixture is active for this test, as it is for every # other, and `_cli` is deliberately outside it. If this starts @@ -709,3 +1340,83 @@ def test_the_entry_point_names_something_that_exists(self): pyproject = (pathlib.Path(__file__).parent.parent / 'pyproject.toml').read_text() assert 'changelog = "charm_tech_code.changelog._cli:main"' in pyproject assert callable(_cli.main) + + +class ConsoleScriptReleaseNotesInputTests(ConsoleScriptTests): + """The same script with `--input release-notes`, which is still a door in. + + It inherits nothing but `run_cli`'s shape deliberately -- the point here + is the flag, and the four subcommands answering the same questions off + the older input. + """ + + def run_cli(self, *argv: str, stdin: str = OPERATOR_3_8_2_NOTES) -> tuple[int, str, str]: + return super().run_cli(*argv, '--input', 'release-notes', stdin=stdin) + + def test_bump_size_prints_one_bare_word(self): + assert self.run_cli('bump-size') == (0, 'patch\n', '') + assert self.run_cli('bump-size', stdin=OPERATOR_BREAKING_NOTES) == (0, 'minor\n', '') + + def test_next_version_prints_one_bare_word(self): + assert self.run_cli('next-version', '--previous', '3.8.1') == (0, '3.8.2\n', '') + minor = self.run_cli('next-version', '--previous', '3.7.1', stdin=OPERATOR_BREAKING_NOTES) + assert minor == (0, '3.8.0\n', '') + + def test_release_notes_is_the_library_output(self): + categories, full_changelog = parse_release_notes(OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM) + _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--team', TEAM_ARGUMENT) + assert out == format_release_notes(categories, full_changelog, repo=REPO) + '\n' + + def test_release_notes_has_no_compare_link_by_default(self): + # Unlike the git-log path: the notes carry the line themselves, and + # it is passed through. + _, out, _ = self.run_cli('release-notes', '--repo', REPO) + assert out.endswith( + '**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2\n' + ) + + def test_release_notes_takes_a_compare_link_it_cannot_work_out(self): + # Here it overrides the line the notes came with, rather than + # supplying one that was missing. + url = 'https://example.com/compare/a...b' + _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--compare-url', url) + assert out.endswith(f'**Full Changelog**: {url}\n') + assert '3.8.1...3.8.2' not in out + + def test_changes_entry_is_the_library_output_byte_for_byte(self): + categories, _ = parse_release_notes(OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM) + _, out, _ = self.run_cli( + 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31', '--team', TEAM_ARGUMENT + ) + assert out == format_changes(categories, '3.8.2', datetime.date(2026, 8, 31)) + assert out.endswith('(#2699)\n\n') + + def test_without_a_team_everyone_is_credited(self): + # By handle rather than by name, which is the one thing this path + # does better: the generated notes name every author as `@handle`, + # including the ones whose commits carry no handle at all. + _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2', '--date', '2026-08-31') + assert ( + '* Compare full event paths when skipping duplicate notices by @Ali-932 (#2684)' in out + ) + assert '* Stop styling page references as blockquotes by @tonyandrewmeyer (#2666)' in out + + def test_an_email_cannot_match_an_author_the_notes_name(self): + # The generated notes give a handle and nothing else, so a team list + # of email addresses matches nobody here, however complete it is. + # The git-log path matches this same person on that same address. + _, out, _ = self.run_cli( + 'changes-entry', + '--tag', + '3.8.2', + '--date', + '2026-08-31', + '--team', + '46688206+Ali-932@users.noreply.github.com', + ) + assert 'by @Ali-932 (#2684)' in out + + def test_git_log_format_prints_the_format_and_reads_nothing(self): + # `--input` is not one of its options: it reads nothing at all. + with self.assertRaises(SystemExit): + self.run_cli('git-log-format', stdin='') From 70f993f973d373cf74c9a4797a39b70571c8b808 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:14:41 +1200 Subject: [PATCH 07/20] Apply suggestion from @tonyandrewmeyer --- changelog/README.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/changelog/README.md b/changelog/README.md index d1603fe..ef6edd9 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -30,16 +30,6 @@ entry = format_changes(categories, version, datetime.date.today()) Running `git log` is the caller's job, as is getting hold of the date: nothing in the library touches the network, git, the filesystem or the clock. That is what lets the tests pin the real behaviour rather than approximate it. -### Why the commits, and not the release notes - -`parse_release_notes` is still here, and reads GitHub's *generated* release-notes text - which GitHub builds from the titles of the pull requests merged in the range. A caller that has a release body in hand should not have to go and fetch a git log to use it. But it is the weaker input, and for a new caller it is the wrong one: - -* **A pull-request title is not a commit subject.** The convention governs commits; a title is written once, when the PR is opened, and can drift from the subject its squash merge lands. When they disagree, the commits are what the repository actually contains. -* **A revert can only be resolved from a git log.** Whether a revert cancels something in the same range is in the revert commit's *body*, and the generated notes are one line per PR with no bodies in them at all. -* **It needs GitHub.** `POST /repos/{owner}/{repo}/releases/generate-notes` is a call, a token and a network. `git log` is neither. - -The two paths otherwise agree: `canonical/operator`'s 3.8.1..3.8.2 and 3.7.1..3.8.0 render byte-for-byte identically from either, which the test suite checks. - ### The pull-request number, and the link A change carries the *number*, taken from the `(#N)` a squash merge appends to the subject, and never a URL. `format_changes` only ever wanted the number, and `format_release_notes` builds the link back up from the number and the `repo` you give it - a string operation, so the no-I/O rule holds. From 62a18b738f874a52f27df9bbf4e8f2a0bb1159ca Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:14:59 +1200 Subject: [PATCH 08/20] docs: split the subprocess call across lines, as ruff format wants `preview = true` in `pyproject.toml` means `ruff format` reaches into Markdown code blocks, and the README's example had three keyword arguments sharing a line. No change to what the example does. --- changelog/README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index ef6edd9..e8e357e 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -20,7 +20,9 @@ from charm_tech_code.changelog import ( log = subprocess.run( ['git', 'log', '--reverse', '--no-merges', f'--format={GIT_LOG_FORMAT}', '3.8.1..3.8.2'], - capture_output=True, text=True, check=True, + capture_output=True, + text=True, + check=True, ).stdout categories = parse_git_log(log, team=MAINTAINERS, repo='canonical/operator') notes = format_release_notes(categories, None, repo='canonical/operator') @@ -30,6 +32,16 @@ entry = format_changes(categories, version, datetime.date.today()) Running `git log` is the caller's job, as is getting hold of the date: nothing in the library touches the network, git, the filesystem or the clock. That is what lets the tests pin the real behaviour rather than approximate it. +### Why the commits, and not the release notes + +`parse_release_notes` is still here, and reads GitHub's *generated* release-notes text - which GitHub builds from the titles of the pull requests merged in the range. A caller that has a release body in hand should not have to go and fetch a git log to use it. But it is the weaker input, and for a new caller it is the wrong one: + +* **A pull-request title is not a commit subject.** The convention governs commits; a title is written once, when the PR is opened, and can drift from the subject its squash merge lands. When they disagree, the commits are what the repository actually contains. +* **A revert can only be resolved from a git log.** Whether a revert cancels something in the same range is in the revert commit's *body*, and the generated notes are one line per PR with no bodies in them at all. +* **It needs GitHub.** `POST /repos/{owner}/{repo}/releases/generate-notes` is a call, a token and a network. `git log` is neither. + +The two paths otherwise agree: `canonical/operator`'s 3.8.1..3.8.2 and 3.7.1..3.8.0 render byte-for-byte identically from either, which the test suite checks. + ### The pull-request number, and the link A change carries the *number*, taken from the `(#N)` a squash merge appends to the subject, and never a URL. `format_changes` only ever wanted the number, and `format_release_notes` builds the link back up from the number and the `repo` you give it - a string operation, so the no-I/O rule holds. From 35499f2af75fba92a1b9cfd7c1a10fb90cee2db7 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:19:00 +1200 Subject: [PATCH 09/20] docs: restore the deleted README section, and stop narrating this branch Three things, all documentation. The "Why the commits, and not the release notes" section was deleted on this branch and my last commit put it back, because I wrote the file from a clone taken before that deletion and replaced it wholesale. Deleted again. Two comments described a state this package has never been in on `main`: the conftest docstring said `format_changes` "used to" read the clock, and a test comment cited "the `(#?)` an earlier version of this rendered". Both now state the rule and the reasoning without the back-story. The `release.py` references stay, because that is a real file in `canonical/operator` and it is the specification these fixtures are checked against. 125 tests pass, ruff check and format clean. --- changelog/README.md | 10 ---------- changelog/tests/conftest.py | 13 ++++++------- changelog/tests/test_changelog.py | 8 ++++---- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/changelog/README.md b/changelog/README.md index e8e357e..e84ced0 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -32,16 +32,6 @@ entry = format_changes(categories, version, datetime.date.today()) Running `git log` is the caller's job, as is getting hold of the date: nothing in the library touches the network, git, the filesystem or the clock. That is what lets the tests pin the real behaviour rather than approximate it. -### Why the commits, and not the release notes - -`parse_release_notes` is still here, and reads GitHub's *generated* release-notes text - which GitHub builds from the titles of the pull requests merged in the range. A caller that has a release body in hand should not have to go and fetch a git log to use it. But it is the weaker input, and for a new caller it is the wrong one: - -* **A pull-request title is not a commit subject.** The convention governs commits; a title is written once, when the PR is opened, and can drift from the subject its squash merge lands. When they disagree, the commits are what the repository actually contains. -* **A revert can only be resolved from a git log.** Whether a revert cancels something in the same range is in the revert commit's *body*, and the generated notes are one line per PR with no bodies in them at all. -* **It needs GitHub.** `POST /repos/{owner}/{repo}/releases/generate-notes` is a call, a token and a network. `git log` is neither. - -The two paths otherwise agree: `canonical/operator`'s 3.8.1..3.8.2 and 3.7.1..3.8.0 render byte-for-byte identically from either, which the test suite checks. - ### The pull-request number, and the link A change carries the *number*, taken from the `(#N)` a squash merge appends to the subject, and never a URL. `format_changes` only ever wanted the number, and `format_release_notes` builds the link back up from the number and the `repo` you give it - a string operation, so the no-I/O rule holds. diff --git a/changelog/tests/conftest.py b/changelog/tests/conftest.py index e386b27..aa24dcb 100644 --- a/changelog/tests/conftest.py +++ b/changelog/tests/conftest.py @@ -14,13 +14,12 @@ """Nothing in this package's library may read the clock. -`format_changes` used to call `datetime.datetime.now()` itself, which is what -made the output of a release depend on which day CI happened to run and made -the function impossible to assert on. The date is an argument now, and this -fixture is what keeps it one: it replaces the `datetime` module as each -library module sees it, so a reinstated `now()` or `today()` call fails the -whole suite rather than quietly passing on every day except the one that -matters. +A library function that reads the clock makes the output of a release depend +on which day CI happened to run, and makes the function impossible to assert +on. So the date is an argument, and this fixture is what keeps it one: it +replaces the `datetime` module as each library module sees it, so a `now()` +or `today()` call anywhere under it fails the whole suite rather than quietly +passing on every day except the one that matters. `_cli` is deliberately not in the list. A console script has to get a date from somewhere for `--date` to be optional, so it is the package's I/O diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py index 16ef1cd..9852643 100644 --- a/changelog/tests/test_changelog.py +++ b/changelog/tests/test_changelog.py @@ -593,10 +593,10 @@ def test_the_pr_number_is_rendered_in_parentheses(self): def test_a_change_with_no_pr_gets_no_reference(self): # A commit pushed straight to the branch has no pull request, and is - # still a change that shipped. It is listed with nothing after it, - # rather than with the `(#?)` an earlier version of this rendered: - # a reader can act on "there is no pull request for this", and a - # question mark only reads as something having gone wrong. + # still a change that shipped. It is listed with nothing after it + # rather than with a placeholder: a reader can act on "there is no + # pull request for this", where a question mark only reads as + # something having gone wrong. assert self.entry(Change('A fix')).endswith('* A fix\n\n') def test_the_credit_goes_before_the_reference(self): From d536a632cd8677979d8ffab512210b02ba420f0a Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:20:55 +1200 Subject: [PATCH 10/20] refactor: make next_version's arguments keyword-only `next_version('3.8.1', MINOR)` does not say what the first argument is, and the two plausible readings - the version this release follows, and the version being cut - differ by exactly one release. The docstring has a paragraph explaining which one it is, which is a sign the signature should say so instead. Both arguments are keyword-only now: `next_version(previous=..., size=...)`. Every caller in the package, the console script and the two documented examples are updated. --- changelog/README.md | 2 +- .../src/charm_tech_code/changelog/__init__.py | 2 +- .../src/charm_tech_code/changelog/_cli.py | 2 +- .../src/charm_tech_code/changelog/_version.py | 2 +- changelog/tests/test_changelog.py | 24 +++++++++---------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/changelog/README.md b/changelog/README.md index e84ced0..939f00b 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -26,7 +26,7 @@ log = subprocess.run( ).stdout categories = parse_git_log(log, team=MAINTAINERS, repo='canonical/operator') notes = format_release_notes(categories, None, repo='canonical/operator') -version = next_version('3.8.1', infer_bump_size(categories)) +version = next_version(previous='3.8.1', size=infer_bump_size(categories)) entry = format_changes(categories, version, datetime.date.today()) ``` diff --git a/changelog/src/charm_tech_code/changelog/__init__.py b/changelog/src/charm_tech_code/changelog/__init__.py index 5d65218..3ded5b3 100644 --- a/changelog/src/charm_tech_code/changelog/__init__.py +++ b/changelog/src/charm_tech_code/changelog/__init__.py @@ -31,7 +31,7 @@ makes the version after `previous`:: size = infer_bump_size(categories) - version = next_version(previous, size) + version = next_version(previous=previous, size=size) `parse_release_notes` is the other door in, reading GitHub's generated release-notes text instead of the commits. It describes the same pull diff --git a/changelog/src/charm_tech_code/changelog/_cli.py b/changelog/src/charm_tech_code/changelog/_cli.py index 19f7bd5..4ef1194 100644 --- a/changelog/src/charm_tech_code/changelog/_cli.py +++ b/changelog/src/charm_tech_code/changelog/_cli.py @@ -280,7 +280,7 @@ def main(argv: Sequence[str] | None = None) -> int: _emit(infer_bump_size(categories)) elif args.command == 'next-version': try: - _emit(next_version(args.previous, infer_bump_size(categories))) + _emit(next_version(previous=args.previous, size=infer_bump_size(categories))) except ValueError as exc: print(f'changelog: {exc}', file=sys.stderr) return 2 diff --git a/changelog/src/charm_tech_code/changelog/_version.py b/changelog/src/charm_tech_code/changelog/_version.py index e918518..ea0cc18 100644 --- a/changelog/src/charm_tech_code/changelog/_version.py +++ b/changelog/src/charm_tech_code/changelog/_version.py @@ -79,7 +79,7 @@ def infer_bump_size(categories: Mapping[str, list[Change]]) -> BumpSize: return PATCH -def next_version(previous: str, size: BumpSize) -> str: +def next_version(*, previous: str, size: BumpSize) -> str: """Apply a bump size to a released version. `previous` is the version this release follows, which for a workflow diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py index 9852643..f488785 100644 --- a/changelog/tests/test_changelog.py +++ b/changelog/tests/test_changelog.py @@ -732,21 +732,21 @@ class NextVersionTests(unittest.TestCase): def test_real_history(self): # The two releases the fixtures above are taken from. - assert next_version('3.7.1', MINOR) == '3.8.0' - assert next_version('3.8.1', PATCH) == '3.8.2' + assert next_version(previous='3.7.1', size=MINOR) == '3.8.0' + assert next_version(previous='3.8.1', size=PATCH) == '3.8.2' def test_a_minor_bump_zeroes_the_patch(self): - assert next_version('3.8.2', MINOR) == '3.9.0' + assert next_version(previous='3.8.2', size=MINOR) == '3.9.0' def test_components_are_numbers_not_digits(self): - assert next_version('3.9.9', PATCH) == '3.9.10' - assert next_version('2.23.16', PATCH) == '2.23.17' - assert next_version('3.9.1', MINOR) == '3.10.0' + assert next_version(previous='3.9.9', size=PATCH) == '3.9.10' + assert next_version(previous='2.23.16', size=PATCH) == '2.23.17' + assert next_version(previous='3.9.1', size=MINOR) == '3.10.0' def test_surrounding_whitespace_is_tolerated(self): # `--previous "$(git describe --tags --abbrev=0)"` arrives with a # newline on it often enough to be worth not failing over. - assert next_version(' 3.8.1\n', PATCH) == '3.8.2' + assert next_version(previous=' 3.8.1\n', size=PATCH) == '3.8.2' def test_a_dev_version_is_rejected(self): # The one that matters. Between releases `ops/version.py` holds @@ -756,26 +756,26 @@ def test_a_dev_version_is_rejected(self): # would skip a version, and stripping the suffix silently would # release whatever that guess happened to be. with self.assertRaises(ValueError): - next_version('3.9.0.dev0', MINOR) + next_version(previous='3.9.0.dev0', size=MINOR) def test_a_pre_release_is_rejected(self): for version in ('3.8.0b1', '3.8.0rc1', '3.8.0a1'): with self.assertRaises(ValueError): - next_version(version, MINOR) + next_version(previous=version, size=MINOR) def test_a_tag_that_is_not_a_version_is_rejected(self): for previous in ('v3.8.1', '3.8', '', 'main'): with self.assertRaises(ValueError): - next_version(previous, PATCH) + next_version(previous=previous, size=PATCH) def test_an_unknown_size_is_rejected(self): # Including 'major', which is not a size this package produces. with self.assertRaises(ValueError): - next_version('3.8.1', 'major') # type: ignore[arg-type] + next_version(previous='3.8.1', size='major') # type: ignore[arg-type] def test_the_error_points_at_the_way_out(self): with self.assertRaises(ValueError) as raised: - next_version('3.9.0.dev0', MINOR) + next_version(previous='3.9.0.dev0', size=MINOR) assert 'explicitly' in str(raised.exception) From 7653c7d1eaf539a4b5beeebf38205bd655405010 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:24:09 +1200 Subject: [PATCH 11/20] style: hold the package to the team's agreed ruff rule set `style/python.md` in canonical/charm-tech names the rule set we standardised on, and the monorepo root turns on eight of the seventeen. The rest are here, in this package's own config, because turning them on at the root would newly fail `ai-failure-notifier`, which is already merged, on eight findings that are not this package's to fix. Moving them up a level is the right end state and wants its own change. Nineteen findings, all documentation: missing blank lines after the last section of a docstring, six summaries not in the imperative mood, a summary running onto a second line, an unsorted import block in the test conftest, and the console script's module docstring, which quotes a shell line continuation and so has to be raw. Two flat-is-better-than-nested cleanups the guide asks for by name: `_team` flattened `--team a,b --team c` with a doubly-nested comprehension, and `parse_git_log` filtered a generator expression inside a list comprehension. Both are loops now. Not changed, deliberately. Internal imports stay `from ._module import names` rather than the guide's module-prefixed form: `ai_failure_notifier` next door does the same, and making one of two packages diverge is worse than either convention. `MINOR` and `PATCH` are compared with `==` because `BumpSize` is a `Literal`, not an `enum.Enum`, so the identity rule does not apply. 125 tests pass, ruff check and format clean. --- changelog/pyproject.toml | 47 +++++++++++++++++++ .../src/charm_tech_code/changelog/_authors.py | 5 +- .../src/charm_tech_code/changelog/_cli.py | 11 +++-- .../src/charm_tech_code/changelog/_format.py | 3 +- .../src/charm_tech_code/changelog/_models.py | 1 + .../src/charm_tech_code/changelog/_parse.py | 19 +++++--- .../src/charm_tech_code/changelog/_version.py | 8 +++- 7 files changed, 79 insertions(+), 15 deletions(-) diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml index c15cf86..c617edb 100644 --- a/changelog/pyproject.toml +++ b/changelog/pyproject.toml @@ -38,3 +38,50 @@ testpaths = ["tests"] # means a setting added here overrides one key rather than the whole config. [tool.ruff] extend = "../pyproject.toml" + +# The rest of the rule set the team agreed on (canonical/charm-tech +# `style/python.md`, "Tooling configuration"). It is here rather than at the +# root because turning it on repo-wide would newly fail `ai-failure-notifier`, +# which is already merged: eight findings, none of them this package's to fix. +# Moving these up a level is the right end state and wants its own change. +[tool.ruff.lint] +extend-select = [ + # flake8-copyright + "CPY", + # flake8-2020 + "YTT", + # flake8-bandit + "S", + # flake8-simplify + "SIM", + # ruff-specific + "RUF", + # perflint + "PERF", + # pydocstyle + "D", + # flake8-future-annotations + "FA", + # flake8-type-checking + "TC", +] +ignore = [ + # Imports used only in annotations do not have to move into a + # `TYPE_CHECKING` block. + "TC001", + "TC002", + "TC003", + # `assert` is fine. + "S101", + # Magic methods and `__init__` do not need docstrings. + "D105", + "D107", + # Two pairs that selecting all of `D` turns on together and that + # contradict each other. Ruff resolves both on its own but warns on every + # run until one of each pair is named here. + "D203", + "D213", +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["D", "S101", "S105", "S106"] diff --git a/changelog/src/charm_tech_code/changelog/_authors.py b/changelog/src/charm_tech_code/changelog/_authors.py index 1f9f94e..47192be 100644 --- a/changelog/src/charm_tech_code/changelog/_authors.py +++ b/changelog/src/charm_tech_code/changelog/_authors.py @@ -71,6 +71,7 @@ def derive_handle(email: str) -> str | None: Returns: The handle without its `@`, or `None` for any other address. + """ match = NOREPLY_EMAIL_REGEX.match(email.strip()) return match.group('handle') if match else None @@ -88,6 +89,7 @@ def credit_for(name: str, email: str, team: Collection[str]) -> str | None: `@handle` when a handle can be recovered from the email, the name when it cannot, or `None` when this author is one of `team` and so is not a guest to be thanked. + """ members = normalise_team(team) handle = derive_handle(email) @@ -99,7 +101,7 @@ def credit_for(name: str, email: str, team: Collection[str]) -> str | None: def credit_for_handle(handle: str, team: Collection[str]) -> str | None: - """The same decision, for a source that gives a handle and nothing else. + """Credit an author a source names only by handle. GitHub's generated release notes name the author as `@handle` and say nothing about who that is, so this is all that path has to go on. It is @@ -114,6 +116,7 @@ def credit_for_handle(handle: str, team: Collection[str]) -> str | None: Returns: `@handle`, or `None` when this author is one of `team`. + """ bare = handle.strip().lstrip('@') if not bare or bare.casefold() in normalise_team(team): diff --git a/changelog/src/charm_tech_code/changelog/_cli.py b/changelog/src/charm_tech_code/changelog/_cli.py index 4ef1194..2bb4fb7 100644 --- a/changelog/src/charm_tech_code/changelog/_cli.py +++ b/changelog/src/charm_tech_code/changelog/_cli.py @@ -13,7 +13,7 @@ # limitations under the License. -"""The console script: a range of changes on stdin, one answer on stdout. +r"""The console script: a range of changes on stdin, one answer on stdout. **This module is the package's I/O boundary, and the only one.** Everything under it is text in, text out -- no network, no git, no filesystem, no clock @@ -76,7 +76,7 @@ def _today() -> datetime.date: - """The default for `--date`, and the package's only reading of the clock. + """Return the default for `--date`, the package's only reading of the clock. UTC rather than local time: the runner is UTC, and a release's date should not depend on who ran it from where. @@ -96,7 +96,7 @@ def _emit(text: str) -> None: def _input_options() -> argparse.ArgumentParser: - """The options that say what is arriving on stdin. + """Build the parent parser for the options that say what arrives on stdin. Shared by the four subcommands that read it, as a parent parser, so that a caller switching input path changes one flag on every command rather @@ -251,7 +251,10 @@ def _team(args: argparse.Namespace) -> list[str]: Both spellings, because a workflow passing a repository variable has one string with commas in it and a human typing the command has neither. """ - return [member for group in (args.team or ()) for member in group.split(',')] + members: list[str] = [] + for group in args.team or (): + members.extend(group.split(',')) + return members def _categories(args: argparse.Namespace, text: str) -> tuple[dict[str, list[Change]], str | None]: diff --git a/changelog/src/charm_tech_code/changelog/_format.py b/changelog/src/charm_tech_code/changelog/_format.py index 9a5c227..72d5c0c 100644 --- a/changelog/src/charm_tech_code/changelog/_format.py +++ b/changelog/src/charm_tech_code/changelog/_format.py @@ -80,6 +80,7 @@ def format_release_notes( number is all a git log has, and all a `CHANGES.md` entry shows, so the link is built here rather than carried around. A change with no pull request renders with no link. + """ lines = ["## What's Changed", ''] if categories[BREAKING]: @@ -104,7 +105,7 @@ def format_release_notes( def _link(change: Change, repo: str) -> str | None: - """The `in ` half of a release-notes bullet, or nothing.""" + """Render the `in ` half of a release-notes bullet, or nothing.""" if change.pr_number is None: return None return 'in ' + PULL_REQUEST_URL_TEMPLATE.format(repo=repo, number=change.pr_number) diff --git a/changelog/src/charm_tech_code/changelog/_models.py b/changelog/src/charm_tech_code/changelog/_models.py index e03fd45..169d2c7 100644 --- a/changelog/src/charm_tech_code/changelog/_models.py +++ b/changelog/src/charm_tech_code/changelog/_models.py @@ -44,6 +44,7 @@ class Change(NamedTuple): credit: How to credit the author, already rendered: `@handle` where one is known, the person's name where it is not, and `None` for an author the caller named as one of its own. See `_authors`. + """ description: str diff --git a/changelog/src/charm_tech_code/changelog/_parse.py b/changelog/src/charm_tech_code/changelog/_parse.py index 0407a94..19f5f19 100644 --- a/changelog/src/charm_tech_code/changelog/_parse.py +++ b/changelog/src/charm_tech_code/changelog/_parse.py @@ -117,6 +117,7 @@ def parse_release_notes( - A dict of category to `Change` list. Every category is present, even when empty, in the order they are rendered in. - The full changelog line if present, or ``None`` if not found. + """ release_notes = NEW_CONTRIBUTORS_REGEX.sub(r'\2', release_notes) categories = _empty_categories() @@ -161,7 +162,7 @@ class _Commit(NamedTuple): def _parse_reverts(body: str, repo: str | None) -> int | None: - """The pull-request number a revert commit's body names, if any. + """Find the pull-request number a revert commit's body names, if any. A ``Reverts other/repo#5`` naming a different repository is not this range's #5, and cancelling against it would drop the wrong pair, so it @@ -226,7 +227,7 @@ def _parse_commit(record: str, team: Collection[str], repo: str | None) -> _Comm def _cancelled(commits: list[_Commit]) -> set[int]: - """The commits that a revert in the same range takes back out of it. + """Find the commits that a revert in the same range takes back out of it. A change that landed and was undone before anything shipped did not happen as far as a reader is concerned, so neither half appears: not the @@ -239,6 +240,7 @@ def _cancelled(commits: list[_Commit]) -> set[int]: Returns: The indices into `commits` to leave out. + """ numbers = {commit.pr_number: index for index, commit in enumerate(commits) if commit.pr_number} cancelled: set[int] = set() @@ -291,13 +293,16 @@ def parse_git_log( return value: a git log carries no compare link for the formatter to pass through, and inventing one would mean knowing the tags at both ends, which is the caller's business. + """ records = log_text.split(GIT_LOG_RECORD_SEPARATOR) - commits = [ - commit - for commit in (_parse_commit(record, team, repo) for record in records if record.strip()) - if commit is not None - ] + commits: list[_Commit] = [] + for record in records: + if not record.strip(): + continue + commit = _parse_commit(record, team, repo) + if commit is not None: + commits.append(commit) categories = _empty_categories() cancelled = _cancelled(commits) diff --git a/changelog/src/charm_tech_code/changelog/_version.py b/changelog/src/charm_tech_code/changelog/_version.py index ea0cc18..5960c84 100644 --- a/changelog/src/charm_tech_code/changelog/_version.py +++ b/changelog/src/charm_tech_code/changelog/_version.py @@ -13,8 +13,10 @@ # limitations under the License. -"""How big a release the commits in a range add up to, and what that makes -the next version. +"""Size a release, and say what version it makes. + +How big the commits in a range add up to, and what that makes the next +version. Two functions, and the split between them is the interesting part. The Charm Tech repositories share a changelog format and a set of @@ -73,6 +75,7 @@ def infer_bump_size(categories: Mapping[str, list[Change]]) -> BumpSize: Returns: `'minor'` or `'patch'`. Never `'major'`: a major release is a deliberate act, not something to infer from a commit range. + """ if any(categories.get(category) for category in MINOR_BUMP_CATEGORIES): return MINOR @@ -96,6 +99,7 @@ def next_version(*, previous: str, size: BumpSize) -> str: Raises: ValueError: if `previous` is not a plain `X.Y.Z`, or `size` is not one of the two sizes `infer_bump_size` returns. + """ match = RELEASE_VERSION_REGEX.fullmatch(previous.strip()) if not match: From 17029939d7c5b1abceecc67515220825ce49fbce Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:30:53 +1200 Subject: [PATCH 12/20] refactor!: read the git log and nothing else `parse_release_notes` was the original implementation and was demoted rather than removed when `parse_git_log` took over. Nothing called it. The flag that reached it existed because the function did, and the one caller this package is being written for runs in a checkout, so it has the log and no reason to fetch generated notes instead. Keeping it was not free. It was a second path through every formatter, an `--input` choice on four subcommands, its own fixtures, and a permanent behavioural divergence the suite had to pin: a revert cannot be resolved from notes at all, so for a range containing one the two paths did not merely differ in detail, they disagreed about what belongs in the changelog. Gone with it: the `--input` flag, `credit_for_handle`, `CHANGE_LINE_REGEX`, `PR_LINK_REGEX`, `NEW_CONTRIBUTORS_REGEX`, the notes fixtures, and the tests that existed to describe the difference between the two paths. `_categories` returns categories rather than a pair, since the compare link now only ever comes from `--compare-url`. The tests that used a notes fixture as a convenient way to build categories are rebased onto the commit fixtures for the same two releases, so what they were actually checking is unchanged. 90 tests pass, ruff check and format clean. --- .../src/charm_tech_code/changelog/__init__.py | 10 +- .../src/charm_tech_code/changelog/_authors.py | 31 +- .../src/charm_tech_code/changelog/_cli.py | 47 +-- .../charm_tech_code/changelog/_constants.py | 13 - .../src/charm_tech_code/changelog/_format.py | 2 +- .../src/charm_tech_code/changelog/_models.py | 8 +- .../src/charm_tech_code/changelog/_parse.py | 112 +---- .../src/charm_tech_code/changelog/_version.py | 2 +- changelog/tests/test_changelog.py | 392 ++---------------- 9 files changed, 73 insertions(+), 544 deletions(-) diff --git a/changelog/src/charm_tech_code/changelog/__init__.py b/changelog/src/charm_tech_code/changelog/__init__.py index 3ded5b3..e498d2b 100644 --- a/changelog/src/charm_tech_code/changelog/__init__.py +++ b/changelog/src/charm_tech_code/changelog/__init__.py @@ -33,13 +33,6 @@ size = infer_bump_size(categories) version = next_version(previous=previous, size=size) -`parse_release_notes` is the other door in, reading GitHub's generated -release-notes text instead of the commits. It describes the same pull -requests by their *titles*, which is a weaker source -- a title is written -once, at open time, while the convention governs the commits -- and it -cannot resolve a revert at all, because that needs a commit body. Prefer -`parse_git_log`; see `_parse` for the full list of differences. - The format is the package's own, not a parameter -- see `_constants` for what that means and why `chore` commits do not appear in a changelog. The `changelog` console script (`_cli`) wraps all of the above for a workflow @@ -56,7 +49,7 @@ ) from ._format import commit_type_to_category, format_changes, format_release_notes from ._models import Change -from ._parse import parse_git_log, parse_release_notes +from ._parse import parse_git_log from ._version import MINOR, PATCH, BumpSize, infer_bump_size, next_version __all__ = [ @@ -74,5 +67,4 @@ 'infer_bump_size', 'next_version', 'parse_git_log', - 'parse_release_notes', ] diff --git a/changelog/src/charm_tech_code/changelog/_authors.py b/changelog/src/charm_tech_code/changelog/_authors.py index 47192be..aaccc5a 100644 --- a/changelog/src/charm_tech_code/changelog/_authors.py +++ b/changelog/src/charm_tech_code/changelog/_authors.py @@ -17,12 +17,7 @@ The rule is that a contributor from outside the team that maintains the repository is credited by name in the entry, and a member of that team is -not. Someone maintaining a project is not a guest in it, and a changelog -where every line ends in the same three handles has stopped carrying any -information; a line that names someone who turned up once and fixed -something is the one worth reading. `canonical/operator`'s own `CHANGES.md` -already does this by hand -- `* Fix typos in code snippets by @MattiaSarti -(#1750)` -- which is the shape this reproduces. +not. **"Outside the team" is not "outside Canonical".** A contributor from another Canonical team has an `@canonical.com` address, no GitHub handle @@ -98,27 +93,3 @@ def credit_for(name: str, email: str, team: Collection[str]) -> str | None: if handle is not None and handle.casefold() in members: return None return f'@{handle}' if handle is not None else name.strip() or None - - -def credit_for_handle(handle: str, team: Collection[str]) -> str | None: - """Credit an author a source names only by handle. - - GitHub's generated release notes name the author as `@handle` and say - nothing about who that is, so this is all that path has to go on. It is - also why the two input paths can disagree: a contributor with no - `users.noreply.github.com` address is credited by name from the git log - and by handle from the notes, and no amount of parsing fixes that -- the - handle simply is not in the git log. - - Args: - handle: The author as the notes name them, `@` optional. - team: The maintainers, as emails and/or handles. - - Returns: - `@handle`, or `None` when this author is one of `team`. - - """ - bare = handle.strip().lstrip('@') - if not bare or bare.casefold() in normalise_team(team): - return None - return f'@{bare}' diff --git a/changelog/src/charm_tech_code/changelog/_cli.py b/changelog/src/charm_tech_code/changelog/_cli.py index 2bb4fb7..3d91524 100644 --- a/changelog/src/charm_tech_code/changelog/_cli.py +++ b/changelog/src/charm_tech_code/changelog/_cli.py @@ -49,11 +49,6 @@ `--format` string the other four expect, so that the exact sequence of `%x1e` and `%x1f` lives in one place rather than being copied into every workflow that calls this. - -`--input release-notes` switches the four back to GitHub's generated -release-notes text. That is the older path and the weaker one -- it reads -pull-request titles rather than commit subjects, and cannot resolve a revert --- so it is the flag rather than the default. """ from __future__ import annotations @@ -66,14 +61,9 @@ from ._constants import FULL_CHANGELOG_PREFIX, GIT_LOG_FORMAT from ._format import format_changes, format_release_notes from ._models import Change -from ._parse import parse_git_log, parse_release_notes +from ._parse import parse_git_log from ._version import infer_bump_size, next_version -#: The two things stdin may be. `git-log` is the default: see the module -#: docstring, and `_parse`. -GIT_LOG_INPUT = 'git-log' -RELEASE_NOTES_INPUT = 'release-notes' - def _today() -> datetime.date: """Return the default for `--date`, the package's only reading of the clock. @@ -103,15 +93,6 @@ def _input_options() -> argparse.ArgumentParser: than learning four spellings of it. """ parser = argparse.ArgumentParser(add_help=False) - parser.add_argument( - '--input', - choices=(GIT_LOG_INPUT, RELEASE_NOTES_INPUT), - default=GIT_LOG_INPUT, - help=( - "What is on stdin: a git log in this tool's format (the default, " - "and the better source), or GitHub's generated release-notes text." - ), - ) parser.add_argument( '--team', action='append', @@ -199,10 +180,9 @@ def _build_parser() -> argparse.ArgumentParser: default=None, metavar='URL', help=( - 'The compare link to end on. A git log has no equivalent of the ' - "line GitHub's generated notes end with, so on that path this is " - 'how to keep one; it overrides the line in the notes on the other. ' - 'Omit it for no link at all.' + 'The compare link to end on. A git log carries no such link, and ' + "the tags at either end of the range are the caller's to know, so " + 'this is how to have one. Omit it for no link at all.' ), ) @@ -257,16 +237,10 @@ def _team(args: argparse.Namespace) -> list[str]: return members -def _categories(args: argparse.Namespace, text: str) -> tuple[dict[str, list[Change]], str | None]: - """Parse stdin by whichever door `--input` names. - - The second half of the pair is the compare line, which only the notes - path can produce for itself. - """ - if args.input == RELEASE_NOTES_INPUT: - return parse_release_notes(text, team=_team(args)) +def _categories(args: argparse.Namespace, text: str) -> dict[str, list[Change]]: + """Parse the git log on stdin into categories.""" repo = getattr(args, 'repo', None) - return parse_git_log(text, team=_team(args), repo=repo), None + return parse_git_log(text, team=_team(args), repo=repo) def main(argv: Sequence[str] | None = None) -> int: @@ -277,7 +251,7 @@ def main(argv: Sequence[str] | None = None) -> int: _emit(GIT_LOG_FORMAT) return 0 - categories, full_changelog = _categories(args, sys.stdin.read()) + categories = _categories(args, sys.stdin.read()) if args.command == 'bump-size': _emit(infer_bump_size(categories)) @@ -288,8 +262,9 @@ def main(argv: Sequence[str] | None = None) -> int: print(f'changelog: {exc}', file=sys.stderr) return 2 elif args.command == 'release-notes': - if args.compare_url: - full_changelog = f'{FULL_CHANGELOG_PREFIX}: {args.compare_url}' + full_changelog = ( + f'{FULL_CHANGELOG_PREFIX}: {args.compare_url}' if args.compare_url else None + ) _emit(format_release_notes(categories, full_changelog, repo=args.repo)) else: _emit(format_changes(categories, args.tag, args.date or _today())) diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py index f0147db..316ecfd 100644 --- a/changelog/src/charm_tech_code/changelog/_constants.py +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -26,16 +26,6 @@ import re -#: The bullet format of GitHub's generated release notes: -#: ``* type!: summary by @user in https://github.com/owner/repo/pull/123``. -#: The ``!`` is optional and marks a breaking change. -CHANGE_LINE_REGEX = re.compile( - r'^\* (?P\w+)(?P!?): (?P.*) by (?P[^ ]+) in (?P.*)' -) - -#: The PR link in a bullet, from which the pull-request number is taken. -PR_LINK_REGEX = re.compile(r'https?://[^ ]+/pull/(\d+)') - #: How a pull-request link is rebuilt from a number. Both parsers reduce a #: change to its *number*, because that is all the git log carries and all a #: ``CHANGES.md`` entry renders, so the URL the release notes want is built @@ -43,9 +33,6 @@ #: string operation, which is what keeps the package free of I/O. PULL_REQUEST_URL_TEMPLATE = 'https://github.com/{repo}/pull/{number}' -#: GitHub appends a section of first-time contributors to its generated -#: notes. It is not part of the changelog, so it is stripped before parsing. -NEW_CONTRIBUTORS_REGEX = re.compile(r'(## New Contributors.*?)(\n|$)', flags=re.DOTALL) #: The line GitHub ends its generated notes with, carrying a compare link. #: It is passed through to the release notes unchanged. diff --git a/changelog/src/charm_tech_code/changelog/_format.py b/changelog/src/charm_tech_code/changelog/_format.py index 72d5c0c..92f90c6 100644 --- a/changelog/src/charm_tech_code/changelog/_format.py +++ b/changelog/src/charm_tech_code/changelog/_format.py @@ -67,7 +67,7 @@ def format_release_notes( Breaking changes are rendered first, under their own heading and a sentence asking the reader to review them. `categories` is expected to - be what `parse_git_log` or `parse_release_notes` returned: every + be what `parse_git_log` returned: every category present, in the order they are rendered in. Args: diff --git a/changelog/src/charm_tech_code/changelog/_models.py b/changelog/src/charm_tech_code/changelog/_models.py index 169d2c7..23ac9b6 100644 --- a/changelog/src/charm_tech_code/changelog/_models.py +++ b/changelog/src/charm_tech_code/changelog/_models.py @@ -15,11 +15,9 @@ """What a parser produces and a formatter renders: one change. -There is one of these per bullet, and both parsers make the same thing, which -is the whole point of having it. `parse_git_log` and `parse_release_notes` -read different text about the same pull requests, and everything downstream -- -the two formatters, the bump-size rule -- works on this rather than on either -input's shape. +There is one of these per bullet. `parse_git_log` makes them and everything +downstream -- the two formatters, the bump-size rule -- works on this rather +than on the shape of the text they were read out of. """ from __future__ import annotations diff --git a/changelog/src/charm_tech_code/changelog/_parse.py b/changelog/src/charm_tech_code/changelog/_parse.py index 19f5f19..447414f 100644 --- a/changelog/src/charm_tech_code/changelog/_parse.py +++ b/changelog/src/charm_tech_code/changelog/_parse.py @@ -15,28 +15,21 @@ """Reading a range of changes out of text, into categories. -Two doors in, one room behind them. `parse_git_log` is the one to use: the -conventional-commit convention governs *commits*, so the commits are what a -changelog should be read off. `parse_release_notes` reads GitHub's generated -notes instead, which describe the same pull requests by their *titles*; it -is kept because a caller that already has a release body in hand should not -have to go and fetch a git log to use it. - -Where they differ is worth knowing before picking one: - -* A pull-request title is written once, when the pull request is opened, and - is not what the conventional-commit rule is about. The squashed subject is - what lands on the branch and what everything else in the repository reads. - When the two disagree, the git log is right. -* A revert can only be resolved from a git log. Working out whether a revert - cancels something in the same range means reading the revert commit's - *body*, and GitHub's generated notes are one line per pull request with no - body anywhere in them. -* The notes carry a handle for every author; the git log carries a name and - an email, from which a handle is sometimes recoverable and sometimes not. - See `_authors`. -* The notes end with a compare link and a git log has no equivalent, so - `parse_git_log` has nothing to return in its place. +`parse_git_log` is the way in. The conventional-commit convention governs +*commits*, so the commits are what a changelog should be read off, and a +squashed subject is what lands on the branch and what everything else in the +repository reads. + +Three things that shapes: + +* **Reverts resolve.** Working out whether a revert cancels something in the + same range means reading the revert commit's *body*, which is in the log. + See `_cancelled`. +* **Authors are a name and an email.** A handle is sometimes recoverable from + the email and sometimes not, so a contributor is credited by whichever is + there. See `_authors`. +* **There is no compare link.** A release body ends with one; a git log has + no equivalent, so the caller supplies it (`--compare-url`) if it wants one. """ from __future__ import annotations @@ -44,17 +37,13 @@ from collections.abc import Collection from typing import NamedTuple -from ._authors import credit_for, credit_for_handle +from ._authors import credit_for from ._constants import ( BREAKING, CATEGORIES, - CHANGE_LINE_REGEX, COMMIT_SUBJECT_REGEX, - FULL_CHANGELOG_PREFIX, GIT_LOG_FIELD_SEPARATOR, GIT_LOG_RECORD_SEPARATOR, - NEW_CONTRIBUTORS_REGEX, - PR_LINK_REGEX, PR_SUFFIX_REGEX, REVERT, REVERT_OF_BREAKING_TYPES, @@ -82,69 +71,6 @@ def _capitalise(summary: str) -> str: return summary[0].upper() + summary[1:] if summary else summary -def parse_release_notes( - release_notes: str, *, team: Collection[str] = () -) -> tuple[dict[str, list[Change]], str | None]: - """Parse GitHub's generated release notes into categories. - - The input is GitHub's *generated* release-notes text, not a ``git log``. - GitHub builds it from the titles of the pull requests merged in the - range, one ``* type!: summary by @user in `` bullet each, which is - why this reads conventional-commit types off pull-request titles rather - than off commit subjects. `parse_git_log` reads the commits, and is the - one to prefer for a new caller; this is here for a caller that has the - notes text already. How it obtained that text is its own problem: a - release has it in its body, and a workflow running before any release - exists can ask GitHub for a preview of it. Nothing here does I/O. - - The "New Contributors" section is removed. Bullets whose type is not a - changelog category -- `chore`, most of all -- are dropped; see - ``_constants.CATEGORIES`` for why that is deliberate. The full-changelog - line is returned separately rather than categorised. - - Reverts are *not* resolved here, and cannot be: see this module's - docstring. A revert in this range appears under `Reverted` whether or - not the thing it reverts is also in the range. - - Args: - release_notes: The generated notes text. - team: Authors not to credit, as emails and/or handles. Only handles - can match anything here, since a handle is all the notes carry. - The default credits everyone; see `_authors`. - - Returns: - A tuple containing: - - A dict of category to `Change` list. Every category is present, - even when empty, in the order they are rendered in. - - The full changelog line if present, or ``None`` if not found. - - """ - release_notes = NEW_CONTRIBUTORS_REGEX.sub(r'\2', release_notes) - categories = _empty_categories() - full_changelog_line = None - - for line in release_notes.splitlines(): - if match := CHANGE_LINE_REGEX.match(line.strip()): - category = match.group('category').strip() - if category not in categories: - continue - description = _capitalise(match.group('summary').strip()) - link_match = PR_LINK_REGEX.match(match.group('pr').strip()) - pr_number = int(link_match.group(1)) if link_match else None - credit = credit_for_handle(match.group('author'), team) - if match.group('breaking') == '!': - categories[BREAKING].append( - Change(f'{category.capitalize()}: {description}', pr_number, credit) - ) - else: - categories[category].append(Change(description, pr_number, credit)) - - elif line.startswith(FULL_CHANGELOG_PREFIX): - full_changelog_line = line - - return categories, full_changelog_line - - class _Commit(NamedTuple): """One record of a `GIT_LOG_FORMAT` log, taken apart.""" @@ -265,10 +191,10 @@ def parse_git_log( that number and `repo` when it is rendered. `log_text` is the output of ``git log`` with ``--format=GIT_LOG_FORMAT``, - oldest first. Getting it is the caller's job, exactly as getting the - notes text is: nothing here runs git, or anything else. + oldest first. Getting it is the caller's job: nothing here runs git, or + anything else. - Three things happen that the notes path cannot do: + Three things the subjects and bodies make possible: * **Reverts cancel.** A revert whose pull request is also in this range removes both itself and what it reverted. See `_cancelled`. diff --git a/changelog/src/charm_tech_code/changelog/_version.py b/changelog/src/charm_tech_code/changelog/_version.py index 5960c84..2d9d04c 100644 --- a/changelog/src/charm_tech_code/changelog/_version.py +++ b/changelog/src/charm_tech_code/changelog/_version.py @@ -50,7 +50,7 @@ def infer_bump_size(categories: Mapping[str, list[Change]]) -> BumpSize: """Work out whether a range of changes is a minor release or a patch one. - `categories` is what `parse_git_log` or `parse_release_notes` returned. + `categories` is what `parse_git_log` returned. That matters rather more than it looks: the parse is where a `!` moves an entry out of its real type and into `breaking`, and where a revert of a released feature is routed there too, so the categories this reads have diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py index f488785..d67c28e 100644 --- a/changelog/tests/test_changelog.py +++ b/changelog/tests/test_changelog.py @@ -60,7 +60,6 @@ infer_bump_size, next_version, parse_git_log, - parse_release_notes, ) # The Charm Tech team as `canonical/operator` would supply it: emails, which @@ -118,44 +117,6 @@ def git_log(*commits: tuple[tuple[str, str], str, str]) -> str: ) -# canonical/operator 3.8.2, released 31 August 2026. Twenty-three merged pull -# requests, eleven of them `chore`. Ali-932's was genuinely their first -# contribution to the repository, so the "New Contributors" section is real -# too. The two bot handles (`@dependabot`, `@prints-charming-bot`) are the -# only part of this not taken straight from the commits; nothing depends on -# them, since the parser only requires a single unspaced token after `by`. -OPERATOR_3_8_2_NOTES = """\ -## What's Changed -* chore: adjust versions after release by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2670 -* docs: give each best-practice admonition a stable :name: anchor by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2524 -* ci: point DB charm CI at the moved mysql-operators repo by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2551 -* chore: bump cryptography from 48.0.1 to 50.0.0 by @dependabot in https://github.com/canonical/operator/pull/2682 -* fix: compare full event paths when skipping duplicate notices by @Ali-932 in https://github.com/canonical/operator/pull/2684 -* chore: bump the actions group across 1 directory with 8 updates by @dependabot in https://github.com/canonical/operator/pull/2674 -* chore: bump the runtime group across 1 directory with 4 updates by @dependabot in https://github.com/canonical/operator/pull/2691 -* docs: reword text that vale 3.17 flags as misspelled by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2695 -* chore: update charm pins by @prints-charming-bot in https://github.com/canonical/operator/pull/2582 -* chore: bump the dev-tooling group in /examples/httpbin-demo with 2 updates by @dependabot in https://github.com/canonical/operator/pull/2675 -* chore: bump the charm-tech group across 1 directory with 3 updates by @dependabot in https://github.com/canonical/operator/pull/2697 -* docs: stop styling page references as blockquotes by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2666 -* ci: switch example charm integration tests to Concierge `k8s` preset by @dwilding in https://github.com/canonical/operator/pull/2696 -* docs: make the custom-endpoint-name sample test actually test something by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2664 -* ci: use the upstream concierge presets again by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2699 -* docs: replace `requests` by `urllib` in K8s tutorial integration tests by @dwilding in https://github.com/canonical/operator/pull/2687 -* fix: don't pass a message when converting an unknown status by name by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2700 -* chore: bump the dev-tooling group with 4 updates by @dependabot in https://github.com/canonical/operator/pull/2676 -* chore: update charm pins by @prints-charming-bot in https://github.com/canonical/operator/pull/2701 -* chore: adopt ruff 0.16's new lint conventions by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2698 -* docs: recommend spread directly, rather than charmcraft test by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2706 -* docs: extract sections in how to write integration tests to their own howto guide by @tromai in https://github.com/canonical/operator/pull/2662 -* chore: update changelog and versions for 3.8.2 release by @dwilding in https://github.com/canonical/operator/pull/2716 - -## New Contributors -* @Ali-932 made their first contribution in https://github.com/canonical/operator/pull/2684 - -**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2 -""" - # The same twenty-three pull requests as they appear in `git log --reverse # 3.8.1..3.8.2`: author name, author email and squash-commit subject, all # verbatim. This is the other end of the fixture above, and the pair is what @@ -211,19 +172,6 @@ def git_log(*commits: tuple[tuple[str, str], str, str]) -> str: '2716', ) -# Four real pull requests from the 3.7.1..3.8.0 range, in merge order, one of -# them the only `!` pull request operator has merged into a 3.x release -# (#2585). Trimmed to four bullets because the full range is fifty; the point -# of this fixture is the `!`, not the volume. -OPERATOR_BREAKING_NOTES = """\ -## What's Changed -* refactor: replace jsonpatch with an inline dict-diff by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2578 -* refactor!: move the otlp-json package to be a regular ops-tracing module by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2585 -* feat: note the socket path in Pebble tracing spans by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2555 -* fix: tear down `Runtime.exec()` when the charm raises by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2581 - -**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0 -""" # The same four pull requests as commits. Observed, and trimmed to the same # four so that this pairs with the notes fixture above. @@ -277,10 +225,14 @@ def git_log(*commits: tuple[tuple[str, str], str, str]) -> str: class RealReleaseTests(unittest.TestCase): """The 3.8.2 fixture, end to end.""" + #: A git log carries no compare link, so a caller that wants one supplies + #: it. This is what `changelog release-notes --compare-url` builds. + full_changelog = ( + '**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2' + ) + def setUp(self): - self.categories, self.full_changelog = parse_release_notes( - OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM - ) + self.categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) def test_categories(self): # Twelve of the twenty-three pull requests survive, in merge order. @@ -318,11 +270,6 @@ def test_categories(self): 'revert': [], } - def test_full_changelog_line(self): - assert self.full_changelog == ( - '**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2' - ) - def test_chore_is_dropped(self): # Eleven of the twenty-three pull requests are `chore`, and none of # them reaches the output. Deliberate: dependency bumps, charm-pin @@ -415,10 +362,14 @@ def test_the_date_is_the_one_it_is_given(self): class BreakingChangeTests(unittest.TestCase): """A `!` moves an entry into its own category, keeping its real type.""" + #: Supplied by the caller, the way `--compare-url` does: see + #: `RealReleaseTests`. + full_changelog = ( + '**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0' + ) + def setUp(self): - self.categories, self.full_changelog = parse_release_notes( - OPERATOR_BREAKING_NOTES, team=OPERATOR_TEAM - ) + self.categories = parse_git_log(OPERATOR_BREAKING_LOG, team=OPERATOR_TEAM, repo=REPO) def test_breaking_entry_keeps_its_real_type_as_a_prefix(self): assert self.categories['breaking'] == [ @@ -483,80 +434,6 @@ def test_changes_entry_puts_breaking_first_without_the_warning(self): ) -class ParseTests(unittest.TestCase): - """The bullet format, in detail.""" - - def parse(self, *bullets: str) -> dict[str, list[Change]]: - # No team, so `@someone` is credited: an empty team credits - # everyone, which is the safe way round for a caller that has not - # said who its maintainers are. - categories, _ = parse_release_notes('\n'.join(bullets)) - return categories - - def test_summary_is_capitalised(self): - # PR titles are lowercase after the conventional-commit type, and - # changelog bullets are sentence case. - categories = self.parse('* fix: do the thing by @someone in https://example.com/pull/1') - assert categories['fix'] == [Change('Do the thing', 1, '@someone')] - - def test_summary_that_starts_with_a_backtick_is_left_alone(self): - categories = self.parse( - '* fix: `Runtime.exec()` tears down by @someone in https://example.com/pull/1' - ) - assert categories['fix'] == [Change('`Runtime.exec()` tears down', 1, '@someone')] - - def test_unrecognised_type_is_dropped(self): - # `build` and `style` are conventional-commit types the PR-title - # check accepts, but they are not changelog categories, so they go - # the same way `chore` does. - categories = self.parse( - '* build: bump the wheel by @someone in https://example.com/pull/1', - '* style: reformat by @someone in https://example.com/pull/2', - '* nonsense: whatever by @someone in https://example.com/pull/3', - ) - assert all(not items for items in categories.values()) - - def test_breaking_on_a_dropped_type_is_still_dropped(self): - # The `!` is only honoured for a type that has a category, so a - # `chore!` does not sneak into the changelog through the breaking - # bucket. - categories = self.parse( - '* chore!: drop python 3.8 by @someone in https://example.com/pull/1' - ) - assert categories['breaking'] == [] - - def test_every_category_is_present_even_when_empty(self): - # Callers index `categories['breaking']` directly, and iterate the - # dict for the rendering order, so the shape does not depend on what - # happened to be in the release. - categories = self.parse('') - assert list(categories) == list(CATEGORIES) - - def test_lines_that_are_not_bullets_are_ignored(self): - categories, full_changelog = parse_release_notes( - "## What's Changed\n" - 'Some prose about the release.\n' - '* not a conventional commit title by @someone in https://example.com/pull/1\n' - '* fix: a real one by @someone in https://example.com/pull/2\n' - ) - assert categories['fix'] == [Change('A real one', 2, '@someone')] - assert full_changelog is None - - def test_indented_bullets_are_parsed(self): - assert self.parse(' * fix: indented by @someone in https://example.com/pull/1')['fix'] - - def test_new_contributors_section_is_stripped_before_parsing(self): - # It is stripped rather than skipped, because its bullets are the - # same shape and would otherwise have to be excluded by luck. - categories, _ = parse_release_notes( - '* fix: a real one by @someone in https://example.com/pull/1\n' - '\n' - '## New Contributors\n' - '* @someone made their first contribution in https://example.com/pull/1\n' - ) - assert categories['fix'] == [Change('A real one', 1, '@someone')] - - class FormatReleaseNotesTests(unittest.TestCase): def empty(self) -> dict[str, list[Change]]: return {category: [] for category in CATEGORIES} @@ -652,25 +529,22 @@ def test_every_category_has_a_heading(self): # The `chore` half of the 3.8.2 fixture on its own: eleven real pull requests, # nothing else. A release with nothing in it but dependency bumps and charm # pins is not hypothetical, and it is a patch. -OPERATOR_CHORE_ONLY_NOTES = '\n'.join( - line - for line in OPERATOR_3_8_2_NOTES.splitlines() - if line.startswith('* chore') or not line.startswith('*') +OPERATOR_CHORE_ONLY_LOG = git_log( + *(commit for commit in OPERATOR_3_8_2_COMMITS if commit[1].startswith('chore')) ) # The one `!` pull request operator has merged into a 3.x release, by itself. # The rest of the 3.7.1..3.8.0 range is what makes that release obviously a # minor one; without it, the `!` has to carry the decision alone. -OPERATOR_BREAKING_ONLY_NOTES = """\ -## What's Changed -* refactor!: move the otlp-json package to be a regular ops-tracing module by @tonyandrewmeyer in https://github.com/canonical/operator/pull/2585 - -**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0 -""" +OPERATOR_BREAKING_ONLY_LOG = git_log(( + TONY, + 'refactor!: move the otlp-json package to be a regular ops-tracing module (#2585)', + '', +)) -def categories_of(notes: str) -> dict[str, list[Change]]: - return parse_release_notes(notes, team=OPERATOR_TEAM)[0] +def categories_of(log: str) -> dict[str, list[Change]]: + return parse_git_log(log, team=OPERATOR_TEAM, repo=REPO) class BumpSizeTests(unittest.TestCase): @@ -679,12 +553,12 @@ class BumpSizeTests(unittest.TestCase): def test_a_release_with_no_features_is_a_patch(self): # 3.8.1 -> 3.8.2: two fixes, seven docs, three CI, eleven chore. It # shipped as a patch. - assert infer_bump_size(categories_of(OPERATOR_3_8_2_NOTES)) == PATCH + assert infer_bump_size(categories_of(OPERATOR_3_8_2_LOG)) == PATCH def test_a_release_with_a_feature_is_a_minor(self): # 3.7.1 -> 3.8.0, trimmed: one `feat` (#2555) among four pull # requests. It shipped as a minor. - assert infer_bump_size(categories_of(OPERATOR_BREAKING_NOTES)) == MINOR + assert infer_bump_size(categories_of(OPERATOR_BREAKING_LOG)) == MINOR def test_a_breaking_change_on_its_own_is_a_minor(self): # #2585 is a `refactor!`, so on the plain reading of the rule -- "a @@ -694,22 +568,20 @@ def test_a_breaking_change_on_its_own_is_a_minor(self): # to let a breaking change ride in a minor when the impact has been # checked. Riding in a patch is not the same decision, and is not one # anyone has made. So a `!` means at least minor. - assert infer_bump_size(categories_of(OPERATOR_BREAKING_ONLY_NOTES)) == MINOR + assert infer_bump_size(categories_of(OPERATOR_BREAKING_ONLY_LOG)) == MINOR def test_a_breaking_feature_is_still_a_minor(self): - # The regression this guards against: `parse_release_notes` *moves* a - # `!` entry out of its real type, so a range whose only feature is a - # `feat!` has an empty `feat` list. A rule that read `feat` alone - # would call this a patch. operator has not merged a `feat!` into a - # 3.x release, so this bullet is made up rather than lifted. - categories = categories_of( - '* feat!: replace the framework API by @someone in https://example.com/pull/1' - ) + # The regression this guards against: parsing *moves* a `!` entry out + # of its real type, so a range whose only feature is a `feat!` has an + # empty `feat` list. A rule that read `feat` alone would call this a + # patch. operator has not merged a `feat!` into a 3.x release, so this + # commit is made up rather than lifted. + categories = categories_of(git_log((TONY, 'feat!: replace the framework API (#1)', ''))) assert categories['feat'] == [] assert infer_bump_size(categories) == MINOR def test_a_release_of_nothing_but_chores_is_a_patch(self): - assert infer_bump_size(categories_of(OPERATOR_CHORE_ONLY_NOTES)) == PATCH + assert infer_bump_size(categories_of(OPERATOR_CHORE_ONLY_LOG)) == PATCH def test_an_empty_range_is_a_patch(self): assert infer_bump_size(categories_of('')) == PATCH @@ -946,39 +818,13 @@ def test_an_empty_team_credits_everyone(self): # edit; the other way round, a contributor is silently left out. assert self.parse(TONY, team=()) == [Change('Do the thing', 1, 'Tony Meyer')] - def test_the_notes_path_credits_the_same_person_from_a_handle(self): - # The generated notes name the author as `@handle` and say nothing - # else about them, so that is all that path has to match on. - categories, _ = parse_release_notes( - '* fix: do the thing by @Ali-932 in https://github.com/canonical/operator/pull/1', - team=OPERATOR_TEAM, - ) - assert categories['fix'] == [Change('Do the thing', 1, '@Ali-932')] - - def test_the_two_paths_agree_where_a_handle_is_derivable(self): - # And do not, where it is not: see - # `test_an_outside_contributor_with_no_handle_is_credited_by_name`. - # That is a difference in what the inputs know, not in what the - # parsers do. - from_log = parse_git_log( - git_log((GCOMNENO, 'fix: treat remote unit zero as explicit (#2454)', '')), - team=OPERATOR_TEAM, - ) - from_notes, _ = parse_release_notes( - '* fix: treat remote unit zero as explicit by @gcomneno' - ' in https://github.com/canonical/operator/pull/2454', - team=OPERATOR_TEAM, - ) - assert from_log['fix'] == from_notes['fix'] - assert from_log['fix'] == [Change('Treat remote unit zero as explicit', 2454, '@gcomneno')] - class RevertTests(unittest.TestCase): - """Reverts, which only the git-log path can resolve. + """Reverts. Working out whether a revert cancels something needs the revert commit's - *body*, and GitHub's generated notes are one line per pull request with - no bodies in them anywhere. + *body*, which is why the parser reads whole log records rather than + subjects alone. Only the first case here is observed. operator has merged exactly one revert into a 3.x release -- #2568, reverting #2538 -- and it reverts a @@ -1107,92 +953,6 @@ def test_a_revert_of_a_chore_is_still_dropped_when_called_out(self): ) assert categories['revert'] == [Change('"chore: bump it"', 102)] - def test_the_notes_path_cannot_do_any_of_this(self): - # Not a shortcoming to fix. A revert's body is simply not in the - # generated notes, so the notes path lists the revert and the thing - # it reverts side by side, and this is the difference that makes the - # git log the input to prefer. - categories, _ = parse_release_notes( - '* fix: do the thing by @tonyandrewmeyer in https://example.com/pull/100\n' - '* revert: "fix: do the thing" by @tonyandrewmeyer in https://example.com/pull/102\n', - team=OPERATOR_TEAM, - ) - assert categories['fix'] == [Change('Do the thing', 100)] - assert categories['revert'] == [Change('"fix: do the thing"', 102)] - - -class SameRangeFromEitherInputTests(unittest.TestCase): - """The two doors, on the two ranges the rest of these tests are built on. - - This is the check that mattered when the git-log path was added: the - package already had a specification, in the form of what it produced from - GitHub's generated notes for two real operator releases, and the new path - had to reproduce it rather than replace it. It does, byte for byte, on - both ranges and in both output formats. - - A difference here would not automatically be a bug -- a pull-request - title that disagrees with the subject its squash merge landed is exactly - what reading the commits is meant to catch, and neither of these two - ranges contains one -- but it would be something to explain rather than - to adjust an expectation around. - """ - - DATE = datetime.date(2026, 8, 31) - - def both(self, notes: str, log: str) -> tuple[tuple[str, str], tuple[str, str]]: - from_notes, full_changelog = parse_release_notes(notes, team=OPERATOR_TEAM) - from_log = parse_git_log(log, team=OPERATOR_TEAM, repo=REPO) - return ( - ( - format_changes(from_notes, '3.8.2', self.DATE), - format_release_notes(from_notes, full_changelog, repo=REPO), - ), - ( - format_changes(from_log, '3.8.2', self.DATE), - format_release_notes(from_log, full_changelog, repo=REPO), - ), - ) - - def test_3_8_2_renders_identically_from_either_input(self): - from_notes, from_log = self.both(OPERATOR_3_8_2_NOTES, OPERATOR_3_8_2_LOG) - assert from_log == from_notes - - def test_3_8_0_renders_identically_from_either_input(self): - from_notes, from_log = self.both(OPERATOR_BREAKING_NOTES, OPERATOR_BREAKING_LOG) - assert from_log == from_notes - - def test_the_bump_size_is_the_same_from_either_input(self): - for notes, log in ( - (OPERATOR_3_8_2_NOTES, OPERATOR_3_8_2_LOG), - (OPERATOR_BREAKING_NOTES, OPERATOR_BREAKING_LOG), - ): - from_notes, _ = parse_release_notes(notes, team=OPERATOR_TEAM) - from_log = parse_git_log(log, team=OPERATOR_TEAM, repo=REPO) - assert infer_bump_size(from_log) == infer_bump_size(from_notes) - - def test_the_categories_are_identical_and_not_merely_the_rendering(self): - # Rendering can hide a difference -- two changes that swapped places - # inside a category, say, if the category happened to be sorted -- - # so the structures are compared as well as the text. - from_notes, _ = parse_release_notes(OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM) - from_log = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) - assert from_log == from_notes - - def test_the_two_fixtures_describe_the_same_pull_requests(self): - # Otherwise the comparison above could pass by both paths agreeing on - # the wrong thing: a bullet quietly missing from one fixture and the - # matching commit from the other. - in_notes = sorted( - int(line.rsplit('/', 1)[1]) - for line in OPERATOR_3_8_2_NOTES.splitlines() - if line.startswith('* ') and '/pull/' in line and 'first contribution' not in line - ) - in_log = sorted( - int(subject.rsplit('(#', 1)[1][:-1]) for _, subject, _ in OPERATOR_3_8_2_COMMITS - ) - assert in_notes == in_log - assert len(in_log) == 23 - class ConsoleScriptTests(unittest.TestCase): """The `changelog` console script: a range on stdin, one answer on stdout.""" @@ -1340,83 +1100,3 @@ def test_the_entry_point_names_something_that_exists(self): pyproject = (pathlib.Path(__file__).parent.parent / 'pyproject.toml').read_text() assert 'changelog = "charm_tech_code.changelog._cli:main"' in pyproject assert callable(_cli.main) - - -class ConsoleScriptReleaseNotesInputTests(ConsoleScriptTests): - """The same script with `--input release-notes`, which is still a door in. - - It inherits nothing but `run_cli`'s shape deliberately -- the point here - is the flag, and the four subcommands answering the same questions off - the older input. - """ - - def run_cli(self, *argv: str, stdin: str = OPERATOR_3_8_2_NOTES) -> tuple[int, str, str]: - return super().run_cli(*argv, '--input', 'release-notes', stdin=stdin) - - def test_bump_size_prints_one_bare_word(self): - assert self.run_cli('bump-size') == (0, 'patch\n', '') - assert self.run_cli('bump-size', stdin=OPERATOR_BREAKING_NOTES) == (0, 'minor\n', '') - - def test_next_version_prints_one_bare_word(self): - assert self.run_cli('next-version', '--previous', '3.8.1') == (0, '3.8.2\n', '') - minor = self.run_cli('next-version', '--previous', '3.7.1', stdin=OPERATOR_BREAKING_NOTES) - assert minor == (0, '3.8.0\n', '') - - def test_release_notes_is_the_library_output(self): - categories, full_changelog = parse_release_notes(OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM) - _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--team', TEAM_ARGUMENT) - assert out == format_release_notes(categories, full_changelog, repo=REPO) + '\n' - - def test_release_notes_has_no_compare_link_by_default(self): - # Unlike the git-log path: the notes carry the line themselves, and - # it is passed through. - _, out, _ = self.run_cli('release-notes', '--repo', REPO) - assert out.endswith( - '**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2\n' - ) - - def test_release_notes_takes_a_compare_link_it_cannot_work_out(self): - # Here it overrides the line the notes came with, rather than - # supplying one that was missing. - url = 'https://example.com/compare/a...b' - _, out, _ = self.run_cli('release-notes', '--repo', REPO, '--compare-url', url) - assert out.endswith(f'**Full Changelog**: {url}\n') - assert '3.8.1...3.8.2' not in out - - def test_changes_entry_is_the_library_output_byte_for_byte(self): - categories, _ = parse_release_notes(OPERATOR_3_8_2_NOTES, team=OPERATOR_TEAM) - _, out, _ = self.run_cli( - 'changes-entry', '--tag', '3.8.2', '--date', '2026-08-31', '--team', TEAM_ARGUMENT - ) - assert out == format_changes(categories, '3.8.2', datetime.date(2026, 8, 31)) - assert out.endswith('(#2699)\n\n') - - def test_without_a_team_everyone_is_credited(self): - # By handle rather than by name, which is the one thing this path - # does better: the generated notes name every author as `@handle`, - # including the ones whose commits carry no handle at all. - _, out, _ = self.run_cli('changes-entry', '--tag', '3.8.2', '--date', '2026-08-31') - assert ( - '* Compare full event paths when skipping duplicate notices by @Ali-932 (#2684)' in out - ) - assert '* Stop styling page references as blockquotes by @tonyandrewmeyer (#2666)' in out - - def test_an_email_cannot_match_an_author_the_notes_name(self): - # The generated notes give a handle and nothing else, so a team list - # of email addresses matches nobody here, however complete it is. - # The git-log path matches this same person on that same address. - _, out, _ = self.run_cli( - 'changes-entry', - '--tag', - '3.8.2', - '--date', - '2026-08-31', - '--team', - '46688206+Ali-932@users.noreply.github.com', - ) - assert 'by @Ali-932 (#2684)' in out - - def test_git_log_format_prints_the_format_and_reads_nothing(self): - # `--input` is not one of its options: it reads nothing at all. - with self.assertRaises(SystemExit): - self.run_cli('git-log-format', stdin='') From 62b7031af20168345a28a25a5a2aae245c6731b4 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:33:08 +1200 Subject: [PATCH 13/20] docs: tidy what removing the release-notes parser left in _constants A stray second blank line, left where the two notes-format regexes were cut out: every other constant in the file is separated by one, and ruff does not mind because two blank lines between top-level statements is ordinary PEP 8. Two comments went stale in the same cut. There is one parser now, not two, and the compare line is not passed through from anything -- the caller supplies the link and this is only the prefix GitHub uses, so that notes rendered here read the same as notes rendered there. --- .../src/charm_tech_code/changelog/_constants.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py index 316ecfd..70255ab 100644 --- a/changelog/src/charm_tech_code/changelog/_constants.py +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -26,16 +26,16 @@ import re -#: How a pull-request link is rebuilt from a number. Both parsers reduce a -#: change to its *number*, because that is all the git log carries and all a -#: ``CHANGES.md`` entry renders, so the URL the release notes want is built -#: back up from the number and the repository the caller names. That is a -#: string operation, which is what keeps the package free of I/O. +#: How a pull-request link is rebuilt from a number. A change carries its +#: *number*, because that is all the git log carries and all a ``CHANGES.md`` +#: entry renders, so the URL a release body wants is built back up from the +#: number and the repository the caller names. That is a string operation, +#: which is what keeps the package free of I/O. PULL_REQUEST_URL_TEMPLATE = 'https://github.com/{repo}/pull/{number}' - -#: The line GitHub ends its generated notes with, carrying a compare link. -#: It is passed through to the release notes unchanged. +#: The prefix of the compare line a release body ends with. The link itself +#: is the caller's (`--compare-url`); this is the shape GitHub uses, so that +#: notes rendered here read the same as notes rendered there. FULL_CHANGELOG_PREFIX = '**Full Changelog**' #: The ``git log --format=`` string `parse_git_log` expects, and the two From 882b4fd90c0d20abcbd6ed33844643c3998cf0df Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:35:00 +1200 Subject: [PATCH 14/20] refactor: format_release_notes takes the compare URL, not the whole line It took `full_changelog`, a pre-formatted line it appended verbatim, so the caller had to know the shape of it. That made sense while `parse_release_notes` existed: the line came out of GitHub's notes and went straight back out, and the package really was passing something through. With that gone, it was the one piece of the output format the library did not own. Headings, bullets, ordering, credits and the breaking-change preamble are all its own; only the closing line was handed in ready-made, and the console script had to import `FULL_CHANGELOG_PREFIX` to build it. So it takes `compare_url` now and renders the line itself. The prefix goes back to being a private detail of the format, which is what `_constants` says it is, and the caller supplies the one thing only it can know: the tags at either end of the range. --- changelog/README.md | 2 +- .../src/charm_tech_code/changelog/_cli.py | 7 ++--- .../src/charm_tech_code/changelog/_format.py | 20 +++++++------- changelog/tests/test_changelog.py | 26 ++++++++----------- 4 files changed, 24 insertions(+), 31 deletions(-) diff --git a/changelog/README.md b/changelog/README.md index 939f00b..20bbede 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -77,7 +77,7 @@ Four invocations re-parse the same text four times. That costs nothing worth cou `git-log-format` is the fifth and the odd one out: it reads nothing, and prints the `--format` string the others expect. Copying that string into the workflow instead would work until someone dropped a separator out of it, and a log that does not parse yields an empty changelog rather than an error. -`--compare-url` is there because a git log has no equivalent of the line GitHub's generated notes end with, and the tags at either end of the range are the workflow's to know. Leave it off for no link. +`--compare-url` is there because a git log does not carry a compare link and the tags at either end of the range are the workflow's to know. You pass the link; the `**Full Changelog**:` prefix is the package's, so that notes rendered here read the same as notes rendered by GitHub. Leave it off for no closing line. `--input release-notes` switches all four back to the older input. diff --git a/changelog/src/charm_tech_code/changelog/_cli.py b/changelog/src/charm_tech_code/changelog/_cli.py index 3d91524..7215cb6 100644 --- a/changelog/src/charm_tech_code/changelog/_cli.py +++ b/changelog/src/charm_tech_code/changelog/_cli.py @@ -58,7 +58,7 @@ import sys from collections.abc import Sequence -from ._constants import FULL_CHANGELOG_PREFIX, GIT_LOG_FORMAT +from ._constants import GIT_LOG_FORMAT from ._format import format_changes, format_release_notes from ._models import Change from ._parse import parse_git_log @@ -262,10 +262,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(f'changelog: {exc}', file=sys.stderr) return 2 elif args.command == 'release-notes': - full_changelog = ( - f'{FULL_CHANGELOG_PREFIX}: {args.compare_url}' if args.compare_url else None - ) - _emit(format_release_notes(categories, full_changelog, repo=args.repo)) + _emit(format_release_notes(categories, args.compare_url, repo=args.repo)) else: _emit(format_changes(categories, args.tag, args.date or _today())) diff --git a/changelog/src/charm_tech_code/changelog/_format.py b/changelog/src/charm_tech_code/changelog/_format.py index 92f90c6..14ad479 100644 --- a/changelog/src/charm_tech_code/changelog/_format.py +++ b/changelog/src/charm_tech_code/changelog/_format.py @@ -25,6 +25,7 @@ BREAKING, BREAKING_PREAMBLE, CATEGORY_HEADINGS, + FULL_CHANGELOG_PREFIX, PULL_REQUEST_URL_TEMPLATE, ) from ._models import Change @@ -58,23 +59,22 @@ def _bullet(change: Change, reference: str | None) -> str: def format_release_notes( - categories: Mapping[str, list[Change]], full_changelog: str | None, *, repo: str + categories: Mapping[str, list[Change]], compare_url: str | None, *, repo: str ) -> str: """Format for release notes. Results in a Markdown formatted string with sections for each commit type. - If `full_changelog` is provided, it is appended at the end. Breaking changes are rendered first, under their own heading and a - sentence asking the reader to review them. `categories` is expected to - be what `parse_git_log` returned: every - category present, in the order they are rendered in. + sentence asking the reader to review them. `categories` is expected to be + what `parse_git_log` returned: every category present, in the order they + are rendered in. Args: categories: The parsed changes. - full_changelog: The compare line to end on, or `None`. A git log has - no equivalent of it, so a caller on that path either leaves it - out or builds one, knowing the tags at both ends. + compare_url: A link comparing the two ends of the range, rendered as + the closing line. A git log does not carry one, and the tags at + either end are the caller's to know. `None` for no closing line. repo: The `owner/name` the pull-request links point into. It is needed because a `Change` carries a number and not a URL -- the number is all a git log has, and all a `CHANGES.md` entry shows, @@ -99,8 +99,8 @@ def format_release_notes( lines.append(f'### {commit_type_to_category(commit_type)}') lines.extend(_bullet(change, _link(change, repo)) for change in items) lines.append('') - if full_changelog: - lines.append(full_changelog) + if compare_url: + lines.append(f'{FULL_CHANGELOG_PREFIX}: {compare_url}') return '\n'.join(lines) diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py index d67c28e..62fb2fd 100644 --- a/changelog/tests/test_changelog.py +++ b/changelog/tests/test_changelog.py @@ -226,10 +226,8 @@ class RealReleaseTests(unittest.TestCase): """The 3.8.2 fixture, end to end.""" #: A git log carries no compare link, so a caller that wants one supplies - #: it. This is what `changelog release-notes --compare-url` builds. - full_changelog = ( - '**Full Changelog**: https://github.com/canonical/operator/compare/3.8.1...3.8.2' - ) + #: it. This is what `changelog release-notes --compare-url` takes. + compare_url = 'https://github.com/canonical/operator/compare/3.8.1...3.8.2' def setUp(self): self.categories = parse_git_log(OPERATOR_3_8_2_LOG, team=OPERATOR_TEAM, repo=REPO) @@ -275,7 +273,7 @@ def test_chore_is_dropped(self): # them reaches the output. Deliberate: dependency bumps, charm-pin # updates and the release's own version bump are not changelog # material. - notes = format_release_notes(self.categories, self.full_changelog, repo=REPO) + notes = format_release_notes(self.categories, self.compare_url, repo=REPO) entry = format_changes(self.categories, '3.8.2', datetime.date(2026, 8, 31)) assert 'chore' not in notes.lower() assert 'chore' not in entry.lower() @@ -284,7 +282,7 @@ def test_chore_is_dropped(self): assert pr not in entry, f'chore PR #{pr} leaked into the changelog entry' def test_new_contributors_section_is_dropped(self): - notes = format_release_notes(self.categories, self.full_changelog, repo=REPO) + notes = format_release_notes(self.categories, self.compare_url, repo=REPO) assert 'New Contributors' not in notes assert 'made their first contribution' not in notes @@ -329,7 +327,7 @@ def test_changes_entry(self): def test_release_notes(self): assert ( - format_release_notes(self.categories, self.full_changelog, repo=REPO) + format_release_notes(self.categories, self.compare_url, repo=REPO) == """\ ## What's Changed @@ -364,9 +362,7 @@ class BreakingChangeTests(unittest.TestCase): #: Supplied by the caller, the way `--compare-url` does: see #: `RealReleaseTests`. - full_changelog = ( - '**Full Changelog**: https://github.com/canonical/operator/compare/3.7.1...3.8.0' - ) + compare_url = 'https://github.com/canonical/operator/compare/3.7.1...3.8.0' def setUp(self): self.categories = parse_git_log(OPERATOR_BREAKING_LOG, team=OPERATOR_TEAM, repo=REPO) @@ -385,7 +381,7 @@ def test_breaking_entry_is_not_also_in_its_own_type(self): def test_release_notes_put_breaking_first_with_a_warning(self): assert ( - format_release_notes(self.categories, self.full_changelog, repo=REPO) + format_release_notes(self.categories, self.compare_url, repo=REPO) == """\ ## What's Changed @@ -452,10 +448,10 @@ def test_categories_render_in_the_declared_order(self): ] assert headings == ['### Features', '### Fixes', '### CI', '### Reverted'] - def test_full_changelog_is_appended_when_given(self): - notes = format_release_notes( - self.empty(), '**Full Changelog**: https://example.com/x', repo=REPO - ) + def test_the_compare_url_becomes_the_closing_line(self): + # The caller passes the link; the prefix is the package's, so that + # notes rendered here read the same as notes rendered by GitHub. + notes = format_release_notes(self.empty(), 'https://example.com/x', repo=REPO) assert notes.endswith('**Full Changelog**: https://example.com/x') From 7e967f714f178bd7af2fe2192e9d895e51ee98f9 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:38:49 +1200 Subject: [PATCH 15/20] docs: scopes are dropped by choice, not because nothing uses them `COMMIT_SUBJECT_REGEX` claimed no repository in the estate uses a scope. That is wrong: `canonical/pebble` carries one on 60 of its last 298 conventional subjects, from `chore(deps)` through `feat(cli)` and `fix(overlord)` to `fix(cmdstate,wsutil)`. The behaviour does not change. Dropping the scope is a decision about what a changelog entry should read like: a reader wants what changed, the package it changed in is in the diff, and a dependency-bump-heavy range would otherwise render sixty near-identical prefixes. The comment now says that, and says where the scope would have to be carried if a repository ever wanted it rendered. Two tests instead of one, since the shape is real rather than hypothetical: the second is `fix(cmdstate,wsutil)`, a real pebble subject and the one most likely to be read as two groups by a regex written for one. --- .../src/charm_tech_code/changelog/_constants.py | 15 ++++++++++++--- changelog/tests/test_changelog.py | 16 +++++++++++----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/changelog/src/charm_tech_code/changelog/_constants.py b/changelog/src/charm_tech_code/changelog/_constants.py index 70255ab..a27a985 100644 --- a/changelog/src/charm_tech_code/changelog/_constants.py +++ b/changelog/src/charm_tech_code/changelog/_constants.py @@ -65,9 +65,18 @@ #: A conventional-commit subject, as the shared `check-conventional-pr-title` #: script defines it: a type, an optional scope, an optional ``!``, then the -#: summary. The scope is captured and ignored -- no repository in the estate -#: uses one today, but the checker accepts one, and the two should not -#: disagree about what a valid subject looks like. +#: summary. +#: +#: The scope is captured and dropped. `canonical/pebble` uses scopes heavily +#: -- 60 of its last 298 conventional subjects carry one, from +#: ``chore(deps)`` to ``fix(cmdstate,wsutil)`` -- so this is a choice about +#: what a changelog entry should read like, not an observation that nothing +#: uses them. A reader of a release's notes wants what changed; which package +#: it changed in is in the diff, and prefixing every bullet with it would +#: mean a `chore(deps)`-heavy range rendering sixty near-identical prefixes. +#: If a repository ever wants them rendered, `Change` is where the scope +#: would have to be carried, and this is the only place it is currently +#: thrown away. COMMIT_SUBJECT_REGEX = re.compile( r'^(?P[A-Za-z]+)' r'(?:\((?P[^()]+)\))?' diff --git a/changelog/tests/test_changelog.py b/changelog/tests/test_changelog.py index 62fb2fd..dbaf2d7 100644 --- a/changelog/tests/test_changelog.py +++ b/changelog/tests/test_changelog.py @@ -703,11 +703,17 @@ def test_a_breaking_commit_moves_to_breaking_with_its_type_kept(self): assert categories['breaking'] == [Change('Refactor: Move the thing', 2585)] assert categories['refactor'] == [] - def test_a_scope_is_accepted_and_ignored(self): - # No repository in the estate uses one, but the shared PR-title check - # accepts `type(scope):`, and the two should not disagree about what - # a valid subject is. - categories = self.parse((TONY, 'fix(tracing): do the thing (#1)', '')) + def test_a_scope_is_accepted_and_dropped(self): + # `canonical/pebble` scopes most of its dependency bumps and a good + # deal else, so this is a real shape and not a hypothetical one. The + # scope does not reach the entry: see `COMMIT_SUBJECT_REGEX`. + categories = self.parse((TONY, 'fix(reaper): do the thing (#1)', '')) + assert categories['fix'] == [Change('Do the thing', 1)] + + def test_a_comma_separated_scope_is_one_scope(self): + # `fix(cmdstate,wsutil):` is a real pebble subject, and the shape most + # likely to be read as two groups by a regex written for one. + categories = self.parse((TONY, 'fix(cmdstate,wsutil): do the thing (#1)', '')) assert categories['fix'] == [Change('Do the thing', 1)] def test_a_breaking_scoped_commit_is_still_breaking(self): From 6b92f3415469bdce57a9cc7a408481f020dcfdc1 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:45:18 +1200 Subject: [PATCH 16/20] docs: drop the release.py dependency inventory from pyproject Naming the three dependencies `release.py` happens to have says nothing about this package, and it is a claim about a file in another repository that nothing here checks. The reason for an empty `dependencies` stands on its own. --- changelog/pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml index c617edb..1808cfb 100644 --- a/changelog/pyproject.toml +++ b/changelog/pyproject.toml @@ -10,9 +10,7 @@ authors = [ license = "Apache-2.0" # No runtime dependencies, and that is worth keeping. The package is pure # text-to-text: it does not talk to GitHub, run git, or read the clock, so -# there is nothing for a dependency to do. `release.py`, which this is lifted -# out of, needs `pygithub`, `packaging` and `rich` -- all three belong to the -# parts that stayed behind in canonical/operator. +# there is nothing for a dependency to do. dependencies = [] # The importable API is the package; this is the same thing wrapped for a From c313f200d92edff138458f305a29b2193bafd3d8 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 11:47:25 +1200 Subject: [PATCH 17/20] build: adopt the team's ruff rule set at the root, not per package `style/python.md` names one rule set for the team's repositories, so it belongs where the line length and the quote style already are: at the root, applying to every tool in the monorepo. Having it in one package's config made the standard a property of whichever package happened to adopt it, which is the opposite of the point. This turns up eight findings in `ai-failure-notifier`, fixed separately. Lint here is red until that lands. --- changelog/pyproject.toml | 47 ---------------------------------------- pyproject.toml | 45 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 49 deletions(-) diff --git a/changelog/pyproject.toml b/changelog/pyproject.toml index 1808cfb..10ff25e 100644 --- a/changelog/pyproject.toml +++ b/changelog/pyproject.toml @@ -36,50 +36,3 @@ testpaths = ["tests"] # means a setting added here overrides one key rather than the whole config. [tool.ruff] extend = "../pyproject.toml" - -# The rest of the rule set the team agreed on (canonical/charm-tech -# `style/python.md`, "Tooling configuration"). It is here rather than at the -# root because turning it on repo-wide would newly fail `ai-failure-notifier`, -# which is already merged: eight findings, none of them this package's to fix. -# Moving these up a level is the right end state and wants its own change. -[tool.ruff.lint] -extend-select = [ - # flake8-copyright - "CPY", - # flake8-2020 - "YTT", - # flake8-bandit - "S", - # flake8-simplify - "SIM", - # ruff-specific - "RUF", - # perflint - "PERF", - # pydocstyle - "D", - # flake8-future-annotations - "FA", - # flake8-type-checking - "TC", -] -ignore = [ - # Imports used only in annotations do not have to move into a - # `TYPE_CHECKING` block. - "TC001", - "TC002", - "TC003", - # `assert` is fine. - "S101", - # Magic methods and `__init__` do not need docstrings. - "D105", - "D107", - # Two pairs that selecting all of `D` turns on together and that - # contradict each other. Ruff resolves both on its own but warns on every - # run until one of each pair is named here. - "D203", - "D213", -] - -[tool.ruff.lint.per-file-ignores] -"tests/*" = ["D", "S101", "S105", "S106"] diff --git a/pyproject.toml b/pyproject.toml index 8a87244..ca1286d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ preview = true [tool.ruff.format] quote-style = "single" +# The rule set the team agreed on: canonical/charm-tech `style/python.md`, +# "Tooling configuration". It lives here so that every tool in the monorepo +# gets it, the same way the line length and the quote style do. [tool.ruff.lint] select = [ # Pyflakes @@ -49,8 +52,46 @@ select = [ "N", # flake8-builtins "A", - # flake8-bugbear - "B", + # flake8-copyright + "CPY", # pyupgrade "UP", + # flake8-2020 + "YTT", + # flake8-bandit + "S", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # ruff-specific + "RUF", + # perflint + "PERF", + # pydocstyle + "D", + # flake8-future-annotations + "FA", + # flake8-type-checking + "TC", +] +ignore = [ + # Imports used only in annotations do not have to move into a + # `TYPE_CHECKING` block. + "TC001", + "TC002", + "TC003", + # `assert` is fine. + "S101", + # Magic methods and `__init__` do not need docstrings. + "D105", + "D107", + # Two pairs that selecting all of `D` turns on together and that + # contradict each other. Ruff resolves both on its own but warns on every + # run until one of each pair is named here. + "D203", + "D213", ] + +[tool.ruff.lint.per-file-ignores] +"*/tests/*" = ["D", "S101", "S105", "S106"] From f912990200450e2b1f31e133e411a30fd3c33061 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 12:00:41 +1200 Subject: [PATCH 18/20] docs: drop the README's last mention of the --input flag The flag went with `parse_release_notes`; this line survived because the sweep for it looked for the words the prose used elsewhere and not for the flag itself. --- changelog/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/changelog/README.md b/changelog/README.md index 20bbede..df8ca6e 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -79,8 +79,6 @@ Four invocations re-parse the same text four times. That costs nothing worth cou `--compare-url` is there because a git log does not carry a compare link and the tags at either end of the range are the workflow's to know. You pass the link; the `**Full Changelog**:` prefix is the package's, so that notes rendered here read the same as notes rendered by GitHub. Leave it off for no closing line. -`--input release-notes` switches all four back to the older input. - `--date` defaults to today (UTC), and `_cli` is the only module in the package that reads the clock. The library stays clock-free, and a fixture in the test suite fails the whole run if that stops being true. Run it from a workflow the way `ai-failure-notifier` is run, pinned to a commit: From c1661765fc8a9c5f62710356f4a1b635b2a35ddd Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 12:01:34 +1200 Subject: [PATCH 19/20] docs: don't point at a sibling package for how to run this one "the way `ai-failure-notifier` is run" reads fine while there are two packages and worse with every one added, and the line below it already shows the whole invocation, so the comparison was carrying nothing. --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index df8ca6e..0582128 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -81,7 +81,7 @@ Four invocations re-parse the same text four times. That costs nothing worth cou `--date` defaults to today (UTC), and `_cli` is the only module in the package that reads the clock. The library stays clock-free, and a fixture in the test suite fails the whole run if that stops being true. -Run it from a workflow the way `ai-failure-notifier` is run, pinned to a commit: +Run it from a workflow pinned to a commit: ```shell uvx --from "git+https://github.com/canonical/charm-tech-code@<40-char-sha>#subdirectory=changelog" changelog bump-size < log.txt From 6b174fc17215d9ee8ea24f7804e1d9cb7c13ccc2 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Tue, 15 Sep 2026 12:02:16 +1200 Subject: [PATCH 20/20] docs: state the two things about the type map without the defensiveness "before you decide it's wrong" argues with the reader before they have said anything. The two bullets make the case on their own. --- changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/README.md b/changelog/README.md index 0582128..72227ce 100644 --- a/changelog/README.md +++ b/changelog/README.md @@ -110,7 +110,7 @@ Where the line falls: the package says what size the range is and does the semve Neither shape is injectable, and neither is the map of commit type to heading. The format is common across our repositories and the set of types is enforced by a shared PR-title check, so there is no second format for an adopting repository to supply, and a template system here would exist for a caller that doesn't. -Two things about that map are worth knowing before you decide it's wrong: +Two things about that map are worth knowing: * `chore` is a type but not a category, so `chore` commits are deliberately dropped. Dependency bumps, charm-pin updates and the release's own version-bump commit are all `chore`, and these sorts of changes are not interesting to our users, and they are available via `git log` if anyone does want them. * `breaking` is a category but not a type. A `!` after the real type (`feat!:`) moves an entry into it, keeping its real type as a prefix, and it renders first with a sentence asking the reader to review carefully. A revert of a released `feat` lands there too, for the reason in "Reverts" above. A `!` should be a major version bump, but if it's appearing here then we have decided to cheat the semver rules and allow a breaking change in a minor release. This should be rare. We will have carefully checked the impact before this decision, but want to make sure the change is particularly noticeable in the changelog.