Skip to content

Commit

Permalink
Add a meritocracy
Browse files Browse the repository at this point in the history
  • Loading branch information
PlasmaPower committed May 29, 2017
1 parent a68b7aa commit 402256f
Show file tree
Hide file tree
Showing 7 changed files with 157 additions and 117 deletions.
195 changes: 105 additions & 90 deletions cron/poll_pull_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions github_api/comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand All @@ -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}.
Expand Down
40 changes: 25 additions & 15 deletions github_api/prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 """

Expand All @@ -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)

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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))
Expand Down
5 changes: 5 additions & 0 deletions github_api/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
19 changes: 11 additions & 8 deletions github_api/voting.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
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
comments/reactions come *after* the last update to the pr, so that someone
can't acquire approval votes, then change the pr """

votes = {}
meritocracy_satisfied = False
pr_owner = pr["user"]["login"]
pr_num = pr["number"]

Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Loading

0 comments on commit 402256f

Please sign in to comment.