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