X-Git-Url: https://pwan.org/git/?p=margebot.git;a=blobdiff_plain;f=plugins%2Fmarge.py;fp=marge.py;h=968ef7ab3b6590956aa4509a5333a8f2fcd64848;hp=548eb042e13f813c14a886a3fc908a9958fad5ee;hb=37a205f220a629d3a02dd1ce6a8dc75a33242de7;hpb=0471aaab125baa8828affab441ec72caa0e44bee diff --git a/marge.py b/plugins/marge.py similarity index 51% rename from marge.py rename to plugins/marge.py index 548eb04..968ef7a 100755 --- a/marge.py +++ b/plugins/marge.py @@ -1,25 +1,36 @@ -import re +""" +Margebot: A Errbot Plugin for Gitlab MR reminders +""" from datetime import datetime, timezone +from time import sleep from dateutil import parser from dateutil.tz import tzutc -from dateutil.relativedelta import * +from dateutil.relativedelta import relativedelta from errbot import BotPlugin, botcmd, webhook -from errbot.backends import xmpp from errcron.bot import CrontabMixin -from time import sleep import gitlab + def deltastr(any_delta): + """ + Output a datetime delta in the format "x days, y hours, z minutes ago" + """ l_delta = [] - (days, hours, mins) = (any_delta.days, any_delta.seconds//3600, (any_delta.seconds//60)%60) + days = any_delta.days + hours = any_delta.seconds // 3600 + mins = (any_delta.seconds // 60) % 60 - for (k,v) in {"day": days, "hour": hours, "minute": mins}.items(): - if v == 1: - l_delta.append("1 " + k) - elif v > 1: - l_delta.append("{} {}s".format(v, k)) + for (key, val) in [("day", days), ("hour", hours), ("minute", mins)]: + if val == 1: + l_delta.append("1 " + key) + elif val > 1: + l_delta.append("{} {}s".format(val, key)) - return ",".join(l_delta) + " ago" + if l_delta == []: + retval = "now" + else: + retval = ", ".join(l_delta) + " ago" + return retval class Marge(BotPlugin, CrontabMixin): @@ -45,41 +56,54 @@ class Marge(BotPlugin, CrontabMixin): def __init__(self, *args, **kwargs): self.git_host = None self.chatroom_host = None + self.gitlab = None + self.soak_delta = None super().__init__(*args, **kwargs) def get_configuration_template(self): """ GITLAB_HOST: Host name of your gitlab server - GITLAB_ADMIN_TOKEN: PAT from an admin's https://${GIT_HOST}/profile/personal_access_tokens page. + GITLAB_ADMIN_TOKEN: PAT from an admin's https://${GIT_HOST}/profile/personal_access_tokens. CHATROOM_HOST: Chatroom host. Usually 'chatroom' + FQDN of Jabber server CRONTAB: Schedule of automated merge request checks in '%M %H %d %m %w' format VERIFY_SSL : True, False, or path to CA cert to verify cert CRONTAB_SOAK_HOURS : Don't send out reminders about MRs opened less than this many hours - """ + """ return {'GITLAB_HOST': 'gitlab.example.com', - 'GITLAB_ADMIN_TOKEN' : 'gitlab-admin-user-private-token', + 'GITLAB_ADMIN_TOKEN': 'gitlab-admin-user-private-token', 'CHATROOM_HOST': 'conference.jabber.example.com', - 'CRONTAB' : '0 11,17 * * *', - 'VERIFY_SSL' : True, - 'CRONTAB_SOAK_HOURS' : 1} + 'CRONTAB': '0 11,17 * * *', + 'VERIFY_SSL': True, + 'CRONTAB_SOAK_HOURS': 1} def check_configuration(self, configuration): + """ + Check that the plugin has been configured properly + """ super().check_configuration(configuration) def activate(self): + """ + Initialization done when the plugin is activated + """ if not self.config: self.log.info('Margebot is not configured. Forbid activation') return self.git_host = self.config['GITLAB_HOST'] self.chatroom_host = self.config['CHATROOM_HOST'] - Marge.CRONTAB = ['{} .crontab_hook'.format(self.config['CRONTAB']) ] - self.gitlab = gitlab.Gitlab(self.git_host, self.config['GITLAB_ADMIN_TOKEN'], verify_ssl=self.config['VERIFY_SSL']) + Marge.CRONTAB = ['{} .crontab_hook'.format(self.config['CRONTAB'])] + gitlab_auth_token = self.config['GITLAB_ADMIN_TOKEN'] + verify_ssl = self.config['VERIFY_SSL'] + self.gitlab = gitlab.Gitlab(self.git_host, gitlab_auth_token, verify_ssl=verify_ssl) self.activate_crontab() - self.soak_delta = relativedelta( hours = self.config['CRONTAB_SOAK_HOURS']) + self.soak_delta = relativedelta(hours=self.config['CRONTAB_SOAK_HOURS']) super().activate() def deactivate(self): + """ + Anything that needs to be tore down when the plugin is deactivated goes here. + """ super().deactivate() @webhook('/margebot//') @@ -88,38 +112,33 @@ class Marge(BotPlugin, CrontabMixin): Webhook that listens on http://:/gitlab """ - self.log.info('margebot webhook request: {}'.format(request)) - self.log.info('margebot webhook rooms {}'.format(rooms)) + self.log.info("webhook: request: {}, rooms: {}".format(request, rooms)) + self.log.info("state: {}".format(request['object_attributes']['state'])) # verify it's a merge request if request['object_kind'] != 'merge_request': - self.log.error('expecting object_kind of merge_request but got {}'.format(request['object_kind'])) - self.log.error('request: {}'.format(request)) - elif request['object_attributes']['state'] == 'opened': - - # TODO: - # - check for reopened / request['object_attributes']['action'] == 'reopn' - # (there's no 'action': 'opened' for MRs are created... - # - pop open_mrs when MRs are closed (action == close / state == closed + self.log.error('unexpecting object_kind: {}'.format(request['object_kind'])) + elif 'opened' in request['object_attributes']['state']: if request['object_attributes']['work_in_progress']: - wip = "WIP" + wip = "WIP " else: wip = "" url = request['project']['homepage'] - state = request['object_attributes']['state'] title = request['object_attributes']['title'] author_id = request['object_attributes']['author_id'] # map this to user name ... author = self.gitlab.getuser(author_id) - author_name = author['username'] + if author: + author_name = author['username'] + else: + self.log.info("unexpected author_id {}".format(author_id)) + author_name = author_id target_project_id = request['object_attributes']['target_project_id'] iid = request['object_attributes']['iid'] - user_name = request['user']['username'] # will this always be Administrator ? - - msg_template = "New Review: {} has opened a new {} MR: \"{}\"\n{}/merge_requests/{}" + msg_template = "New Review: {} has opened a new {}MR: \"{}\"\n{}/merge_requests/{}" msg = msg_template.format(author_name, wip, title, url, iid) for a_room in rooms.split(','): @@ -130,20 +149,29 @@ class Marge(BotPlugin, CrontabMixin): empty_dict = {} self['OPEN_MRS'] = empty_dict - with self.mutable('OPEN_MRS') as open_mrs: - open_mrs[(target_project_id, iid, rooms)] = True + open_mrs = self['OPEN_MRS'] + self.log.info("webhook: Saving ({}, {}, {})".format(target_project_id, iid, rooms)) + open_mrs[(target_project_id, id, rooms)] = True + self['OPEN_MRS'] = open_mrs return "OK" def mr_status_msg(self, a_mr, author=None): + """ + Create the merge request status message + """ self.log.info("mr_status_msg: a_mr: {}".format(a_mr)) now = datetime.now(timezone.utc) creation_time = parser.parse(a_mr['created_at'], tzinfos=tzutc) self.log.info("times: {}, {}, {}".format(creation_time, self.soak_delta, now)) if creation_time + self.soak_delta > now: - info_msg = "skipping: MR <{},{}> was opened less than {} hours ago".format(project, iid, soak_hours) - self.log.info(info_msg) + project = a_mr['project'] + iid = a_mr['iid'] + soak_hours = self.config['CRONTAB_SOAK_HOURS'] + info_template = "skipping: MR <{},{}> was opened less than {} hours ago" + info_msg = info_template.format(project, iid, soak_hours) + self.log.info(info_msg) return None str_open_since = deltastr(now - creation_time) @@ -159,9 +187,8 @@ class Marge(BotPlugin, CrontabMixin): else: authored = False - # TODO: Include the count of opened MR notes (does the API show resolved state ??) - - # getapprovals is only available in GitLab 8.9 EE or greater (not the open source CE version) + # getapprovals is only available in GitLab 8.9 EE or greater + # (not the open source CE version) # approvals = self.gitlab.getapprovals(a_mr['id']) # also_approved = "" # for approved in approvals['approved_by']: @@ -170,11 +197,11 @@ class Marge(BotPlugin, CrontabMixin): upvotes = a_mr['upvotes'] msg = "{} (opened {})".format(a_mr['web_url'], str_open_since) if upvotes >= 2: - msg += ": Has 2+ upvotes / Could be merge in now" + msg += ": Has 2+ upvotes and could be merged in now" if warning != "": - msg += " except {}".format(a_mr['web_url'], str_open_since, warning) + msg += " except {}.".format(warning) else: - msg += "." + msg += "." elif upvotes == 1: if authored: @@ -182,7 +209,7 @@ class Marge(BotPlugin, CrontabMixin): else: msg += ": Waiting for another upvote" if warning != "": - msg += "but {}.".format(warning) + msg += " but {}.".format(warning) else: msg += "." @@ -192,12 +219,11 @@ class Marge(BotPlugin, CrontabMixin): else: msg += ": No upvotes, please review" if warning != "": - msg += "but {}".format(warning) + msg += " but {}.".format(warning) else: msg += "." - return((creation_time, msg)) - + return (creation_time, msg) def crontab_hook(self, polled_time): """ @@ -214,117 +240,146 @@ class Marge(BotPlugin, CrontabMixin): for a_room in rooms: reminder_msg[a_room.node] = [] - msgs = "" + msgs = "" still_open_mrs = {} # Let's walk through the MRs we've seen already: - with self.mutable('OPEN_MRS') as open_mrs: - for (project, iid, notify_rooms) in open_mrs: + open_mrs = self['OPEN_MRS'] - # Lookup the MR from the project/iid - a_mr = self.gitlab.getmergerequest(project, iid) + for (project, iid, notify_rooms) in open_mrs: - self.log.info("a_mr: {} {} {} {}".format(project, iid, notify_rooms, a_mr['state'])) + # Lookup the MR from the project/iid + a_mr = self.gitlab.getmergerequest(project, iid) - # If the MR is no longer open, skip to the next MR, - # and don't include this MR in the next check - if a_mr['state'] != 'opened': - continue - else: - still_open_mrs[(project, iid, notify_rooms)] = True + self.log.info("a_mr: {} {} {} {}".format(project, iid, notify_rooms, a_mr['state'])) - msg_tuple = self.mr_status_msg(a_mr) - if msg_tuple is None: - continue + # If the MR is no longer open, skip to the next MR, + # and don't include this MR in the next check + if 'opened' not in a_mr['state']: + continue + else: + still_open_mrs[(project, iid, notify_rooms)] = True + + msg_tuple = self.mr_status_msg(a_mr) + if msg_tuple is None: + continue + + for a_room in notify_rooms.split(','): + reminder_msg[a_room].append(msg_tuple) - for a_room in notify_rooms.split(','): - reminder_msg[a_room].append(msg_tuple) + self['OPEN_MRS'] = open_mrs # Remind each of the rooms about open MRs for a_room, room_msg_list in reminder_msg.items(): - if room_msg != []: + if room_msg_list != []: + + # sort by the creation time + sorted_room_msg_list = sorted(room_msg_list, key=lambda x: x[0]) + + # extract the msgs from the tuple list + msgs = [x[1] for x in sorted_room_msg_list] - sorted_room_msg_list = sorted(room_msg_list, key=lambda x: x[0]) # sort by the creation time - msgs = [x[1] for x in sorted_room_msg_list] # extract the msgs from the tuple list - room_msg = "\n".join(msgs) # join those msgs together. + # join those msgs together. + room_msg = "\n".join(msgs) if self.config: msg_template = "These MRs need some attention:{}\n" - msg_template += "You can get an updated list with the '/msg MargeB !reviews' command." + msg_template += "You can get an updated list with the !reviews command." + to_room = a_room + '@' + self.config['CHATROOM_HOST'] msg = msg_template.format(room_msg) - self.send(self.build_identifier(a_room + '@' + self.config['CHATROOM_HOST']), msg) + self.send(self.build_identifier(to_room), msg) self['OPEN_MRS'] = still_open_mrs @botcmd() - def reviews(self, msg, args): # a command callable with !mrs + def reviews(self, msg, args): """ Returns a list of MRs that are waiting for some luv. Also returns a list of MRs that have had enough luv but aren't merged in yet. """ - ## Sending directly to Margbot: sender in the form sender@.... - ## Sending to a chatroom: snder in the form room@rooms/sender + # Sending directly to Margbot: sender in the form sender@.... + # Sending to a chatroom: snder in the form room@rooms/sender - if msg.frm.domain == self.config['CHATROOM_HOST']: - sender = msg.frm.resource + log_template = 'reviews: msg: {}, args: {}, \nmsg.frm: {}, \nmsg.msg.frm: {}, chatroom_host: {}' + self.log.info(log_template.format(msg, args, msg.frm.__dict__, dir(msg.frm), self.config['CHATROOM_HOST'])) + self.log.info('reviews: bot mode: {}'.format(self._bot.mode)) + + if self._bot.mode == "xmpp": + if msg.frm.domain == self.config['CHATROOM_HOST']: + sender = msg.frm.resource + else: + sender = msg.frm.node else: - sender = msg.frm.node + sender = str(msg.frm).split('@')[0] - if 'OPEN_MRS' not in self.keys(): + keys = self.keys() + if 'OPEN_MRS' not in keys: + self.log.error('OPEN_MRS not in {}'.format(keys)) return "No MRs to review" sender_gitlab_id = None - for user in self.gitlab.getusers(): - if user['username'] == sender: - sender_gitlab_id = user['id'] - break - - if not sender_gitlab_id: + sender_users = self.gitlab.getusers(search=(('username', sender))) + if not sender_users: self.log.error('problem mapping {} to a gitlab user'.format(sender)) - return "Sorry, I couldn't find your gitlab account." + sender_gitlab_id = None + else: + sender_gitlab_id = sender_users[0]['id'] # Walk through the MRs we've seen already: msg_list = [] msg = "" still_open_mrs = {} - with self.mutable('OPEN_MRS') as open_mrs: - for (project, iid, notify_rooms) in open_mrs: + open_mrs = self['OPEN_MRS'] + for (project, iid, notify_rooms) in open_mrs: - # Lookup the MR from the project/iid - a_mr = self.gitlab.getmergerequest(project, iid) - - # If the MR is no longer open, skip to the next MR, - # and don't include this MR in the next check - if a_mr['state'] != 'opened': - continue - else: - still_open_mrs[(project, iid, notify_rooms)] = True + # Lookup the MR from the project/iid + a_mr = self.gitlab.getmergerequest(project, iid) - msg_tuple = self.mr_status_msg(a_mr, author=sender_gitlab_id) - if msg_tuple is None: - continue + # If the MR is no longer open, skip to the next MR, + # and don't include this MR in the next check + if 'opened' not in a_mr['state']: + continue + else: + still_open_mrs[(project, iid, notify_rooms)] = True - msg_list.append(msg_tuple) + msg_tuple = self.mr_status_msg(a_mr, author=sender_gitlab_id) + if msg_tuple is None: + continue + + msg_list.append(msg_tuple) if msg_list == []: response = 'Hi {}: {}'.format(sender, 'I found no open MRs for you.') else: - sorted_msg_list = sorted(msg_list, key=lambda x: x[0]) # sort by the creation time - msgs = [x[1] for x in sorted_msg_list] # extract the msgs from the tuple list - msg = "\n".join(msgs) # join those msgs together. - response = 'Hi {}: These MRs need some attention:\n{}'.format(sender,msg) + # sort by the creation time + sorted_msg_list = sorted(msg_list, key=lambda x: x[0]) + + # extract the msgs from the tuple list + msgs = [x[1] for x in sorted_msg_list] - with self.mutable('OPEN_MRS') as open_mrs: - open_mrs = still_open_mrs + # join those msgs together. + msg = "\n".join(msgs) + response = 'Hi {}: These MRs need some attention:\n{}'.format(sender, msg) + + self['OPEN_MRS'] = still_open_mrs return response + # pragma pylint: disable=unused-argument @botcmd() def hello(self, msg, args): + """ + A simple command to check if the bot is responding + """ return "Hi there" @botcmd() def xyzzy(self, msg, args): - yield "/me whispers \"All open MRs have ben merged into master.\"" + """ + Don't call this command... + """ + yield "/me whispers \"All open MRs have been merged into master.\"" sleep(5) yield "(just kidding)" + + # pragma pylint: enable=unused-argument