Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a231e35
feat: add a changelog package
claude Sep 10, 2026
441b095
Apply suggestion from @tonyandrewmeyer
tonyandrewmeyer Sep 10, 2026
1edb056
Apply suggestion from @tonyandrewmeyer
tonyandrewmeyer Sep 10, 2026
9c9adb7
Apply suggestion from @tonyandrewmeyer
tonyandrewmeyer Sep 10, 2026
7ddbee1
feat: infer the bump size and add a console script to changelog
claude Sep 11, 2026
f32ad50
feat: read the changelog off the git log, not off the release notes
claude Sep 14, 2026
12850ef
Apply suggestion from @tonyandrewmeyer
tonyandrewmeyer Sep 14, 2026
811c1d8
docs: split the subprocess call across lines, as ruff format wants
tonyandrewmeyer Sep 14, 2026
f3af1ac
docs: restore the deleted README section, and stop narrating this branch
tonyandrewmeyer Sep 14, 2026
0b7beeb
refactor: make next_version's arguments keyword-only
tonyandrewmeyer Sep 14, 2026
f50aa7a
style: hold the package to the team's agreed ruff rule set
tonyandrewmeyer Sep 14, 2026
5bbea9e
refactor!: read the git log and nothing else
tonyandrewmeyer Sep 14, 2026
b40d807
docs: tidy what removing the release-notes parser left in _constants
tonyandrewmeyer Sep 14, 2026
b762364
refactor: format_release_notes takes the compare URL, not the whole line
tonyandrewmeyer Sep 14, 2026
a34d387
docs: scopes are dropped by choice, not because nothing uses them
tonyandrewmeyer Sep 14, 2026
4291824
docs: drop the release.py dependency inventory from pyproject
tonyandrewmeyer Sep 14, 2026
8d48ada
build: adopt the team's ruff rule set at the root, not per package
tonyandrewmeyer Sep 14, 2026
21131cf
docs: drop the README's last mention of the --input flag
tonyandrewmeyer Sep 15, 2026
b31dcd5
docs: don't point at a sibling package for how to run this one
tonyandrewmeyer Sep 15, 2026
bb4e2d2
docs: state the two things about the type map without the defensiveness
tonyandrewmeyer Sep 15, 2026
ffa7fd2
feat: surface unrecognised commit types, and drop the library API
tonyandrewmeyer Sep 17, 2026
0f96d99
Apply batched suggestions from code review
tonyandrewmeyer Sep 17, 2026
c8f0f75
refactor: swap which output spells a pull request out in full
tonyandrewmeyer Sep 17, 2026
7ec05f0
build: tell ruff the docstrings are Google-style
tonyandrewmeyer Sep 17, 2026
46bd6bf
style: bring ai-failure-notifier up to the widened rule set
tonyandrewmeyer Sep 17, 2026
1d227a2
docs: cut the _cli narration back to the rules it is keeping
tonyandrewmeyer Sep 17, 2026
c0624a5
test: name the fixture maintainers after Ubuntu releases
tonyandrewmeyer Sep 17, 2026
9cdd3b7
docs: one sentence per rule in the README
tonyandrewmeyer Sep 17, 2026
bbad3ff
build: name the ignored ruff rules instead of coding them
tonyandrewmeyer Sep 17, 2026
5925f9d
docs: link the pull request in CHANGES.md instead of printing its URL
tonyandrewmeyer Sep 17, 2026
4dc0d1f
style: drop the inline suppressions the root ignores replace
tonyandrewmeyer Sep 20, 2026
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 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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from __future__ import annotations

import json
import subprocess # ruff: ignore[suspicious-subprocess-import]
import subprocess
from typing import Any

