llvm/llvm/utils/Reviewing/find_interesting_reviews.py

#!/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-monorepo", "https://github.com/llvm/llvm-project"),)

# 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.encode("utf-8"))

    newest_reviews = get_most_recent_reviews(days)
    add_msg(
        "These are the reviews that look interesting to be reviewed. "
        + "The report below has 2 sections. The first "
        + "section is organized per review; the second section is organized "
        + "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(
        (
            "The report below is based on analyzing the reviews that got "
            + "touched in the past {0} days (since {1}). "
            + "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(
            (
                "{0:>3}. https://reviews.llvm.org/D{1} by {2}\n     {3}\n"
                + "     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(
                "    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(
            "\n\nSUMMARY FOR {0} (found {1} reviews):".format(
                reviewer, len(reviews_and_scores)
            )
        )
        for review, scores in reviews_and_scores:
            add_msg(
                "[{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_lines):
    email2nr_occurences = {}
    if blame_output_lines is None:
        return email2nr_occurences
    for line in blame_output_lines:
        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


class BlameOutputCache:
    def __init__(self):
        self.cache = {}

    def _populate_cache_for(self, cache_key):
        assert cache_key not in self.cache
        git_repo, base_revision, path = cache_key
        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)
        self.cache[cache_key] = (
            blame_output.split("\n") if blame_output is not None else None
        )
        # FIXME: the blame cache could probably be made more effective still if
        # instead of storing the requested base_revision in the cache, the last
        # revision before the base revision this file/path got changed in gets
        # stored. That way multiple project revisions for which this specific
        # file/patch hasn't changed would get cache hits (instead of misses in
        # the current implementation).

    def get_blame_output_for(
        self, git_repo, base_revision, path, start_line=-1, end_line=-1
    ):
        cache_key = (git_repo, base_revision, path)
        if cache_key not in self.cache:
            self._populate_cache_for(cache_key)
        assert cache_key in self.cache
        all_blame_lines = self.cache[cache_key]
        if all_blame_lines is None:
            return None
        if start_line == -1 and end_line == -1:
            return all_blame_lines
        assert start_line >= 0
        assert end_line >= 0
        assert end_line <= len(all_blame_lines)
        assert start_line <= len(all_blame_lines)
        assert start_line <= end_line
        return all_blame_lines[start_line:end_line]

    def get_parsed_git_blame_for(
        self, git_repo, base_revision, path, start_line=-1, end_line=-1
    ):
        return parse_blame_output_line_porcelain(
            self.get_blame_output_for(
                git_repo, base_revision, path, start_line, end_line
            )
        )


blameOutputCache = BlameOutputCache()


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.
    assert len(GIT_REPO_METADATA) == 1
    git_repo = os.path.join("git_repos", GIT_REPO_METADATA[0][0])
    cmd = 'git -C {0} rev-list -n 1 --before="{1}" main'.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.
                for (
                    reviewer,
                    nr_occurences,
                ) in blameOutputCache.get_parsed_git_blame_for(
                    git_repo, base_revision, path, start_line, end_line
                ).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.
        for reviewer, nr_occurences in blameOutputCache.get_parsed_git_blame_for(
            git_repo, base_revision, path
        ).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("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()