/
voting.py
226 lines (177 loc) · 7.57 KB
/
voting.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
import arrow
from emoji import demojize
from github_api.misc import dynamic_voting_window
from . import prs
from . import comments
from . import users
from . import repos
import settings
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"]
# get all the comment-and-reaction-based votes
for voter, vote in get_pr_comment_votes_all(api, urn, pr_num):
votes[voter] = vote
# get all the pr review reactions
# turn it into a dict to sort out duplicates (last value wins)
reviews = get_pr_review_reactions(api, urn, pr)
reviews = {user: (is_current, vote) for user, is_current, vote in reviews}
for vote_owner, (is_current, vote) in reviews.items():
if (vote > 0 and is_current and vote_owner != pr_owner
and vote_owner.lower() in meritocracy):
meritocracy_satisfied = True
break
# 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, meritocracy_satisfied
def get_pr_comment_votes_all(api, urn, pr_num):
""" yields votes via comments and votes via reactions on comments for a
given pr """
for comment in prs.get_pr_comments(api, urn, pr_num):
comment_owner = comment["user"]["login"]
vote = parse_comment_for_vote(comment["body"])
if vote:
yield comment_owner, vote
# reactions count as votes too.
# but notice we're only counting reactions on comments NEWER than the
# last pr update, and the reaction itself also has to be later than the
# latest pr update:
#
# | old reaction | new reaction
# ------------|---------------|---------------
# old comment | doesn't count | doesn't count
# new comment | not possible | counts
'''
Removed in response to issue #21
reaction_votes = get_comment_reaction_votes(api, urn,
comment["id"], since)
for reaction_owner, vote in reaction_votes:
yield reaction_owner, vote
'''
# we consider the pr itself to be the "first comment." in the web ui, it
# looks like a comment, complete with reactions, so let's treat it like a
# comment
reaction_votes = get_pr_reaction_votes(api, urn, pr_num)
for reaction_owner, vote in reaction_votes:
yield reaction_owner, vote
def get_pr_reaction_votes(api, urn, pr_num):
""" yields reaction votes to a pr-comment. very similar to getting
reactions from comments on the pr """
reactions = prs.get_reactions_for_pr(api, urn, pr_num)
for reaction in reactions:
reaction_owner = reaction["user"]["login"]
vote = parse_reaction_for_vote(reaction["content"])
if vote:
yield reaction_owner, vote
def get_comment_reaction_votes(api, urn, comment_id):
""" yields votes via reactions on comments on a pr. don't use this
directly, it is called by get_pr_comment_votes_all """
reactions = comments.get_reactions_for_comment(api, urn, comment_id)
for reaction in reactions:
reaction_owner = reaction["user"]["login"]
vote = parse_reaction_for_vote(reaction["content"])
if vote:
yield reaction_owner, vote
def get_pr_review_reactions(api, urn, pr):
""" https://help.github.com/articles/about-pull-request-reviews/ """
for review in prs.get_pr_reviews(api, urn, pr["number"]):
state = review["state"]
user = review["user"]["login"]
is_current = review["commit_id"] == pr["head"]["sha"]
vote = parse_review_for_vote(state)
if vote != 0:
yield user, is_current, vote
def get_vote_weight(api, username, contributors):
""" for a given username, determine the weight that their -1 or +1 vote
should be scaled by """
user = users.get_user(api, username)
# we don't want new spam malicious spam accounts to have an influence on the project
# if they've got a PR merged, they get a free pass
if user["login"] not in contributors:
# otherwise, check their account age
now = arrow.utcnow()
created = arrow.get(user["created_at"])
age = (now - created).total_seconds()
if age < settings.MIN_VOTER_AGE:
return 0
if username.lower() == "smittyvb":
return 0.50002250052
return 1
def get_vote_sum(api, votes, contributors):
""" for a vote mapping of username => -1 or 1, compute the weighted vote
total and variance(measure of controversy)"""
total = 0
positive = 0
negative = 0
for user, vote in votes.items():
weight = get_vote_weight(api, user, contributors)
weighted_vote = weight * vote
total += weighted_vote
if weighted_vote > 0:
positive += weighted_vote
elif weighted_vote < 0:
negative -= weighted_vote
variance = min(positive, negative)
return total, variance
def get_approval_threshold(api, urn):
""" the weighted vote total that must be reached for a PR to be approved
and merged """
num_watchers = repos.get_num_watchers(api, urn)
threshold = max(1, settings.MIN_VOTE_WATCHERS * num_watchers)
return threshold
def parse_review_for_vote(state):
if state == "APPROVED":
return 1
elif state == "CHANGES_REQUESTED":
return -1
else:
return 0
def parse_reaction_for_vote(body):
""" turns a comment reaction into a vote, if possible """
return parse_emojis_for_vote(":{emoji}:".format(emoji=body))
def parse_comment_for_vote(body):
""" turns a comment into a vote, if possible """
return parse_emojis_for_vote(demojize(body))
def parse_emojis_for_vote(body):
""" searches text for matching emojis """
for positive_emoji in prepare_emojis_list('positive'):
if positive_emoji in body:
return 1
for negative_emoji in prepare_emojis_list('negative'):
if negative_emoji in body:
return -1
return 0
def prepare_emojis_list(type):
fname = "data/emojis.{type}".format(type=type)
with open(fname) as f:
content = f.readlines()
content = [x.strip() for x in content]
return list(filter(None, content))
def friendly_voting_record(votes):
""" returns a sorted list (a string list, not datatype list) of voters and
their raw (unweighted) vote. this is used in merge commit messages """
voters = sorted(votes.items())
record = "\n".join("@%s: %d" % (user, vote) for user, vote in voters)
return record
def get_initial_voting_window():
""" Returns the current voting window for new PRs in seconds. """
return settings.DEFAULT_VOTE_WINDOW * 60 * 60
def get_extended_voting_window(api, urn):
""" returns the extending voting window for PRs mitigated,
based on the difference between repo creation and now """
now = arrow.utcnow()
# delta between now and the repo creation date
delta = now - repos.get_creation_date(api, urn)
days = delta.days
minimum_window = settings.DEFAULT_VOTE_WINDOW
maximum_window = settings.EXTENDED_VOTE_WINDOW
seconds = dynamic_voting_window(days, minimum_window, maximum_window) * 60 * 60
return seconds