"""Wrapped client for the configured GitLab bot.""" import logging import random import re import gitlab from gitlab_bot.models import GitlabConfig log = logging.getLogger(__name__) class GitlabBot(object): """Bot for doing GitLab stuff. Might be used by the IRC bot, or daemons, or whatever.""" REVIEWS_START_FORMAT = "Starting review process." REVIEWS_RESET_FORMAT = "Review process reset." REVIEWS_REVIEW_REGISTERED_FORMAT = "Review identified." REVIEWS_PROGRESS_FORMAT = "{0:d} reviews of {1:d} necessary to proceed." REVIEWS_REVIEWS_COMPLETE = "Reviews complete." NEW_REVIEWER_FORMAT = "Assigning to {0:s} to review merge request." NEW_ACCEPTER_FORMAT = "Assigning to {0:s} to accept merge request." NOTE_COMMENT = [ "The computer is your friend.", "Trust the computer.", "Quality is mandatory.", "Errors show your disloyalty to the computer.", ] def __init__(self): """Initialize the actual GitLab client.""" config = GitlabConfig.objects.first() self.client = gitlab.Gitlab(config.url, private_token=config.token) self.client.auth() def random_reviews_start_message(self): return "{0:s} {1:s} {2:s}".format(self.REVIEWS_START_FORMAT, self.REVIEWS_PROGRESS_FORMAT, random.choice(self.NOTE_COMMENT)) def random_reviews_reset_message(self): return "{0:s} {1:s} {2:s}".format(self.REVIEWS_RESET_FORMAT, self.REVIEWS_PROGRESS_FORMAT, random.choice(self.NOTE_COMMENT)) def random_review_progress_message(self): return "{0:s} {1:s} {2:s}".format(self.REVIEWS_REVIEW_REGISTERED_FORMAT, self.REVIEWS_PROGRESS_FORMAT, random.choice(self.NOTE_COMMENT)) def random_reviews_complete_message(self): return "{0:s} {1:s}".format(self.REVIEWS_REVIEWS_COMPLETE, random.choice(self.NOTE_COMMENT)) def random_new_reviewer_message(self): return "{0:s} {1:s}".format(self.NEW_REVIEWER_FORMAT, random.choice(self.NOTE_COMMENT)) def random_reviews_done_message(self): return "{0:s} {1:s}".format(self.NEW_ACCEPTER_FORMAT, random.choice(self.NOTE_COMMENT)) def scan_project_for_reviews(self, project, merge_request_ids=None): project_obj = self.client.projects.get(project.project_id) if not project_obj: return if merge_request_ids: merge_requests = [] for merge_request_id in merge_request_ids: merge_requests.append(project_obj.mergerequests.get(id=merge_request_id)) else: merge_requests = project_obj.mergerequests.list(state='opened') for merge_request in merge_requests: log.debug("scanning merge request '%s'", merge_request.title) if merge_request.state in ['merged', 'closed']: log.info("merge request '%s' is already %s, doing nothing", merge_request.title, merge_request.state) continue request_state = _MergeRequestScanningState() notes = sorted(self.client.project_mergerequest_notes.list(project_id=merge_request.project_id, merge_request_id=merge_request.id, all=True), key=lambda x: x.id) for note in notes: if not note.system: log.debug("merge request '%s', note '%s' is a normal message", merge_request.title, note.id) # note that we can't ignore note.system = True + merge_request.author, that might have been # a new push or something, so we only ignore normal notes from the author if note.author == merge_request.author: log.debug("skipping note from the merge request author") elif note.author.username == self.client.user.username: log.debug("saw a message from myself, i might have already sent a notice i'm sitting on") if note.body.find(self.REVIEWS_RESET_FORMAT) >= 0 and request_state.unlogged_approval_reset: log.debug("saw a reset message, unsetting the flag") request_state.unlogged_approval_reset = False elif note.body.find(self.REVIEWS_START_FORMAT) >= 0 and request_state.unlogged_review_start: log.debug("saw a start message, unsetting the flag") request_state.unlogged_review_start = False elif note.body.find(self.REVIEWS_REVIEW_REGISTERED_FORMAT) >= 0 and request_state.unlogged_approval: log.debug("saw a review log message, unsetting the flag") request_state.unlogged_approval = False elif note.body.find(self.REVIEWS_REVIEWS_COMPLETE) >= 0 and request_state.unlogged_review_complete: log.debug("saw a review complete message, unsetting the flag") request_state.unlogged_review_complete = False else: log.debug("nothing in particular relevant in '%s'", note.body) else: if note.body.find("LGTM") >= 0: log.debug("merge request '%s', note '%s' has a LGTM", merge_request.title, note.id) request_state.unlogged_approval = True request_state.approver_list.append(note.author.username) log.debug("approvers: %s", request_state.approver_list) if len(request_state.approver_list) < project.code_reviews_necessary: log.debug("not enough code reviews yet, setting needs_reviewer") request_state.needs_reviewer = True request_state.needs_accepter = False else: log.debug("enough code reviews, setting needs_accepter") request_state.needs_accepter = True request_state.needs_reviewer = False request_state.unlogged_review_complete = True else: log.debug("merge request '%s', note '%s' does not have a LGTM", merge_request.title, note.id) else: log.debug("merge request '%s', note '%s' is a system message", merge_request.title, note.id) if re.match(r'Added \d+ commit', note.body): log.debug("resetting approval list, '%s' looks like a push!", note.body) # only set the unlogged approval reset flag if there's some kind of progress if len(request_state.approver_list) > 0: request_state.unlogged_approval_reset = True request_state.needs_reviewer = True request_state.approver_list.clear() else: log.debug("leaving the approval list as it is, i don't think '%s' is a push", note.body) # do some cleanup excluded_review_candidates = request_state.approver_list + [merge_request.author.username] review_candidates = [x for x in project.code_reviewers.split(',') if x not in excluded_review_candidates] if merge_request.assignee: if request_state.needs_reviewer and merge_request.assignee.username in review_candidates: log.debug("unsetting the needs_reviewer flag, the request is already assigned to one") request_state.needs_reviewer = False elif request_state.needs_reviewer and merge_request.assignee.username == merge_request.author.username: log.info("unsetting the needs_reviewer flag, the request is assigned to the author") log.info("in this case we are assuming that the author has work to do, and will re/unassign") request_state.needs_reviewer = False excluded_accept_candidates = [merge_request.author.username] accept_candidates = [x for x in project.code_review_final_merge_assignees.split(',') if x not in excluded_accept_candidates] if merge_request.assignee: if request_state.needs_accepter and merge_request.assignee.username in accept_candidates: log.debug("unsetting the needs_accepter flag, the request is already assigned to one") request_state.needs_accepter = False elif request_state.needs_accepter and merge_request.assignee.username == merge_request.author.username: log.info("unsetting the needs_accepter flag, the request is assigned to the author") log.info("in this case we are assuming that the author has work to do, and will re/unassign") request_state.needs_accepter = False log.debug("%s", request_state.__dict__) # status message stuff if request_state.unlogged_review_start: log.info("sending message for start of reviews") msg = {'body': self.random_reviews_start_message().format(len(request_state.approver_list), project.code_reviews_necessary)} self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id, merge_request_id=merge_request.id) if request_state.unlogged_approval_reset: log.info("sending message for review reset") msg = {'body': self.random_reviews_reset_message().format(len(request_state.approver_list), project.code_reviews_necessary)} self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id, merge_request_id=merge_request.id) if request_state.unlogged_approval: log.info("sending message for code review progress") msg = {'body': self.random_review_progress_message().format(len(request_state.approver_list), project.code_reviews_necessary)} self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id, merge_request_id=merge_request.id) if request_state.unlogged_review_complete: log.info("sending message for code review complete") msg = {'body': self.random_reviews_complete_message()} self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id, merge_request_id=merge_request.id) # if there's a reviewer necessary, assign the merge request if len(request_state.approver_list) < project.code_reviews_necessary and request_state.needs_reviewer: log.debug("%s needs a code review", merge_request.title) if merge_request.assignee is not None: log.debug("%s currently assigned to %s", merge_request.title, merge_request.assignee.username) if merge_request.assignee is None or merge_request.assignee.username not in review_candidates: if len(review_candidates) > 0: new_reviewer = review_candidates[merge_request.iid % len(review_candidates)] log.debug("%s is the new reviewer", new_reviewer) # get the user object for the new reviewer new_reviewer_obj = self.client.users.get_by_username(new_reviewer) # create note for the update msg = {'body': self.random_new_reviewer_message().format(new_reviewer_obj.name)} self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id, merge_request_id=merge_request.id) # assign the merge request to the new reviewer self.client.update(merge_request, assignee_id=new_reviewer_obj.id) else: log.warning("no reviewers left to review %s, doing nothing", merge_request.title) else: log.debug("needs_reviewer set but the request is assigned to a reviewer, doing nothing") # if there's an accepter necessary, assign the merge request if len(request_state.approver_list) >= project.code_reviews_necessary and request_state.needs_accepter: log.debug("%s needs an accepter", merge_request.title) if merge_request.assignee is not None: log.debug("%s currently assigned to %s", merge_request.title, merge_request.assignee.username) if merge_request.assignee is None or merge_request.assignee.username not in accept_candidates: if len(accept_candidates) > 0: new_accepter = accept_candidates[merge_request.iid % len(accept_candidates)] log.debug("%s is the new accepter", new_accepter) # get the user object for the new accepter new_accepter_obj = self.client.users.get_by_username(new_accepter) # create note for the update msg = {'body': self.random_reviews_done_message().format(new_accepter_obj.name)} self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id, merge_request_id=merge_request.id) # assign the merge request to the new reviewer self.client.update(merge_request, assignee_id=new_accepter_obj.id) else: log.warning("no accepters left to accept %s, doing nothing", merge_request.title) class _MergeRequestScanningState(object): """Track the state of a merge request as it is scanned and appropriate action identified.""" def __init__(self): """Set default flags/values.""" self.approver_list = [] self.unlogged_review_start = True self.unlogged_approval_reset = False self.unlogged_approval = False self.unlogged_review_complete = False self.needs_reviewer = True self.needs_accepter = False