2 # -*- coding: utf-8 -*-
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`).
9 from __future__ import print_function
21 import urllib.request as urllib2
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.
27 'miri': {'oli-obk', 'RalfJung', 'eddyb'},
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'},
40 'miri': ['A-miri', 'C-bug'],
41 'rls': ['A-rls', 'C-bug'],
42 'rustfmt': ['A-rustfmt', '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'],
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',
65 def load_json_from_response(resp):
67 if isinstance(content, bytes):
68 content = content.decode('utf-8')
70 print("Refusing to decode " + str(type(content)) + " to str")
71 return json.loads(content)
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"')
77 # Load the list of assignable people in the GitHub repo
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',
86 assignable.extend(user['login'] for user in load_json_from_response(response))
87 # Load the next page if available
89 link_header = response.headers.get('Link')
91 matches = next_link_re.match(link_header)
92 if matches is not None:
93 url = matches.group(1)
96 for tool, maintainers in MAINTAINERS.items():
97 for maintainer in maintainers:
98 if maintainer not in assignable:
101 "error: %s maintainer @%s is not assignable in the %s repo"
102 % (tool, maintainer, repo),
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.")
114 print("The build will fail due to this.")
118 def read_current_status(current_commit, path):
119 '''Reads build status of `current_commit` from content of `history/*.tsv`
121 with open(path, 'rU') as f:
123 (commit, status) = line.split('\t', 1)
124 if commit == current_commit:
125 return json.loads(status)
130 return os.environ['TOOLSTATE_ISSUES_API_URL']
133 def maybe_delink(message):
134 if os.environ.get('TOOLSTATE_SKIP_MENTIONS') is not None:
135 return message.replace("@", "")
147 # Open an issue about the toolstate failure.
148 if status == 'test-fail':
149 status_description = 'has failing tests'
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.
158 cc @{}, do you think you would have time to do the follow-up work?
159 If so, that would be great!
161 relevant_pr_number, tool, status_description,
162 REPOS.get(tool), relevant_pr_user
164 'title': '`{}` no longer builds after {}'.format(tool, relevant_pr_number),
165 'assignees': list(assignees),
168 print("Creating issue:\n{}".format(request))
169 response = urllib2.urlopen(urllib2.Request(
173 'Authorization': 'token ' + github_token,
174 'Content-Type': 'application/json',
188 '''Updates `_data/latest.json` to match build result of the given commit.
190 with open('_data/latest.json', 'r+') as f:
191 latest = json.load(f, object_pairs_hook=collections.OrderedDict)
194 os: read_current_status(current_commit, 'history/' + os + '.tsv')
195 for os in ['windows', 'linux']
198 slug = 'rust-lang/rust'
199 message = textwrap.dedent('''\
200 📣 Toolstate changed by {}!
202 Tested on commit {}@{}.
203 Direct link to PR: <{}>
205 ''').format(relevant_pr_number, slug, current_commit, relevant_pr_url)
206 anything_changed = False
207 for status in latest:
208 tool = status['tool']
210 create_issue_for_status = None # set to the status that caused the issue
212 for os, s in current_status.items():
214 new = s.get(tool, old)
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"
220 # things got fixed or at least the status quo improved
222 message += '🎉 {} on {}: {} → {} (cc {}).\n' \
223 .format(tool, os, old, new, maintainers)
225 # tests or builds are failing and were not failing before
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.
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
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
243 if create_issue_for_status is not None:
246 tool, create_issue_for_status, MAINTAINERS.get(tool, ''),
247 relevant_pr_number, relevant_pr_user, LABELS.get(tool, ''),
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()))
255 print("I/O error when creating issue for status regression: {0}".format(e))
257 print("Unexpected error when creating issue for status regression: {0}"
258 .format(sys.exc_info()[0]))
262 status['commit'] = current_commit
263 status['datetime'] = current_datetime
264 anything_changed = True
266 if not anything_changed:
271 json.dump(latest, f, indent=4, separators=(',', ': '))
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`.
279 if __name__ != '__main__':
281 repo = os.environ.get('TOOLSTATE_VALIDATE_MAINTAINERS_REPO')
283 github_token = os.environ.get('TOOLSTATE_REPO_ACCESS_TOKEN')
285 validate_maintainers(repo, github_token)
287 print('skipping toolstate maintainers validation since no GitHub token is present')
288 # When validating maintainers don't run the full script.
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]
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+)',
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)
310 relevant_pr_user = 'ghost'
311 relevant_pr_number = '<unknown PR>'
312 relevant_pr_url = '<unknown>'
313 pr_reviewer = 'ghost'
315 message = update_latest(
324 print('<Nothing changed>')
330 print('Dry run only, not committing anything')
333 with open(save_message_to_path, 'w') as f:
336 # Write the toolstate comment on the PR as well.
337 issue_url = gh_url() + '/{}/comments'.format(number)
338 response = urllib2.urlopen(urllib2.Request(
340 json.dumps({'body': maybe_delink(message)}),
342 'Authorization': 'token ' + github_token,
343 'Content-Type': 'application/json',
347 except urllib2.HTTPError as e:
348 print("HTTPError: %s\n%s" % (e, e.read()))