Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
46 changes: 46 additions & 0 deletions changelog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# changelog

Turns GitHub's generated release notes 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.

## 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 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.

## Developing

```shell
uv sync --group unit
uv run pytest
```
34 changes: 34 additions & 0 deletions changelog/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
43 changes: 43 additions & 0 deletions changelog/src/charm_tech_code/changelog/__init__.py
Original file line number Diff line number Diff line change
@@ -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',
]
98 changes: 98 additions & 0 deletions changelog/src/charm_tech_code/changelog/_constants.py
Original file line number Diff line number Diff line change
@@ -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<category>\w+)(?P<breaking>!?): (?P<summary>.*) by [^ ]+ in (?P<pr>.*)'
)

#: 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:'
104 changes: 104 additions & 0 deletions changelog/src/charm_tech_code/changelog/_format.py
Original file line number Diff line number Diff line change
@@ -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'
Loading