diff --git a/cron/poll_pull_requests.py b/cron/poll_pull_requests.py index 142c49a9..c7208f20 100644 --- a/cron/poll_pull_requests.py +++ b/cron/poll_pull_requests.py @@ -25,99 +25,114 @@ def poll_pull_requests(): # get all ready prs (disregarding of the voting window) prs = gh.prs.get_ready_prs(api, settings.URN, 0) - needs_update = False - for pr in prs: - pr_num = pr["number"] - __log.info("processing PR #%d", pr_num) - - # gather all current votes - votes = gh.voting.get_votes(api, settings.URN, pr) - - # is our PR approved or rejected? - vote_total, variance = gh.voting.get_vote_sum(api, votes) - threshold = gh.voting.get_approval_threshold(api, settings.URN) - is_approved = vote_total >= threshold - - # the PR is mitigated or the threshold is not reached ? - if variance >= threshold or not is_approved: - voting_window = gh.voting.get_extended_voting_window(api, settings.URN) - - # is our PR in voting window? - in_window = gh.prs.is_pr_in_voting_window(pr, voting_window) - - if is_approved: - __log.info("PR %d status: will be approved", pr_num) - - gh.prs.post_accepted_status( - api, settings.URN, pr, voting_window, votes, vote_total, threshold) - - if in_window: - __log.info("PR %d approved for merging!", pr_num) - - try: - sha = gh.prs.merge_pr(api, settings.URN, pr, votes, vote_total, - threshold) - # some error, like suddenly there's a merge conflict, or some - # new commits were introduced between finding this ready pr and - # merging it - except gh.exceptions.CouldntMerge: - __log.info("couldn't merge PR %d for some reason, skipping", - pr_num) - gh.prs.label_pr(api, settings.URN, pr_num, ["can't merge"]) - continue - - gh.comments.leave_accept_comment( - api, settings.URN, pr_num, sha, votes, vote_total, threshold) - gh.prs.label_pr(api, settings.URN, pr_num, ["accepted"]) - - # chaosbot rewards merge owners with a follow - pr_owner = pr["user"]["login"] - gh.users.follow_user(api, pr_owner) - - needs_update = True - - else: - __log.info("PR %d status: will be rejected", pr_num) - - if in_window: - gh.prs.post_rejected_status( - api, settings.URN, pr, voting_window, votes, vote_total, threshold) - __log.info("PR %d rejected, closing", pr_num) - gh.comments.leave_reject_comment( - api, settings.URN, pr_num, votes, vote_total, threshold) - gh.prs.label_pr(api, settings.URN, pr_num, ["rejected"]) - gh.prs.close_pr(api, settings.URN, pr) - elif vote_total < 0: - gh.prs.post_rejected_status( - api, settings.URN, pr, voting_window, votes, vote_total, threshold) + # This sets up a voting record, with each user having a count of votes + # that they have cast. + try: + fp = open('server/voters.json', 'x') + fp.close() + except: + # file already exists, which is what we want + pass + + with open('server/voters.json', 'r+') as fp: + total_votes = {} + fs = fp.read() + if fs: + # if the voting record exists, read it in + total_votes = json.loads(fs) + # then prepare for overwriting + fp.seek(0) + fp.truncate() + + top_contributors = sorted(gh.repos.get_contributors(api, settings.URN), + key=lambda user: user["total"]) + top_contributors = top_contributors[:settings.MERITOCRACY_TOP_CONTRIBUTORS] + top_contributors = [user["login"].lower() for user in top_contributors] + top_voters = sorted(total_votes.iteritems(), key=lambda k, v: (v, k)) + top_voters = [user[0].lower() for user in top_voters[:settings.MERITOCRACY_TOP_VOTERS]] + meritocracy = top_voters + top_contributors + + needs_update = False + for pr in prs: + pr_num = pr["number"] + __log.info("processing PR #%d", pr_num) + + # gather all current votes + votes, meritocracy_satisfied = gh.voting.get_votes(api, settings.URN, pr, meritocracy) + + # is our PR approved or rejected? + vote_total, variance = gh.voting.get_vote_sum(api, votes) + threshold = gh.voting.get_approval_threshold(api, settings.URN) + is_approved = vote_total >= threshold and meritocracy_satisfied + + # the PR is mitigated or the threshold is not reached ? + if variance >= threshold or not is_approved: + voting_window = gh.voting.get_extended_voting_window(api, settings.URN) + + # is our PR in voting window? + in_window = gh.prs.is_pr_in_voting_window(pr, voting_window) + + if is_approved: + __log.info("PR %d status: will be approved", pr_num) + + gh.prs.post_accepted_status( + api, settings.URN, pr, voting_window, votes, vote_total, + threshold, meritocracy_satisfied) + + if in_window: + __log.info("PR %d approved for merging!", pr_num) + + try: + sha = gh.prs.merge_pr(api, settings.URN, pr, votes, vote_total, + threshold, meritocracy_satisfied) + # some error, like suddenly there's a merge conflict, or some + # new commits were introduced between finding this ready pr and + # merging it + except gh.exceptions.CouldntMerge: + __log.info("couldn't merge PR %d for some reason, skipping", + pr_num) + gh.prs.label_pr(api, settings.URN, pr_num, ["can't merge"]) + continue + + gh.comments.leave_accept_comment( + api, settings.URN, pr_num, sha, votes, vote_total, + threshold, meritocracy_satisfied) + gh.prs.label_pr(api, settings.URN, pr_num, ["accepted"]) + + # chaosbot rewards merge owners with a follow + pr_owner = pr["user"]["login"] + gh.users.follow_user(api, pr_owner) + + needs_update = True + else: - gh.prs.post_pending_status( - api, settings.URN, pr, voting_window, votes, vote_total, threshold) - - # This sets up a voting record, with each user having a count of votes - # that they have cast. - try: - fp = open('server/voters.json', 'x') - fp.close() - except: - # file already exists, which is what we want - pass - - with open('server/voters.json', 'r+') as fp: - old_votes = {} - fs = fp.read() - if fs: - # if the voting record exists, read it in - old_votes = json.loads(fs) - # then prepare for overwriting - fp.seek(0) - fp.truncate() + __log.info("PR %d status: will be rejected", pr_num) + + if in_window: + gh.prs.post_rejected_status( + api, settings.URN, pr, voting_window, votes, vote_total, + threshold, meritocracy_satisfied) + __log.info("PR %d rejected, closing", pr_num) + gh.comments.leave_reject_comment( + api, settings.URN, pr_num, votes, vote_total, threshold, + meritocracy_satisfied) + gh.prs.label_pr(api, settings.URN, pr_num, ["rejected"]) + gh.prs.close_pr(api, settings.URN, pr) + elif vote_total < 0: + gh.prs.post_rejected_status( + api, settings.URN, pr, voting_window, votes, vote_total, + threshold, meritocracy_satisfied) + else: + gh.prs.post_pending_status( + api, settings.URN, pr, voting_window, votes, vote_total, + threshold, meritocracy_satisfied) + for user in votes: - if user in old_votes: - old_votes[user] += 1 + if user in total_votes: + total_votes[user] += 1 else: - old_votes[user] = 1 - json.dump(old_votes, fp) + total_votes[user] = 1 + json.dump(total_votes, fp) # flush all buffers because we might restart, which could cause a crash os.fsync(fp) diff --git a/github_api/comments.py b/github_api/comments.py index 57a4c998..cfd8eecb 100644 --- a/github_api/comments.py +++ b/github_api/comments.py @@ -33,8 +33,8 @@ def get_reactions_for_comment(api, urn, comment_id): yield reaction -def leave_reject_comment(api, urn, pr, votes, total, threshold): - votes_summary = prs.formatted_votes_summary(votes, total, threshold) +def leave_reject_comment(api, urn, pr, votes, total, threshold, meritocracy_satisfied): + votes_summary = prs.formatted_votes_summary(votes, total, threshold, meritocracy_satisfied) body = """ :no_good: PR rejected {summary}. @@ -43,8 +43,8 @@ def leave_reject_comment(api, urn, pr, votes, total, threshold): return leave_comment(api, urn, pr, body) -def leave_accept_comment(api, urn, pr, sha, votes, total, threshold): - votes_summary = prs.formatted_votes_summary(votes, total, threshold) +def leave_accept_comment(api, urn, pr, sha, votes, total, threshold, meritocracy_satisfied): + votes_summary = prs.formatted_votes_summary(votes, total, threshold, meritocracy_satisfied) body = """ :ok_woman: PR passed {summary}. diff --git a/github_api/prs.py b/github_api/prs.py index a7c41e16..a6b6206e 100644 --- a/github_api/prs.py +++ b/github_api/prs.py @@ -12,7 +12,7 @@ TRAVIS_CI_CONTEXT = "continuous-integration/travis-ci" -def merge_pr(api, urn, pr, votes, total, threshold): +def merge_pr(api, urn, pr, votes, total, threshold, meritocracy_satisfied): """ merge a pull request, if possible, and use a nice detailed merge commit message """ @@ -26,7 +26,7 @@ def merge_pr(api, urn, pr, votes, total, threshold): if record: record = "Vote record:\n" + record - votes_summary = formatted_votes_summary(votes, total, threshold) + votes_summary = formatted_votes_summary(votes, total, threshold, meritocracy_satisfied) pr_url = "https://github.com/{urn}/pull/{pr}".format(urn=urn, pr=pr_num) @@ -79,21 +79,28 @@ def merge_pr(api, urn, pr, votes, total, threshold): raise -def formatted_votes_summary(votes, total, threshold): +def formatted_votes_summary(votes, total, threshold, meritocracy_satisfied): vfor = sum(v for v in votes.values() if v > 0) vagainst = abs(sum(v for v in votes.values() if v < 0)) + meritocracy_str = "a" if meritocracy_satisfied else "**NO**" - return ("with a vote of {vfor} for and {vagainst} against, with a weighted total \ - of {total:.1f} and a threshold of {threshold:.1f}" - .strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold)) + return """ +with a vote of {vfor} for and {vagainst} against, a weighted total of {total:.1f} \ +and a threshold of {threshold:.1f}, and {meritocracy} current meritocracy review + """.strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold, + meritocracy=meritocracy_str) -def formatted_votes_short_summary(votes, total, threshold): +def formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied): vfor = sum(v for v in votes.values() if v > 0) vagainst = abs(sum(v for v in votes.values() if v < 0)) + meritocracy_str = "✓" if meritocracy_satisfied else "✗" - return "vote: {vfor}-{vagainst}, weighted total: {total:.1f}, threshold: {threshold:.1f}" \ - .strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold) + return """ +vote: {vfor}-{vagainst}, weighted total: {total:.1f}, threshold: {threshold:.1f}, \ +meritocracy: {meritocracy} + """.strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold, + meritocracy=meritocracy_str) def label_pr(api, urn, pr_num, labels): @@ -260,34 +267,37 @@ def get_reactions_for_pr(api, urn, pr): yield reaction -def post_accepted_status(api, urn, pr, voting_window, votes, total, threshold): +def post_accepted_status(api, urn, pr, voting_window, votes, total, threshold, + meritocracy_satisfied): sha = pr["head"]["sha"] remaining_seconds = voting_window_remaining_seconds(pr, voting_window) remaining_human = misc.seconds_to_human(remaining_seconds) - votes_summary = formatted_votes_short_summary(votes, total, threshold) + votes_summary = formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied) post_status(api, urn, sha, "success", "remaining: {time}, {summary}".format(time=remaining_human, summary=votes_summary)) -def post_rejected_status(api, urn, pr, voting_window, votes, total, threshold): +def post_rejected_status(api, urn, pr, voting_window, votes, total, threshold, + meritocracy_satisfied): sha = pr["head"]["sha"] remaining_seconds = voting_window_remaining_seconds(pr, voting_window) remaining_human = misc.seconds_to_human(remaining_seconds) - votes_summary = formatted_votes_short_summary(votes, total, threshold) + votes_summary = formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied) post_status(api, urn, sha, "failure", "remaining: {time}, {summary}".format(time=remaining_human, summary=votes_summary)) -def post_pending_status(api, urn, pr, voting_window, votes, total, threshold): +def post_pending_status(api, urn, pr, voting_window, votes, total, threshold, + meritocracy_satisfied): sha = pr["head"]["sha"] remaining_seconds = voting_window_remaining_seconds(pr, voting_window) remaining_human = misc.seconds_to_human(remaining_seconds) - votes_summary = formatted_votes_short_summary(votes, total, threshold) + votes_summary = formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied) post_status(api, urn, sha, "pending", "remaining: {time}, {summary}".format(time=remaining_human, summary=votes_summary)) diff --git a/github_api/repos.py b/github_api/repos.py index a2a2a623..88847aa1 100644 --- a/github_api/repos.py +++ b/github_api/repos.py @@ -30,3 +30,8 @@ def get_creation_date(api, urn): """ returns the creation date of the repo """ data = api("get", get_path(urn)) return arrow.get(data["created_at"]) + + +def get_contributors(api, urn): + """ returns the list of contributors to the repo """ + return api("get", "/repos/{urn}/stats/contributors".format(urn=urn)) diff --git a/github_api/voting.py b/github_api/voting.py index 5bc89d90..9e30f31e 100644 --- a/github_api/voting.py +++ b/github_api/voting.py @@ -10,7 +10,7 @@ import settings -def get_votes(api, urn, pr): +def get_votes(api, urn, pr, meritocracy): """ return a mapping of username => -1 or 1 for the votes on the current state of a pr. we consider comments and reactions, but only from users who are not the owner of the pr. we also make sure that the voting @@ -18,6 +18,7 @@ def get_votes(api, urn, pr): can't acquire approval votes, then change the pr """ votes = {} + meritocracy_satisfied = False pr_owner = pr["user"]["login"] pr_num = pr["number"] @@ -26,15 +27,16 @@ def get_votes(api, urn, pr): votes[voter] = vote # get all the pr-review-based votes - for vote_owner, vote in get_pr_review_votes(api, urn, pr_num): - if vote and vote_owner != pr_owner: - votes[vote_owner] = vote + for vote_owner, is_current, vote in get_pr_review_votes(api, urn, pr): + if (vote > 0 and is_current and vote_owner != pr_owner + and vote_owner.lower() in meritocracy): + meritocracy_satisfied = True # by virtue of creating the PR, the owner defaults to a vote of 1 if votes.get(pr_owner) != -1: votes[pr_owner] = 1 - return votes + return votes, meritocracy_satisfied def get_pr_comment_votes_all(api, urn, pr_num): @@ -94,15 +96,16 @@ def get_comment_reaction_votes(api, urn, comment_id): yield reaction_owner, vote -def get_pr_review_votes(api, urn, pr_num): +def get_pr_review_votes(api, urn, pr): """ votes made through https://help.github.com/articles/about-pull-request-reviews/ """ - for review in prs.get_pr_reviews(api, urn, pr_num): + for review in prs.get_pr_reviews(api, urn, pr["number"]): state = review["state"] if state in ("APPROVED", "DISMISSED"): user = review["user"]["login"] + is_current = review["commit_id"] == pr["head"]["sha"] vote = parse_review_for_vote(state) - yield user, vote + yield user, is_current, vote def get_vote_weight(api, username): diff --git a/patch.py b/patch.py index e1698b34..2bfc1fa0 100644 --- a/patch.py +++ b/patch.py @@ -30,3 +30,4 @@ def decorate(fn, dec): decorate(github_api.voting.get_vote_weight, api_memoize("1d")) decorate(github_api.repos.get_num_watchers, api_memoize("10m")) decorate(github_api.prs.get_is_mergeable, api_memoize("2m")) +decorate(github_api.prs.get_contributors, api_memoize("1d")) diff --git a/settings.py b/settings.py index 7978f796..5d67b5c8 100644 --- a/settings.py +++ b/settings.py @@ -93,3 +93,9 @@ # The location of error log -- also found in the supervisor conf. # If you are going to change it, change it there too. CHAOSBOT_STDERR_LOG = "/var/log/supervisor/chaos-stderr.log" + +# The top n contributors will be allowed in the meritocracy +MERITOCRACY_TOP_CONTRIBUTORS = 10 + +# The top n voters will be allowed in the meritocracy +MERITOCRACY_TOP_VOTERS = 10