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