forked from botwillacceptanything/botwillacceptanything
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathvoting.js
404 lines (340 loc) · 12.9 KB
/
voting.js
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
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
var EventEmitter = require('events').EventEmitter;
var request = require('request');
// voting settings
var PERIOD = 30; // time for the vote to be open, in minutes
var MIN_VOTES = 5; // minimum number of votes for a decision to be made
var REQUIRED_SUPERMAJORITY = 0.65;
var MINUTE = 60 * 1000; // (one minute in ms)
var decideVoteResult = function(yeas, nays) {
// vote passes if yeas > nays
return (yeas / (yeas + nays)) > REQUIRED_SUPERMAJORITY;
}
var voteStartedComment = '#### :ballotbox: Voting procedure reminder:\n' +
'To cast a vote, post a comment containing `:+1:` (:+1:), or `:-1:` (:-1:).\n' +
'Remember, you **must star this repo for your vote to count.**\n\n' +
'All comments within this discussion are searched for votes, regardless of the time of posting.\n' +
'You can cast as many votes as you want, but only the last one will be counted.\n' +
'(You may consider editing your comment instead of adding a new one.)\n' +
'Comments containing both up- and dow-votes are disregarded.\n' +
'Pull request is not counted as a vote, so vote for (or against) your own PRs!\n' +
'A decision will be made after this PR has been open for **'+PERIOD+'** ' +
'minutes, and at least **'+MIN_VOTES+'** votes have been made.\n\n' +
'A supermajority of ' + (REQUIRED_SUPERMAJORITY * 100) + '% is required for the vote to pass.\n\n' +
'*NOTE: the PR will be closed if any new commits are added after:* ';
var modifiedWarning = '#### :warning: This PR has been modified and is now being closed.\n\n' +
'To prevent people from sneaking in changes after votes have been made, pull ' +
'requests can\'t be committed on after they have been opened. Feel free to ' +
'open a new PR for the proposed changes to start another round of voting.';
var couldntMergeWarning = '#### :warning: Error: This PR could not be merged\n\n' +
'The changes in this PR conflict with other changes, so we couldn\'t automatically merge it. ' +
'You can fix the conflicts and submit the changes in a new PR to start the voting process again.'
var kitten = '';
var votePassComment = ':+1: The vote passed! This PR will now be merged into master.';
var voteFailComment = ':-1: The vote failed. This PR will now be closed. Why don\'t you try some ideas that don\'t suck next time, you incredible git?'
var voteEndComment = function(pass, yea, nay, nonStarGazers) {
var total = yea + nay;
var yeaPercent = percent(yea / total);
var nayPercent = percent(nay / total);
var resp = '#### ' + (pass ? (kitten + votePassComment) : voteFailComment) + '\n\n' +
'----\n' +
'**Tallies:**\n' +
':+1:: ' + yea + ' (' + yeaPercent + '%) \n' +
':-1:: ' + nay + ' (' + nayPercent + '%)';
if (nonStarGazers.length > 0) {
resp += "\n\n";
resp += "These users aren't stargazers, so their votes were not counted: \n";
nonStarGazers.forEach(function(user) {
resp += " * @" + user + "\n";
});
}
return resp;
}
function percent(n) { return Math.floor(n * 1000) / 10; }
function noop(err) {
if(err) console.error(err);
}
// Export this module as a function
// (so we can pass it the config and Github client)
module.exports = function(config, gh) {
// the value returned by this module
var voting = new EventEmitter();
// an index of PRs we have posted a 'vote started' comment on
var started = {};
// get a random kitten to be used by this instance of the bot
var options = {
hostname: 'thecatapi.com',
port: 80,
path: '/api/images/get?format=html',
method: 'POST'
};
var req = require('http').request(options, function(res) {
res.setEncoding('utf8');
res.on('data', function(chunk) {
kitten += chunk;
});
});
req.write('');
req.end();
// handles an open PR
function handlePR(pr) {
// if there is no 'vote started' comment, post one
if(!started[pr.number]) {
postVoteStarted(pr);
}
checkPrVoteLength(pr);
// TODO: instead of closing PRs that get changed, just post a warning that
// votes have been reset, and only count votes that happen after the
// last change
assertNotModified(pr, function() {
// if the age of the PR is >= the voting period, count the votes
var age = Date.now() - new Date(pr.created_at).getTime();
if(age / MINUTE >= PERIOD) {
countVotes(pr);
}
});
}
function checkPrVoteLength(pr) {
// Grab the contents of voting.js and reject the pull request if they are too long.
var voteFileName = 'https://raw.githubusercontent.com/' + pr.head.repo.full_name + '/' + pr.merge_commit_sha + '/voting.js'
, currentFileName = 'https://raw.githubusercontent.com/botwillacceptanything/botwillacceptanything/master/voting.js';
var prVoteFile, currentVoteFile;
function compareVoteFiles() {
// If one of the files isn't loaded yet, we're not ready to compare.
if (typeof prVoteFile === 'undefined' || typeof currentVoteFile === 'undefined') {
return;
}
if (prVoteFile.length > currentVoteFile.length) {
gh.issues.createComment({
user: config.user,
repo: config.repo,
number: pr.number,
body: 'Warning: New voting strategy is ineffecient. Do not vote for this PR unless you hate your planet.',
}, function(err, res) {
});
}
}
request(voteFileName, function (err, response, body) {
// Handle any failed requests.
if (err) { return console.error('Failed to get PR voting.js', err); }
if (response.statusCode !== 200) {
return console.error('PR voting.js status code was', response.statusCode);
}
prVoteFile = body;
compareVoteFiles();
});
request(currentFileName, function (err, response, body) {
// Handle any failed requests.
if (err) { return console.error('Failed to get PR voting.js', err); }
if (response.statusCode !== 200) {
return console.error('PR voting.js status code was', response.statusCode);
}
currentVoteFile = body;
compareVoteFiles();
});
}
// posts a comment explaining that the vote has started
// (if one isn't already posted)
function postVoteStarted(pr) {
getVoteStartedComment(pr, function(err, comment) {
if(err) return console.error('error in postVoteStarted:', err);
if(comment) {
// we already posted the comment
started[pr.number] = true;
return;
}
gh.issues.createComment({
user: config.user,
repo: config.repo,
number: pr.number,
body: voteStartedComment + pr.head.sha
}, function(err, res) {
if(err) return console.error('error in postVoteStarted:', err);
started[pr.number] = true;
console.log('Posted a "vote started" comment for PR #' + pr.number);
});
});
}
// checks for a "vote started" comment posted by ourself
// returns the comment if found
function getVoteStartedComment(pr, cb) {
// TODO: check more than just the first page
gh.issues.getComments({
user: config.user,
repo: config.repo,
number: pr.number,
per_page: 100
}, function(err, comments) {
if(err || !comments) return cb(err);
for(var i = 0; i < comments.length; i++) {
var postedByMe = comments[i].user.login === config.user;
var isVoteStarted = comments[i].body.indexOf(voteStartedComment) === 0;
if(postedByMe && isVoteStarted) {
// comment was found
return cb(null, comments[i]);
}
}
// comment wasn't found
return cb(null, null);
});
}
// calls cb if the PR has not been committed to since the voting started,
// otherwise displays an error
function assertNotModified(pr, cb) {
getVoteStartedComment(pr, function(err, comment) {
if(err) return console.error('error in assertNotModified:', err);
if(comment) {
var originalHead = comment.body.substr(comment.body.lastIndexOf(' ')+1);
if(pr.head.sha !== originalHead) {
console.log('Posting a "modified PR" warning, and closing #' + pr.number);
return closePR(modifiedWarning, pr, noop);
}
}
cb();
});
}
// counts the votes in the PR. if the minimum number of votes has been reached,
// make the decision (merge the PR, or close it).
function countVotes(pr) {
console.log('Counting votes for PR #' + pr.number);
// TODO: cache the stargazers list so we don't have to make a bunch of requests to check?
// get the people who starred the repo so we can ignore votes from people who did not star it
getStargazerIndex(function(err, stargazers) {
if(err || !stargazers) return console.error('error in countVotes:', err);
// get the comments so we can count the votes
getAllPages(pr, gh.issues.getComments, function(err, comments) {
if(err || !comments) return console.error('error in countVotes:', err);
// index votes by username so we only count 1 vote per person
var votes = {};
var nonStarGazers = [];
for(var i = 0; i < comments.length; i++) {
var user = comments[i].user.login;
var body = comments[i].body;
if(user === config.user) continue; // ignore self
if(!stargazers[user]) {
nonStarGazers.push(user);
continue; // ignore people who didn't star the repo
}
// Skip people who vote both ways.
if(body.indexOf(':-1:') !== -1 && body.indexOf(':+1:') !== -1) continue;
else if(body.indexOf(':-1:') !== -1) votes[user] = false;
else if(body.indexOf(':+1:') !== -1) votes[user] = true;
}
// tally votes
var yeas = 0, nays = 0;
for(var user in votes) {
if(votes[user]) yeas++;
else nays++;
}
console.log('Yeas: ' + yeas + ', Nays: ' + nays);
// only make a decision if we have the minimum amount of votes
if(yeas + nays < MIN_VOTES) return;
// vote passes if yeas > nays
var passes = decideVoteResult(yeas, nays);
gh.issues.createComment({
user: config.user,
repo: config.repo,
number: pr.number,
body: voteEndComment(passes, yeas, nays, nonStarGazers)
}, noop);
if(passes) {
mergePR(pr, noop);
} else {
closePR(pr, noop);
}
});
});
}
// returns an object of all the people who have starred the repo, indexed by username
function getStargazerIndex(cb) {
getAllPages(gh.repos.getStargazers, function(err, stargazers) {
if(err || !stargazers) return cb(err);
var index = {};
stargazers.forEach(function(stargazer) {
index[stargazer.login] = true;
});
cb(null, index);
});
}
// returns all results of a paginated function
function getAllPages(pr, f, cb, n, results) {
// pr is optional
if(typeof pr === 'function') {
cb = f;
f = pr;
pr = null;
}
if(!results) results = [];
if(!n) n = 0;
f({
user: config.user,
repo: config.repo,
number: pr ? pr.number : null,
page: n,
per_page: 100
}, function(err, res) {
if(err || !res) return cb(err);
results = results.concat(res);
// if we got to the end of the results, return them
if(res.length < 100) {
return cb(null, results)
}
// otherwise keep getting more pages recursively
getAllPages(pr, f, cb, n+1, results);
})
}
// closes the PR. if `message` is provided, it will be posted as a comment
function closePR(message, pr, cb) {
// message is optional
if(typeof pr === 'function') {
cb = pr;
pr = message;
message = '';
}
if(message) {
gh.issues.createComment({
user: config.user,
repo: config.repo,
number: pr.number,
body: message
}, noop);
}
gh.pullRequests.update({
user: config.user,
repo: config.repo,
number: pr.number,
state: 'closed',
title: pr.title,
body: pr.body
}, function(err, res) {
if(err) return cb(err);
voting.emit('close', pr);
console.log('Closed PR #' + pr.number);
return cb(null, res);
});
}
// merges a PR into master. If it can't be merged, a warning is posted and the PR is closed.
function mergePR(pr, cb) {
gh.pullRequests.get({
user: config.user,
repo: config.repo,
number: pr.number
}, function(err, res) {
if(err || !res) return cb(err);
if(!res.mergeable) {
return closePR(couldntMergeWarning, pr, function() {
cb(new Error('Could not merge PR because the author of the PR is a smeghead.'));
});
}
gh.pullRequests.merge({
user: config.user,
repo: config.repo,
number: pr.number
}, function(err, res) {
if(!err) voting.emit('merge', pr);
cb(err, res);
});
});
}
voting.handlePR = handlePR;
return voting;
};