|
|
|
#!/usr/bin/env python
|
|
|
|
# Copyright 2017 gRPC authors.
|
|
|
|
#
|
|
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
# you may not use this file except in compliance with the License.
|
|
|
|
# You may obtain a copy of the License at
|
|
|
|
#
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
#
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
# See the License for the specific language governing permissions and
|
|
|
|
# limitations under the License.
|
|
|
|
"""Measure the time between PR creation and completion of all tests.
|
|
|
|
|
|
|
|
You'll need a github API token to avoid being rate-limited. See
|
|
|
|
https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/
|
|
|
|
|
|
|
|
This script goes over the most recent 100 pull requests. For PRs with a single
|
|
|
|
commit, it uses the PR's creation as the initial time; othewise, it uses the
|
|
|
|
date of the last commit. This is somewhat fragile, and imposed by the fact that
|
|
|
|
GitHub reports a PR's updated timestamp for any event that modifies the PR (e.g.
|
|
|
|
comments), not just the addition of new commits.
|
|
|
|
|
|
|
|
In addition, it ignores latencies greater than five hours, as that's likely due
|
|
|
|
to a manual re-run of tests.
|
|
|
|
"""
|
|
|
|
|
|
|
|
from __future__ import absolute_import
|
|
|
|
from __future__ import division
|
|
|
|
from __future__ import print_function
|
|
|
|
|
|
|
|
import json
|
|
|
|
import logging
|
|
|
|
import pprint
|
|
|
|
import urllib2
|
|
|
|
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
|
|
logging.basicConfig(format='%(asctime)s %(message)s')
|
|
|
|
|
|
|
|
PRS = 'https://api.github.com/repos/grpc/grpc/pulls?state=open&per_page=100'
|
|
|
|
COMMITS = 'https://api.github.com/repos/grpc/grpc/pulls/{pr_number}/commits'
|
|
|
|
|
|
|
|
|
|
|
|
def gh(url):
|
|
|
|
request = urllib2.Request(url)
|
|
|
|
if TOKEN:
|
|
|
|
request.add_header('Authorization', 'token {}'.format(TOKEN))
|
|
|
|
response = urllib2.urlopen(request)
|
|
|
|
return response.read()
|
|
|
|
|
|
|
|
|
|
|
|
def print_csv_header():
|
|
|
|
print('pr,base_time,test_time,latency_seconds,successes,failures,errors')
|
|
|
|
|
|
|
|
|
|
|
|
def output(pr,
|
|
|
|
base_time,
|
|
|
|
test_time,
|
|
|
|
diff_time,
|
|
|
|
successes,
|
|
|
|
failures,
|
|
|
|
errors,
|
|
|
|
mode='human'):
|
|
|
|
if mode == 'human':
|
|
|
|
print(
|
|
|
|
"PR #{} base time: {} UTC, Tests completed at: {} UTC. Latency: {}."
|
|
|
|
"\n\tSuccesses: {}, Failures: {}, Errors: {}".format(
|
|
|
|
pr, base_time, test_time, diff_time, successes, failures,
|
|
|
|
errors))
|
|
|
|
elif mode == 'csv':
|
|
|
|
print(','.join([
|
|
|
|
str(pr),
|
|
|
|
str(base_time),
|
|
|
|
str(test_time),
|
|
|
|
str(int((test_time - base_time).total_seconds())),
|
|
|
|
str(successes),
|
|
|
|
str(failures),
|
|
|
|
str(errors)
|
|
|
|
]))
|
|
|
|
|
|
|
|
|
|
|
|
def parse_timestamp(datetime_str):
|
|
|
|
return datetime.strptime(datetime_str, '%Y-%m-%dT%H:%M:%SZ')
|
|
|
|
|
|
|
|
|
|
|
|
def to_posix_timestamp(dt):
|
|
|
|
return str((dt - datetime(1970, 1, 1)).total_seconds())
|
|
|
|
|
|
|
|
|
|
|
|
def get_pr_data():
|
|
|
|
latest_prs = json.loads(gh(PRS))
|
|
|
|
res = [{
|
|
|
|
'number': pr['number'],
|
|
|
|
'created_at': parse_timestamp(pr['created_at']),
|
|
|
|
'updated_at': parse_timestamp(pr['updated_at']),
|
|
|
|
'statuses_url': pr['statuses_url']
|
|
|
|
} for pr in latest_prs]
|
|
|
|
return res
|
|
|
|
|
|
|
|
|
|
|
|
def get_commits_data(pr_number):
|
|
|
|
commits = json.loads(gh(COMMITS.format(pr_number=pr_number)))
|
|
|
|
return {
|
|
|
|
'num_commits': len(commits),
|
|
|
|
'most_recent_date':
|
|
|
|
parse_timestamp(commits[-1]['commit']['author']['date'])
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def get_status_data(statuses_url, system):
|
|
|
|
status_url = statuses_url.replace('statuses', 'status')
|
|
|
|
statuses = json.loads(gh(status_url + '?per_page=100'))
|
|
|
|
successes = 0
|
|
|
|
failures = 0
|
|
|
|
errors = 0
|
|
|
|
latest_datetime = None
|
|
|
|
if not statuses: return None
|
|
|
|
if system == 'kokoro': string_in_target_url = 'kokoro'
|
|
|
|
elif system == 'jenkins': string_in_target_url = 'grpc-testing'
|
|
|
|
for status in statuses['statuses']:
|
|
|
|
if not status['target_url'] or string_in_target_url not in status['target_url']:
|
|
|
|
continue # Ignore jenkins
|
|
|
|
if status['state'] == 'pending': return None
|
|
|
|
elif status['state'] == 'success': successes += 1
|
|
|
|
elif status['state'] == 'failure': failures += 1
|
|
|
|
elif status['state'] == 'error': errors += 1
|
|
|
|
if not latest_datetime:
|
|
|
|
latest_datetime = parse_timestamp(status['updated_at'])
|
|
|
|
else:
|
|
|
|
latest_datetime = max(latest_datetime,
|
|
|
|
parse_timestamp(status['updated_at']))
|
|
|
|
# First status is the most recent one.
|
|
|
|
if any([successes, failures, errors
|
|
|
|
]) and sum([successes, failures, errors]) > 15:
|
|
|
|
return {
|
|
|
|
'latest_datetime': latest_datetime,
|
|
|
|
'successes': successes,
|
|
|
|
'failures': failures,
|
|
|
|
'errors': errors
|
|
|
|
}
|
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def build_args_parser():
|
|
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser()
|
|
|
|
parser.add_argument(
|
|
|
|
'--format',
|
|
|
|
type=str,
|
|
|
|
choices=['human', 'csv'],
|
|
|
|
default='human',
|
|
|
|
help='Output format: are you a human or a machine?')
|
|
|
|
parser.add_argument(
|
|
|
|
'--system',
|
|
|
|
type=str,
|
|
|
|
choices=['jenkins', 'kokoro'],
|
|
|
|
required=True,
|
|
|
|
help='Consider only the given CI system')
|
|
|
|
parser.add_argument(
|
|
|
|
'--token',
|
|
|
|
type=str,
|
|
|
|
default='',
|
|
|
|
help='GitHub token to use its API with a higher rate limit')
|
|
|
|
return parser
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
import sys
|
|
|
|
global TOKEN
|
|
|
|
args_parser = build_args_parser()
|
|
|
|
args = args_parser.parse_args()
|
|
|
|
TOKEN = args.token
|
|
|
|
if args.format == 'csv': print_csv_header()
|
|
|
|
for pr_data in get_pr_data():
|
|
|
|
commit_data = get_commits_data(pr_data['number'])
|
|
|
|
# PR with a single commit -> use the PRs creation time.
|
|
|
|
# else -> use the latest commit's date.
|
|
|
|
base_timestamp = pr_data['updated_at']
|
|
|
|
if commit_data['num_commits'] > 1:
|
|
|
|
base_timestamp = commit_data['most_recent_date']
|
|
|
|
else:
|
|
|
|
base_timestamp = pr_data['created_at']
|
|
|
|
last_status = get_status_data(pr_data['statuses_url'], args.system)
|
|
|
|
if last_status:
|
|
|
|
diff = last_status['latest_datetime'] - base_timestamp
|
|
|
|
if diff < timedelta(hours=5):
|
|
|
|
output(
|
|
|
|
pr_data['number'],
|
|
|
|
base_timestamp,
|
|
|
|
last_status['latest_datetime'],
|
|
|
|
diff,
|
|
|
|
last_status['successes'],
|
|
|
|
last_status['failures'],
|
|
|
|
last_status['errors'],
|
|
|
|
mode=args.format)
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
main()
|