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

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. 

6 

7This module contains the business logic for creating release notes from a 

8markdown template, collecting git statistics, and resolving contributors. 

9""" 

10 

11from __future__ import annotations 

12 

13import re 

14from pathlib import Path 

15 

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 

20 

21RELEASE_TEMPLATE_FILE = Path('.gitlab/release_templates/Default.md') 

22"""Path to the markdown template used to build release notes.""" 

23 

24 

25def get_release_note_base_ref() -> str: 

26 """ 

27 Determine the git reference used as baseline for release-note metadata. 

28 

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] 

39 

40 

41def get_release_statistics(since_ref: str) -> tuple[str, str]: 

42 """ 

43 Collect release statistics since the baseline reference. 

44 

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 

53 

54 

55def get_contributors(since_ref: str) -> list[str]: 

56 """ 

57 Collect contributor names since the baseline reference. 

58 

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 

71 

72 

73def render_release_note_section(content: str, header: str, section_markdown: str) -> str: 

74 """ 

75 Replace a release-note section in the markdown template. 

76 

77 If the provided section markdown is empty, the entire section (header and 

78 placeholder) is removed from the content. 

79 

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) 

99 

100 if replacements != 1: 

101 raise DevtoolsError(f'Unable to update section "{header}" in {RELEASE_TEMPLATE_FILE}.') 

102 return updated 

103 

104 

105def create_release_note(version: str, dry_run: bool) -> Path: 

106 """ 

107 Create the release note markdown file for the given version. 

108 

109 Change sections are copied from the generated changelog for the same 

110 version to ensure release notes stay aligned with changelog content. 

111 

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 

124 

125 if not RELEASE_TEMPLATE_FILE.exists(): 

126 raise DevtoolsError(f'Release note template not found: {RELEASE_TEMPLATE_FILE}') 

127 

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) 

132 

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 ) 

156 

157 output_path.write_text(content, encoding='utf-8') 

158 CONSOLE.print(f'Release note generated: {output_path}') 

159 return output_path