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 |
|
---|
24 | import asyncio
|
---|
25 | import datetime
|
---|
26 | import os
|
---|
27 | import pathlib
|
---|
28 | import re
|
---|
29 | import subprocess
|
---|
30 | import sys
|
---|
31 | import textwrap
|
---|
32 | import typing
|
---|
33 | import urllib.parse
|
---|
34 |
|
---|
35 | import aiohttp
|
---|
36 | from mako.template import Template
|
---|
37 | from mako import exceptions
|
---|
38 |
|
---|
39 | import docutils.utils
|
---|
40 | import docutils.parsers.rst.states as states
|
---|
41 |
|
---|
42 | CURRENT_GL_VERSION = '4.6'
|
---|
43 | CURRENT_VK_VERSION = '1.3'
|
---|
44 |
|
---|
45 | TEMPLATE = 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
|
---|
107 | class 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 | checked = re.sub('@', '\\@', checked)
|
---|
172 | return docutils.utils.unescape(checked, 1)
|
---|
173 |
|
---|
174 | inliner = Inliner();
|
---|
175 |
|
---|
176 |
|
---|
177 | async def gather_commits(version: str) -> str:
|
---|
178 | p = await asyncio.create_subprocess_exec(
|
---|
179 | 'git', 'log', '--oneline', f'mesa-{version}..', '-i', '--grep', r'\(Closes\|Fixes\): \(https\|#\).*',
|
---|
180 | stdout=asyncio.subprocess.PIPE)
|
---|
181 | out, _ = await p.communicate()
|
---|
182 | assert p.returncode == 0, f"git log didn't work: {version}"
|
---|
183 | return out.decode().strip()
|
---|
184 |
|
---|
185 |
|
---|
186 | async def parse_issues(commits: str) -> typing.List[str]:
|
---|
187 | issues: typing.List[str] = []
|
---|
188 | for commit in commits.split('\n'):
|
---|
189 | sha, message = commit.split(maxsplit=1)
|
---|
190 | p = await asyncio.create_subprocess_exec(
|
---|
191 | 'git', 'log', '--max-count', '1', r'--format=%b', sha,
|
---|
192 | stdout=asyncio.subprocess.PIPE)
|
---|
193 | _out, _ = await p.communicate()
|
---|
194 | out = _out.decode().split('\n')
|
---|
195 |
|
---|
196 | for line in reversed(out):
|
---|
197 | if not line.lower().startswith(('closes:', 'fixes:')):
|
---|
198 | continue
|
---|
199 | bug = line.split(':', 1)[1].strip()
|
---|
200 | if (bug.startswith('https://gitlab.freedesktop.org/mesa/mesa')
|
---|
201 | # Avoid parsing "merge_requests" URL. Note that a valid issue
|
---|
202 | # URL may or may not contain the "/-/" text, so we check if
|
---|
203 | # the word "issues" is contained in URL.
|
---|
204 | and '/issues' in bug):
|
---|
205 | # This means we have a bug in the form "Closes: https://..."
|
---|
206 | issues.append(os.path.basename(urllib.parse.urlparse(bug).path))
|
---|
207 | elif ',' in bug:
|
---|
208 | multiple_bugs = [b.strip().lstrip('#') for b in bug.split(',')]
|
---|
209 | if not all(b.isdigit() for b in multiple_bugs):
|
---|
210 | # this is likely a "Fixes" tag that refers to a commit name
|
---|
211 | continue
|
---|
212 | issues.extend(multiple_bugs)
|
---|
213 | elif bug.startswith('#'):
|
---|
214 | issues.append(bug.lstrip('#'))
|
---|
215 |
|
---|
216 | return issues
|
---|
217 |
|
---|
218 |
|
---|
219 | async def gather_bugs(version: str) -> typing.List[str]:
|
---|
220 | commits = await gather_commits(version)
|
---|
221 | if commits:
|
---|
222 | issues = await parse_issues(commits)
|
---|
223 | else:
|
---|
224 | issues = []
|
---|
225 |
|
---|
226 | loop = asyncio.get_event_loop()
|
---|
227 | async with aiohttp.ClientSession(loop=loop) as session:
|
---|
228 | results = await asyncio.gather(*[get_bug(session, i) for i in issues])
|
---|
229 | typing.cast(typing.Tuple[str, ...], results)
|
---|
230 | bugs = list(results)
|
---|
231 | if not bugs:
|
---|
232 | bugs = ['None']
|
---|
233 | return bugs
|
---|
234 |
|
---|
235 |
|
---|
236 | async def get_bug(session: aiohttp.ClientSession, bug_id: str) -> str:
|
---|
237 | """Query gitlab to get the name of the issue that was closed."""
|
---|
238 | # Mesa's gitlab id is 176,
|
---|
239 | url = 'https://gitlab.freedesktop.org/api/v4/projects/176/issues'
|
---|
240 | params = {'iids[]': bug_id}
|
---|
241 | async with session.get(url, params=params) as response:
|
---|
242 | content = await response.json()
|
---|
243 | if not content:
|
---|
244 | # issues marked as "confidential" look like "404" page for
|
---|
245 | # unauthorized users
|
---|
246 | return f'Confidential issue #{bug_id}'
|
---|
247 | else:
|
---|
248 | return content[0]['title']
|
---|
249 |
|
---|
250 |
|
---|
251 | async def get_shortlog(version: str) -> str:
|
---|
252 | """Call git shortlog."""
|
---|
253 | p = await asyncio.create_subprocess_exec('git', 'shortlog', f'mesa-{version}..',
|
---|
254 | stdout=asyncio.subprocess.PIPE)
|
---|
255 | out, _ = await p.communicate()
|
---|
256 | assert p.returncode == 0, 'error getting shortlog'
|
---|
257 | assert out is not None, 'just for mypy'
|
---|
258 | return out.decode()
|
---|
259 |
|
---|
260 |
|
---|
261 | def walk_shortlog(log: str) -> typing.Generator[typing.Tuple[str, bool], None, None]:
|
---|
262 | for l in log.split('\n'):
|
---|
263 | if l.startswith(' '): # this means we have a patch description
|
---|
264 | yield l.lstrip(), False
|
---|
265 | elif l.strip():
|
---|
266 | yield l, True
|
---|
267 |
|
---|
268 |
|
---|
269 | def calculate_next_version(version: str, is_point: bool) -> str:
|
---|
270 | """Calculate the version about to be released."""
|
---|
271 | if '-' in version:
|
---|
272 | version = version.split('-')[0]
|
---|
273 | if is_point:
|
---|
274 | base = version.split('.')
|
---|
275 | base[2] = str(int(base[2]) + 1)
|
---|
276 | return '.'.join(base)
|
---|
277 | return version
|
---|
278 |
|
---|
279 |
|
---|
280 | def calculate_previous_version(version: str, is_point: bool) -> str:
|
---|
281 | """Calculate the previous version to compare to.
|
---|
282 |
|
---|
283 | In the case of -rc to final that version is the previous .0 release,
|
---|
284 | (19.3.0 in the case of 20.0.0, for example). for point releases that is
|
---|
285 | the last point release. This value will be the same as the input value
|
---|
286 | for a point release, but different for a major release.
|
---|
287 | """
|
---|
288 | if '-' in version:
|
---|
289 | version = version.split('-')[0]
|
---|
290 | if is_point:
|
---|
291 | return version
|
---|
292 | base = version.split('.')
|
---|
293 | if base[1] == '0':
|
---|
294 | base[0] = str(int(base[0]) - 1)
|
---|
295 | base[1] = '3'
|
---|
296 | else:
|
---|
297 | base[1] = str(int(base[1]) - 1)
|
---|
298 | return '.'.join(base)
|
---|
299 |
|
---|
300 |
|
---|
301 | def get_features(is_point_release: bool) -> typing.Generator[str, None, None]:
|
---|
302 | p = pathlib.Path('docs') / 'relnotes' / 'new_features.txt'
|
---|
303 | if p.exists() and p.stat().st_size > 0:
|
---|
304 | if is_point_release:
|
---|
305 | print("WARNING: new features being introduced in a point release", file=sys.stderr)
|
---|
306 | with p.open('rt') as f:
|
---|
307 | for line in f:
|
---|
308 | yield line.rstrip()
|
---|
309 | p.unlink()
|
---|
310 | subprocess.run(['git', 'add', p])
|
---|
311 | else:
|
---|
312 | yield "None"
|
---|
313 |
|
---|
314 |
|
---|
315 | def update_release_notes_index(version: str) -> None:
|
---|
316 | relnotes_index_path = pathlib.Path('docs') / 'relnotes.rst'
|
---|
317 |
|
---|
318 | with relnotes_index_path.open('r') as f:
|
---|
319 | relnotes = f.readlines()
|
---|
320 |
|
---|
321 | new_relnotes = []
|
---|
322 | first_list = True
|
---|
323 | second_list = True
|
---|
324 | for line in relnotes:
|
---|
325 | if first_list and line.startswith('-'):
|
---|
326 | first_list = False
|
---|
327 | new_relnotes.append(f'- :doc:`{version} release notes <relnotes/{version}>`\n')
|
---|
328 | if (not first_list and second_list and
|
---|
329 | re.match(r' \d+.\d+(.\d+)? <relnotes/\d+.\d+(.\d+)?>', line)):
|
---|
330 | second_list = False
|
---|
331 | new_relnotes.append(f' {version} <relnotes/{version}>\n')
|
---|
332 | new_relnotes.append(line)
|
---|
333 |
|
---|
334 | with relnotes_index_path.open('w', encoding='utf-8') as f:
|
---|
335 | for line in new_relnotes:
|
---|
336 | f.write(line)
|
---|
337 |
|
---|
338 | subprocess.run(['git', 'add', relnotes_index_path])
|
---|
339 |
|
---|
340 |
|
---|
341 | async def main() -> None:
|
---|
342 | v = pathlib.Path('VERSION')
|
---|
343 | with v.open('rt') as f:
|
---|
344 | raw_version = f.read().strip()
|
---|
345 | is_point_release = '-rc' not in raw_version
|
---|
346 | assert '-devel' not in raw_version, 'Do not run this script on -devel'
|
---|
347 | version = raw_version.split('-')[0]
|
---|
348 | previous_version = calculate_previous_version(version, is_point_release)
|
---|
349 | this_version = calculate_next_version(version, is_point_release)
|
---|
350 | today = datetime.date.today()
|
---|
351 | header = f'Mesa {this_version} Release Notes / {today}'
|
---|
352 | header_underline = '=' * len(header)
|
---|
353 |
|
---|
354 | shortlog, bugs = await asyncio.gather(
|
---|
355 | get_shortlog(previous_version),
|
---|
356 | gather_bugs(previous_version),
|
---|
357 | )
|
---|
358 |
|
---|
359 | final = pathlib.Path('docs') / 'relnotes' / f'{this_version}.rst'
|
---|
360 | with final.open('wt', encoding='utf-8') as f:
|
---|
361 | try:
|
---|
362 | f.write(TEMPLATE.render(
|
---|
363 | bugfix=is_point_release,
|
---|
364 | bugs=bugs,
|
---|
365 | changes=walk_shortlog(shortlog),
|
---|
366 | features=get_features(is_point_release),
|
---|
367 | gl_version=CURRENT_GL_VERSION,
|
---|
368 | this_version=this_version,
|
---|
369 | header=header,
|
---|
370 | header_underline=header_underline,
|
---|
371 | previous_version=previous_version,
|
---|
372 | vk_version=CURRENT_VK_VERSION,
|
---|
373 | rst_escape=inliner.quoteInline,
|
---|
374 | ))
|
---|
375 | except:
|
---|
376 | print(exceptions.text_error_template().render())
|
---|
377 | return
|
---|
378 |
|
---|
379 | subprocess.run(['git', 'add', final])
|
---|
380 |
|
---|
381 | update_release_notes_index(this_version)
|
---|
382 |
|
---|
383 | subprocess.run(['git', 'commit', '-m',
|
---|
384 | f'docs: add release notes for {this_version}'])
|
---|
385 |
|
---|
386 |
|
---|
387 | if __name__ == "__main__":
|
---|
388 | loop = asyncio.get_event_loop()
|
---|
389 | loop.run_until_complete(main())
|
---|