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