X-Git-Url: https://pwan.org/git/?a=blobdiff_plain;f=plugins%2Fmarge.py;h=0dbbffe558726647fce1f5ed3198b6ee90e35177;hb=133b2a8561644869ab29db462718f7121f54d800;hp=7f664236c27209e306c6041ae0205932137c1ad5;hpb=ea189b9619448d87c2333bed0559a166d6f53193;p=margebot.git diff --git a/plugins/marge.py b/plugins/marge.py index 7f66423..0dbbffe 100755 --- a/plugins/marge.py +++ b/plugins/marge.py @@ -2,11 +2,10 @@ 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 relativedelta -from errbot import BotPlugin, botcmd, re_botcmd, webhook +from errbot import BotPlugin, botcmd, arg_botcmd, re_botcmd, webhook from errbot.templating import tenv from errcron.bot import CrontabMixin import gitlab @@ -59,6 +58,7 @@ class Marge(BotPlugin, CrontabMixin): self.chatroom_host = None self.gitlab = None self.soak_delta = None + self.webhook_url = None super().__init__(*args, **kwargs) def get_configuration_template(self): @@ -69,13 +69,15 @@ class Marge(BotPlugin, CrontabMixin): 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 + WEBHOOK_URL : URL to use for defining MR integration in gitlab """ return {'GITLAB_HOST': 'gitlab.example.com', '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_SOAK_HOURS': 1, + 'WEBHOOK_URL': 'https://webhooks.example.com:3142/margebot/'} def check_configuration(self, configuration): """ @@ -95,10 +97,13 @@ class Marge(BotPlugin, CrontabMixin): 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.gitlab = gitlab.Gitlab('https://' + self.git_host, gitlab_auth_token, ssl_verify=verify_ssl) self.activate_crontab() self.soak_delta = relativedelta(hours=self.config['CRONTAB_SOAK_HOURS']) + self.webhook_url = self.config['WEBHOOK_URL'] + if self.webhook_url[-1] != '/': + self.webhook_url += '/' super().activate() def deactivate(self): @@ -114,7 +119,7 @@ class Marge(BotPlugin, CrontabMixin): """ self.log.info("webhook: request: {}, rooms: {}".format(request, rooms)) - self.log.info("state: {}".format(request['object_attributes']['state'])) + # self.log.info("state: {}".format(request['object_attributes']['state'])) # verify it's a merge request if request['object_kind'] != 'merge_request': @@ -125,20 +130,26 @@ class Marge(BotPlugin, CrontabMixin): wip = "WIP " else: wip = "" + url = request['project']['homepage'] title = request['object_attributes']['title'] - author_id = request['object_attributes']['author_id'] # map this to user name ... - author = self.gitlab.getuser(author_id) - if author: - author_name = author['username'] - else: - self.log.info("unexpected author_id {}".format(author_id)) + try: + author = self.gitlab.users.get(author_id) + author_name = author.attributes['username'] + except Exception as exp: + self.log.info("unexpected author_id {}, exp={}".format(author_id, exp)) author_name = author_id target_project_id = request['object_attributes']['target_project_id'] iid = request['object_attributes']['iid'] - mr_id = request['object_attributes']['id'] + + # If the MR is tagged 'never-close' ignore it + if 'labels' in request: + for a_label in request['labels']: + if a_label['title'] == 'never-close': + self.log.info("Skipping never-close notice for {} MR".format(url)) + return "OK" msg_template = "Hi there ! {} has opened a new {}MR: \"{}\"\n{}/merge_requests/{}" msg = msg_template.format(author_name, wip, title, url, iid) @@ -149,14 +160,21 @@ class Marge(BotPlugin, CrontabMixin): open_mrs = self['OPEN_MRS'] - if (target_project_id, mr_id, rooms) not in open_mrs: + if (target_project_id, iid, rooms) not in open_mrs: for a_room in rooms.split(','): if self.config: self.send(self.build_identifier(a_room + '@' + self.chatroom_host), msg) - self.log.info("webhook: Saving ({}, {}, {})".format(target_project_id, mr_id, rooms)) - open_mrs[(target_project_id, mr_id, rooms)] = True + self.log.info("webhook: Saving ({}, {}, {})".format(target_project_id, iid, rooms)) + open_mrs[(target_project_id, iid, rooms)] = True self['OPEN_MRS'] = open_mrs + + # TODO: Add check if an MR has toggled the WIP indicator + # (trigger on updates (what's that look like in request['object_attributes']['state']) + # Then check in request['changes']['title']['previous'] starts with 'WIP:' + # but not request['changes']['title']['current'], and vice versa + # See https://gitlab.com/gitlab-org/gitlab-ce/issues/53529 + return "OK" def mr_status_msg(self, a_mr, author=None): @@ -164,15 +182,16 @@ class Marge(BotPlugin, CrontabMixin): Create the merge request status message """ self.log.info("mr_status_msg: a_mr: {}".format(a_mr)) + mr_attrs = a_mr.attributes # Only weed out MRs less than the soak time for the crontab output (where author==None) now = datetime.now(timezone.utc) - creation_time = parser.parse(a_mr['created_at'], tzinfos=tzutc) + creation_time = parser.parse(mr_attrs['created_at'], tzinfos=tzutc) if not author: self.log.info("times: {}, {}, {}".format(creation_time, self.soak_delta, now)) if creation_time + self.soak_delta > now: - project_id = a_mr['target_project_id'] - mr_id = a_mr['id'] + project_id = mr_attrs['target_project_id'] + mr_id = mr_attrs['id'] soak_hours = self.config['CRONTAB_SOAK_HOURS'] info_template = "skipping: MR <{},{}> was opened less than {} hours ago" info_msg = info_template.format(project_id, mr_id, soak_hours) @@ -182,25 +201,18 @@ class Marge(BotPlugin, CrontabMixin): str_open_since = deltastr(now - creation_time) warning = "" - if a_mr['work_in_progress']: + if mr_attrs['work_in_progress']: warning = "still WIP" - elif a_mr['merge_status'] != 'can_be_merged': + elif mr_attrs['merge_status'] != 'can_be_merged': warning = "there are merge conflicts" if author: - authored = (a_mr['author']['id'] == author) + authored = (mr_attrs['author']['id'] == author) else: authored = False - # 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']: - # also_approved += "," + approved['user']['name'] - - upvotes = a_mr['upvotes'] - msg = "{} (opened {})".format(a_mr['web_url'], str_open_since) + msg = "{} (opened {})".format(mr_attrs['web_url'], str_open_since) + upvotes = mr_attrs['upvotes'] if upvotes >= 2: msg += ": Has 2+ upvotes and could be merged in now" if warning != "": @@ -209,10 +221,19 @@ class Marge(BotPlugin, CrontabMixin): msg += "." elif upvotes == 1: + self.log.error("pre-award a_mr: {}".format(dir(a_mr.awardemojis))) + award_emoji = a_mr.awardemojis.list() + self.log.info("award_emoji: {}".format(award_emoji)) + already_approved = "someone" + for an_emoji in award_emoji: + emoji_attr = an_emoji.attributes + if emoji_attr["name"] == "thumbsup": + already_approved = emoji_attr["user"]["username"] + if authored: - msg += ": Your MR is waiting for another upvote" + msg += ": Your MR has been approved by " + already_approved + " and is waiting for another upvote" else: - msg += ": Waiting for another upvote" + msg += ": Approved by " + already_approved + " and waiting for another upvote" if warning != "": msg += " but {}.".format(warning) else: @@ -243,6 +264,7 @@ class Marge(BotPlugin, CrontabMixin): # initialize the reminders rooms = self.rooms() for a_room in rooms: + self.log.info("poller: a_room.node: {}".format(a_room.node)) reminder_msg[a_room.node] = [] still_open_mrs = {} @@ -252,21 +274,27 @@ class Marge(BotPlugin, CrontabMixin): for (project_id, mr_id, notify_rooms) in open_mrs: + a_project = self.gitlab.projects.get(project_id) + if not a_project: + self.log.debug("Couldn't find project: {}".format(project_id)) + continue + # Lookup the MR from the project/id - a_mr = self.gitlab.getmergerequest(project_id, mr_id) + a_mr = a_project.mergerequests.get(mr_id) + mr_attrs = a_mr.attributes if not a_mr: self.log.debug("Couldn't find project: {}, id: {}".format(project_id, mr_id)) continue # If the MR is tagged 'never-close' ignore it - if 'labels' in a_mr and 'never-close' in a_mr['labels']: + if 'labels' in mr_attrs and 'never-close' in mr_attrs['labels']: continue - self.log.info("a_mr: {} {} {} {}".format(project_id, mr_id, notify_rooms, a_mr['state'])) + self.log.info("a_mr: {} {} {} {}".format(project_id, mr_id, notify_rooms, mr_attrs['state'])) # 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']: + if 'opened' not in mr_attrs['state']: continue else: still_open_mrs[(project_id, mr_id, notify_rooms)] = True @@ -276,7 +304,10 @@ class Marge(BotPlugin, CrontabMixin): continue for a_room in notify_rooms.split(','): - reminder_msg[a_room].append(msg_dict) + if a_room in reminder_msg: + reminder_msg[a_room].append(msg_dict) + else: + self.log.error("{} not in reminder_msg (project_id={}, mr_id={})".format(a_room, project_id, mr_id)) self['OPEN_MRS'] = open_mrs @@ -317,35 +348,45 @@ class Marge(BotPlugin, CrontabMixin): return "No MRs to review" sender_gitlab_id = None - sender_users = self.gitlab.getusers(search=(('username', sender))) - if not sender_users: - self.log.error('problem mapping {} to a gitlab user'.format(sender)) + try: + sender_users = self.gitlab.users.list(username=sender) + sender_gitlab_id = sender_users[0].attributes['id'] + except Exception as exp: + self.log.error('problem mapping {} to a gitlab user, exp={}'.format(sender, exp)) 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 = {} open_mrs = self['OPEN_MRS'] + self.log.info('open_mrs: {}'.format(open_mrs)) for (project, mr_id, notify_rooms) in open_mrs: + try: + a_project = self.gitlab.projects.get(project) + except Exception as exp: + self.log.debug("Couldn't find project: {}, exp: {}".format(project, exp)) + continue + # Lookup the MR from the project/id - a_mr = self.gitlab.getmergerequest(project, mr_id) - if not a_mr: - self.log.debug("Couldn't find project: {}, id: {}".format(project, id)) + try: + a_mr = a_project.mergerequests.get(mr_id) + mr_attrs = a_mr.attributes + except Exception as exp: + self.log.debug("Couldn't find project: {}, id: {}, exp: {}".format(project, mr_id, exp)) continue self.log.info('project: {}, id: {}, a_mr: {}'.format(project, id, a_mr)) # If the MR is tagged 'never-close' ignore it - if 'labels' in a_mr and 'never-close' in a_mr['labels']: + if 'labels' in mr_attrs and 'never-close' in mr_attrs['labels']: 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']: + if 'opened' not in mr_attrs['state']: + self.log.info('state not opened: {}'.format(mr_attrs['state'])) continue else: still_open_mrs[(project, mr_id, notify_rooms)] = True @@ -360,35 +401,122 @@ class Marge(BotPlugin, CrontabMixin): return {'sender': sender, 'msg_list': msg_list} - # pragma pylint: disable=unused-argument - @botcmd() - def hello(self, msg, args): + @arg_botcmd('rooms', type=str, help="Comma-separated room list without @conference-room suffix") + @arg_botcmd('repo', type=str, help="repo to start watching for MRs in NAMESPACE/PROJECT_NAME format") + def watchrepo(self, msg, repo, rooms): """ - A simple command to check if the bot is responding + Add the margebot webhook to a repo, and prepopulate any open MRs in the repo with margebot """ - return "Hi there" + self.log.info("msg={}".format(msg)) + self.log.info("repo={}".format(repo)) + self.log.info("rooms={}".format(rooms)) + + # get the group/repo repo, error out if it doesn't exist + project = self.gitlab.projects.get(repo) + if not project: + msg = "Couldn't find repo {}".format(repo) + self.log.info("watchrepo: {}".format(msg)) + return msg + + self.log.info('project: {}'.format(project)) + + target_project_id = project.id + + # Check is the project already includes the margebot hook + # If no hooks, will it return False or [] ? + marge_hook = None + hooks = project.hooks.list() + self.log.error("hooks = {}".format(hooks)) + if hooks is False: + msg = "Couldn't find {} hooks".format(repo) + self.log.error("watchrepo: {}".format(msg)) + return msg + else: + for a_hook in hooks: + self.log.info('a_hook: {}'.format(a_hook)) + hook_attributes = a_hook.attributes + if hook_attributes['merge_requests_events'] and hook_attributes['url'].startswith(self.webhook_url): + marge_hook = a_hook + break + + # If so replace it (or error out ?) + url = "{}{}".format(self.webhook_url, rooms) # webhooks_url will end in '/' + hook_updated = True + if marge_hook: + + old_rooms = marge_hook.attributes['url'].split(self.webhook_url, 1)[1] + if old_rooms == rooms: + msg = "Already reporting {} MRs to the {} room(s)".format(repo, rooms) + self.log.info('watchrepo: {}'.format(msg)) + return msg + else: + try: + s_action = "update" + marge_hook.attributes['url'] = url + marge_hook.save() + s_watch_msg = "Updating room list for {} MRs from {} to {}".format(repo, old_rooms, rooms) + except Exception as exp: + hook_updated = False + self.log.error("watchrepo; update hook {} raised exception {}".format(repo, exp)) + else: + try: + s_action = "add" + project.hooks.create({'url': url, 'merge_request_events': 1, 'enable_ssl_verification': True}) + s_watch_msg = "Now watching for new MRs in the {} repo to the {} room(s)".format(repo, rooms) + except Exception as exp: + hook_updated = False + self.log.error("watchrepo; create hook {} raised exception {}".format(repo, exp)) + + if not hook_updated: + msg = "Couldn't {} hook: {}".format(s_action, repo) + self.log.error("watchrepo: {}".format(msg)) + return msg - @botcmd() - def xyzzy(self, msg, args): - """ - Don't call this command... - """ - yield "/me whispers \"All open MRs have been merged into master.\"" - sleep(5) - yield "(just kidding)" + open_mrs = self['OPEN_MRS'] + mr_count = 0 + # get the open MRs in the repo + + # If updating the room list, walk through the existing MR list + if s_action == "update": + new_open_mrs = {} + for (project_id, mr_id, old_rooms) in open_mrs: + # pragma pylint: disable=simplifiable-if-statement + if project_id == target_project_id: + new_open_mrs[(project_id, mr_id, rooms)] = True + else: + new_open_mrs[(project_id, mr_id, old_rooms)] = True + # pragma pylint: enable=simplifiable-if-statement + open_mrs = new_open_mrs + + # If adding a new repo, check for existing opened MRs in the repo. + else: + for state in ['opened']: + a_project = self.gitlab.projects.get(target_project_id) + mr_list = a_project.mergerequests.list(state=state) + for an_mr in mr_list: + mr_count += 1 + mr_id = an_mr.attributes['iid'] + open_mrs[(target_project_id, mr_id, rooms)] = True - @re_botcmd(pattern=r"I blame [Mm]arge([Bb]ot)?") - def dont_blame_margebot(self, msg, match): - """ - margebot is innocent. - """ - yield "(」゚ロ゚)」NOOOooooo say it ain't so." + self['OPEN_MRS'] = open_mrs + + if mr_count == 0: + mr_msg = "No open MRs were found in the repo." + elif mr_count == 1: + mr_msg = "1 open MR was found in the repo. Run !reviews to see the updated MR list." + else: + mr_msg = "{} open MRs were found in the repo. Run !reviews to see the updated MR list.".format(mr_count) + return "{}\n{}".format(s_watch_msg, mr_msg) + + # pragma pylint: disable=unused-argument + + # Check Chucklebot for the chuckles - @re_botcmd(pattern=r"good bot") - def best_bot(self, msg, match): + @re_botcmd(pattern=r".*", prefixed=True) + def catchall(self, msg, args): """ - margebot is the best. + Don't have the bot complain about unknown commands if the first word in a msg is its name """ - yield "Best bot" + return # pragma pylint: enable=unused-argument