Skip to content

Commit f6b7885

Browse files
committed
Add a meritocracy
1 parent a68b7aa commit f6b7885

File tree

7 files changed

+158
-117
lines changed

7 files changed

+158
-117
lines changed

cron/poll_pull_requests.py

Lines changed: 106 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import sys
66
from os.path import join, abspath, dirname
7+
from operators import itemgetter
78

89
import settings
910
import github_api as gh
@@ -25,99 +26,114 @@ def poll_pull_requests():
2526
# get all ready prs (disregarding of the voting window)
2627
prs = gh.prs.get_ready_prs(api, settings.URN, 0)
2728

28-
needs_update = False
29-
for pr in prs:
30-
pr_num = pr["number"]
31-
__log.info("processing PR #%d", pr_num)
32-
33-
# gather all current votes
34-
votes = gh.voting.get_votes(api, settings.URN, pr)
35-
36-
# is our PR approved or rejected?
37-
vote_total, variance = gh.voting.get_vote_sum(api, votes)
38-
threshold = gh.voting.get_approval_threshold(api, settings.URN)
39-
is_approved = vote_total >= threshold
40-
41-
# the PR is mitigated or the threshold is not reached ?
42-
if variance >= threshold or not is_approved:
43-
voting_window = gh.voting.get_extended_voting_window(api, settings.URN)
44-
45-
# is our PR in voting window?
46-
in_window = gh.prs.is_pr_in_voting_window(pr, voting_window)
47-
48-
if is_approved:
49-
__log.info("PR %d status: will be approved", pr_num)
50-
51-
gh.prs.post_accepted_status(
52-
api, settings.URN, pr, voting_window, votes, vote_total, threshold)
53-
54-
if in_window:
55-
__log.info("PR %d approved for merging!", pr_num)
56-
57-
try:
58-
sha = gh.prs.merge_pr(api, settings.URN, pr, votes, vote_total,
59-
threshold)
60-
# some error, like suddenly there's a merge conflict, or some
61-
# new commits were introduced between finding this ready pr and
62-
# merging it
63-
except gh.exceptions.CouldntMerge:
64-
__log.info("couldn't merge PR %d for some reason, skipping",
65-
pr_num)
66-
gh.prs.label_pr(api, settings.URN, pr_num, ["can't merge"])
67-
continue
68-
69-
gh.comments.leave_accept_comment(
70-
api, settings.URN, pr_num, sha, votes, vote_total, threshold)
71-
gh.prs.label_pr(api, settings.URN, pr_num, ["accepted"])
72-
73-
# chaosbot rewards merge owners with a follow
74-
pr_owner = pr["user"]["login"]
75-
gh.users.follow_user(api, pr_owner)
76-
77-
needs_update = True
78-
79-
else:
80-
__log.info("PR %d status: will be rejected", pr_num)
81-
82-
if in_window:
83-
gh.prs.post_rejected_status(
84-
api, settings.URN, pr, voting_window, votes, vote_total, threshold)
85-
__log.info("PR %d rejected, closing", pr_num)
86-
gh.comments.leave_reject_comment(
87-
api, settings.URN, pr_num, votes, vote_total, threshold)
88-
gh.prs.label_pr(api, settings.URN, pr_num, ["rejected"])
89-
gh.prs.close_pr(api, settings.URN, pr)
90-
elif vote_total < 0:
91-
gh.prs.post_rejected_status(
92-
api, settings.URN, pr, voting_window, votes, vote_total, threshold)
29+
# This sets up a voting record, with each user having a count of votes
30+
# that they have cast.
31+
try:
32+
fp = open('server/voters.json', 'x')
33+
fp.close()
34+
except:
35+
# file already exists, which is what we want
36+
pass
37+
38+
with open('server/voters.json', 'r+') as fp:
39+
total_votes = {}
40+
fs = fp.read()
41+
if fs:
42+
# if the voting record exists, read it in
43+
total_votes = json.loads(fs)
44+
# then prepare for overwriting
45+
fp.seek(0)
46+
fp.truncate()
47+
48+
top_contributors = sorted(gh.repos.get_contributors(api, settings.URN),
49+
key=itemgetter('total'))
50+
top_contributors = top_contributors[:settings.MERITOCRACY_TOP_CONTRIBUTORS]
51+
top_contributors = [user["login"].lower() for user in top_contributors]
52+
top_voters = sorted(total_votes.iteritems(), key=lambda k, v: (v, k))
53+
top_voters = [user[0].lower() for user in top_voters[:settings.MERITOCRACY_TOP_VOTERS]]
54+
meritocracy = top_voters + top_contributors
55+
56+
needs_update = False
57+
for pr in prs:
58+
pr_num = pr["number"]
59+
__log.info("processing PR #%d", pr_num)
60+
61+
# gather all current votes
62+
votes, meritocracy_satisfied = gh.voting.get_votes(api, settings.URN, pr, meritocracy)
63+
64+
# is our PR approved or rejected?
65+
vote_total, variance = gh.voting.get_vote_sum(api, votes)
66+
threshold = gh.voting.get_approval_threshold(api, settings.URN)
67+
is_approved = vote_total >= threshold and meritocracy_satisfied
68+
69+
# the PR is mitigated or the threshold is not reached ?
70+
if variance >= threshold or not is_approved:
71+
voting_window = gh.voting.get_extended_voting_window(api, settings.URN)
72+
73+
# is our PR in voting window?
74+
in_window = gh.prs.is_pr_in_voting_window(pr, voting_window)
75+
76+
if is_approved:
77+
__log.info("PR %d status: will be approved", pr_num)
78+
79+
gh.prs.post_accepted_status(
80+
api, settings.URN, pr, voting_window, votes, vote_total,
81+
threshold, meritocracy_satisfied)
82+
83+
if in_window:
84+
__log.info("PR %d approved for merging!", pr_num)
85+
86+
try:
87+
sha = gh.prs.merge_pr(api, settings.URN, pr, votes, vote_total,
88+
threshold, meritocracy_satisfied)
89+
# some error, like suddenly there's a merge conflict, or some
90+
# new commits were introduced between finding this ready pr and
91+
# merging it
92+
except gh.exceptions.CouldntMerge:
93+
__log.info("couldn't merge PR %d for some reason, skipping",
94+
pr_num)
95+
gh.prs.label_pr(api, settings.URN, pr_num, ["can't merge"])
96+
continue
97+
98+
gh.comments.leave_accept_comment(
99+
api, settings.URN, pr_num, sha, votes, vote_total,
100+
threshold, meritocracy_satisfied)
101+
gh.prs.label_pr(api, settings.URN, pr_num, ["accepted"])
102+
103+
# chaosbot rewards merge owners with a follow
104+
pr_owner = pr["user"]["login"]
105+
gh.users.follow_user(api, pr_owner)
106+
107+
needs_update = True
108+
93109
else:
94-
gh.prs.post_pending_status(
95-
api, settings.URN, pr, voting_window, votes, vote_total, threshold)
96-
97-
# This sets up a voting record, with each user having a count of votes
98-
# that they have cast.
99-
try:
100-
fp = open('server/voters.json', 'x')
101-
fp.close()
102-
except:
103-
# file already exists, which is what we want
104-
pass
105-
106-
with open('server/voters.json', 'r+') as fp:
107-
old_votes = {}
108-
fs = fp.read()
109-
if fs:
110-
# if the voting record exists, read it in
111-
old_votes = json.loads(fs)
112-
# then prepare for overwriting
113-
fp.seek(0)
114-
fp.truncate()
110+
__log.info("PR %d status: will be rejected", pr_num)
111+
112+
if in_window:
113+
gh.prs.post_rejected_status(
114+
api, settings.URN, pr, voting_window, votes, vote_total,
115+
threshold, meritocracy_satisfied)
116+
__log.info("PR %d rejected, closing", pr_num)
117+
gh.comments.leave_reject_comment(
118+
api, settings.URN, pr_num, votes, vote_total, threshold,
119+
meritocracy_satisfied)
120+
gh.prs.label_pr(api, settings.URN, pr_num, ["rejected"])
121+
gh.prs.close_pr(api, settings.URN, pr)
122+
elif vote_total < 0:
123+
gh.prs.post_rejected_status(
124+
api, settings.URN, pr, voting_window, votes, vote_total,
125+
threshold, meritocracy_satisfied)
126+
else:
127+
gh.prs.post_pending_status(
128+
api, settings.URN, pr, voting_window, votes, vote_total,
129+
threshold, meritocracy_satisfied)
130+
115131
for user in votes:
116-
if user in old_votes:
117-
old_votes[user] += 1
132+
if user in total_votes:
133+
total_votes[user] += 1
118134
else:
119-
old_votes[user] = 1
120-
json.dump(old_votes, fp)
135+
total_votes[user] = 1
136+
json.dump(total_votes, fp)
121137

