Skip to content

feat: add a changelog package that reads the git log - #6

Open
tonyandrewmeyer wants to merge 20 commits into
canonical:mainfrom
tonyandrewmeyer:changelog-from-git-log
Open

tonyandrewmeyer wants to merge 20 commits into
canonical:mainfrom
tonyandrewmeyer:changelog-from-git-log

Conversation

@tonyandrewmeyer

@tonyandrewmeyer tonyandrewmeyer commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

The canonical/operator release.py turns a release range into CHANGES.md entries 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".

  • The input is git log, rather than a draft release generated by GitHub.
  • Authors outside the team are credited, inside it they are not.
  • A revert of a released 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.2 and a trimmed 3.7.1..3.8.0 produce byte-identical CHANGES.md entries 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.

claude and others added 6 commits September 10, 2026 14:09
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
Comment thread changelog/README.md Outdated
`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.
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
tonyandrewmeyer marked this pull request as ready for review September 15, 2026 00:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants