Skip to content

Commit

Permalink
merging PR #377: Add a meritocracy
Browse files Browse the repository at this point in the history
#377: Add a meritocracy

Description:
I decided to not go with a clickbait title this time 😄

Look at #361 for more details, but **I tried to give the meritocracy minimal power. Democracy still decides the outcome >90% of the time.** This is just to prevent against sneaky take over attempts.

Basis of how it works:

> At least one positive non-author meritocracy review on the most recent commit of a PR is required for that PR to be accepted.

Changes from #361:

- Autogenerate meritocracy list (currently top 10 contributors + top 10 voters)
- Don't count reviews as votes (so members of the meritocracy can allow a PR without voting for it)

:ok_woman: PR passed with a vote of 30 for and 1 against, with a weighted total             of 27.5 and a threshold of 6.5.

Vote record:
@CheezBallzPi: 1
@CodingPower472: 1
@DasSkelett: 1
@Evanito: 1
@FrankVanVuuren: 1
@Leigende: 1
@MUCHZER: 1
@N00d13: 1
@Neofox: 1
@PlasmaPower: 1
@Redmega: 1
@Smittyvb: 1
@SylvainThrd: 1
@andrewda: 1
@cthulhuely: 1
@davidadas: 1
@davidak: 1
@eukaryote31: 1
@flibustier: 1
@hongaar: 1
@ike709: 1
@jmerle: 1
@mark-i-m: 1
@md678685: 1
@mgardne8: -1
@phil-r: 1
@rhengles: 1
@rudehn: 1
@tarunbatra: 1
@viktorsec: 1
@vivekzhere: 1
  • Loading branch information
PlasmaPower authored and chaosbot committed May 30, 2017
1 parent d249f06 commit 3e6d99c
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 119 deletions.
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

0 comments on commit 3e6d99c

Please sign in to comment.