Source code for mafw.devtools.release.notes

#  Copyright 2026 European Union
#  Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
#  SPDX-License-Identifier: EUPL-1.2
"""
Release note generation utilities for MAFw releases.

This module contains the business logic for creating release notes from a
markdown template, collecting git statistics, and resolving contributors.
"""

from __future__ import annotations

import re
from pathlib import Path

from mafw.devtools import DevtoolsError
from mafw.devtools.git import get_last_stable_tag
from mafw.devtools.release.changelog import RELEASE_SECTION_HEADERS, extract_change_sections_from_changelog
from mafw.tools.shell_tools import CONSOLE, run_stdout

RELEASE_TEMPLATE_FILE = Path('.gitlab/release_templates/Default.md')
"""Path to the markdown template used to build release notes."""


[docs] def get_release_note_base_ref() -> str: """ Determine the git reference used as baseline for release-note metadata. :return: Last stable tag if available, otherwise the first commit hash. :rtype: str """ stable_tag = get_last_stable_tag() if stable_tag is not None: return stable_tag first_commit = run_stdout('git rev-list --max-parents=0 HEAD').splitlines() if not first_commit: raise DevtoolsError('Unable to determine release-note baseline reference.') return first_commit[0]
[docs] def get_release_statistics(since_ref: str) -> tuple[str, str]: """ Collect release statistics since the baseline reference. :param since_ref: Baseline reference to compare against ``HEAD``. :type since_ref: str :return: Commit count and short diff statistics. :rtype: tuple[str, str] """ commit_count = run_stdout(f'git rev-list --count {since_ref}..HEAD') files_changed = run_stdout(f'git diff --shortstat {since_ref}..HEAD') return commit_count, files_changed
[docs] def get_contributors(since_ref: str) -> list[str]: """ Collect contributor names since the baseline reference. :param since_ref: Baseline reference to compare against ``HEAD``. :type since_ref: str :return: Ordered list of contributor names. :rtype: list[str] """ output = run_stdout(f'git shortlog -sn {since_ref}..HEAD') contributors: list[str] = [] for line in output.splitlines(): match = re.match(r'^\s*\d+\s+(.+)$', line) if match is not None: contributors.append(match.group(1).strip()) return contributors
[docs] def render_release_note_section(content: str, header: str, section_markdown: str) -> str: """ Replace a release-note section in the markdown template. If the provided section markdown is empty, the entire section (header and placeholder) is removed from the content. :param content: Current release note markdown text. :type content: str :param header: Section header to replace. :type header: str :param section_markdown: Markdown content to insert under the section header. :type section_markdown: str :return: Updated markdown text. :rtype: str :raises DevtoolsError: If the section header cannot be matched in template. """ section_content = section_markdown.strip() if not section_content: # Remove header and placeholder comment, including trailing whitespace # to avoid leaving multiple empty lines between sections. pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->\s*' updated, replacements = re.subn(pattern, '', content, flags=re.DOTALL) else: pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->' updated, replacements = re.subn(pattern, f'{header}\n{section_content}', content, flags=re.DOTALL) if replacements != 1: raise DevtoolsError(f'Unable to update section "{header}" in {RELEASE_TEMPLATE_FILE}.') return updated
[docs] def create_release_note(version: str, dry_run: bool) -> Path: """ Create the release note markdown file for the given version. Change sections are copied from the generated changelog for the same version to ensure release notes stay aligned with changelog content. :param version: Target release version. :type version: str :param dry_run: Whether command execution is disabled. :type dry_run: bool :return: Path to the release note markdown file. :rtype: Path :raises DevtoolsError: If the template is missing. """ output_path = Path(f'release_note_v{version}.md') if dry_run: CONSOLE.print(f'Release note would be generated at: {output_path}') return output_path if not RELEASE_TEMPLATE_FILE.exists(): raise DevtoolsError(f'Release note template not found: {RELEASE_TEMPLATE_FILE}') change_sections = extract_change_sections_from_changelog(version) since_ref = get_release_note_base_ref() commit_count, files_changed = get_release_statistics(since_ref) contributors = get_contributors(since_ref) content = RELEASE_TEMPLATE_FILE.read_text(encoding='utf-8') content = content.replace('${TAG}', f'v{version}') content = render_release_note_section( content, RELEASE_SECTION_HEADERS['new_features'], change_sections['new_features'] ) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['bug_fixes'], change_sections['bug_fixes']) content = render_release_note_section( content, RELEASE_SECTION_HEADERS['refactorings'], change_sections['refactorings'] ) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['removed'], change_sections['removed']) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['deprecated'], change_sections['deprecated']) content = render_release_note_section(content, RELEASE_SECTION_HEADERS['security'], change_sections['security']) content = render_release_note_section( content, RELEASE_SECTION_HEADERS['other_changes'], change_sections['other_changes'] ) content = render_release_note_section( content, '## 👥 Contributors', '\n'.join(f'* {name}' for name in contributors) ) content = render_release_note_section( content, '## 📊 Statistics', f'* Commits: {commit_count}\n* Files changed: {files_changed}', ) output_path.write_text(content, encoding='utf-8') CONSOLE.print(f'Release note generated: {output_path}') return output_path