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
39
40CURRENT_GL_VERSION = '4.6'
41CURRENT_VK_VERSION = '1.2'
42
43TEMPLATE = Template(textwrap.dedent("""\
44    ${header}
45    ${header_underline}
46
47    %if not bugfix:
48    Mesa ${this_version} is a new development release. People who are concerned
49    with stability and reliability should stick with a previous release or
50    wait for Mesa ${this_version[:-1]}1.
51    %else:
52    Mesa ${this_version} is a bug fix release which fixes bugs found since the ${previous_version} release.
53    %endif
54
55    Mesa ${this_version} implements the OpenGL ${gl_version} API, but the version reported by
56    glGetString(GL_VERSION) or glGetIntegerv(GL_MAJOR_VERSION) /
57    glGetIntegerv(GL_MINOR_VERSION) depends on the particular driver being used.
58    Some drivers don't support all the features required in OpenGL ${gl_version}. OpenGL
59    ${gl_version} is **only** available if requested at context creation.
60    Compatibility contexts may report a lower version depending on each driver.
61
62    Mesa ${this_version} implements the Vulkan ${vk_version} API, but the version reported by
63    the apiVersion property of the VkPhysicalDeviceProperties struct
64    depends on the particular driver being used.
65
66    SHA256 checksum
67    ---------------
68
69    ::
70
71        TBD.
72
73
74    New features
75    ------------
76
77    %for f in features:
78    - ${rst_escape(f)}
79    %endfor
80
81
82    Bug fixes
83    ---------
84
85    %for b in bugs:
86    - ${rst_escape(b)}
87    %endfor
88
89
90    Changes
91    -------
92    %for c, author_line in changes:
93      %if author_line:
94
95    ${rst_escape(c)}
96
97      %else:
98    - ${rst_escape(c)}
99      %endif
100    %endfor
101    """))
102
103
104def rst_escape(unsafe_str: str) -> str:
105    "Escape rST special chars when they follow or preceed a whitespace"
106    special = re.escape(r'`<>*_#[]|')
107    unsafe_str = re.sub(r'(^|\s)([' + special + r'])',
108                        r'\1\\\2',
109                        unsafe_str)
110    unsafe_str = re.sub(r'([' + special + r'])(\s|$)',
111                        r'\\\1\2',
112                        unsafe_str)
113    return unsafe_str
114
115
116async def gather_commits(version: str) -> str:
117    p = await asyncio.create_subprocess_exec(
118        'git', 'log', '--oneline', f'mesa-{version}..', '--grep', r'Closes: \(https\|#\).*',
119        stdout=asyncio.subprocess.PIPE)
120    out, _ = await p.communicate()
121    assert p.returncode == 0, f"git log didn't work: {version}"
122    return out.decode().strip()
123
124
125async def gather_bugs(version: str) -> typing.List[str]:
126    commits = await gather_commits(version)
127
128    issues: typing.List[str] = []
129    for commit in commits.split('\n'):
130        sha, message = commit.split(maxsplit=1)
131        p = await asyncio.create_subprocess_exec(
132            'git', 'log', '--max-count', '1', r'--format=%b', sha,
133            stdout=asyncio.subprocess.PIPE)
134        _out, _ = await p.communicate()
135        out = _out.decode().split('\n')
136        for line in reversed(out):
137            if line.startswith('Closes:'):
138                bug = line.lstrip('Closes:').strip()
139                break
140        else:
141            raise Exception('No closes found?')
142        if bug.startswith('h'):
143            # This means we have a bug in the form "Closes: https://..."
144            issues.append(os.path.basename(urllib.parse.urlparse(bug).path))
145        else:
146            issues.append(bug.lstrip('#'))
147
148    loop = asyncio.get_event_loop()
149    async with aiohttp.ClientSession(loop=loop) as session:
150        results = await asyncio.gather(*[get_bug(session, i) for i in issues])
151    typing.cast(typing.Tuple[str, ...], results)
152    bugs = list(results)
153    if not bugs:
154        bugs = ['None']
155    return bugs
156
157
158async def get_bug(session: aiohttp.ClientSession, bug_id: str) -> str:
159    """Query gitlab to get the name of the issue that was closed."""
160    # Mesa's gitlab id is 176,
161    url = 'https://gitlab.freedesktop.org/api/v4/projects/176/issues'
162    params = {'iids[]': bug_id}
163    async with session.get(url, params=params) as response:
164        content = await response.json()
165    return content[0]['title']
166
167
168async def get_shortlog(version: str) -> str:
169    """Call git shortlog."""
170    p = await asyncio.create_subprocess_exec('git', 'shortlog', f'mesa-{version}..',
171                                             stdout=asyncio.subprocess.PIPE)
172    out, _ = await p.communicate()
173    assert p.returncode == 0, 'error getting shortlog'
174    assert out is not None, 'just for mypy'
175    return out.decode()
176
177
178def walk_shortlog(log: str) -> typing.Generator[typing.Tuple[str, bool], None, None]:
179    for l in log.split('\n'):
180        if l.startswith(' '): # this means we have a patch description
181            yield l.lstrip(), False
182        elif l.strip():
183            yield l, True
184
185
186def calculate_next_version(version: str, is_point: bool) -> str:
187    """Calculate the version about to be released."""
188    if '-' in version:
189        version = version.split('-')[0]
190    if is_point:
191        base = version.split('.')
192        base[2] = str(int(base[2]) + 1)
193        return '.'.join(base)
194    return version
195
196
197def calculate_previous_version(version: str, is_point: bool) -> str:
198    """Calculate the previous version to compare to.
199
200    In the case of -rc to final that verison is the previous .0 release,
201    (19.3.0 in the case of 20.0.0, for example). for point releases that is
202    the last point release. This value will be the same as the input value
203    for a point release, but different for a major release.
204    """
205    if '-' in version:
206        version = version.split('-')[0]
207    if is_point:
208        return version
209    base = version.split('.')
210    if base[1] == '0':
211        base[0] = str(int(base[0]) - 1)
212        base[1] = '3'
213    else:
214        base[1] = str(int(base[1]) - 1)
215    return '.'.join(base)
216
217
218def get_features(is_point_release: bool) -> typing.Generator[str, None, None]:
219    p = pathlib.Path(__file__).parent.parent / 'docs' / 'relnotes' / 'new_features.txt'
220    if p.exists():
221        if is_point_release:
222            print("WARNING: new features being introduced in a point release", file=sys.stderr)
223        with p.open('rt') as f:
224            for line in f:
225                yield line
226            else:
227                yield "None"
228        p.unlink()
229    else:
230        yield "None"
231
232
233async def main() -> None:
234    v = pathlib.Path(__file__).parent.parent / 'VERSION'
235    with v.open('rt') as f:
236        raw_version = f.read().strip()
237    is_point_release = '-rc' not in raw_version
238    assert '-devel' not in raw_version, 'Do not run this script on -devel'
239    version = raw_version.split('-')[0]
240    previous_version = calculate_previous_version(version, is_point_release)
241    this_version = calculate_next_version(version, is_point_release)
242    today = datetime.date.today()
243    header = f'Mesa {this_version} Release Notes / {today}'
244    header_underline = '=' * len(header)
245
246    shortlog, bugs = await asyncio.gather(
247        get_shortlog(previous_version),
248        gather_bugs(previous_version),
249    )
250
251    final = pathlib.Path(__file__).parent.parent / 'docs' / 'relnotes' / f'{this_version}.rst'
252    with final.open('wt') as f:
253        try:
254            f.write(TEMPLATE.render(
255                bugfix=is_point_release,
256                bugs=bugs,
257                changes=walk_shortlog(shortlog),
258                features=get_features(is_point_release),
259                gl_version=CURRENT_GL_VERSION,
260                this_version=this_version,
261                header=header,
262                header_underline=header_underline,
263                previous_version=previous_version,
264                vk_version=CURRENT_VK_VERSION,
265                rst_escape=rst_escape,
266            ))
267        except:
268            print(exceptions.text_error_template().render())
269
270    subprocess.run(['git', 'add', final])
271    subprocess.run(['git', 'commit', '-m',
272                    f'docs: add release notes for {this_version}'])
273
274
275if __name__ == "__main__":
276    loop = asyncio.get_event_loop()
277    loop.run_until_complete(main())
278