]> git.lizzy.rs Git - rust.git/blob - src/tools/publish_toolstate.py
Auto merge of #81132 - bugadani:map-prealloc, r=matthewjasper
[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', 'JohnTitor'},
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': {'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         ''').format(
161             relevant_pr_number, tool, status_description,
162             REPOS.get(tool), relevant_pr_user
163         )),
164         'title': '`{}` no longer builds after {}'.format(tool, relevant_pr_number),
165         'assignees': list(assignees),
166         'labels': labels,
167     })
168     print("Creating issue:\n{}".format(request))
169     response = urllib2.urlopen(urllib2.Request(
170         gh_url(),
171         request,
172         {
173             'Authorization': 'token ' + github_token,
174             'Content-Type': 'application/json',
175         }
176     ))
177     response.read()
178
179
180 def update_latest(
181     current_commit,
182     relevant_pr_number,
183     relevant_pr_url,
184     relevant_pr_user,
185     pr_reviewer,
186     current_datetime
187 ):
188     '''Updates `_data/latest.json` to match build result of the given commit.
189     '''
190     with open('_data/latest.json', 'r+') as f:
191         latest = json.load(f, object_pairs_hook=collections.OrderedDict)
192
193         current_status = {
194             os: read_current_status(current_commit, 'history/' + os + '.tsv')
195             for os in ['windows', 'linux']
196         }
197
198         slug = 'rust-lang/rust'
199         message = textwrap.dedent('''\
200             ðŸ“£ Toolstate changed by {}!
201
202             Tested on commit {}@{}.
203             Direct link to PR: <{}>
204
205         ''').format(relevant_pr_number, slug, current_commit, relevant_pr_url)
206         anything_changed = False
207         for status in latest:
208             tool = status['tool']
209             changed = False
210             create_issue_for_status = None  # set to the status that caused the issue
211
212             for os, s in current_status.items():
213                 old = status[os]
214                 new = s.get(tool, old)
215                 status[os] = new
216                 maintainers = ' '.join('@'+name for name in MAINTAINERS.get(tool, ()))
217                 # comparing the strings, but they are ordered appropriately:
218                 # "test-pass" > "test-fail" > "build-fail"
219                 if new > old:
220                     # things got fixed or at least the status quo improved
221                     changed = True
222                     message += '🎉 {} on {}: {} â†’ {} (cc {}).\n' \
223                         .format(tool, os, old, new, maintainers)
224                 elif new < old:
225                     # tests or builds are failing and were not failing before
226                     changed = True
227                     title = '💔 {} on {}: {} â†’ {}' \
228                         .format(tool, os, old, new)
229                     message += '{} (cc {}).\n' \
230                         .format(title, maintainers)
231                     # See if we need to create an issue.
232                     if tool == 'miri':
233                         # Create issue if tests used to pass before. Don't open a *second*
234                         # issue when we regress from "test-fail" to "build-fail".
235                         if old == 'test-pass':
236                             create_issue_for_status = new
237                     else:
238                         # Create issue if things no longer build.
239                         # (No issue for mere test failures to avoid spurious issues.)
240                         if new == 'build-fail':
241                             create_issue_for_status = new
242
243             if create_issue_for_status is not None:
244                 try:
245                     issue(
246                         tool, create_issue_for_status, MAINTAINERS.get(tool, ''),
247                         relevant_pr_number, relevant_pr_user, LABELS.get(tool, ''),
248                     )
249                 except urllib2.HTTPError as e:
250                     # network errors will simply end up not creating an issue, but that's better
251                     # than failing the entire build job
252                     print("HTTPError when creating issue for status regression: {0}\n{1}"
253                           .format(e, e.read()))
254                 except IOError as e:
255                     print("I/O error when creating issue for status regression: {0}".format(e))
256                 except:
257                     print("Unexpected error when creating issue for status regression: {0}"
258                           .format(sys.exc_info()[0]))
259                     raise
260
261             if changed:
262                 status['commit'] = current_commit
263                 status['datetime'] = current_datetime
264                 anything_changed = True
265
266         if not anything_changed:
267             return ''
268
269         f.seek(0)
270         f.truncate(0)
271         json.dump(latest, f, indent=4, separators=(',', ': '))
272         return message
273
274
275 # Warning: Do not try to add a function containing the body of this try block.
276 # There are variables declared within that are implicitly global; it is unknown
277 # which ones precisely but at least this is true for `github_token`.
278 try:
279     if __name__ != '__main__':
280         exit(0)
281     repo = os.environ.get('TOOLSTATE_VALIDATE_MAINTAINERS_REPO')
282     if repo:
283         github_token = os.environ.get('TOOLSTATE_REPO_ACCESS_TOKEN')
284         if github_token:
285             validate_maintainers(repo, github_token)
286         else:
287             print('skipping toolstate maintainers validation since no GitHub token is present')
288         # When validating maintainers don't run the full script.
289         exit(0)
290
291     cur_commit = sys.argv[1]
292     cur_datetime = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
293     cur_commit_msg = sys.argv[2]
294     save_message_to_path = sys.argv[3]
295     github_token = sys.argv[4]
296
297     # assume that PR authors are also owners of the repo where the branch lives
298     relevant_pr_match = re.search(
299         r'Auto merge of #([0-9]+) - ([^:]+):[^,]+, r=(\S+)',
300         cur_commit_msg,
301     )
302     if relevant_pr_match:
303         number = relevant_pr_match.group(1)
304         relevant_pr_user = relevant_pr_match.group(2)
305         relevant_pr_number = 'rust-lang/rust#' + number
306         relevant_pr_url = 'https://github.com/rust-lang/rust/pull/' + number
307         pr_reviewer = relevant_pr_match.group(3)
308     else:
309         number = '-1'
310         relevant_pr_user = 'ghost'
311         relevant_pr_number = '<unknown PR>'
312         relevant_pr_url = '<unknown>'
313         pr_reviewer = 'ghost'
314
315     message = update_latest(
316         cur_commit,
317         relevant_pr_number,
318         relevant_pr_url,
319         relevant_pr_user,
320         pr_reviewer,
321         cur_datetime
322     )
323     if not message:
324         print('<Nothing changed>')
325         sys.exit(0)
326
327     print(message)
328
329     if not github_token:
330         print('Dry run only, not committing anything')
331         sys.exit(0)
332
333     with open(save_message_to_path, 'w') as f:
334         f.write(message)
335
336     # Write the toolstate comment on the PR as well.
337     issue_url = gh_url() + '/{}/comments'.format(number)
338     response = urllib2.urlopen(urllib2.Request(
339         issue_url,
340         json.dumps({'body': maybe_delink(message)}),
341         {
342             'Authorization': 'token ' + github_token,
343             'Content-Type': 'application/json',
344         }
345     ))
346     response.read()
347 except urllib2.HTTPError as e:
348     print("HTTPError: %s\n%s" % (e, e.read()))
349     raise