blob: 7bfbec8cfde9e5459274fc25dc3140438ef921e5 [file] [log] [blame]
#!/usr/bin/env python
from __future__ import print_function
import argparse
import email.mime.multipart
import email.mime.text
import logging
import os.path
import pickle
import re
import smtplib
import subprocess
import sys
from datetime import datetime, timedelta
from phabricator import Phabricator
# Setting up a virtualenv to run this script can be done by running the
# following commands:
# $ virtualenv venv
# $ . ./venv/bin/activate
# $ pip install Phabricator
GIT_REPO_METADATA = (("llvm", "https://llvm.org/git/llvm.git"), )
# The below PhabXXX classes represent objects as modelled by Phabricator.
# The classes can be serialized to disk, to try and make sure that we don't
# needlessly have to re-fetch lots of data from Phabricator, as that would
# make this script unusably slow.
class PhabObject:
OBJECT_KIND = None
def __init__(self, id):
self.id = id
class PhabObjectCache:
def __init__(self, PhabObjectClass):
self.PhabObjectClass = PhabObjectClass
self.most_recent_info = None
self.oldest_info = None
self.id2PhabObjects = {}
def get_name(self):
return self.PhabObjectClass.OBJECT_KIND + "sCache"
def get(self, id):
if id not in self.id2PhabObjects:
self.id2PhabObjects[id] = self.PhabObjectClass(id)
return self.id2PhabObjects[id]
def get_ids_in_cache(self):
return list(self.id2PhabObjects.keys())
def get_objects(self):
return list(self.id2PhabObjects.values())
DEFAULT_DIRECTORY = "PhabObjectCache"
def _get_pickle_name(self, directory):
file_name = "Phab" + self.PhabObjectClass.OBJECT_KIND + "s.pickle"
return os.path.join(directory, file_name)
def populate_cache_from_disk(self, directory=DEFAULT_DIRECTORY):
"""
FIXME: consider if serializing to JSON would bring interoperability
advantages over serializing to pickle.
"""
try:
f = open(self._get_pickle_name(directory), "rb")
except IOError as err:
print("Could not find cache. Error message: {0}. Continuing..."
.format(err))
else:
with f:
try:
d = pickle.load(f)
self.__dict__.update(d)
except EOFError as err:
print("Cache seems to be corrupt. " +
"Not using cache. Error message: {0}".format(err))
def write_cache_to_disk(self, directory=DEFAULT_DIRECTORY):
if not os.path.exists(directory):
os.makedirs(directory)
with open(self._get_pickle_name(directory), "wb") as f:
pickle.dump(self.__dict__, f)
print("wrote cache to disk, most_recent_info= {0}".format(
datetime.fromtimestamp(self.most_recent_info)
if self.most_recent_info is not None else None))
class PhabReview(PhabObject):
OBJECT_KIND = "Review"
def __init__(self, id):
PhabObject.__init__(self, id)
def update(self, title, dateCreated, dateModified, author):
self.title = title
self.dateCreated = dateCreated
self.dateModified = dateModified
self.author = author
def setPhabDiffs(self, phabDiffs):
self.phabDiffs = phabDiffs
class PhabUser(PhabObject):
OBJECT_KIND = "User"
def __init__(self, id):
PhabObject.__init__(self, id)
def update(self, phid, realName):
self.phid = phid
self.realName = realName
class PhabHunk:
def __init__(self, rest_api_hunk):
self.oldOffset = int(rest_api_hunk["oldOffset"])
self.oldLength = int(rest_api_hunk["oldLength"])
# self.actual_lines_changed_offset will contain the offsets of the
# lines that were changed in this hunk.
self.actual_lines_changed_offset = []
offset = self.oldOffset
inHunk = False
hunkStart = -1
contextLines = 3
for line in rest_api_hunk["corpus"].split("\n"):
if line.startswith("+"):
# line is a new line that got introduced in this patch.
# Do not record it as a changed line.
if inHunk is False:
inHunk = True
hunkStart = max(self.oldOffset, offset - contextLines)
continue
if line.startswith("-"):
# line was changed or removed from the older version of the
# code. Record it as a changed line.
if inHunk is False:
inHunk = True
hunkStart = max(self.oldOffset, offset - contextLines)
offset += 1
continue
# line is a context line.
if inHunk is True:
inHunk = False
hunkEnd = offset + contextLines
self.actual_lines_changed_offset.append((hunkStart, hunkEnd))
offset += 1
if inHunk is True:
hunkEnd = offset + contextLines
self.actual_lines_changed_offset.append((hunkStart, hunkEnd))
# The above algorithm could result in adjacent or overlapping ranges
# being recorded into self.actual_lines_changed_offset.
# Merge the adjacent and overlapping ranges in there:
t = []
lastRange = None
for start, end in self.actual_lines_changed_offset + \
[(sys.maxsize, sys.maxsize)]:
if lastRange is None:
lastRange = (start, end)
else:
if lastRange[1] >= start:
lastRange = (lastRange[0], end)
else:
t.append(lastRange)
lastRange = (start, end)
self.actual_lines_changed_offset = t
class PhabChange:
def __init__(self, rest_api_change):
self.oldPath = rest_api_change["oldPath"]
self.hunks = [PhabHunk(h) for h in rest_api_change["hunks"]]
class PhabDiff(PhabObject):
OBJECT_KIND = "Diff"
def __init__(self, id):
PhabObject.__init__(self, id)
def update(self, rest_api_results):
self.revisionID = rest_api_results["revisionID"]
self.dateModified = int(rest_api_results["dateModified"])
self.dateCreated = int(rest_api_results["dateCreated"])
self.changes = [PhabChange(c) for c in rest_api_results["changes"]]
class ReviewsCache(PhabObjectCache):
def __init__(self):
PhabObjectCache.__init__(self, PhabReview)
class UsersCache(PhabObjectCache):
def __init__(self):
PhabObjectCache.__init__(self, PhabUser)
reviews_cache = ReviewsCache()
users_cache = UsersCache()
def init_phab_connection():
phab = Phabricator()
phab.update_interfaces()
return phab
def update_cached_info(phab, cache, phab_query, order, record_results,
max_nr_entries_per_fetch, max_nr_days_to_cache):
q = phab
LIMIT = max_nr_entries_per_fetch
for query_step in phab_query:
q = getattr(q, query_step)
results = q(order=order, limit=LIMIT)
most_recent_info, oldest_info = record_results(cache, results, phab)
oldest_info_to_fetch = datetime.fromtimestamp(most_recent_info) - \
timedelta(days=max_nr_days_to_cache)
most_recent_info_overall = most_recent_info
cache.write_cache_to_disk()
after = results["cursor"]["after"]
print("after: {0!r}".format(after))
print("most_recent_info: {0}".format(
datetime.fromtimestamp(most_recent_info)))
while (after is not None
and datetime.fromtimestamp(oldest_info) > oldest_info_to_fetch):
need_more_older_data = \
(cache.oldest_info is None or
datetime.fromtimestamp(cache.oldest_info) > oldest_info_to_fetch)
print(("need_more_older_data={0} cache.oldest_info={1} " +
"oldest_info_to_fetch={2}").format(
need_more_older_data,
datetime.fromtimestamp(cache.oldest_info)
if cache.oldest_info is not None else None,
oldest_info_to_fetch))
need_more_newer_data = \
(cache.most_recent_info is None or
cache.most_recent_info < most_recent_info)
print(("need_more_newer_data={0} cache.most_recent_info={1} " +
"most_recent_info={2}")
.format(need_more_newer_data, cache.most_recent_info,
most_recent_info))
if not need_more_older_data and not need_more_newer_data:
break
results = q(order=order, after=after, limit=LIMIT)
most_recent_info, oldest_info = record_results(cache, results, phab)
after = results["cursor"]["after"]
print("after: {0!r}".format(after))
print("most_recent_info: {0}".format(
datetime.fromtimestamp(most_recent_info)))
cache.write_cache_to_disk()
cache.most_recent_info = most_recent_info_overall
if after is None:
# We did fetch all records. Mark the cache to contain all info since
# the start of time.
oldest_info = 0
cache.oldest_info = oldest_info
cache.write_cache_to_disk()
def record_reviews(cache, reviews, phab):
most_recent_info = None
oldest_info = None
for reviewInfo in reviews["data"]:
if reviewInfo["type"] != "DREV":
continue
id = reviewInfo["id"]
# phid = reviewInfo["phid"]
dateModified = int(reviewInfo["fields"]["dateModified"])
dateCreated = int(reviewInfo["fields"]["dateCreated"])
title = reviewInfo["fields"]["title"]
author = reviewInfo["fields"]["authorPHID"]
phabReview = cache.get(id)
if "dateModified" not in phabReview.__dict__ or \
dateModified > phabReview.dateModified:
diff_results = phab.differential.querydiffs(revisionIDs=[id])
diff_ids = sorted(diff_results.keys())
phabDiffs = []
for diff_id in diff_ids:
diffInfo = diff_results[diff_id]
d = PhabDiff(diff_id)
d.update(diffInfo)
phabDiffs.append(d)
phabReview.update(title, dateCreated, dateModified, author)
phabReview.setPhabDiffs(phabDiffs)
print("Updated D{0} modified on {1} ({2} diffs)".format(
id, datetime.fromtimestamp(dateModified), len(phabDiffs)))
if most_recent_info is None:
most_recent_info = dateModified
elif most_recent_info < dateModified:
most_recent_info = dateModified
if oldest_info is None:
oldest_info = dateModified
elif oldest_info > dateModified:
oldest_info = dateModified
return most_recent_info, oldest_info
def record_users(cache, users, phab):
most_recent_info = None
oldest_info = None
for info in users["data"]:
if info["type"] != "USER":
continue
id = info["id"]
phid = info["phid"]
dateModified = int(info["fields"]["dateModified"])
# dateCreated = int(info["fields"]["dateCreated"])
realName = info["fields"]["realName"]
phabUser = cache.get(id)
phabUser.update(phid, realName)
if most_recent_info is None:
most_recent_info = dateModified
elif most_recent_info < dateModified:
most_recent_info = dateModified
if oldest_info is None:
oldest_info = dateModified
elif oldest_info > dateModified:
oldest_info = dateModified
return most_recent_info, oldest_info
PHABCACHESINFO = ((reviews_cache, ("differential", "revision", "search"),
"updated", record_reviews, 5, 7),
(users_cache, ("user", "search"), "newest", record_users,
100, 1000))
def load_cache():
for cache, phab_query, order, record_results, _, _ in PHABCACHESINFO:
cache.populate_cache_from_disk()
print("Loaded {0} nr entries: {1}".format(
cache.get_name(), len(cache.get_ids_in_cache())))
print("Loaded {0} has most recent info: {1}".format(
cache.get_name(),
datetime.fromtimestamp(cache.most_recent_info)
if cache.most_recent_info is not None else None))
def update_cache(phab):
load_cache()
for cache, phab_query, order, record_results, max_nr_entries_per_fetch, \
max_nr_days_to_cache in PHABCACHESINFO:
update_cached_info(phab, cache, phab_query, order, record_results,
max_nr_entries_per_fetch, max_nr_days_to_cache)
ids_in_cache = cache.get_ids_in_cache()
print("{0} objects in {1}".format(len(ids_in_cache), cache.get_name()))
cache.write_cache_to_disk()
def get_most_recent_reviews(days):
newest_reviews = sorted(
reviews_cache.get_objects(), key=lambda r: -r.dateModified)
if len(newest_reviews) == 0:
return newest_reviews
most_recent_review_time = \
datetime.fromtimestamp(newest_reviews[0].dateModified)
cut_off_date = most_recent_review_time - timedelta(days=days)
result = []
for review in newest_reviews:
if datetime.fromtimestamp(review.dateModified) < cut_off_date:
return result
result.append(review)
return result
# All of the above code is about fetching data from Phabricator and caching it
# on local disk. The below code contains the actual "business logic" for this
# script.
_userphid2realname = None
def get_real_name_from_author(user_phid):
global _userphid2realname
if _userphid2realname is None:
_userphid2realname = {}
for user in users_cache.get_objects():
_userphid2realname[user.phid] = user.realName
return _userphid2realname.get(user_phid, "unknown")
def print_most_recent_reviews(phab, days, filter_reviewers):
msgs = []
def add_msg(msg):
msgs.append(msg)
print(msg)
newest_reviews = get_most_recent_reviews(days)
add_msg(u"These are the reviews that look interesting to be reviewed. " +
u"The report below has 2 sections. The first " +
u"section is organized per review; the second section is organized "
+ u"per potential reviewer.\n")
oldest_review = newest_reviews[-1] if len(newest_reviews) > 0 else None
oldest_datetime = \
datetime.fromtimestamp(oldest_review.dateModified) \
if oldest_review else None
add_msg((u"The report below is based on analyzing the reviews that got " +
u"touched in the past {0} days (since {1}). " +
u"The script found {2} such reviews.\n").format(
days, oldest_datetime, len(newest_reviews)))
reviewer2reviews_and_scores = {}
for i, review in enumerate(newest_reviews):
matched_reviewers = find_reviewers_for_review(review)
matched_reviewers = filter_reviewers(matched_reviewers)
if len(matched_reviewers) == 0:
continue
add_msg((u"{0:>3}. https://reviews.llvm.org/D{1} by {2}\n {3}\n" +
u" Last updated on {4}").format(
i, review.id,
get_real_name_from_author(review.author), review.title,
datetime.fromtimestamp(review.dateModified)))
for reviewer, scores in matched_reviewers:
add_msg(u" potential reviewer {0}, score {1}".format(
reviewer,
"(" + "/".join(["{0:.1f}%".format(s) for s in scores]) + ")"))
if reviewer not in reviewer2reviews_and_scores:
reviewer2reviews_and_scores[reviewer] = []
reviewer2reviews_and_scores[reviewer].append((review, scores))
# Print out a summary per reviewer.
for reviewer in sorted(reviewer2reviews_and_scores.keys()):
reviews_and_scores = reviewer2reviews_and_scores[reviewer]
reviews_and_scores.sort(key=lambda rs: rs[1], reverse=True)
add_msg(u"\n\nSUMMARY FOR {0} (found {1} reviews):".format(
reviewer, len(reviews_and_scores)))
for review, scores in reviews_and_scores:
add_msg(u"[{0}] https://reviews.llvm.org/D{1} '{2}' by {3}".format(
"/".join(["{0:.1f}%".format(s) for s in scores]), review.id,
review.title, get_real_name_from_author(review.author)))
return "\n".join(msgs)
def get_git_cmd_output(cmd):
output = None
try:
logging.debug(cmd)
output = subprocess.check_output(
cmd, shell=True, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
logging.debug(str(e))
if output is None:
return None
return output.decode("utf-8", errors='ignore')
reAuthorMail = re.compile("^author-mail <([^>]*)>.*$")
def parse_blame_output_line_porcelain(blame_output):
email2nr_occurences = {}
if blame_output is None:
return email2nr_occurences
for line in blame_output.split('\n'):
m = reAuthorMail.match(line)
if m:
author_email_address = m.group(1)
if author_email_address not in email2nr_occurences:
email2nr_occurences[author_email_address] = 1
else:
email2nr_occurences[author_email_address] += 1
return email2nr_occurences
def find_reviewers_for_diff_heuristic(diff):
# Heuristic 1: assume good reviewers are the ones that touched the same
# lines before as this patch is touching.
# Heuristic 2: assume good reviewers are the ones that touched the same
# files before as this patch is touching.
reviewers2nr_lines_touched = {}
reviewers2nr_files_touched = {}
# Assume last revision before diff was modified is the revision the diff
# applies to.
git_repo = "git_repos/llvm"
cmd = 'git -C {0} rev-list -n 1 --before="{1}" master'.format(
git_repo,
datetime.fromtimestamp(
diff.dateModified).strftime("%Y-%m-%d %H:%M:%s"))
base_revision = get_git_cmd_output(cmd).strip()
logging.debug("Base revision={0}".format(base_revision))
for change in diff.changes:
path = change.oldPath
# Compute heuristic 1: look at context of patch lines.
for hunk in change.hunks:
for start_line, end_line in hunk.actual_lines_changed_offset:
# Collect git blame results for authors in those ranges.
cmd = ("git -C {0} blame --encoding=utf-8 --date iso -f -e " +
"-w --line-porcelain -L {1},{2} {3} -- {4}").format(
git_repo, start_line, end_line, base_revision, path)
blame_output = get_git_cmd_output(cmd)
for reviewer, nr_occurences in \
parse_blame_output_line_porcelain(blame_output).items():
if reviewer not in reviewers2nr_lines_touched:
reviewers2nr_lines_touched[reviewer] = 0
reviewers2nr_lines_touched[reviewer] += nr_occurences
# Compute heuristic 2: don't look at context, just at files touched.
# Collect git blame results for authors in those ranges.
cmd = ("git -C {0} blame --encoding=utf-8 --date iso -f -e -w " +
"--line-porcelain {1} -- {2}").format(git_repo, base_revision,
path)
blame_output = get_git_cmd_output(cmd)
for reviewer, nr_occurences in parse_blame_output_line_porcelain(
blame_output).items():
if reviewer not in reviewers2nr_files_touched:
reviewers2nr_files_touched[reviewer] = 0
reviewers2nr_files_touched[reviewer] += 1
# Compute "match scores"
total_nr_lines = sum(reviewers2nr_lines_touched.values())
total_nr_files = len(diff.changes)
reviewers_matchscores = \
[(reviewer,
(reviewers2nr_lines_touched.get(reviewer, 0)*100.0/total_nr_lines
if total_nr_lines != 0 else 0,
reviewers2nr_files_touched[reviewer]*100.0/total_nr_files
if total_nr_files != 0 else 0))
for reviewer, nr_lines
in reviewers2nr_files_touched.items()]
reviewers_matchscores.sort(key=lambda i: i[1], reverse=True)
return reviewers_matchscores
def find_reviewers_for_review(review):
# Process the newest diff first.
diffs = sorted(
review.phabDiffs, key=lambda d: d.dateModified, reverse=True)
if len(diffs) == 0:
return
diff = diffs[0]
matched_reviewers = find_reviewers_for_diff_heuristic(diff)
# Show progress, as this is a slow operation:
sys.stdout.write('.')
sys.stdout.flush()
logging.debug(u"matched_reviewers: {0}".format(matched_reviewers))
return matched_reviewers
def update_git_repos():
git_repos_directory = "git_repos"
for name, url in GIT_REPO_METADATA:
dirname = os.path.join(git_repos_directory, name)
if not os.path.exists(dirname):
cmd = "git clone {0} {1}".format(url, dirname)
output = get_git_cmd_output(cmd)
cmd = "git -C {0} pull --rebase".format(dirname)
output = get_git_cmd_output(cmd)
def send_emails(email_addresses, sender, msg):
s = smtplib.SMTP()
s.connect()
for email_address in email_addresses:
email_msg = email.mime.multipart.MIMEMultipart()
email_msg['From'] = sender
email_msg['To'] = email_address
email_msg['Subject'] = 'LLVM patches you may be able to review.'
email_msg.attach(email.mime.text.MIMEText(msg.encode('utf-8'), 'plain'))
# python 3.x: s.send_message(email_msg)
s.sendmail(email_msg['From'], email_msg['To'], email_msg.as_string())
s.quit()
def filter_reviewers_to_report_for(people_to_look_for):
# The below is just an example filter, to only report potential reviews
# to do for the people that will receive the report email.
return lambda potential_reviewers: [r for r in potential_reviewers
if r[0] in people_to_look_for]
def main():
parser = argparse.ArgumentParser(
description='Match open reviews to potential reviewers.')
parser.add_argument(
'--no-update-cache',
dest='update_cache',
action='store_false',
default=True,
help='Do not update cached Phabricator objects')
parser.add_argument(
'--email-report',
dest='email_report',
nargs='*',
default="",
help="A email addresses to send the report to.")
parser.add_argument(
'--sender',
dest='sender',
default="",
help="The email address to use in 'From' on messages emailed out.")
parser.add_argument(
'--email-addresses',
dest='email_addresses',
nargs='*',
help="The email addresses (as known by LLVM git) of " +
"the people to look for reviews for.")
parser.add_argument('--verbose', '-v', action='count')
args = parser.parse_args()
if args.verbose >= 1:
logging.basicConfig(level=logging.DEBUG)
people_to_look_for = [e.decode('utf-8') for e in args.email_addresses]
logging.debug("Will look for reviews that following contributors could " +
"review: {}".format(people_to_look_for))
logging.debug("Will email a report to: {}".format(args.email_report))
phab = init_phab_connection()
if args.update_cache:
update_cache(phab)
load_cache()
update_git_repos()
msg = print_most_recent_reviews(
phab,
days=1,
filter_reviewers=filter_reviewers_to_report_for(people_to_look_for))
if args.email_report != []:
send_emails(args.email_report, args.sender, msg)
if __name__ == "__main__":
main()