1#!/usr/bin/env python3 2# Copyright © 2019-2020 Intel Corporation 3 4# Permission is hereby granted, free of charge, to any person obtaining a copy 5# of this software and associated documentation files (the "Software"), to deal 6# in the Software without restriction, including without limitation the rights 7# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8# copies of the Software, and to permit persons to whom the Software is 9# furnished to do so, subject to the following conditions: 10 11# The above copyright notice and this permission notice shall be included in 12# all copies or substantial portions of the Software. 13 14# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20# SOFTWARE. 21 22"""Generates release notes for a given version of mesa.""" 23 24import asyncio 25import datetime 26import os 27import pathlib 28import re 29import subprocess 30import sys 31import textwrap 32import typing 33import urllib.parse 34 35import aiohttp 36from mako.template import Template 37from mako import exceptions 38 39import docutils.utils 40import docutils.parsers.rst.states as states 41 42CURRENT_GL_VERSION = '4.6' 43CURRENT_VK_VERSION = '1.2' 44 45TEMPLATE = Template(textwrap.dedent("""\ 46 ${header} 47 ${header_underline} 48 49 %if not bugfix: 50 Mesa ${this_version} is a new development release. People who are concerned 51 with stability and reliability should stick with a previous release or 52 wait for Mesa ${this_version[:-1]}1. 53 %else: 54 Mesa ${this_version} is a bug fix release which fixes bugs found since the ${previous_version} release. 55 %endif 56 57 Mesa ${this_version} implements the OpenGL ${gl_version} API, but the version reported by 58 glGetString(GL_VERSION) or glGetIntegerv(GL_MAJOR_VERSION) / 59 glGetIntegerv(GL_MINOR_VERSION) depends on the particular driver being used. 60 Some drivers don't support all the features required in OpenGL ${gl_version}. OpenGL 61 ${gl_version} is **only** available if requested at context creation. 62 Compatibility contexts may report a lower version depending on each driver. 63 64 Mesa ${this_version} implements the Vulkan ${vk_version} API, but the version reported by 65 the apiVersion property of the VkPhysicalDeviceProperties struct 66 depends on the particular driver being used. 67 68 SHA256 checksum 69 --------------- 70 71 :: 72 73 TBD. 74 75 76 New features 77 ------------ 78 79 %for f in features: 80 - ${rst_escape(f)} 81 %endfor 82 83 84 Bug fixes 85 --------- 86 87 %for b in bugs: 88 - ${rst_escape(b)} 89 %endfor 90 91 92 Changes 93 ------- 94 %for c, author_line in changes: 95 %if author_line: 96 97 ${rst_escape(c)} 98 99 %else: 100 - ${rst_escape(c)} 101 %endif 102 %endfor 103 """)) 104 105 106# copied from https://docutils.sourceforge.io/sandbox/xml2rst/xml2rstlib/markup.py 107class Inliner(states.Inliner): 108 """ 109 Recognizer for inline markup. Derive this from the original inline 110 markup parser for best results. 111 """ 112 113 # Copy static attributes from super class 114 vars().update(vars(states.Inliner)) 115 116 def quoteInline(self, text): 117 """ 118 `text`: ``str`` 119 Return `text` with inline markup quoted. 120 """ 121 # Method inspired by `states.Inliner.parse` 122 self.document = docutils.utils.new_document("<string>") 123 self.document.settings.trim_footnote_reference_space = False 124 self.document.settings.character_level_inline_markup = False 125 self.document.settings.pep_references = False 126 self.document.settings.rfc_references = False 127 128 self.init_customizations(self.document.settings) 129 130 self.reporter = self.document.reporter 131 self.reporter.stream = None 132 self.language = None 133 self.parent = self.document 134 remaining = docutils.utils.escape2null(text) 135 checked = "" 136 processed = [] 137 unprocessed = [] 138 messages = [] 139 while remaining: 140 original = remaining 141 match = self.patterns.initial.search(remaining) 142 if match: 143 groups = match.groupdict() 144 method = self.dispatch[groups['start'] or groups['backquote'] 145 or groups['refend'] or groups['fnend']] 146 before, inlines, remaining, sysmessages = method(self, match, 0) 147 checked += before 148 if inlines: 149 assert len(inlines) == 1, "More than one inline found" 150 inline = original[len(before) 151 :len(original) - len(remaining)] 152 rolePfx = re.search("^:" + self.simplename + ":(?=`)", 153 inline) 154 refSfx = re.search("_+$", inline) 155 if rolePfx: 156 # Prefixed roles need to be quoted in the middle 157 checked += (inline[:rolePfx.end()] + "\\" 158 + inline[rolePfx.end():]) 159 elif refSfx and not re.search("^`", inline): 160 # Pure reference markup needs to be quoted at the end 161 checked += (inline[:refSfx.start()] + "\\" 162 + inline[refSfx.start():]) 163 else: 164 # Quote other inlines by prefixing 165 checked += "\\" + inline 166 else: 167 checked += remaining 168 break 169 # Quote all original backslashes 170 checked = re.sub('\x00', "\\\x00", checked) 171 return docutils.utils.unescape(checked, 1) 172 173inliner = Inliner(); 174 175 176async def gather_commits(version: str) -> str: 177 p = await asyncio.create_subprocess_exec( 178 'git', 'log', '--oneline', f'mesa-{version}..', '--grep', r'Closes: \(https\|#\).*', 179 stdout=asyncio.subprocess.PIPE) 180 out, _ = await p.communicate() 181 assert p.returncode == 0, f"git log didn't work: {version}" 182 return out.decode().strip() 183 184 185async def parse_issues(commits: str) -> typing.List[str]: 186 issues: typing.List[str] = [] 187 for commit in commits.split('\n'): 188 sha, message = commit.split(maxsplit=1) 189 p = await asyncio.create_subprocess_exec( 190 'git', 'log', '--max-count', '1', r'--format=%b', sha, 191 stdout=asyncio.subprocess.PIPE) 192 _out, _ = await p.communicate() 193 out = _out.decode().split('\n') 194 195 for line in reversed(out): 196 if line.startswith('Closes:'): 197 bug = line.lstrip('Closes:').strip() 198 if bug.startswith('https://gitlab.freedesktop.org/mesa/mesa'): 199 # This means we have a bug in the form "Closes: https://..." 200 issues.append(os.path.basename(urllib.parse.urlparse(bug).path)) 201 elif ',' in bug: 202 issues.extend([b.strip().lstrip('#') for b in bug.split(',')]) 203 elif bug.startswith('#'): 204 issues.append(bug.lstrip('#')) 205 206 return issues 207 208 209async def gather_bugs(version: str) -> typing.List[str]: 210 commits = await gather_commits(version) 211 issues = await parse_issues(commits) 212 213 loop = asyncio.get_event_loop() 214 async with aiohttp.ClientSession(loop=loop) as session: 215 results = await asyncio.gather(*[get_bug(session, i) for i in issues]) 216 typing.cast(typing.Tuple[str, ...], results) 217 bugs = list(results) 218 if not bugs: 219 bugs = ['None'] 220 return bugs 221 222 223async def get_bug(session: aiohttp.ClientSession, bug_id: str) -> str: 224 """Query gitlab to get the name of the issue that was closed.""" 225 # Mesa's gitlab id is 176, 226 url = 'https://gitlab.freedesktop.org/api/v4/projects/176/issues' 227 params = {'iids[]': bug_id} 228 async with session.get(url, params=params) as response: 229 content = await response.json() 230 return content[0]['title'] 231 232 233async def get_shortlog(version: str) -> str: 234 """Call git shortlog.""" 235 p = await asyncio.create_subprocess_exec('git', 'shortlog', f'mesa-{version}..', 236 stdout=asyncio.subprocess.PIPE) 237 out, _ = await p.communicate() 238 assert p.returncode == 0, 'error getting shortlog' 239 assert out is not None, 'just for mypy' 240 return out.decode() 241 242 243def walk_shortlog(log: str) -> typing.Generator[typing.Tuple[str, bool], None, None]: 244 for l in log.split('\n'): 245 if l.startswith(' '): # this means we have a patch description 246 yield l.lstrip(), False 247 elif l.strip(): 248 yield l, True 249 250 251def calculate_next_version(version: str, is_point: bool) -> str: 252 """Calculate the version about to be released.""" 253 if '-' in version: 254 version = version.split('-')[0] 255 if is_point: 256 base = version.split('.') 257 base[2] = str(int(base[2]) + 1) 258 return '.'.join(base) 259 return version 260 261 262def calculate_previous_version(version: str, is_point: bool) -> str: 263 """Calculate the previous version to compare to. 264 265 In the case of -rc to final that verison is the previous .0 release, 266 (19.3.0 in the case of 20.0.0, for example). for point releases that is 267 the last point release. This value will be the same as the input value 268 for a point release, but different for a major release. 269 """ 270 if '-' in version: 271 version = version.split('-')[0] 272 if is_point: 273 return version 274 base = version.split('.') 275 if base[1] == '0': 276 base[0] = str(int(base[0]) - 1) 277 base[1] = '3' 278 else: 279 base[1] = str(int(base[1]) - 1) 280 return '.'.join(base) 281 282 283def get_features(is_point_release: bool) -> typing.Generator[str, None, None]: 284 p = pathlib.Path(__file__).parent.parent / 'docs' / 'relnotes' / 'new_features.txt' 285 if p.exists(): 286 if is_point_release: 287 print("WARNING: new features being introduced in a point release", file=sys.stderr) 288 with p.open('rt') as f: 289 for line in f: 290 yield line 291 else: 292 yield "None" 293 p.unlink() 294 else: 295 yield "None" 296 297 298async def main() -> None: 299 v = pathlib.Path(__file__).parent.parent / 'VERSION' 300 with v.open('rt') as f: 301 raw_version = f.read().strip() 302 is_point_release = '-rc' not in raw_version 303 assert '-devel' not in raw_version, 'Do not run this script on -devel' 304 version = raw_version.split('-')[0] 305 previous_version = calculate_previous_version(version, is_point_release) 306 this_version = calculate_next_version(version, is_point_release) 307 today = datetime.date.today() 308 header = f'Mesa {this_version} Release Notes / {today}' 309 header_underline = '=' * len(header) 310 311 shortlog, bugs = await asyncio.gather( 312 get_shortlog(previous_version), 313 gather_bugs(previous_version), 314 ) 315 316 final = pathlib.Path(__file__).parent.parent / 'docs' / 'relnotes' / f'{this_version}.rst' 317 with final.open('wt') as f: 318 try: 319 f.write(TEMPLATE.render( 320 bugfix=is_point_release, 321 bugs=bugs, 322 changes=walk_shortlog(shortlog), 323 features=get_features(is_point_release), 324 gl_version=CURRENT_GL_VERSION, 325 this_version=this_version, 326 header=header, 327 header_underline=header_underline, 328 previous_version=previous_version, 329 vk_version=CURRENT_VK_VERSION, 330 rst_escape=inliner.quoteInline, 331 )) 332 except: 333 print(exceptions.text_error_template().render()) 334 335 subprocess.run(['git', 'add', final]) 336 subprocess.run(['git', 'commit', '-m', 337 f'docs: add release notes for {this_version}']) 338 339 340if __name__ == "__main__": 341 loop = asyncio.get_event_loop() 342 loop.run_until_complete(main()) 343