from . import _summary
Expand All @@ -30,7 +30,7 @@ def gh(*args: str, check: bool = True) -> subprocess.CompletedProcess:
"""Run a `gh` subcommand, returning the completed process."""
# `gh` is deliberately called by name so the runner's PATH resolves it.
# `args` is constructed in this module and is trusted.
return subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true]
return subprocess.run(
['gh', *args], # ruff: ignore[start-process-with-partial-path]
text=True,
capture_output=True,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ def _error_detail(exc: urllib.error.HTTPError) -> str:
"""
try:
raw = exc.read().decode(errors='replace').strip()
except Exception: # ruff: ignore[blind-except]
except Exception:
# Any read failure means no detail, not a crash.
return 'no response body'
if not raw:
Expand Down
2 changes: 1 addition & 1 deletion ai-failure-notifier/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from __future__ import annotations

import subprocess # ruff: ignore[suspicious-subprocess-import]
import subprocess
import urllib.request

import pytest
Expand Down
24 changes: 15 additions & 9 deletions ai-failure-notifier/tests/test_ai_failure_notifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -1311,26 +1311,32 @@ def test_the_error_carries_openrouters_own_explanation(self):
'error': {'code': 400, 'message': "Invalid schema: 'required' is missing 'also'"}
}).encode()
error = self._http_error(400, body)
with mock.patch.object(_openrouter.urllib.request, 'urlopen', side_effect=error):
with self.assertRaises(RuntimeError) as raised:
_openrouter.call_openrouter('sys', 'user', 'm', 'k')
with (
mock.patch.object(_openrouter.urllib.request, 'urlopen', side_effect=error),
self.assertRaises(RuntimeError) as raised,
):
_openrouter.call_openrouter('sys', 'user', 'm', 'k')
message = str(raised.exception)
self.assertIn('HTTP Error 400', message)
self.assertIn("'required' is missing 'also'", message)

def test_a_body_that_is_not_json_is_reported_as_it_came(self):
error = self._http_error(502, b'<html>upstream is unwell</html>')
with mock.patch.object(_openrouter.urllib.request, 'urlopen', side_effect=error):
with self.assertRaises(RuntimeError) as raised:
_openrouter.call_openrouter('sys', 'user', 'm', 'k')
with (
mock.patch.object(_openrouter.urllib.request, 'urlopen', side_effect=error),
self.assertRaises(RuntimeError) as raised,
):
_openrouter.call_openrouter('sys', 'user', 'm', 'k')
self.assertIn('upstream is unwell', str(raised.exception))

def test_an_unreadable_body_still_leaves_the_status(self):
error = self._http_error(429, b'')
error.read = mock.Mock(side_effect=OSError('connection reset'))
with mock.patch.object(_openrouter.urllib.request, 'urlopen', side_effect=error):
with self.assertRaises(RuntimeError) as raised:
_openrouter.call_openrouter('sys', 'user', 'm', 'k')
with (
mock.patch.object(_openrouter.urllib.request, 'urlopen', side_effect=error),
self.assertRaises(RuntimeError) as raised,
):
_openrouter.call_openrouter('sys', 'user', 'm', 'k')
self.assertIn('HTTP Error 429', str(raised.exception))


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

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 stay in each repository.

## What it produces

Four commits in a range, the first of them from a contributor outside the team:

```
fix: stop the framework mistaking two notices for twins (#2684)
docs: stop dressing cross-references up as quotations (#2666)
ci: crawl back to the upstream concierge presets (#2699)
chore: bump cryptography from 48.0.1 to 50.0.0 (#2682)
```

become one `CHANGES.md` entry:

```markdown
# 3.8.2 - 31 August 2026

## Fixes

* Stop the framework mistaking two notices for twins by @ducky-debugger ([#2684](https://github.com/canonical/operator/pull/2684))

## Documentation

* Stop dressing cross-references up as quotations ([#2666](https://github.com/canonical/operator/pull/2666))

## CI

* Crawl back to the upstream concierge presets ([#2699](https://github.com/canonical/operator/pull/2699))
```

Release notes are the same content with `###` headings and `in #2684` in place of the parenthesised link. The short form is GitHub's own: a release body renders `#2684` as a link to the pull request, where a `CHANGES.md` is read in an editor, on PyPI and in the docs as well, and only GitHub would make a link of it.

Most of the rules are visible there:

* **A contributor from outside the maintaining team is credited; a maintainer is not.** Pass the team to `--team`, comma-separated, as emails and/or handles; an empty team credits everyone, which is the safe way round, since over-crediting is visible in the draft release and crediting nobody is not.
* **A handle is only sometimes recoverable.** `46688206+ducky-debugger@users.noreply.github.com` gives `@ducky-debugger`, GitHub's default for an account with a private email; where the log has no handle, the person is credited by name.
* **`chore` is dropped on purpose.** Dependency bumps, charm pins and the release's own version bump are not what a reader came for, and `git log` still has them.
* **The headings, their order and the commit-type map are fixed.** The format is common across our repositories, so there is nothing for an adopting repository to supply.

And the rules it doesn't show:

* **A `!` means breaking.** The entry moves to a `Breaking Changes` section that renders first, keeping its real type as a prefix and carrying a sentence asking the reader to review carefully; it does not infer a *major* bump.
* **A revert of something in the same range cancels with it**, and neither appears; a revert of something already released goes under `Reverted`, or under `Breaking Changes` where it undoes a released `feat` or anything carrying a `!`.
* **Anything unplaceable is surfaced rather than dropped.** An unrecognised commit type, or a subject that is not conventional at all, lands under `Uncategorised` with the type kept, for a human to fix while reading the draft.
* **A commit with no `(#N)` in its subject** - one pushed straight to the branch - renders with no reference rather than a placeholder standing in for one.

## Using it

Everything goes through the `changelog` console script. It reads a git log on stdin and prints one answer on stdout:

```shell
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 --repo "$REPO" --tag "$VERSION" --team "$TEAM" < log.txt > changes-entry.md
```

The two commands that print one word are for `$GITHUB_OUTPUT`; the two that print Markdown are for redirecting into a file, because a `$GITHUB_OUTPUT` line only takes a multi-line document through a heredoc delimiter the document must not itself contain. `git-log-format` prints the `--format` string the others expect, so the separators live in one place rather than in every workflow - copy them and it works until someone drops one, at which point the log stops parsing and the release goes out with an empty changelog rather than an error.

`--compare-url` is optional: a git log carries no compare link and the tags at either end are the workflow's to know. `--date` defaults to today in UTC, and the console script is the only part of the package that reads the clock. `changelog --help` has the rest.

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
```

## Versions

`bump-size` says whether a range is a minor or a patch - a `feat` or a breaking change means minor, anything else means patch - and `next-version` applies that to a plain `X.Y.Z`. A major bump is never inferred, nor is a pre-release: `next-version` raises rather than guess at anything that isn't `X.Y.Z`, `3.9.0.dev0` included, since that is the guess the last post-release bump left in the version file rather than a version anyone shipped.

The repository decides what to count from, whether a `.dev0` goes on afterwards, and what any of it means 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 which branches those are is the repository's to know, so that check belongs there.

## Developing

```shell
uv sync --group unit
uv run pytest
```
38 changes: 38 additions & 0 deletions changelog/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[project]
name = "charm-tech-code-changelog"
version = "0.1.0"
description = "Turn a range of commits 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.
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"

[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"
23 changes: 23 additions & 0 deletions changelog/src/charm_tech_code/changelog/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# 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 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: the `changelog` console script
(`_cli`) is the package's one I/O boundary, and is how a workflow step calls
this. See `_constants` for the format, and why `chore` commits do not appear
in a changelog.
"""
82 changes: 82 additions & 0 deletions changelog/src/charm_tech_code/changelog/_authors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# 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.

**"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.
"""

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.
Comment on lines +34 to +38

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should just settle on one format for the team list, emails or Github users.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's harder to match in that case. It's not about what the package gets passed, it's about what's available in the Git log.

"""
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`.
A parameter rather than a constant because it drifts and differs
per repository. 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, while crediting nobody is invisible
until a contributor notices.

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
Loading
Loading