From 8f7b477fb8b9c23e4b250c69978febcb8cf1f78e Mon Sep 17 00:00:00 2001 From: "Brian S. Stephan" Date: Thu, 23 Jun 2016 23:49:38 -0500 Subject: [PATCH] start of gitlab code review/merge bot the intent of this thing is to scan configured projects' merge requests, and automatically assign them to designated code reviewers. if someone says "LGTM" in the merge request thread, that counts as a review and the bot either selects another reviewer or assigns the thing to a designated merge approver for the final approval and merge this is most of the way there, but not done yet. things still to do: 1) more strings than "LGTM", but we should be careful to avoid things that someone might actually say 2) i'm trying to avoid it but i probably need to track the 2 of 2 reviewer message separate from the message assigning the merge request to an approver. it's plausible that a reviewer is also an approver, and if the last reviewer is a candidate approver, the script does nothing, but we probably want it to still log the 2 of 2 part. i could track the "nagging" for 2 of 2 messages, to avoid the bot repeating itself, but that seems unfortunately annoying --- dr_botzo/dr_botzo/settings.py | 1 + dr_botzo/gitlab_bot/__init__.py | 0 dr_botzo/gitlab_bot/admin.py | 8 + dr_botzo/gitlab_bot/lib.py | 158 ++++++++++++++++++ dr_botzo/gitlab_bot/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/code_review_scan.py | 22 +++ .../gitlab_bot/migrations/0001_initial.py | 33 ++++ dr_botzo/gitlab_bot/migrations/__init__.py | 0 dr_botzo/gitlab_bot/models.py | 36 ++++ requirements.txt | 2 +- 11 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 dr_botzo/gitlab_bot/__init__.py create mode 100644 dr_botzo/gitlab_bot/admin.py create mode 100644 dr_botzo/gitlab_bot/lib.py create mode 100644 dr_botzo/gitlab_bot/management/__init__.py create mode 100644 dr_botzo/gitlab_bot/management/commands/__init__.py create mode 100644 dr_botzo/gitlab_bot/management/commands/code_review_scan.py create mode 100644 dr_botzo/gitlab_bot/migrations/0001_initial.py create mode 100644 dr_botzo/gitlab_bot/migrations/__init__.py create mode 100644 dr_botzo/gitlab_bot/models.py diff --git a/dr_botzo/dr_botzo/settings.py b/dr_botzo/dr_botzo/settings.py index 5d3012b..43f8ad9 100644 --- a/dr_botzo/dr_botzo/settings.py +++ b/dr_botzo/dr_botzo/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = ( 'countdown', 'dispatch', 'facts', + 'gitlab_bot', 'ircbot', 'karma', 'markov', diff --git a/dr_botzo/gitlab_bot/__init__.py b/dr_botzo/gitlab_bot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dr_botzo/gitlab_bot/admin.py b/dr_botzo/gitlab_bot/admin.py new file mode 100644 index 0000000..e160534 --- /dev/null +++ b/dr_botzo/gitlab_bot/admin.py @@ -0,0 +1,8 @@ +"""Admin stuff for GitLab bot models.""" + +from django.contrib import admin + +from gitlab_bot.models import GitlabConfig, GitlabProjectConfig + +admin.site.register(GitlabConfig) +admin.site.register(GitlabProjectConfig) diff --git a/dr_botzo/gitlab_bot/lib.py b/dr_botzo/gitlab_bot/lib.py new file mode 100644 index 0000000..cc13e2c --- /dev/null +++ b/dr_botzo/gitlab_bot/lib.py @@ -0,0 +1,158 @@ +"""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.""" + + COUNTER_RESET_FORMAT = "Review counter reset." + COUNTER_START_FORMAT = "Please obtain {0:d} reviews." + NEW_REVIEWER_FORMAT = "{0:d} of {1:d} reviewed. Assigning to {2:s} to review." + REVIEWS_DONE_FORMAT = "Reviews conducted. Assigning to {0:s} to approve." + NOTE_COMMENT = [ + "The computer is your friend.", + "Thank you for complying.", + ] + + 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_counter_reset_message(self): + return "{0:s} {1:s} {2:s}".format(self.COUNTER_RESET_FORMAT, self.COUNTER_START_FORMAT, + 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.REVIEWS_DONE_FORMAT, random.choice(self.NOTE_COMMENT)) + + def get_code_review_acceptance_count(self, merge_request): + """Walk the provided merge request and determine the number of code review signoffs in the comments.""" + if type(merge_request) != gitlab.objects.ProjectMergeRequest: + raise TypeError("merge_request must be a ProjectMergeRequest object") + + approve_count = 0 + approvers = list() + reset_found = False + send_reset = False + 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: + # 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, maybe i already nagged about the reset") + if note.body.find(self.COUNTER_RESET_FORMAT) >= 0: + log.debug("...yup") + send_reset = False + else: + log.debug("...nope") + else: + if note.body.find("LGTM") >= 0: + log.debug("merge request '%s', note '%s' has a LGTM", merge_request.title, note.id) + approve_count += 1 + if reset_found: + log.debug("ignoring the previous reset, since we have a counter") + reset_found = False + send_reset = False + approvers.append(note.author.username) + log.debug("approval count set to %d", approve_count) + 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("setting the counter to 0, '%s' looks like a push!", note.body) + approve_count = 0 + approvers = list() + reset_found = True + send_reset = True + else: + log.debug("leaving the counter as it is, i don't think '%s' is a push", note.body) + + return approve_count, approvers, send_reset + + def scan_for_reviews(self, project, project_obj, merge_request): + log.debug("scanning merge request '%s'", merge_request.title) + + # check to see if the merge request needs a reviewer or a merge + approve_count, approvers, send_reset = self.get_code_review_acceptance_count(merge_request) + if approve_count < project.code_reviews_necessary: + 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) + excluded_candidates = approvers + [merge_request.author.username] + candidates = [x for x in project.code_reviewers.split(',') if x not in excluded_candidates] + + # if the current assignee is in candidates, then we don't need to do anything + if merge_request.assignee is None or merge_request.assignee.username not in candidates: + if len(candidates) > 0: + new_reviewer = random.choice(candidates) + log.debug("%s is the new reviewer", new_reviewer) + + # find the user object for the new reviewer + new_reviewer_obj = self.client.users.get_by_username(new_reviewer) + + # create note for the update + assign_msg = {'body': self.random_new_reviewer_message().format(approve_count, + project.code_reviews_necessary, + new_reviewer)} + self.client.project_mergerequest_notes.create(assign_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.info("%s is already assigned to a candidate reviewer", merge_request.title) + + # regardless of our decision to assign or not, if we're supposed to send the reset + # message, we should + if send_reset: + log.info("adding reset message to %s", merge_request.title) + reset_msg = {'body': self.random_counter_reset_message().format(project.code_reviews_necessary)} + self.client.project_mergerequest_notes.create(reset_msg, project_id=project_obj.id, + merge_request_id=merge_request.id) + else: + log.debug("%s has enough code reviews, it needs someone to merge it", merge_request.title) + if merge_request.assignee is not None: + log.debug("%s currently assigned to %s", merge_request.title, merge_request.assignee.username) + excluded = [merge_request.author.username] + candidates = [x for x in project.code_review_final_merge_assignees.split(',') if x not in excluded] + if merge_request.assignee is None or merge_request.assignee.username not in candidates: + if len(candidates) > 0: + new_approver = random.choice(candidates) + log.debug("%s is the new approver", new_approver) + + # find the user object for the new reviewer + new_approver_obj = self.client.users.get_by_username(new_approver) + + # create note for the update + assign_msg = {'body': self.random_reviews_done_message().format(new_approver)} + self.client.project_mergerequest_notes.create(assign_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_approver_obj.id) + else: + log.warning("no approvers left to approve %s, doing nothing", merge_request.title) diff --git a/dr_botzo/gitlab_bot/management/__init__.py b/dr_botzo/gitlab_bot/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dr_botzo/gitlab_bot/management/commands/__init__.py b/dr_botzo/gitlab_bot/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dr_botzo/gitlab_bot/management/commands/code_review_scan.py b/dr_botzo/gitlab_bot/management/commands/code_review_scan.py new file mode 100644 index 0000000..cb48931 --- /dev/null +++ b/dr_botzo/gitlab_bot/management/commands/code_review_scan.py @@ -0,0 +1,22 @@ +"""Find merge requests that need code reviewers.""" + +import logging + +from django.core.management import BaseCommand + +from gitlab_bot.lib import GitlabBot +from gitlab_bot.models import GitlabProjectConfig + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Find merge requests needing code reviewers/assignees" + + def handle(self, *args, **options): + bot = GitlabBot() + projects = GitlabProjectConfig.objects.filter(manage_merge_request_code_reviews=True) + for project in projects: + project_obj = bot.client.projects.get(project.project_id) + for merge_request in project_obj.mergerequests.list(state='opened'): + bot.scan_for_reviews(project, project_obj, merge_request) diff --git a/dr_botzo/gitlab_bot/migrations/0001_initial.py b/dr_botzo/gitlab_bot/migrations/0001_initial.py new file mode 100644 index 0000000..4800d92 --- /dev/null +++ b/dr_botzo/gitlab_bot/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='GitlabConfig', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('url', models.URLField()), + ('token', models.CharField(max_length=64)), + ], + ), + migrations.CreateModel( + name='GitlabProjectConfig', + fields=[ + ('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)), + ('project_id', models.CharField(max_length=64)), + ('manage_merge_request_code_reviews', models.BooleanField(default=False)), + ('code_reviews_necessary', models.PositiveSmallIntegerField(default=0)), + ('code_reviewers', models.TextField(blank=True, default='')), + ('code_review_final_merge_assignees', models.TextField(blank=True, default='')), + ('gitlab_config', models.ForeignKey(to='gitlab_bot.GitlabConfig', null=True)), + ], + ), + ] diff --git a/dr_botzo/gitlab_bot/migrations/__init__.py b/dr_botzo/gitlab_bot/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dr_botzo/gitlab_bot/models.py b/dr_botzo/gitlab_bot/models.py new file mode 100644 index 0000000..78211e4 --- /dev/null +++ b/dr_botzo/gitlab_bot/models.py @@ -0,0 +1,36 @@ +"""Bot/daemons for doing stuff with GitLab.""" + +import logging + +from django.db import models + +log = logging.getLogger(__name__) + + +class GitlabConfig(models.Model): + + """Maintain bot-wide settings (URL, auth key, etc.).""" + + url = models.URLField() + token = models.CharField(max_length=64) + + def __str__(self): + """String representation.""" + return "bot @ {0:s}".format(self.url) + + +class GitlabProjectConfig(models.Model): + + """Maintain settings for a particular project in GitLab.""" + + gitlab_config = models.ForeignKey('GitlabConfig', null=True) + project_id = models.CharField(max_length=64) + + manage_merge_request_code_reviews = models.BooleanField(default=False) + code_reviews_necessary = models.PositiveSmallIntegerField(default=0) + code_reviewers = models.TextField(default='', blank=True) + code_review_final_merge_assignees = models.TextField(default='', blank=True) + + def __str__(self): + """String representation.""" + return "configuration for {0:s} @ {1:s}".format(self.project_id, self.gitlab_config.url) diff --git a/requirements.txt b/requirements.txt index 7db8cd6..bb05e39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,6 +43,7 @@ pylint-common==0.2.2 pylint-django==0.7.1 pylint-plugin-utils==0.2.3 python-dateutil==2.4.2 +python-gitlab==0.13 pytz==2015.7 PyYAML==3.11 requests==2.9.1 @@ -52,7 +53,6 @@ setoptconf==0.2.0 six==1.10.0 tempora==1.4 twython==3.3.0 -wheel==0.26.0 wrapt==1.10.6 yg.lockfile==2.1 zc.lockfile==1.1.0