botwillacceptanything/botwillacceptanything

View on GitHub
lib/voting/voting.js

Summary

Maintainability
C
1 day
Test Coverage
(function() {
    var define = require('amdefine')(module);

    var deps = [
        'lodash',
        'sentiment',

        '../../config',
        '../events',
        '../github',
        './templates',
        './votes.js',
    ];

    define(deps, function(
        _,
        sentiment,

        config,
        events,
        gh,
        templates,
        VoteController
    ) {
      var MINUTE = 60 * 1000; // One minute in ms.

      function noop(err) {
        if (err) {
          console.error(err);
          console.error(err.stack);
        }
      }


      var Voting = function Voting(repo) {
        var self = this;

        this.starGazers = {};
        this.pullRequests = {};
        this.startedPRs = {};
        this.votesPerPR = {};

        this.intervals = {};

        this.repo = repo;
        this.votingConfig = repo.votingConfig;
        this.votingConfig.truePeriod =
          this.votingConfig.period * (1 + (Math.random() - 0.5) * this.votingConfig.period_jitter);
        this.votingConfig.guaranteedResult =
          Math.ceil(this.votingConfig.minVotes * this.votingConfig.supermajority);
        this.voteController = new VoteController(repo, this.votingConfig, this.starGazers, this.votesPerPR);
        this.templates = templates(this.votingConfig);

        // Only initialize the regular polling if the bot is not being tested.
        if (process.env.BUILD_ENVIRONMENT !== 'test') {
          this.initialize();
        }

        // Initialize our event handling.
        this.initializeEvents();
      };

      Voting.prototype.initialize = function initialize() {
        // Immediately update the stargazers, and fetch and process all PRs.
        this.updateStarGazers(this.refreshAllPRs.bind(this));

        // If we're not set up for GitHub Webhooks, poll the server every interval.
        if (typeof config.githubAuth === 'undefined') {
          this.intervals.refreshAllPRs = setInterval(this.refreshAllPRs.bind(this), this.votingConfig.pollInterval * MINUTE);
        } else {
          // Repoll all PRs every 30 minutes, just to be safe.
          this.intervals.refreshAllPRs = setInterval(this.refreshAllPRs.bind(this), 30 * MINUTE);
        }
        // Check the stargazers every interval.
        this.intervals.updateStarGazers = setInterval(this.updateStarGazers.bind(this), this.votingConfig.pollInterval * MINUTE);
      };

      /**
       * When a pull request is opened, add it to the cache and handle it.
       */
      Voting.prototype.eventsPullRequestOpened = function eventsPullRequestOpened(data) {
        // Don't process events for other repositories.
        if (data.repository.name !== this.repo.repo ||
            data.repository.owner.login !== this.repo.user) {
          return;
        }

        data.pull_request.comments = [];
        this.pullRequests[data.number] = data.pull_request;
        this.handlePR(data.pull_request);
      };

      /**
       * When a pull request is closed, mark it so we don't process it again.
       */
      Voting.prototype.eventsPullRequestClosed = function eventsPullRequestClosed(data) {
        // Don't process events for other repositories.
        if (data.repository.name !== this.repo.repo ||
            data.repository.owner.login !== this.repo.user) {
          return;
        }

        // Remove this PR from votesPerPR so it no longer shows up on website.
        delete this.votesPerPR[data.number];

        var pr = this.pullRequests[data.number];
        if (typeof pr !== 'undefined') {
          pr.state = 'closed';
        }
      };

      /**
       * When a comment is created, push it onto the PR, and handle the PR again.
       */
      Voting.prototype.eventsCommentCreated = function eventsCommentCreated(data) {
        // Don't process events for other repositories.
        if (data.repository.name !== this.repo.repo ||
            data.repository.owner.login !== this.repo.user) {
          return;
        }

        var pr = this.pullRequests[data.issue.number];
        if (typeof pr === 'undefined') {
          return console.error('Could not find PR', data.issue.number, 'when adding a comment');
        }
        pr.comments.push(data.comment);
        this.handlePR(pr);
      };

      Voting.prototype.initializeEvents = function initializeEvents() {
        events.on('github.pull_request.opened', this.eventsPullRequestOpened);
        events.on('github.pull_request.closed', this.eventsPullRequestClosed);
        events.on('github.comment.created', this.eventsCommentCreated);
      };

      Voting.prototype.destroy = function destroy() {
        events.removeListener('github.pull_request.opened', this.eventsPullRequestOpened);
        events.removeListener('github.pull_request.closed', this.eventsPullRequestClosed);
        events.removeListener('github.comment.created', this.eventsCommentCreated);

        Object.keys(this.intervals).forEach(function (interval) {
          clearInterval(this.intervals[interval]);
        }.bind(this));
      };

      /**
       * Update the StarGazer cache.
       */
      Voting.prototype.updateStarGazers = function updateStarGazers(cb) {
        var self = this;

        gh.getAllPages(this.repo, gh.repos.getStargazers, function (err, stargazers) {
          if (err || !stargazers) {
            console.error('Error getting stargazers:', err);
            if (typeof cb === 'function') {
              return cb(err, stargazers);
            }
            return;
          }

          var index = {};
          // Erase the existing stargazers.
          Object.keys(self.starGazers).forEach(function (stargazer) {
            delete self.starGazers[stargazer];
          });
          // Add in the updated list of stargazers.
          stargazers.forEach(function (stargazer) {
            self.starGazers[stargazer.login] = true;
          });
          if (typeof cb === 'function') {
            cb(stargazers);
          }
        });
      };

      /**
       * Fetch all PRs from GitHub, and then look up all of their comments.
       */
      Voting.prototype.refreshAllPRs = function refreshAllPRs() {
        var self = this;

        gh.getAllPages(this.repo, gh.pullRequests.getAll, function (err, prs) {
          if (err || !prs) {
            return console.error('Error getting Pull Requests.', err);
          }

          prs.map(function (pr) {
            pr.comments = [];
            self.pullRequests[pr.number] = pr;
            self.refreshAllComments(pr, self.handlePR.bind(self));
          });
        });
      };

      /**
       * Fetch all comments for a PR from GitHub.
       */
      Voting.prototype.refreshAllComments = function refreshAllComments(pr, cb) {
        var self = this;
        var prRequest = _.merge({}, this.repo, { number: pr.number });

        gh.getAllPages(prRequest, gh.issues.getComments, function (err, comments) {
          if (err || !comments) {
            return console.error('Error getting Comments.', err);
          }

          pr.comments = comments;
          if (typeof cb === 'function') {
            cb(pr);
          }
        });
      };

      Voting.prototype.handlePR = function handlePR(pr) {
        var self = this;

        // Don't act on closed PRs
        if (pr.state === 'closed') {
          return console.log('Update triggered on closed PR #' + pr.number);
        }

        // if there is no 'vote started' comment, post one
        if (!this.startedPRs[pr.number]) {
          this.postVoteStarted(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
        this.assertNotModified(pr, function () {
          self.processPR(pr);
        });
      };

      Voting.prototype.postVoteStarted = function postVoteStarted(pr) {
        var self = this;

        this.getVoteStartedComment(pr, function (err, comment) {
          if (err) { return console.error('error in postVoteStarted:', err); }
          if (comment) {
            // we already posted the comment
            self.startedPRs[pr.number] = true;
            return;
          }

          gh.issues.createComment({
            user: self.repo.user,
            repo: self.repo.repo,
            number: pr.number,
            body: self.templates.voteStartedComment + " " + pr.head.sha

          }, function (err) {
            if (err) { return console.error('error in postVoteStarted:', err); }

            events.emit('bot.pull_request.vote_started', pr);
            self.startedPRs[pr.number] = true;
            console.log('Posted a "vote started" comment for PR #' + pr.number);

            // determine whether I like this PR or not
            var score = sentiment(pr.title + ' ' + pr.body).score;
            if (score > 1) {
              // I like this PR, let's vote for it!
              gh.issues.createComment({
                user: self.repo.user,
                repo: self.repo.repo,
                number: pr.number,
                body: self.voteController.votePositive,
              });
            } else if (score < -1) {
              // Ugh, this PR sucks, boooo!
              gh.issues.createComment({
                user: self.repo.user,
                repo: self.repo.repo,
                number: pr.number,
                body: self.voteController.voteNegative,
              });
            } // otherwise it's meh, so I don't care
          });
        });
      };

      /**
       * Check for a "vote started" comment posted by ourself.
       *
       * @return (object|null)
       *   Return the comment if found, and null otherwise.
       */
      Voting.prototype.getVoteStartedComment  = function getVoteStartedComment(pr, cb) {
        for (var i = 0; i < pr.comments.length; i++) {
          var postedByMe = pr.comments[i].user.login === this.repo.user;
          var isVoteStarted = pr.comments[i].body.indexOf(this.templates.voteStartedComment) === 0;
          if (postedByMe && isVoteStarted) {
            // comment was found
            return cb(null, pr.comments[i]);
          }
        }

        // comment wasn't found
        return cb(null, null);
      };

      /**
       * If the PR has not been modified, call the cb, otherwise display an error.
       */
      Voting.prototype.assertNotModified = function assertNotModified(pr, cb) {
        var self = this;

        this.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 self.closePR(modifiedWarning, pr, noop);
            }
          }

          cb();
        });
      };

      /**
       * Tally all of the votes for a PR, and if conditions pass, merge or close it.
       */
      Voting.prototype.processPR = function processPR(pr, cb) {
        var self = this;

        if (typeof cb === 'undefined') { cb = noop; }

        var voteResults = this.voteController.tallyVotes(pr);

        // only make a decision if the minimum period has elapsed.
        var age = Date.now() - new Date(pr.created_at).getTime();
        if (age / MINUTE < this.votingConfig.truePeriod) { return cb(null, false); }

        var highestVote = Math.max(voteResults.positive, voteResults.negative);

        // only make a decision if we have the minimum amount of votes
        if (highestVote < this.votingConfig.guaranteedResult) { return cb(null, false); }

        // vote passes if yeas > nays
        var passes = this.voteController.doesVotePass(voteResults.positive, voteResults.negative);

        gh.issues.createComment({
          user: self.repo.user,
          repo: self.repo.repo,
          number: pr.number,
          body: this.templates.voteEndComment(passes, voteResults.positive, voteResults.negative, voteResults.nonStarGazers)
        }, noop);

        // Post in IRC
        if (passes) {
          this.mergePR(pr, cb);
        } else {
          this.closePR(pr, cb);
        }
      };

      // closes the PR. if `message` is provided, it will be posted as a comment
      Voting.prototype.closePR = function closePR(message, pr, cb) {
        var self = this;

        // message is optional
        if (typeof pr === 'function') {
          cb = pr;
          pr = message;
          message = '';
        }

        // Flag the PR as closed pre-emptively to prevent multiple comments.
        this.pullRequests[pr.number].state = 'closed';

        if (message) {
          gh.issues.createComment({
            user: self.repo.user,
            repo: self.repo.repo,
            number: pr.number,
            body: message
          }, noop);
        }

        gh.pullRequests.update({
          user: self.repo.user,
          repo: self.repo.repo,
          number: pr.number,
          state: 'closed',
          title: pr.title,
          body: pr.body

        }, function (err, res) {
          if (err) { return cb(err); }
          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.
      Voting.prototype.mergePR = function mergePR(pr, cb) {
        var self = this;

        gh.pullRequests.get({
          user: self.repo.user,
          repo: self.repo.repo,
          number: pr.number

        }, function (err, res) {
          if (err || !res) { return cb(err); }
          if (res.mergeable === false) {
            console.error('Attempted to merge PR #' + res.number +
            ' but it failed with a mergeable (' + res.mergeable +
            ') state of ' + res.mergeable_state);
            return self.closePR(self.templates.couldntMergeWarning, pr, function () {
              cb(new Error('Could not merge PR.'));
            });
          } else if (res.mergeable === null) {
            console.error('Attempted to merge PR #' + res.number +
            ' but it was postponed with a mergeable (' +
            res.mergeable + ') state of ' + res.mergeable_state);
            // Try it again in 5 seconds if it failed with a "null" mergeable state.
            return setTimeout(function () {
              self.mergePR(pr, cb);
            }, 5000);
          }

          // Flag the PR as closed pre-emptively to prevent multiple comments.
          self.pullRequests[pr.number].state = 'closed';

          gh.pullRequests.merge({
            user: self.repo.user,
            repo: self.repo.repo,
            number: pr.number
          }, cb);
        });
      };



      module.exports = Voting;
    });
}());