122138
# flush all buffers because we might restart, which could cause a crash
123139
os.fsync(fp)

github_api/comments.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ def get_reactions_for_comment(api, urn, comment_id):
3333
yield reaction
3434

3535

36-
def leave_reject_comment(api, urn, pr, votes, total, threshold):
37-
votes_summary = prs.formatted_votes_summary(votes, total, threshold)
36+
def leave_reject_comment(api, urn, pr, votes, total, threshold, meritocracy_satisfied):
37+
votes_summary = prs.formatted_votes_summary(votes, total, threshold, meritocracy_satisfied)
3838
body = """
3939
:no_good: PR rejected {summary}.
4040
@@ -43,8 +43,8 @@ def leave_reject_comment(api, urn, pr, votes, total, threshold):
4343
return leave_comment(api, urn, pr, body)
4444

4545

46-
def leave_accept_comment(api, urn, pr, sha, votes, total, threshold):
47-
votes_summary = prs.formatted_votes_summary(votes, total, threshold)
46+
def leave_accept_comment(api, urn, pr, sha, votes, total, threshold, meritocracy_satisfied):
47+
votes_summary = prs.formatted_votes_summary(votes, total, threshold, meritocracy_satisfied)
4848
body = """
4949
:ok_woman: PR passed {summary}.
5050

