Skip to content

Commit 20fabfc

Browse files
committed
Add a meritocracy
1 parent a68b7aa commit 20fabfc

File tree

7 files changed

+157
-117
lines changed

7 files changed

+157
-117
lines changed

cron/poll_pull_requests.py

Lines changed: 105 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -25,99 +25,114 @@ def poll_pull_requests():
2525
# get all ready prs (disregarding of the voting window)
2626
prs = gh.prs.get_ready_prs(api, settings.URN, 0)
2727

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

122137
# flush all buffers because we might restart, which could cause a crash
123138
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):

patch.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ def decorate(fn, dec):
3030
decorate(github_api.voting.get_vote_weight, api_memoize("1d"))
3131
decorate(github_api.repos.get_num_watchers, api_memoize("10m"))
3232
decorate(github_api.prs.get_is_mergeable, api_memoize("2m"))
33+
decorate(github_api.repos.get_contributors, api_memoize("1d"))

0 commit comments

Comments
 (0)