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