1#!/usr/bin/env python 2 3from __future__ import print_function 4 5import argparse 6import email.mime.multipart 7import email.mime.text 8import logging 9import os.path 10import pickle 11import re 12import smtplib 13import subprocess 14import sys 15from datetime import datetime, timedelta 16from phabricator import Phabricator 17 18# Setting up a virtualenv to run this script can be done by running the 19# following commands: 20# $ virtualenv venv 21# $ . ./venv/bin/activate 22# $ pip install Phabricator 23 24GIT_REPO_METADATA = (("llvm-monorepo", "https://github.com/llvm/llvm-project"), 25 ) 26 27# The below PhabXXX classes represent objects as modelled by Phabricator. 28# The classes can be serialized to disk, to try and make sure that we don't 29# needlessly have to re-fetch lots of data from Phabricator, as that would 30# make this script unusably slow. 31 32 33class PhabObject: 34 OBJECT_KIND = None 35 36 def __init__(self, id): 37 self.id = id 38 39 40class PhabObjectCache: 41 def __init__(self, PhabObjectClass): 42 self.PhabObjectClass = PhabObjectClass 43 self.most_recent_info = None 44 self.oldest_info = None 45 self.id2PhabObjects = {} 46 47 def get_name(self): 48 return self.PhabObjectClass.OBJECT_KIND + "sCache" 49 50 def get(self, id): 51 if id not in self.id2PhabObjects: 52 self.id2PhabObjects[id] = self.PhabObjectClass(id) 53 return self.id2PhabObjects[id] 54 55 def get_ids_in_cache(self): 56 return list(self.id2PhabObjects.keys()) 57 58 def get_objects(self): 59 return list(self.id2PhabObjects.values()) 60 61 DEFAULT_DIRECTORY = "PhabObjectCache" 62 63 def _get_pickle_name(self, directory): 64 file_name = "Phab" + self.PhabObjectClass.OBJECT_KIND + "s.pickle" 65 return os.path.join(directory, file_name) 66 67 def populate_cache_from_disk(self, directory=DEFAULT_DIRECTORY): 68 """ 69 FIXME: consider if serializing to JSON would bring interoperability 70 advantages over serializing to pickle. 71 """ 72 try: 73 f = open(self._get_pickle_name(directory), "rb") 74 except IOError as err: 75 print("Could not find cache. Error message: {0}. Continuing..." 76 .format(err)) 77 else: 78 with f: 79 try: 80 d = pickle.load(f) 81 self.__dict__.update(d) 82 except EOFError as err: 83 print("Cache seems to be corrupt. " + 84 "Not using cache. Error message: {0}".format(err)) 85 86 def write_cache_to_disk(self, directory=DEFAULT_DIRECTORY): 87 if not os.path.exists(directory): 88 os.makedirs(directory) 89 with open(self._get_pickle_name(directory), "wb") as f: 90 pickle.dump(self.__dict__, f) 91 print("wrote cache to disk, most_recent_info= {0}".format( 92 datetime.fromtimestamp(self.most_recent_info) 93 if self.most_recent_info is not None else None)) 94 95 96class PhabReview(PhabObject): 97 OBJECT_KIND = "Review" 98 99 def __init__(self, id): 100 PhabObject.__init__(self, id) 101 102 def update(self, title, dateCreated, dateModified, author): 103 self.title = title 104 self.dateCreated = dateCreated 105 self.dateModified = dateModified 106 self.author = author 107 108 def setPhabDiffs(self, phabDiffs): 109 self.phabDiffs = phabDiffs 110 111 112class PhabUser(PhabObject): 113 OBJECT_KIND = "User" 114 115 def __init__(self, id): 116 PhabObject.__init__(self, id) 117 118 def update(self, phid, realName): 119 self.phid = phid 120 self.realName = realName 121 122 123class PhabHunk: 124 def __init__(self, rest_api_hunk): 125 self.oldOffset = int(rest_api_hunk["oldOffset"]) 126 self.oldLength = int(rest_api_hunk["oldLength"]) 127 # self.actual_lines_changed_offset will contain the offsets of the 128 # lines that were changed in this hunk. 129 self.actual_lines_changed_offset = [] 130 offset = self.oldOffset 131 inHunk = False 132 hunkStart = -1 133 contextLines = 3 134 for line in rest_api_hunk["corpus"].split("\n"): 135 if line.startswith("+"): 136 # line is a new line that got introduced in this patch. 137 # Do not record it as a changed line. 138 if inHunk is False: 139 inHunk = True 140 hunkStart = max(self.oldOffset, offset - contextLines) 141 continue 142 if line.startswith("-"): 143 # line was changed or removed from the older version of the 144 # code. Record it as a changed line. 145 if inHunk is False: 146 inHunk = True 147 hunkStart = max(self.oldOffset, offset - contextLines) 148 offset += 1 149 continue 150 # line is a context line. 151 if inHunk is True: 152 inHunk = False 153 hunkEnd = offset + contextLines 154 self.actual_lines_changed_offset.append((hunkStart, hunkEnd)) 155 offset += 1 156 if inHunk is True: 157 hunkEnd = offset + contextLines 158 self.actual_lines_changed_offset.append((hunkStart, hunkEnd)) 159 160 # The above algorithm could result in adjacent or overlapping ranges 161 # being recorded into self.actual_lines_changed_offset. 162 # Merge the adjacent and overlapping ranges in there: 163 t = [] 164 lastRange = None 165 for start, end in self.actual_lines_changed_offset + \ 166 [(sys.maxsize, sys.maxsize)]: 167 if lastRange is None: 168 lastRange = (start, end) 169 else: 170 if lastRange[1] >= start: 171 lastRange = (lastRange[0], end) 172 else: 173 t.append(lastRange) 174 lastRange = (start, end) 175 self.actual_lines_changed_offset = t 176 177 178class PhabChange: 179 def __init__(self, rest_api_change): 180 self.oldPath = rest_api_change["oldPath"] 181 self.hunks = [PhabHunk(h) for h in rest_api_change["hunks"]] 182 183 184class PhabDiff(PhabObject): 185 OBJECT_KIND = "Diff" 186 187 def __init__(self, id): 188 PhabObject.__init__(self, id) 189 190 def update(self, rest_api_results): 191 self.revisionID = rest_api_results["revisionID"] 192 self.dateModified = int(rest_api_results["dateModified"]) 193 self.dateCreated = int(rest_api_results["dateCreated"]) 194 self.changes = [PhabChange(c) for c in rest_api_results["changes"]] 195 196 197class ReviewsCache(PhabObjectCache): 198 def __init__(self): 199 PhabObjectCache.__init__(self, PhabReview) 200 201 202class UsersCache(PhabObjectCache): 203 def __init__(self): 204 PhabObjectCache.__init__(self, PhabUser) 205 206 207reviews_cache = ReviewsCache() 208users_cache = UsersCache() 209 210 211def init_phab_connection(): 212 phab = Phabricator() 213 phab.update_interfaces() 214 return phab 215 216 217def update_cached_info(phab, cache, phab_query, order, record_results, 218 max_nr_entries_per_fetch, max_nr_days_to_cache): 219 q = phab 220 LIMIT = max_nr_entries_per_fetch 221 for query_step in phab_query: 222 q = getattr(q, query_step) 223 results = q(order=order, limit=LIMIT) 224 most_recent_info, oldest_info = record_results(cache, results, phab) 225 oldest_info_to_fetch = datetime.fromtimestamp(most_recent_info) - \ 226 timedelta(days=max_nr_days_to_cache) 227 most_recent_info_overall = most_recent_info 228 cache.write_cache_to_disk() 229 after = results["cursor"]["after"] 230 print("after: {0!r}".format(after)) 231 print("most_recent_info: {0}".format( 232 datetime.fromtimestamp(most_recent_info))) 233 while (after is not None 234 and datetime.fromtimestamp(oldest_info) > oldest_info_to_fetch): 235 need_more_older_data = \ 236 (cache.oldest_info is None or 237 datetime.fromtimestamp(cache.oldest_info) > oldest_info_to_fetch) 238 print(("need_more_older_data={0} cache.oldest_info={1} " + 239 "oldest_info_to_fetch={2}").format( 240 need_more_older_data, 241 datetime.fromtimestamp(cache.oldest_info) 242 if cache.oldest_info is not None else None, 243 oldest_info_to_fetch)) 244 need_more_newer_data = \ 245 (cache.most_recent_info is None or 246 cache.most_recent_info < most_recent_info) 247 print(("need_more_newer_data={0} cache.most_recent_info={1} " + 248 "most_recent_info={2}") 249 .format(need_more_newer_data, cache.most_recent_info, 250 most_recent_info)) 251 if not need_more_older_data and not need_more_newer_data: 252 break 253 results = q(order=order, after=after, limit=LIMIT) 254 most_recent_info, oldest_info = record_results(cache, results, phab) 255 after = results["cursor"]["after"] 256 print("after: {0!r}".format(after)) 257 print("most_recent_info: {0}".format( 258 datetime.fromtimestamp(most_recent_info))) 259 cache.write_cache_to_disk() 260 cache.most_recent_info = most_recent_info_overall 261 if after is None: 262 # We did fetch all records. Mark the cache to contain all info since 263 # the start of time. 264 oldest_info = 0 265 cache.oldest_info = oldest_info 266 cache.write_cache_to_disk() 267 268 269def record_reviews(cache, reviews, phab): 270 most_recent_info = None 271 oldest_info = None 272 for reviewInfo in reviews["data"]: 273 if reviewInfo["type"] != "DREV": 274 continue 275 id = reviewInfo["id"] 276 # phid = reviewInfo["phid"] 277 dateModified = int(reviewInfo["fields"]["dateModified"]) 278 dateCreated = int(reviewInfo["fields"]["dateCreated"]) 279 title = reviewInfo["fields"]["title"] 280 author = reviewInfo["fields"]["authorPHID"] 281 phabReview = cache.get(id) 282 if "dateModified" not in phabReview.__dict__ or \ 283 dateModified > phabReview.dateModified: 284 diff_results = phab.differential.querydiffs(revisionIDs=[id]) 285 diff_ids = sorted(diff_results.keys()) 286 phabDiffs = [] 287 for diff_id in diff_ids: 288 diffInfo = diff_results[diff_id] 289 d = PhabDiff(diff_id) 290 d.update(diffInfo) 291 phabDiffs.append(d) 292 phabReview.update(title, dateCreated, dateModified, author) 293 phabReview.setPhabDiffs(phabDiffs) 294 print("Updated D{0} modified on {1} ({2} diffs)".format( 295 id, datetime.fromtimestamp(dateModified), len(phabDiffs))) 296 297 if most_recent_info is None: 298 most_recent_info = dateModified 299 elif most_recent_info < dateModified: 300 most_recent_info = dateModified 301 302 if oldest_info is None: 303 oldest_info = dateModified 304 elif oldest_info > dateModified: 305 oldest_info = dateModified 306 return most_recent_info, oldest_info 307 308 309def record_users(cache, users, phab): 310 most_recent_info = None 311 oldest_info = None 312 for info in users["data"]: 313 if info["type"] != "USER": 314 continue 315 id = info["id"] 316 phid = info["phid"] 317 dateModified = int(info["fields"]["dateModified"]) 318 # dateCreated = int(info["fields"]["dateCreated"]) 319 realName = info["fields"]["realName"] 320 phabUser = cache.get(id) 321 phabUser.update(phid, realName) 322 if most_recent_info is None: 323 most_recent_info = dateModified 324 elif most_recent_info < dateModified: 325 most_recent_info = dateModified 326 if oldest_info is None: 327 oldest_info = dateModified 328 elif oldest_info > dateModified: 329 oldest_info = dateModified 330 return most_recent_info, oldest_info 331 332 333PHABCACHESINFO = ((reviews_cache, ("differential", "revision", "search"), 334 "updated", record_reviews, 5, 7), 335 (users_cache, ("user", "search"), "newest", record_users, 336 100, 1000)) 337 338 339def load_cache(): 340 for cache, phab_query, order, record_results, _, _ in PHABCACHESINFO: 341 cache.populate_cache_from_disk() 342 print("Loaded {0} nr entries: {1}".format( 343 cache.get_name(), len(cache.get_ids_in_cache()))) 344 print("Loaded {0} has most recent info: {1}".format( 345 cache.get_name(), 346 datetime.fromtimestamp(cache.most_recent_info) 347 if cache.most_recent_info is not None else None)) 348 349 350def update_cache(phab): 351 load_cache() 352 for cache, phab_query, order, record_results, max_nr_entries_per_fetch, \ 353 max_nr_days_to_cache in PHABCACHESINFO: 354 update_cached_info(phab, cache, phab_query, order, record_results, 355 max_nr_entries_per_fetch, max_nr_days_to_cache) 356 ids_in_cache = cache.get_ids_in_cache() 357 print("{0} objects in {1}".format(len(ids_in_cache), cache.get_name())) 358 cache.write_cache_to_disk() 359 360 361def get_most_recent_reviews(days): 362 newest_reviews = sorted( 363 reviews_cache.get_objects(), key=lambda r: -r.dateModified) 364 if len(newest_reviews) == 0: 365 return newest_reviews 366 most_recent_review_time = \ 367 datetime.fromtimestamp(newest_reviews[0].dateModified) 368 cut_off_date = most_recent_review_time - timedelta(days=days) 369 result = [] 370 for review in newest_reviews: 371 if datetime.fromtimestamp(review.dateModified) < cut_off_date: 372 return result 373 result.append(review) 374 return result 375 376 377# All of the above code is about fetching data from Phabricator and caching it 378# on local disk. The below code contains the actual "business logic" for this 379# script. 380 381_userphid2realname = None 382 383 384def get_real_name_from_author(user_phid): 385 global _userphid2realname 386 if _userphid2realname is None: 387 _userphid2realname = {} 388 for user in users_cache.get_objects(): 389 _userphid2realname[user.phid] = user.realName 390 return _userphid2realname.get(user_phid, "unknown") 391 392 393def print_most_recent_reviews(phab, days, filter_reviewers): 394 msgs = [] 395 396 def add_msg(msg): 397 msgs.append(msg) 398 print(msg.encode('utf-8')) 399 400 newest_reviews = get_most_recent_reviews(days) 401 add_msg(u"These are the reviews that look interesting to be reviewed. " + 402 u"The report below has 2 sections. The first " + 403 u"section is organized per review; the second section is organized " 404 + u"per potential reviewer.\n") 405 oldest_review = newest_reviews[-1] if len(newest_reviews) > 0 else None 406 oldest_datetime = \ 407 datetime.fromtimestamp(oldest_review.dateModified) \ 408 if oldest_review else None 409 add_msg((u"The report below is based on analyzing the reviews that got " + 410 u"touched in the past {0} days (since {1}). " + 411 u"The script found {2} such reviews.\n").format( 412 days, oldest_datetime, len(newest_reviews))) 413 reviewer2reviews_and_scores = {} 414 for i, review in enumerate(newest_reviews): 415 matched_reviewers = find_reviewers_for_review(review) 416 matched_reviewers = filter_reviewers(matched_reviewers) 417 if len(matched_reviewers) == 0: 418 continue 419 add_msg((u"{0:>3}. https://reviews.llvm.org/D{1} by {2}\n {3}\n" + 420 u" Last updated on {4}").format( 421 i, review.id, 422 get_real_name_from_author(review.author), review.title, 423 datetime.fromtimestamp(review.dateModified))) 424 for reviewer, scores in matched_reviewers: 425 add_msg(u" potential reviewer {0}, score {1}".format( 426 reviewer, 427 "(" + "/".join(["{0:.1f}%".format(s) for s in scores]) + ")")) 428 if reviewer not in reviewer2reviews_and_scores: 429 reviewer2reviews_and_scores[reviewer] = [] 430 reviewer2reviews_and_scores[reviewer].append((review, scores)) 431 432 # Print out a summary per reviewer. 433 for reviewer in sorted(reviewer2reviews_and_scores.keys()): 434 reviews_and_scores = reviewer2reviews_and_scores[reviewer] 435 reviews_and_scores.sort(key=lambda rs: rs[1], reverse=True) 436 add_msg(u"\n\nSUMMARY FOR {0} (found {1} reviews):".format( 437 reviewer, len(reviews_and_scores))) 438 for review, scores in reviews_and_scores: 439 add_msg(u"[{0}] https://reviews.llvm.org/D{1} '{2}' by {3}".format( 440 "/".join(["{0:.1f}%".format(s) for s in scores]), review.id, 441 review.title, get_real_name_from_author(review.author))) 442 return "\n".join(msgs) 443 444 445def get_git_cmd_output(cmd): 446 output = None 447 try: 448 logging.debug(cmd) 449 output = subprocess.check_output( 450 cmd, shell=True, stderr=subprocess.STDOUT) 451 except subprocess.CalledProcessError as e: 452 logging.debug(str(e)) 453 if output is None: 454 return None 455 return output.decode("utf-8", errors='ignore') 456 457 458reAuthorMail = re.compile("^author-mail <([^>]*)>.*$") 459 460 461def parse_blame_output_line_porcelain(blame_output_lines): 462 email2nr_occurences = {} 463 if blame_output_lines is None: 464 return email2nr_occurences 465 for line in blame_output_lines: 466 m = reAuthorMail.match(line) 467 if m: 468 author_email_address = m.group(1) 469 if author_email_address not in email2nr_occurences: 470 email2nr_occurences[author_email_address] = 1 471 else: 472 email2nr_occurences[author_email_address] += 1 473 return email2nr_occurences 474 475 476class BlameOutputCache: 477 def __init__(self): 478 self.cache = {} 479 480 def _populate_cache_for(self, cache_key): 481 assert cache_key not in self.cache 482 git_repo, base_revision, path = cache_key 483 cmd = ("git -C {0} blame --encoding=utf-8 --date iso -f -e -w " + 484 "--line-porcelain {1} -- {2}").format(git_repo, base_revision, 485 path) 486 blame_output = get_git_cmd_output(cmd) 487 self.cache[cache_key] = \ 488 blame_output.split('\n') if blame_output is not None else None 489 # FIXME: the blame cache could probably be made more effective still if 490 # instead of storing the requested base_revision in the cache, the last 491 # revision before the base revision this file/path got changed in gets 492 # stored. That way multiple project revisions for which this specific 493 # file/patch hasn't changed would get cache hits (instead of misses in 494 # the current implementation). 495 496 def get_blame_output_for(self, git_repo, base_revision, path, start_line=-1, 497 end_line=-1): 498 cache_key = (git_repo, base_revision, path) 499 if cache_key not in self.cache: 500 self._populate_cache_for(cache_key) 501 assert cache_key in self.cache 502 all_blame_lines = self.cache[cache_key] 503 if all_blame_lines is None: 504 return None 505 if start_line == -1 and end_line == -1: 506 return all_blame_lines 507 assert start_line >= 0 508 assert end_line >= 0 509 assert end_line <= len(all_blame_lines) 510 assert start_line <= len(all_blame_lines) 511 assert start_line <= end_line 512 return all_blame_lines[start_line:end_line] 513 514 def get_parsed_git_blame_for(self, git_repo, base_revision, path, 515 start_line=-1, end_line=-1): 516 return parse_blame_output_line_porcelain( 517 self.get_blame_output_for(git_repo, base_revision, path, start_line, 518 end_line)) 519 520 521blameOutputCache = BlameOutputCache() 522 523 524def find_reviewers_for_diff_heuristic(diff): 525 # Heuristic 1: assume good reviewers are the ones that touched the same 526 # lines before as this patch is touching. 527 # Heuristic 2: assume good reviewers are the ones that touched the same 528 # files before as this patch is touching. 529 reviewers2nr_lines_touched = {} 530 reviewers2nr_files_touched = {} 531 # Assume last revision before diff was modified is the revision the diff 532 # applies to. 533 assert len(GIT_REPO_METADATA) == 1 534 git_repo = os.path.join("git_repos", GIT_REPO_METADATA[0][0]) 535 cmd = 'git -C {0} rev-list -n 1 --before="{1}" master'.format( 536 git_repo, 537 datetime.fromtimestamp( 538 diff.dateModified).strftime("%Y-%m-%d %H:%M:%s")) 539 base_revision = get_git_cmd_output(cmd).strip() 540 logging.debug("Base revision={0}".format(base_revision)) 541 for change in diff.changes: 542 path = change.oldPath 543 # Compute heuristic 1: look at context of patch lines. 544 for hunk in change.hunks: 545 for start_line, end_line in hunk.actual_lines_changed_offset: 546 # Collect git blame results for authors in those ranges. 547 for reviewer, nr_occurences in \ 548 blameOutputCache.get_parsed_git_blame_for( 549 git_repo, base_revision, path, start_line, end_line 550 ).items(): 551 if reviewer not in reviewers2nr_lines_touched: 552 reviewers2nr_lines_touched[reviewer] = 0 553 reviewers2nr_lines_touched[reviewer] += nr_occurences 554 # Compute heuristic 2: don't look at context, just at files touched. 555 # Collect git blame results for authors in those ranges. 556 for reviewer, nr_occurences in \ 557 blameOutputCache.get_parsed_git_blame_for( 558 git_repo, base_revision, path).items(): 559 if reviewer not in reviewers2nr_files_touched: 560 reviewers2nr_files_touched[reviewer] = 0 561 reviewers2nr_files_touched[reviewer] += 1 562 563 # Compute "match scores" 564 total_nr_lines = sum(reviewers2nr_lines_touched.values()) 565 total_nr_files = len(diff.changes) 566 reviewers_matchscores = \ 567 [(reviewer, 568 (reviewers2nr_lines_touched.get(reviewer, 0)*100.0/total_nr_lines 569 if total_nr_lines != 0 else 0, 570 reviewers2nr_files_touched[reviewer]*100.0/total_nr_files 571 if total_nr_files != 0 else 0)) 572 for reviewer, nr_lines 573 in reviewers2nr_files_touched.items()] 574 reviewers_matchscores.sort(key=lambda i: i[1], reverse=True) 575 return reviewers_matchscores 576 577 578def find_reviewers_for_review(review): 579 # Process the newest diff first. 580 diffs = sorted( 581 review.phabDiffs, key=lambda d: d.dateModified, reverse=True) 582 if len(diffs) == 0: 583 return 584 diff = diffs[0] 585 matched_reviewers = find_reviewers_for_diff_heuristic(diff) 586 # Show progress, as this is a slow operation: 587 sys.stdout.write('.') 588 sys.stdout.flush() 589 logging.debug(u"matched_reviewers: {0}".format(matched_reviewers)) 590 return matched_reviewers 591 592 593def update_git_repos(): 594 git_repos_directory = "git_repos" 595 for name, url in GIT_REPO_METADATA: 596 dirname = os.path.join(git_repos_directory, name) 597 if not os.path.exists(dirname): 598 cmd = "git clone {0} {1}".format(url, dirname) 599 output = get_git_cmd_output(cmd) 600 cmd = "git -C {0} pull --rebase".format(dirname) 601 output = get_git_cmd_output(cmd) 602 603 604def send_emails(email_addresses, sender, msg): 605 s = smtplib.SMTP() 606 s.connect() 607 for email_address in email_addresses: 608 email_msg = email.mime.multipart.MIMEMultipart() 609 email_msg['From'] = sender 610 email_msg['To'] = email_address 611 email_msg['Subject'] = 'LLVM patches you may be able to review.' 612 email_msg.attach(email.mime.text.MIMEText(msg.encode('utf-8'), 'plain')) 613 # python 3.x: s.send_message(email_msg) 614 s.sendmail(email_msg['From'], email_msg['To'], email_msg.as_string()) 615 s.quit() 616 617 618def filter_reviewers_to_report_for(people_to_look_for): 619 # The below is just an example filter, to only report potential reviews 620 # to do for the people that will receive the report email. 621 return lambda potential_reviewers: [r for r in potential_reviewers 622 if r[0] in people_to_look_for] 623 624 625def main(): 626 parser = argparse.ArgumentParser( 627 description='Match open reviews to potential reviewers.') 628 parser.add_argument( 629 '--no-update-cache', 630 dest='update_cache', 631 action='store_false', 632 default=True, 633 help='Do not update cached Phabricator objects') 634 parser.add_argument( 635 '--email-report', 636 dest='email_report', 637 nargs='*', 638 default="", 639 help="A email addresses to send the report to.") 640 parser.add_argument( 641 '--sender', 642 dest='sender', 643 default="", 644 help="The email address to use in 'From' on messages emailed out.") 645 parser.add_argument( 646 '--email-addresses', 647 dest='email_addresses', 648 nargs='*', 649 help="The email addresses (as known by LLVM git) of " + 650 "the people to look for reviews for.") 651 parser.add_argument('--verbose', '-v', action='count') 652 653 args = parser.parse_args() 654 655 if args.verbose >= 1: 656 logging.basicConfig(level=logging.DEBUG) 657 658 people_to_look_for = [e.decode('utf-8') for e in args.email_addresses] 659 logging.debug("Will look for reviews that following contributors could " + 660 "review: {}".format(people_to_look_for)) 661 logging.debug("Will email a report to: {}".format(args.email_report)) 662 663 phab = init_phab_connection() 664 665 if args.update_cache: 666 update_cache(phab) 667 668 load_cache() 669 update_git_repos() 670 msg = print_most_recent_reviews( 671 phab, 672 days=1, 673 filter_reviewers=filter_reviewers_to_report_for(people_to_look_for)) 674 675 if args.email_report != []: 676 send_emails(args.email_report, args.sender, msg) 677 678 679if __name__ == "__main__": 680 main() 681