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
This commit is contained in:
Brian S. Stephan 2016-06-23 23:49:38 -05:00
parent 18ff814bc8
commit 8f7b477fb8
11 changed files with 259 additions and 1 deletions

View File

@ -46,6 +46,7 @@ INSTALLED_APPS = (
'countdown',
'dispatch',
'facts',
'gitlab_bot',
'ircbot',
'karma',
'markov',

View File

View File

@ -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)

158
dr_botzo/gitlab_bot/lib.py Normal file
View File

@ -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)

View File

@ -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)

View File

@ -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)),
],
),
]

View File

@ -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)

View File

@ -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