bots: Add GitHub issue and pr bot
ADD
contrib_bots/lib/github_detail.md
+# GitHub detail bot
+This bot links and details issues and pull requests.
+To use it username mention the bot then type an id:
+Ids can be specified in three different forms:
+- Id only: `#2000`
+- Repository and id: `zulip#2000`
+- Owner, repository and id `zulip/zulip#2000`
+
+Both the username mention and the id can occur at any time in the message. You
+can also mention multiple ids in a single message.
+
+The bot *requires* a default owner and repository to be configured.
+The configuration file should be located at `~/.contrib_bots/github_detail.ini`.
+It should look like this:
+```ini
+[GitHub]
+owner = <repository_owner>
+repo = <repository>
+```
+If you don't want a default repository you can define one that doesn't exist, the bot
+will fail silently.
+
+The bot won't reply to any other bots to avoid infinite loops.
+
+Zulip also has a realm feature which will show information about linked sites.
+Because of this the bot escapes it's links. Once issue
+[#2968](https://github.com/zulip/zulip/issues/2968) is resolved this can be
+changed.

ADD
contrib_bots/lib/github_detail.py
+import re
+import os
+import sys
+import logging
+import six.moves.configparser
+
+import requests
+
+class GithubHandler(object):
+ '''
+ This bot provides details on github issues and pull requests when they're
+ referenced in the chat.
+ '''
+
+ CONFIG_PATH = os.path.expanduser('~/.contrib_bots/github_detail.ini')
+ GITHUB_ISSUE_URL_TEMPLATE = 'https://api.github.com/repos/{owner}/{repo}/issues/{id}'
+ GITHUB_PULL_URL_TEMPLATE = 'https://api.github.com/repos/{owner}/{repo}/pulls/{id}'
+ HANDLE_MESSAGE_REGEX = re.compile("(?:([\w-]+)\/)?([\w-]+)?#(\d+)")
+ MAX_LENGTH_OF_MESSAGE = 200
+
+ def __init__(self):
+ config = six.moves.configparser.ConfigParser()
+ with open(self.CONFIG_PATH) as config_file:
+ config.readfp(config_file)
+ if config.get('GitHub', 'owner'):
+ self.owner = config.get('GitHub', 'owner')
+ else:
+ # Allowing undefined default repos would require multiple triage_message regexs.
+ # It's simpler to require them to be defined.
+ sys.exit('Default owner not defined')
+
+ if config.get('GitHub', 'repo'):
+ self.repo = config.get('GitHub', 'repo')
+ else:
+ sys.exit('Default repo not defined')
+
+ def usage(self):
+ # type: () -> None
+ return ("This plugin displays details on github issues and pull requests. "
+ "To reference an issue or pull request usename mention the bot then "
+ "anytime in the message type its id, for example:\n"
+ "@**Github detail** #3212 zulip/#3212 zulip/zulip#3212\n"
+ "The default owner is {} and the default repo is {}.".format(self.owner, self.repo))
+
+ def triage_message(self, message, client):
+ # type: () -> bool
+ # Check the message contains a username mention, an issue idi
+ # or 'help', and that we're not replying to another bot.
+ regex = "(?:@(?:\*\*){}).+?(?:#\d+)|(?:help)".format(re.escape(client.full_name))
+ return re.search(regex, message['content']) and not message['sender_email'].endswith('-bot@zulip.com')
+
+ def format_message(self, details):
+ # type: (Dict[Text, Union[Text, int, bool]]) -> Text
+ number = details['number']
+ title = details['title']
+ link = details['html_url']
+ # Truncate if longer than 200 characters.
+ ellipsis = '...'
+
+ if len(details['body']) > self.MAX_LENGTH_OF_MESSAGE + len(ellipsis):
+ description = "{}{}".format(details['body'][:self.MAX_LENGTH_OF_MESSAGE], ellipsis)
+ else:
+ description = details['body']
+ status = details['state'].title()
+
+ return '**[{id} | {title}]({link})** - **{status}**\n```quote\n{description}\n```'\
+ .format(id=number, title=title, link=link, status=status, description=description)
+
+ def get_details_from_github(self, owner, repo, number):
+ # type: (Text, Text, Text) -> Dict[Text, Union[Text, Int, Bool]]
+ # Gets the details of an issues or pull request
+
+ # Try to get an issue, try to get a pull if that fails
+ try:
+ r = requests.get(
+ self.GITHUB_ISSUE_URL_TEMPLATE.format(owner=owner, repo=repo, id=number))
+ except requests.exceptions.RequestException as e:
+ logging.exception(e)
+ return
+
+ if r.status_code == 404:
+ try:
+ r = requests.get(
+ self.GITHUB_PULL_URL_TEMPLATE.format(owner=owner, repo=repo, id=number))
+ except requests.exceptions.RequestException as e:
+ logging.exception(e)
+ return
+
+ if r.status_code != requests.codes.ok:
+ return
+
+ return r.json()
+
+ def get_owner_and_repo(self, issue_pr):
+ owner = issue_pr.group(1)
+ repo = issue_pr.group(2)
+ if owner is None:
+ owner = self.owner
+ if repo is None:
+ repo = self.repo
+ return (owner, repo)
+
+ def handle_message(self, message, client, state_handler):
+ # type: () -> None
+ # Send help message
+ if message['content'] == '@**{}** help'.format(client.full_name):
+ client.send_message(dict(
+ type='stream',
+ to=message['display_recipient'],
+ subject=message['subject'],
+ content=self.usage(),
+ ))
+
+ # Capture owner, repo, id
+ issue_prs = re.finditer(
+ self.HANDLE_MESSAGE_REGEX, message['content'])
+
+ bot_messages = []
+ for issue_pr in issue_prs:
+ owner, repo = self.get_owner_and_repo(issue_pr)
+ details = self.get_details_from_github(owner, repo, issue_pr.group(3))
+ if details is not None:
+ bot_messages.append(self.format_message(details))
+ else:
+ bot_messages.append("Failed to find issue/pr: {owner}/{repo}#{id}".format(owner=owner, repo=repo, id=issue_pr.group(3)))
+ bot_message = '\n'.join(bot_messages)
+
+ client.send_message(dict(
+ type='stream',
+ to=message['display_recipient'],
+ subject=message['subject'],
+ content=bot_message,
+ ))
+
+handler_class = GithubHandler

bots: Add GitHub issue and pr bot