Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a meritocracy #377

Merged
merged 5 commits into from
May 30, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ Wondering how to contribute? Try implementing a feature requested [here](https:/
Votes on a PR are determined through following mechanism:
* A comment that contains an emoji signifying a vote somewhere in the body counts as a vote for
or against the PR.
* Same for reactions on the PR itself and an accept/reject [pull
request review](https://help.github.com/articles/about-pull-request-reviews/)
* Same for reactions on the PR itself
* The PR itself counts as :+1: from the owner, unless they vote otherwise.
* Voting goes on for the duration of the voting window - currently 2 or 3 hours,
depending on the local server time.
Expand Down Expand Up @@ -109,6 +108,11 @@ will manually be restarted and the death counter will be incremented.
A: Users must vote on your PR, through either a comment or reaction,
or a accept/reject pull request review. See [Voting](https://github.com/chaosbot/Chaos/blob/master/README.md#voting).

In addition, a member of the meritocracy must approve the most recent commit of the PR with a review.
A member of the meritocracy approving their own PR does not count.
The meritocracy is determined by combining the top 10 contributors and the top 10 voters.
Both of those datasets are publicly available, or you can look in chaosbot's logs to determine the current meritocracy.

#### Q: What if ChaosBot has a problem that can't be solved by a PR?
A: Please open a [project issue](https://github.com/chaosbot/Chaos/issues) and a
real live human will take a look at it.
196 changes: 106 additions & 90 deletions cron/poll_pull_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,99 +23,115 @@ def poll_pull_requests(api):
# 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"], reverse=True)
top_contributors = top_contributors[:settings.MERITOCRACY_TOP_CONTRIBUTORS]
top_contributors = set([item["author"]["login"].lower() for item in top_contributors])
top_voters = sorted(total_votes, key=total_votes.get, reverse=True)
top_voters = set([user.lower() for user in top_voters[:settings.MERITOCRACY_TOP_VOTERS]])
meritocracy = top_voters | top_contributors
__log.info("generated meritocracy: " + str(meritocracy))

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))
Loading