Skip to content

Commit 801bac5

Browse files
authored
Release: Fix changelog formatting (#5529)
# Improve release notes formatting / changelog conversion from rst to md During our last release, we discovered issues with changelog formatting. This PR improves and fixes several aspects: ## Changes - Rewrite the changelog conversion logic to be more robust and maintainable - Fix indentation issues with nested bullet points - Improve handling of long section headers - Order bullet points alphabetically within sections for better readability - Use Sphinx `objects.inv` to resolve references and include links to the documentation in _Markdown_ - Add tests to prevent formatting regressions - Add pandoc as a dependency for Ubuntu CI builds - Ensure documentation is built before generating changelog
2 parents c01d059 + eb557f7 commit 801bac5

File tree

4 files changed

+261
-39
lines changed

4 files changed

+261
-39
lines changed

.github/workflows/ci.yaml

+4-3
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@ jobs:
2929
python-version: ${{ matrix.python-version }}
3030
cache: poetry
3131

32-
- name: Install PyGobject dependencies on Ubuntu
32+
- name: Install PyGobject and release script dependencies on Ubuntu
3333
if: matrix.platform == 'ubuntu-latest'
3434
run: |
3535
sudo apt update
36-
sudo apt install ffmpeg gobject-introspection libgirepository1.0-dev
37-
poetry install --extras=replaygain --extras=reflink
36+
sudo apt install ffmpeg gobject-introspection libgirepository1.0-dev pandoc
37+
poetry install --with=release --extras=docs --extras=replaygain --extras=reflink
38+
poe docs
3839
3940
- name: Install Python dependencies
4041
run: poetry install --only=main,test --extras=autobpm

.github/workflows/make_release.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ jobs:
6565
- name: Obtain the changelog
6666
id: generate_changelog
6767
run: |
68+
poe docs
6869
{
6970
echo 'changelog<<EOF'
70-
poe changelog
71+
poe --quiet changelog
7172
echo EOF
7273
} >> "$GITHUB_OUTPUT"
7374

extra/release.py

+147-35
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,141 @@
66

77
import re
88
import subprocess
9+
from contextlib import redirect_stdout
910
from datetime import datetime, timezone
11+
from functools import partial
12+
from io import StringIO
1013
from pathlib import Path
11-
from typing import Callable
14+
from typing import Callable, NamedTuple
1215

1316
import click
1417
import tomli
1518
from packaging.version import Version, parse
19+
from sphinx.ext import intersphinx
20+
from typing_extensions import TypeAlias
1621

1722
BASE = Path(__file__).parent.parent.absolute()
1823
PYPROJECT = BASE / "pyproject.toml"
1924
CHANGELOG = BASE / "docs" / "changelog.rst"
25+
DOCS = "https://beets.readthedocs.io/en/stable"
2026

21-
MD_CHANGELOG_SECTION_LIST = re.compile(r"- .+?(?=\n\n###|$)", re.DOTALL)
22-
version_header = r"\d+\.\d+\.\d+ \([^)]+\)"
27+
VERSION_HEADER = r"\d+\.\d+\.\d+ \([^)]+\)"
2328
RST_LATEST_CHANGES = re.compile(
24-
rf"{version_header}\n--+\s+(.+?)\n\n+{version_header}", re.DOTALL
29+
rf"{VERSION_HEADER}\n--+\s+(.+?)\n\n+{VERSION_HEADER}", re.DOTALL
30+
)
31+
32+
Replacement: TypeAlias = "tuple[str, str | Callable[[re.Match[str]], str]]"
33+
34+
35+
class Ref(NamedTuple):
36+
"""A reference to documentation with ID, path, and optional title."""
37+
38+
id: str
39+
path: str | None
40+
title: str | None
41+
42+
@classmethod
43+
def from_line(cls, line: str) -> Ref:
44+
"""Create Ref from a Sphinx objects.inv line.
45+
46+
Each line has the following structure:
47+
<id> [optional title : ] <relative-url-path>
48+
49+
"""
50+
if len(line_parts := line.split(" ", 1)) == 1:
51+
return cls(line, None, None)
52+
53+
id, path_with_name = line_parts
54+
parts = [p.strip() for p in path_with_name.split(":", 1)]
55+
56+
if len(parts) == 1:
57+
path, name = parts[0], None
58+
else:
59+
name, path = parts
60+
61+
return cls(id, path, name)
62+
63+
@property
64+
def url(self) -> str:
65+
"""Full documentation URL."""
66+
return f"{DOCS}/{self.path}"
67+
68+
@property
69+
def name(self) -> str:
70+
"""Display name (title if available, otherwise ID)."""
71+
return self.title or self.id
72+
73+
74+
def get_refs() -> dict[str, Ref]:
75+
"""Parse Sphinx objects.inv and return dict of documentation references."""
76+
objects_filepath = Path("docs/_build/html/objects.inv")
77+
if not objects_filepath.exists():
78+
raise ValueError("Documentation does not exist. Run 'poe docs' first.")
79+
80+
captured_output = StringIO()
81+
82+
with redirect_stdout(captured_output):
83+
intersphinx.inspect_main([str(objects_filepath)])
84+
85+
return {
86+
r.id: r
87+
for ln in captured_output.getvalue().split("\n")
88+
if ln.startswith("\t") and (r := Ref.from_line(ln.strip()))
89+
}
90+
91+
92+
def create_rst_replacements() -> list[Replacement]:
93+
"""Generate list of pattern replacements for RST changelog."""
94+
refs = get_refs()
95+
96+
def make_ref_link(ref_id: str, name: str | None = None) -> str:
97+
ref = refs[ref_id]
98+
return rf"`{name or ref.name} <{ref.url}>`_"
99+
100+
commands = "|".join(r.split("-")[0] for r in refs if r.endswith("-cmd"))
101+
plugins = "|".join(
102+
r.split("/")[-1] for r in refs if r.startswith("plugins/")
103+
)
104+
return [
105+
# Fix nested bullet points indent: use 2 spaces consistently
106+
(r"(?<=\n) {3,4}(?=\*)", " "),
107+
# Fix nested text indent: use 4 spaces consistently
108+
(r"(?<=\n) {5,6}(?=[\w:`])", " "),
109+
# Replace Sphinx :ref: and :doc: directives by documentation URLs
110+
# :ref:`/plugins/autobpm` -> [AutoBPM Plugin](DOCS/plugins/autobpm.html)
111+
(
112+
r":(?:ref|doc):`+(?:([^`<]+)<)?/?([\w./_-]+)>?`+",
113+
lambda m: make_ref_link(m[2], m[1]),
114+
),
115+
# Convert command references to documentation URLs
116+
# `beet move` or `move` command -> [import](DOCS/reference/cli.html#import)
117+
(
118+
rf"`+beet ({commands})`+|`+({commands})`+(?= command)",
119+
lambda m: make_ref_link(f"{m[1] or m[2]}-cmd"),
120+
),
121+
# Convert plugin references to documentation URLs
122+
# `fetchart` plugin -> [fetchart](DOCS/plugins/fetchart.html)
123+
(rf"`+({plugins})`+", lambda m: make_ref_link(f"plugins/{m[1]}")),
124+
# Add additional backticks around existing backticked text to ensure it
125+
# is rendered as inline code in Markdown
126+
(r"(?<=[\s])(`[^`]+`)(?!_)", r"`\1`"),
127+
# Convert bug references to GitHub issue links
128+
(r":bug:`(\d+)`", r":bug: (#\1)"),
129+
# Convert user references to GitHub @mentions
130+
(r":user:`(\w+)`", r"\@\1"),
131+
]
132+
133+
134+
MD_REPLACEMENTS: list[Replacement] = [
135+
(r"^ (- )", r"\1"), # remove indent from top-level bullet points
136+
(r"^ +( - )", r"\1"), # adjust nested bullet points indent
137+
(r"^(\w[^\n]{,80}):(?=\n\n[^ ])", r"### \1"), # format section headers
138+
(r"^(\w[^\n]{81,}):(?=\n\n[^ ])", r"**\1**"), # and bolden too long ones
139+
(r"### [^\n]+\n+(?=### )", ""), # remove empty sections
140+
]
141+
order_bullet_points = partial(
142+
re.compile("(\n- .*?(?=\n(?! *- )|$))", flags=re.DOTALL).sub,
143+
lambda m: "\n- ".join(sorted(m.group().split("\n- "))),
25144
)
26145

27146

@@ -41,8 +160,11 @@ def update_changelog(text: str, new: Version) -> str:
41160
----------
42161
43162
New features:
163+
44164
Bug fixes:
165+
45166
For packagers:
167+
46168
Other changes:
47169
48170
{new_header}
@@ -95,50 +217,36 @@ def bump_version(new: Version) -> None:
95217

96218
def rst2md(text: str) -> str:
97219
"""Use Pandoc to convert text from ReST to Markdown."""
98-
# Other backslashes with verbatim ranges.
99-
rst = re.sub(r"(?<=[\s(])`([^`]+)`(?=[^_])", r"``\1``", text)
100-
101-
# Bug numbers.
102-
rst = re.sub(r":bug:`(\d+)`", r":bug: (#\1)", rst)
103-
104-
# Users.
105-
rst = re.sub(r":user:`(\w+)`", r"@\1", rst)
106220
return (
107221
subprocess.check_output(
108-
["/usr/bin/pandoc", "--from=rst", "--to=gfm", "--wrap=none"],
109-
input=rst.encode(),
222+
["pandoc", "--from=rst", "--to=gfm+hard_line_breaks"],
223+
input=text.encode(),
110224
)
111225
.decode()
112226
.strip()
113227
)
114228

115229

116-
def changelog_as_markdown() -> str:
117-
"""Get the latest changelog entry as hacked up Markdown."""
118-
with CHANGELOG.open() as f:
119-
contents = f.read()
230+
def get_changelog_contents() -> str | None:
231+
if m := RST_LATEST_CHANGES.search(CHANGELOG.read_text()):
232+
return m.group(1)
120233

121-
m = RST_LATEST_CHANGES.search(contents)
122-
rst = m.group(1) if m else ""
234+
return None
123235

124-
# Convert with Pandoc.
125-
md = rst2md(rst)
126236

127-
# Make sections stand out
128-
md = re.sub(r"^(\w.+?):$", r"### \1", md, flags=re.M)
237+
def changelog_as_markdown(rst: str) -> str:
238+
"""Get the latest changelog entry as hacked up Markdown."""
239+
for pattern, repl in create_rst_replacements():
240+
rst = re.sub(pattern, repl, rst, flags=re.M | re.DOTALL)
129241

130-
# Highlight plugin names
131-
md = re.sub(
132-
r"^- `/?plugins/(\w+)`:?", r"- Plugin **`\1`**:", md, flags=re.M
133-
)
242+
md = rst2md(rst)
134243

135-
# Highlights command names.
136-
md = re.sub(r"^- `(\w+)-cmd`:?", r"- Command **`\1`**:", md, flags=re.M)
244+
for pattern, repl in MD_REPLACEMENTS:
245+
md = re.sub(pattern, repl, md, flags=re.M | re.DOTALL)
137246

138-
# sort list items alphabetically for each of the sections
139-
return MD_CHANGELOG_SECTION_LIST.sub(
140-
lambda m: "\n".join(sorted(m.group().splitlines())), md
141-
)
247+
# order bullet points in each of the lists alphabetically to
248+
# improve readability
249+
return order_bullet_points(md)
142250

143251

144252
@click.group()
@@ -156,7 +264,11 @@ def bump(version: Version) -> None:
156264
@cli.command()
157265
def changelog():
158266
"""Get the most recent version's changelog as Markdown."""
159-
print(changelog_as_markdown())
267+
if changelog := get_changelog_contents():
268+
try:
269+
print(changelog_as_markdown(changelog))
270+
except ValueError as e:
271+
raise click.exceptions.UsageError(str(e))
160272

161273

162274
if __name__ == "__main__":

test/test_release.py

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""Tests for the release utils."""
2+
3+
import os
4+
import shutil
5+
import sys
6+
7+
import pytest
8+
9+
release = pytest.importorskip("extra.release")
10+
11+
12+
pytestmark = pytest.mark.skipif(
13+
not (
14+
(os.environ.get("GITHUB_ACTIONS") == "true" and sys.platform != "win32")
15+
or bool(shutil.which("pandoc"))
16+
),
17+
reason="pandoc isn't available",
18+
)
19+
20+
21+
@pytest.fixture
22+
def rst_changelog():
23+
return """New features:
24+
25+
* :doc:`/plugins/substitute`: Some substitute
26+
multi-line change.
27+
:bug:`5467`
28+
* :ref:`list-cmd` Update.
29+
30+
You can do something with this command::
31+
32+
$ do-something
33+
34+
Bug fixes:
35+
36+
* Some fix that refers to an issue.
37+
:bug:`5467`
38+
* Some fix that mentions user :user:`username`.
39+
* Some fix thanks to
40+
:user:`username`. :bug:`5467`
41+
* Some fix with its own bullet points using incorrect indentation:
42+
* First nested bullet point
43+
with some text that wraps to the next line
44+
* Second nested bullet point
45+
* Another fix with its own bullet points using correct indentation:
46+
* First
47+
* Second
48+
49+
Section naaaaaaaaaaaaaaaaaaaaaaaammmmmmmmmmmmmmmmeeeeeeeeeeeeeee with over 80
50+
characters:
51+
52+
Empty section:
53+
54+
Other changes:
55+
56+
* Changed `bitesize` label to `good first issue`. Our `contribute`_ page is now
57+
automatically populated with these issues. :bug:`4855`
58+
59+
.. _contribute: https://github.com/beetbox/beets/contribute
60+
61+
2.1.0 (November 22, 2024)
62+
-------------------------
63+
64+
Bug fixes:
65+
66+
* Fixed something."""
67+
68+
69+
@pytest.fixture
70+
def md_changelog():
71+
return r"""### New features
72+
73+
- [Substitute Plugin](https://beets.readthedocs.io/en/stable/plugins/substitute.html): Some substitute multi-line change. :bug: (\#5467)
74+
- [list](https://beets.readthedocs.io/en/stable/reference/cli.html#list-cmd) Update.
75+
76+
You can do something with this command:
77+
78+
$ do-something
79+
80+
### Bug fixes
81+
82+
- Another fix with its own bullet points using correct indentation:
83+
- First
84+
- Second
85+
- Some fix thanks to @username. :bug: (\#5467)
86+
- Some fix that mentions user @username.
87+
- Some fix that refers to an issue. :bug: (\#5467)
88+
- Some fix with its own bullet points using incorrect indentation:
89+
- First nested bullet point with some text that wraps to the next line
90+
- Second nested bullet point
91+
92+
**Section naaaaaaaaaaaaaaaaaaaaaaaammmmmmmmmmmmmmmmeeeeeeeeeeeeeee with over 80 characters**
93+
94+
### Other changes
95+
96+
- Changed `bitesize` label to `good first issue`. Our [contribute](https://github.com/beetbox/beets/contribute) page is now automatically populated with these issues. :bug: (\#4855)
97+
98+
# 2.1.0 (November 22, 2024)
99+
100+
### Bug fixes
101+
102+
- Fixed something.""" # noqa: E501
103+
104+
105+
def test_convert_rst_to_md(rst_changelog, md_changelog):
106+
actual = release.changelog_as_markdown(rst_changelog)
107+
108+
assert actual == md_changelog

0 commit comments

Comments
 (0)