feat: add a changelog package that reads the git log - #6
Open
tonyandrewmeyer wants to merge 20 commits into
Open
tonyandrewmeyer wants to merge 20 commits into
tonyandrewmeyer wants to merge 20 commits into
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DC6P7uEu7Qw7kh2tDBuJyB
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fHEMV23WkCiU8YoLdF6iW
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018YMpsxxo5JJQMa8FGdMGwf
tonyandrewmeyer
commented
Sep 14, 2026
`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.
tonyandrewmeyer
force-pushed
the
changelog-from-git-log
branch
from
September 14, 2026 23:15
57f5a11 to
62a18b7
Compare
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.
`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.
`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.
`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.
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.
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.
`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.
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.
`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.
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.
"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.
"before you decide it's wrong" argues with the reader before they have said anything. The two bullets make the case on their own.
tonyandrewmeyer
marked this pull request as ready for review
September 15, 2026 00:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The canonical/operator
release.pyturns a release range intoCHANGES.mdentries and a release-notes body, and every repo that wants the same thing has to copy it. This is that logic as a package, so ops, charmlibs, jubilant and the rest can share one implementation of "our changelog format".git log, rather than a draft release generated by GitHub.feat, or of anything carrying a!, is routed to Breaking Changes.Tests are built on two real operator releases as fixtures.
3.8.1..3.8.2and a trimmed3.7.1..3.8.0produce byte-identicalCHANGES.mdentries and release-notes bodies from either input path, against what those releases actually shipped - with the one deliberate exception of the author credits, which is the whole point of the fourth bullet.