github_api/prs.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
TRAVIS_CI_CONTEXT = "continuous-integration/travis-ci"
1313

1414

15-
def merge_pr(api, urn, pr, votes, total, threshold):
15+
def merge_pr(api, urn, pr, votes, total, threshold, meritocracy_satisfied):
1616
""" merge a pull request, if possible, and use a nice detailed merge commit
1717
message """
1818

@@ -26,7 +26,7 @@ def merge_pr(api, urn, pr, votes, total, threshold):
2626
if record:
2727
record = "Vote record:\n" + record
2828

29-
votes_summary = formatted_votes_summary(votes, total, threshold)
29+
votes_summary = formatted_votes_summary(votes, total, threshold, meritocracy_satisfied)
3030

3131
pr_url = "https://github.com/{urn}/pull/{pr}".format(urn=urn, pr=pr_num)
3232

@@ -79,21 +79,28 @@ def merge_pr(api, urn, pr, votes, total, threshold):
7979
raise
8080

8181

82-
def formatted_votes_summary(votes, total, threshold):
82+
def formatted_votes_summary(votes, total, threshold, meritocracy_satisfied):
8383
vfor = sum(v for v in votes.values() if v > 0)
8484
vagainst = abs(sum(v for v in votes.values() if v < 0))
85+
meritocracy_str = "a" if meritocracy_satisfied else "**NO**"
8586

86-
return ("with a vote of {vfor} for and {vagainst} against, with a weighted total \
87-
of {total:.1f} and a threshold of {threshold:.1f}"
88-
.strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold))
87+
return """
88+
with a vote of {vfor} for and {vagainst} against, a weighted total of {total:.1f} \
89+
and a threshold of {threshold:.1f}, and {meritocracy} current meritocracy review
90+
""".strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold,
91+
meritocracy=meritocracy_str)
8992

9093

91-
def formatted_votes_short_summary(votes, total, threshold):
94+
def formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied):
9295
vfor = sum(v for v in votes.values() if v > 0)
9396
vagainst = abs(sum(v for v in votes.values() if v < 0))
97+
meritocracy_str = "✓" if meritocracy_satisfied else "✗"
9498

95-
return "vote: {vfor}-{vagainst}, weighted total: {total:.1f}, threshold: {threshold:.1f}" \
96-
.strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold)
99+
return """
100+
vote: {vfor}-{vagainst}, weighted total: {total:.1f}, threshold: {threshold:.1f}, \
101+
meritocracy: {meritocracy}
102+
""".strip().format(vfor=vfor, vagainst=vagainst, total=total, threshold=threshold,
103+
meritocracy=meritocracy_str)
97104

98105

99106
def label_pr(api, urn, pr_num, labels):
@@ -260,34 +267,37 @@ def get_reactions_for_pr(api, urn, pr):
260267
yield reaction
261268

262269

263-
def post_accepted_status(api, urn, pr, voting_window, votes, total, threshold):
270+
def post_accepted_status(api, urn, pr, voting_window, votes, total, threshold,
271+
meritocracy_satisfied):
264272
sha = pr["head"]["sha"]
265273

266274
remaining_seconds = voting_window_remaining_seconds(pr, voting_window)
267275
remaining_human = misc.seconds_to_human(remaining_seconds)
268-
votes_summary = formatted_votes_short_summary(votes, total, threshold)
276+
votes_summary = formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied)
269277

