Coverage for src / mafw / devtools / release / notes.py: 100%
64 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-06-28 13:34 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""
5Release note generation utilities for MAFw releases.
7This module contains the business logic for creating release notes from a
8markdown template, collecting git statistics, and resolving contributors.
9"""
11from __future__ import annotations
13import re
14from pathlib import Path
16from mafw.devtools import DevtoolsError
17from mafw.devtools.git import get_last_stable_tag
18from mafw.devtools.release.changelog import RELEASE_SECTION_HEADERS, extract_change_sections_from_changelog
19from mafw.tools.shell_tools import CONSOLE, run_stdout
21RELEASE_TEMPLATE_FILE = Path('.gitlab/release_templates/Default.md')
22"""Path to the markdown template used to build release notes."""
25def get_release_note_base_ref() -> str:
26 """
27 Determine the git reference used as baseline for release-note metadata.
29 :return: Last stable tag if available, otherwise the first commit hash.
30 :rtype: str
31 """
32 stable_tag = get_last_stable_tag()
33 if stable_tag is not None:
34 return stable_tag
35 first_commit = run_stdout('git rev-list --max-parents=0 HEAD').splitlines()
36 if not first_commit:
37 raise DevtoolsError('Unable to determine release-note baseline reference.')
38 return first_commit[0]
41def get_release_statistics(since_ref: str) -> tuple[str, str]:
42 """
43 Collect release statistics since the baseline reference.
45 :param since_ref: Baseline reference to compare against ``HEAD``.
46 :type since_ref: str
47 :return: Commit count and short diff statistics.
48 :rtype: tuple[str, str]
49 """
50 commit_count = run_stdout(f'git rev-list --count {since_ref}..HEAD')
51 files_changed = run_stdout(f'git diff --shortstat {since_ref}..HEAD')
52 return commit_count, files_changed
55def get_contributors(since_ref: str) -> list[str]:
56 """
57 Collect contributor names since the baseline reference.
59 :param since_ref: Baseline reference to compare against ``HEAD``.
60 :type since_ref: str
61 :return: Ordered list of contributor names.
62 :rtype: list[str]
63 """
64 output = run_stdout(f'git shortlog -sn {since_ref}..HEAD')
65 contributors: list[str] = []
66 for line in output.splitlines():
67 match = re.match(r'^\s*\d+\s+(.+)$', line)
68 if match is not None:
69 contributors.append(match.group(1).strip())
70 return contributors
73def render_release_note_section(content: str, header: str, section_markdown: str) -> str:
74 """
75 Replace a release-note section in the markdown template.
77 If the provided section markdown is empty, the entire section (header and
78 placeholder) is removed from the content.
80 :param content: Current release note markdown text.
81 :type content: str
82 :param header: Section header to replace.
83 :type header: str
84 :param section_markdown: Markdown content to insert under the section header.
85 :type section_markdown: str
86 :return: Updated markdown text.
87 :rtype: str
88 :raises DevtoolsError: If the section header cannot be matched in template.
89 """
90 section_content = section_markdown.strip()
91 if not section_content:
92 # Remove header and placeholder comment, including trailing whitespace
93 # to avoid leaving multiple empty lines between sections.
94 pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->\s*'
95 updated, replacements = re.subn(pattern, '', content, flags=re.DOTALL)
96 else:
97 pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->'
98 updated, replacements = re.subn(pattern, f'{header}\n{section_content}', content, flags=re.DOTALL)
100 if replacements != 1:
101 raise DevtoolsError(f'Unable to update section "{header}" in {RELEASE_TEMPLATE_FILE}.')
102 return updated
105def create_release_note(version: str, dry_run: bool) -> Path:
106 """
107 Create the release note markdown file for the given version.
109 Change sections are copied from the generated changelog for the same
110 version to ensure release notes stay aligned with changelog content.
112 :param version: Target release version.
113 :type version: str
114 :param dry_run: Whether command execution is disabled.
115 :type dry_run: bool
116 :return: Path to the release note markdown file.
117 :rtype: Path
118 :raises DevtoolsError: If the template is missing.
119 """
120 output_path = Path(f'release_note_v{version}.md')
121 if dry_run:
122 CONSOLE.print(f'Release note would be generated at: {output_path}')
123 return output_path
125 if not RELEASE_TEMPLATE_FILE.exists():
126 raise DevtoolsError(f'Release note template not found: {RELEASE_TEMPLATE_FILE}')
128 change_sections = extract_change_sections_from_changelog(version)
129 since_ref = get_release_note_base_ref()
130 commit_count, files_changed = get_release_statistics(since_ref)
131 contributors = get_contributors(since_ref)
133 content = RELEASE_TEMPLATE_FILE.read_text(encoding='utf-8')
134 content = content.replace('${TAG}', f'v{version}')
135 content = render_release_note_section(
136 content, RELEASE_SECTION_HEADERS['new_features'], change_sections['new_features']
137 )
138 content = render_release_note_section(content, RELEASE_SECTION_HEADERS['bug_fixes'], change_sections['bug_fixes'])
139 content = render_release_note_section(
140 content, RELEASE_SECTION_HEADERS['refactorings'], change_sections['refactorings']
141 )
142 content = render_release_note_section(content, RELEASE_SECTION_HEADERS['removed'], change_sections['removed'])
143 content = render_release_note_section(content, RELEASE_SECTION_HEADERS['deprecated'], change_sections['deprecated'])
144 content = render_release_note_section(content, RELEASE_SECTION_HEADERS['security'], change_sections['security'])
145 content = render_release_note_section(
146 content, RELEASE_SECTION_HEADERS['other_changes'], change_sections['other_changes']
147 )
148 content = render_release_note_section(
149 content, '## 👥 Contributors', '\n'.join(f'* {name}' for name in contributors)
150 )
151 content = render_release_note_section(
152 content,
153 '## 📊 Statistics',
154 f'* Commits: {commit_count}\n* Files changed: {files_changed}',
155 )
157 output_path.write_text(content, encoding='utf-8')
158 CONSOLE.print(f'Release note generated: {output_path}')
159 return output_path