TooAngel/democratic-collaboration

View on GitHub
src/PullRequest.py

Summary

Maintainability
A
3 hrs
Test Coverage
import logging
import configparser
from datetime import datetime, timedelta
import os
import github
import requests
from GithubReviews import fetch_reviews
import sys
from flask import Flask

from models import Repository, db

DOMAIN = 'https://www.worlddriven.org'


def toDateTime(value):
    return datetime.strptime(value, '%Y-%m-%dT%H:%M:%SZ')


def _get_last_date(data):
    data_sorted = sorted(data, key=lambda event: event.created_at, reverse=True)
    return data_sorted[0].created_at if len(data_sorted) > 0 else datetime(1960, 1, 1)


class PullRequest(object):
    def __init__(self, repository, pull_request, token):
        self.repository = repository
        self.pull_request = pull_request
        self.token = token
        self.url = '{}/{}/pull/{}'.format(DOMAIN, self.repository.full_name, self.pull_request.number)
        self.config = {
            'baseMergeTimeInHours': 240.,
            'perCommitTimeInHours': 0.,
            'merge_method': 'squash',
        }

        try:
            config_file_content = repository.get_contents(".worlddriven.ini")
            config = configparser.ConfigParser()
            config.read_string(config_file_content.decoded_content.decode('utf-8'))
            config_new = {}
            config_new['baseMergeTimeInHours'] = float(config['DEFAULT'].get('baseMergeTimeInHours'))
            config_new['perCommitTimeInHours'] = float(config['DEFAULT'].get('perCommitTimeInHours'))
            config_new['merge_method'] = config['DEFAULT'].get('merge_method')
            self.config.update(config_new)
        except Exception as e:
            pass

    def set_status(self):
        if self.coefficient >= 0:
            status_message = '{} Merge at {}'.format(round(self.coefficient, 2), self.max_date + self.merge_duration)
            self._update_status('success', status_message)
        else:
            status_message = '{} Will not merge'.format(round(self.coefficient, 2))
            self._update_status('error', status_message)
            return

    def _update_status(self, state, message):
        commit = self.pull_request.get_commits().reversed[0]
        statuses = commit.get_statuses()
        for status in statuses:
            if status.context == 'World driven' and status.description == message:
                return
        try:
            commit.create_status(state, self.url, message, 'World driven')
        except Exception as e:
            logging.exception('PullRequest._set_status exception {}'.format(commit))

    def get_contributors(self):
        contributors = self.repository.get_stats_contributors()
        self.contributors = {contributor.author.login: {'review_value': 0, 'name': contributor.author.login, 'commits': contributor.total} for contributor in (contributors or []) if contributor.author}
        if self.pull_request.user.login not in self.contributors:
            self.contributors[self.pull_request.user.login] = {'review_value': 0, 'name': self.pull_request.user.login, 'commits': 0}

    def update_contributors_with_reviews(self):
        data = fetch_reviews(self.repository.full_name, self.pull_request.number, self.token)
        reviews_decided = [review for review in data if review['state'] != 'COMMENTED']
        for review in reviews_decided:
            value = 0
            if review['state'] == 'APPROVED':
                value = 1
            elif review['state'] == 'CHANGES_REQUESTED':
                value = -1

            user = review['user']['login']
            if user not in self.contributors:
                self.contributors[user] = {'name': review['user']['login'], 'review_value': value, 'review_date': review['submitted_at']}
                continue

            if 'review_date' not in self.contributors[user] or toDateTime(self.contributors[user]['review_date']) < toDateTime(review['submitted_at']):
                self.contributors[user]['review_value'] = value
                self.contributors[user]['review_date'] = review['submitted_at']
                continue

        self.contributors[self.pull_request.user.login]['review_value'] = 1

    def update_votes(self):
        # Sum of total number of commits, initialize votes with the authors weight
        self.votes_total = sum(self.contributors[contributor].get('commits', 0) for contributor in self.contributors)
        self.votes = sum(self.contributors[contributor].get('review_value', 0) * self.contributors[contributor].get('commits', 0) for contributor in self.contributors)
        self.coefficient = 0
        if self.votes_total != 0:
            self.coefficient = float(self.votes) / float(self.votes_total)

    def get_latest_dates(self):
        issue = self.repository.get_issue(self.pull_request.number)
        issue_events = [event for event in issue.get_events() if event.event == 'unlabeled' and event.raw_data['label']['name'] == 'WIP']
        # TODO this is removing the label, instead draft PRs should be used - so need to check when the status changed to open
        ready_for_review_events = [event for event in issue.get_events() if event.event == 'ready_for_review']
        self.ready_for_review_date = _get_last_date(ready_for_review_events)
        self.unlabel_date = _get_last_date(issue_events)
        events = [event for event in self.pull_request.head.repo.get_events() if event.type == 'PushEvent' and event.payload['ref'] == 'refs/heads/{}'.format(self.pull_request.head.ref)]
        self.push_date = _get_last_date(events)

        commits = self.pull_request.get_commits().reversed
        commit = max(commits, key=lambda commit: commit.commit.author.date)
        # TODO is this correct? `author.date`
        self.commit_date = commit.commit.author.date
        self.pull_request_date = self.pull_request.created_at

        self.max_date = max(self.commit_date, self.unlabel_date, self.push_date, self.pull_request.created_at, self.ready_for_review_date)
        self.age = datetime.utcnow() - self.max_date

    def get_merge_time(self):
        self.total_merge_time = (self.config['baseMergeTimeInHours'] / 24 + self.pull_request.commits * self.config['perCommitTimeInHours'] / 24)
        self.merge_duration = timedelta(days=(1 - self.coefficient) * self.total_merge_time)
        self.days_to_merge = self.merge_duration - self.age
        self.commits = self.pull_request.commits

    def mergeable_pull_request(self):
        self.pull_request.mergeable

    def check_for_merge(self):
        self.set_status()

        if self.coefficient >= 0 and self.max_date + self.merge_duration < datetime.utcnow():
            logging.info('Would merge now')
            try:
                self.pull_request.merge(merge_method=self.config['merge_method'])
            except Exception as e:
                # Maybe add a comment that the conflicts should be resolved
                logging.exception(self.pull_request)
                logging.info(self.pull_request.state)
                return
            try:
                self.pull_request.create_issue_comment('This pull request was merged by [worlddriven](https://www.worlddriven.org).')
            except Exception as e:
                logging.exception(self.pull_request)
                return

    def execute(self):
        if not self.pull_request.head.repo:
            logging.info('Pull Request head repository deleted, delete Pull Request')
            self.pull_request.edit(state="closed")
            return

        self.get_contributors()
        self.update_contributors_with_reviews()
        self.update_votes()
        self.get_latest_dates()
        self.get_merge_time()

        self.check_for_merge()


def check_pull_request(repository, pull_request, commentOnIssue, token):
    logging.info('Pull Request: {}'.format(pull_request.title.encode('utf-8')))

    pr = PullRequest(repository, pull_request, token)
    pr.execute()
    return pr


def check_pull_requests():
    logging.info('Check pull requests: {}'.format(datetime.utcnow()))
    app = Flask(__name__)
    app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('JAWSDB_MARIA_URL', 'mysql://worlddriven:password@127.0.0.1/worlddriven')
    db.init_app(app)
    with app.app_context():
        db_repositories = Repository.query.all()
        for db_repository in db_repositories:
            repository_name = db_repository.full_name
            logging.info('Repository: {}'.format(repository_name))
            github_client = github.Github(db_repository.github_access_token)
            repository = github_client.get_repo(repository_name)
            for pull_request in repository.get_pulls(state='open'):
                if not pull_request.mergeable:
                    continue
                check_pull_request(repository, pull_request, False, db_repository.github_access_token)


if __name__ == '__main__':
    logging.basicConfig(stream=sys.stdout, level=logging.INFO)
    check_pull_requests()