Skip to content

Commit e93b772

Browse files
authored
Add ddev release changelog show command (DataDog#23586)
1 parent 50c9a3f commit e93b772

4 files changed

Lines changed: 251 additions & 0 deletions

File tree

ddev/changelog.d/23586.added

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `ddev release changelog show` command to print the section of a target's `CHANGELOG.md` for a given version.

ddev/src/ddev/cli/release/changelog/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from ddev.cli.release.changelog.draft import draft
77
from ddev.cli.release.changelog.fix import fix
88
from ddev.cli.release.changelog.new import new
9+
from ddev.cli.release.changelog.show import show
910

1011

1112
@click.group(short_help='Manage changelogs')
@@ -18,3 +19,4 @@ def changelog():
1819
changelog.add_command(draft)
1920
changelog.add_command(fix)
2021
changelog.add_command(new)
22+
changelog.add_command(show)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
6+
from typing import TYPE_CHECKING
7+
8+
import click
9+
10+
from ddev.utils.fs import Path
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import Iterator
14+
15+
from ddev.cli.application import Application
16+
17+
18+
@click.command(short_help="Show the changelog section for a target's version")
19+
@click.argument('target')
20+
@click.argument('version')
21+
@click.option(
22+
'--file',
23+
'-f',
24+
'output_file',
25+
type=click.Path(dir_okay=False, writable=True, path_type=Path),
26+
help='Write the extracted section to this file (overwrites if it exists) instead of stdout',
27+
)
28+
@click.pass_obj
29+
def show(app: Application, target: str, version: str, output_file: Path | None):
30+
"""
31+
Print the section of TARGET's CHANGELOG.md that corresponds to VERSION.
32+
33+
The output is the markdown content between the ``## VERSION`` heading and the
34+
next ``## `` heading, with surrounding blank lines stripped. Useful for
35+
populating GitHub release notes from the just-built changelog.
36+
"""
37+
try:
38+
integration = app.repo.integrations.get(target)
39+
except OSError:
40+
app.abort(f'Unknown target: {target}')
41+
42+
changelog_path = integration.path / 'CHANGELOG.md'
43+
if not changelog_path.is_file():
44+
app.abort(f'Changelog not found: {changelog_path}')
45+
46+
section = _extract_version_section(changelog_path, version)
47+
if section is None:
48+
app.abort(f'No changelog section found for {target} version {version}')
49+
50+
if output_file:
51+
output_file.parent.mkdir(parents=True, exist_ok=True)
52+
output_file.write_text(section, encoding='utf-8')
53+
app.display_success(f'Wrote changelog section for {target} {version} to {output_file}')
54+
else:
55+
app.display_markdown(section)
56+
57+
58+
def _iter_version_section(path: Path, version: str) -> Iterator[str]:
59+
"""Yield the lines of the requested version's section, lazily.
60+
61+
Stops as soon as the next ``## `` heading after the section is encountered,
62+
so we never read past the requested release in a long changelog.
63+
"""
64+
with path.open(mode='r', encoding='utf-8') as f:
65+
in_section = False
66+
for line in f:
67+
if line.startswith('## '):
68+
tokens = line[3:].split()
69+
if in_section:
70+
return
71+
if tokens and tokens[0] == version:
72+
in_section = True
73+
continue
74+
if in_section:
75+
yield line
76+
77+
78+
def _extract_version_section(path: Path, version: str) -> str | None:
79+
section = ''.join(_iter_version_section(path, version)).strip('\n')
80+
return section or None
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# (C) Datadog, Inc. 2026-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
from __future__ import annotations
5+
6+
import pytest
7+
8+
from ddev.cli.release.changelog.show import _extract_version_section
9+
from ddev.utils.fs import Path
10+
from tests.helpers.git import ClonedRepo
11+
from tests.helpers.runner import CliRunner
12+
13+
CHANGELOG_BODY = """\
14+
# CHANGELOG - ddev
15+
16+
<!-- towncrier release notes start -->
17+
18+
## 16.1.1 / 2026-04-29
19+
20+
***Fixed***:
21+
22+
* Bumped datadog_checks_dev to version 38.0.0. Fixes dependency issues. ([#23516](https://github.com/DataDog/integrations-core/pull/23516))
23+
24+
## 16.1.0 / 2026-04-29
25+
26+
***Added***:
27+
28+
* Add async GitHub API client. ([#22734](https://github.com/DataDog/integrations-core/pull/22734))
29+
* Use uv as the installer for hatch test environments. ([#23497](https://github.com/DataDog/integrations-core/pull/23497))
30+
31+
## 1.0.10 / 2024-01-01
32+
33+
***Fixed***:
34+
35+
* Older release that should not match against 1.0.1. ([#1](https://github.com/DataDog/integrations-core/pull/1))
36+
37+
## 1.0.1 / 2024-01-02
38+
39+
***Fixed***:
40+
41+
* Patch release. ([#2](https://github.com/DataDog/integrations-core/pull/2))
42+
"""
43+
44+
45+
@pytest.fixture
46+
def ddev_changelog(repo_with_towncrier: ClonedRepo) -> Path:
47+
changelog = repo_with_towncrier.path / 'ddev' / 'CHANGELOG.md'
48+
changelog.write_text(CHANGELOG_BODY)
49+
return changelog
50+
51+
52+
@pytest.mark.parametrize(
53+
'version, expected_substring, forbidden_substring',
54+
[
55+
pytest.param(
56+
'16.1.1',
57+
'* Bumped datadog_checks_dev to version 38.0.0',
58+
'## 16.1.0',
59+
id='middle_section',
60+
),
61+
pytest.param(
62+
'16.1.0',
63+
'* Add async GitHub API client.',
64+
'## 16.1.1',
65+
id='multi_bullet_section',
66+
),
67+
pytest.param(
68+
'1.0.1',
69+
'* Patch release.',
70+
'* Older release',
71+
id='strict_match_does_not_substring_into_1_0_10',
72+
),
73+
],
74+
)
75+
def test_extract_version_section_returns_expected_block(
76+
ddev_changelog: Path, version: str, expected_substring: str, forbidden_substring: str
77+
):
78+
section = _extract_version_section(ddev_changelog, version)
79+
80+
assert section is not None
81+
assert expected_substring in section
82+
assert forbidden_substring not in section
83+
assert not section.startswith('\n')
84+
assert not section.endswith('\n')
85+
86+
87+
def test_extract_version_section_returns_none_for_missing_version(ddev_changelog: Path):
88+
assert _extract_version_section(ddev_changelog, '99.99.99') is None
89+
90+
91+
def test_show_prints_section_to_stdout(ddev: CliRunner, ddev_changelog: Path, helpers):
92+
result = ddev('release', 'changelog', 'show', 'ddev', '16.1.1')
93+
94+
assert result.exit_code == 0, result.output
95+
output = helpers.remove_trailing_spaces(result.output)
96+
assert 'Fixed:' in output
97+
assert 'Bumped datadog_checks_dev to version 38.0.0' in output
98+
assert '16.1.0' not in output
99+
100+
101+
def test_show_writes_to_file(ddev: CliRunner, ddev_changelog: Path, tmp_path: Path):
102+
output_file = tmp_path / 'release-notes.md'
103+
104+
result = ddev('release', 'changelog', 'show', 'ddev', '16.1.0', '--file', str(output_file))
105+
106+
assert result.exit_code == 0, result.output
107+
assert f'Wrote changelog section for ddev 16.1.0 to {output_file}' in result.output
108+
contents = output_file.read_text()
109+
assert '* Add async GitHub API client.' in contents
110+
assert '## 16.1.1' not in contents
111+
112+
113+
def test_show_creates_missing_parent_directories(ddev: CliRunner, ddev_changelog: Path, tmp_path: Path):
114+
output_file = tmp_path / 'nested' / 'sub' / 'release-notes.md'
115+
116+
result = ddev('release', 'changelog', 'show', 'ddev', '16.1.1', '--file', str(output_file))
117+
118+
assert result.exit_code == 0, result.output
119+
assert output_file.is_file()
120+
assert '* Bumped datadog_checks_dev to version 38.0.0' in output_file.read_text()
121+
122+
123+
def test_show_strict_version_matching_against_substring(ddev: CliRunner, ddev_changelog: Path, helpers):
124+
result = ddev('release', 'changelog', 'show', 'ddev', '1.0.1')
125+
126+
assert result.exit_code == 0, result.output
127+
output = helpers.remove_trailing_spaces(result.output)
128+
assert 'Patch release.' in output
129+
assert 'Older release' not in output
130+
131+
132+
def test_show_aborts_when_version_missing(ddev: CliRunner, ddev_changelog: Path):
133+
result = ddev('release', 'changelog', 'show', 'ddev', '99.99.99')
134+
135+
assert result.exit_code != 0
136+
assert 'No changelog section found for ddev version 99.99.99' in result.output
137+
138+
139+
def test_show_aborts_when_changelog_missing(ddev: CliRunner, repo_with_towncrier: ClonedRepo):
140+
changelog = repo_with_towncrier.path / 'ddev' / 'CHANGELOG.md'
141+
if changelog.is_file():
142+
changelog.unlink()
143+
144+
result = ddev('release', 'changelog', 'show', 'ddev', '1.0.0')
145+
146+
assert result.exit_code != 0
147+
assert 'Changelog not found' in result.output
148+
149+
150+
def test_show_aborts_for_unknown_target(ddev: CliRunner, repo_with_towncrier: ClonedRepo):
151+
result = ddev('release', 'changelog', 'show', 'definitely_not_an_integration', '1.0.0')
152+
153+
assert result.exit_code != 0
154+
assert 'Unknown target: definitely_not_an_integration' in result.output
155+
156+
157+
@pytest.mark.parametrize(
158+
'args, expected_message',
159+
[
160+
pytest.param([], "Missing argument 'TARGET'", id='no_args'),
161+
pytest.param(['ddev'], "Missing argument 'VERSION'", id='target_only'),
162+
],
163+
)
164+
def test_show_argument_errors(ddev: CliRunner, repo_with_towncrier: ClonedRepo, args: list[str], expected_message: str):
165+
result = ddev('release', 'changelog', 'show', *args)
166+
167+
assert result.exit_code != 0
168+
assert expected_message in result.output

0 commit comments

Comments
 (0)