├── __init__.py ├── tests ├── __init__.py ├── test_fixtures.py ├── test_slack_comm.py ├── test_parse_functions.py ├── conftest.py └── test_decorators.py ├── auth_plugins ├── __init__.py ├── duo │ ├── __init__.py │ └── plugin.py └── enabled_plugins.py ├── command_plugins ├── __init__.py ├── github │ ├── __init__.py │ ├── parse_functions.py │ ├── config.py │ └── decorators.py ├── repeat │ ├── __init__.py │ ├── config.py │ └── plugin.py ├── travis_ci │ ├── __init__.py │ ├── config.py │ └── plugin.py └── enabled_plugins.py ├── OSSMETADATA ├── bot_components ├── __init__.py ├── bot_classes.py ├── slack_comm.py ├── parse_functions.py └── decorators.py ├── test-requirements.txt ├── docs ├── images │ ├── DirPath.png │ ├── AddNewConfig.png │ ├── DebugConfig.png │ ├── ProjectView.png │ ├── SourcesRoot.png │ ├── TitleScreen.png │ └── IntegratedTools.png ├── logos │ ├── HC_symbol.png │ └── HC_full_logo.png ├── docker.md ├── authentication.md ├── command_config.md ├── pycharm_debugging.md ├── travis_ci.md ├── contributing.md ├── making_plugins.md ├── installation.md └── decorators.md ├── .dockerignore ├── config.py ├── AUTHORS ├── __about__.py ├── .gitignore ├── launch_in_docker.sh ├── setup.py ├── .travis.yml ├── publish_via_travis.sh ├── Dockerfile ├── README.md ├── decrypt_creds.py ├── hubcommander.py ├── basic_install.sh └── LICENSE.txt /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /auth_plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /auth_plugins/duo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /command_plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /OSSMETADATA: -------------------------------------------------------------------------------- 1 | osslifecycle=active 2 | -------------------------------------------------------------------------------- /command_plugins/github/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /command_plugins/repeat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /command_plugins/travis_ci/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bot_components/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | SLACK_CLIENT = None 3 | -------------------------------------------------------------------------------- /command_plugins/repeat/config.py: -------------------------------------------------------------------------------- 1 | USER_COMMAND_DICT = {} 2 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==3.0.6 2 | slackclient==1.0.5 3 | -------------------------------------------------------------------------------- /docs/images/DirPath.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/images/DirPath.png -------------------------------------------------------------------------------- /docs/logos/HC_symbol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/logos/HC_symbol.png -------------------------------------------------------------------------------- /docs/images/AddNewConfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/images/AddNewConfig.png -------------------------------------------------------------------------------- /docs/images/DebugConfig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/images/DebugConfig.png -------------------------------------------------------------------------------- /docs/images/ProjectView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/images/ProjectView.png -------------------------------------------------------------------------------- /docs/images/SourcesRoot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/images/SourcesRoot.png -------------------------------------------------------------------------------- /docs/images/TitleScreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/images/TitleScreen.png -------------------------------------------------------------------------------- /docs/logos/HC_full_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/logos/HC_full_logo.png -------------------------------------------------------------------------------- /docs/images/IntegratedTools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix/hubcommander/HEAD/docs/images/IntegratedTools.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | venv* 3 | .idea 4 | .git/ 5 | .gitignore 6 | .dockerignore 7 | .DS_Store 8 | Dockerfile 9 | basic_install.sh 10 | build_docker.sh 11 | publish_via_travis.sh 12 | AUTHORS 13 | OSSMETADATA 14 | .gitkeep 15 | tests/ 16 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | 2 | ONLY_LISTEN = [ 3 | #"SLACKROOM_ID_HERE" 4 | ] 5 | 6 | IGNORE_ROOMS = [ 7 | #"SLACKROOM_ID_HERE" 8 | ] 9 | 10 | 11 | # For using AWS KMS for credential management: 12 | # KMS_REGION = "us-west-2" 13 | # KMS_CIPHERTEXT = "CTXTHERE" 14 | -------------------------------------------------------------------------------- /auth_plugins/enabled_plugins.py: -------------------------------------------------------------------------------- 1 | """ 2 | Use this file to initialize all authentication plugins. 3 | 4 | The "setup" method will be executed by hubcommander on startup. 5 | """ 6 | #from auth_plugins.duo.plugin import DuoPlugin 7 | 8 | AUTH_PLUGINS = { 9 | #"duo": DuoPlugin() 10 | } 11 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | - Mike Grima 2 | - Ayoub Dardory 3 | - bmanuel 4 | - retornam 5 | - invisiblethreat 6 | - Bryce Boe 7 | - jaybrueder <@jaybrueder> 8 | - goduncan 9 | - wbengston 10 | - dmart 11 | - viveksyngh 12 | - sgedward 13 | -------------------------------------------------------------------------------- /command_plugins/enabled_plugins.py: -------------------------------------------------------------------------------- 1 | """ 2 | Use this file to initialize all command plugins. 3 | 4 | The "setup" method will be executed by hubcommander on startup. 5 | """ 6 | from hubcommander.command_plugins.repeat.plugin import RepeatPlugin 7 | from hubcommander.command_plugins.github.plugin import GitHubPlugin 8 | #from hubcommander.command_plugins.travis_ci.plugin import TravisPlugin 9 | 10 | COMMAND_PLUGINS = { 11 | "repeat": RepeatPlugin(), 12 | "github": GitHubPlugin(), 13 | #"travisci": TravisPlugin(), 14 | } 15 | -------------------------------------------------------------------------------- /tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.tests.test_fixtures 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | 10 | 11 | def test_slack_client(slack_client): 12 | # Test the "say" method to ensure that the Slack client is working... 13 | import hubcommander.bot_components 14 | assert hubcommander.bot_components.SLACK_CLIENT == slack_client 15 | -------------------------------------------------------------------------------- /__about__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "__title__", "__summary__", "__uri__", "__version__", "__author__", 3 | "__email__", "__license__", "__copyright__", 4 | ] 5 | 6 | __title__ = "hubcommander" 7 | __summary__ = ("A Slack bot for GitHub organization management.") 8 | __uri__ = "https://github.com/Netflix/hubcommander" 9 | 10 | __version__ = "0.1.0" 11 | 12 | __author__ = "The HubCommander developers" 13 | __email__ = "netflixoss@netflix.com" 14 | 15 | __license__ = "Apache License, Version 2.0" 16 | __copyright__ = "Copyright 2017 {0}".format(__author__) 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | _build 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib64 20 | __pycache__ 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | 30 | # Translations 31 | *.mo 32 | 33 | # Mr Developer 34 | .mr.developer.cfg 35 | .project 36 | .pydevproject 37 | 38 | # breadcrumbs 39 | .DS_Store 40 | .ropeproject 41 | 42 | *.log 43 | *.log.* 44 | 45 | devlog/ 46 | venv/ 47 | venv 48 | .idea/ 49 | *.tar.gz 50 | 51 | 52 | boto.cfg 53 | *.crt 54 | *.key 55 | 56 | python-rtmbot-*/ 57 | 58 | .cache/ 59 | -------------------------------------------------------------------------------- /command_plugins/travis_ci/config.py: -------------------------------------------------------------------------------- 1 | from hubcommander.auth_plugins.enabled_plugins import AUTH_PLUGINS 2 | 3 | USER_AGENT = "YOUR_USER_AGENT_FOR_TRAVIS_CI_HERE" 4 | 5 | # Define the organizations which Travis is enabled on: 6 | # This is largely a copy and paste from the GitHub plugin config 7 | ORGS = { 8 | "Real_Org_Name_here": { 9 | "aliases": [ 10 | "some_alias_for_your_org_here" 11 | ] 12 | } 13 | } 14 | 15 | USER_COMMAND_DICT = { 16 | # This is an example for enabling Duo 2FA support for the "!EnableTravis" command: 17 | # "!EnableTravis": { 18 | # "auth": { 19 | # "plugin": AUTH_PLUGINS["duo"], 20 | # "kwargs": {} 21 | # } 22 | # } 23 | } 24 | -------------------------------------------------------------------------------- /bot_components/bot_classes.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.bot_components.bot_classes 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | 10 | 11 | class BotPlugin: 12 | def __init__(self): 13 | pass 14 | 15 | def setup(self, secrets, **kwargs): 16 | raise NotImplementedError() 17 | 18 | 19 | class BotCommander(BotPlugin): 20 | def __init__(self): 21 | super().__init__() 22 | self.commands = {} 23 | 24 | def setup(self, secrets, **kwargs): 25 | pass 26 | 27 | 28 | class BotAuthPlugin(BotPlugin): 29 | def __init__(self): 30 | super().__init__() 31 | 32 | def setup(self, secrets, **kwargs): 33 | pass 34 | 35 | def authenticate(self, *args, **kwargs): 36 | raise NotImplementedError() 37 | -------------------------------------------------------------------------------- /command_plugins/github/parse_functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.github.plugin.parse_functions 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | import validators 10 | from hubcommander.bot_components.parse_functions import ParseException 11 | 12 | 13 | def lookup_real_org(plugin_obj, org, **kwargs): 14 | try: 15 | return plugin_obj.org_lookup[org.lower()][0] 16 | except KeyError as _: 17 | raise ParseException("org", "Invalid org name sent in. Run `!ListOrgs` to see the valid orgs.") 18 | 19 | 20 | def extract_url(plugin_obj, url, **kwargs): 21 | if "|" in url: 22 | url = url.split("|")[0] 23 | 24 | return url.replace("<", "").replace(">", "") 25 | 26 | 27 | def validate_homepage(plugin_obj, homepage, **kwargs): 28 | url = extract_url(plugin_obj, homepage) 29 | 30 | if url != "": 31 | if not validators.url(url): 32 | raise ParseException("homepage", "Invalid homepage URL was sent in. It must be a well formed URL.") 33 | 34 | return url 35 | -------------------------------------------------------------------------------- /launch_in_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ################################################################################ 4 | # 5 | # 6 | # Copyright 2017 Netflix, Inc. 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | # 21 | ################################################################################ 22 | 23 | # This will build a docker image of HubCommander. 24 | echo 'DEBUG: True' > /rtmbot/rtmbot.conf 25 | echo 'SLACK_TOKEN: "'$SLACK_TOKEN'"' >> /rtmbot/rtmbot.conf 26 | echo 'ACTIVE_PLUGINS:' >> /rtmbot/rtmbot.conf 27 | echo ' - hubcommander.hubcommander.HubCommander' >> /rtmbot/rtmbot.conf 28 | 29 | # Launch it! 30 | export PYTHONIOENCODING="UTF-8" 31 | source venv/bin/activate 32 | cd /rtmbot 33 | rtmbot 34 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | HubCommander 3 | ================ 4 | 5 | A Slack bot for GitHub organization management. 6 | 7 | :copyright: (c) 2017 by Netflix, see AUTHORS for more 8 | :license: Apache, see LICENSE for more details. 9 | """ 10 | import sys 11 | import os.path 12 | 13 | from setuptools import setup, find_packages 14 | 15 | ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__))) 16 | 17 | # When executing the setup.py, we need to be able to import ourselves, this 18 | # means that we need to add the src/ directory to the sys.path. 19 | sys.path.insert(0, ROOT) 20 | 21 | about = {} 22 | with open(os.path.join(ROOT, "__about__.py")) as f: 23 | exec(f.read(), about) 24 | 25 | 26 | install_requires = [ 27 | 'boto3>=1.4.3', # For KMS support 28 | 'duo_client>=4.0', 29 | 'tabulate>=0.7.7', 30 | 'validators>=0.11.1', 31 | 'rtmbot>=0.4.0' 32 | ] 33 | 34 | tests_require = [ 35 | 'pytest==3.0.6', 36 | 'slackclient==1.0.5' 37 | ] 38 | 39 | setup( 40 | name=about["__title__"], 41 | version=about["__version__"], 42 | author=about["__author__"], 43 | author_email=about["__email__"], 44 | url=about["__uri__"], 45 | description=about["__summary__"], 46 | long_description=open(os.path.join(ROOT, 'README.md')).read(), 47 | packages=find_packages(), 48 | include_package_data=True, 49 | zip_safe=False, 50 | install_requires=install_requires, 51 | extras_require={ 52 | 'tests': tests_require, 53 | }, 54 | ) 55 | -------------------------------------------------------------------------------- /command_plugins/github/config.py: -------------------------------------------------------------------------------- 1 | from hubcommander.auth_plugins.enabled_plugins import AUTH_PLUGINS 2 | 3 | # Define the organizations that this Bot will examine. 4 | ORGS = { 5 | "Real_Org_Name_here": { 6 | "aliases": [ 7 | "some_alias_for_your_org_here" 8 | ], 9 | "public_only": False, # False means that your org supports Private repos, True otherwise. 10 | "new_repo_teams": [ # This is a list, so add all the teams you want to here... 11 | { 12 | "id": "0000000", # The team ID for the team that you want new repos to be attached to 13 | "perm": "push", # The permission, either "push", "pull", or "admin"... 14 | "name": "TeamName" # The name of the team here... 15 | } 16 | ], 17 | "collab_validation_teams": [ 18 | # If users are members of any of the teams in this list, they will 19 | # not be added as outside collaborators 20 | "TeamName" 21 | ] 22 | } 23 | } 24 | 25 | # github API Version 26 | GITHUB_VERSION = "application/vnd.github.v3+json" # Is this still needed? 27 | 28 | # GITHUB API PATH: 29 | GITHUB_URL = "https://api.github.com/" 30 | 31 | # You can use this to add/replace fields from the command_plugins dictionary: 32 | USER_COMMAND_DICT = { 33 | # This is an example for enabling Duo 2FA support for the "!SetDefaultBranch" command: 34 | # "!SetDefaultBranch": { 35 | # "auth": { 36 | # "plugin": AUTH_PLUGINS["duo"], 37 | # "kwargs": {} 38 | # } 39 | #} 40 | } 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.8" 5 | 6 | install: 7 | - pip install -e . 8 | - pip install ."[tests]" 9 | 10 | before_script: 11 | - py.test -s tests 12 | 13 | script: 14 | - ./build_docker.sh 15 | - ./publish_via_travis.sh 16 | 17 | services: 18 | - docker 19 | 20 | env: 21 | global: 22 | - BUILD_TAG=$TRAVIS_TAG 23 | - secure: "baz4o5dPTHBnQ7lLq4uhSPwduSWkErr8v5d/TKJSTegzgh4TjIGLjdjwho07LjS76bDAnd96odTf5GQEm90oMh48vJowP1TzvDdzqkWRbr9j12SY78RMAKRA+v2ZuhA/ePpomFNcJ2R+3Cppezc5W3mXEMsKrkKSHGJL8TyjzJK/gT9OvojuZ4W/QLah7zXRhycihL+DQvHvwbGjzUbtVw4aqUA5R2ETlltAzyU7GCqEnjL4skMrXCoclJd6M8eCCLaZ4wjzWSOkwEcBMGLPUHtCs0+vOGf3oGeRugv2gaMYM70TUR4b6RrmZSkP3UjIA9niSKjySjaWQD7IWr681i39xI4ThQJicO+38C2c4sfQ6p4fTVkYOCOspH5RIC2jZCvLtTaJbl1+0I5eV+AMjmKcbeI4BN8NYl/NOzLtpWxkqaEz0DrtQIoHFD/b6xzsbqxBOkyNbzste2P9myaY3Xsfur8LntZFuT6SzbpV1DbLpoxWvfCcufsF+h9s4Wgd5ghS88TVBCfUcaTaz+g04XdaL+otfABPkLx9eDmGgLCpbGc2LG8qhM+lj1ub31BmroKv61eyQlRTcZeEL84lQ9aFYIjCgdKD14t2jjNkCdo1aVimN6rJjqbp0ju89HFYMDa861Hg6adZ2FNQ68ig4sCCLCZWKGIqFqaKZmL8uIQ=" 24 | - secure: "KOV1gCE4dZc+ujgsj24iPVyUfWBTJAJJB1nRDvQr9TEWuo4cdHM2r06r4jnJ7XX1594RW6PTe628ibOJCitcbXhphGuYG3IiWTbASmwsMla6NwJqc9fbfxwf1uEyHEBDNtf3Bcw1CnEH6eV1EjIelFOeypjY4llD3TVY8W3onyaGrzKKysUJGTaP1WutSN4VH/VNeVUPvQjxnkxS9ocC0Hq2emOiLh4xZE+5k9gE38HmlsOCKtlQVgJaKA3lyi9NFwd0oSlmqFQVqksrqxiQsDTg/EHl5GE7XfA8n6QfN8o8cNKmsL/+ErVNDiC4tMYNhUhv9jH0rnT/+027QhZc5ISQO4jd7SHvInB+gqzPZ5OXZ2jX5FFoblwM9QTqgSOhPfHTs1ta8BaWx4q3GgRYZSLbmI/lWMXI5llw/z9U0HTzGu2uQ5pS5S+T4IVz/kkg1cFiAT+cBqVZ62Q6B1ITA4gs3PEdQudyXE7y7lIN8riaR6uUwENDlo9mwk9R58+PSkKNgMD2jVv19AELjK3JGiItLi1SnvOAfjFv1aan+LHYmNWH1EeVPccxXheiocnKBuZd7uPN0SqdmrGAT+W5wKjJEHF/MNwY/7WZSDGOrgW33jgCiCE82xyTrKxueT1auH7BJomVYJ3mkvIM2+m6smi73Xw18Ud+cmegmGbqGyk=" 25 | -------------------------------------------------------------------------------- /publish_via_travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ################################################################################ 4 | # 5 | # 6 | # Copyright 2017 Netflix, Inc. 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | # 21 | ################################################################################ 22 | 23 | # If this is running in Travis, AND the Python version IS NOT 3.6, then don't build 24 | # the Docker image: 25 | if [ $TRAVIS ]; then 26 | PYTHON_VERSION=$( python --version ) 27 | if [[ $PYTHON_VERSION != *"3.6"* ]]; then 28 | echo "This only publishes Docker images in the Python 3.6 Travis job" 29 | exit 0 30 | fi 31 | else 32 | echo "This can only be run from Travis CI. Exiting..." 33 | exit -1 34 | fi 35 | 36 | # Complete the Docker tasks (only if this is a tagged release): 37 | if [ -z ${TRAVIS_TAG} ]; then 38 | echo "Not publishing to Docker Hub, because this is not a tagged release." 39 | exit 0 40 | fi 41 | 42 | echo "TAGGED RELEASE: ${TRAVIS_TAG}, publishing to Docker Hub..." 43 | 44 | docker tag netflixoss/hubcommander:${TRAVIS_TAG} netflixoss/hubcommander:latest 45 | docker images 46 | docker login -u=${dockerhubUsername} -p=${dockerhubPassword} 47 | docker push netflixoss/hubcommander:$TRAVIS_TAG 48 | docker push netflixoss/hubcommander:latest 49 | 50 | echo "Completed publishing to Docker Hub." 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:xenial 2 | 3 | # Mostly Mike Grima: mgrima@netflix.com 4 | LABEL maintainer="netflixoss@netflix.com" 5 | 6 | RUN \ 7 | # Install Python: 8 | apt-get update && \ 9 | apt-get upgrade -y && \ 10 | apt-get install python3 python3-venv nano curl -y 11 | 12 | # Install the Python RTM bot itself: 13 | ARG RTM_VERSION="0.4.0" 14 | ARG RTM_PATH="python-rtmbot-${RTM_VERSION}" 15 | RUN curl -L https://github.com/slackhq/python-rtmbot/archive/${RTM_VERSION}.tar.gz > /${RTM_PATH}.tar.gz && tar xvzf python-rtmbot-0.4.0.tar.gz 16 | 17 | 18 | # Add all the other stuff to the plugins: 19 | COPY / /python-rtmbot-${RTM_VERSION}/hubcommander 20 | 21 | # Install all the things: 22 | RUN \ 23 | # Rename the rtmbot: 24 | mv /python-rtmbot-${RTM_VERSION} /rtmbot && \ 25 | # Set up the VENV: 26 | pyvenv /venv && \ 27 | # Install all the deps: 28 | /bin/bash -c "source /venv/bin/activate && pip install --upgrade pip" && \ 29 | /bin/bash -c "source /venv/bin/activate && pip install --upgrade setuptools" && \ 30 | /bin/bash -c "source /venv/bin/activate && pip install wheel" && \ 31 | /bin/bash -c "source /venv/bin/activate && pip install /rtmbot/hubcommander" && \ 32 | # The launcher script: 33 | mv /rtmbot/hubcommander/launch_in_docker.sh / && chmod +x /launch_in_docker.sh && \ 34 | rm /python-rtmbot-${RTM_VERSION}.tar.gz 35 | 36 | # DEFINE YOUR ENV VARS FOR SECRETS HERE: 37 | ENV SLACK_TOKEN="REPLACEMEINCMDLINE" \ 38 | GITHUB_TOKEN="REPLACEMEINCMDLINE" \ 39 | TRAVIS_PRO_USER="REPLACEMEINCMDLINE" \ 40 | TRAVIS_PRO_ID="REPLACEMEINCMDLINE" \ 41 | TRAVIS_PRO_TOKEN="REPLACEMEINCMDLINE" \ 42 | TRAVIS_PUBLIC_USER="REPLACEMEINCMDLINE" \ 43 | TRAVIS_PUBLIC_ID="REPLACEMEINCMDLINE" \ 44 | TRAVIS_PUBLIC_TOKEN="REPLACEMEINCMDLINE" \ 45 | DUO_HOST="REPLACEMEINCMDLINE" \ 46 | DUO_IKEY="REPLACEMEINCMDLINE" \ 47 | DUO_SKEY="REPLACEMEINCMDLINE" 48 | 49 | # Installation complete! Ensure that things can run properly: 50 | ENTRYPOINT ["/bin/bash", "-c", "./launch_in_docker.sh"] 51 | -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | HubCommander Docker Image 2 | ==================== 3 | 4 | HubCommander has a Docker image to help you get up and running as quickly and easily as possible. 5 | 6 | The image can be found on Docker Hub [here](https://hub.docker.com/r/netflixoss/hubcommander/). Alternatively, 7 | a [`Dockerfile`](https://github.com/Netflix/hubcommander/blob/master/Dockerfile) is included should you want to make your own image. 8 | 9 | ## Fetching the Docker image 10 | 11 | Run `docker pull netflixoss/hubcommander:latest` to fetch and install the Docker Hub image. 12 | 13 | ## Running the Docker image 14 | 15 | The docker image is configured to have secrets for Slack, GitHub, Duo (optional), and Travis CI (optional) passed in 16 | as environment variables passed in from `docker run`. 17 | 18 | Additionally, HubCommander requires other configuration files to be modified outside of the Docker image, and mounted 19 | into the image. An example of this is the [`github/config.py`](https://github.com/Netflix/hubcommander/blob/master/github/config.py) file. 20 | 21 | Here is an example of running HubCommander with the Duo plugin: 22 | ``` 23 | docker run -d \ 24 | -e "SLACK_TOKEN=SOME_SLACK_TOKEN" \ 25 | -e "GITHUB_TOKEN=SOME_GITHUB_TOKEN" \ 26 | -e "DUO_HOST=SOME_DUO_HOST.duosecurity.com" \ 27 | -e "DUO_IKEY=SOME_DUO_IKEY" \ 28 | -e "DUO_SKEY=SOME_DUO_SKEY" \ 29 | -v /path/to/cloned/hubcommander/auth_plugins/enabled_plugins.py:/rtmbot/hubcommander/auth_plugins/enabled_plugins.py \ 30 | -v /path/to/cloned/hubcommander/github/config.py:/rtmbot/hubcommander/github/config.py \ 31 | netflixoss/hubcommander:latest 32 | ``` 33 | 34 | The above commands passes in the secrets for Slack, GitHub, and Duo via environment variables. The 35 | [`auth_plugins/enabled_plugins.py`](https://github.com/Netflix/hubcommander/blob/master/auth_plugins/enabled_plugins.py) 36 | is modified to enable Duo, and mounted into the image. 37 | 38 | An alternative to mounting configuration files into the image is to modify the source files directly, and run the 39 | [`build_docker.sh`](https://github.com/Netflix/hubcommander/blob/master/build_docker.sh) script to re-build the Docker 40 | image to your specifications. 41 | -------------------------------------------------------------------------------- /command_plugins/repeat/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.command_plugins.repeat 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | .. moduleauthor:: Duncan Godfrey @duncangodfrey 9 | """ 10 | from hubcommander.bot_components.decorators import hubcommander_command 11 | from hubcommander.bot_components.bot_classes import BotCommander 12 | from hubcommander.bot_components.slack_comm import send_info 13 | 14 | from .config import USER_COMMAND_DICT 15 | 16 | 17 | class RepeatPlugin(BotCommander): 18 | def __init__(self): 19 | super().__init__() 20 | 21 | self.commands = { 22 | "!Repeat": { 23 | "command": "!Repeat", 24 | "func": self.repeat_command, 25 | "help": "Just repeats text passed in (for testing and debugging purposes)", 26 | "user_data_required": True, 27 | "enabled": True 28 | }, 29 | "!RepeatThread": { 30 | "command": "!RepeatThread", 31 | "func": self.repeat_thread_command, 32 | "help": "Just repeats text passed in (for testing and debugging purposes -- but in a thread)", 33 | "user_data_required": True, 34 | "enabled": True 35 | } 36 | } 37 | 38 | def setup(self, *args): 39 | # Add user-configurable arguments to the command_plugins dictionary: 40 | for cmd, keys in USER_COMMAND_DICT.items(): 41 | self.commands[cmd].update(keys) 42 | 43 | @hubcommander_command( 44 | name="!Repeat", 45 | usage="!Repeat ", 46 | description="Text to repeat to test if HubCommander is working.", 47 | required=[ 48 | dict(name="text", properties=dict(type=str, help="Text to repeat.")), 49 | ] 50 | ) 51 | def repeat_command(self, data, user_data, text): 52 | new_text = data['text'].split(' ', 1)[1] 53 | send_info(data["channel"], new_text, markdown=True, ephemeral_user=user_data["id"]) 54 | 55 | @hubcommander_command( 56 | name="!RepeatThread", 57 | usage="!RepeatThread ", 58 | description="Text to repeat to test if HubCommander is working (but threaded).", 59 | required=[ 60 | dict(name="text", properties=dict(type=str, help="Text to repeat.")), 61 | ] 62 | ) 63 | def repeat_thread_command(self, data, user_data, text): 64 | new_text = data['text'].split(' ', 1)[1] 65 | send_info(data["channel"], new_text, markdown=True, thread=data["ts"]) 66 | -------------------------------------------------------------------------------- /tests/test_slack_comm.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.tests.test_slack_comm 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | import json 10 | 11 | import pytest 12 | 13 | 14 | def actually_said(channel, attachments, slack_client): 15 | slack_client.api_call.assert_called_with("chat.postMessage", channel=channel, text=" ", 16 | attachments=json.dumps(attachments), as_user=True) 17 | 18 | 19 | def test_say(slack_client): 20 | from hubcommander.bot_components.slack_comm import say 21 | attachments = {"attachment_is": "ʕ•ᴥ•ʔ"} 22 | 23 | say("some_channel", attachments) 24 | 25 | actually_said("some_channel", attachments, slack_client) 26 | 27 | 28 | @pytest.mark.parametrize("markdown", [True, False]) 29 | def test_send_error(slack_client, markdown): 30 | from hubcommander.bot_components.slack_comm import send_error 31 | attachment = { 32 | "text": "ʕ•ᴥ•ʔ", 33 | "color": "danger" 34 | } 35 | 36 | if markdown: 37 | attachment["mrkdwn_in"] = ["text"] 38 | 39 | send_error("some_channel", attachment["text"], markdown) 40 | 41 | actually_said("some_channel", [attachment], slack_client) 42 | 43 | 44 | @pytest.mark.parametrize("markdown", [True, False]) 45 | def test_send_info(slack_client, markdown): 46 | from hubcommander.bot_components.slack_comm import send_info, WORKING_COLOR 47 | attachment = { 48 | "text": "ʕ•ᴥ•ʔ", 49 | "color": WORKING_COLOR 50 | } 51 | 52 | if markdown: 53 | attachment["mrkdwn_in"] = ["text"] 54 | 55 | send_info("some_channel", attachment["text"], markdown) 56 | 57 | actually_said("some_channel", [attachment], slack_client) 58 | 59 | 60 | @pytest.mark.parametrize("markdown", [True, False]) 61 | def test_send_success(slack_client, markdown): 62 | from hubcommander.bot_components.slack_comm import send_success 63 | attachment = { 64 | "text": "ʕ•ᴥ•ʔ", 65 | "color": "good" 66 | } 67 | 68 | if markdown: 69 | attachment["mrkdwn_in"] = ["text"] 70 | 71 | send_success("some_channel", attachment["text"], markdown) 72 | 73 | actually_said("some_channel", [attachment], slack_client) 74 | 75 | 76 | def test_get_user(slack_client): 77 | from hubcommander.bot_components.slack_comm import get_user_data 78 | result, error = get_user_data({"user": "hcommander"}) 79 | assert not error 80 | assert result["name"] == "hcommander" 81 | 82 | result, error = get_user_data({"user": "error"}) 83 | assert not result 84 | assert error 85 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | Authentication Plugins 2 | =================== 3 | HubCommander supports the ability to add authentication to the command flow. This is useful to safeguard 4 | specific commands, and additionally, add a speedbump to privileged commands. 5 | 6 | For organizations making use of Duo, a plugin is supplied that will prompt a user's device for approval 7 | before a command is executed. 8 | 9 | Making Auth Plugins 10 | ------------- 11 | See the documentation for creating Auth plugins 12 | [here](https://github.com/Netflix/hubcommander/blob/master/docs/making_plugins.md#authentication-plugins). 13 | 14 | Enabling Auth Plugins 15 | -------------- 16 | You first need to `import` the authentication plugin in the 17 | [`auth_plugins/enabled_plugins.py`](https://github.com/Netflix/hubcommander/blob/master/auth_plugins/enabled_plugins.py) 18 | file. Then, you need to instantiate the plugin in the `AUTH_PLUGINS` `dict`. 19 | 20 | Once the plugin is enabled, you then need to modify a plugin's given command to make use of the specific authentication 21 | plugin. This is done by adding a command specific configuration entry to the `USER_COMMAND_DICT`. 22 | An example for how this is configured can be found in 23 | [`github/config.py`](https://github.com/Netflix/hubcommander/blob/master/github/config.py). 24 | 25 | Enabling Duo 26 | ------------ 27 | Duo is disabled by default. To enable Duo, you will need the following information: 28 | 1. The domain name that is Duo protected 29 | 1. The Duo Host 30 | 1. The "IKEY" 31 | 1. The "SKEY" 32 | 33 | HubCommander supports multiple Duo domains. For this to work, you will need the information above 34 | for the given domain. Additionally, the secrets dictionary needs to be updated such that it has a key that starts 35 | with `DUO_`. This key needs a comma-separated list of the domain, duo host, `ikey`, and `skey`. It needs to look like: 36 | 37 | "DUO_DOMAIN_ONE": "domainone.com,YOURHOST.duosecurity.com,THEIKEY,THESKEY" 38 | "DUO_DOMAIN_TWO": "domaintwo.com,YOUROTHERHOST.duosecurity.com,THEOTHERIKEY,THEOTHERSKEY" 39 | 40 | The email address of the Slack user will determine which domain gets used. 41 | 42 | With the above information, you need to modify the secrets `dict` that is decrypted by the application 43 | on startup. 44 | 45 | Additionally, you will need to uncomment out the `import` statement in 46 | [`auth_plugins/enabled_plugins.py`](https://github.com/Netflix/hubcommander/blob/master/auth_plugins/enabled_plugins.py), 47 | and also uncomment the `"duo": DuoPlugin()` entry in `AUTH_PLUGINS`. 48 | 49 | Using Authentication for Custom Commands 50 | --------- 51 | You need to decorate methods with `@hubcommander_command`, and `@auth`. Please refer to the 52 | [making plugins](making_plugins.md) documentation for details. 53 | -------------------------------------------------------------------------------- /docs/command_config.md: -------------------------------------------------------------------------------- 1 | Command Configuration 2 | ==================== 3 | Commands in HubCommander are setup in such a way that they can be modified. 4 | 5 | All plugins in HubCommander expose commands via a `dict`. For example, 6 | see the [`github/plugin.py`](https://github.com/Netflix/hubcommander/blob/master/github/plugin.py#L23)'s 7 | `init()` method. 8 | 9 | In there, there is a `self.commands = {}`. The `dict` entry is the case-sensitive command with a nested `dict` with the 10 | command configuration. At a minimum, each command has the following fields: 11 | 12 | - `command`: The specific command (ALWAYS prefixed with `!`). This is the **SAME** as the dict entry name. 13 | This is case sensitive for the dict, but the command itself that the user executes is not 14 | case sensitive. 15 | - `help`: This is the help text for the command that will be output when running 16 | the `!help` command. 17 | - `user_data_required`: This should generally be set to `True`. This means that the function 18 | will reference the user that sent the command in (like for `@` mentions). 19 | - `enabled`: Either `True` or `False`. Disabled commands will not be reachable. 20 | - `func`: This is the actual function that will be run to process the command. 21 | 22 | Additional Configuration 23 | ------------------------ 24 | Commands can have additional configuration to disable commands and to enable authentication for them. 25 | 26 | In the plugin's respective `config.py` file, there is a `dict` named `USER_COMMAND_DICT`. This dictionary is 27 | merged with the plugin's commands in the `setup()` method on startup. This is where you will make command-specific 28 | configuration settings. 29 | 30 | ### Disabling Commands 31 | By default, all commands are enabled. To disable a command, you need to add an `enabled: False` to the 32 | `USER_COMMAND_DICT`'s entry for the command. For example to disable the ability to create new repositories, 33 | you would set the GitHub plugin's `USER_COMMAND_DICT` to contain: 34 | ``` 35 | USER_COMMAND_DICT = { 36 | "!CreateRepo": { 37 | "enabled": False 38 | } 39 | } 40 | ``` 41 | 42 | ### Enabling Authentication 43 | By default, authentication is disabled. An example of enabling authentication can be see in the GitHub plugin's 44 | [`config.py`](https://github.com/Netflix/hubcommander/blob/master/github/config.py) file. 45 | 46 | _For more details on authentication plugins, please read the [authentication plugin documentation](authentication.md)._ 47 | 48 | 49 | ### Hidden Commands 50 | 51 | If you ever retire a command, you can make it hidden from the `!Help` output. You can modify the command so that it 52 | outputs information redirecting the user to the new and supported command to utilize. 53 | 54 | To do this, in the command configuration, simply remove the `help` text. This command can stil be 55 | executed, but won't appear as a command. 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | HubCommander 2 | ===================== 3 | HubCommander Logo 4 | 5 | [![NetflixOSS Lifecycle](https://img.shields.io/badge/NetflixOSS-active-brightgreen.svg)]() 6 | [![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/Netflix/hubcommander) 7 | 8 | A user-extendable Slack bot for GitHub organization management. 9 | 10 | HubCommander provides a chat-ops means for managing GitHub organizations. 11 | It creates a simple way to perform privileged GitHub organization management tasks without granting 12 | administrative or `owner` privileges to your GitHub organization members. 13 | 14 |
15 |
16 | 17 | | Service | Master | Develop | 18 | |:-----------:|:--------:|:---------:| 19 | |Travis CI|[![Build Status](https://travis-ci.com/Netflix/hubcommander.svg?branch=master)](https://travis-ci.com/Netflix/hubcommander)|[![Build Status](https://travis-ci.com/Netflix/hubcommander.svg?branch=develop)](https://travis-ci.com/Netflix/hubcommander)| 20 | 21 | 22 | How does it work? 23 | ------------- 24 | HubCommander is based on [slackhq/python-rtmbot](https://github.com/slackhq/python-rtmbot) 25 | (currently, dependent on release [0.4.0](https://github.com/slackhq/python-rtmbot/releases/tag/0.4.0)) 26 | 27 | You simply type `!help`, and the bot will output a list of commands that the bot supports. Typing 28 | the name of the command, for example: `!CreateRepo`, will output help text on how to execute the command. 29 | 30 | At a minimum, you will need to have the following: 31 | * Python 3.5+ 32 | * Slack and Slack credentials 33 | * A GitHub organization 34 | * A GitHub bot user with ownership level privileges 35 | 36 | A Docker image is also available to help get up and running quickly. 37 | 38 | Features 39 | ------------- 40 | Out of the box, HubCommander has the following GitHub features: 41 | * Repository creation 42 | * Repository deletion 43 | * Repository description and website modification 44 | * Granting outside collaborators specific permissions to repositories 45 | * Repository default branch modification 46 | * Repository PR listing 47 | * Repository deploy Key listing/creation/deletion 48 | * Repository topics creation/deletion 49 | * Repository branch protection enabling/disabling 50 | 51 | HubCommander also features the ability to: 52 | * Enable Travis CI on a GitHub repo 53 | * Safeguard commands with 2FA via Duo 54 | 55 | You can add additional commands by creating plugins. For example, you can create a plugin to invite users 56 | to your organizations. 57 | 58 | HubCommander also supports Slack ephemeral messages and threads. 59 | 60 | 61 | Installation Documentation 62 | ----------- 63 | Please see the documentation [here](docs/installation.md) for details. 64 | 65 | 66 | Contributing 67 | --------------- 68 | If you are interested in contributing to HubCommander, please review the [contributing documentation](docs/contributing.md). 69 | 70 | -------------------------------------------------------------------------------- /tests/test_parse_functions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_preformat_args(): 5 | from hubcommander.bot_components.parse_functions import preformat_args 6 | test_string = "!TheCommand [argOne] {argTwo}" 7 | result = preformat_args(test_string) 8 | assert len(result) == 2 9 | assert result[0] == "argone" 10 | assert result[1] == "argtwo" 11 | 12 | 13 | def test_preformat_args_with_spaces(): 14 | from hubcommander.bot_components.parse_functions import preformat_args_with_spaces 15 | test_string = "!TheCommand [argOne] {argTwo} “argThree” \"argFour\" \"argFive\"" 16 | result = preformat_args_with_spaces(test_string, 3) 17 | assert len(result) == 5 18 | assert result[0] == "argone" 19 | assert result[1] == "argtwo" 20 | assert result[2] == "argThree" 21 | assert result[3] == "argFour" 22 | assert result[4] == "argFive" 23 | 24 | test_string_two = "!SetDescription Netflix HubCommander \"A Slack bot for GitHub ‘management’\"" 25 | result = preformat_args_with_spaces(test_string_two, 1) 26 | assert len(result) == 3 27 | assert result[2] == "A Slack bot for GitHub 'management'" 28 | 29 | test_failures = [ 30 | "!SomeCommand \"only one quote", 31 | "!SomeCommand \"three quotes\"\"", 32 | "!SomeCommand no quotes", 33 | ] 34 | for tf in test_failures: 35 | with pytest.raises(SystemExit): 36 | preformat_args_with_spaces(tf, 1) 37 | 38 | # Failure with incorrect number of quoted params: 39 | with pytest.raises(SystemExit): 40 | preformat_args_with_spaces(test_string_two, 3) 41 | 42 | 43 | def test_extract_repo_name(): 44 | from hubcommander.bot_components.parse_functions import extract_repo_name 45 | test_strings = { 46 | "www.foo.com": "", 47 | "foo": "foo", 48 | "HubCommander": "HubCommander", 49 | "netflix.github.io": "" 50 | } 51 | 52 | for repo, uri in test_strings.items(): 53 | assert extract_repo_name(None, uri) == repo 54 | 55 | 56 | def test_extract_multiple_repo_names(): 57 | from hubcommander.bot_components.parse_functions import extract_multiple_repo_names 58 | 59 | test_repos = ["www.foo.com", "foo", "HubCommander", "netflix.github.io"] 60 | test_repo_strings = ",foo,HubCommander," \ 61 | "" 62 | 63 | assert extract_multiple_repo_names(None, test_repo_strings) == test_repos 64 | 65 | 66 | def test_parse_toggles(): 67 | from hubcommander.bot_components.parse_functions import parse_toggles, TOGGLE_ON_VALUES, \ 68 | TOGGLE_OFF_VALUES, ParseException 69 | 70 | for toggle in TOGGLE_ON_VALUES: 71 | assert parse_toggles(None, toggle) 72 | 73 | for toggle in TOGGLE_OFF_VALUES: 74 | assert not parse_toggles(None, toggle) 75 | 76 | with pytest.raises(ParseException): 77 | parse_toggles(None, "NotAProperToggle") 78 | -------------------------------------------------------------------------------- /decrypt_creds.py: -------------------------------------------------------------------------------- 1 | """ 2 | ADD WHATEVER CODE YOU NEED TO DO HERE TO DECRYPT CREDENTIALS FOR USE OF YOUR BOT! 3 | """ 4 | 5 | 6 | def get_credentials(): 7 | # Here is a KMS example: (uncomment to make work) 8 | # return kms_decrypt() 9 | 10 | # For Docker, encryption is assumed to be happening outside of this, and the secrets 11 | # are instead being passed in as environment variables: 12 | import os 13 | 14 | creds = { 15 | # Minimum 16 | "SLACK": os.environ["SLACK_TOKEN"], 17 | 18 | # Optional: 19 | "GITHUB": os.environ.get("GITHUB_TOKEN"), 20 | 21 | # These are named the same as the env var, but these are the env vars should you 22 | # want to leverage the feature: 23 | # "TRAVIS_PRO_USER": os.environ.get("TRAVIS_PRO_USER"), 24 | # "TRAVIS_PRO_ID": os.environ.get("TRAVIS_PRO_ID"), 25 | # "TRAVIS_PRO_TOKEN": os.environ.get("TRAVIS_PRO_TOKEN"), 26 | # "TRAVIS_PUBLIC_USER": os.environ.get("TRAVIS_PUBLIC_USER"), 27 | # "TRAVIS_PUBLIC_ID": os.environ.get("TRAVIS_PUBLIC_ID"), 28 | # "TRAVIS_PUBLIC_TOKEN": os.environ.get("TRAVIS_PUBLIC_TOKEN"), 29 | 30 | # DUO_...NAME_OF_DUO_CRED: "domain-that-is-duod.com,duo_host,duo_ikey,duo_skey" 31 | 32 | # ADD MORE HERE... 33 | } 34 | 35 | # Just adds the rest for freely-named ones (Like for Duo): 36 | for variable, value in os.environ.items(): 37 | creds[variable] = value 38 | 39 | return creds 40 | 41 | 42 | # def kms_decrypt(): 43 | # """ 44 | # This is a method to decrypt credentials utilizing on-instance credentials 45 | # for AWS KMS. Please review AWS documentation for details. 46 | # 47 | # The secret should be a JSON blob of the secrets that are required. 48 | # :return: A Dict with the secrets in them. 49 | # """ 50 | # import boto3 51 | # import base64 52 | # import json 53 | # from config import KMS_REGION, KMS_CIPHERTEXT 54 | # 55 | # kms_client = boto3.client("kms", region_name=KMS_REGION) 56 | # decrypt_res = kms_client.decrypt(CiphertextBlob=bytes(base64.b64decode(KMS_CIPHERTEXT))) 57 | # return json.loads(decrypt_res["Plaintext"].decode("utf-8")) 58 | 59 | 60 | """ 61 | Sample KMS encryption: 62 | -------------------- 63 | import boto3 64 | import json 65 | import base64 66 | 67 | kms_client = boto3.client("kms", region_name=KMS_REGION) 68 | account_id = "YOUR ACCOUNT ID HERE" 69 | key_id = "YOUR KEY ID HERE" 70 | kms_arn = "arn:aws:kms:{region}:{account_id}:key/{key_id}".format(region=KMS_REGION, account_id=account_id, key_id=key_id) 71 | 72 | secrets_to_encrypt = { 73 | "SLACK": "SLACK TOKEN HERE", 74 | "GITHUB": "GITHUB TOKEN HERE", 75 | "TRAVIS_PRO_USER": "GitHub ID of GitHub account with access to Travis Pro", 76 | "TRAVIS_PRO_ID": "The ID of the Travis user. Use the Travis API to get this (for Pro)", 77 | "TRAVIS_PRO_TOKEN": "Use the Travis API to get the Travis token (for the Travis Pro account)", 78 | "TRAVIS_PUBLIC_USER": "GitHub ID of GitHub account with access to Travis Public", 79 | "TRAVIS_PUBLIC_ID": "The ID of the Travis user. Use the Travis API to get this (for Public)", 80 | "TRAVIS_PUBLIC_TOKEN": Use the Travis API to get the Travis token (for the Travis Public account)", 81 | "DUO_YOUR_DOMAIN": "your-domain-here.com,xxxxxxxx.duosecurity.com,THEDUOIKEY,THEDUOSKEY" 82 | } 83 | 84 | encrypt_res = kms_client.encrypt(KeyId=kms_arn, Plaintext=bytes(json.dumps(secrets_to_encrypt, indent=4), "utf-8")) 85 | 86 | # Your results are: 87 | print("The encrypted PTXT in B64:") 88 | print(base64.b64encode(encrypt_res["CiphertextBlob"]).decode("utf-8")) 89 | """ 90 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.tests.conftest 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | from unittest.mock import MagicMock 10 | 11 | import pytest 12 | import slackclient 13 | from bot_components.bot_classes import BotAuthPlugin 14 | 15 | USER_DATA = { 16 | "ok": True, 17 | "user": { 18 | "profile": { 19 | "first_name": "Hub", 20 | "last_name": "Commander", 21 | "phone": "1112223333", 22 | "image_1024": "https://some/path.jpg", 23 | "title": "Awesome", 24 | "real_name": "HubCommander", 25 | "image_24": "https://some/path.jpg", 26 | "image_original": "https://some/path.jpg", 27 | "real_name_normalized": "HubCommander", 28 | "image_512": "https://some/path.jpg", 29 | "image_72": "https://some/path.jpg", 30 | "image_32": "https://some/path.jpg", 31 | "image_48": "https://some/path.jpg", 32 | "skype": "", 33 | "avatar_hash": "123456789abc", 34 | "email": "hc@hubcommander", 35 | "image_192": "https://some/path.jpg" 36 | }, 37 | "status": None, 38 | "tz": "America/Los_Angeles", 39 | "name": "hcommander", 40 | "deleted": False, 41 | "is_bot": False, 42 | "tz_offset": -28800, 43 | "real_name": "Hub Commander", 44 | "color": "7d414c", 45 | "team_id": "T12345678", 46 | "is_admin": False, 47 | "is_ultra_restricted": False, 48 | "is_restricted": False, 49 | "is_owner": False, 50 | "tz_label": "Pacific Standard Time", 51 | "id": "U12345678", 52 | "is_primary_owner": False 53 | } 54 | } 55 | 56 | 57 | def pytest_runtest_makereport(item, call): 58 | if "incremental" in item.keywords: 59 | if call.excinfo is not None: 60 | parent = item.parent 61 | parent._previousfailed = item 62 | 63 | 64 | def pytest_addoption(parser): 65 | parser.addoption("--hubcommanderconfig", help="override the default test config") 66 | 67 | 68 | def slack_client_side_effect(*args, **kwargs): 69 | if args[0] == "users.info": 70 | if kwargs["user"] == "error": 71 | return {"error": "Error"} 72 | return USER_DATA 73 | 74 | 75 | @pytest.fixture(scope="function") 76 | def slack_client(): 77 | import hubcommander.bot_components.slack_comm 78 | sc = slackclient.SlackClient("testtoken") 79 | sc.api_call = MagicMock(side_effect=slack_client_side_effect) 80 | 81 | # Need to fix both: 82 | hubcommander.bot_components.SLACK_CLIENT = sc 83 | hubcommander.bot_components.slack_comm.bot_components.SLACK_CLIENT = sc 84 | 85 | return sc 86 | 87 | 88 | @pytest.fixture(scope="function") 89 | def user_data(slack_client): 90 | import hubcommander.bot_components.slack_comm 91 | hubcommander.bot_components.slack_comm.bot_components.SLACK_CLIENT = slack_client 92 | 93 | from bot_components.slack_comm import get_user_data 94 | return get_user_data(USER_DATA)[0] 95 | 96 | 97 | @pytest.fixture(scope="function") 98 | def auth_plugin(): 99 | class TestAuthPlugin(BotAuthPlugin): 100 | def __init__(self): 101 | super().__init__() 102 | 103 | def authenticate(self, data, user_data, should_auth=False): 104 | return should_auth 105 | 106 | return TestAuthPlugin() 107 | -------------------------------------------------------------------------------- /bot_components/slack_comm.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.bot_components.slack_comm 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | import json 10 | 11 | from hubcommander import bot_components 12 | 13 | # A nice color to output 14 | WORKING_COLOR = "#439FE0" 15 | 16 | 17 | def say(channel, attachments, text=None, ephemeral_user=None, thread=None): 18 | """ 19 | Sends a message (with attachments) to Slack. Use the send_* methods instead. 20 | :param channel: 21 | :param attachments: 22 | :param text: 23 | :param ephemeral_user:ID of the user who will receive the ephemeral message 24 | :param thread: 25 | :return: 26 | """ 27 | kwargs_to_send = { 28 | "channel": channel, 29 | "text": text if text else " ", 30 | "attachments": json.dumps(attachments), 31 | "as_user": True 32 | } 33 | verb = "chat.postMessage" 34 | 35 | if ephemeral_user: 36 | kwargs_to_send["user"] = ephemeral_user 37 | verb = "chat.postEphemeral" 38 | 39 | if thread: 40 | kwargs_to_send["thread_ts"] = thread 41 | 42 | bot_components.SLACK_CLIENT.api_call(verb, **kwargs_to_send) 43 | 44 | 45 | def send_error(channel, text, markdown=False, ephemeral_user=None, thread=None): 46 | """ 47 | Sends an "error" message to Slack. 48 | :param channel: 49 | :param text: 50 | :param markdown: If True, then look for markdown in the message. 51 | :param ephemeral_user:ID of the user who will receive the ephemeral message 52 | :param thread: 53 | :return: 54 | """ 55 | attachment = { 56 | "text": text, 57 | "color": "danger", 58 | } 59 | 60 | if markdown: 61 | attachment["mrkdwn_in"] = ["text"] 62 | 63 | say(channel, [attachment], ephemeral_user=ephemeral_user, thread=thread) 64 | 65 | 66 | def send_info(channel, text, markdown=False, ephemeral_user=None, thread=None): 67 | """ 68 | Sends an "info" message to Slack. 69 | :param channel: 70 | :param text: 71 | :param markdown: If True, then look for markdown in the message. 72 | :param ephemeral_user:ID of the user who will receive the ephemeral message 73 | :param thread: 74 | :return: 75 | """ 76 | attachment = { 77 | "text": text, 78 | "color": WORKING_COLOR, 79 | } 80 | 81 | if markdown: 82 | attachment["mrkdwn_in"] = ["text"] 83 | 84 | say(channel, [attachment], ephemeral_user=ephemeral_user, thread=thread) 85 | 86 | 87 | def send_success(channel, text, markdown=False, ephemeral_user=None, thread=None): 88 | """ 89 | Sends an "success" message to Slack. 90 | :param channel: 91 | :param text: 92 | :param markdown: If True, then look for markdown in the message. 93 | :param ephemeral_user:ID of the user who will receive the ephemeral message 94 | :param thread: 95 | :return: 96 | """ 97 | attachment = { 98 | "text": text, 99 | "color": "good", 100 | } 101 | 102 | if markdown: 103 | attachment["mrkdwn_in"] = ["text"] 104 | 105 | say(channel, [attachment], ephemeral_user=ephemeral_user, thread=thread) 106 | 107 | 108 | def send_raw(channel, text, ephemeral_user=None, thread=None): 109 | """ 110 | Sends an "info" message to Slack. 111 | :param channel: 112 | :param text: 113 | :param ephemeral_user:ID of the user who will receive the ephemeral message 114 | :param thread: 115 | :return: 116 | """ 117 | say(channel, None, text, ephemeral_user=ephemeral_user, thread=thread) 118 | 119 | 120 | def get_user_data(data): 121 | """ 122 | Gets information about the calling user from the Slack API. 123 | NOTE: Must be called after get_tokens() 124 | 125 | :param data: 126 | :return: 127 | """ 128 | result = bot_components.SLACK_CLIENT.api_call("users.info", user=data["user"]) 129 | if result.get("error"): 130 | return None, result["error"] 131 | 132 | else: 133 | return result["user"], None 134 | -------------------------------------------------------------------------------- /auth_plugins/duo/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.auth_plugins.duo.plugin 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | import json 10 | 11 | from duo_client.client import Client 12 | 13 | from hubcommander.bot_components.bot_classes import BotAuthPlugin 14 | from hubcommander.bot_components.slack_comm import send_info, send_error, send_success 15 | 16 | 17 | class InvalidDuoResponseError(Exception): 18 | pass 19 | 20 | 21 | class CantDuoUserError(Exception): 22 | pass 23 | 24 | 25 | class NoSecretsProvidedError(Exception): 26 | pass 27 | 28 | 29 | class DuoPlugin(BotAuthPlugin): 30 | def __init__(self): 31 | super().__init__() 32 | 33 | self.clients = {} 34 | 35 | def setup(self, secrets, **kwargs): 36 | for variable, secret in secrets.items(): 37 | if "DUO_" in variable: 38 | domain, host, ikey, skey = secret.split(",") 39 | self.clients[domain] = Client(ikey, skey, host) 40 | 41 | if not len(self.clients): 42 | raise NoSecretsProvidedError("Must provide secrets to enable authentication.") 43 | 44 | def authenticate(self, data, user_data, **kwargs): 45 | # Which domain does this user belong to? 46 | domain = user_data["profile"]["email"].split("@")[1] 47 | if not self.clients.get(domain): 48 | send_error(data["channel"], "💀 @{}: Duo in this bot is not configured for the domain: `{}`. It needs " 49 | "to be configured for you to run this command." 50 | .format(user_data["name"], domain), markdown=True, thread=data["ts"]) 51 | return False 52 | 53 | send_info(data["channel"], "🎟 @{}: Sending a Duo notification to your device. You must approve!" 54 | .format(user_data["name"]), markdown=True, ephemeral_user=user_data["id"]) 55 | 56 | try: 57 | result = self._perform_auth(user_data, self.clients[domain]) 58 | except InvalidDuoResponseError as idre: 59 | send_error(data["channel"], "💀 @{}: There was a problem communicating with Duo. Got this status: {}. " 60 | "Aborting..." 61 | .format(user_data["name"], str(idre)), thread=data["ts"], markdown=True) 62 | return False 63 | 64 | except CantDuoUserError as _: 65 | send_error(data["channel"], "💀 @{}: I can't Duo authenticate you. Please consult with your identity team." 66 | " Aborting..." 67 | .format(user_data["name"]), thread=data["ts"], markdown=True) 68 | return False 69 | 70 | except Exception as e: 71 | send_error(data["channel"], "💀 @{}: I encountered some issue with Duo... Here are the details: ```{}```" 72 | .format(user_data["name"], str(e)), thread=data["ts"], markdown=True) 73 | return False 74 | 75 | if not result: 76 | send_error(data["channel"], "💀 @{}: Your Duo request was rejected. Aborting..." 77 | .format(user_data["name"]), markdown=True, thread=data["ts"]) 78 | return False 79 | 80 | # All Good: 81 | send_success(data["channel"], "🎸 @{}: Duo approved! Completing request..." 82 | .format(user_data["name"]), markdown=True, ephemeral_user=user_data["id"]) 83 | return True 84 | 85 | def _perform_auth(self, user_data, client): 86 | # Push to devices: 87 | duo_params = { 88 | "username": user_data["profile"]["email"], 89 | "factor": "push", 90 | "device": "auto" 91 | } 92 | response, data = client.api_call("POST", "/auth/v2/auth", duo_params) 93 | result = json.loads(data.decode("utf-8")) 94 | 95 | if response.status != 200: 96 | raise InvalidDuoResponseError(response.status) 97 | 98 | if result["stat"] != "OK": 99 | raise CantDuoUserError() 100 | 101 | if result["response"]["result"] == "allow": 102 | return True 103 | 104 | return False 105 | -------------------------------------------------------------------------------- /hubcommander.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | from rtmbot.core import Plugin 10 | 11 | from hubcommander.auth_plugins.enabled_plugins import AUTH_PLUGINS 12 | from hubcommander.bot_components.slack_comm import get_user_data, send_error, send_info 13 | from hubcommander.command_plugins.enabled_plugins import COMMAND_PLUGINS 14 | from hubcommander.config import IGNORE_ROOMS, ONLY_LISTEN 15 | from hubcommander.decrypt_creds import get_credentials 16 | 17 | HELP_TEXT = [] 18 | 19 | 20 | def print_help(data): 21 | text = "I support the following commands:\n" 22 | 23 | for txt in HELP_TEXT: 24 | text += txt 25 | 26 | text += "`!Help` - This command." 27 | 28 | send_info(data["channel"], text, markdown=True) 29 | 30 | 31 | COMMANDS = { 32 | "!help": {"func": print_help, "user_data_required": False}, 33 | } 34 | 35 | 36 | class HubCommander(Plugin): 37 | def __init__(self, **kwargs): 38 | super(HubCommander, self).__init__(**kwargs) 39 | setup(self.slack_client) 40 | 41 | def process_message(self, data): 42 | """ 43 | The Slack Bot's only required method -- checks if the message involves this bot. 44 | :param data: 45 | :return: 46 | """ 47 | if data["channel"] in IGNORE_ROOMS: 48 | return 49 | 50 | if len(ONLY_LISTEN) > 0 and data["channel"] not in ONLY_LISTEN: 51 | return 52 | 53 | # Only process if it starts with one of our GitHub commands: 54 | command_prefix = data["text"].split(" ")[0].lower() 55 | if COMMANDS.get(command_prefix): 56 | process_the_command(data, command_prefix) 57 | 58 | 59 | def process_the_command(data, command_prefix): 60 | """ 61 | Will perform all command_plugins duties if a command_plugins arrived. 62 | 63 | :param data: 64 | :param command_prefix: 65 | :return: 66 | """ 67 | # Reach out to slack to get the user's information: 68 | user_data, error = get_user_data(data) 69 | if error: 70 | send_error(data["channel"], "ERROR: Unable to communicate with the Slack API. Error:\n{}".format(error)) 71 | return 72 | 73 | # Execute the message: 74 | if COMMANDS[command_prefix]["user_data_required"]: 75 | COMMANDS[command_prefix]["func"](data, user_data) 76 | 77 | else: 78 | COMMANDS[command_prefix]["func"](data) 79 | 80 | 81 | def setup(slackclient): 82 | """ 83 | This is called by the Slack RTM Bot to initialize the plugin. 84 | 85 | This contains code to load all the secrets that are used by all the other services. 86 | :return: 87 | """ 88 | # Need to open the secrets file: 89 | secrets = get_credentials() 90 | 91 | from . import bot_components 92 | bot_components.SLACK_CLIENT = slackclient 93 | 94 | print("[-->] Enabling Auth Plugins") 95 | for name, plugin in AUTH_PLUGINS.items(): 96 | print("\t[ ] Enabling Auth Plugin: {}".format(name)) 97 | plugin.setup(secrets) 98 | print("\t[+] Successfully enabled auth plugin \"{}\"".format(name)) 99 | print("[✔] Completed enabling auth plugins plugins.") 100 | 101 | print("[-->] Enabling Command Plugins") 102 | 103 | # Register the command_plugins plugins: 104 | for name, plugin in COMMAND_PLUGINS.items(): 105 | print("[ ] Enabling Command Plugin: {}".format(name)) 106 | plugin.setup(secrets) 107 | for cmd in plugin.commands.values(): 108 | if cmd["enabled"]: 109 | print("\t[+] Adding command: \'{cmd}\'".format(cmd=cmd["command"])) 110 | COMMANDS[cmd["command"].lower()] = cmd 111 | 112 | # Hidden commands: don't show on the help: 113 | if cmd.get("help"): 114 | HELP_TEXT.append("`{cmd}` - {help}\n".format(cmd=cmd["command"], help=cmd["help"])) 115 | else: 116 | print("\t[!] Not adding help text for hidden command: {}".format(cmd["command"])) 117 | else: 118 | print("\t[/] Skipping disabled command: \'{cmd}\'".format(cmd=cmd["command"])) 119 | print("[+] Successfully enabled command plugin \"{}\"".format(name)) 120 | 121 | print("[✔] Completed enabling command plugins.") 122 | -------------------------------------------------------------------------------- /docs/pycharm_debugging.md: -------------------------------------------------------------------------------- 1 | Debugging with Pycharm 2 | ======================= 3 | This guide explains how to use [PyCharm](https://www.jetbrains.com/pycharm/) to debug HubCommander. 4 | 5 | This guide assumes that you: 6 | 1. Have read the [contributing document](contributing.md), configured Slack, and have a working Python 3 development environment. 7 | 1. Have installed HubCommander 8 | 9 | Once you complete those two steps, download and install PyCharm. 10 | 11 | 12 | Get PyCharm configured 13 | ---------------------- 14 | Follow the instructions below to get PyCharm configured and working properly: 15 | 16 | 1. Open PyCharm. At the title screen click `Open` 17 | 18 | ![PyCharm Title Screen](images/TitleScreen.png) 19 | 20 | 1. Navigate to the directory that _contains_ the HubCommander code. Which, if following the contributing and installation 21 | instructions, will be located in a directory named `python-rtmbot-0.4.0`. You want to open the encompassing directory. 22 | It should look like: 23 | 24 | ![Directory Browser](images/DirPath.png) 25 | 26 | 1. You will then be presented with a new PyCharm project. On the left-side of the screen, there is the Project Viewer. 27 | It should look similar to this: 28 | 29 | ![Directory Browser](images/ProjectView.png) 30 | 31 | 1. Right-click on the `hubcommander` directory in the project viewer > `Mark Directory as` > `Sources Root` 32 | 33 | ![Sources Root](images/SourcesRoot.png) 34 | 35 | 1. On macOS, type `⌘,` -- on Linux and Windows, go to File > Settings. Next, type `Project Interpreter` in the search box. 36 | Verify that it is set to the virtual environment that was created by the installation script. 37 | If not, then click the gears icon > `Add Local`, and then navigate to the `venv/bin/python` that was created by the script. 38 | Click `OK` to save and close the window. 39 | 40 | 41 | Unit Tests First 42 | --------------- 43 | We will now make sure that [py.test](https://docs.pytest.org/en/latest/contents.html) can run properly. 44 | 45 | If you did not already, follow the instructions in the `Install the unit test requirements` section of the [contributing guide](contributing.md#install-the-unit-test-requirements) 46 | to set up all the required testing dependencies. 47 | 48 | Next, to configure PyCharm for tests, open the PyCharm preferences, and type `default test runner` in the search box. In 49 | the drop-down for `Default test runner`, select `py.test`, then click OK. 50 | 51 | ![Integrated Tools](images/IntegratedTools.png) 52 | 53 | Then, to test that it is working, expand `hubcommander` in the project explorer. Right-click on `tests`, and click 54 | `Debug py.test in tests`. It should hopefully report that a number > 19 tests pass. 55 | 56 | 57 | Standard debugging 58 | ----------------- 59 | If you want to debug a feature, you can place breakpoints and then step through them. This can be immensely helpful at 60 | debugging what's happening under the hood. 61 | 62 | To get HubCommander runnable, you will need to make a debug configuration in PyCharm. This is somewhat identical to the 63 | unit test section above, but will require you to define environment variables with the secrets in them. 64 | 65 | ****🚨⚠️CAUTION CAUTION CAUTION⚠️🚨**** For you to run against Slack, GitHub, Duo and the like -- you will need to have PyCharm store environment 66 | variables with secrets in them. Please ensure that your computer is properly secured (lock screens and encrypted drives, to name a few). 67 | If your system is ever stolen, these secrets will need to be rotated! 68 | 69 | Follow the these steps: 70 | 71 | 1. To configure the debug configuration, go to `Run` > `Edit Configurations`, and a new Python configuration: 72 | 73 | ![Python Config](images/AddNewConfig.png) 74 | 75 | 1. Under `Script`, you want to navigate to your `venv/bin/rtmbot`. 76 | 77 | 1. Under `Environment` > `Environment Variables`, you want to click the three dots (...), and click the green `+` to add in, 78 | at a minimum, the following: 79 | - `PYTHONIOENCODING`: `"UTF-8"` 80 | - `SLACK_TOKEN`: `YOUR-SLACK-TOKEN-HERE` 81 | - ... and any other tokens and secrets that need to be present for you to debug 82 | 83 | 1. Set the `Working Directory` to the `python-rtmbot-0.4.0` directory. 84 | 85 | 1. Lastly... Set a name for the configuration to `Debug HubCommander`. Your configuration window should look something along 86 | the lines of this: 87 | 88 | ![Sample Debug Config](images/DebugConfig.png) 89 | 90 | Once this is all complete, you are ready to debug! Place breakpoints where you want them, fire up Slack, and start debugging away. 91 | -------------------------------------------------------------------------------- /command_plugins/github/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.github.plugin.decorators 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | from hubcommander.bot_components.slack_comm import send_error 10 | 11 | 12 | def repo_must_exist(org_arg="org"): 13 | def command_decorator(func): 14 | def decorated_command(github_plugin, data, user_data, *args, **kwargs): 15 | # Just 1 repo -- or multiple? 16 | if kwargs.get("repo"): 17 | repos = [kwargs["repo"]] 18 | else: 19 | repos = kwargs["repos"] 20 | 21 | # Check if the specified GitHub repo exists: 22 | for repo in repos: 23 | if not github_plugin.check_if_repo_exists(data, user_data, repo, kwargs[org_arg]): 24 | return 25 | 26 | # Run the next function: 27 | return func(github_plugin, data, user_data, *args, **kwargs) 28 | 29 | return decorated_command 30 | 31 | return command_decorator 32 | 33 | 34 | def team_must_exist(org_arg="org", team_arg="team"): 35 | def command_decorator(func): 36 | def decorated_command(github_plugin, data, user_data, *args, **kwargs): 37 | # Check if the specified GitHub team exists: 38 | team_id = github_plugin.find_team_id_by_name(kwargs[org_arg], kwargs[team_arg]) 39 | if not team_id: 40 | send_error(data["channel"], "@{}: The GitHub team: {} does not exist.".format(user_data["name"], 41 | kwargs[team_arg]), 42 | thread=data["ts"]) 43 | return 44 | # Run the next function: 45 | return func(github_plugin, data, user_data, *args, **kwargs) 46 | 47 | return decorated_command 48 | 49 | return command_decorator 50 | 51 | 52 | def github_user_exists(user_arg): 53 | def command_decorator(func): 54 | def decorated_command(github_plugin, data, user_data, *args, **kwargs): 55 | # Check if the given GitHub user actually exists: 56 | try: 57 | found_user = github_plugin.get_github_user(kwargs[user_arg]) 58 | 59 | if not found_user: 60 | send_error(data["channel"], "@{}: The GitHub user: {} does not exist.".format(user_data["name"], 61 | kwargs[user_arg]), 62 | thread=data["ts"]) 63 | return 64 | 65 | except Exception as e: 66 | send_error(data["channel"], 67 | "@{}: A problem was encountered communicating with GitHub to verify the user's GitHub " 68 | "id. Here are the details:\n{}".format(user_data["name"], str(e)), 69 | thread=data["ts"]) 70 | return 71 | 72 | # Run the next function: 73 | return func(github_plugin, data, user_data, *args, **kwargs) 74 | 75 | return decorated_command 76 | 77 | return command_decorator 78 | 79 | 80 | def branch_must_exist(repo_arg="repo", org_arg="org", branch_arg="branch"): 81 | """ 82 | This should be placed AFTER the `@repo_must_exist()` decorator. 83 | :param repo_arg: 84 | :param org_arg: 85 | :param branch_arg: 86 | :param kwargs: 87 | :return: 88 | """ 89 | def command_decorator(func): 90 | def decorated_command(github_plugin, data, user_data, *args, **kwargs): 91 | # Check if the branch exists on the repo.... 92 | if not (github_plugin.check_for_repo_branch(kwargs[repo_arg], kwargs[org_arg], kwargs[branch_arg])): 93 | send_error(data["channel"], 94 | "@{}: This repository does not have the branch: `{}`.".format(user_data["name"], 95 | kwargs[branch_arg]), 96 | markdown=True, thread=data["ts"]) 97 | return 98 | 99 | # Run the next function: 100 | return func(github_plugin, data, user_data, *args, **kwargs) 101 | 102 | return decorated_command 103 | 104 | return command_decorator 105 | -------------------------------------------------------------------------------- /docs/travis_ci.md: -------------------------------------------------------------------------------- 1 | Travis CI Plugin 2 | ================= 3 | 4 | The [Travis CI](https://travis-ci.com/) plugin features an `!EnableTravis` command which will enable Travis CI 5 | on a given repository. By default, this plugin is disabled. 6 | 7 | This plugin makes an assumption that you have Travis CI enabled for your GitHub organizations. 8 | 9 | Since Travis CI Public (travis-ci.org) [is moving](https://blog.travis-ci.com/2018-05-02-open-source-projects-on-travis-ci-com-with-github-apps) to Travis CI Pro (travis-ci.com), 10 | the plugin will default to using Travis CI Pro. 11 | 12 | How does it work? 13 | ---------------- 14 | The Travis CI plugin operates similarly to the GitHub plugin in that it brokers privileged commands 15 | via a simple tool. This is necessary, because enabling Travis CI on an repository [requires administrative 16 | permissions](https://docs.travis-ci.com/user/getting-started#To-get-started-with-Travis-CI%3A) 17 | for a given repository. 18 | 19 | As such, this plugin is only effective if you utilize Travis CI credentials with the GitHub user account 20 | shared by the GitHub plugin. 21 | 22 | This plugin works by first fetching details about the repository from GitHub. The plugin will then 23 | have Travis CI synchronize with GitHub so it can see the repository. Once synchronized, it will 24 | then run the API command to enable Travis CI on the repo. 25 | 26 | Configuration 27 | ------------- 28 | This plugin requires access to [Travis CI API version 3](https://developer.travis-ci.com/). 29 | 30 | You will need to get your Travis CI tokens. These tokens 31 | are _different_ for public and professional Travis CI. 32 | 33 | ### GitHub API Token for Travis 34 | 35 | You must create a GitHub API token first. This is used to fetch the Travis CI tokens. You must 36 | create a personal access token to with the following scopes: 37 | 38 | - `repo` (All) 39 | - `read:org` 40 | - `write:repo_hook` 41 | - `read:repo_hook` 42 | - `user:email` 43 | 44 | Keep the generated token in a safe place for the time being. You only need token for the 45 | next section -- after that, you shouldn't need the token stored anywhere. However, don't 46 | delete the generated key on GitHub. 47 | 48 | ### Get Travis CI Credentials 49 | You need to obtain the following 6 items: 50 | - `TRAVIS_PRO_USER`: This is the name of the GitHub user name running the Travis CI (Pro) commands 51 | - `TRAVIS_PRO_ID`: The Travis CI Pro ID of the GitHub user (see below) 52 | - `TRAVIS_PRO_TOKEN`: The Travis CI Pro Token (see below) 53 | - `TRAVIS_PUBLIC_USER`: This is the name of the GitHub user name running the Travis CI (Public) commands 54 | - `TRAVIS_PUBLIC_ID`: The Travis CI Public ID of the GitHub user (see below) 55 | - `TRAVIS_PUBLIC_TOKEN`: The Travis CI Public Token (see below) 56 | 57 | The 6 fields above will need to be added into the bot secrets `dict` and encrypted. 58 | 59 | #### Fetch Travis CI Tokens 60 | You must follow the instructions [here](https://docs.travis-ci.com/api#authentication) to obtain the 61 | Travis CI credentials. *Note: you must do this TWICE, once for `.org` (public) and again for 62 | `.com` (professional) to get all the required tokens.* 63 | 64 | #### Fetch Travis CI IDs 65 | Once you fetch the tokens, you will also need to fetch your Travis CI ID for both public 66 | and pro. You do this by executing the 67 | [Travis CI `user` API](https://developer.travis-ci.org/explore/user) to fetch the `id`. 68 | (Again, this needs to be run twice, for Public and Pro.) 69 | 70 | #### Update the credentials dictionary 71 | You must update the credentials dictionary that is used on the startup of the bot. 72 | The fields are specified above. 73 | 74 | #### Define the GitHub organizations to enable Travis CI on 75 | In the Travis CI plugin's [configuration file](https://github.com/Netflix/hubcommander/blob/master/command_plugins/travis_ci/config.py), there is an `ORGS` `dict`. 76 | This is very similar in nature to the `ORGS` `dict` that exists in the GitHub plugin's configuration file. This 77 | `dict` defines the GitHub Organizations that Travis CI is enabled on, and the corresponding aliases for those orgs. 78 | 79 | #### Define the Travis CI User Agent 80 | This can be anything of your choosing. Make sure that you set the `USER_AGENT` variable in the Travis CI plugin's config file. 81 | 82 | ### Enable the plugin 83 | To enable the plugin, uncomment the `import` statement in 84 | [`command_plugins/enabled_plugins.py`](https://github.com/Netflix/hubcommander/blob/master/command_plugins/enabled_plugins.py) 85 | 86 | Then, uncomment the `#"travisci": TravisPlugin(),` entry in the `COMMAND_PLUGINS` `dict`. 87 | 88 | Restart the bot, and you should see output on app startup for the `travisci` plugin being enabled. 89 | -------------------------------------------------------------------------------- /basic_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ################################################################################ 4 | # 5 | # 6 | # Copyright 2017 Netflix, Inc. 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | # 20 | # 21 | ################################################################################ 22 | 23 | # This is a quick-and-dirty script to install HubCommander. 24 | # This will first download and extract the slackhq/python-rtmbot, and then place 25 | # HubCommander into the proper directory path. 26 | 27 | # After the files are fetched, the script will attempt to create the python virtual 28 | # environments and install all the python dependencies (PYTHON 3.5+ IS REQUIRED!) 29 | 30 | # You MUST run this from the parent directory that contains this script. So, if this 31 | # script is in a directory named "hubcommander", you must run this from: 32 | # ../hubcommander so that you issue: ./hubcommander/basic_install.sh 33 | 34 | echo "Checking if I am able to CD into the proper directory..." 35 | cd hubcommander 36 | if [ $? -ne 0 ]; then 37 | echo "[X] DIRECTORY PATHS ARE WRONG. Source directory needs to be named 'hubcommander' and you" 38 | echo " must run this from the parent directory of 'hubcommander'!!" 39 | exit -1 40 | fi 41 | cd .. 42 | RTM_VERSION="0.4.0" 43 | RTM_PATH="python-rtmbot-${RTM_VERSION}" 44 | echo "Installing HubCommander and all dependencies..." 45 | 46 | # Fetch the python-rtmbot in the parent directory of this one: 47 | echo "[-->] Downloading the RTM bot to: $( pwd )" 48 | curl -L https://github.com/slackhq/python-rtmbot/archive/${RTM_VERSION}.tar.gz > ${RTM_PATH}.tar.gz 49 | echo "[+] Completed download" 50 | 51 | # Extract: 52 | echo "[-->] Extracting the RTM bot..." 53 | tar xvf ${RTM_PATH}.tar.gz 54 | cd ${RTM_PATH} 55 | echo "[+] Completed extracted RTM bot" 56 | 57 | # Create the virtualenvs: 58 | echo "[-->] Creating venv in ${RTM_PATH}..." 59 | 60 | if command -v pyvenv >/dev/null 2>&1 ; then 61 | 62 | PYTHON_VERSION=`python --version 2>&1 | grep -i continuum` 63 | if [[ $PYTHON_VERSION != "" ]]; then 64 | echo "[+] Conda installation detected ..." 65 | pyvenv venv --without-pip 66 | source venv/bin/activate 67 | 68 | echo "[-->] Installing PIP in venv..." 69 | curl -O https://bootstrap.pypa.io/get-pip.py 70 | python get-pip.py 71 | echo "[+] PIP Installed" 72 | else 73 | pyvenv venv 74 | source venv/bin/activate 75 | fi 76 | 77 | echo "[+] Created venv" 78 | 79 | # Install HubCommander 80 | echo "[-->] Moving HubCommander to the correct dir..." 81 | mv ../hubcommander hubcommander/ 82 | echo "[+] Completed moving HubCommander to the correct dir." 83 | 84 | # Install the dependencies for the rtmbot: 85 | echo "[-->] Installing rtmbot's dependencies..." 86 | pip install wheel 87 | pip install ./hubcommander/ 88 | echo "[+] Completed installing HubCommander's dependencies." 89 | 90 | # May not be relevant anymore -- but can't hurt: 91 | echo "[-->] Removing unnecessary files..." 92 | # Need to delete the "setup.py" file because it interferes with the rtmbot: 93 | rm -f hubcommander/setup.py 94 | # Need to delete the "tests/" directory, because it also interferes with the rtmbot: 95 | rm -Rf hubcommander/tests 96 | echo "[-] Completed unnecessary file removal." 97 | 98 | # Make a skeleton of the rtmbot.conf file: 99 | echo "[-->] Creating the skeleton 'rtmbot.conf' file..." 100 | echo 'DEBUG: True' > rtmbot.conf 101 | echo 'SLACK_TOKEN: "'PLACE SLACK TOKEN HERE'"' >> rtmbot.conf 102 | echo 'ACTIVE_PLUGINS:' >> rtmbot.conf 103 | echo ' - hubcommander.hubcommander.HubCommander' >> rtmbot.conf 104 | echo "[+] Completed the creation of the skeleton 'rtmbot.conf' file..." 105 | 106 | echo 107 | echo "-------- What's left to do? --------" 108 | echo "At this point, you will need to modify the 'rtmbot.conf' file as per the instructions for the rtmbot." 109 | echo "Additionally, you will need to perform all of the remaining configuration that is required for" 110 | echo "HubCommander. Please review the instructions for details." 111 | 112 | echo 113 | echo "DONE!" 114 | else 115 | echo "pyvenv is not installed. Install pyvenv to continue. Aborting." 116 | exit 1; 117 | fi 118 | -------------------------------------------------------------------------------- /bot_components/parse_functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.bot_components.parse_functions 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | 10 | import warnings 11 | 12 | TOGGLE_ON_VALUES = ["on", "true", "enable", "enabled"] 13 | TOGGLE_OFF_VALUES = ["off", "false", "disable", "disabled"] 14 | 15 | 16 | class ParseException(Exception): 17 | """ 18 | Exception specific to parsing logic, where improper data was fed in. 19 | """ 20 | def __init__(self, arg_type, proper_values): 21 | super(ParseException, self).__init__("Parse Error") 22 | 23 | self.arg_type = arg_type 24 | self.proper_values = proper_values 25 | 26 | def format_proper_usage(self, user): 27 | usage_text = "@{user}: Invalid argument passed for `{arg_type}`.\n\n" \ 28 | "{proper_values}" 29 | 30 | return usage_text.format(user=user, arg_type=self.arg_type, proper_values=self.proper_values) 31 | 32 | 33 | def preformat_args(text): 34 | """ 35 | THIS METHOD IS DEPRECATED! USE THE DECORATORS FOR PARSING!! 36 | :param text: 37 | :return: 38 | """ 39 | warnings.simplefilter('always', DeprecationWarning) 40 | warnings.warn("The function: 'preformat_args' is deprecated. Please use the decorators for " 41 | "argument parsing.", DeprecationWarning) 42 | return text.lower() \ 43 | .replace('[', '').replace(']', '') \ 44 | .replace('{', '').replace('}', '') \ 45 | .split(" ")[1:] 46 | 47 | 48 | def preformat_args_with_spaces(text, num_quoted): 49 | """ 50 | THIS METHOD IS DEPRECATED! USE THE DECORATORS FOR PARSING!! 51 | 52 | This method will not only strip out the things that need to be stripped out, but it will also 53 | ensure that double-quoted objects are extracted as independent arguments. 54 | 55 | For example, if the text passed in was: 56 | !SetDescription Netflix HubCommander "A Slack bot for GitHub management", we would want to get back: 57 | a list that contains: ["netflix", "hubcommander", '"A Slack bot for GitHub management"'] 58 | 59 | The `num_quoted` param refers to the number of double-quoted parameters are required for the command. 60 | For the example above, there is one double-quoted parameter required for the command. 61 | 62 | :param text: 63 | :param num_quoted: 64 | :return: 65 | """ 66 | warnings.simplefilter('always', DeprecationWarning) 67 | warnings.warn("The function: 'preformat_args_with_spaces' is deprecated. Please use the decorators for " 68 | "argument parsing.", DeprecationWarning) 69 | working = text.replace('[', '').replace(']', '') \ 70 | .replace('{', '').replace('}', '') \ 71 | .replace(u'\u201C', "\"").replace(u'\u201D', "\"") \ 72 | .replace(u'\u2018', "\'").replace(u'\u2019', "\'") # macOS's Bullshit around quotes.. 73 | 74 | # Check if there are 0 or an un-even number of quotation marks. If so, then exit: 75 | if working.count('\"') < 2: 76 | raise SystemExit() 77 | 78 | if working.count('\"') % 2 != 0: 79 | raise SystemExit() 80 | 81 | # Get all the quoted things: (We only really care about the double-quotes, since they are related to command 82 | # syntax.) 83 | quotes = working.split('"')[1::2] 84 | if len(quotes) != num_quoted: 85 | raise SystemExit() 86 | 87 | # Remove them from the working string: 88 | working = working.replace("\"", '') 89 | 90 | for quote in quotes: 91 | working = working.replace(quote, '') 92 | 93 | # Get the space delimited commands: 94 | working = working.lower() 95 | 96 | # Remove extra dangling whitespaces (there must be 1 dangling space at the end for the split operation to operate): 97 | if num_quoted > 1: 98 | working = working[0:-(num_quoted - 1)] 99 | 100 | space_delimited = working.split(" ")[1:-1] 101 | # The -1 above is needed, because there will be additional empty items on the list due to the space 102 | # after the other positional arguments :/ 103 | 104 | return space_delimited + quotes 105 | 106 | 107 | def extract_repo_name(plugin_obj, reponame, **kwargs): 108 | """ 109 | Reponames can be FQDN's. Slack has an annoying habit of sending over URL's like so: 110 | 111 | ^^ Need to pull out the URL. In our case, we care only about the label, which is the last part between | and > 112 | :param plugin_obj: Not used 113 | :param reponame: 114 | :return: 115 | """ 116 | if "|" not in reponame: 117 | return reponame 118 | 119 | split_repo = reponame.split("|")[1] 120 | 121 | return split_repo.replace(">", "") 122 | 123 | 124 | def extract_multiple_repo_names(plugin_obj, repos, **kwargs): 125 | """ 126 | Does what the above does, but does it for a comma separated list of repos. 127 | :param plugin_obj: 128 | :param repos: 129 | :param kwargs: 130 | :return: 131 | """ 132 | repo_list = repos.split(",") 133 | 134 | parsed_repos = [] 135 | 136 | for repo in repo_list: 137 | parsed_repos.append(extract_repo_name(plugin_obj, repo, **kwargs)) 138 | 139 | return parsed_repos 140 | 141 | 142 | def parse_toggles(plugin_obj, toggle, toggle_type="toggle", **kwargs): 143 | """ 144 | Parses typical toggle values, like off, on, enabled, disabled, true, false, etc. 145 | :param plugin_obj: Not used 146 | :param toggle_type: 147 | :param toggle: 148 | :return: 149 | """ 150 | toggle = toggle.lower() 151 | 152 | if toggle in TOGGLE_ON_VALUES: 153 | return True 154 | 155 | elif toggle in TOGGLE_OFF_VALUES: 156 | return False 157 | 158 | raise ParseException(toggle_type, "Acceptable values are: `{on}, {off}`".format( 159 | on=", ".join(TOGGLE_ON_VALUES), off=", ".join(TOGGLE_OFF_VALUES) 160 | )) 161 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ======================= 3 | Contributions to HubCommander are always welcome! :) 4 | 5 | This doc contains some tips that can help get you up and running. 6 | 7 | 8 | First things first... 9 | ---------------------- 10 | Please review the [installation documentation here first](installation.md). 11 | This will give you an understanding on what is required for HubCommander to function. 12 | 13 | 14 | Development Setup 15 | ---------------- 16 | HubCommander is a Python 3 application, and as such, having a good Python 3 environment up and running is essential. 17 | 18 | Google has very good documentation on setting up a Python environment [here](https://cloud.google.com/python/setup). 19 | (You don't need to set up the Google Cloud dependencies -- unless you want to do really cool GCP chatopcy things with HubCommander.) 20 | 21 | You will also need to install `git`. Some good guides on `git` can be found [here](https://git-scm.com/documentation). 22 | 23 | To write Python, you can take your pick from various different text editors, such as [PyCharm](https://www.jetbrains.com/pycharm/), 24 | [Atom](https://atom.io), or [VS Code](https://code.visualstudio.com/) to name a few. 25 | 26 | A section of debugging HubCommander in PyCharm [is available here](pycharm_debugging.md). 27 | 28 | ### Slack Setup 29 | You can't write and test a Slack bot without Slack. For this, you will need a workspace on Slack (you can create one for free). 30 | Once you have a workspace set up, you will need to create a channel. We recommend creating a testing channel for HubCommander for you 31 | to issue test commands on. 32 | 33 | At this point, follow [Slack's documentation here](https://api.slack.com/bot-users) to create a bot user for your workspace. This bot user 34 | will need to be a member of the channel you want it to listen to commands on. 35 | 36 | You will also need to fetch a Slack token for this bot. Keep this token in a safe place where you can fetch it later. 37 | You can always regenerate a new one, but the token is essentially a password that will grant anyone who possesses it access to your Slack resources -- so keep it secure! 38 | 39 | ### GitHub Setup 40 | The installation documentation includes the required instructions to get GitHub credentials. If you do not wish to use HubCommander to develop 41 | against GitHub, then you can skip this. 42 | 43 | Fetch the code 44 | ---------------- 45 | To fetch the code for development, fork this repository, and `git clone` your fork. For more details on this, please review 46 | [GitHub's documentation here](https://help.github.com/articles/fork-a-repo/). 47 | 48 | All development on HubCommander should be based upon the `develop` branch. 49 | 50 | ### Get the code working 51 | To get HubCommander working in your development environment, you will need to install all of its dependencies. Fortunately, we have included a 52 | `basic_install.sh` file that should do all the hard work for you :) 53 | 54 | To summarize, this will fetch [Slack's Python rtmbot](https://github.com/slackapi/python-rtmbot) version 55 | [0.4.0](https://github.com/slackapi/python-rtmbot/releases/tag/0.4.0) plugin -- which HubCommander 56 | depends on -- and it will then set up the virtual environments with all the dependencies installed. 57 | 58 | To do this, follow these steps: 59 | 1. Fire up a Bash terminal 60 | 1. Navigate to any directory that you want to use for development 61 | 1. Follow the installation guide to install all the required dependencies (for OS X and Linux) [here](installation.md#install-the-bot) 62 | 1. Once installed, all files related to HubCommander will be placed in the `python-rtmbot-0.4.0/hubcommander` directory. `cd` to this directory. 63 | 1. Modify the `rtmbot.conf` file to include your Slack token obtained from the instructions above 64 | 1. Activate your virtual environment by running `source venv/bin/activate` 65 | 1. By default the HubCommander plugin wants the Slack token as a environment variable. You need to export it by running `export SLACK_TOKEN=YOUR-TOKEN-HERE`. 66 | 1. OPTIONAL: If you want to use HubCommander for GitHub and have created a token, you can export that via: `export GITHUB_TOKEN=YOUR-TOKEN-HERE` 67 | 1. You can now run a very basic bot by running `rtmbot` 68 | 1. You can test that it's working properly by navigating to the Slack channel you created and invited HubCommander to and issuing a `!help` command. 69 | 1. The "Repeat" plugin is also enabled by default, you can test that it works by running `!repeat Hello World!`. 70 | 71 | ### Install the unit test requirements 72 | There are a few more steps to do before you can start developing: 73 | 1. Navigate to the `python-rtmbot-0.4.0/hubcommander` directory 74 | 1. Run: `git checkout -- .` (this will restore things like unit tests and which were removed by the `basic_install.sh` script) 75 | 1. Run: `pip install -r test-requirements.txt` to install the unit test dependencies 76 | 1. Verify that things are working by running `py.test tests`. You should see the unit tests pass. 77 | 78 | 79 | Developing new features 80 | ----------------- 81 | To write new features for HubCommander, please review the [plugin documentation here](making_plugins.md). This should provide all the information required to get you developing 82 | features. Also, as a pointer, please take a look at existing plugins to get a feel for how they operate. Don't be afraid to copy and paste! 83 | 84 | Some other things to consider: 85 | 1. Develop Python code that follows the [PEP8](https://www.python.org/dev/peps/pep-0008/) coding standards (this is where PyCharm and other Python editors can come in handy) 86 | 1. Develop against the `develop` branch 87 | 1. Write unit tests! (This is something we need to improve) 88 | 1. Be careful of committing secrets!! 89 | 1. Submit PRs back against the `develop` branch 90 | 1. Keep feature requests small. It will make it easier to develop and merge things in. 91 | 92 | 93 | Where can I find help? 94 | -------------------- 95 | If you are stuck and need assistance, please feel free to reach out to us on [Gitter](https://gitter.im/Netflix/hubcommander). 96 | Alternatively, you can file an issue here on GitHub, and we'll be sure to assist! 97 | -------------------------------------------------------------------------------- /docs/making_plugins.md: -------------------------------------------------------------------------------- 1 | Making Custom Plugins 2 | ===================== 3 | 4 | There are two types of plugins that can be created for HubCommander: authentication plugins, and 5 | command plugins. 6 | 7 | All plugins must be classes, and must implement a `setup()` method that takes in as parameters 8 | a `secrets`, and optional `kwargs`. This is used to pass secrets and other configuration 9 | details to the plugin so that it is ready to use when commands are to be executed. 10 | 11 | Authentication Plugins 12 | ---------------------- 13 | Authentication plugins provide a means of safeguarding commands and providing a speedbump for their 14 | execution. 15 | 16 | An example plugin is provided for organizations making use of Duo. Please use the 17 | [Duo plugin](https://github.com/Netflix/hubcommander/blob/master/auth_plugins/duo/plugin.py) 18 | as a reference for creating auth plugins. 19 | 20 | All auth plugins are child classes of 21 | [`BotAuthPlugin`](https://github.com/Netflix/hubcommander/blob/master/bot_components/bot_classes.py#L25). 22 | 23 | The plugin must implement the `authenticate` method with the following parameters: `data, user_data, **kwargs`, and 24 | it must return a boolean to indicate if it was successful or not. `True` means that the authentication 25 | was successful, `False` otherwise. Commands that require authentication will continue execution 26 | if auth was successful, and will stop if there was a failure. 27 | 28 | ### Enabling Auth Plugins 29 | Please see the [authentication plugins documentation](authentication.md) for details. 30 | 31 | Command Plugins 32 | --------------- 33 | Command plugins are where you will add custom commands. All command plugins are child classes of 34 | [`BotCommander`](https://github.com/Netflix/hubcommander/blob/master/bot_components/bot_classes.py#L19). 35 | 36 | Please review existing plugins for ideas on how to add plugins to HubCommander. However, to summarize, 37 | you must add a `self.commands = []` with a list of `dict`s to outline the commands that the plugin supports. 38 | For details on how this should look, please refer to the [command configuration documentation](command_config.md). 39 | 40 | Your plugin should have a `config.py` with a `USER_COMMAND_DICT` to permit customization, such as 41 | the ability to enable authentication and disable a command. To make the custom config stick, you 42 | need the following code in your `setup()` method: 43 | ``` 44 | # Add user-configurable arguments to the command_plugins dictionary: 45 | for cmd, keys in USER_COMMAND_DICT.items(): 46 | self.commands[cmd].update(keys) 47 | ``` 48 | 49 | For command parsing, please take a look at the GitHub plugin for an example for how 50 | that should be done. All existing plugins are making use of `argparse` with some 51 | `try`-`except` logic to handle errors, and to be able to print help text. 52 | 53 | ### Command Methods 54 | Decorators are provided to perform a lot of the heavy lifting for command functions. All command functions should be 55 | decorated with `@hubcommander_command()`, and `@auth()`. The first decorator performs the argument parsing and other 56 | pre-command validation, the second enables authentication support for the command. Once the arguments are successfully parsed, 57 | they are then passed into the command function. 58 | 59 | More details on how the decorators are used are [documented here](decorators.md). 60 | 61 | The command methods take in the following parameters: `data`, if `user_data_required`, then `user_data`, as well as 62 | the remaining parameters defined in `@hubcommander_command`. `data` contains information about the message that arrived, 63 | including the channel that originated the message. `data["channel"]` contains the channel for where the message was posted. 64 | 65 | `user_data` contains information about the Slack user that issued the command. 66 | `user_data["name"]` is the Slack username of the user that can be used for `@` mentions. 67 | 68 | ### Printing Messages 69 | There are three functions provided for convenience when wanting to write to the Slack channel. 70 | They are defined in [`bot_components/slack_comm.py`](https://github.com/Netflix/hubcommander/blob/master/bot_components/slack_comm.py). 71 | 72 | Please use the `send_info`, `send_error`, and `send_success` functions to sent info, error, and success messages, 73 | respectively. 74 | 75 | These functions take in as parameters the Slack channel to post to (from `data["channel"]` in your command method), 76 | the `text you want to be displayed in the channel`, and whether or not `markdown` should be rendered. 77 | 78 | These functions also support threads and ephemeral messages. 79 | 80 | #### Threads 81 | For sending messages within Slack threads, Slack requires a timestamp to be sent over. HubCommander provides this in the 82 | `data` dictionary's `ts` value that gets passed into each command function. To use this, in your `send_*` function call, simply 83 | pass in `thread=data["ts"]`. An example of this in action is [here](https://github.com/Netflix/hubcommander/blob/develop/command_plugins/repeat/plugin.py#L65). 84 | 85 | #### Ephemeral Messages 86 | Sending ephemeral messages is similar to threads. For this, your command must receive the `user_data` dictionary that gets passed into 87 | each command function. To send the ephemeral message to the user, you need to pass into the `send_*` function call 88 | `ephemeral_user=user_data["id"]`. An example of this in action is [here](https://github.com/Netflix/hubcommander/blob/develop/command_plugins/repeat/plugin.py#L53). 89 | 90 | 91 | ### Add Command Authentication 92 | To add authentication, you simply decorate the method with `@auth` (after the `@hubcommander_command()` decorator) 93 | 94 | See the [authentication plugin documentation](authentication.md) for how to enable authentication. 95 | 96 | ### Enabling Command Plugins 97 | To enable the plugin, you must `import` the plugin's `class` in 98 | [`command_plugins/enabled_plugins.py`](https://github.com/Netflix/hubcommander/blob/master/command_plugins/enabled_plugins.py) 99 | 100 | Then, add an entry to the `COMMAND_PLUGINS` dict with the plugin instantiated. The plugin will get recognized 101 | on startup of the bot and configured. 102 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | HubCommander Installation 2 | ===================== 3 | 4 | The steps below make the following assumptions: 5 | * You are using Linux or macOS 6 | * Have Python 3.5 installed 7 | * Have a GitHub organization to manage 8 | * Have a GitHub user with `owner` privileges. 9 | * Have Slack API credentials 10 | * Have a Slack Channel dedicated for running the bot in 11 | * **Preferably have a means of protecting secrets -- you will need to write some code here!** 12 | 13 | Basic Installation 14 | ----------------- 15 | HubCommander is dependent on the [slackhq/python-rtmbot](https://github.com/slackhq/python-rtmbot) 16 | release [0.4.0](https://github.com/slackhq/python-rtmbot/releases/tag/0.4.0)). Please review details about the 17 | python-rtmbot before continuing to install HubCommander. 18 | 19 | The python-rtmbot typically operates by placing a plugin in the `plugins` directory. 20 | 21 | A docker image for HubCommander is provided to help get up and running as fast as possible. Alternatively, 22 | a shell script is provided [here](https://github.com/Netflix/hubcommander/blob/master/basic_install.sh) 23 | that will fetch the rtmbot, and will `git clone` HubCommander into the `plugins` directory. 24 | 25 | Once that is done, you will need to perform all additional configuration steps required to make it function in your 26 | environment, including credential management. 27 | 28 | Install the bot 29 | -------------- 30 | 31 | ### For Docker: 32 | 33 | Continue reading this document first. Once done, continue reading the Docker details (linked below). 34 | 35 | ### For macOS: 36 | 37 | 1. Install [Homebrew](http://brew.sh) 38 | 2. Install Python 3.5+: `brew install python3` 39 | 3. Proceed to "Continued Instructions" 40 | 41 | ### For Ubuntu or other Linuxes: 42 | 43 | 1. Run `sudo apt-get update` 44 | 2. Run `sudo apt-get install python3 python3-venv curl git -y` 45 | 3. Proceed to "Continued Instructions" 46 | 47 | ### Continued Instructions 48 | 49 | 1. Clone the HubCommander git repository: `git clone git@github.com:Netflix/hubcommander.git`. 50 | 2. Run the following commands: 51 | 52 | ``` 53 | chmod +x hubcommander/basic_install.sh 54 | ./hubcommander/basic_install.sh 55 | ``` 56 | 3. Proceed to "Configuration" 57 | 58 | ## OPTIONAL for unit tests: 59 | If you are installing for development you will need to install the testing dependencies. 60 | Follow the `Install the unit test requirements` section of the [contributing guide](contributing.md#install-the-unit-test-requirements) for details. 61 | 62 | 63 | Configuration 64 | -------------- 65 | 66 | The primary steps for configuration center around credential management for Slack, and GitHub 67 | (and optionally Travis CI and Duo if you utilize those services). 68 | 69 | ### Decrypting secrets 70 | 71 | Out of the box, HubCommander is configured to receive secrets from environment variables. This is provided 72 | to simplify running HubCommander in Docker. These secrets should not be stored at rest unencrypted. 73 | 74 | HubCommander also provides an [AWS KMS](https://aws.amazon.com/kms/) method for extracting an encrypted 75 | JSON blob that contains the secrets. 76 | 77 | **If your organization utilizes a different mechanism for encrypting credentials, you will need to add code 78 | to [`decrypted_creds.py`](https://github.com/Netflix/hubcommander/blob/master/decrypt_creds.py)'s 79 | `get_credentials()` function.** __Please DO NOT store credentials and tokens in plaintext here!__ 80 | 81 | No matter the encryption mechanism utilized, all secrets are passed into each plugin's `setup()` method, which 82 | enable the plugins to make authenticated calls to their respective services. You must get the credentials 83 | required for plugins to work. 84 | 85 | 1. Contact your Slack administrator to obtain a Slack token that can be used for bots. 86 | - More specifically, you will need to create a Slack app, and give the app the permissions to be a bot. 87 | - You will need to use the bot token for Oauth in order to get `rtm:stream` permissions. 88 | 2. If you haven't already, create a GitHub bot account, and invite it to all the organizations that 89 | you manage with the `owner` role. Also, please configure this account with a strong password 90 | and 2FA! (Don't forget to back up those recovery codes into a safe place!) 91 | 92 | ### GitHub Configuration 93 | 94 | Each plugin in HubCommander typically has a `config.py` file. This is where you can place in any additional 95 | configuration that the plugin supports. 96 | 97 | For the GitHub plugin, you are required to define a Python `dict` with the organizations that you manage. An example 98 | of what this `dict` looks like can be found in the sample 99 | [`command_plugins/github/config.py`](https://github.com/Netflix/hubcommander/blob/develop/command_plugins/github/config.py) file. 100 | 101 | At a minimum, you need to specify the real name of the organization, a list of aliases for the orgs (or an empty list), 102 | whether the organization can only create public repos (via the `public_only` boolean), as well as 103 | a list of `dicts` that define the teams specific to the organization for new repositories will be assigned with. 104 | This `dict` consists of 3 parts: 105 | the `id` of the GitHub org's team (you can get this from the 106 | [`list_teams`](https://developer.github.com/v3/orgs/teams/#list-teams) GitHub API command, along with the 107 | permission for that team to have on newly created repos (either `pull`, `push`, or `admin`), 108 | as well as the actual `name` of the team. 109 | 110 | #### GitHub Configuration: API Token 111 | 112 | Once you have a GitHub bot user account available, it is time to generate an API token. This will be used 113 | by HubCommander to perform privileged GitHub tasks. 114 | 115 | You will need to create an [access token](https://help.github.com/articles/creating-an-access-token-for-command-line-use/). 116 | To do this, you will need to: 117 | 118 | 1. Log into your GitHub bot user's account. 119 | 2. Visit [this settings page](https://github.com/settings/tokens) to see the `Personal Access Tokens` 120 | 3. Click `Generate new token`. 121 | 4. Provide a description for this token, such as `HubCommander Slack GitHub Bot API token`. 122 | 5. Provide the following scopes: 123 | 124 | - `repo` (All) 125 | - `read:org` 126 | - `write:org` 127 | - `delete_repo` 128 | - (These scopes can be later modified) 129 | 130 | 6. Click `Generate Token` to get the API key. This will only be displayed once. You can always re-generate 131 | a new token, but you will need to modify your HubCommander configuration each time you do. 132 | 133 | ### HubCommander Secrets 134 | 135 | Once you have your GitHub and Slack Tokens, you are now ready to configure HubCommander (if you wish to make use 136 | of Travis CI and Duo integration, please refer to the docs for those plugins 137 | [here](travis_ci.md) and [here](authentication.md)). 138 | 139 | You will need to encrypt the Slack and GitHub credentials. If you make use of AWS, a KMS example is provided 140 | in [`decrypted_creds.py`](https://github.com/Netflix/hubcommander/blob/master/decrypt_creds.py). 141 | 142 | At a minimum, HubCommander requires the following secrets (as a Python `dict`): 143 | ``` 144 | { 145 | "SLACK": "Your slack token here...", 146 | "GITHUB": "Your GitHub API token here..." 147 | } 148 | ``` 149 | 150 | Encrypt this via any desirable means you choose, and add in your decryption code to the `get_credentials()` function. 151 | 152 | *Note:* You will need to ensure that the rtmbot's Slack credentials are also encrypted (this requires the 153 | same Slack token). Use whatever deployment mechanism you have in place to ensure that it is encrypted and 154 | in a safe place before running the application. 155 | 156 | ## Additional Command Configuration 157 | 158 | Please refer to the documentation [here](command_config.md) for additional details. 159 | 160 | Running HubCommander 161 | ------------------- 162 | 163 | ### `rtmbot` Configuration 164 | 165 | Regardless of how you run the bot, you will need to worry about the `rtmbot` configuration. The factory docker image 166 | generates this dynamically, but if you decide to make changes to the image, you will need to be aware of how 167 | this works. 168 | 169 | The `rtmbot.conf` file is required to be placed in the base directory of the `rtmbot`. This file MUST have some 170 | elements in it. Namely, it must look similar to this: 171 | 172 | ``` 173 | DEBUG: True 174 | SLACK_TOKEN: "YOUR-SLACK-TOKEN-HERE" 175 | ACTIVE_PLUGINS: 176 | - hubcommander.hubcommander.HubCommander 177 | ``` 178 | 179 | 180 | ### Using Docker 181 | 182 | Continue reading the [HubCommander Docker documentation here](docker.md). 183 | 184 | ### Not using Docker 185 | 186 | If you ran the installation shell script, and made all the configuration file changes you need, then you 187 | are ready to run this! 188 | 189 | You will simply follow the instructions for running the python-rtmbot, which is typically to run: 190 | ``` 191 | # Activate your venv: 192 | source /path/to/venv/.../bin/activate 193 | rtmbot 194 | ``` 195 | If all is successful, you should see no errors in the output, and your bot should appear in the Slack channel 196 | that it was configured to run in. 197 | 198 | Test that it works by running `!Help`, and `!ListOrgs`. 199 | -------------------------------------------------------------------------------- /bot_components/decorators.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.bot_components.decorators 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | import argparse 10 | import shlex 11 | 12 | from hubcommander.bot_components.parse_functions import ParseException 13 | from hubcommander.bot_components.slack_comm import send_info, send_error 14 | 15 | ARG_TYPE = ["required", "optional"] 16 | 17 | 18 | def format_help_text(data, user_data, **kwargs): 19 | full_help_text = "@{user}: `{command_name}`: {description}\n\n" \ 20 | "```{usage}```\n\n" \ 21 | "{required}" \ 22 | "{optional}" 23 | 24 | required_args = [] 25 | if kwargs.get("required"): 26 | required_args.append("Required Arguments:") 27 | for required in kwargs["required"]: 28 | if type(required["name"]) is list: 29 | required_args.append("\t`{name}`\t{help}".format(name=", ".join(required["name"]), 30 | help=required["properties"]["help"])) 31 | else: 32 | required_args.append("\t`{name}`\t{help}".format(name=required["name"], 33 | help=required["properties"]["help"])) 34 | 35 | required_args = "\n".join(required_args) + "\n\n" 36 | 37 | optional_args = ["Optional Arguments:", 38 | "\t`-h, --help`\tShow this help text."] 39 | if kwargs.get("optional"): 40 | for optional in kwargs["optional"]: 41 | if type(optional["name"]) is list: 42 | optional_args.append("\t`{name}`\t{help}".format(name=", ".join(optional["name"]), 43 | help=optional["properties"]["help"])) 44 | else: 45 | optional_args.append("\t`{name}`\t{help}".format(name=optional["name"], 46 | help=optional["properties"]["help"])) 47 | 48 | optional_args = "\n".join(optional_args) 49 | 50 | return full_help_text.format( 51 | user=user_data["name"], 52 | command_name=kwargs["name"], 53 | description=kwargs["description"], 54 | usage=kwargs["usage"], 55 | required=required_args if required_args else "", 56 | optional=optional_args if optional_args else "" 57 | ) 58 | 59 | 60 | def perform_additional_verification(plugin_obj, args, **kwargs): 61 | """ 62 | This will run the custom verification functions that you can set for parameters. 63 | 64 | This will also, by default, lowercase all values that arrive. This behavior can be disabled 65 | via the lowercase=False flag for the argument. 66 | :param plugin_obj: 67 | :param args: 68 | :param kwargs: 69 | :return: 70 | """ 71 | for at in ARG_TYPE: 72 | if kwargs.get(at): 73 | for argument in kwargs[at]: 74 | # Perform case changing logic if required (lowercase by default) 75 | real_arg_name = argument["name"].replace("--", "") 76 | if args.get(real_arg_name): 77 | if type(args[real_arg_name]) is str: 78 | if argument.get("uppercase", False): 79 | args[real_arg_name] = args[real_arg_name].upper() 80 | 81 | elif argument.get("lowercase", True): 82 | args[real_arg_name] = args[real_arg_name].lower() 83 | 84 | # Perform cleanups? Removes <>, {}, <> from the variables if `cleanup=False` not set. 85 | if argument.get("cleanup", True): 86 | args[real_arg_name] = args[real_arg_name].replace("<", "") \ 87 | .replace(">", "").replace("{", "").replace("}", "") \ 88 | .replace("[", "").replace("]", "") \ 89 | .replace("<", "").replace(">", "") 90 | 91 | # Perform custom validation if needed: 92 | if argument.get("validation_func"): 93 | validation_kwargs = {} 94 | if argument.get("validation_func_kwargs"): 95 | validation_kwargs = argument["validation_func_kwargs"] 96 | 97 | args[real_arg_name] = argument["validation_func"]( 98 | plugin_obj, args[real_arg_name], **validation_kwargs 99 | ) 100 | 101 | return args 102 | 103 | 104 | def hubcommander_command(**kwargs): 105 | def command_decorator(func): 106 | def decorated_command(plugin_obj, data, user_data): 107 | parser = argparse.ArgumentParser(prog=kwargs["name"], 108 | description=kwargs["description"], 109 | usage=kwargs["usage"]) 110 | 111 | # Dynamically add in the required and optional arguments: 112 | arg_type = ["required", "optional"] 113 | for at in arg_type: 114 | if kwargs.get(at): 115 | for argument in kwargs[at]: 116 | # If there is a list of available values, then ensure that they are added in for argparse to 117 | # process properly. This can be done 1 of two ways: 118 | # 1.) [Not recommended] Use argparse directly by passing in a fixed list within 119 | # `properties["choices"]` 120 | # 121 | # 2.) [Recommended] Add `choices` outside of `properties` where you can define where 122 | # the list of values appear within the Plugin's command config. This is 123 | # preferred, because it reflects how the command is actually configured after the plugin's 124 | # `setup()` method is run. 125 | # 126 | # To make use of this properly, you need to have the help text contain: "{values}" 127 | # This will then ensure that the list of values are properly in there. 128 | ## 129 | if argument.get("choices"): 130 | # Add the dynamic choices: 131 | argument["properties"]["choices"] = plugin_obj.commands[kwargs["name"]][argument["choices"]] 132 | 133 | # Fix the help text: 134 | argument["properties"]["help"] = argument["properties"]["help"].format( 135 | values=", ".join(plugin_obj.commands[kwargs["name"]][argument["choices"]]) 136 | ) 137 | 138 | parser.add_argument(argument["name"], **argument["properties"]) 139 | 140 | # Remove all the macOS "Smart Quotes": 141 | data["text"] = data["text"].replace(u'\u201C', "\"").replace(u'\u201D', "\"") \ 142 | .replace(u'\u2018', "\'").replace(u'\u2019', "\'") 143 | 144 | # Remove the command from the command string: 145 | split_args = shlex.split(data["text"])[1:] 146 | try: 147 | args = vars(parser.parse_args(split_args)) 148 | 149 | except SystemExit as _: 150 | send_info(data["channel"], format_help_text(data, user_data, **kwargs), markdown=True, 151 | ephemeral_user=user_data["id"]) 152 | return 153 | 154 | # Perform additional verification: 155 | try: 156 | args = perform_additional_verification(plugin_obj, args, **kwargs) 157 | except ParseException as pe: 158 | send_error(data["channel"], pe.format_proper_usage(user_data["name"]), 159 | markdown=True, ephemeral_user=user_data["id"]) 160 | return 161 | except Exception as e: 162 | send_error(data["channel"], "An exception was encountered while running validation for the input. " 163 | "The exception details are: `{}`".format(str(e)), 164 | markdown=True) 165 | return 166 | 167 | # Run the next function: 168 | data["command_name"] = kwargs["name"] 169 | return func(plugin_obj, data, user_data, **args) 170 | 171 | return decorated_command 172 | 173 | return command_decorator 174 | 175 | 176 | def auth(**kwargs): 177 | def command_decorator(func): 178 | def decorated_command(command_plugin, data, user_data, *args, **kwargs): 179 | # Perform authentication: 180 | if command_plugin.commands[data["command_name"]].get("auth"): 181 | if not command_plugin.commands[data["command_name"]]["auth"]["plugin"].authenticate( 182 | data, user_data, *args, **command_plugin.commands[data["command_name"]]["auth"]["kwargs"]): 183 | return 184 | 185 | # Run the next function: 186 | return func(command_plugin, data, user_data, *args, **kwargs) 187 | 188 | return decorated_command 189 | 190 | return command_decorator 191 | -------------------------------------------------------------------------------- /docs/decorators.md: -------------------------------------------------------------------------------- 1 | Command Decorators & Argument Parsing 2 | =================== 3 | HubCommander features decorators that can perform the heavy lifting of argument parsing, as well as performing 4 | pre-command validation, and authentication. 5 | 6 | Declaring HubCommander Commands 7 | ------------- 8 | The primary decorator that must be placed on all HubCommander commands is `@hubcommander_command`. This decorator 9 | does all of the argument parsing, and provides mechanisms where you can define your own custom parsers. 10 | 11 | Here is an example: 12 | ``` 13 | @hubcommander_command( 14 | name="!SetBranchProtection", 15 | usage="!SetBranchProtection ", 16 | description="This will enable basic branch protection to a GitHub repo.\n\n" 17 | "Please Note: GitHub prefers lowercase branch names. You may encounter issues " 18 | "with uppercase letters.", 19 | required=[ 20 | dict(name="org", properties=dict(type=str, help="The organization that contains the repo."), 21 | validation_func=lookup_real_org, validation_func_kwargs={}), 22 | dict(name="repo", properties=dict(type=str, help="The name of the repo to set the default on."), 23 | validation_func=extract_repo_name, validation_func_kwargs={}), 24 | dict(name="branch", properties=dict(type=str, help="The name of the branch to set as default. " 25 | "(Case-Sensitive)"), lowercase=False), 26 | dict(name="toggle", properties=dict(type=str, help="Toggle to enable or disable branch protection"), 27 | validation_func=parse_toggles, validation_func_kwargs={}) 28 | ], 29 | optional=[] 30 | ) 31 | ``` 32 | 33 | Here are the components: 34 | - `name`: This is the command itself. This must be defined in the plugin's `commands` `dict`. 35 | - `usage`: This is the command usage help text. This should be a simple summary of the command and its arguments. 36 | - `description`: A description of what the command does. Make this as detailed as you need it to be. 37 | - `required`: This is a list (order matters) of the arguments that are required for the command to execute properly. 38 | - `optional`: Similar to `required`, this is a list of optional arguments that can be supplied to the command. 39 | 40 | ### Argument Parsing 41 | Argument parsing is handled by Python's [`argparse`](https://docs.python.org/3/library/argparse.html) library. 42 | The `@hubcommander_command` decorator provides some higher-level wrapping logic around it. 43 | 44 | To make use of HubCommander's argument parsing, you need to define a list of `dict`s under `required` or `optional` 45 | that have the following fields: 46 | - `name`: **-Required-** The name of the argument 47 | - `properties`: **-Required-** This is what actually gets passed into `argparse`. The main elements here that you need to be concerned with are: 48 | - `type`: **-Required-** The Python type of the argument. This will typically be `str` (recommended), but can also be `int` or any other Python type. 49 | - `help`: **-Required-** This is the text that will appear for the given argument in the usage output. Make this descriptive to what the argument is. 50 | - `lowercase`: This is `True` by **default** for all `str` arguments. This will lowercase the argument. If you have case-sensitive arguments, set this to `False`. 51 | - `uppercase`: `False` by default for all `str` arguments. This will uppercase the argument if set to `True`. 52 | - `cleanup`: This is `True` by **default** for all `str` arguments. This will remove brackets `<>, {}, []` and replace macOS "smart quotes" with regular quotation characters. 53 | - `validation_func`: This is the Python function that will perform additional validation and transformation against the argument beyond what `argparse` can do. This is detailed further below. 54 | - `validation_func_kwargs`: A `dict` containing the keyword arguments to pass into the validation function defined above. 55 | - `choices`: This is used when there is a specific list fo values that are acceptable for the command. This field is supposed to be used 56 | in conjunction with the plugin's `commands` `dict`. This contains the name of the list with the acceptable values, and passes that 57 | into `argparse`. This will also properly format the help text. This is also further detailed below. 58 | 59 | 60 | #### Validation Functions 61 | Validation functions provide additional validation and transformation that is not possible to perform with `argparse`. 62 | 63 | As an example, a validation function is provided that can parse common toggle types, such as `off`, `on`, `true`, `false`, `enabled`, and `disabled`. 64 | To make use of this, you would define an argument that has the `parse_toggles` function set. Here is an example: 65 | ``` 66 | dict(name="toggle", properties=dict(type=str, help="Toggle to enable or disable branch protection"), 67 | validation_func=parse_toggles, validation_func_kwargs={}) 68 | ``` 69 | This will verify that the input for the `toggle` argument will fit the one of the acceptable values for that field. 70 | If the value is an enabling toggle, the function will return `True`, or return `False` if it's a disabling toggle. 71 | 72 | However, if the toggle is invalid, the method will raise a `ParseException`. This will be caught in the `@hubcommander_command` decorator. 73 | This will cause HubCommander to output details about what the acceptable values are. This output is set by passing in the usage text to the exception. 74 | 75 | To make your own, use the pre-existing ones as an example. You can find examples of these in `bot_components/parse_functions.py`, 76 | and you can also reference `command_plugins/github/parse_functions.py` as well. 77 | 78 | 79 | #### Multiple choices/options 80 | Python's `argparse` supports the ability of specifying lists. You can certainly have that defined 81 | in the `properties` `dict`, however, this does no permit configurability. With HubCommander, it 82 | is designed so that a plugin's configuration file can outline overridable parameters. As such, 83 | it is recommended to avoid directly specifying the options in the decorator. 84 | 85 | Instead, `@hubcommander_command` has an abstraction layer that can take care of that for you. 86 | 87 | Here is an example of a multiple choice argument, as seen in the `!ListPRs` command: 88 | ``` 89 | dict(name="state", properties=dict(type=str.lower, help="The state of the PR. Must be one of: `{values}`"), 90 | choices="permitted_states") 91 | ``` 92 | 93 | In here, `choices` refers to the name of the `plugin.commands[THECOMMANDHERE][AListOfAvailableChoices]`. It is the name 94 | of the `list` within the plugin's command configuration that contains the available choices for the argument. This is done 95 | because it allows the user of HubCommander to override this list in the plugin's configuration. 96 | 97 | Also keep note of the `type`. The `type` in the example above is set to `str.lower`. This is to ensure that you 98 | have case insensitivity in the parsed command. This is documented on StackOverflow [here](https://stackoverflow.com/questions/27616778/case-insensitive-argparse-choices/27616814). 99 | 100 | Additionally, the `help` text is altered to include `{values}`. The `@hubcommander_command` decorator will properly format 101 | the help text for that argument and fill in `{values}` with a comma separated list of the values in the specified list. 102 | 103 | In the example above, `permitted_states` is found in the GitHub `!ListPrs` command config, which looks like this: 104 | ``` 105 | class GitHubPlugin(BotCommander): 106 | def __init__(self): 107 | super().__init__() 108 | 109 | self.commands = { 110 | ... 111 | "!ListPRs": { 112 | "command": "!ListPRs", 113 | "func": self.list_pull_requests_command, 114 | "user_data_required": True, 115 | "help": "List the Pull Requests for a repo.", 116 | "permitted_states": ["open", "closed", "all"], # <-- This is what "choices" refers to. 117 | "enabled": True 118 | }, 119 | ... 120 | } 121 | ... 122 | ``` 123 | The help text will be formatted to say ``The state of the PR. Must be one of: `open, closed, all` ``. 124 | 125 | #### Access to the plugin object 126 | Both validation functions and decorators take in the plugin object as the first parameter. This is useful 127 | as it allows you to access all of the attributes, configuration, and functions that are a part of the 128 | plugin. 129 | 130 | An example of this is in the GitHub plugin. There are decorators that will verify that 131 | repositories exist. These decorators utilize the GitHub plugin's configuration to access 132 | the GitHub API, and verify that the repository exists before the command function is executed. 133 | 134 | ## Authentication 135 | To add authentication support, you simply add the `@auth()` decorator after the `@hubcommander_command` decorator. 136 | 137 | Custom Decorators 138 | ------------- 139 | Feel free to add custom decorators as you like. In general, plugin specific decorators should reside within the 140 | the directory of the plugin. For convention, we use `decorators.py` as the filename for decorators, and 141 | `parse_functions.py` for verification functions. 142 | 143 | Please refer to the existing plugins for ideas on how to implement and expand these. 144 | 145 | Of course, please feel free to submit pull requests with new decorators and verification functions! 146 | -------------------------------------------------------------------------------- /command_plugins/travis_ci/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | .. module: hubcommander.command_plugins.travis_ci.plugin 3 | :platform: Unix 4 | :copyright: (c) 2017 by Netflix Inc., see AUTHORS for more 5 | :license: Apache, see LICENSE for more details. 6 | 7 | .. moduleauthor:: Mike Grima 8 | """ 9 | import json 10 | import time 11 | 12 | import requests 13 | 14 | from hubcommander.bot_components.bot_classes import BotCommander 15 | from hubcommander.bot_components.decorators import hubcommander_command, auth 16 | from hubcommander.bot_components.slack_comm import send_error, send_info, send_success 17 | from hubcommander.bot_components.parse_functions import extract_repo_name, ParseException 18 | 19 | from tabulate import tabulate 20 | 21 | from .config import USER_COMMAND_DICT, USER_AGENT, ORGS 22 | 23 | 24 | TRAVIS_URLS = { 25 | "pro": "https://api.travis-ci.com", 26 | "public": "https://api.travis-ci.org" 27 | } 28 | 29 | 30 | class TravisCIException(Exception): 31 | pass 32 | 33 | 34 | def lookup_real_org(plugin_obj, org, **kwargs): 35 | try: 36 | return plugin_obj.org_lookup[org.lower()][0] 37 | except KeyError as _: 38 | raise ParseException("org", "Either that org doesn't exist, or Travis CI is not enabled for it." 39 | "Run `!ListTravisOrgs` to see which orgs this bot manages.") 40 | 41 | 42 | class TravisPlugin(BotCommander): 43 | def __init__(self): 44 | super().__init__() 45 | 46 | self.commands = { 47 | "!ListTravisOrgs": { 48 | "command": "!ListTravisOrgs", 49 | "func": self.list_org_command, 50 | "user_data_required": False, 51 | "help": "Lists the GitHub organizations that have Travis CI enabled.", 52 | "enabled": True 53 | }, 54 | "!EnableTravis": { 55 | "command": "!EnableTravis", 56 | "func": self.enable_travis_command, 57 | "help": "Enables Travis CI on a GitHub Repo.", 58 | "user_data_required": True, 59 | "enabled": True 60 | } 61 | } 62 | 63 | # For org alias lookup convenience: 64 | self.org_lookup = None 65 | 66 | self.credentials = None 67 | 68 | def setup(self, secrets, **kwargs): 69 | # GitHub is a dependency: 70 | from hubcommander.command_plugins.enabled_plugins import COMMAND_PLUGINS 71 | if not COMMAND_PLUGINS.get("github"): 72 | self.commands = {} 73 | print("[X] Travis CI Plugin is not enabling any commands because" 74 | " the GitHub plugin is not enabled.") 75 | return 76 | 77 | # Create the lookup table: 78 | self.org_lookup = {} 79 | for org in ORGS.items(): 80 | # The lookup table is the lowercase real name of the org, plus the aliases, along with 81 | # a tuple containing the full real name of the org, with the org dict details: 82 | self.org_lookup[org[0].lower()] = (org[0], org[1]) 83 | for alias in org[1]["aliases"]: 84 | self.org_lookup[alias] = (org[0], org[1]) 85 | 86 | self.credentials = { 87 | "pro": { 88 | "user": secrets["TRAVIS_PRO_USER"], 89 | "id": secrets["TRAVIS_PRO_ID"], 90 | "token": secrets["TRAVIS_PRO_TOKEN"] 91 | }, 92 | "public": { 93 | "user": secrets["TRAVIS_PUBLIC_USER"], 94 | "id": secrets["TRAVIS_PUBLIC_ID"], 95 | "token": secrets["TRAVIS_PUBLIC_TOKEN"] 96 | } 97 | } 98 | 99 | # Add user-configurable arguments to the command_plugins dictionary: 100 | for cmd, keys in USER_COMMAND_DICT.items(): 101 | self.commands[cmd].update(keys) 102 | 103 | @staticmethod 104 | def list_org_command(data): 105 | """ 106 | The "!ListTravisOrgs" command. Lists all organizations that have Travis CI enabled. 107 | :param data: 108 | :return: 109 | """ 110 | headers = ["Alias", "Organization"] 111 | rows = [] 112 | for org in ORGS.items(): 113 | rows.append([org[0].lower(), org[0]]) 114 | for alias in org[1]["aliases"]: 115 | rows.append([alias, org[0]]) 116 | 117 | send_info(data["channel"], "Travis CI is enabled on the following orgs:\n" 118 | "```{}```".format(tabulate(rows, headers=headers)), markdown=True, thread=data["ts"]) 119 | 120 | @hubcommander_command( 121 | name="!EnableTravis", 122 | usage="!EnableTravis [--public=true]", 123 | description="This will enable Travis CI on a GitHub repository.", 124 | required=[ 125 | dict(name="org", properties=dict(type=str, help="The organization that contains the repo."), 126 | validation_func=lookup_real_org, validation_func_kwargs={}), 127 | dict(name="repo", properties=dict(type=str, help="The repository to enable Travis CI on."), 128 | validation_func=extract_repo_name, validation_func_kwargs={}) 129 | ], 130 | optional=[dict(name="--public", 131 | properties=dict(type=str, help="When set to true - attempts to enable Travis CI using the public travis-ci.org"))] 132 | ) 133 | @auth() 134 | def enable_travis_command(self, data, user_data, org, repo, public): 135 | """ 136 | Enables Travis CI on a repository within the organization. 137 | 138 | Command is as follows: !enabletravis [--public=true] 139 | :param public: 140 | :param repo: 141 | :param org: 142 | :param user_data: 143 | :param data: 144 | :return: 145 | """ 146 | from hubcommander.command_plugins.enabled_plugins import COMMAND_PLUGINS 147 | github_plugin = COMMAND_PLUGINS["github"] 148 | 149 | # Output that we are doing work: 150 | send_info(data["channel"], "@{}: Working, Please wait...".format(user_data["name"]), thread=data["ts"]) 151 | 152 | # Get the repo information from GitHub: 153 | try: 154 | repo_result = github_plugin.check_gh_for_existing_repo(repo, org) 155 | 156 | if not repo_result: 157 | send_error(data["channel"], 158 | "@{}: This repository does not exist in {}!".format(user_data["name"], org), 159 | thread=data["ts"]) 160 | return 161 | 162 | except Exception as e: 163 | send_error(data["channel"], 164 | "@{}: I encountered a problem:\n\n{}".format(user_data["name"], e), thread=data["ts"]) 165 | return 166 | 167 | which = "public" if (public and public.lower() == 'true') else "pro" 168 | 169 | try: 170 | # Sync with Travis CI so that it knows about the repo: 171 | send_info(data["channel"], ":skull: Need to sync Travis CI with GitHub. Please wait...", thread=data["ts"]) 172 | self.sync_with_travis(which) 173 | 174 | send_info(data["channel"], ":guitar: Synced! Going to enable Travis CI on the repo now...", 175 | thread=data["ts"]) 176 | 177 | travis_data = self.look_for_repo(which, repo_result) 178 | if not travis_data: 179 | send_error(data["channel"], "@{}: Couldn't find the repo in Travis for some reason...\n\n".format( 180 | user_data["name"]), thread=data["ts"]) 181 | return 182 | 183 | # Is it already enabled? 184 | if travis_data["active"]: 185 | send_success(data["channel"], 186 | "@{}: Travis CI is already enabled on {}/{}.\n\n".format( 187 | user_data["name"], org, repo), thread=data["ts"]) 188 | return 189 | 190 | # Enable it: 191 | self.enable_travis_on_repo(which, repo_result) 192 | 193 | except Exception as e: 194 | send_error(data["channel"], 195 | "@{}: I encountered a problem communicating with Travis CI:\n\n{}".format(user_data["name"], e), 196 | thread=data["ts"]) 197 | return 198 | 199 | message = "@{}: Travis CI has been enabled on {}/{}.\n\n".format(user_data["name"], org, repo) 200 | send_success(data["channel"], message, thread=data["ts"]) 201 | 202 | def sync_with_travis(self, which): 203 | """ 204 | Syncs Travis CI with GitHub to ensure that it can see all the latest 205 | :param which: 206 | :return: 207 | """ 208 | result = requests.post("{base}/user/{userid}/sync".format(base=TRAVIS_URLS[which], 209 | userid=self.credentials[which]["id"]), 210 | headers=self._make_headers(which)) 211 | if result.status_code != 200: 212 | raise TravisCIException("Travis CI Status Code: {}".format(result.status_code)) 213 | 214 | time.sleep(2) # Eventual consistency issues may exist? 215 | 216 | while True: 217 | response = requests.get("{base}/user/{userid}".format(base=TRAVIS_URLS[which], 218 | userid=self.credentials[which]["id"]), 219 | headers=self._make_headers(which)) 220 | if response.status_code != 200: 221 | raise TravisCIException("Sync Status Code: {}".format(response.status_code)) 222 | 223 | result = json.loads(response.text) 224 | 225 | if not result["is_syncing"]: 226 | break 227 | 228 | time.sleep(2) 229 | 230 | def look_for_repo(self, which, repo_dict): 231 | """ 232 | This will check if a repository is currently seen in Travis CI. 233 | :param which: 234 | :param repo_dict: 235 | :return: 236 | """ 237 | result = requests.get("{base}/repo/{id}".format(base=TRAVIS_URLS[which], 238 | id=repo_dict["full_name"].replace("/", "%2F")), 239 | headers=self._make_headers(which)) 240 | 241 | if result.status_code == 404: 242 | return None 243 | 244 | elif result.status_code != 200: 245 | raise TravisCIException("Repo Lookup Status Code: {}".format(result.status_code)) 246 | 247 | return json.loads(result.text) 248 | 249 | def enable_travis_on_repo(self, which, repo_dict): 250 | """ 251 | This will enable Travis CI on a specified repository. 252 | :param which: 253 | :param repo_dict: 254 | :return: 255 | """ 256 | result = requests.post("{base}/repo/{repo}/activate".format(base=TRAVIS_URLS[which], 257 | repo=repo_dict["full_name"].replace("/", "%2F")), 258 | headers=self._make_headers(which)) 259 | 260 | if result.status_code != 200: 261 | raise TravisCIException("Enable Repo Status Code: {}".format(result.status_code)) 262 | 263 | def _make_headers(self, which): 264 | return { 265 | "User-Agent": USER_AGENT, 266 | "Authorization": "token {}".format(self.credentials[which]["token"]), 267 | "Travis-API-Version": "3" 268 | } 269 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2014 Netflix, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from hubcommander.bot_components.decorators import hubcommander_command, format_help_text, auth 4 | from hubcommander.bot_components.slack_comm import WORKING_COLOR 5 | from hubcommander.bot_components.parse_functions import ParseException 6 | 7 | 8 | def test_hubcommander_command_required(user_data, slack_client): 9 | fail_command_kwargs = dict( 10 | name="!FailCommand", 11 | usage="!FailCommand ", 12 | description="This is a test command that will fail due to lack of required args", 13 | required=[ 14 | dict(name="arg1", properties=dict(type=str, help="This is argument 1")), 15 | ], 16 | optional=[] 17 | ) 18 | 19 | class TestCommands: 20 | def __init__(self): 21 | pass 22 | 23 | @hubcommander_command( 24 | name="!TestCommand", 25 | usage="!TestCommand ", 26 | description="This is a test command to make sure that things are working properly.", 27 | required=[ 28 | dict(name="arg1", properties=dict(type=str, help="This is argument 1")), 29 | dict(name="arg2", properties=dict(type=str, help="This is argument 2")), 30 | dict(name="arg3", properties=dict(type=str, help="This is argument 3")) 31 | ], 32 | optional=[] 33 | ) 34 | def pass_command(self, data, user_data, arg1, arg2, arg3): 35 | assert self 36 | assert data 37 | assert user_data 38 | assert arg1 == "arg1" 39 | assert arg2 == "arg2" 40 | assert arg3 == "arg3" 41 | 42 | @hubcommander_command(**fail_command_kwargs) 43 | def fail_command(self, data, user_data, arg1): 44 | assert False # Can't Touch This... 45 | 46 | tc = TestCommands() 47 | 48 | data = dict(text="!TestCommand arg1 arg2 arg3") 49 | tc.pass_command(data, user_data) 50 | 51 | data = dict(text="!FailCommand", channel="12345") 52 | tc.fail_command(data, user_data) 53 | 54 | help_text = format_help_text(data, user_data, **fail_command_kwargs) 55 | attachment = { 56 | "text": help_text, 57 | "color": WORKING_COLOR, 58 | "mrkdwn_in": ["text"] 59 | } 60 | slack_client.api_call.assert_called_with("chat.postEphemeral", channel="12345", as_user=True, 61 | attachments=json.dumps([attachment]), text=" ", user=user_data["id"]) 62 | 63 | 64 | def test_hubcommander_command_optional(user_data, slack_client): 65 | class TestCommands: 66 | def __init__(self): 67 | pass 68 | 69 | @hubcommander_command( 70 | name="!OptionalArgs", 71 | usage="!OptionalArgs ", 72 | description="This is a test command with an optional argument", 73 | required=[ 74 | dict(name="arg1", properties=dict(type=str, help="This is argument 1")), 75 | ], 76 | optional=[ 77 | dict(name="--optional", properties=dict(type=str, help="This is argument 2")), 78 | dict(name="--optional2", properties=dict(type=str, help="This is argument 3")), 79 | dict(name="--optional3", properties=dict(type=str, help="This is argument 4")) 80 | ] 81 | ) 82 | def optional_arg_command(self, data, user_data, arg1, **optionals): 83 | assert arg1 == "required" 84 | assert len(optionals) == 3 85 | assert optionals["optional"] == "some_optional" 86 | assert optionals["optional2"] == "some_optional2" 87 | assert not optionals["optional3"] 88 | 89 | tc = TestCommands() 90 | 91 | data = dict(text="!OptionalArgs required --optional some_optional --optional2 some_optional2") 92 | tc.optional_arg_command(data, user_data) 93 | 94 | 95 | def test_hubcommander_command_with_custom_validation(user_data, slack_client): 96 | from hubcommander.bot_components.parse_functions import parse_toggles 97 | 98 | verify_command_kwargs = dict( 99 | name="!VerifyToggle", 100 | usage="!VerifyToggle ", 101 | description="This is a test command to verify proper toggles", 102 | required=[ 103 | dict(name="test_toggle", properties=dict(type=str, help="This is argument 1"), 104 | validation_func=parse_toggles, 105 | validation_func_kwargs=dict( 106 | toggle_type="" 107 | )) 108 | ] 109 | ) 110 | 111 | class TestCommands: 112 | def __init__(self): 113 | pass 114 | 115 | @hubcommander_command(**verify_command_kwargs) 116 | def verify_toggle(self, data, user_data, test_toggle): 117 | assert type(test_toggle) is bool 118 | 119 | tc = TestCommands() 120 | 121 | data = dict(text="!VerifyToggle on") 122 | tc.verify_toggle(data, user_data) 123 | 124 | data = dict(text="!VerifyToggle false") 125 | tc.verify_toggle(data, user_data) 126 | 127 | data = dict(text="!VerifyToggle WillFail", channel="12345") 128 | tc.verify_toggle(data, user_data) 129 | 130 | proper_usage_text = "" 131 | try: 132 | parse_toggles(tc, "WillFail", toggle_type="") 133 | except ParseException as pe: 134 | proper_usage_text = pe.format_proper_usage(user_data["name"]) 135 | 136 | attachment = { 137 | "text": proper_usage_text, 138 | "color": "danger", 139 | "mrkdwn_in": ["text"] 140 | } 141 | slack_client.api_call.assert_called_with("chat.postEphemeral", channel="12345", as_user=True, 142 | attachments=json.dumps([attachment]), text=" ", user=user_data["id"]) 143 | 144 | 145 | def test_auth_decorator(user_data, slack_client, auth_plugin): 146 | class TestCommands: 147 | def __init__(self): 148 | self.commands = { 149 | "!TestCommand": { 150 | "auth": { 151 | "plugin": auth_plugin, 152 | "kwargs": { 153 | "should_auth": True 154 | } 155 | } 156 | }, 157 | "!TestCommand2": { 158 | "auth": { 159 | "plugin": auth_plugin, 160 | "kwargs": { 161 | "should_auth": False 162 | } 163 | } 164 | }, 165 | "!OptionalArgs": { 166 | "auth": { 167 | "plugin": auth_plugin, 168 | "kwargs": { 169 | "should_auth": True 170 | } 171 | } 172 | }, 173 | "!OptionalArgs2": { 174 | "auth": { 175 | "plugin": auth_plugin, 176 | "kwargs": { 177 | "should_auth": False 178 | } 179 | } 180 | } 181 | } 182 | 183 | @hubcommander_command( 184 | name="!TestCommand", 185 | usage="!TestCommand ", 186 | description="This is a test command to make sure that things are working properly.", 187 | required=[ 188 | dict(name="arg1", properties=dict(type=str, help="This is argument 1")), 189 | dict(name="arg2", properties=dict(type=str, help="This is argument 2")), 190 | dict(name="arg3", properties=dict(type=str, help="This is argument 3")) 191 | ], 192 | optional=[] 193 | ) 194 | @auth() 195 | def pass_command(self, data, user_data, arg1, arg2, arg3): 196 | assert arg1 == "arg1" 197 | assert arg2 == "arg2" 198 | assert arg3 == "arg3" 199 | return True 200 | 201 | @hubcommander_command( 202 | name="!TestCommand2", 203 | usage="!TestCommand2 ", 204 | description="This is a test command that will fail to authenticate.", 205 | required=[ 206 | dict(name="arg1", properties=dict(type=str, help="This is argument 1")), 207 | dict(name="arg2", properties=dict(type=str, help="This is argument 2")), 208 | dict(name="arg3", properties=dict(type=str, help="This is argument 3")) 209 | ], 210 | optional=[] 211 | ) 212 | @auth() 213 | def fail_command(self, data, user_data, arg1, arg2, arg3): 214 | # Will never get here... 215 | assert False 216 | 217 | @hubcommander_command( 218 | name="!OptionalArgs", 219 | usage="!OptionalArgs ", 220 | description="This is a test command with an optional argument", 221 | required=[ 222 | dict(name="arg1", properties=dict(type=str, help="This is argument 1")), 223 | ], 224 | optional=[ 225 | dict(name="--optional", properties=dict(type=str, help="This is argument 2")), 226 | dict(name="--optional2", properties=dict(type=str, help="This is argument 3")), 227 | dict(name="--optional3", properties=dict(type=str, help="This is argument 4")) 228 | ] 229 | ) 230 | @auth() 231 | def optional_arg_command(self, data, user_data, arg1, **optionals): 232 | assert arg1 == "required" 233 | assert len(optionals) == 3 234 | assert optionals["optional"] == "some_optional" 235 | assert optionals["optional2"] == "some_optional2" 236 | assert not optionals["optional3"] 237 | return True 238 | 239 | @hubcommander_command( 240 | name="!OptionalArgs2", 241 | usage="!OptionalArgs2 ", 242 | description="This is a test command with an optional argument that will fail to auth", 243 | required=[ 244 | dict(name="arg1", properties=dict(type=str, help="This is argument 1")), 245 | ], 246 | optional=[ 247 | dict(name="--optional", properties=dict(type=str, help="This is argument 2")), 248 | dict(name="--optional2", properties=dict(type=str, help="This is argument 3")), 249 | dict(name="--optional3", properties=dict(type=str, help="This is argument 4")) 250 | ] 251 | ) 252 | @auth() 253 | def optional_fail_arg_command(self, data, user_data, arg1, **optionals): 254 | # Will never reach this: 255 | assert False 256 | 257 | tc = TestCommands() 258 | data = dict(text="!TestCommand arg1 arg2 arg3") 259 | assert tc.pass_command(data, user_data) 260 | assert not tc.fail_command(data, user_data) 261 | 262 | # Test that commands with optional arguments work properly: 263 | data = dict(text="!OptionalArgs required --optional some_optional --optional2 some_optional2") 264 | assert tc.optional_arg_command(data, user_data) 265 | assert not tc.optional_fail_arg_command(data, user_data) 266 | 267 | 268 | def test_help_command_with_list(user_data, slack_client): 269 | valid_values = ["one", "two", "three"] 270 | 271 | verify_command_kwargs = dict( 272 | name="!TestCommand", 273 | usage="!TestCommand ", 274 | description="This is a test command to test help text for things in lists", 275 | required=[ 276 | dict(name="test_thing", properties=dict(type=str.lower, help="Must be one of: `{values}`"), 277 | choices="valid_values") 278 | ] 279 | ) 280 | 281 | class TestCommands: 282 | def __init__(self): 283 | self.commands = { 284 | "!TestCommand": { 285 | "valid_values": valid_values 286 | } 287 | } 288 | 289 | @hubcommander_command(**verify_command_kwargs) 290 | def the_command(self, data, user_data, test_thing): 291 | assert True 292 | 293 | tc = TestCommands() 294 | 295 | # Will assert True 296 | data = dict(text="!TestCommand one") 297 | tc.the_command(data, user_data) 298 | 299 | # Will ALSO assert True... we are making sure to lowercase the choices with str.lower as the type: 300 | data = dict(text="!TestCommand ThReE") 301 | tc.the_command(data, user_data) 302 | 303 | # Will NOT assert true -- this will output help text: 304 | data = dict(text="!TestCommand", channel="12345") 305 | tc.the_command(data, user_data) 306 | 307 | help_text = format_help_text(data, user_data, **verify_command_kwargs) 308 | attachment = { 309 | "text": help_text, 310 | "color": WORKING_COLOR, 311 | "mrkdwn_in": ["text"] 312 | } 313 | slack_client.api_call.assert_called_with("chat.postEphemeral", channel="12345", as_user=True, 314 | attachments=json.dumps([attachment]), text=" ", user=user_data["id"]) 315 | 316 | # Will NOT assert true 317 | data = dict(text="!TestCommand alskjfasdlkf", channel="12345") 318 | tc.the_command(data, user_data) 319 | attachment = { 320 | "text": help_text, 321 | "color": WORKING_COLOR, 322 | "mrkdwn_in": ["text"] 323 | } 324 | slack_client.api_call.assert_called_with("chat.postEphemeral", channel="12345", as_user=True, 325 | attachments=json.dumps([attachment]), text=" ", user=user_data["id"]) 326 | 327 | 328 | def test_uppercase_and_lowercasing(user_data, slack_client): 329 | class TestCommands: 330 | def __init__(self): 331 | pass 332 | 333 | @hubcommander_command( 334 | name="!TestCommand", 335 | usage="!TestCommand ", 336 | description="This is a test command to make sure that casing is correct.", 337 | required=[ 338 | dict(name="arg1", properties=dict(type=str, help="NoT AlL LoWeRCaSE"), 339 | lowercase=False), 340 | dict(name="arg2", properties=dict(type=str, help="all lowercase")), 341 | dict(name="arg3", properties=dict(type=str, help="ALL UPPERCASE"), 342 | uppercase=True) 343 | ], 344 | ) 345 | def pass_command(self, data, user_data, arg1, arg2, arg3): 346 | assert self 347 | assert data 348 | assert user_data 349 | assert arg1 == "NoT AlL LoWeRCaSE" 350 | assert arg2 == "all lowercase" 351 | assert arg3 == "ALL UPPERCASE" 352 | 353 | tc = TestCommands() 354 | 355 | data = dict(text="!TestCommand \"NoT AlL LoWeRCaSE\" \"ALL lOWERcASE\" \"all Uppercase\"") 356 | tc.pass_command(data, user_data) 357 | 358 | 359 | def test_cleanup(user_data, slack_client): 360 | class TestCommands: 361 | def __init__(self): 362 | pass 363 | 364 | @hubcommander_command( 365 | name="!TestCommand", 366 | usage="!TestCommand ", 367 | description="This is a test command to make sure that undesirable characters are cleaned up.", 368 | required=[ 369 | dict(name="arg1", properties=dict(type=str, help="This will clean things up")), 370 | dict(name="arg2", properties=dict(type=str, help="This will not clean things up."), 371 | cleanup=False), 372 | ], 373 | ) 374 | def pass_command(self, data, user_data, arg1, arg2): 375 | assert self 376 | assert data 377 | assert user_data 378 | assert arg1 == "all cleaned up!" 379 | assert arg2 == ">][\" \"