270278
post_status(api, urn, sha, "success",
271279
"remaining: {time}, {summary}".format(time=remaining_human, summary=votes_summary))
272280

273281

274-
def post_rejected_status(api, urn, pr, voting_window, votes, total, threshold):
282+
def post_rejected_status(api, urn, pr, voting_window, votes, total, threshold,
283+
meritocracy_satisfied):
275284
sha = pr["head"]["sha"]
276285

277286
remaining_seconds = voting_window_remaining_seconds(pr, voting_window)
278287
remaining_human = misc.seconds_to_human(remaining_seconds)
279-
votes_summary = formatted_votes_short_summary(votes, total, threshold)
288+
votes_summary = formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied)
280289

281290
post_status(api, urn, sha, "failure",
282291
"remaining: {time}, {summary}".format(time=remaining_human, summary=votes_summary))
283292

284293

285-
def post_pending_status(api, urn, pr, voting_window, votes, total, threshold):
294+
def post_pending_status(api, urn, pr, voting_window, votes, total, threshold,
295+
meritocracy_satisfied):
286296
sha = pr["head"]["sha"]
287297

288298
remaining_seconds = voting_window_remaining_seconds(pr, voting_window)
289299
remaining_human = misc.seconds_to_human(remaining_seconds)
290-
votes_summary = formatted_votes_short_summary(votes, total, threshold)
300+
votes_summary = formatted_votes_short_summary(votes, total, threshold, meritocracy_satisfied)
291301

292302
post_status(api, urn, sha, "pending",
293303
"remaining: {time}, {summary}".format(time=remaining_human, summary=votes_summary))

github_api/repos.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ def get_creation_date(api, urn):
3030
""" returns the creation date of the repo """
3131
data = api("get", get_path(urn))
3232
return arrow.get(data["created_at"])
33+
34+
35+
def get_contributors(api, urn):
36+
""" returns the list of contributors to the repo """
37+
return api("get", "/repos/{urn}/stats/contributors".format(urn=urn))

github_api/voting.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
import settings
1111

1212

13-
def get_votes(api, urn, pr):
13+
def get_votes(api, urn, pr, meritocracy):
1414
""" return a mapping of username => -1 or 1 for the votes on the current
1515
state of a pr. we consider comments and reactions, but only from users who
1616
are not the owner of the pr. we also make sure that the voting
1717
comments/reactions come *after* the last update to the pr, so that someone
1818
can't acquire approval votes, then change the pr """
1919

2020
votes = {}
21+
meritocracy_satisfied = False
2122
pr_owner = pr["user"]["login"]
2223
pr_num = pr["number"]
2324

@@ -26,15 +27,16 @@ def get_votes(api, urn, pr):
2627
votes[voter] = vote
2728

2829
# get all the pr-review-based votes
29-
for vote_owner, vote in get_pr_review_votes(api, urn, pr_num):
30-
if vote and vote_owner != pr_owner:
31-
votes[vote_owner] = vote
30+
for vote_owner, is_current, vote in get_pr_review_votes(api, urn, pr):
31+
if (vote > 0 and is_current and vote_owner != pr_owner
32+
and vote_owner.lower() in meritocracy):
33+
meritocracy_satisfied = True
3234

3335
# by virtue of creating the PR, the owner defaults to a vote of 1
3436
if votes.get(pr_owner) != -1:
3537
votes[pr_owner] = 1
3638

37-
return votes
39+
return votes, meritocracy_satisfied
3840

3941

4042
def get_pr_comment_votes_all(api, urn, pr_num):
@@ -94,15 +96,16 @@ def get_comment_reaction_votes(api, urn, comment_id):
9496
yield reaction_owner, vote
9597

9698

97-
def get_pr_review_votes(api, urn, pr_num):
99+
def get_pr_review_votes(api, urn, pr):
98100
""" votes made through
99101
https://help.github.com/articles/about-pull-request-reviews/ """
100-
for review in prs.get_pr_reviews(api, urn, pr_num):
102+
for review in prs.get_pr_reviews(api, urn, pr["number"]):
101103
state = review["state"]
102104
if state in ("APPROVED", "DISMISSED"):
103105
user = review["user"]["login"]
106+
is_current = review["commit_id"] == pr["head"]["sha"]
104107
vote = parse_review_for_vote(state)
105-
yield user, vote
108+
yield user, is_current, vote
106109

107110

108111
def get_vote_weight(api, username):

0 commit comments

Comments
 (0)