Backporting production changes / updating tests
[margebot.git] / plugins / marge.py
index 227e16a..0fe540f 100755 (executable)
@@ -2,59 +2,13 @@
 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, arg_botcmd, 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
-import requests
-
-
-class MargeGitlab(gitlab.Gitlab):
-    """
-    Subclass gitlab.Gitlab so extra_data args can be added
-    to the addprojecthook() and editprojecthook() methods
-    """
-
-    def __init__(self, host, token="", oauth_token="", verify_ssl=True):
-        super().__init__(host, token, oauth_token, verify_ssl)
-
-    def addprojecthook_extra(self, project_id, url, push=False, issues=False, merge_requests=False, tag_push=False, extra_data=None):
-        """
-        A copy parent addprojecthook with an extra_data field
-        """
-        data = {"id": project_id, "url": url}
-        if extra_data:
-            for ed_key, ed_value in extra_data.items():
-                data[ed_key] = ed_value
-        data['push_events'] = int(bool(push))
-        data['issues_events'] = int(bool(issues))
-        data['merge_requests_events'] = int(bool(merge_requests))
-        data['tag_push_events'] = int(bool(tag_push))
-        request = requests.post("{0}/{1}/hooks".format(self.projects_url, project_id),
-                                headers=self.headers, data=data, verify=self.verify_ssl)
-        if request.status_code == 201:
-            return request.json()
-        return False
-
-    def editprojecthook_extra(self, project_id, hook_id, url, push=False, issues=False, merge_requests=False, tag_push=False, extra_data=None):
-        """
-        A copy of the parent editprojecthook with an extra_data field
-        """
-        data = {"id": project_id, "hook_id": hook_id, "url": url}
-        if extra_data:
-            for ed_key, ed_value in extra_data.items():
-                data[ed_key] = ed_value
-        data['push_events'] = int(bool(push))
-        data['issues_events'] = int(bool(issues))
-        data['merge_requests_events'] = int(bool(merge_requests))
-        data['tag_push_events'] = int(bool(tag_push))
-        request = requests.put("{0}/{1}/hooks/{2}".format(self.projects_url, project_id, hook_id),
-                               headers=self.headers, data=data, verify=self.verify_ssl)
-        return request.status_code == 200
 
 
 def deltastr(any_delta):
@@ -143,8 +97,7 @@ 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 = MargeGitlab(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'])
@@ -166,31 +119,37 @@ 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':
-            self.log.error('unexpecting object_kind: {}'.format(request['object_kind']))
+        if request['event_type'] != 'merge_request':
+            self.log.error('unexpecting event_type: {}'.format(request['event_type']))
         elif 'opened' in request['object_attributes']['state']:
 
             if request['object_attributes']['work_in_progress']:
                 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' or 'abandoned' ignore it
+            if 'labels' in request:
+                for a_label in request['labels']:
+                    if a_label['title'] in ['never-close', 'abandoned']:
+                        self.log.info("Skipping {} notice for {} MR".format(a_label['title'], 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)
@@ -201,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):
@@ -216,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()]).astimezone(timezone.utc)
         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)
@@ -234,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 by {} {})".format(mr_attrs['web_url'], mr_attrs['author']['username'], str_open_since)
+        upvotes = mr_attrs['upvotes']
         if upvotes >= 2:
             msg += ": Has 2+ upvotes and could be merged in now"
             if warning != "":
@@ -261,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:
@@ -295,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 = {}
@@ -304,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 the MR is tagged 'never-close' or 'abandoned', ignore it
+            if 'labels' in mr_attrs and ('never-close' in mr_attrs['labels'] or 'abandoned' 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
@@ -328,7 +304,12 @@ 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
 
         # Remind each of the rooms about open MRs
         for a_room, room_msg_list in reminder_msg.items():
@@ -367,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 the MR is tagged 'never-close' or 'abandoned',  ignore it
+            if 'labels' in mr_attrs and ('never-close' in mr_attrs['labels'] or 'abandoned' 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
@@ -410,23 +401,18 @@ class Marge(BotPlugin, CrontabMixin):
 
         return {'sender': sender, 'msg_list': msg_list}
 
-    # -----------------------------------------------------------
-    # webhook maintenance commands
-
-    @arg_botcmd('rooms', type=str)
-    @arg_botcmd('repo', type=str)
+    @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):
         """
         Add the margebot webhook to a repo, and prepopulate any open MRs in the repo with margebot
-        args: repo: gitlab repo name in the 'NAMESPACE/PROJECT_NAME' format
-              rooms:  comma separates list of rooms to notify when the webhook triggers
         """
         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.getproject(repo)
+        project = self.gitlab.projects.get(repo)
         if not project:
             msg = "Couldn't find repo {}".format(repo)
             self.log.info("watchrepo: {}".format(msg))
@@ -434,40 +420,49 @@ class Marge(BotPlugin, CrontabMixin):
 
         self.log.info('project: {}'.format(project))
 
-        target_project_id = project['id']
+        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 = self.gitlab.getprojecthooks(target_project_id)
-        if not hooks:
+        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))
-                if a_hook['merge_requests_events'] and a_hook['url'].startswith(self.webhook_url):
-                    marge_hook = a_hook
-                    break
+        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['url'].split(self.webhook_url, 1)[1]
+            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:
-                hook_updated = self.gitlab.editprojecthook_extra(target_project_id, marge_hook['id'], url, merge_requests=True, extra_data={'enable_ssl_verification': False})
-                s_watch_msg = "Updating room list for {} MRs from {} to {}".format(repo, old_rooms, rooms)
+            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:
-            hook_updated = self.gitlab.addprojecthook_extra(target_project_id, url, merge_requests=True, extra_data={'enable_ssl_verification': False})
-            s_watch_msg = "Now watching for new MRs in the {} repo to the {} roomi(s)".format(repo, rooms)
-            s_action = "add"
+            try:
+                s_action = "add"
+                project.hooks.create({'url': url, 'merge_requests_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)
@@ -490,20 +485,15 @@ class Marge(BotPlugin, CrontabMixin):
                 # pragma pylint: enable=simplifiable-if-statement
             open_mrs = new_open_mrs
 
-        # If adding a new repo, check for existing opened/reopened MRs in the repo.
+        # If adding a new repo, check for existing opened MRs in the repo.
         else:
-            for state in ['opened', 'reopened']:
-                page = 1
-                mr_list = self.gitlab.getmergerequests(target_project_id, page=page, per_page=100, state=state)
-                while not mr_list and mr_list != []:
-                    for an_mr in mr_list:
-                        mr_count += 1
-                        self.log.info('watchrepo: an_mr WATS THE ID\n{}'.format(an_mr))
-                        mr_id = an_mr['id']
-                        open_mrs[(target_project_id, mr_id, rooms)] = True
-                    # Get the next page of MRs
-                    page += 1
-                    mr_list = self.gitlab.getmergerequests(target_project_id, page=page, per_page=100)
+            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
 
         self['OPEN_MRS'] = open_mrs
 
@@ -512,39 +502,18 @@ class Marge(BotPlugin, CrontabMixin):
         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."
-
+            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
-    @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):
-        """
-        Don't call this command...
-        """
-        yield "/me whispers \"All open MRs have been merged into master.\""
-        sleep(5)
-        yield "(just kidding)"
-
-    @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."
+    # 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