]> git.lizzy.rs Git - rust.git/blob - src/tools/publish_toolstate.py
Merge commit '7c7683c8efe447b251d6c5ca6cce51233060f6e8' into clippyup
[rust.git] / src / tools / publish_toolstate.py
1 #!/usr/bin/env python
2 # -*- coding: utf-8 -*-
3
4 # This script computes the new "current" toolstate for the toolstate repo (not to be
5 # confused with publishing the test results, which happens in `src/bootstrap/toolstate.rs`).
6 # It gets called from `src/ci/publish_toolstate.sh` when a new commit lands on `master`
7 # (i.e., after it passed all checks on `auto`).
8
9 from __future__ import print_function
10
11 import sys
12 import re
13 import os
14 import json
15 import datetime
16 import collections
17 import textwrap
18 try:
19     import urllib2
20     from urllib2 import HTTPError
21 except ImportError:
22     import urllib.request as urllib2
23     from urllib.error import HTTPError
24 try:
25     import typing
26 except ImportError:
27     pass
28
29 # List of people to ping when the status of a tool or a book changed.
30 # These should be collaborators of the rust-lang/rust repository (with at least
31 # read privileges on it). CI will fail otherwise.
32 MAINTAINERS = {
33     'miri': {'oli-obk', 'RalfJung', 'eddyb'},
34     'rls': {'Xanewok'},
35     'rustfmt': {'topecongiro', 'calebcartwright'},
36     'book': {'carols10cents', 'steveklabnik'},
37     'nomicon': {'frewsxcv', 'Gankra', 'JohnTitor'},
38     'reference': {'steveklabnik', 'Havvy', 'matthewjasper', 'ehuss'},
39     'rust-by-example': {'steveklabnik', 'marioidival'},
40     'embedded-book': {'adamgreig', 'andre-richter', 'jamesmunns', 'therealprof'},
41     'edition-guide': {'ehuss', 'steveklabnik'},
42     'rustc-dev-guide': {'spastorino', 'amanjeev', 'JohnTitor'},
43 }
44
45 LABELS = {
46     'miri': ['A-miri', 'C-bug'],
47     'rls': ['A-rls', 'C-bug'],
48     'rustfmt': ['A-rustfmt', 'C-bug'],
49     'book': ['C-bug'],
50     'nomicon': ['C-bug'],
51     'reference': ['C-bug'],
52     'rust-by-example': ['C-bug'],
53     'embedded-book': ['C-bug'],
54     'edition-guide': ['C-bug'],
55     'rustc-dev-guide': ['C-bug'],
56 }
57
58 REPOS = {
59     'miri': 'https://github.com/rust-lang/miri',
60     'rls': 'https://github.com/rust-lang/rls',
61     'rustfmt': 'https://github.com/rust-lang/rustfmt',
62     'book': 'https://github.com/rust-lang/book',
63     'nomicon': 'https://github.com/rust-lang/nomicon',
64     'reference': 'https://github.com/rust-lang/reference',
65     'rust-by-example': 'https://github.com/rust-lang/rust-by-example',
66     'embedded-book': 'https://github.com/rust-embedded/book',
67     'edition-guide': 'https://github.com/rust-lang/edition-guide',
68     'rustc-dev-guide': 'https://github.com/rust-lang/rustc-dev-guide',
69 }
70
71 def load_json_from_response(resp):
72     # type: (typing.Any) -> typing.Any
73     content = resp.read()
74     if isinstance(content, bytes):
75         content_str = content.decode('utf-8')
76     else:
77         print("Refusing to decode " + str(type(content)) + " to str")
78     return json.loads(content_str)
79
80 def validate_maintainers(repo, github_token):
81     # type: (str, str) -> None
82     '''Ensure all maintainers are assignable on a GitHub repo'''
83     next_link_re = re.compile(r'<([^>]+)>; rel="next"')
84
85     # Load the list of assignable people in the GitHub repo
86     assignable = [] # type: typing.List[str]
87     url = 'https://api.github.com/repos/' \
88         + '%s/collaborators?per_page=100' % repo # type: typing.Optional[str]
89     while url is not None:
90         response = urllib2.urlopen(urllib2.Request(url, headers={
91             'Authorization': 'token ' + github_token,
92             # Properly load nested teams.
93             'Accept': 'application/vnd.github.hellcat-preview+json',
94         }))
95         assignable.extend(user['login'] for user in load_json_from_response(response))
96         # Load the next page if available
97         url = None
98         link_header = response.headers.get('Link')
99         if link_header:
100             matches = next_link_re.match(link_header)
101             if matches is not None:
102                 url = matches.group(1)
103
104     errors = False
105     for tool, maintainers in MAINTAINERS.items():
106         for maintainer in maintainers:
107             if maintainer not in assignable:
108                 errors = True
109                 print(
110                     "error: %s maintainer @%s is not assignable in the %s repo"
111                     % (tool, maintainer, repo),
112                 )
113
114     if errors:
115         print()
116         print("  To be assignable, a person needs to be explicitly listed as a")
117         print("  collaborator in the repository settings. The simple way to")
118         print("  fix this is to ask someone with 'admin' privileges on the repo")
119         print("  to add the person or whole team as a collaborator with 'read'")
120         print("  privileges. Those privileges don't grant any extra permissions")
121         print("  so it's safe to apply them.")
122         print()
123         print("The build will fail due to this.")
124         exit(1)
125
126
127 def read_current_status(current_commit, path):
128     # type: (str, str) -> typing.Mapping[str, typing.Any]
129     '''Reads build status of `current_commit` from content of `history/*.tsv`
130     '''
131     with open(path, 'r') as f:
132         for line in f:
133             (commit, status) = line.split('\t', 1)
134             if commit == current_commit:
135                 return json.loads(status)
136     return {}
137
138
139 def gh_url():
140     # type: () -> str
141     return os.environ['TOOLSTATE_ISSUES_API_URL']
142
143
144 def maybe_delink(message):
145     # type: (str) -> str
146     if os.environ.get('TOOLSTATE_SKIP_MENTIONS') is not None:
147         return message.replace("@", "")
148     return message
149
150
151 def issue(
152     tool,
153     status,
154     assignees,
155     relevant_pr_number,
156     relevant_pr_user,
157     labels,
158     github_token,
159 ):
160     # type: (str, str, typing.Iterable[str], str, str, typing.List[str], str) -> None
161     '''Open an issue about the toolstate failure.'''
162     if status == 'test-fail':
163         status_description = 'has failing tests'
164     else:
165         status_description = 'no longer builds'
166     request = json.dumps({
167         'body': maybe_delink(textwrap.dedent('''\
168         Hello, this is your friendly neighborhood mergebot.
169         After merging PR {}, I observed that the tool {} {}.
170         A follow-up PR to the repository {} is needed to fix the fallout.
171
172         cc @{}, do you think you would have time to do the follow-up work?
173         If so, that would be great!
174         ''').format(
175             relevant_pr_number, tool, status_description,
176             REPOS.get(tool), relevant_pr_user
177         )),
178         'title': '`{}` no longer builds after {}'.format(tool, relevant_pr_number),
179         'assignees': list(assignees),
180         'labels': labels,
181     })
182     print("Creating issue:\n{}".format(request))
183     response = urllib2.urlopen(urllib2.Request(
184         gh_url(),
185         request.encode(),
186         {
187             'Authorization': 'token ' + github_token,
188             'Content-Type': 'application/json',
189         }
190     ))
191     response.read()
192
193
194 def update_latest(
195     current_commit,
196     relevant_pr_number,
197     relevant_pr_url,
198     relevant_pr_user,
199     pr_reviewer,
200     current_datetime,
201     github_token,
202 ):
203     # type: (str, str, str, str, str, str, str) -> str
204     '''Updates `_data/latest.json` to match build result of the given commit.
205     '''
206     with open('_data/latest.json', 'r+') as f:
207         latest = json.load(f, object_pairs_hook=collections.OrderedDict)
208
209         current_status = {
210             os: read_current_status(current_commit, 'history/' + os + '.tsv')
211             for os in ['windows', 'linux']
212         }
213
214         slug = 'rust-lang/rust'
215         message = textwrap.dedent('''\
216             ðŸ“£ Toolstate changed by {}!
217
218             Tested on commit {}@{}.
219             Direct link to PR: <{}>
220
221         ''').format(relevant_pr_number, slug, current_commit, relevant_pr_url)
222         anything_changed = False
223         for status in latest:
224             tool = status['tool']
225             changed = False
226             create_issue_for_status = None  # set to the status that caused the issue
227
228             for os, s in current_status.items():
229                 old = status[os]
230                 new = s.get(tool, old)
231                 status[os] = new
232                 maintainers = ' '.join('@'+name for name in MAINTAINERS.get(tool, ()))
233                 # comparing the strings, but they are ordered appropriately:
234                 # "test-pass" > "test-fail" > "build-fail"
235                 if new > old:
236                     # things got fixed or at least the status quo improved
237                     changed = True
238                     message += '🎉 {} on {}: {} â†’ {} (cc {}).\n' \
239                         .format(tool, os, old, new, maintainers)
240                 elif new < old:
241                     # tests or builds are failing and were not failing before
242                     changed = True
243                     title = '💔 {} on {}: {} â†’ {}' \
244                         .format(tool, os, old, new)
245                     message += '{} (cc {}).\n' \
246                         .format(title, maintainers)
247                     # See if we need to create an issue.
248                     if tool == 'miri':
249                         # Create issue if tests used to pass before. Don't open a *second*
250                         # issue when we regress from "test-fail" to "build-fail".
251                         if old == 'test-pass':
252                             create_issue_for_status = new
253                     else:
254                         # Create issue if things no longer build.
255                         # (No issue for mere test failures to avoid spurious issues.)
256                         if new == 'build-fail':
257                             create_issue_for_status = new
258
259             if create_issue_for_status is not None:
260                 try:
261                     issue(
262                         tool, create_issue_for_status, MAINTAINERS.get(tool, ()),
263                         relevant_pr_number, relevant_pr_user, LABELS.get(tool, []),
264                         github_token,
265                     )
266                 except HTTPError as e:
267                     # network errors will simply end up not creating an issue, but that's better
268                     # than failing the entire build job
269                     print("HTTPError when creating issue for status regression: {0}\n{1!r}"
270                           .format(e, e.read()))
271                 except IOError as e:
272                     print("I/O error when creating issue for status regression: {0}".format(e))
273                 except:
274                     print("Unexpected error when creating issue for status regression: {0}"
275                           .format(sys.exc_info()[0]))
276                     raise
277
278             if changed:
279                 status['commit'] = current_commit
280                 status['datetime'] = current_datetime
281                 anything_changed = True
282
283         if not anything_changed:
284             return ''
285
286         f.seek(0)
287         f.truncate(0)
288         json.dump(latest, f, indent=4, separators=(',', ': '))
289         return message
290
291
292 # Warning: Do not try to add a function containing the body of this try block.
293 # There are variables declared within that are implicitly global; it is unknown
294 # which ones precisely but at least this is true for `github_token`.
295 try:
296     if __name__ != '__main__':
297         exit(0)
298     repo = os.environ.get('TOOLSTATE_VALIDATE_MAINTAINERS_REPO')
299     if repo:
300         github_token = os.environ.get('TOOLSTATE_REPO_ACCESS_TOKEN')
301         if github_token:
302             validate_maintainers(repo, github_token)
303         else:
304             print('skipping toolstate maintainers validation since no GitHub token is present')
305         # When validating maintainers don't run the full script.
306         exit(0)
307
308     cur_commit = sys.argv[1]
309     cur_datetime = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
310     cur_commit_msg = sys.argv[2]
311     save_message_to_path = sys.argv[3]
312     github_token = sys.argv[4]
313
314     # assume that PR authors are also owners of the repo where the branch lives
315     relevant_pr_match = re.search(
316         r'Auto merge of #([0-9]+) - ([^:]+):[^,]+, r=(\S+)',
317         cur_commit_msg,
318     )
319     if relevant_pr_match:
320         number = relevant_pr_match.group(1)
321         relevant_pr_user = relevant_pr_match.group(2)
322         relevant_pr_number = 'rust-lang/rust#' + number
323         relevant_pr_url = 'https://github.com/rust-lang/rust/pull/' + number
324         pr_reviewer = relevant_pr_match.group(3)
325     else:
326         number = '-1'
327         relevant_pr_user = 'ghost'
328         relevant_pr_number = '<unknown PR>'
329         relevant_pr_url = '<unknown>'
330         pr_reviewer = 'ghost'
331
332     message = update_latest(
333         cur_commit,
334         relevant_pr_number,
335         relevant_pr_url,
336         relevant_pr_user,
337         pr_reviewer,
338         cur_datetime,
339         github_token,
340     )
341     if not message:
342         print('<Nothing changed>')
343         sys.exit(0)
344
345     print(message)
346
347     if not github_token:
348         print('Dry run only, not committing anything')
349         sys.exit(0)
350
351     with open(save_message_to_path, 'w') as f:
352         f.write(message)
353
354     # Write the toolstate comment on the PR as well.
355     issue_url = gh_url() + '/{}/comments'.format(number)
356     response = urllib2.urlopen(urllib2.Request(
357         issue_url,
358         json.dumps({'body': maybe_delink(message)}).encode(),
359         {
360             'Authorization': 'token ' + github_token,
361             'Content-Type': 'application/json',
362         }
363     ))
364     response.read()
365 except HTTPError as e:
366     print("HTTPError: %s\n%r" % (e, e.read()))
367     raise