]> git.lizzy.rs Git - rust.git/blob - src/tools/publish_toolstate.py
Rollup merge of #75837 - GuillaumeGomez:fix-font-color-help-button, r=Cldfire
[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 def main():
279     repo = os.environ.get('TOOLSTATE_VALIDATE_MAINTAINERS_REPO')
280     if repo:
281         github_token = os.environ.get('TOOLSTATE_REPO_ACCESS_TOKEN')
282         if github_token:
283             validate_maintainers(repo, github_token)
284         else:
285             print('skipping toolstate maintainers validation since no GitHub token is present')
286         # When validating maintainers don't run the full script.
287         exit(0)
288
289     cur_commit = sys.argv[1]
290     cur_datetime = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
291     cur_commit_msg = sys.argv[2]
292     save_message_to_path = sys.argv[3]
293     github_token = sys.argv[4]
294
295     # assume that PR authors are also owners of the repo where the branch lives
296     relevant_pr_match = re.search(
297         r'Auto merge of #([0-9]+) - ([^:]+):[^,]+, r=(\S+)',
298         cur_commit_msg,
299     )
300     if relevant_pr_match:
301         number = relevant_pr_match.group(1)
302         relevant_pr_user = relevant_pr_match.group(2)
303         relevant_pr_number = 'rust-lang/rust#' + number
304         relevant_pr_url = 'https://github.com/rust-lang/rust/pull/' + number
305         pr_reviewer = relevant_pr_match.group(3)
306     else:
307         number = '-1'
308         relevant_pr_user = 'ghost'
309         relevant_pr_number = '<unknown PR>'
310         relevant_pr_url = '<unknown>'
311         pr_reviewer = 'ghost'
312
313     message = update_latest(
314         cur_commit,
315         relevant_pr_number,
316         relevant_pr_url,
317         relevant_pr_user,
318         pr_reviewer,
319         cur_datetime
320     )
321     if not message:
322         print('<Nothing changed>')
323         sys.exit(0)
324
325     print(message)
326
327     if not github_token:
328         print('Dry run only, not committing anything')
329         sys.exit(0)
330
331     with open(save_message_to_path, 'w') as f:
332         f.write(message)
333
334     # Write the toolstate comment on the PR as well.
335     issue_url = gh_url() + '/{}/comments'.format(number)
336     response = urllib2.urlopen(urllib2.Request(
337         issue_url,
338         json.dumps({'body': maybe_delink(message)}),
339         {
340             'Authorization': 'token ' + github_token,
341             'Content-Type': 'application/json',
342         }
343     ))
344     response.read()
345
346
347 if __name__ == '__main__':
348     try:
349         main()
350     except urllib2.HTTPError as e:
351         print("HTTPError: %s\n%s" % (e, e.read()))
352         raise