diff --git a/cron/poll_pull_requests.py b/cron/poll_pull_requests.py index fa7a19af..78ed861c 100644 --- a/cron/poll_pull_requests.py +++ b/cron/poll_pull_requests.py @@ -3,7 +3,7 @@ import os import sys from os.path import join, abspath, dirname -from lib.db.models import MeritocracyMentioned +from lib.db.models import MeritocracyMentioned, Issue import settings import github_api as gh @@ -65,16 +65,41 @@ def poll_pull_requests(api): # is our PR approved or rejected? vote_total, variance = gh.voting.get_vote_sum(api, votes, contributors) threshold = gh.voting.get_approval_threshold(api, settings.URN) - is_approved = vote_total >= threshold and meritocracy_satisfied + is_approved = vote_total >= threshold and meritocracy_satisfied > 0 seconds_since_updated = gh.prs.seconds_since_updated(api, pr) + half_window = False + + # read the db to see if this PR is expedited by /vote fast... + try: + issue = Issue.get(issue_id=pr["id"]) + half_window = issue.expedited and \ + meritocracy_satisfied >= settings.FAST_PR_MERITOCRATS + + except Issue.DoesNotExist: + pass # not expedited + + except: + __log.exception("Failed to get expedited status") + voting_window = base_voting_window + + if half_window: + voting_window /= 2 + + # Add the "expedited label" + gh.issues.label_issue(api, settings.URN, pr["number"], "expedited") + # the PR is mitigated or the threshold is not reached ? if variance >= threshold or not is_approved: voting_window = gh.voting.get_extended_voting_window(api, settings.URN) + + if half_window: + voting_window /= 2 + if (settings.IN_PRODUCTION and vote_total >= threshold / 2 and - seconds_since_updated > base_voting_window and not meritocracy_satisfied): + seconds_since_updated > base_voting_window and meritocracy_satisfied == 0): # check if we need to mention the meritocracy try: commit = pr["head"]["sha"] @@ -96,14 +121,14 @@ def poll_pull_requests(api): gh.prs.post_accepted_status( api, settings.URN, pr, seconds_since_updated, voting_window, votes, vote_total, - threshold, meritocracy_satisfied) + threshold, meritocracy_satisfied > 0) if in_window: __log.info("PR %d approved for merging!", pr_num) try: sha = gh.prs.merge_pr(api, settings.URN, pr, votes, vote_total, - threshold, meritocracy_satisfied) + threshold, meritocracy_satisfied > 0) # some error, like suddenly there's a merge conflict, or some # new commits were introduced between finding this ready pr and # merging it @@ -115,7 +140,7 @@ def poll_pull_requests(api): gh.comments.leave_accept_comment( api, settings.URN, pr_num, sha, votes, vote_total, - threshold, meritocracy_satisfied) + threshold, meritocracy_satisfied > 0) gh.issues.label_issue(api, settings.URN, pr_num, ["accepted"]) # chaosbot rewards merge owners with a follow @@ -130,21 +155,21 @@ def poll_pull_requests(api): if in_window: gh.prs.post_rejected_status( api, settings.URN, pr, seconds_since_updated, voting_window, votes, - vote_total, threshold, meritocracy_satisfied) + vote_total, threshold, meritocracy_satisfied > 0) __log.info("PR %d rejected, closing", pr_num) gh.comments.leave_reject_comment( api, settings.URN, pr_num, votes, vote_total, threshold, - meritocracy_satisfied) + meritocracy_satisfied > 0) gh.issues.label_issue(api, settings.URN, pr_num, ["rejected"]) gh.prs.close_pr(api, settings.URN, pr) elif vote_total < 0: gh.prs.post_rejected_status( api, settings.URN, pr, seconds_since_updated, voting_window, votes, - vote_total, threshold, meritocracy_satisfied) + vote_total, threshold, meritocracy_satisfied > 0) else: gh.prs.post_pending_status( api, settings.URN, pr, seconds_since_updated, voting_window, votes, - vote_total, threshold, meritocracy_satisfied) + vote_total, threshold, meritocracy_satisfied > 0) for user in votes: if user in total_votes: diff --git a/cron/poll_read_issue_comments.py b/cron/poll_read_issue_comments.py index 14283197..8e2937a0 100644 --- a/cron/poll_read_issue_comments.py +++ b/cron/poll_read_issue_comments.py @@ -1,13 +1,14 @@ import logging import arrow import re +import json from requests.exceptions import HTTPError import settings import github_api as gh from lib.db.models import Comment, User, ActiveIssueCommands, Issue -from lib.db.models import RunTimes, InactiveIssueCommands +from lib.db.models import RunTimes, InactiveIssueCommands, MeritocracyMentioned ''' Command Syntax @@ -21,7 +22,7 @@ # If no subcommands, map cmd: None COMMAND_LIST = { - "/vote": ("close", "reopen") + "/vote": ("close", "reopen", "fast") } __log = logging.getLogger("read_issue_comments") @@ -35,13 +36,34 @@ def get_seconds_remaining(api, comment_id): return seconds_remaining +def insert_or_update_issue(api, issue_id, number): + # get more info on the issue + gh_issue = gh.issue.open_issue(api, settings.URN, number) + + # get user from db + user, _ = User.get_or_create(user_id=gh_issue["user"]["id"], + defaults={"login": gh_issue["user"]["login"]}) + + # db insert + issue, _ = Issue.get_or_create(issue_id=issue_id, + defaults={ + "issue_id": issue_id, + "number": number, + "user": user, + "created_at": gh_issue["created_at"], + "expedited": False, + "is_pr": "pull_request" in gh_issue, + }) + + return issue + + def insert_or_update(api, cmd_obj): # Find the comment, or create it if it doesn't exit comment_id = cmd_obj["global_comment_id"] - issue, _ = Issue.get_or_create(issue_id=cmd_obj["issue_id"]) user, _ = User.get_or_create(user_id=cmd_obj["user"]["id"], defaults={"login": cmd_obj["user"]["login"]}) - + issue = insert_or_update_issue(api, cmd_obj["issue_id"], cmd_obj["number"]) comment, _ = Comment.get_or_create(comment_id=comment_id, defaults={ "user": user, "text": cmd_obj["comment_text"], @@ -135,7 +157,75 @@ def get_command_votes(api, urn, comment_id): return votes -def handle_vote_command(api, command, issue_id, comment_id, votes): +def get_meritocracy(api): + with open('server/voters.json', 'r+') as fp: + total_votes = {} + fs = fp.read() + if fs: + total_votes = json.loads(fs) + + top_contributors = sorted(gh.repos.get_contributors(api, settings.URN), + key=lambda user: user["total"], reverse=True) + top_contributors = [item["author"]["login"].lower() for item in top_contributors] + top_contributors = top_contributors[:settings.MERITOCRACY_TOP_CONTRIBUTORS] + top_contributors = set(top_contributors) + + top_voters = sorted(total_votes, key=total_votes.get, reverse=True) + top_voters = map(lambda user: user.lower(), top_voters) + top_voters = list(filter(lambda user: user not in settings.MERITOCRACY_VOTERS_BLACKLIST, + top_voters)) + top_voters = set(top_voters[:settings.MERITOCRACY_TOP_VOTERS]) + meritocracy = top_voters | top_contributors + + return meritocracy + + +def fast_vote(api, cmdmeta): + comment_updated_at = cmdmeta.comment.updated_at + comment_created_at = cmdmeta.comment.created_at + comment_poster = cmdmeta.comment.user + issue_created_at = cmdmeta.issue.created_at + + # This must be a PR + if not cmdmeta.issue.is_pr: + return + + # The post should not have been updated + if comment_updated_at != comment_created_at: + return + + # The comment poster must be in the meritocracy + meritocracy = get_meritocracy(api) + if comment_poster not in meritocracy: + return + + # The comment must posted within 5 min of the issue + if (arrow.get(comment_created_at) - + arrow.get(issue_created_at)).total_seconds() > 5*60: + return + + # Leave a note on the issue that it is expedited + Issue.update(expedited=True).where(Issue.issue_id == cmdmeta.issue.issue_id).execute() + + # mention the meritocracy immediately + try: + pr = gh.prs.get_pr(api, settings.URN, cmdmeta.issue.number) + commit = pr["head"]["sha"] + + mm, created = MeritocracyMentioned.get_or_create(commit_hash=commit) + if created: + meritocracy_mentions = meritocracy - {pr["user"]["login"].lower(), + "chaosbot"} + gh.comments.leave_expedite_comment(api, + settings.URN, pr["number"], + meritocracy_mentions) + except: + __log.exception("Failed to process meritocracy mention") + + +def handle_vote_command(api, command, cmdmeta, votes): + issue_id = cmdmeta.issue.issue_id + orig_command = command[:] # Check for correct command syntax, ie, subcommands log_warning = False @@ -145,6 +235,9 @@ def handle_vote_command(api, command, issue_id, comment_id, votes): gh.issues.close_issue(api, settings.URN, issue_id) elif sub_command == "reopen": gh.issues.open_issue(api, settings.URN, issue_id) + elif sub_command == "fast": + fast_vote(api, cmdmeta) + else: # Implement other commands pass @@ -176,7 +269,7 @@ def handle_comment(api, cmd): comment=comment_text)) if command == "/vote": - handle_vote_command(api, parsed_comment, issue_id, comment_id, votes) + handle_vote_command(api, parsed_comment, cmd, votes) update_command_ran(api, comment_id, "Command Ran") diff --git a/github_api/comments.py b/github_api/comments.py index 99359bcb..4d0ceac3 100644 --- a/github_api/comments.py +++ b/github_api/comments.py @@ -43,6 +43,7 @@ def get_all_issue_comments(api, urn, page=1, since=None): "login": comment["user"]["login"], "id": comment["user"]["id"] } + issue_comment["number"] = comment["number"] yield issue_comment @@ -107,6 +108,17 @@ def leave_meritocracy_comment(api, urn, pr, meritocracy): return leave_comment(api, urn, pr, body) +def leave_expedite_comment(api, urn, pr, meritocracy): + meritocracy_str = " ".join(map(lambda user: "@" + user, meritocracy)) + body = """ +:warning: This PR is nominated to be **expedited**. +This requires **{num}** positive reviews from the meritocracy. + +Please review: {meritocracy} + """.strip().format(meritocracy=meritocracy_str, num=settings.FAST_PR_MERITOCRATS) + return leave_comment(api, urn, pr, body) + + def leave_deleted_comment(api, urn, pr): body = """ :no_entry: The repository backing this PR has been deleted. diff --git a/github_api/voting.py b/github_api/voting.py index ce695415..c248cab7 100644 --- a/github_api/voting.py +++ b/github_api/voting.py @@ -18,7 +18,7 @@ def get_votes(api, urn, pr, meritocracy): can't acquire approval votes, then change the pr """ votes = {} - meritocracy_satisfied = False + meritocracy_satisfied = 0 pr_owner = pr["user"]["login"] pr_num = pr["number"] @@ -33,7 +33,7 @@ def get_votes(api, urn, pr, meritocracy): 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 + meritocracy_satisfied += 1 break # by virtue of creating the PR, the owner defaults to a vote of 1 diff --git a/lib/db/models.py b/lib/db/models.py index 0f42df96..3a730a31 100644 --- a/lib/db/models.py +++ b/lib/db/models.py @@ -28,6 +28,11 @@ class Comment(BaseModel): class Issue(BaseModel): issue_id = pw.IntegerField(primary_key=True) + number = pw.IntegerField() + created_at = pw.DateField() + user = pw.ForeignKeyField(User, related_name='poster') + is_pr = pw.BooleanField() + expedited = pw.BooleanField() class ActiveIssueCommands(BaseModel): diff --git a/settings.py b/settings.py index af2844ca..5e44c183 100644 --- a/settings.py +++ b/settings.py @@ -92,7 +92,8 @@ "mergeable": "dddddd", "can't merge": "ededed", "ci failed": "ff9800", - "crash report": "ff0000" + "crash report": "ff0000", + "expedited": "f49542", } # PRs that have merge conflicts and haven't been touched in this many hours @@ -124,6 +125,9 @@ # Make sure usernames are lowercased MERITOCRACY_VOTERS_BLACKLIST = {user.lower() for user in MERITOCRACY_VOTERS_BLACKLIST} +# Number of meritocrats needed to expedite a PR +FAST_PR_MERITOCRATS = 5 + # Database settings DB_ADAPTER = "sqlite" DB_CONFIG = {