├── docs ├── .gitkeep ├── rocket-logo.png ├── rocket-share.png ├── rocket2arch.png ├── rocket-banner.gif ├── api │ ├── config.rst │ ├── factory.rst │ ├── util.rst │ ├── tests.rst │ ├── model.rst │ ├── db.rst │ ├── webhook.rst │ ├── interface.rst │ └── command.rst ├── rocket-logo-square.png ├── _static │ └── theme_overrides.css ├── KarmaCommands.rst ├── Architecture.rst ├── Testing.rst ├── Database.rst ├── UserCommands.rst ├── TeamCommands.rst ├── doc_reqs.txt ├── Scripts.rst └── Config.rst ├── tests ├── __init__.py ├── app │ ├── __init__.py │ ├── model │ │ ├── __init__.py │ │ ├── permissions_test.py │ │ ├── user_test.py │ │ └── team_test.py │ ├── controller │ │ ├── __init__.py │ │ ├── command │ │ │ ├── __init__.py │ │ │ ├── commands │ │ │ │ ├── __init__.py │ │ │ │ ├── token_test.py │ │ │ │ ├── mention_test.py │ │ │ │ ├── karma_test.py │ │ │ │ ├── iquit_test.py │ │ │ │ └── export_test.py │ │ │ └── parser_test.py │ │ └── webhook │ │ │ ├── __init__.py │ │ │ ├── github │ │ │ ├── __init__.py │ │ │ └── events │ │ │ │ └── __init__.py │ │ │ └── slack │ │ │ ├── __init__.py │ │ │ └── core_test.py │ └── scheduler │ │ ├── base_test.py │ │ └── modules │ │ └── random_channel_test.py ├── db │ ├── __init__.py │ └── utils_test.py ├── config │ ├── __init__.py │ └── config_test.py ├── interface │ ├── __init__.py │ ├── gcp_utils_test.py │ ├── cloudwatch_metrics_test.py │ └── gcp_test.py ├── utils │ ├── slack_msg_fmt_test.py │ └── slack_parse_test.py ├── util.py ├── memorydb.py └── memorydb_test.py ├── credentials └── .gitkeep ├── scripts ├── update.sh ├── docker_build.sh ├── docker_run_local.sh ├── Makefile ├── run_local_dynamodb.sh ├── setup_deploy.sh ├── setup_localaws.sh ├── build_check.sh ├── download_dynamodb_and_run.sh └── port_busy.py ├── pytest.ini ├── Procfile ├── .codecov.yml ├── app ├── controller │ ├── command │ │ ├── __init__.py │ │ ├── commands │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── mention.py │ │ │ ├── token.py │ │ │ └── karma.py │ │ └── parser.py │ ├── webhook │ │ ├── slack │ │ │ ├── __init__.py │ │ │ └── core.py │ │ └── github │ │ │ ├── __init__.py │ │ │ ├── events │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── membership.py │ │ │ └── organization.py │ │ │ └── core.py │ └── __init__.py ├── model │ ├── __init__.py │ ├── permissions.py │ ├── base.py │ └── user.py └── scheduler │ ├── modules │ ├── base.py │ └── random_channel.py │ └── __init__.py ├── db ├── __init__.py ├── utils.py └── facade.py ├── CODE_OF_CONDUCT.rst ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── docs.md │ ├── tooling.md │ ├── refactor.md │ ├── bug-report.md │ └── feature-request.md ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── publish.yml │ ├── pull-request.yml │ ├── pipeline.yml │ └── planning.yml ├── inertia.toml ├── .gitignore ├── interface ├── exceptions │ └── github.py ├── cloudwatch_metrics.py ├── gcp_utils.py ├── github_app.py └── slack.py ├── .coveragerc ├── utils ├── slack_msg_fmt.py └── slack_parse.py ├── Dockerfile ├── sandbox.yml ├── dump-db.py ├── sample-env ├── Makefile ├── index.rst ├── mypy.ini ├── restore-db.py ├── Pipfile ├── LICENSE.rst ├── nginx.conf ├── docker-compose.yml ├── factory └── __init__.py ├── README.rst └── config └── __init__.py /docs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /credentials/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/interface/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/controller/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/controller/command/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/controller/webhook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/controller/webhook/github/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/controller/webhook/slack/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/controller/command/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/app/controller/webhook/github/events/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/update.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | pipenv sync --dev 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | db: mark a test as involving use of a database 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn --preload -b 0.0.0.0:$PORT -w 4 --forwarded-allow-ips=* app.server:app 2 | -------------------------------------------------------------------------------- /docs/rocket-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubclaunchpad/rocket2/HEAD/docs/rocket-logo.png -------------------------------------------------------------------------------- /docs/rocket-share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubclaunchpad/rocket2/HEAD/docs/rocket-share.png -------------------------------------------------------------------------------- /docs/rocket2arch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubclaunchpad/rocket2/HEAD/docs/rocket2arch.png -------------------------------------------------------------------------------- /docs/rocket-banner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubclaunchpad/rocket2/HEAD/docs/rocket-banner.gif -------------------------------------------------------------------------------- /docs/api/config.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | .. automodule:: config 5 | :members: 6 | -------------------------------------------------------------------------------- /scripts/docker_build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | docker build -t rocket2-dev-img . 4 | -------------------------------------------------------------------------------- /docs/rocket-logo-square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubclaunchpad/rocket2/HEAD/docs/rocket-logo-square.png -------------------------------------------------------------------------------- /docs/api/factory.rst: -------------------------------------------------------------------------------- 1 | Factories 2 | ========= 3 | 4 | .. automodule:: factory 5 | :members: 6 | :undoc-members: 7 | -------------------------------------------------------------------------------- /scripts/docker_run_local.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | docker run --rm -it -p 0.0.0.0:5000:5000 $@ rocket2-dev-img 4 | -------------------------------------------------------------------------------- /scripts/Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install 2 | install: 3 | cp build_check.sh ../.git/hooks/pre-commit 4 | cp update.sh ../.git/hooks/post-merge 5 | -------------------------------------------------------------------------------- /docs/api/util.rst: -------------------------------------------------------------------------------- 1 | Utilities 2 | ========= 3 | 4 | .. automodule:: utils.slack_msg_fmt 5 | :members: 6 | 7 | .. automodule:: utils.slack_parse 8 | :members: 9 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | threshold: 3 6 | patch: 7 | default: 8 | enabled: no 9 | -------------------------------------------------------------------------------- /app/controller/command/__init__.py: -------------------------------------------------------------------------------- 1 | """Pack the modules contained in the command directory.""" 2 | import app.controller.command.parser as parser 3 | 4 | CommandParser = parser.CommandParser 5 | -------------------------------------------------------------------------------- /db/__init__.py: -------------------------------------------------------------------------------- 1 | """Pack the modules contained in the db directory.""" 2 | import db.dynamodb as ddb 3 | import db.facade as dbf 4 | 5 | 6 | DynamoDB = ddb.DynamoDB 7 | DBFacade = dbf.DBFacade 8 | -------------------------------------------------------------------------------- /app/controller/webhook/slack/__init__.py: -------------------------------------------------------------------------------- 1 | """Contain the handlers needed to handle Slack events.""" 2 | import app.controller.webhook.slack.core as core 3 | 4 | SlackEventsHandler = core.SlackEventsHandler 5 | -------------------------------------------------------------------------------- /scripts/run_local_dynamodb.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Run DynamoDB through java 4 | cd DynamoDB 5 | java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb & 6 | cd .. 7 | 8 | sleep 3 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Code of Conduct 2 | =============== 3 | 4 | All contributors are required to adhere to the 5 | `UBC Launch Pad Code of Conduct `_. 6 | -------------------------------------------------------------------------------- /app/controller/webhook/github/__init__.py: -------------------------------------------------------------------------------- 1 | """Contain the handlers needed to handle GitHub webhooks.""" 2 | import app.controller.webhook.github.core as core 3 | 4 | GitHubWebhookHandler = core.GitHubWebhookHandler 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ubclaunchpad/rocket2 @ubclaunchpad/leads @ubclaunchpad/exec 2 | 3 | # Assign nobody as CODEOWNER for the lockfile, which is generally updated 4 | # automatically by dependabot 5 | Pipfile.lock @ghost 6 | -------------------------------------------------------------------------------- /docs/api/tests.rst: -------------------------------------------------------------------------------- 1 | Utilities for Testing 2 | ===================== 3 | 4 | To read about the in-memory database used for testing without the local/remote database, 5 | see :py:class:`tests.memorydb.MemoryDB`. 6 | 7 | .. automodule:: tests.util 8 | :members: 9 | -------------------------------------------------------------------------------- /app/controller/__init__.py: -------------------------------------------------------------------------------- 1 | """Pack the modules contained in the controller directory.""" 2 | from typing import Tuple, Dict, List, Any, Union 3 | 4 | ResponseTuple = Tuple[Union[Dict[str, List[Dict[str, Any]]], 5 | str, 6 | Dict[str, Any]], int] 7 | -------------------------------------------------------------------------------- /inertia.toml: -------------------------------------------------------------------------------- 1 | # Inertia configuration - https://inertia.ubclaunchpad.com 2 | name = "rocket2" 3 | url = "git@github.com:ubclaunchpad/rocket2.git" 4 | version = "v0.7.0" 5 | 6 | [[profile]] 7 | name = "default" 8 | branch = "master" 9 | [profile.build] 10 | type = "docker-compose" 11 | buildfile = "docker-compose.yml" 12 | -------------------------------------------------------------------------------- /app/model/__init__.py: -------------------------------------------------------------------------------- 1 | """Pack the modules contained in the model directory.""" 2 | import app.model.user as user 3 | import app.model.team as team 4 | import app.model.permissions as permissions 5 | import app.model.base as base 6 | 7 | User = user.User 8 | Team = team.Team 9 | Permissions = permissions.Permissions 10 | BaseModel = base.RocketModel 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE files 2 | .idea/ 3 | .vscode/ 4 | *.code-workspace 5 | 6 | # Python build files 7 | *.pyc 8 | __pycache__/ 9 | 10 | # Testing files 11 | DynamoDB/ 12 | .coverage 13 | .env 14 | .pytest_cache 15 | 16 | # Docs generation files 17 | _build/ 18 | .mypy_cache/ 19 | 20 | # Credentials 21 | /credentials/ 22 | 23 | # Database dumps 24 | db.pkl 25 | -------------------------------------------------------------------------------- /interface/exceptions/github.py: -------------------------------------------------------------------------------- 1 | """Exceptions from interacting with Github API.""" 2 | 3 | 4 | class GithubAPIException(Exception): 5 | """Exception representing an error while calling Github API.""" 6 | 7 | def __init__(self, data): 8 | """ 9 | Initialize a new GithubAPIException. 10 | 11 | :param data: 12 | """ 13 | self.data = data 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Problem with docs 3 | about: Something wrong with Rocket documentation 4 | title: '' 5 | labels: 'theme:docs' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary 11 | 12 | 17 | -------------------------------------------------------------------------------- /docs/api/model.rst: -------------------------------------------------------------------------------- 1 | Models 2 | ====== 3 | 4 | User 5 | ---- 6 | 7 | .. autoclass:: app.model.User 8 | :members: 9 | 10 | Team 11 | ---- 12 | 13 | .. autoclass:: app.model.Team 14 | :members: 15 | 16 | Permissions 17 | ----------- 18 | 19 | .. autoclass:: app.model.Permissions 20 | :members: 21 | :special-members: 22 | 23 | .. autoattribute:: member 24 | .. autoattribute:: team_lead 25 | .. autoattribute:: admin 26 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | conf.py 4 | tests/*/* 5 | tests/__init__.py 6 | tests/*_test.py 7 | app/server.py 8 | app/model/base.py 9 | app/scheduler/modules/base.py 10 | app/controller/webhook/github/events/base.py 11 | app/controller/command/commands/base.py 12 | factory/__init__.py 13 | scripts/* 14 | dump-db.py 15 | restore-db.py 16 | 17 | [report] 18 | exclude_lines = 19 | pragma: no cover 20 | raise NotImplementedError 21 | -------------------------------------------------------------------------------- /scripts/setup_deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | docker run --rm \ 5 | -p 443:443 -p 80:80 --name letsencrypt \ 6 | -v "/etc/letsencrypt:/etc/letsencrypt" \ 7 | -v "/var/lib/letsencrypt:/var/lib/letsencrypt" \ 8 | certbot/certbot certonly -n \ 9 | -m "president@ubclaunchpad.com" \ 10 | -d rocket2.ubclaunchpad.com \ 11 | --standalone --agree-tos 12 | 13 | mkdir -p /etc/nginx 14 | cp nginx.conf /etc/nginx/nginx.conf 15 | -------------------------------------------------------------------------------- /utils/slack_msg_fmt.py: -------------------------------------------------------------------------------- 1 | """Utility class for formatting Slack Messages.""" 2 | 3 | 4 | def wrap_slack_code(str): 5 | """Format code.""" 6 | return f"`{str}`" 7 | 8 | 9 | def wrap_code_block(str): 10 | """Format code block.""" 11 | return f"```\n{str}\n```" 12 | 13 | 14 | def wrap_quote(str): 15 | """Format quote.""" 16 | return f"> {str}\n" 17 | 18 | 19 | def wrap_emph(str): 20 | """Format emph.""" 21 | return f"*{str}*" 22 | -------------------------------------------------------------------------------- /docs/api/db.rst: -------------------------------------------------------------------------------- 1 | Database 2 | ======== 3 | 4 | Commonly used database utilities 5 | -------------------------------- 6 | 7 | .. automodule:: db.utils 8 | :members: 9 | 10 | Database Facade 11 | --------------- 12 | 13 | .. autoclass:: db.facade.DBFacade 14 | :members: 15 | 16 | DynamoDB 17 | -------- 18 | 19 | .. autoclass:: db.dynamodb.DynamoDB 20 | :members: 21 | 22 | MemoryDB 23 | -------- 24 | 25 | .. automodule:: tests.memorydb 26 | :members: 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | # Let Docker cache things that persist between runs 4 | RUN pip install pipenv 5 | 6 | # Set up working directory 7 | WORKDIR /app 8 | 9 | # Install everything - lets Docker cache this as a layer 10 | COPY ./Pipfile . 11 | COPY ./Pipfile.lock . 12 | RUN pipenv install 13 | 14 | # Copy source code into working directory 15 | COPY . /app 16 | 17 | EXPOSE 5000 18 | 19 | ENV FLASK_APP=server/server.py 20 | 21 | CMD ["pipenv", "run", "launch"] 22 | -------------------------------------------------------------------------------- /sandbox.yml: -------------------------------------------------------------------------------- 1 | # sandbox.yml 2 | # Spin up a sandbox environment for local development using Docker. 3 | # 4 | # Usage: 5 | # docker-compose -f sandbox.yml up 6 | # 7 | 8 | version: '3' 9 | 10 | services: 11 | dynamodb: 12 | image: amazon/dynamodb-local 13 | ports: 14 | - 8000:8000 15 | command: -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb -dbPath ./data 16 | volumes: 17 | - ${PWD}/DynamoDB/data:/home/dynamodblocal/data 18 | -------------------------------------------------------------------------------- /scripts/setup_localaws.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # AWS CLI asks for: 3 | # - AWS Access Key ID (random key id for local db) 4 | # - AWS Secret Access Key (random key id for local db) 5 | # - Default region name (any region name should do; check list at 6 | # https://goo.gl/BcTEGn) 7 | # - Default output format (we use json, but text or table works as well) 8 | pipenv run aws configure << EOF 9 | Access_Key_ID_Dont_Look 10 | Super_Secret_Access_Key 11 | ca-central-1 12 | json 13 | EOF 14 | -------------------------------------------------------------------------------- /app/controller/webhook/github/events/__init__.py: -------------------------------------------------------------------------------- 1 | """Contain the handlers for each type of supported GitHub webhook.""" 2 | import app.controller.webhook.github.events.membership as membership 3 | import app.controller.webhook.github.events.organization as organization 4 | import app.controller.webhook.github.events.team as team 5 | 6 | MembershipEventHandler = membership.MembershipEventHandler 7 | OrganizationEventHandler = organization.OrganizationEventHandler 8 | TeamEventHandler = team.TeamEventHandler 9 | -------------------------------------------------------------------------------- /docs/_static/theme_overrides.css: -------------------------------------------------------------------------------- 1 | /* override table width restrictions */ 2 | @media screen and (min-width: 767px) { 3 | 4 | .wy-table-responsive table td { 5 | /* !important prevents the common CSS stylesheets from overriding 6 | this as on RTD they are loaded after this stylesheet */ 7 | white-space: normal !important; 8 | } 9 | 10 | .wy-table-responsive { 11 | overflow: visible !important; 12 | } 13 | } 14 | 15 | .invisible { 16 | display: none; 17 | } 18 | 19 | .center { 20 | text-align: center; 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/tooling.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tooling improvement 3 | about: Suggest improvements for Rocket tooling 4 | title: '' 5 | labels: 'theme:tooling' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary 11 | 12 | 15 | 16 | ## Requirements 17 | 18 | 22 | 23 | ## Context 24 | 25 | 28 | -------------------------------------------------------------------------------- /docs/api/webhook.rst: -------------------------------------------------------------------------------- 1 | Webhooks 2 | ======== 3 | 4 | Github 5 | ------ 6 | 7 | .. automodule:: app.controller.webhook.github.core 8 | :members: 9 | 10 | .. automodule:: app.controller.webhook.github.events.base 11 | :members: 12 | 13 | .. automodule:: app.controller.webhook.github.events.membership 14 | :members: 15 | 16 | .. automodule:: app.controller.webhook.github.events.organization 17 | :members: 18 | 19 | .. automodule:: app.controller.webhook.github.events.team 20 | :members: 21 | 22 | Slack 23 | ----- 24 | 25 | .. automodule:: app.controller.webhook.slack.core 26 | :members: 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | versioning-strategy: "lockfile-only" # only Pipfile.lock changes 11 | commit-message: 12 | prefix: "deps" 13 | schedule: 14 | interval: "monthly" 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/refactor.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Refactoring and cleanup 3 | about: Request refactoring or code cleanup 4 | title: '' 5 | labels: 'theme:refactoring' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary 11 | 12 | 15 | 16 | ## Requirements 17 | 18 | 23 | 24 | ## Context 25 | 26 | 29 | -------------------------------------------------------------------------------- /docs/api/interface.rst: -------------------------------------------------------------------------------- 1 | Interface 2 | ========= 3 | 4 | Amazon CloudWatch 5 | ----------------- 6 | 7 | .. autoclass:: interface.cloudwatch_metrics.CWMetrics 8 | :members: 9 | 10 | Github 11 | ------ 12 | 13 | .. automodule:: interface.github 14 | :members: 15 | 16 | .. automodule:: interface.github_app 17 | :members: 18 | 19 | .. automodule:: interface.exceptions.github 20 | :members: 21 | 22 | Google 23 | ------ 24 | 25 | .. automodule:: interface.gcp 26 | :members: 27 | 28 | .. automodule:: interface.gcp_utils 29 | :members: 30 | 31 | Slack 32 | ----- 33 | 34 | .. automodule:: interface.slack 35 | :members: 36 | -------------------------------------------------------------------------------- /dump-db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Dumps all tables into a single python pickle file. 3 | 4 | Run with pipenv run python dump-db.py 5 | """ 6 | from config import Config 7 | from factory import make_dbfacade 8 | from app.model import Team, User 9 | import pickle 10 | 11 | filename = 'db.pkl' 12 | 13 | db = make_dbfacade(Config()) 14 | all_teams = db.query(Team) 15 | all_users = db.query(User) 16 | 17 | data = { 18 | 'teams': all_teams, 19 | 'users': all_users 20 | } 21 | 22 | with open(filename, 'wb') as f: 23 | pickle.dump(data, f) 24 | 25 | print('Data written to file `%s`; %d teams and %d users' % 26 | (filename, len(all_teams), len(all_users))) 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Ticket(s) 4 | 5 | 12 | 13 | ## Details 14 | 15 | 22 | -------------------------------------------------------------------------------- /sample-env: -------------------------------------------------------------------------------- 1 | # Refer to https://rocket2.readthedocs.io/en/latest/docs/Config.html 2 | 3 | SLACK_NOTIFICATION_CHANNEL='#rocket2' 4 | SLACK_ANNOUNCEMENT_CHANNEL='#rocket2-announcements' 5 | SLACK_SIGNING_SECRET='' 6 | SLACK_API_TOKEN='' 7 | 8 | GITHUB_APP_ID='' 9 | GITHUB_ORG_NAME='ubclaunchpad' 10 | GITHUB_DEFAULT_TEAM_NAME='all' 11 | GITHUB_ADMIN_TEAM_NAME='exec' 12 | GITHUB_LEADS_TEAM_NAME='leads' 13 | GITHUB_WEBHOOK_ENDPT='/webhook' 14 | GITHUB_WEBHOOK_SECRET='' 15 | GITHUB_KEY='BEGIN KEY END KEY' 16 | 17 | AWS_ACCESS_KEYID='53' 18 | AWS_SECRET_KEY='itsa secret' 19 | AWS_USERS_TABLE='users' 20 | AWS_TEAMS_TABLE='teams' 21 | AWS_REGION='us-west-2' 22 | AWS_LOCAL='False' # set to 'True' to use local DynamoDB 23 | -------------------------------------------------------------------------------- /scripts/build_check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | REPO_ROOT=$(git rev-parse --show-toplevel) 5 | pushd "${REPO_ROOT}" 6 | 7 | make lint 8 | 9 | # We use a script to check if dynamodb is running locally 10 | COV_OPTIONS="--mypy --cov=./ --cov-branch --cov-config .coveragerc" 11 | PORT_CHECKER="scripts/port_busy.py" 12 | TESTS="tests/" 13 | PORT_BUSY="pipenv run python ${PORT_CHECKER} 8000" 14 | if ${PORT_BUSY}; then 15 | printf "DynamoDB detected. Running all tests.\n" 16 | pipenv run pytest "${TESTS}" ${COV_OPTIONS} 17 | else 18 | printf "Warning: DynamoDB not detected. Running without the tests.\n" 19 | pipenv run pytest "${TESTS}" -m "not db" ${COV_OPTIONS} 20 | fi 21 | 22 | popd 23 | -------------------------------------------------------------------------------- /app/scheduler/modules/base.py: -------------------------------------------------------------------------------- 1 | """The base class for all scheduler modules.""" 2 | from abc import ABC 3 | from typing import Dict, Any 4 | from flask import Flask 5 | from config import Config 6 | 7 | 8 | class ModuleBase(ABC): 9 | """Base class for all scheduler modules.""" 10 | 11 | NAME = 'Base Module' 12 | 13 | def __init__(self, 14 | flask_app: Flask, 15 | config: Config): 16 | """Initialize the object.""" 17 | pass 18 | 19 | def do_it(self): 20 | """Call to execute the function to be executed.""" 21 | pass 22 | 23 | def get_job_args(self) -> Dict[str, Any]: 24 | """Call to obtain a dictionary of job arguments.""" 25 | pass 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile lint 15 | 16 | lint: 17 | pipenv run pycodestyle . 18 | pipenv run flake8 . 19 | pipenv run mypy . 20 | 21 | # Catch-all target: route all unknown targets to Sphinx using the new 22 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 23 | %: Makefile 24 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help Rocket 2 improve 4 | title: '' 5 | labels: 'theme:bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary 11 | 12 | 15 | 16 | ## Reproduction steps 17 | 18 | 26 | 27 | ## Expected behaviour 28 | 29 | 33 | 34 | ## Context 35 | 36 | 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Rocket 2 4 | title: '' 5 | labels: 'theme:feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary 11 | 12 | 15 | 16 | ## Requirements 17 | 18 | 23 | 24 | ## Context 25 | 26 | 30 | -------------------------------------------------------------------------------- /app/controller/command/commands/__init__.py: -------------------------------------------------------------------------------- 1 | """Pack the modules contained in the commands directory.""" 2 | import app.controller.command.commands.team as team 3 | import app.controller.command.commands.user as user 4 | import app.controller.command.commands.export as export 5 | import app.controller.command.commands.token as token 6 | import app.controller.command.commands.karma as karma 7 | import app.controller.command.commands.mention as mention 8 | import app.controller.command.commands.iquit as iquit 9 | 10 | TeamCommand = team.TeamCommand 11 | UserCommand = user.UserCommand 12 | ExportCommand = export.ExportCommand 13 | TokenCommand = token.TokenCommand 14 | KarmaCommand = karma.KarmaCommand 15 | MentionCommand = mention.MentionCommand 16 | IQuitCommand = iquit.IQuitCommand 17 | -------------------------------------------------------------------------------- /docs/api/command.rst: -------------------------------------------------------------------------------- 1 | Commands 2 | ======== 3 | 4 | Commands Parser 5 | --------------- 6 | 7 | .. automodule:: app.controller.command.parser 8 | :members: 9 | 10 | User 11 | ---- 12 | 13 | .. automodule:: app.controller.command.commands.user 14 | :members: 15 | 16 | Team 17 | ---- 18 | 19 | .. automodule:: app.controller.command.commands.team 20 | :members: 21 | 22 | Token 23 | ----- 24 | 25 | .. automodule:: app.controller.command.commands.token 26 | :members: 27 | 28 | Karma 29 | ----- 30 | 31 | .. automodule:: app.controller.command.commands.karma 32 | :members: 33 | 34 | Mention 35 | ------- 36 | 37 | .. automodule:: app.controller.command.commands.mention 38 | :members: 39 | 40 | I-Quit 41 | ------ 42 | 43 | .. automodule:: app.controller.command.commands.iquit 44 | :members: 45 | -------------------------------------------------------------------------------- /scripts/download_dynamodb_and_run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # This script downloads the latest dynamodb archive for local use, sets up aws 3 | # configurations with dummy values (to get it going), and starts the db daemon. 4 | # Meant for use on automated builds only, and not for personal use. 5 | 6 | # Download DynamoDB archive for local use (testing) 7 | if [[ ! -d DynamoDB ]]; then 8 | printf "Setting up DynamoDB, locally...\n" 9 | wget https://s3-us-west-2.amazonaws.com/dynamodb-local/dynamodb_local_latest.tar.gz 10 | mkdir DynamoDB 11 | tar -xvf dynamodb_local_latest.tar.gz --directory DynamoDB 12 | else 13 | printf "DynamoDB set up correctly\n" 14 | fi 15 | 16 | # Run DynamoDB through java 17 | cd DynamoDB 18 | java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar -sharedDb & 19 | cd .. 20 | 21 | sleep 3 22 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Check out the repo 12 | uses: actions/checkout@v2 13 | - name: Set up Docker Buildx 14 | uses: docker/setup-buildx-action@v1 15 | - name: Login to gcr.io 16 | uses: docker/login-action@v1 17 | with: 18 | registry: ghcr.io 19 | username: ubclaunchpad 20 | password: ${{ secrets.GH_PACKAGES_TOKEN }} 21 | - name: Build and push to GitHub Container Registry 22 | uses: docker/build-push-action@v2 23 | with: 24 | registry: ghcr.io 25 | push: true 26 | tags: | 27 | ghcr.io/ubclaunchpad/rocket2:latest 28 | -------------------------------------------------------------------------------- /index.rst: -------------------------------------------------------------------------------- 1 | .. include:: README.rst 2 | 3 | .. toctree:: 4 | :hidden: 5 | 6 | 🚀 Rocket 2 7 | CONTRIBUTING 8 | 9 | .. toctree:: 10 | :caption: Development 11 | :hidden: 12 | 13 | docs/LocalDevelopmentGuide 14 | docs/Scripts 15 | docs/Testing 16 | docs/DevelopmentTutorials 17 | 18 | .. toctree:: 19 | :caption: How Rocket 2 Works 20 | :hidden: 21 | 22 | docs/Architecture 23 | docs/Config 24 | docs/Database 25 | docs/Deployment 26 | 27 | .. toctree:: 28 | :caption: Usage 29 | :hidden: 30 | 31 | docs/UserCommands 32 | docs/TeamCommands 33 | docs/KarmaCommands 34 | 35 | .. toctree:: 36 | :caption: Internal 37 | :maxdepth: 2 38 | :glob: 39 | :hidden: 40 | 41 | docs/api/* 42 | 43 | .. toctree:: 44 | :caption: Other 45 | :hidden: 46 | 47 | LICENSE 48 | CODE_OF_CONDUCT 49 | 50 | Indices and tables 51 | ================== 52 | 53 | * :ref:`genindex` 54 | * :ref:`modindex` 55 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.7 3 | warn_return_any = True 4 | warn_unused_configs = True 5 | namespace_packages = True 6 | 7 | # Files to ignore 8 | [mypy-conf] 9 | ignore_errors = True 10 | 11 | # Imports to ignore 12 | [mypy-github.*] 13 | ignore_missing_imports = True 14 | 15 | [mypy-slack.*] 16 | ignore_missing_imports = True 17 | 18 | [mypy-boto3.*] 19 | ignore_missing_imports = True 20 | 21 | [mypy-slackeventsapi.*] 22 | ignore_missing_imports = True 23 | 24 | [mypy-structlog.*] 25 | ignore_missing_imports = True 26 | 27 | [mypy-flask_talisman.*] 28 | ignore_missing_imports = True 29 | 30 | [mypy-flask_seasurf.*] 31 | ignore_missing_imports = True 32 | 33 | [mypy-pytest.*] 34 | ignore_missing_imports = True 35 | 36 | [mypy-pem.*] 37 | ignore_missing_imports = True 38 | 39 | [mypy-apscheduler.*] 40 | ignore_missing_imports = True 41 | 42 | [mypy-googleapiclient.*] 43 | ignore_missing_imports = True 44 | 45 | [mypy-google.oauth2] 46 | ignore_missing_imports = True 47 | -------------------------------------------------------------------------------- /restore-db.py: -------------------------------------------------------------------------------- 1 | """ 2 | Restores all tables from a pickle file to database. 3 | 4 | This is done by inserting them into the database via the db.store function. 5 | With Amazon DynamoDB, nothing happens when the row inserted is a duplicate 6 | (i.e. has the same primary key). 7 | 8 | Run with pipenv run python restore-db.py 9 | """ 10 | from config import Config 11 | from factory import make_dbfacade 12 | import pickle 13 | 14 | filename = 'db.pkl' 15 | 16 | db = make_dbfacade(Config()) 17 | 18 | with open(filename, 'rb') as f: 19 | data = pickle.load(f) 20 | 21 | if 'teams' in data and 'users' in data: 22 | restored = 0 23 | 24 | for team in data['teams']: 25 | restored += 1 if db.store(team) else 0 26 | for user in data['users']: 27 | restored += 1 if db.store(user) else 0 28 | 29 | print('Restored %d/%d items.' % 30 | (restored, len(data['teams']) + len(data['users']))) 31 | else: 32 | print('Could not read data; try exporting it again. Missing keys.') 33 | -------------------------------------------------------------------------------- /tests/app/model/permissions_test.py: -------------------------------------------------------------------------------- 1 | from app.model import Permissions 2 | from unittest import TestCase 3 | 4 | 5 | class TestPermissionsModel(TestCase): 6 | def test_admin_the_biggest(self): 7 | self.assertGreater(Permissions.admin, Permissions.team_lead) 8 | self.assertGreaterEqual(Permissions.admin, Permissions.member) 9 | self.assertGreaterEqual(Permissions.admin, Permissions.admin) 10 | 11 | def test_invalid_typing(self): 12 | with self.assertRaises(TypeError): 13 | Permissions.member < 0 14 | with self.assertRaises(TypeError): 15 | Permissions.team_lead <= 0 16 | with self.assertRaises(TypeError): 17 | Permissions.team_lead > 12 18 | with self.assertRaises(TypeError): 19 | Permissions.admin >= 13 20 | 21 | def test_member_the_smallest(self): 22 | self.assertLess(Permissions.member, Permissions.team_lead) 23 | self.assertLessEqual(Permissions.member, Permissions.member) 24 | self.assertLessEqual(Permissions.member, Permissions.admin) 25 | -------------------------------------------------------------------------------- /tests/utils/slack_msg_fmt_test.py: -------------------------------------------------------------------------------- 1 | """Test slack message formatting utility class.""" 2 | from utils.slack_msg_fmt import \ 3 | wrap_slack_code, wrap_code_block, wrap_quote, wrap_emph 4 | from unittest import TestCase 5 | 6 | 7 | class TestGithubInterface(TestCase): 8 | """Unittest TestCase for testing SlackMsgFmt.""" 9 | 10 | def test_code(self): 11 | """Test code formatting.""" 12 | code = 'map(lambda x: x)' 13 | assert wrap_slack_code(code) == f"`{code}`" 14 | 15 | def test_code_block(self): 16 | """Test code block formatting.""" 17 | code_block = 'map(lambda x: x)\nmap(lambda x: x)' 18 | assert wrap_code_block(code_block) == f"```\n{code_block}\n```" 19 | 20 | def test_quote(self): 21 | """Test quote formatting.""" 22 | quote = 'this is\na multi-line\n quote.' 23 | assert wrap_quote(quote) == f"> {quote}\n" 24 | 25 | def test_emph(self): 26 | """Test emph formatting.""" 27 | emph = 'THIS IS VERY IMPORTANT!!!\nPLEASE READ IT!!!' 28 | assert wrap_emph(emph) == f"*{emph}*" 29 | -------------------------------------------------------------------------------- /tests/app/scheduler/base_test.py: -------------------------------------------------------------------------------- 1 | """Test Scheduler base class.""" 2 | from unittest import TestCase 3 | from unittest.mock import MagicMock 4 | from flask import Flask 5 | from apscheduler.schedulers.background import BackgroundScheduler 6 | from app.scheduler import Scheduler 7 | from config import Config 8 | 9 | 10 | class TestScheduler(TestCase): 11 | """Test Scheduler base class.""" 12 | 13 | def setUp(self): 14 | """Set up testing environment.""" 15 | self.config = MagicMock(Config) 16 | self.flask = MagicMock(Flask) 17 | self.args = (self.flask, self.config) 18 | self.bgsched = MagicMock(BackgroundScheduler) 19 | 20 | self.config.slack_announcement_channel = "#general" 21 | self.config.slack_notification_channel = "#general" 22 | self.config.slack_api_token = "sometoken.exe" 23 | 24 | def test_proper_initialization(self): 25 | """Test proper initialization with proper arguments.""" 26 | s = Scheduler(self.bgsched, self.args) 27 | 28 | self.assertEqual(self.bgsched.add_job.call_count, 1) 29 | self.assertEqual(len(s.modules), 1) 30 | -------------------------------------------------------------------------------- /app/controller/webhook/github/events/base.py: -------------------------------------------------------------------------------- 1 | """Define the abstract base class for a GitHub event handler.""" 2 | from abc import ABC, abstractmethod 3 | from db.facade import DBFacade 4 | from interface.github import GithubInterface 5 | from app.controller import ResponseTuple 6 | from config import Config 7 | from typing import Dict, Any, List 8 | 9 | 10 | class GitHubEventHandler(ABC): 11 | """Define the properties and methods needed for a GitHub event handler.""" 12 | 13 | def __init__(self, db_facade: DBFacade, gh_face: GithubInterface, 14 | conf: Config): 15 | """Give handler access to the database facade.""" 16 | self._facade = db_facade 17 | self._gh = gh_face 18 | self._conf = conf 19 | super().__init__() 20 | 21 | @property 22 | @abstractmethod 23 | def supported_action_list(self) -> List[str]: 24 | """Provide a list of all actions this handler can handle.""" 25 | pass 26 | 27 | @abstractmethod 28 | def handle(self, payload: Dict[str, Any]) -> ResponseTuple: 29 | """Handle a GitHub event.""" 30 | pass 31 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | Flask = "~=1.1.0" 8 | flask-talisman = "~=0.7.0" 9 | flask-limiter = "~=1.4" 10 | boto3 = "~=1.15.0" 11 | gunicorn = "~=20.0.0" 12 | pytest = "~=6.0.0" 13 | slackclient = "~=2.9.0" 14 | slackeventsapi = "~=2.2.0" 15 | pygithub = "~=1.53" 16 | structlog = "~=20.1.0" 17 | colorama = "~=0.4" 18 | pyjwt = "~=1.7.1" 19 | pem = "~=20.1.0" 20 | cryptography = "~=3.1.1" 21 | requests = "~=2.24.0" 22 | apscheduler = "~=3.6.3" 23 | watchtower = "~=0.7.3" 24 | google-api-python-client = "~=1.12.2" 25 | google-auth-oauthlib = "~=0.4.0" 26 | 27 | [dev-packages] 28 | awscli = "~=1.18.0" 29 | codecov = "~=2.1.10" 30 | flake8 = "~=3.8.4" 31 | ipython = "~=7.12.0" 32 | pycodestyle = "~=2.6.0" 33 | pylint = "~=2.6.0" 34 | pytest-cov = "~=2.10.1" 35 | sphinx = "~=3.2.1" 36 | sphinx-rtd-theme = "~=0.5.0" 37 | mypy = "~=0.782" 38 | pytest-mypy = "~=0.7.0" 39 | sphinx-autodoc-typehints = "~=1.11.1" 40 | 41 | [requires] 42 | python_version = "3.8" 43 | 44 | [scripts] 45 | launch = "gunicorn -b 0.0.0.0:5000 -w 1 --forwarded-allow-ips=* app.server:app" 46 | lint = "make lint" 47 | -------------------------------------------------------------------------------- /scripts/port_busy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Check to see if a port is busy on the machine you are running on. 3 | 4 | Usage: python3 scripts/port_busy.py 5 | 6 | Used in place of `nmap` for automatically checking if the port used for local 7 | instances of DynamoDB is in use. 8 | 9 | Exits with 0 if the port is in use. 10 | Exits with 1 if there is an issue connecting with the port you provided. 11 | Exits with 2 if the 'port' you provided couldn't be converted to an integer. 12 | Exits with 3 if you didn't provide exactly 1 argument. 13 | Exits with 4 if the port is not already in use. 14 | """ 15 | import socket 16 | import errno 17 | import sys 18 | 19 | if len(sys.argv) != 2: 20 | print(f'{sys.argv[0]} ') 21 | sys.exit(3) 22 | 23 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 24 | 25 | try: 26 | s.bind(('localhost', int(sys.argv[1]))) 27 | 28 | sys.exit(4) 29 | except socket.error as e: 30 | if e.errno == errno.EADDRINUSE: 31 | # Address in use 32 | sys.exit(0) 33 | else: 34 | # Some other thing 35 | sys.exit(1) 36 | except TypeError: 37 | # Couldn't convert string to int 38 | sys.exit(2) 39 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | MIT License 5 | 6 | Copyright (c) 2018 UBC Launch Pad 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a 9 | copy of this software and associated documentation files (the 10 | “Software”), to deal in the Software without restriction, including 11 | without limitation the rights to use, copy, modify, merge, publish, 12 | distribute, sublicense, and/or sell copies of the Software, and to 13 | permit persons to whom the Software is furnished to do so, subject to 14 | the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included 17 | in all copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS 20 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 21 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 22 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 23 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 24 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 25 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /docs/KarmaCommands.rst: -------------------------------------------------------------------------------- 1 | Karma Command Reference 2 | ======================= 3 | 4 | Command to giveth or taketh away a user's karma 5 | 6 | Options 7 | ------- 8 | 9 | For normal users 10 | ~~~~~~~~~~~~~~~~ 11 | 12 | Add 1 karma to user 13 | ^^^^^^^^^^^^^^^^^^^ 14 | 15 | .. code:: sh 16 | 17 | /rocket @user ++ 18 | 19 | View a user's karma 20 | ^^^^^^^^^^^^^^^^^^^ 21 | 22 | .. code:: sh 23 | 24 | /rocket karma view @user 25 | 26 | For admin only 27 | ~~~~~~~~~~~~~~ 28 | 29 | Set user karma 30 | ^^^^^^^^^^^^^^ 31 | 32 | .. code:: sh 33 | 34 | /rocket karma set @user {amount} 35 | 36 | Reset all user karma 37 | ^^^^^^^^^^^^^^^^^^^^ 38 | 39 | .. code:: sh 40 | 41 | /rocket karma reset --all 42 | 43 | Examples 44 | ~~~~~~~~ 45 | 46 | .. code:: sh 47 | 48 | # normal user 49 | /rocket @coolkid1 ++ #adds 1 karma to coolkid1 50 | /rocket karma view @coolkid1 #view how much karma coolkid1 has 51 | 52 | # admin only 53 | /rocket karma set @coolkid1 5 #sets coolkid's karma to 5 54 | /rocket karma reset --all #resets all users karma to 1 55 | 56 | Help 57 | ^^^^ 58 | 59 | Display options for karma commands 60 | '''''''''''''''''''''''''''''''''' 61 | 62 | .. code:: sh 63 | 64 | /rocket karma help 65 | -------------------------------------------------------------------------------- /app/model/permissions.py: -------------------------------------------------------------------------------- 1 | """Data model to represent permissions.""" 2 | from enum import Enum 3 | 4 | 5 | class OrderedEnum(Enum): 6 | """ 7 | Comparable enum - 8 | copied from https://docs.python.org/3/library/enum.html#orderedenum 9 | """ 10 | 11 | def __ge__(self, other): 12 | if self.__class__ is other.__class__: 13 | return self.value >= other.value 14 | return NotImplemented 15 | 16 | def __gt__(self, other): 17 | if self.__class__ is other.__class__: 18 | return self.value > other.value 19 | return NotImplemented 20 | 21 | def __le__(self, other): 22 | if self.__class__ is other.__class__: 23 | return self.value <= other.value 24 | return NotImplemented 25 | 26 | def __lt__(self, other): 27 | if self.__class__ is other.__class__: 28 | return self.value < other.value 29 | return NotImplemented 30 | 31 | 32 | class Permissions(OrderedEnum): 33 | """Enum to represent possible permissions levels.""" 34 | 35 | member = 1 36 | team_lead = 2 37 | admin = 3 38 | 39 | def __str__(self) -> str: 40 | """Return the string without 'Permissions.' prepended.""" 41 | return self.name 42 | -------------------------------------------------------------------------------- /docs/Architecture.rst: -------------------------------------------------------------------------------- 1 | Architecture 2 | ============ 3 | 4 | .. image:: rocket2arch.png 5 | 6 | Our Flask server serves to handle all incoming Slack events, slash command, and 7 | Github webhooks. Slash commands are handled by :py:mod:`app.controller.command.parser`, 8 | and the remaining events and webhooks are handled by :py:mod:`app.controller.webhook.github.core` 9 | and :py:mod:`app.controller.webhook.slack.core`. 10 | 11 | We store our data in an Amazon DynamoDB, which can be accessed directly by the 12 | database facade :py:class:`db.dynamodb.DynamoDB`. 13 | 14 | We treat GitHub itself as the sole source of truth. Any modifications done on the 15 | Github side (e.g. changing team names, adding/removing team members, 16 | creating/deleting teams, etc.) is reflected into the database. 17 | Whenever an attempt is made at modifying the existing teams (e.g. using a slash 18 | command to add/remove members, create/delete teams, edit teams), the changes are 19 | made using the Github API, and then done on our database. 20 | 21 | We run a cron-style scheduler that execute specific tasks at regular intervals. 22 | To learn how to add tasks and modules to it, have a look at `this tutorial`_. 23 | 24 | .. _this tutorial: DevelopmentTutorials.html#create-a-scheduler-module 25 | -------------------------------------------------------------------------------- /app/scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | """Scheduler for scheduling.""" 2 | import atexit 3 | from flask import Flask 4 | from apscheduler.schedulers.background import BackgroundScheduler 5 | from .modules.random_channel import RandomChannelPromoter 6 | from .modules.base import ModuleBase 7 | from typing import Tuple, List 8 | from config import Config 9 | 10 | 11 | class Scheduler: 12 | """The scheduler class for scheduling everything.""" 13 | 14 | def __init__(self, 15 | scheduler: BackgroundScheduler, 16 | args: Tuple[Flask, Config]): 17 | """Initialize scheduler class.""" 18 | self.scheduler = scheduler 19 | self.args = args 20 | self.modules: List[ModuleBase] = [] 21 | 22 | self.__init_periodic_tasks() 23 | 24 | atexit.register(self.scheduler.shutdown) 25 | 26 | def start(self): 27 | """Start the scheduler, officially.""" 28 | self.scheduler.start() 29 | 30 | def __add_job(self, module: ModuleBase): 31 | """Add module as a job.""" 32 | self.scheduler.add_job(func=module.do_it, **module.get_job_args()) 33 | self.modules.append(module) 34 | 35 | def __init_periodic_tasks(self): 36 | """Add jobs that fire every interval.""" 37 | self.__add_job(RandomChannelPromoter(*self.args)) 38 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull request 2 | 3 | on: pull_request 4 | 5 | env: 6 | PIPENV_CACHE_DIR: ~/.cache/pipenv 7 | 8 | jobs: 9 | pipeline: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Set up Python 3.8 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: 3.8 17 | - name: Cache Pipfile Dependencies 18 | uses: actions/cache@v2 19 | with: 20 | path: ~/.cache/pipenv 21 | key: ${{ runner.os }}-build-pipfile-${{ hashFiles('Pipfile*') }} 22 | restore-keys: | 23 | ${{ runner.os }}-build-pipfile- 24 | - name: Cache pip Dependencies 25 | uses: actions/cache@v2 26 | with: 27 | path: ~/.cache/pip 28 | key: ${{ runner.os }}-build-pip-${{ hashFiles('Pipfile*') }} 29 | restore-keys: | 30 | ${{ runner.os }}-build-pip- 31 | - name: Cache local DDB 32 | uses: actions/cache@v2 33 | with: 34 | path: DynamoDB 35 | key: ddb 36 | - name: Install Dependencies 37 | run: | 38 | python -m pip install --upgrade pipenv 39 | pip install pipenv 40 | pipenv install --dev 41 | - name: Start local DynamoDB 42 | run: scripts/download_dynamodb_and_run.sh 43 | - name: Test everything 44 | run: | 45 | scripts/build_check.sh 46 | -------------------------------------------------------------------------------- /interface/cloudwatch_metrics.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import boto3 3 | from config import Config 4 | 5 | 6 | class CWMetrics: 7 | def __init__(self, config: Config): 8 | if config.aws_local: 9 | self.cw = None 10 | else: 11 | self.cw = boto3.client( 12 | service_name='cloudwatch', 13 | region_name=config.aws_region, 14 | aws_access_key_id=config.aws_access_keyid, 15 | aws_secret_access_key=config.aws_secret_key) 16 | logging.info('Initialized CWMetrics') 17 | 18 | def submit_cmd_mstime(self, cmd_name: str, ms: float): 19 | if self.cw is None: 20 | logging.info( 21 | f'Command Execution Time [{cmd_name}@Rocket 2]: {ms} ms' 22 | ) 23 | return 24 | 25 | self.cw.put_metric_data( 26 | Namespace='Rocket 2', 27 | MetricData=[ 28 | { 29 | 'MetricName': 'Command Execution Time', 30 | 'Dimensions': [ 31 | { 32 | 'Name': 'Command type', 33 | 'Value': cmd_name 34 | } 35 | ], 36 | 'Value': ms, 37 | 'Unit': 'Milliseconds' 38 | } 39 | ] 40 | ) 41 | -------------------------------------------------------------------------------- /app/controller/webhook/slack/core.py: -------------------------------------------------------------------------------- 1 | """Handle Slack events.""" 2 | import logging 3 | from app.model import User 4 | from db.facade import DBFacade 5 | from interface.slack import Bot, SlackAPIError 6 | from typing import Dict, Any 7 | 8 | 9 | class SlackEventsHandler: 10 | """Encapsulate the handlers for all Slack events.""" 11 | 12 | welcome = 'Welcome to UBC Launch Pad! Please type `/rocket user edit '\ 13 | '--github $GITHUB_USERNAME` to add yourself to the GitHub '\ 14 | 'organization.' 15 | 16 | def __init__(self, 17 | db_facade: DBFacade, 18 | bot: Bot): 19 | """Initialize all the required interfaces.""" 20 | self.__facade = db_facade 21 | self.__bot = bot 22 | 23 | def handle_team_join(self, event_data: Dict[str, Any]): 24 | """ 25 | Handle the event of a new user joining the workspace. 26 | 27 | :param event_data: JSON event data 28 | """ 29 | new_id = event_data["event"]["user"]["id"] 30 | new_user = User(new_id) 31 | self.__facade.store(new_user) 32 | try: 33 | self.__bot.send_dm(SlackEventsHandler.welcome, new_id) 34 | logging.info(f"{new_id} added to database - user notified") 35 | except SlackAPIError: 36 | logging.error(f"{new_id} added to database - user not notified") 37 | -------------------------------------------------------------------------------- /tests/interface/gcp_utils_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock, TestCase 2 | from interface.gcp_utils import sync_user_email_perms, sync_team_email_perms 3 | from app.model import User, Team 4 | from tests.memorydb import MemoryDB 5 | from interface.gcp import GCPInterface 6 | 7 | 8 | class TestGCPUtils(TestCase): 9 | def setUp(self): 10 | self.u0 = User('U93758') 11 | self.u0.github_id = '22343' 12 | self.u0.github_username = 'evilguy' 13 | self.u0.email = 'a@gmail.com' 14 | 15 | self.t0 = Team('465884', 'team-plasma', 'Team Plasma') 16 | self.t0.add_member(self.u0.github_id) 17 | self.t0.folder = 'oieasotbokneawsoieomieaomiewrsdoie' 18 | 19 | self.t1 = Team('394783', 'team-rocket', 'Team Rocket') 20 | self.t1.add_member(self.u0.github_id) 21 | 22 | self.db = MemoryDB(users=[self.u0], teams=[self.t0, self.t1]) 23 | 24 | self.gcp = mock.MagicMock(GCPInterface) 25 | 26 | def test_sync_user_email_perms(self): 27 | sync_user_email_perms(self.gcp, self.db, self.u0) 28 | 29 | self.gcp.ensure_drive_permissions.assert_called_once_with( 30 | self.t0.github_team_name, self.t0.folder, [self.u0.email] 31 | ) 32 | 33 | def test_sync_team_email_perms_bad_email(self): 34 | self.u0.email = 'bad@email@some.com' 35 | sync_team_email_perms(self.gcp, self.db, self.t0) 36 | 37 | self.gcp.ensure_drive_permissions.assert_not_called() 38 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - ec2-release 8 | 9 | env: 10 | PIPENV_CACHE_DIR: ${{ secrets.PIPENV_CACHE_DIR }} 11 | 12 | jobs: 13 | pipeline: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Set up Python 3.8 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: 3.8 21 | - name: Cache Pipfile Dependencies 22 | uses: actions/cache@v2 23 | with: 24 | path: ${{ secrets.PIPENV_CACHE_DIR }} 25 | key: ${{ runner.os }}-build-pipfile-${{ hashFiles('Pipfile*') }} 26 | restore-keys: | 27 | ${{ runner.os }}-build-pipfile- 28 | - name: Cache pip Dependencies 29 | uses: actions/cache@v2 30 | with: 31 | path: ~/.cache/pip 32 | key: ${{ runner.os }}-build-pip-${{ hashFiles('Pipfile*') }} 33 | restore-keys: | 34 | ${{ runner.os }}-build-pip- 35 | - name: Cache local DDB 36 | uses: actions/cache@v2 37 | with: 38 | path: DynamoDB 39 | key: ddb 40 | - name: Install Dependencies 41 | run: | 42 | python -m pip install --upgrade pipenv 43 | pip install pipenv 44 | pipenv install --dev 45 | - name: Start local DynamoDB 46 | run: scripts/download_dynamodb_and_run.sh 47 | - name: Test everything 48 | env: 49 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 50 | run: | 51 | scripts/build_check.sh 52 | pipenv run codecov 53 | -------------------------------------------------------------------------------- /.github/workflows/planning.yml: -------------------------------------------------------------------------------- 1 | # This workflow file defines automation for managing Rocket 2 planning, 2 | # primarily via the Rocket 2 Planning board: 3 | # https://github.com/ubclaunchpad/rocket2/projects/1?fullscreen=true 4 | name: Planning 5 | 6 | on: 7 | issues: 8 | types: 9 | - opened 10 | - assigned 11 | - unassigned 12 | 13 | env: 14 | PROJECT_NAME: 'Rocket 2 Planning' 15 | 16 | jobs: 17 | move-issue-to-project: 18 | if: ${{ github.event.action == 'opened' }} 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: alex-page/github-project-automation-plus@v0.3.0 22 | with: 23 | project: ${{ env.PROJECT_NAME }} 24 | column: 'Needs triage' 25 | # secrets.GITHUB_TOKEN is supposed to work, but doesn't seem to, 26 | # so a PAT with repo access is provided. 27 | repo-token: ${{ secrets.LP_GH_PUBLIC_REPO_TOKEN }} 28 | 29 | issue-planned: 30 | if: ${{ github.event.action == 'assigned' }} 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: alex-page/github-project-automation-plus@v0.3.0 34 | with: 35 | project: ${{ env.PROJECT_NAME }} 36 | column: '🚀 Planned' 37 | repo-token: ${{ secrets.LP_GH_PUBLIC_REPO_TOKEN }} 38 | 39 | issue-unplanned: 40 | if: ${{ github.event.action == 'unassigned' }} 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: alex-page/github-project-automation-plus@v0.3.0 44 | with: 45 | project: ${{ env.PROJECT_NAME }} 46 | column: '🗂 Backlog' 47 | repo-token: ${{ secrets.LP_GH_PUBLIC_REPO_TOKEN }} 48 | -------------------------------------------------------------------------------- /tests/interface/cloudwatch_metrics_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock, TestCase 2 | from interface.cloudwatch_metrics import CWMetrics 3 | from config import Config 4 | 5 | 6 | class TestCWMetrics(TestCase): 7 | def setUp(self): 8 | self.conf_disable_metrics = mock.MagicMock(Config) 9 | self.conf_enable_metrics = mock.MagicMock(Config) 10 | 11 | self.conf_enable_metrics.aws_local = False 12 | self.conf_enable_metrics.aws_region = 'us-west-2' 13 | self.conf_enable_metrics.aws_access_keyid = 'access key id' 14 | self.conf_enable_metrics.aws_secret_key = 'secret key' 15 | 16 | self.conf_disable_metrics.aws_local = True 17 | 18 | @mock.patch('logging.info') 19 | @mock.patch('boto3.client') 20 | def test_disabled_metrics(self, b3client, log): 21 | cwm = CWMetrics(self.conf_disable_metrics) 22 | b3client.assert_not_called() 23 | 24 | cwm.submit_cmd_mstime('team', 30) 25 | log.assert_called_with( 26 | 'Command Execution Time [team@Rocket 2]: 30 ms') 27 | 28 | @mock.patch('boto3.client') 29 | def test_enabled_metrics(self, b3client): 30 | client = mock.Mock() 31 | b3client.return_value = client 32 | 33 | cwm = CWMetrics(self.conf_enable_metrics) 34 | b3client.assert_called_once_with( 35 | service_name='cloudwatch', 36 | region_name=self.conf_enable_metrics.aws_region, 37 | aws_access_key_id=self.conf_enable_metrics.aws_access_keyid, 38 | aws_secret_access_key=self.conf_enable_metrics.aws_secret_key) 39 | 40 | cwm.submit_cmd_mstime('team', 30) 41 | client.put_metric_data.assert_called_once() 42 | -------------------------------------------------------------------------------- /app/scheduler/modules/random_channel.py: -------------------------------------------------------------------------------- 1 | """Feature random public channels.""" 2 | from slack import WebClient 3 | from interface.slack import Bot 4 | from random import choice 5 | from .base import ModuleBase 6 | from typing import Dict, Any 7 | from flask import Flask 8 | from config import Config 9 | import logging 10 | 11 | 12 | class RandomChannelPromoter(ModuleBase): 13 | """Module that promotes a random channel every Saturday.""" 14 | 15 | NAME = 'Feature random channels' 16 | 17 | def __init__(self, 18 | flask_app: Flask, 19 | config: Config): 20 | """Initialize the object.""" 21 | self.default_channel = config.slack_announcement_channel 22 | self.bot = Bot(WebClient(config.slack_api_token), 23 | config.slack_notification_channel) 24 | 25 | def get_job_args(self) -> Dict[str, Any]: 26 | """Get job configuration arguments for apscheduler.""" 27 | return {'trigger': 'cron', 28 | 'day_of_week': 'sat', 29 | 'hour': 12, 30 | 'name': self.NAME} 31 | 32 | def do_it(self): 33 | """Select and post random channels to #general.""" 34 | channels = list(filter(lambda c: not c['is_archived'], 35 | self.bot.get_channels())) 36 | rand_channel = choice(channels) 37 | channel_id, channel_name = rand_channel['id'], rand_channel['name'] 38 | self.bot.send_to_channel('Featured channel of the week: ' + 39 | f'<#{channel_id}|{channel_name}>!', 40 | self.default_channel) 41 | 42 | logging.info(f'Featured #{channel_name}') 43 | -------------------------------------------------------------------------------- /app/model/base.py: -------------------------------------------------------------------------------- 1 | """Define the abstract base class for a data model.""" 2 | from abc import ABC, abstractmethod 3 | from typing import Dict, Any, TypeVar, Type 4 | 5 | T = TypeVar('T', bound='RocketModel') 6 | 7 | 8 | class RocketModel(ABC): 9 | """Define the properties and methods needed for a data model.""" 10 | 11 | @abstractmethod 12 | def get_attachment(self) -> Dict[str, Any]: 13 | """Return slack-formatted attachment (dictionary) for data model.""" 14 | pass 15 | 16 | @classmethod 17 | @abstractmethod 18 | def to_dict(cls: Type[T], model: T) -> Dict[str, Any]: 19 | """ 20 | Convert data model object to dict object. 21 | 22 | The difference with the in-built ``self.__dict__`` is that this is more 23 | compatible with storing into NoSQL databases like DynamoDB. 24 | :param model: the data model object 25 | :return: the dictionary representing the data model 26 | """ 27 | pass 28 | 29 | @classmethod 30 | @abstractmethod 31 | def from_dict(cls: Type[T], d: Dict[str, Any]) -> T: 32 | """ 33 | Convert dict response object to data model object. 34 | 35 | :param d: the dictionary representing a data model 36 | :return: the converted data model object. 37 | """ 38 | pass 39 | 40 | @classmethod 41 | @abstractmethod 42 | def is_valid(cls: Type[T], model: T) -> bool: 43 | """ 44 | Return true if this data model has no missing required fields. 45 | 46 | :param model: data model object to check 47 | :return: true if this data model has no missing required fields 48 | """ 49 | pass 50 | -------------------------------------------------------------------------------- /tests/app/model/user_test.py: -------------------------------------------------------------------------------- 1 | """Test the data model for a user.""" 2 | from app.model import User, Permissions 3 | from unittest import TestCase 4 | 5 | 6 | class TestUserModel(TestCase): 7 | """Test some functions in the user model.""" 8 | 9 | def setUp(self): 10 | """Set up example models for use.""" 11 | self.brussel_sprouts = User('brussel-sprouts') 12 | self.brussel_sprouts2 = User('brussel-sprouts') 13 | self.brussel_trouts = User('brussel-trouts') 14 | 15 | self.no_id = User('') 16 | self.admin = User('U0G9QF9C6') 17 | self.admin.biography = 'bio test' 18 | self.admin.email = 'email@email.com' 19 | self.admin.permissions_level = Permissions.admin 20 | 21 | def test_user_equality(self): 22 | """Test the User class method __eq__() and __ne__().""" 23 | self.assertEqual(self.brussel_sprouts, self.brussel_sprouts2) 24 | self.assertNotEqual(self.brussel_sprouts2, self.brussel_trouts) 25 | 26 | def test_valid_user(self): 27 | """Test the User static class method is_valid().""" 28 | self.assertFalse(User.is_valid(self.no_id)) 29 | self.assertTrue(User.is_valid(self.admin)) 30 | 31 | def test_print(self): 32 | """Test print user class.""" 33 | expected = "{'slack_id': 'U0G9QF9C6'," \ 34 | " 'name': ''," \ 35 | " 'email': 'email@email.com'," \ 36 | " 'github_username': ''," \ 37 | " 'github_id': ''," \ 38 | " 'major': ''," \ 39 | " 'position': ''," \ 40 | " 'biography': 'bio test'," \ 41 | " 'image_url': ''," \ 42 | " 'permissions_level': ,"\ 43 | " 'karma': 1}" 44 | self.assertEqual(str(self.admin), expected) 45 | -------------------------------------------------------------------------------- /tests/db/utils_test.py: -------------------------------------------------------------------------------- 1 | from db.utils import get_team_members, get_users_by_ghid, get_team_by_name 2 | from tests.memorydb import MemoryDB 3 | from app.model import User, Team 4 | from unittest import TestCase 5 | 6 | 7 | class TestDbUtils(TestCase): 8 | def setUp(self): 9 | self.u0 = User('U395474') 10 | self.u0.github_id = '321132' 11 | self.u1 = User('U85739') 12 | self.u1.github_id = '000584' 13 | self.u2 = User('U3048485') 14 | self.u2.github_id = '11121' 15 | self.t0 = Team('305738', 'some-team', 'Some Team') 16 | self.t0.add_member(self.u0.github_id) 17 | self.t0.add_member(self.u1.github_id) 18 | self.t1 = Team('305849', 'some-team', 'Some Team') 19 | self.db = MemoryDB(users=[self.u0, self.u1, self.u2], 20 | teams=[self.t0]) 21 | 22 | def test_get_users_by_ghid_empty_list(self): 23 | self.assertEqual(get_users_by_ghid(self.db, []), []) 24 | 25 | def test_get_team_members(self): 26 | self.assertCountEqual(get_team_members(self.db, self.t0), 27 | [self.u0, self.u1]) 28 | 29 | def test_get_team_by_name_lots_of_teams_same_name(self): 30 | db = MemoryDB(teams=[self.t0, self.t1]) 31 | with self.assertRaises(RuntimeError): 32 | get_team_by_name(db, 'some-team') 33 | 34 | def test_get_team_by_name_no_team_name(self): 35 | with self.assertRaises(LookupError): 36 | get_team_by_name(self.db, 'random-team') 37 | 38 | def test_get_users_by_ghid(self): 39 | self.assertCountEqual( 40 | get_users_by_ghid(self.db, [self.u0.github_id, 41 | self.u1.github_id, 42 | self.u2.github_id]), 43 | [self.u0, self.u1, self.u2]) 44 | -------------------------------------------------------------------------------- /tests/app/controller/command/commands/token_test.py: -------------------------------------------------------------------------------- 1 | import jwt 2 | 3 | from app.controller.command.commands import TokenCommand 4 | from app.controller.command.commands.token import TokenCommandConfig 5 | from datetime import timedelta 6 | from tests.memorydb import MemoryDB 7 | from tests.util import create_test_admin 8 | from app.model import User, Permissions 9 | from unittest import TestCase 10 | 11 | 12 | def extract_jwt(msg): 13 | '''Hacky way to get returned token out when testing TokenCommand.''' 14 | parts = msg.split('```') 15 | return parts[1].strip() 16 | 17 | 18 | class TestTokenCommand(TestCase): 19 | def setUp(self): 20 | self.u = User('U12345') 21 | self.admin = create_test_admin('Uadmin') 22 | self.db = MemoryDB(users=[self.u, self.admin]) 23 | 24 | self.testcommand = TokenCommand( 25 | self.db, 26 | TokenCommandConfig(timedelta(days=7), 'secret') 27 | ) 28 | 29 | def test_handle_nonexistent_member(self): 30 | ret_val, ret_code = self.testcommand.handle('', 'nonexistent') 31 | self.assertEqual(ret_val, TokenCommand.lookup_error) 32 | self.assertEqual(ret_code, 200) 33 | 34 | def test_handle_member_request(self): 35 | ret_val, ret_code = self.testcommand.handle('', self.u.slack_id) 36 | self.assertEqual(ret_val, TokenCommand.permission_error) 37 | self.assertEqual(ret_code, 200) 38 | 39 | def test_handle_non_member_request(self): 40 | ret_msg, ret_code = self.testcommand.handle('', self.admin.slack_id) 41 | token = extract_jwt(ret_msg) 42 | decoded = jwt.decode(token, 'secret', algorithms='HS256') 43 | self.assertEqual(decoded['user_id'], self.admin.slack_id) 44 | self.assertEqual(decoded['permissions'], Permissions.admin.value) 45 | self.assertEqual(ret_code, 200) 46 | -------------------------------------------------------------------------------- /db/utils.py: -------------------------------------------------------------------------------- 1 | """Database utilities, for functions that you use all the time.""" 2 | from db.facade import DBFacade 3 | from app.model import Team, User 4 | from typing import List 5 | import logging 6 | 7 | 8 | def get_team_by_name(dbf: DBFacade, gh_team_name: str) -> Team: 9 | """ 10 | Query team by github team name. 11 | 12 | Can only return a single team. If there are no teams with that name, or 13 | there are multiple teams with that name, we raise an error. 14 | 15 | :raises: LookupError if the calling user, user to add, 16 | or specified team cannot be found in the database 17 | :raises: RuntimeError if more than one team has the specified 18 | team name 19 | :return: Team if found 20 | """ 21 | teams = dbf.query(Team, 22 | [('github_team_name', gh_team_name)]) 23 | 24 | if len(teams) < 1: 25 | msg = f"No teams found with team name {gh_team_name}" 26 | logging.error(msg) 27 | raise LookupError(msg) 28 | elif len(teams) > 1: 29 | msg = f"{len(teams)} found with team name {gh_team_name}" 30 | logging.error(msg) 31 | raise RuntimeError(msg) 32 | else: 33 | logging.info(f"Team queried with team name {gh_team_name}:" 34 | f" {teams[0].__str__()}") 35 | return teams[0] 36 | 37 | 38 | def get_team_members(dbf: DBFacade, team: Team) -> List[User]: 39 | """ 40 | Query users that are members of the given team. 41 | 42 | :return: Users that belong to the team 43 | """ 44 | return get_users_by_ghid(dbf, list(team.members)) 45 | 46 | 47 | def get_users_by_ghid(dbf: DBFacade, gh_ids: List[str]) -> List[User]: 48 | """ 49 | Query users by github user id. 50 | 51 | :return: List of users if found 52 | """ 53 | if len(gh_ids) == 0: 54 | return [] 55 | 56 | q = [('github_user_id', gh_id) for gh_id in gh_ids] 57 | users = dbf.query_or(User, q) 58 | return users 59 | -------------------------------------------------------------------------------- /tests/app/controller/command/commands/mention_test.py: -------------------------------------------------------------------------------- 1 | from app.controller.command.commands.mention import MentionCommand 2 | from tests.memorydb import MemoryDB 3 | from flask import Flask 4 | from app.model import User 5 | from unittest import TestCase 6 | 7 | 8 | class MentionCommandTest(TestCase): 9 | def setUp(self): 10 | self.app = Flask(__name__) 11 | 12 | self.u0 = User('UFJ42EU67') 13 | self.u0.name = 'steve' 14 | self.u1 = User('U12346456') 15 | self.u1.name = 'maria' 16 | self.db = MemoryDB(users=[self.u0, self.u1]) 17 | 18 | self.testcommand = MentionCommand(self.db) 19 | 20 | def test_handle_no_input(self): 21 | self.assertEqual(self.testcommand.handle(f'{self.u0.slack_id}', 22 | self.u1.slack_id), 23 | ('invalid command', 200)) 24 | 25 | def test_handle_unimplemented_fn(self): 26 | self.assertEqual(self.testcommand.handle(f"{self.u0.slack_id} --", 27 | self.u1.slack_id), 28 | (self.testcommand.unsupported_error, 200)) 29 | 30 | def test_handle_add_karma_to_another_user(self): 31 | self.assertEqual(self.testcommand.handle(f'{self.u0.slack_id} ++', 32 | self.u1.slack_id), 33 | (f'gave 1 karma to {self.u0.name}', 200)) 34 | self.assertEqual(self.u0.karma, 2) 35 | 36 | def test_handle_add_karma_to_self(self): 37 | self.assertEqual(self.testcommand.handle(f'{self.u0.slack_id} ++', 38 | self.u0.slack_id), 39 | ('cannot give karma to self', 200)) 40 | 41 | def test_handle_user_not_found(self): 42 | self.assertEqual(self.testcommand.handle('rando.id ++', 43 | self.u0.slack_id), 44 | (self.testcommand.lookup_error, 200)) 45 | -------------------------------------------------------------------------------- /app/controller/command/commands/base.py: -------------------------------------------------------------------------------- 1 | """Define the abstract base class for a command parser.""" 2 | from abc import ABC, abstractmethod 3 | from app.controller import ResponseTuple 4 | from typing import Optional 5 | 6 | 7 | class Command(ABC): 8 | """Define the properties and methods needed for a command parser.""" 9 | 10 | command_name = "" 11 | desc = "" 12 | 13 | def __init__(self): 14 | self.subparser = None 15 | 16 | @abstractmethod 17 | def handle(self, 18 | _command: str, 19 | user_id: str) -> ResponseTuple: 20 | """Handle a command.""" 21 | pass 22 | 23 | def get_help(self, subcommand: Optional[str] = None) -> str: 24 | """ 25 | Return command options with Slack formatting. 26 | 27 | If ``self.subparser`` isn't used, return the command's description 28 | instead. 29 | 30 | If ``subcommand`` is specified, return options for that specific 31 | subcommand (if that subcommand is one of the subparser's choices). 32 | Otherwise return a list of subcommands along with a short description. 33 | 34 | :param subcommand: name of specific subcommand to get help 35 | :return: nicely formatted string of options and help text 36 | """ 37 | if self.subparser is None: 38 | return self.desc 39 | 40 | if subcommand is None or subcommand not in self.subparser.choices: 41 | # Return commands and their descriptions 42 | res = f"\n*{self.command_name} commands:*" 43 | for argument in self.subparser.choices: 44 | cmd = self.subparser.choices[argument] 45 | res += f'\n> *{cmd.prog}*: {cmd.description}' 46 | return res 47 | else: 48 | # Return specific help-text of command 49 | res = "\n```" 50 | cmd = self.subparser.choices[subcommand] 51 | res += cmd.format_help() 52 | return res + "```" 53 | -------------------------------------------------------------------------------- /app/controller/command/commands/mention.py: -------------------------------------------------------------------------------- 1 | """Parser for all direct mentions made using rocket.""" 2 | import argparse 3 | import logging 4 | import shlex 5 | from app.controller.command.commands.base import Command 6 | from app.controller import ResponseTuple 7 | from db.facade import DBFacade 8 | from app.model import User 9 | 10 | 11 | class MentionCommand(Command): 12 | """Mention command parser.""" 13 | 14 | command_name = "mention" 15 | lookup_error = "User doesn't exist" 16 | desc = "for dealing with " + command_name 17 | karma_add_amount = 1 18 | unsupported_error = "unsupported usage" 19 | 20 | def __init__(self, db_facade: DBFacade): 21 | """Initialize Mention command.""" 22 | super().__init__() 23 | logging.info("Starting Mention command initializer") 24 | self.parser = argparse.ArgumentParser(prog="Mention") 25 | self.parser.add_argument("Mention") 26 | self.facade = db_facade 27 | 28 | def handle(self, command: str, user_id: str) -> ResponseTuple: 29 | """Handle command by splitting into substrings.""" 30 | logging.debug('Handling Mention Command') 31 | command_arg = shlex.split(command) 32 | if len(command_arg) <= 1: 33 | return "invalid command", 200 34 | elif command_arg[1] == '++': 35 | return self.add_karma(user_id, command_arg[0]) 36 | else: 37 | return "unsupported usage", 200 38 | 39 | def add_karma(self, giver_id: str, receiver_id: str) -> ResponseTuple: 40 | """Give karma from giver_id to receiver_id.""" 41 | logging.info("giving karma to " + receiver_id) 42 | if giver_id == receiver_id: 43 | return "cannot give karma to self", 200 44 | try: 45 | user = self.facade.retrieve(User, receiver_id) 46 | user.karma += self.karma_add_amount 47 | self.facade.store(user) 48 | return f"gave {self.karma_add_amount} karma to {user.name}", 200 49 | except LookupError: 50 | return self.lookup_error, 200 51 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes 1; 3 | 4 | error_log /dev/stderr warn; 5 | pid /var/run/nginx.pid; 6 | 7 | events { 8 | worker_connections 1024; 9 | accept_mutex off; 10 | } 11 | 12 | http { 13 | include /etc/nginx/mime.types; 14 | default_type application/octet-stream; 15 | 16 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 17 | '$status $body_bytes_sent "$http_referer" ' 18 | '"$http_user_agent" "$http_x_forwarded_for"'; 19 | access_log /dev/stdout main; 20 | 21 | sendfile on; 22 | keepalive_timeout 65; 23 | 24 | upstream app_server { 25 | # Note that this should be deployed via docker-compose, 26 | # hence rocket2 (the service name) is the correct hostname, 27 | # not localhost 28 | server rocket2:5000 fail_timeout=0; 29 | } 30 | 31 | server { 32 | # Redirect from HTTP to HTTPS 33 | listen 80; 34 | server_name _; 35 | return 301 https://$host$request_uri; 36 | } 37 | 38 | server { 39 | listen 443 ssl; 40 | server_name rocket2.ubclaunchpad.com; 41 | ssl_certificate /etc/letsencrypt/live/rocket2.ubclaunchpad.com/fullchain.pem; 42 | ssl_certificate_key /etc/letsencrypt/live/rocket2.ubclaunchpad.com/privkey.pem; 43 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 44 | ssl_ciphers HIGH:!aNULL:!MD5; 45 | 46 | location ^~ /.well-known/ { 47 | # Validate domain via LetsEncrypt 48 | root /usr/share/nginx/html; 49 | allow all; 50 | } 51 | 52 | location / { 53 | # Try to serve static files, 54 | # fallback to app otherwise 55 | try_files $uri @proxy_to_app; 56 | } 57 | 58 | location @proxy_to_app { 59 | # Proxy to Rocket 2 app server 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | proxy_set_header X-Forwarded-Proto $scheme; 62 | proxy_set_header Host $http_host; 63 | proxy_redirect off; 64 | proxy_pass http://app_server; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | """Some important (and often-used) utility functions.""" 2 | from app.model import User, Team, Permissions 3 | 4 | 5 | def create_test_admin(slack_id: str) -> User: 6 | """ 7 | Create a test admin user with slack id, and with all other attributes set. 8 | 9 | ========== ============================= 10 | Property Preset 11 | ========== ============================= 12 | Slack ID ``slack_id`` 13 | Bio I like puppies and kittens! 14 | Email admin@ubc.ca 15 | Name Iemann Atmin 16 | Github kibbles 17 | Github ID 123453 18 | Image URL https://via.placeholder.com/150 19 | Major Computer Science 20 | Permission Admin 21 | Position Adrenaline Junkie 22 | ========== ============================= 23 | 24 | :param slack_id: The slack id string 25 | :return: a filled-in user model (no empty strings) 26 | """ 27 | u = User(slack_id) 28 | u.biography = 'I like puppies and kittens!' 29 | u.email = 'admin@ubc.ca' 30 | u.name = 'Iemann Atmin' 31 | u.github_username = 'kibbles' 32 | u.github_id = '123453' 33 | u.image_url = 'https:///via.placeholder.com/150' 34 | u.major = 'Computer Science' 35 | u.permissions_level = Permissions.admin 36 | u.position = 'Adrenaline Junkie' 37 | u.karma = 1 38 | return u 39 | 40 | 41 | def create_test_team(tid: str, 42 | team_name: str, 43 | display_name: str) -> Team: 44 | """ 45 | Create a test team with team name, and with all other attributes the same. 46 | 47 | ========== ============================= 48 | Property Preset 49 | ========== ============================= 50 | Github ``tid`` 51 | Name slug ``team_name`` 52 | Display ``display_name`` 53 | Platform slack 54 | Members ['abc_123'] 55 | ========== ============================= 56 | 57 | :param tid: The github ID associated with the team 58 | :param team_name: The github team name slug 59 | :param display_name: The github team name 60 | :return: a filled-in team model (no empty strings) 61 | """ 62 | t = Team(tid, team_name, display_name) 63 | t.platform = 'slack' 64 | t.add_member('abc_123') 65 | return t 66 | -------------------------------------------------------------------------------- /tests/app/scheduler/modules/random_channel_test.py: -------------------------------------------------------------------------------- 1 | """Test how random channels would work.""" 2 | from unittest import mock, TestCase 3 | from app.scheduler.modules.random_channel import RandomChannelPromoter 4 | 5 | 6 | class TestRandomChannelExec(TestCase): 7 | """Test cases for execution of random channel selector.""" 8 | 9 | def setUp(self): 10 | """Set up necessary spec'd components for testing later.""" 11 | self.config = mock.Mock() 12 | self.config.slack_api_token = '' 13 | self.config.slack_notification_channel = '' 14 | self.config.slack_announcement_channel = '' 15 | self.app = mock.Mock() 16 | self.slackbot = mock.Mock() 17 | 18 | self.promoter = RandomChannelPromoter(self.app, self.config) 19 | self.promoter.bot = self.slackbot 20 | 21 | def test_choose_from_one(self): 22 | """Test choosing from 1 channel.""" 23 | self.slackbot.get_channels.return_value = [ 24 | {'id': '123', 'name': 'general', 'is_archived': False} 25 | ] 26 | 27 | self.promoter.do_it() 28 | 29 | self.slackbot.send_to_channel.assert_called() 30 | self.assertIn('general', self.slackbot.send_to_channel.call_args[0][0]) 31 | 32 | def test_choose_from_archived(self): 33 | """Test choosing from all archived channels.""" 34 | self.slackbot.get_channels.return_value = [ 35 | {'id': '123', 'name': 'general', 'is_archived': True}, 36 | {'id': '123', 'name': 'random', 'is_archived': True}, 37 | {'id': '123', 'name': 'choochoo', 'is_archived': True}, 38 | ] 39 | 40 | with self.assertRaises(IndexError): 41 | self.promoter.do_it() 42 | 43 | self.slackbot.send_to_channel.assert_not_called() 44 | 45 | def test_choose_non_archived(self): 46 | """Test choosing from all but one archived channels.""" 47 | self.slackbot.get_channels.return_value = [ 48 | {'id': '123', 'name': 'general', 'is_archived': True}, 49 | {'id': '123', 'name': 'random', 'is_archived': False}, 50 | {'id': '123', 'name': 'choochoo', 'is_archived': True}, 51 | ] 52 | 53 | self.promoter.do_it() 54 | 55 | self.slackbot.send_to_channel.assert_called() 56 | self.assertIn('random', self.slackbot.send_to_channel.call_args[0][0]) 57 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # See https://finnian.io/blog/ssl-with-docker-swarm-lets-encrypt-and-nginx/ 2 | # and http://docs.gunicorn.org/en/stable/deploy.html 3 | version: '3.2' 4 | services: 5 | nginx: 6 | image: nginx:stable-alpine 7 | volumes: 8 | - /etc/letsencrypt:/etc/letsencrypt 9 | - /usr/share/nginx/html:/usr/share/nginx/html 10 | - /etc/nginx:/etc/nginx:ro 11 | ports: 12 | - 80:80 13 | - 443:443 14 | restart: on-failure 15 | 16 | rocket2: 17 | build: 18 | context: . 19 | dockerfile: Dockerfile 20 | ports: 21 | - 5000:5000 22 | environment: 23 | - SLACK_NOTIFICATION_CHANNEL=${SLACK_NOTIFICATION_CHANNEL} 24 | - SLACK_ANNOUNCEMENT_CHANNEL=${SLACK_ANNOUNCEMENT_CHANNEL} 25 | - SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET} 26 | - SLACK_API_TOKEN=${SLACK_API_TOKEN} 27 | - GITHUB_APP_ID=${GITHUB_APP_ID} 28 | - GITHUB_ORG_NAME=${GITHUB_ORG_NAME} 29 | - GITHUB_WEBHOOK_ENDPT=${GITHUB_WEBHOOK_ENDPT} 30 | - GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET} 31 | - GITHUB_DEFAULT_TEAM_NAME=${GITHUB_DEFAULT_TEAM_NAME} 32 | - GITHUB_ADMIN_TEAM_NAME=${GITHUB_ADMIN_TEAM_NAME} 33 | - GITHUB_LEADS_TEAM_NAME=${GITHUB_LEADS_TEAM_NAME} 34 | - GITHUB_KEY=${GITHUB_KEY} 35 | - AWS_ACCESS_KEYID=${AWS_ACCESS_KEYID} 36 | - AWS_SECRET_KEY=${AWS_SECRET_KEY} 37 | - AWS_USERS_TABLE=${AWS_USERS_TABLE} 38 | - AWS_TEAMS_TABLE=${AWS_TEAMS_TABLE} 39 | - AWS_PROJECTS_TABLE=${AWS_PROJECTS_TABLE} 40 | - AWS_REGION=${AWS_REGION} 41 | - AWS_LOCAL=${AWS_LOCAL} 42 | - GCP_SERVICE_ACCOUNT_CREDENTIALS=${GCP_SERVICE_ACCOUNT_CREDENTIALS} 43 | - GCP_SERVICE_ACCOUNT_SUBJECT=${GCP_SERVICE_ACCOUNT_SUBJECT} 44 | restart: on-failure 45 | 46 | certbot: 47 | image: certbot/certbot 48 | restart: on-failure 49 | volumes: 50 | - /etc/letsencrypt:/etc/letsencrypt 51 | - /var/lib/letsencrypt:/var/lib/letsencrypt 52 | - /usr/share/nginx/html:/usr/share/nginx/html 53 | entrypoint: "/bin/sh -c 'trap exit TERM; sleep 12h; while :; do certbot renew --webroot -w /usr/share/nginx/html; sleep 12h & wait $${!}; done;'" 54 | -------------------------------------------------------------------------------- /docs/Testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | **Warning**: This is no longer the most up-to-date documentation on 5 | how testing is done here. You may want to head over 6 | `here `__ for more up-to-date 7 | documentation on how we test things. *You have been warned….* 8 | 9 | Running Pytest Efficiently 10 | -------------------------- 11 | 12 | Test Driven Development… we hear professors preach about it during 13 | lectures but we never got an opportunity to put it to good use until 14 | Rocket2 came along. Unfortunately we got over excited and wrote A LOT of 15 | tests. Running them all every time is a bit painful, that's where 16 | ``@pytest.mark`` comes in. ``pytest.mark`` allows you to label your 17 | tests to run them in groups. 18 | 19 | We only have tests that test the functions by themselves. Features that 20 | involve multiple parts (such as a new command involving Slack, Github, 21 | and the database) should be tested manually as well. 22 | 23 | Run all the tests 24 | ~~~~~~~~~~~~~~~~~ 25 | 26 | ``pytest`` 27 | 28 | Run only db tests 29 | ~~~~~~~~~~~~~~~~~ 30 | 31 | ``pytest -m db`` 32 | 33 | Run all tests except database tests 34 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 35 | 36 | ``pytest -m "not db"`` 37 | 38 | Testing the Database 39 | -------------------- 40 | 41 | What are environment variables? Variables for the environment of course! 42 | These variables set up the environment for testing. Rocket2 uses them 43 | because we have both a local and a sever DynamoDB database and each 44 | require an extra variable to get everything working. 45 | 46 | Run local DynamoDB 47 | ~~~~~~~~~~~~~~~~~~ 48 | 49 | We use the ``AWS_LOCAL`` environment variable to indicate if we want to 50 | run DynamoDB locally or on a server. Change ``AWS_LOCAL = 'True'`` to 51 | use local DynamoDB. 52 | 53 | If ``AWS_LOCAL == 'True'`` but you did not start an instance of local 54 | DynamoDB, ``scripts/build_check.sh`` will automatically skip all 55 | database tests. 56 | 57 | This is the recommended way for unit testing. 58 | 59 | Run server DynamoDB 60 | ~~~~~~~~~~~~~~~~~~~ 61 | 62 | To run the server DynamoDB we need to set the ``AWS_REGION`` and obtain 63 | ``AWS_ACCESS_KEYID``, ``AWS_SECRET_KEY``, and ``GITHUB_KEY``. 64 | 65 | This is the recommended way for testing everything (not unit testing, 66 | but testing the slack commands themselves). Click 67 | `here `__ to learn how to set up a full 68 | development environment (including the testing part). 69 | -------------------------------------------------------------------------------- /app/controller/command/commands/token.py: -------------------------------------------------------------------------------- 1 | """Command to obtain signed authentication token.""" 2 | import jwt 3 | import logging 4 | 5 | from app.controller import ResponseTuple 6 | from app.controller.command.commands.base import Command 7 | from datetime import datetime, timedelta 8 | from db.facade import DBFacade 9 | from app.model import User, Permissions 10 | from utils.slack_msg_fmt import wrap_code_block 11 | 12 | 13 | class TokenCommand(Command): 14 | """Token command model class.""" 15 | 16 | command_name = "token" 17 | desc = "Generate a signed token for use with the HTTP API" 18 | permission_error = "You do not have the sufficient " \ 19 | "permission level for this command!" 20 | lookup_error = "Requesting user not found!" 21 | success_msg = f"This is your token:\n{wrap_code_block('{}')}" \ 22 | "\nKeep it secret! Keep it safe!\nIt will expire at {}." 23 | 24 | def __init__(self, 25 | db_facade: DBFacade, 26 | config: 'TokenCommandConfig'): 27 | """ 28 | Initialize TokenCommand. 29 | 30 | :param db_facade: Database connection 31 | :param config: :class:`app.controller.command.commands 32 | .TokenCommandConfig` object 33 | """ 34 | super().__init__() 35 | logging.info("Initializing TokenCommand instance") 36 | self.facade = db_facade 37 | self.expiry = config.expiry 38 | self.signing_key = config.signing_key 39 | 40 | def handle(self, 41 | _command: str, 42 | user_id: str) -> ResponseTuple: 43 | """Handle request for token.""" 44 | logging.debug("Handling token command") 45 | try: 46 | user = self.facade.retrieve(User, user_id) 47 | if user.permissions_level == Permissions.member: 48 | return self.permission_error, 200 49 | except LookupError: 50 | return self.lookup_error, 200 51 | expiry = datetime.utcnow() + self.expiry 52 | payload = { 53 | 'nbf': datetime.utcnow(), 54 | 'exp': expiry, 55 | 'iss': 'ubclaunchpad:rocket2', 56 | 'iat': datetime.utcnow(), 57 | 'user_id': user_id, 58 | 'permissions': user.permissions_level.value 59 | } 60 | token = jwt.encode(payload, self.signing_key, algorithm='HS256') \ 61 | .decode('utf-8') 62 | return self.success_msg.format(token, expiry), 200 63 | 64 | 65 | class TokenCommandConfig: 66 | """Configuration options for TokenCommand.""" 67 | 68 | def __init__(self, 69 | expiry: timedelta, 70 | signing_key: str): 71 | """Initialize config for TokenCommand.""" 72 | self.expiry = expiry 73 | self.signing_key = signing_key 74 | -------------------------------------------------------------------------------- /interface/gcp_utils.py: -------------------------------------------------------------------------------- 1 | """Utilities for common interactions with Google API.""" 2 | import logging 3 | from typing import List, Optional 4 | from interface.gcp import GCPInterface, standardize_email 5 | from db import DBFacade 6 | from db.utils import get_team_members 7 | from app.model import User, Team 8 | 9 | 10 | def sync_user_email_perms(gcp: Optional[GCPInterface], 11 | db: DBFacade, 12 | user: User): 13 | """ 14 | Refresh Google Drive permissions for a provided user. If no GCP client is 15 | provided, this function is a no-op. 16 | 17 | Finds folders for user by checking all teams the user is a part of, and 18 | calling :func:`sync_team_email_perms`. 19 | 20 | :param gcp: the interface to do this from; can be `None` to function as 21 | no-op 22 | :param db: the database facade interface 23 | :param user: user model to refresh Google Drive permissions 24 | """ 25 | if gcp is None: 26 | logging.debug('GCP not enabled, skipping drive permissions') 27 | return 28 | 29 | if len(user.email) == 0 or len(user.github_id) == 0: 30 | return 31 | 32 | teams_user_is_in = db.query(Team, [('members', user.github_id)]) 33 | for team in teams_user_is_in: 34 | sync_team_email_perms(gcp, db, team) 35 | 36 | 37 | def sync_team_email_perms(gcp: Optional[GCPInterface], 38 | db: DBFacade, 39 | team: Team): 40 | """ 41 | Refresh Google Drive permissions for provided team. If no GCP client 42 | is provided, this function is a no-op. 43 | 44 | :param gcp: the interface to do this from; can be `None` to function as 45 | no-op 46 | :param db: the database facade interface 47 | :param team: refresh Google Drive permissions based on team model; if there 48 | is no folder for the team model, functions as no-op 49 | """ 50 | if gcp is None: 51 | logging.debug("GCP not enabled, skipping drive permissions") 52 | return 53 | 54 | if len(team.folder) == 0: 55 | return 56 | 57 | # Generate who to share with 58 | team_members = get_team_members(db, team) 59 | emails: List[str] = [] 60 | for user in team_members: 61 | if len(user.email) > 0: 62 | try: 63 | emails.append(standardize_email(user.email)) 64 | except Exception as e: 65 | logging.warning(f'Found malformed email {user.email} for user ' 66 | + f'{user.github_username}: {e}') 67 | 68 | # Sync permissions 69 | if len(emails) > 0: 70 | logging.info("Synchronizing permissions for " 71 | + f"{team.github_team_name}'s folder ({team.folder})") 72 | gcp.ensure_drive_permissions( 73 | team.github_team_name, team.folder, emails) 74 | -------------------------------------------------------------------------------- /docs/Database.rst: -------------------------------------------------------------------------------- 1 | Database Reference 2 | ================== 3 | 4 | ``users`` Table 5 | --------------- 6 | 7 | The ``users`` table stores all the users. With DynamoDB, we only need to 8 | specify a fixed attribute to be the primary index. In this case, the 9 | user's ``slack_id`` is the primary index. All other attributes are 10 | specified in the ``model/user.py`` file, and are also listed here: 11 | 12 | ==================== =============================================== 13 | Attribute Name Description 14 | ==================== =============================================== 15 | ``slack_id`` ``String``; The user's slack id 16 | ``email`` ``String``; The user's email address 17 | ``github`` ``String``; The user's Github handler 18 | ``github_user_id`` ``String``; The user's Github user ID 19 | ``major`` ``String``; The subject major the user is in 20 | ``position`` ``String``; The user's position in *Launch Pad* 21 | ``bio`` ``String``; A short (auto)biography 22 | ``image_url`` ``String``; The user's avatar image URL 23 | ``permission_level`` ``String``; The user's permission level 24 | ``karma`` ``Integer``; The user's karma points 25 | ==================== =============================================== 26 | 27 | The user's permission level is one of [``member``, ``admin``, 28 | ``team_lead``]. 29 | 30 | ``teams`` Table 31 | --------------- 32 | 33 | The ``teams`` table stores all teams where ``github_team_id`` is the 34 | primary index. All other attributes are specified in the 35 | ``model/team.py`` file, and are also listed here: 36 | 37 | +----------------------+----------------------------------------------+ 38 | | Attribute Name | Description | 39 | +======================+==============================================+ 40 | | ``github_team_id`` | ``String``; The team's Github ID | 41 | +----------------------+----------------------------------------------+ 42 | | ``github_team_name`` | ``String``; The team's Github name | 43 | +----------------------+----------------------------------------------+ 44 | | ``display_name`` | ``String``; The teams's display | 45 | +----------------------+----------------------------------------------+ 46 | | ``platform`` | ``String``; The team's working platform | 47 | +----------------------+----------------------------------------------+ 48 | | ``team_leads`` | ``String Set``; The team's set of team | 49 | | | leads' Github IDs | 50 | +----------------------+----------------------------------------------+ 51 | | ``members`` | ``String Set``; The team's set of members' | 52 | | | Github IDs | 53 | +----------------------+----------------------------------------------+ 54 | -------------------------------------------------------------------------------- /tests/config/config_test.py: -------------------------------------------------------------------------------- 1 | """Test the loading of config.""" 2 | from unittest import TestCase 3 | from config import Config, MissingConfigError 4 | import os 5 | 6 | 7 | class TestConfig(TestCase): 8 | """Test error handling of configuration initialization.""" 9 | 10 | def setUp(self): 11 | """Set up environments and variables.""" 12 | self.complete_config = { 13 | 'SLACK_SIGNING_SECRET': 'something secret', 14 | 'SLACK_API_TOKEN': 'some token idk', 15 | 'SLACK_NOTIFICATION_CHANNEL': '#rocket2', 16 | 'SLACK_ANNOUNCEMENT_CHANNEL': '#ot-random', 17 | 18 | 'GITHUB_APP_ID': '2024', 19 | 'GITHUB_ORG_NAME': 'ubclaunchpad', 20 | 'GITHUB_WEBHOOK_ENDPT': '/webhook', 21 | 'GITHUB_WEBHOOK_SECRET': 'oiarstierstiemoiarno', 22 | 'GITHUB_DEFAULT_TEAM_NAME': '', 23 | 'GITHUB_KEY': 'BEGIN END', 24 | 25 | 'AWS_ACCESS_KEYID': '324098102', 26 | 'AWS_SECRET_KEY': 'more secret', 27 | 'AWS_USERS_TABLE': 'users', 28 | 'AWS_TEAMS_TABLE': 'teams', 29 | 'AWS_REGION': 'us-west-2', 30 | 'AWS_LOCAL': 'True', 31 | 32 | 'GCP_SERVICE_ACCOUNT_CREDENTIALS': '{"hello":"world"}', 33 | } 34 | self.incomplete_config = { 35 | 'GITHUB_APP_ID': '2024', 36 | 'GITHUB_ORG_NAME': '', 37 | 'GITHUB_WEBHOOK_ENDPT': '/webhook', 38 | 'GITHUB_WEBHOOK_SECRET': 'oiarstierstiemoiarno', 39 | 'GITHUB_KEY': 'BEGIN END', 40 | 41 | 'AWS_ACCESS_KEYID': '324098102', 42 | 'AWS_SECRET_KEY': 'more secret', 43 | 'AWS_USERS_TABLE': 'users', 44 | 'AWS_TEAMS_TABLE': 'teams', 45 | 'AWS_REGION': 'us-west-2', 46 | } 47 | 48 | def test_complete_config(self): 49 | """Test a few things from the completed config object.""" 50 | os.environ = self.complete_config 51 | conf = Config() 52 | self.assertTrue(conf.aws_local) 53 | self.assertEqual(conf.gcp_service_account_credentials, 54 | '{"hello":"world"}') 55 | 56 | def test_incomplete_config(self): 57 | """Test a few things from an incompleted config object.""" 58 | with self.assertRaises(MissingConfigError) as e: 59 | os.environ = self.incomplete_config 60 | Config() 61 | 62 | missing_fields = ['SLACK_NOTIFICATION_CHANNEL', 'SLACK_SIGNING_SECRET', 63 | 'SLACK_API_TOKEN', 'SLACK_ANNOUNCEMENT_CHANNEL', 64 | 'GITHUB_ORG_NAME'] 65 | optional_fields = ['AWS_LOCAL'] 66 | e = e.exception 67 | for field in missing_fields: 68 | self.assertIn(field, e.error) 69 | for field in optional_fields: 70 | self.assertNotIn(field, e.error) 71 | -------------------------------------------------------------------------------- /docs/UserCommands.rst: -------------------------------------------------------------------------------- 1 | User Command Reference 2 | ====================== 3 | 4 | Commands that manipulate user data. Remember that parameters with 5 | whitespace must be enclosed in quotation marks. 6 | 7 | Options 8 | ------- 9 | 10 | .. code:: sh 11 | 12 | /rocket user {add, edit, view, help, delete} 13 | 14 | Add 15 | ~~~ 16 | 17 | .. code:: sh 18 | 19 | /rocket user add [-f|--force] 20 | 21 | Add the current user into the database. This command by default does not 22 | overwrite users that have already been entered into the database. By 23 | using the ``-f`` flag, you force Rocket to overwrite the entry in 24 | the database, if any. 25 | 26 | Edit 27 | ~~~~ 28 | 29 | .. code:: sh 30 | 31 | /rocket user edit [--name NAME] [--email EMAIL] [--pos POSITION] 32 | [--github GITHUB_HANDLE] [--major MAJOR] 33 | [--bio BIOGRAPHY] 34 | [--permission {member,team_lead,admin}] 35 | 36 | Allows user to edit their Launch Pad profile. Admins and team leads can 37 | edit another user's Launch Pad profile by using ``[--username SLACK_ID]`` 38 | option. ``SLACK_ID`` is the ``@``-name, for easy slack autocomplete. 39 | 40 | If a user edits their Github handle, Rocket will also add the handle to 41 | Launch Pad's Github organization. 42 | 43 | .. code:: sh 44 | 45 | # Normal use 46 | /rocket user edit --name "Steven Universe" --email "su@gmail.com" 47 | 48 | # Admin/Team lead use 49 | /rocket user edit --username @s_universe --name "Steven Universe" 50 | 51 | Admins can easily promote other admins or team leads. 52 | 53 | .. code:: sh 54 | 55 | /rocket user edit --username @s_universe --permission admin 56 | /rocket user edit --username @s_universe --permission team_lead 57 | # Demotion 58 | /rocket user edit --username @s_universe --permission member 59 | 60 | View 61 | ~~~~ 62 | 63 | .. code:: sh 64 | 65 | /rocket user view [--username SLACKID] [--github GITHUB] 66 | [--email EMAIL] [--inspect] 67 | 68 | Display information about a user. ``SLACKID`` is the ``@``-name, for 69 | easy slack autocomplete. If ``SLACKID`` is not specified, this command 70 | displays information about the one who ran the command instead. You can also 71 | specify a user's Github username or a user's email. 72 | 73 | If the `--inspect` flag is used, this command lists the teams that the user 74 | is a part of, along with the teams that this user is leading, if any. 75 | 76 | .. code:: sh 77 | 78 | # Lookup via Github username, listing teams user is a part of 79 | /rocket user view --github octoverse --inspect 80 | 81 | Help 82 | ~~~~ 83 | 84 | .. code:: sh 85 | 86 | /rocket user help 87 | 88 | Display options for the user commands. 89 | 90 | Delete (Admin only) 91 | ~~~~~~~~~~~~~~~~~~~ 92 | 93 | .. code:: sh 94 | 95 | /rocket user delete SLACK_ID 96 | 97 | Permanently delete a member's Launch Pad profile. Can only be used by 98 | admins. ``SLACK_ID`` is the ``@``-name, for easy slack autocomplete. 99 | -------------------------------------------------------------------------------- /app/controller/webhook/github/core.py: -------------------------------------------------------------------------------- 1 | """Handle GitHub webhooks.""" 2 | import logging 3 | import hmac 4 | import hashlib 5 | from db.facade import DBFacade 6 | from interface.github import GithubInterface 7 | from typing import Dict, Any 8 | from app.controller import ResponseTuple 9 | from config import Config 10 | from app.controller.webhook.github.events import MembershipEventHandler, \ 11 | OrganizationEventHandler, TeamEventHandler 12 | 13 | 14 | class GitHubWebhookHandler: 15 | """Encapsulate the handlers for all GitHub webhook events.""" 16 | 17 | def __init__(self, 18 | db_facade: DBFacade, 19 | gh_face: GithubInterface, 20 | config: Config): 21 | """Give handlers access to the database.""" 22 | self.__secret = config.github_webhook_secret 23 | self.__event_handlers = [ 24 | OrganizationEventHandler(db_facade, gh_face, config), 25 | TeamEventHandler(db_facade, gh_face, config), 26 | MembershipEventHandler(db_facade, gh_face, config) 27 | ] 28 | 29 | def handle(self, 30 | request_body: bytes, 31 | xhub_signature: str, 32 | payload: Dict[str, Any]) -> ResponseTuple: 33 | """ 34 | Verify and handle the webhook event. 35 | 36 | :param request_body: Byte string of the request body 37 | :param xhub_signature: Hashed signature to validate 38 | :return: appropriate ResponseTuple depending on the validity and type 39 | of webhook 40 | """ 41 | logging.debug(f"payload: {str(payload)}") 42 | if self.verify_hash(request_body, xhub_signature): 43 | action = payload["action"] 44 | for event_handler in self.__event_handlers: 45 | if action in event_handler.supported_action_list: 46 | return event_handler.handle(payload) 47 | return "Unsupported payload received, ignoring.", 202 48 | else: 49 | return "Hashed signature is not valid", 400 50 | 51 | def verify_hash(self, request_body: bytes, xhub_signature: str): 52 | """ 53 | Verify if a webhook event comes from GitHub. 54 | 55 | :param request_body: Byte string of the request body 56 | :param xhub_signature: Hashed signature to validate 57 | :return: True if the signature is valid, False otherwise 58 | """ 59 | h = hmac.new(bytes(self.__secret, encoding='utf8'), 60 | request_body, hashlib.sha1) 61 | verified = hmac.compare_digest( 62 | bytes("sha1=" + h.hexdigest(), encoding='utf8'), 63 | bytes(xhub_signature, encoding='utf8')) 64 | if verified: 65 | logging.debug("Webhook signature verified") 66 | else: 67 | logging.warning( 68 | f"Webhook not from GitHub; signature: {xhub_signature}") 69 | return verified 70 | -------------------------------------------------------------------------------- /tests/app/controller/command/commands/karma_test.py: -------------------------------------------------------------------------------- 1 | from app.controller.command.commands.karma import KarmaCommand 2 | from tests.memorydb import MemoryDB 3 | from tests.util import create_test_admin 4 | from flask import Flask 5 | from app.model import User 6 | from unittest import TestCase 7 | 8 | 9 | class KarmaCommandTest(TestCase): 10 | def setUp(self): 11 | self.app = Flask(__name__) 12 | 13 | self.u0 = User('U0G9QF9C6') 14 | self.u0.karma = KarmaCommand.karma_default_amount 15 | self.u1 = User('UFJ42EU67') 16 | self.u0.karma = KarmaCommand.karma_default_amount 17 | self.admin = create_test_admin('Uadmin') 18 | self.db = MemoryDB(users=[self.u0, self.u1, self.admin]) 19 | 20 | self.testcommand = KarmaCommand(self.db) 21 | self.maxDiff = None 22 | 23 | def test_handle_bad_args(self): 24 | self.assertEqual(self.testcommand.handle('karma ggwp', 25 | self.u0.slack_id), 26 | (self.testcommand.get_help(), 200)) 27 | 28 | def test_handle_view(self): 29 | self.u1.karma = 15 30 | cmd = f'karma view {self.u1.slack_id}' 31 | resp, _ = self.testcommand.handle(cmd, self.u0.slack_id) 32 | self.assertIn(str(self.u1.karma), resp) 33 | 34 | def test_handle_view_lookup_error(self): 35 | cmd = 'karma view ABCDE8FA9' 36 | self.assertTupleEqual(self.testcommand.handle(cmd, self.u0.slack_id), 37 | (KarmaCommand.lookup_error, 200)) 38 | 39 | def test_handle_reset_all_as_admin(self): 40 | self.u0.karma = 2019 41 | self.u1.karma = 2048 42 | with self.app.app_context(): 43 | resp, _ = self.testcommand.handle( 44 | 'karma reset --all', self.admin.slack_id) 45 | 46 | self.assertEqual(self.u0.karma, KarmaCommand.karma_default_amount) 47 | self.assertEqual(self.u1.karma, KarmaCommand.karma_default_amount) 48 | 49 | def test_handle_reset_all_not_as_admin(self): 50 | self.u1.karma = 20 51 | with self.app.app_context(): 52 | resp, _ = self.testcommand.handle( 53 | 'karma reset --all', self.u0.slack_id) 54 | self.assertEqual(KarmaCommand.permission_error, resp) 55 | 56 | self.assertNotEqual(self.u1.karma, KarmaCommand.karma_default_amount) 57 | 58 | def test_handle_set_as_admin(self): 59 | cmd = f'karma set {self.u0.slack_id} 10' 60 | self.assertNotEqual(self.u0.karma, 10) 61 | self.testcommand.handle(cmd, self.admin.slack_id) 62 | self.assertEqual(self.u0.karma, 10) 63 | 64 | def test_handle_set_as_non_admin(self): 65 | cmd = f'karma set {self.u1.slack_id} 10' 66 | self.assertEqual(self.testcommand.handle(cmd, self.u0.slack_id), 67 | (KarmaCommand.permission_error, 200)) 68 | 69 | def test_handle_set_lookup_error(self): 70 | cmd = 'karma set rando.id 10' 71 | self.assertEqual(self.testcommand.handle(cmd, self.admin.slack_id), 72 | (KarmaCommand.lookup_error, 200)) 73 | -------------------------------------------------------------------------------- /tests/app/model/team_test.py: -------------------------------------------------------------------------------- 1 | from app.model import Team 2 | from unittest import TestCase 3 | 4 | 5 | class TestTeamModel(TestCase): 6 | def setUp(self): 7 | self.brussel_sprouts = Team('1', 'brussel-sprouts', 'Brussel Sprouts') 8 | self.brussel_sprouts_copy =\ 9 | Team('1', 'brussel-sprouts', 'Brussel Sprouts') 10 | self.brussel_trouts = Team('1', 'brussel-trouts', 'Brussel Trouts') 11 | 12 | def test_team_equality(self): 13 | """Test the Team class method __eq__() and __ne__().""" 14 | self.assertEqual(self.brussel_sprouts, self.brussel_sprouts_copy) 15 | self.assertNotEqual(self.brussel_sprouts, self.brussel_trouts) 16 | 17 | def test_valid_team(self): 18 | """Test the Team static class method is_valid().""" 19 | self.assertTrue(Team.is_valid(self.brussel_sprouts)) 20 | self.brussel_sprouts.github_team_name = '' 21 | self.assertFalse(Team.is_valid(self.brussel_sprouts)) 22 | 23 | def test_add_member(self): 24 | """Test the Team class method add_member(github_id).""" 25 | new_github_id = "U0G9QF9C6" 26 | self.brussel_sprouts.add_member(new_github_id) 27 | self.assertIn(new_github_id, self.brussel_sprouts.members) 28 | 29 | def test_discard_member(self): 30 | """Test the Team class method discard_member(github_id).""" 31 | new_github_id = "U0G9QF9C6" 32 | self.brussel_sprouts.add_member(new_github_id) 33 | self.brussel_sprouts.discard_member(new_github_id) 34 | self.assertSetEqual(self.brussel_sprouts.members, set()) 35 | 36 | def test_is_member(self): 37 | """Test the Team class method is_member(github_id).""" 38 | new_github_id = "U0G9QF9C6" 39 | self.assertFalse(self.brussel_sprouts.has_member(new_github_id)) 40 | self.brussel_sprouts.add_member(new_github_id) 41 | assert self.brussel_sprouts.has_member(new_github_id) 42 | 43 | def test_add_lead(self): 44 | """Test the Team class method add_team_lead(github_id).""" 45 | new_github_id = "U0G9QF9C6" 46 | self.brussel_sprouts.add_team_lead(new_github_id) 47 | self.assertIn(new_github_id, self.brussel_sprouts.team_leads) 48 | 49 | def test_is_lead(self): 50 | """Test the Team class method is_team_lead(github_id).""" 51 | new_github_id = "U0G9QF9C6" 52 | self.assertFalse(self.brussel_sprouts.has_team_lead(new_github_id)) 53 | self.brussel_sprouts.add_team_lead(new_github_id) 54 | self.assertTrue(self.brussel_sprouts.has_team_lead(new_github_id)) 55 | 56 | def test_print(self): 57 | """Test print team class.""" 58 | new_slack_id = "U0G9QF9C6" 59 | self.brussel_sprouts.add_member(new_slack_id) 60 | self.brussel_sprouts.add_team_lead(new_slack_id) 61 | self.brussel_sprouts.platform = "web" 62 | expected = "{'github_team_id': '1'," \ 63 | " 'github_team_name': 'brussel-sprouts'," \ 64 | " 'displayname': 'Brussel Sprouts'," \ 65 | " 'platform': 'web'," \ 66 | " 'team_leads': {'U0G9QF9C6'}," \ 67 | " 'members': {'U0G9QF9C6'}," \ 68 | " 'folder': ''}" 69 | self.assertEqual(str(self.brussel_sprouts), expected) 70 | -------------------------------------------------------------------------------- /utils/slack_parse.py: -------------------------------------------------------------------------------- 1 | """The following are a few functions to help in handling command.""" 2 | import re 3 | from app.model import Permissions, User, Team 4 | from typing import Optional 5 | 6 | 7 | def regularize_char(c: str) -> str: 8 | """ 9 | Convert any unicode quotation marks to ascii ones. 10 | 11 | Leaves all other characters alone. 12 | 13 | :param c: character to convert 14 | :return: ascii equivalent (only quotes are changed) 15 | """ 16 | if c == "‘" or c == "’": 17 | return "'" 18 | if c == '“' or c == '”': 19 | return '"' 20 | return c 21 | 22 | 23 | def escaped_id_to_id(s: str) -> str: 24 | """ 25 | Convert a string with escaped IDs to just the IDs. 26 | 27 | Before:: 28 | 29 | /rocket user edit --username <@U1143214|su> --name "Steven Universe" 30 | 31 | After:: 32 | 33 | /rocket user edit --username U1143214 --name "Steven Universe" 34 | 35 | :param s: string to convert 36 | :return: string where all instances of escaped ID is replaced with IDs 37 | """ 38 | return re.sub(r"<[#@](\w+)\|[^>]+>", 39 | r"\1", 40 | s) 41 | 42 | 43 | def ios_dash(s: str) -> str: 44 | """ 45 | Convert a string with a dash (—) to just double-hyphens (--). 46 | 47 | Before:: 48 | 49 | /rocket user edit —name "Steven Universe" 50 | 51 | After:: 52 | 53 | /rocket user edit --name "Steven Universe" 54 | 55 | :param s: string to convert 56 | :return: string where all dashes are replaced with double-hyphens 57 | """ 58 | return s.replace("—", "--") 59 | 60 | 61 | def check_permissions(user: User, team: Optional[Team]) -> bool: 62 | """ 63 | Check if given user is admin or team lead. 64 | 65 | If team is specified and user is not admin, check if user is team lead in 66 | team. If team is not specified, check if user is team lead. 67 | 68 | :param user: user who's permission needs to be checked 69 | :param team: team you want to check that has user as team lead 70 | :return: true if user is admin or a team lead, false otherwise 71 | """ 72 | if user.permissions_level == Permissions.admin: 73 | return True 74 | if team is None: 75 | return user.permissions_level == Permissions.team_lead 76 | else: 77 | return team.has_team_lead(user.github_id) 78 | 79 | 80 | def is_slack_id(slack_id: str) -> bool: 81 | """ 82 | Check if id given is a valid slack id. 83 | 84 | :param id: string of the object you want to check 85 | :return: true if object is a slack id, false otherwise 86 | """ 87 | return re.match("^[UW][A-Z0-9]{8}$", slack_id) is not None 88 | 89 | 90 | def escape_email(email: str) -> str: 91 | """ 92 | Convert a string with escaped emails to just the email. 93 | 94 | Before:: 95 | 96 | 97 | 98 | After:: 99 | 100 | email@a.com 101 | 102 | Does nothing if the email is not escaped. 103 | 104 | :param email: email to convert 105 | :return: unescaped email 106 | """ 107 | if email.startswith('<'): 108 | return email.split('|')[0][8:] 109 | else: 110 | return email 111 | -------------------------------------------------------------------------------- /tests/app/controller/command/parser_test.py: -------------------------------------------------------------------------------- 1 | from app.controller.command import CommandParser 2 | from unittest import mock, TestCase 3 | from app.model import User 4 | from interface.cloudwatch_metrics import CWMetrics 5 | 6 | 7 | class TestParser(TestCase): 8 | def setUp(self): 9 | self.conf = mock.Mock() 10 | self.dbf = mock.Mock() 11 | self.gh = mock.Mock() 12 | self.token_conf = mock.Mock() 13 | self.bot = mock.Mock() 14 | self.metrics = mock.Mock(spec=CWMetrics) 15 | self.parser = CommandParser(self.conf, self.dbf, self.bot, self.gh, 16 | self.token_conf, self.metrics) 17 | self.usercmd = mock.Mock() 18 | self.mentioncmd = mock.Mock() 19 | self.mentioncmd.get_help.return_value = ('', 200) 20 | self.parser.commands['mention'] = self.mentioncmd 21 | self.parser.commands['user'] = self.usercmd 22 | 23 | @mock.patch('logging.error') 24 | def test_handle_app_command(self, mock_logging_error): 25 | self.parser.handle_app_command('hello world', 'U061F7AUR', '') 26 | mock_logging_error.assert_called_with( 27 | 'app command triggered incorrectly') 28 | 29 | @mock.patch('logging.error') 30 | def test_handle_invalid_command(self, mock_logging_error): 31 | self.usercmd.handle.side_effect = KeyError 32 | user = 'U061F7AUR' 33 | self.parser.handle_app_command('fake command', user, '') 34 | mock_logging_error.assert_called_with( 35 | 'app command triggered incorrectly') 36 | 37 | @mock.patch('logging.error') 38 | def test_handle_user_command(self, mock_logging_error): 39 | self.usercmd.handle.return_value = ('', 200) 40 | self.parser.handle_app_command('user name', 'U061F7AUR', '') 41 | self.usercmd.handle.\ 42 | assert_called_once_with("user name", "U061F7AUR") 43 | mock_logging_error.assert_not_called() 44 | 45 | @mock.patch('logging.error') 46 | def test_handle_mention_command(self, mock_logging_error): 47 | user = User('U061F7AUR') 48 | self.dbf.retrieve.return_value = user 49 | self.mentioncmd.handle.return_value = ('', 200) 50 | self.parser.handle_app_command('U061F7AUR ++', 'UFJ42EU67', '') 51 | self.mentioncmd.handle.\ 52 | assert_called_once_with('U061F7AUR ++', 'UFJ42EU67') 53 | mock_logging_error.assert_not_called() 54 | 55 | @mock.patch('logging.error') 56 | def test_handle_help(self, mock_logging_error): 57 | self.parser.handle_app_command('help', 'UFJ42EU67', '') 58 | mock_logging_error.assert_not_called() 59 | 60 | def test_handle_single_cmd_iquit(self): 61 | self.parser.handle_app_command('i-quit', 'UFJ43EU67', '') 62 | self.metrics.submit_cmd_mstime.assert_called_once_with( 63 | 'i-quit', mock.ANY) 64 | 65 | def test_handle_single_cmd_iquit_with_dash(self): 66 | self.parser.handle_app_command('i-quit --help', 'UFJ43EU67', '') 67 | self.metrics.submit_cmd_mstime.assert_called_once_with( 68 | 'i-quit', mock.ANY) 69 | 70 | @mock.patch('requests.post') 71 | def test_handle_make_post_req(self, post): 72 | self.parser.handle_app_command('i-quit', 'UFJ43EU67', 73 | 'https://google.com') 74 | post.assert_called_once_with(url='https://google.com', json=mock.ANY) 75 | -------------------------------------------------------------------------------- /factory/__init__.py: -------------------------------------------------------------------------------- 1 | """All necessary class initializations.""" 2 | import random 3 | import string 4 | import json 5 | import logging 6 | 7 | from app.controller.command import CommandParser 8 | from app.controller.command.commands.token import TokenCommandConfig 9 | from datetime import timedelta 10 | from db import DBFacade 11 | from db.dynamodb import DynamoDB 12 | from interface.github import GithubInterface, DefaultGithubFactory 13 | from interface.slack import Bot 14 | from interface.gcp import GCPInterface 15 | from interface.cloudwatch_metrics import CWMetrics 16 | from slack import WebClient 17 | from app.controller.webhook.github import GitHubWebhookHandler 18 | from app.controller.webhook.slack import SlackEventsHandler 19 | from config import Config 20 | from google.oauth2 import service_account as gcp_service_account 21 | from googleapiclient.discovery import build as gcp_build 22 | from typing import Optional 23 | 24 | 25 | def make_dbfacade(config: Config) -> DBFacade: 26 | return DynamoDB(config) 27 | 28 | 29 | def make_github_interface(config: Config) -> GithubInterface: 30 | return GithubInterface(DefaultGithubFactory(config.github_app_id, 31 | config.github_key), 32 | config.github_org_name) 33 | 34 | 35 | def make_command_parser(config: Config, gh: GithubInterface) \ 36 | -> CommandParser: 37 | # Initialize database 38 | facade = make_dbfacade(config) 39 | # Create Slack bot 40 | bot = Bot(WebClient(config.slack_api_token), 41 | config.slack_notification_channel) 42 | # TODO: make token config expiry configurable 43 | token_config = TokenCommandConfig(timedelta(days=7), config.github_key) 44 | # Metrics 45 | metrics = CWMetrics(config) 46 | # Create GCP client (optional) 47 | gcp_client = make_gcp_client(config) 48 | return CommandParser(config, facade, bot, gh, token_config, metrics, 49 | gcp=gcp_client) 50 | 51 | 52 | def make_github_webhook_handler(gh: GithubInterface, 53 | config: Config) -> GitHubWebhookHandler: 54 | facade = make_dbfacade(config) 55 | return GitHubWebhookHandler(facade, gh, config) 56 | 57 | 58 | def make_slack_events_handler(config: Config) -> SlackEventsHandler: 59 | facade = make_dbfacade(config) 60 | bot = Bot(WebClient(config.slack_api_token), 61 | config.slack_notification_channel) 62 | return SlackEventsHandler(facade, bot) 63 | 64 | 65 | def make_gcp_client(config: Config) -> Optional[GCPInterface]: 66 | if len(config.gcp_service_account_credentials) == 0: 67 | logging.info("Google Cloud client not provided, disabling") 68 | return None 69 | 70 | scopes = ['https://www.googleapis.com/auth/drive'] 71 | 72 | try: 73 | raw_credentials = json.loads(config.gcp_service_account_credentials) 74 | credentials = gcp_service_account.Credentials\ 75 | .from_service_account_info(raw_credentials, scopes=scopes) 76 | if len(config.gcp_service_account_subject) > 0: 77 | credentials = credentials.with_subject( 78 | config.gcp_service_account_subject) 79 | except Exception as e: 80 | logging.error(f"Unable to load GCP credentials, disabling: {e}") 81 | return None 82 | 83 | # Build appropriate service clients. 84 | # See https://github.com/googleapis/google-api-python-client/blob/master/docs/dyn/index.md # noqa 85 | drive = gcp_build('drive', 'v3', credentials=credentials) 86 | return GCPInterface(drive, subject=config.gcp_service_account_subject) 87 | 88 | 89 | def create_signing_token() -> str: 90 | """Create a new, random signing token.""" 91 | return ''.join(random.choice(string.ascii_lowercase) for _ in range(24)) 92 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. 2 | WARNING - when making changes to this file, make sure that it works as a 3 | GitHub README as well! Some things that are known to not work: 4 | * variety of RST features (image widths, centering, etc.) - use raw HTML 5 | * relative links - use full links 6 | 7 | .. raw:: html 8 | 9 |

10 | 11 |

12 | 13 |

Rocket 2

14 | 15 |

16 | Rocket 2 is the official UBC Launch Pad 17 | Slack bot and team management platform. 18 |

19 | 20 |

21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |

34 | 35 | | 36 | 37 | Rocket 2 is a from-the-ground-up rewrite of the `original Rocket`_, 38 | and it is a Slack bot that aims to be a ChatOps-style tool for team management 39 | across platforms like GitHub and Google Drive, with extensive configuration 40 | options so that it can be used by other organizations as well. Rocket 2 is used, 41 | built, and maintained with ❤️ by `UBC Launch Pad`_, UBC's student-run software 42 | engineering club. 43 | 44 | .. _UBC Launch Pad: https://ubclaunchpad.com 45 | .. _original Rocket: https://github.com/ubclaunchpad/rocket 46 | 47 | .. list-table:: 48 | :widths: 3 50 49 | :header-rows: 1 50 | 51 | * - 52 | - Main features 53 | * - 💬 54 | - **Unix-style command system in Slack** - invoke commands with a simple ``/rocket`` in Slack 55 | * - 🔗 56 | - **Platform integrations** - easily configure GitHub organization invites and teams, Google Drive permissions, and more 57 | * - 🗂 58 | - **Team directory** - provide and manage member information such as emails and other accounts 59 | * - 🔒 60 | - **Permissions system** - control access to Rocket functionality with a tiered set of permissions 61 | * - 🔨 62 | - **Hackable and extensible** - an open codebase makes it easy to add commands, scheduled modules, and more! 63 | 64 | | 65 | 66 | 📦 Usage 67 | -------- 68 | 69 | Check out our `command reference pages`_ to get started interacting with 70 | Rocket, or take a look at how Rocket is used at UBC Launch Pad in 71 | the `Launch Pad handbook`_. 72 | 73 | To set up a Rocket instance for your organization, refer to the `deployment`_ 74 | and `configuration`_ documentation. 75 | 76 | .. _deployment: https://rocket2.readthedocs.io/en/latest/docs/Deployment.html 77 | .. _configuration: https://rocket2.readthedocs.io/en/latest/docs/Config.html 78 | .. _command reference pages: https://rocket2.readthedocs.io/en/latest/docs/UserCommands.html 79 | .. _Launch Pad handbook: https://docs.ubclaunchpad.com/handbook/tools/slack#rocket 80 | 81 | | 82 | 83 | 📚 Contributing 84 | --------------- 85 | 86 | Any contribution (pull requests, feedback, bug reports, ideas, etc.) is welcome! 87 | 88 | Please refer to our `contribution guide`_ for contribution guidelines as well as 89 | detailed guides to help you get started with Rocket 2's codebase. 90 | 91 | .. _contribution guide: CONTRIBUTING.rst 92 | 93 | | 94 | -------------------------------------------------------------------------------- /tests/app/controller/command/commands/iquit_test.py: -------------------------------------------------------------------------------- 1 | from app.controller.command.commands import IQuitCommand 2 | from app.model import User, Team, Permissions 3 | from unittest import TestCase 4 | from tests.memorydb import MemoryDB 5 | 6 | 7 | def make_user(slack, gid, guser, perm): 8 | user = User(slack) 9 | user.github_id = gid 10 | user.github_username = guser 11 | user.permissions_level = perm 12 | return user 13 | 14 | 15 | def make_team(ghid, leads_ghid, members_ghid): 16 | team = Team(ghid, 'COVID19', 'Crime Stoppers') 17 | team.team_leads = team.team_leads.union(leads_ghid) 18 | team.members = team.members.union(members_ghid) 19 | return team 20 | 21 | 22 | class TestIQuitCommand(TestCase): 23 | def setUp(self): 24 | self.users = { 25 | 'u1': make_user('u1', 'g1', 'G1', Permissions.admin), 26 | 'u2': make_user('u2', 'g2', 'G2', Permissions.member), 27 | 'u3': make_user('u3', 'g3', 'G3', Permissions.team_lead), 28 | 'u4': make_user('u4', 'g4', 'G4', Permissions.team_lead), 29 | 'u5': make_user('u5', 'g5', 'G5', Permissions.member), 30 | 'u6': make_user('u6', 'g6', 'G6', Permissions.member) 31 | } 32 | self.teams = { 33 | 't1': make_team('t1', [], []), 34 | 't2': make_team('t2', ['g1', 'g3'], ['g1', 'g2', 'g3']), 35 | 't3': make_team('t3', ['g1'], ['g1', 'g4', 'g2', 'g5', 'g6']), 36 | 't4': make_team('t4', [], ['g6']), 37 | 't5': make_team('t5', ['g4'], ['g5', 'g3']), 38 | 't6': make_team('t6', ['g3', 'g4'], ['g3', 'g4']), 39 | 't7': make_team('t7', ['g3'], ['abacus', 'g3']) 40 | } 41 | self.facade = MemoryDB(users=self.users.values(), 42 | teams=self.teams.values()) 43 | self.cmd = IQuitCommand(self.facade) 44 | 45 | def test_get_no_duplicate_users(self): 46 | actual, resp = self.cmd.handle('', 'u2') 47 | self.assertEqual(actual.count('u1'), 1) 48 | self.assertEqual(actual.count('u3'), 1) 49 | 50 | def test_members_only_see_leads_n_admins(self): 51 | actual, resp = self.cmd.handle('', 'u6') 52 | self.assertEqual(actual.count('u1'), 1) 53 | self.assertNotEqual(actual.count('u2'), 1) 54 | self.assertNotEqual(actual.count('u3'), 1) 55 | self.assertNotEqual(actual.count('u6'), 1) 56 | 57 | def test_no_team_lead_so_return_nobody(self): 58 | actual, resp = self.cmd.handle('', 'u5') 59 | self.assertEqual(actual.count('u1'), 1) 60 | self.assertEqual(actual.count('u3'), 1) 61 | self.assertEqual(actual.count('u4'), 1) 62 | self.assertNotEqual(actual.count('u5'), 1) 63 | 64 | def test_cannot_find_caller(self): 65 | actual, resp = self.cmd.handle('', 'unknown user') 66 | self.assertEqual(actual, IQuitCommand.lookup_error) 67 | self.assertEqual(resp, 200) 68 | 69 | def test_call_as_team_lead(self): 70 | self.teams['t6'].github_team_name = 'pretty bad lol' 71 | actual, resp = self.cmd.handle('', 'u4') 72 | self.assertTrue('replacing you with <@u5>' in actual or 73 | 'replacing you with <@u3>' in actual) 74 | self.assertEqual(actual.count('u1'), 1) 75 | self.assertIn('cannot find your replacement; deleting team', actual) 76 | 77 | def test_call_as_team_lead_gh_only_members(self): 78 | self.teams['t7'].github_team_name = 'somewhat sketch' 79 | actual, resp = self.cmd.handle('', 'u3') 80 | self.assertIn( 81 | '*Team somewhat sketch*:' 82 | ' cannot find your replacement; deleting team', actual) 83 | 84 | def test_call_as_admin(self): 85 | actual, resp = self.cmd.handle('', 'u1') 86 | self.assertEqual(IQuitCommand.adminmsg, actual) 87 | -------------------------------------------------------------------------------- /tests/app/controller/webhook/slack/core_test.py: -------------------------------------------------------------------------------- 1 | from app.controller.webhook.slack import SlackEventsHandler 2 | from app.model import User 3 | from interface.slack import SlackAPIError 4 | from unittest import mock, TestCase 5 | from tests.memorydb import MemoryDB 6 | 7 | 8 | class TestSlackWebhookCore(TestCase): 9 | def setUp(self): 10 | self.u_id = 'U012A3CDE' 11 | self.db = MemoryDB() 12 | self.bot = mock.Mock() 13 | self.handler = SlackEventsHandler(self.db, self.bot) 14 | self.event = { 15 | 'token': 'XXYYZZ', 16 | 'team_id': 'TXXXXXXXX', 17 | 'api_app_id': 'AXXXXXXXXX', 18 | 'event': { 19 | 'type': 'team_join', 20 | 'user': { 21 | 'id': self.u_id, 22 | 'team_id': 'T012AB3C4', 23 | 'name': 'spengler', 24 | 'deleted': False, 25 | 'color': '9f69e7', 26 | 'real_name': 'Egon Spengler', 27 | 'tz': 'America/Los_Angeles', 28 | 'tz_label': 'Pacific Daylight Time', 29 | 'tz_offset': -25200, 30 | 'profile': { 31 | 'avatar_hash': 'ge3b51ca72de', 32 | 'status_text': 'Print is dead', 33 | 'status_emoji': ':books:', 34 | 'status_expiration': 1502138999, 35 | 'real_name': 'Egon Spengler', 36 | 'display_name': 'spengler', 37 | 'real_name_normalized': 'Egon Spengler', 38 | 'display_name_normalized': 'spengler', 39 | 'email': 'spengler@ghostbusters.example.com', 40 | 'image_24': 'https://.../avatar/hello.jpg', 41 | 'image_32': 'https://.../avatar/hello.jpg', 42 | 'image_48': 'https://.../avatar/hello.jpg', 43 | 'image_72': 'https://.../avatar/hello.jpg', 44 | 'image_192': 'https://.../avatar/hello.jpg', 45 | 'image_512': 'https://.../avatar/hello.jpg', 46 | 'team': 'T012AB3C4' 47 | }, 48 | 'is_admin': True, 49 | 'is_owner': False, 50 | 'is_primary_owner': False, 51 | 'is_restricted': False, 52 | 'is_ultra_restricted': False, 53 | 'is_bot': False, 54 | 'is_stranger': False, 55 | 'updated': 1502138686, 56 | 'is_app_user': False, 57 | 'has_2fa': False, 58 | 'locale': 'en-US' 59 | } 60 | }, 61 | 'type': 'app_mention', 62 | 'authed_users': ['UXXXXXXX1', 'UXXXXXXX2'], 63 | 'event_id': 'Ev08MFMKH6', 64 | 'event_time': 1234567890 65 | } 66 | 67 | def test_handle_team_join_success(self): 68 | self.handler.handle_team_join(self.event) 69 | self.bot.send_dm.assert_called_once_with(SlackEventsHandler.welcome, 70 | self.u_id) 71 | u = self.db.retrieve(User, self.u_id) 72 | self.assertEqual(u.slack_id, self.u_id) 73 | 74 | def test_handle_team_join_slack_error(self): 75 | self.bot.send_dm.side_effect = SlackAPIError(None) 76 | self.handler.handle_team_join(self.event) 77 | self.bot.send_dm.assert_called_once_with(SlackEventsHandler.welcome, 78 | self.u_id) 79 | u = self.db.retrieve(User, self.u_id) 80 | self.assertEqual(u.slack_id, self.u_id) 81 | -------------------------------------------------------------------------------- /docs/TeamCommands.rst: -------------------------------------------------------------------------------- 1 | Team Command Reference 2 | ====================== 3 | 4 | Commands that manipulate team data. Remember that parameters with 5 | whitespace must be enclosed by quotation marks. 6 | 7 | Options 8 | ------- 9 | 10 | .. code:: sh 11 | 12 | /rocket team {list, view, help, create, edit, add, remove, lead, delete} 13 | 14 | List 15 | ~~~~ 16 | 17 | .. code:: sh 18 | 19 | /rocket team list 20 | 21 | Display a list of Github team names and display names of all teams. 22 | 23 | View 24 | ~~~~ 25 | 26 | .. code:: sh 27 | 28 | /rocket team view GITHUB_TEAM_NAME 29 | 30 | Display information and members of a specific team. 31 | 32 | Help 33 | ~~~~ 34 | 35 | .. code:: sh 36 | 37 | /rocket team help 38 | 39 | Display options for team commands. 40 | 41 | Create (Team Lead and Admin only) 42 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 43 | 44 | .. code:: sh 45 | 46 | /rocket team create GITHUB_TEAM_NAME [--name DISPLAY_NAME] 47 | [--platform PLATFORM] 48 | [--channel CHANNEL] 49 | [--lead SLACK_ID] 50 | 51 | Create a new team with a Github team name and optional display name. The 52 | user who runs the command will be automatically added to team as Team 53 | Lead. If the ``--lead`` flag is used, user with ``SLACK_ID`` will be 54 | added as Team Lead instead. If the ``--channel`` flag is used, all 55 | members in specified channel will be added. 'SLACK_ID' is the 56 | ``@``-name, for easy slack autocomplete. 57 | 58 | We use Github API to create the team on Github. 59 | 60 | The Github team name cannot contain spaces. 61 | 62 | .. code:: sh 63 | 64 | /rocket team create "struddle-bouts" --name "Struddle Bouts" --channel @brussel_sprouts 65 | 66 | Edit (Team Lead\* and Admin only) 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | .. code:: sh 70 | 71 | /rocket team edit GITHUB_TEAM_NAME [--name DISPLAY_NAME] [--platform PLATFORM] 72 | 73 | Edit the properties of a specific team. Team Leads can only edit the 74 | teams that they are a part of, but admins can edit any teams. 75 | 76 | Add (Team Lead\* and Admin only) 77 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 78 | 79 | .. code:: sh 80 | 81 | /rocket team add GITHUB_TEAM_NAME SLACK_ID 82 | 83 | Add a user to the team. Team Leads can only add users into teams that 84 | they are a part of, but admins can add users to any team. ``SLACK_ID`` 85 | is the ``@``-name, for easy slack autocomplete. 86 | 87 | Users will be added to the teams on Github as well. 88 | 89 | .. code:: sh 90 | 91 | /rocket team add struddle-bouts @s_universe 92 | 93 | Remove (Team Lead\* and Admin only) 94 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 95 | 96 | .. code:: sh 97 | 98 | /rocket team remove GITHUB_TEAM_NAME SLACK_ID 99 | 100 | Remove a user from a team, removes them as Team Lead if they were one. 101 | Team Leads can only remove users from teams that they are a part of, but 102 | admins can remove users from any team. ``SLACK_ID`` is the ``@``-name, 103 | for easy slack autocomplete. 104 | 105 | Users will be removed from the teams on Github as well. 106 | 107 | Lead (Team Lead\* and Admin only) 108 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 109 | 110 | .. code:: sh 111 | 112 | /rocket team lead GITHUB_TEAM_NAME SLACK_ID [--remove] 113 | 114 | Adds a user as Team Lead, and adds them to team if not already added. If 115 | ``--remove`` flag is used, will remove user as Team Lead, but not from 116 | the team. Team Leads can only promote/demote users in teams that they 117 | are part of, but admins can promote/demote users in any team. 'SLACK_ID' 118 | is the ``@``-name, for easy slack autocomplete. 119 | 120 | Delete (Team Lead\* and Admin only) 121 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 122 | 123 | .. code:: sh 124 | 125 | /rocket team delete GITHUB_TEAM_NAME 126 | 127 | Permanently delete a team. Team Leads can only delete teams that they 128 | are a part of, but admins can delete any team. 129 | -------------------------------------------------------------------------------- /app/controller/webhook/github/events/membership.py: -------------------------------------------------------------------------------- 1 | """Handle GitHub membership events.""" 2 | import logging 3 | from app.model import User, Team 4 | from app.controller import ResponseTuple 5 | from typing import Dict, Any 6 | from app.controller.webhook.github.events.base import GitHubEventHandler 7 | 8 | 9 | class MembershipEventHandler(GitHubEventHandler): 10 | """Encapsulate the handler methods for GitHub membership events.""" 11 | 12 | supported_action_list = ['removed', 'added'] 13 | 14 | def handle(self, 15 | payload: Dict[str, Any]) -> ResponseTuple: 16 | """Handle the event where a user is added or removed from a team.""" 17 | action = payload["action"] 18 | github_user = payload["member"] 19 | github_username = github_user["login"] 20 | github_id = str(github_user["id"]) 21 | team = payload["team"] 22 | team_id = str(team["id"]) 23 | team_name = team["name"] 24 | logging.info("Github Membership webhook triggered with " 25 | f"{{action: {action}, user: {github_username}, " 26 | f"user_id: {github_id}, team: {team_name}, " 27 | f"team_id: {team_id}}}") 28 | selected_team = self._facade.retrieve(Team, team_id) 29 | if action == "removed": 30 | return self.mem_remove(github_id, selected_team, team_name) 31 | elif action == "added": 32 | return self.mem_added(github_id, selected_team, team_name, 33 | github_username) 34 | else: 35 | logging.error(f"invalid action specified: {str(payload)}") 36 | return "Unsupported action triggered, ignoring.", 202 37 | 38 | def mem_remove(self, 39 | github_id: str, 40 | selected_team: Team, 41 | team_name: str) -> ResponseTuple: 42 | """Help membership function if payload action is removal.""" 43 | member_list = self._facade. \ 44 | query(User, [('github_user_id', github_id)]) 45 | slack_ids_string = "" 46 | if len(member_list) == 1: 47 | slack_id = member_list[0].slack_id 48 | if selected_team.has_member(github_id): 49 | selected_team.discard_member(github_id) 50 | self._facade.store(selected_team) 51 | logging.info(f"deleted slack user {slack_id} " 52 | f"from {team_name}") 53 | slack_ids_string += f" {slack_id}" 54 | return (f"deleted slack ID{slack_ids_string} " 55 | f"from {team_name}", 200) 56 | else: 57 | logging.error(f"slack user {slack_id} not in {team_name}") 58 | return (f"slack user {slack_id} not in {team_name}", 200) 59 | elif len(member_list) > 1: 60 | logging.error("Error: found github ID connected to" 61 | " multiple slack IDs") 62 | return ("Error: found github ID connected to multiple" 63 | " slack IDs", 200) 64 | else: 65 | logging.error(f"could not find user {github_id}") 66 | return f"could not find user {github_id}", 200 67 | 68 | def mem_added(self, 69 | github_id: str, 70 | selected_team: Team, 71 | team_name: str, 72 | github_username: str) -> ResponseTuple: 73 | """Help membership function if payload action is added.""" 74 | member_list = self._facade.query(User, 75 | [('github_user_id', github_id)]) 76 | slack_ids_string = "" 77 | if len(member_list) > 0: 78 | selected_team.add_member(github_id) 79 | self._facade.store(selected_team) 80 | for member in member_list: 81 | slack_id = member.slack_id 82 | logging.info(f"user {github_username} added to {team_name}") 83 | slack_ids_string += f" {slack_id}" 84 | return f"added slack ID{slack_ids_string}", 200 85 | else: 86 | logging.error(f"could not find user {github_id}") 87 | return f"could not find user {github_username}", 200 88 | -------------------------------------------------------------------------------- /docs/doc_reqs.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.org/simple 2 | aiohttp==3.6.2; python_full_version >= '3.5.3' 3 | alabaster==0.7.12 4 | apscheduler==3.6.3 5 | astroid==2.4.2; python_version >= '3.5' 6 | async-timeout==3.0.1; python_full_version >= '3.5.3' 7 | attrs==20.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 8 | awscli==1.18.145 9 | babel==2.8.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 10 | backcall==0.2.0 11 | boto3==1.15.4 12 | botocore==1.18.4 13 | certifi==2020.6.20 14 | cffi==1.14.3 15 | chardet==3.0.4 16 | click==7.1.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 17 | codecov==2.1.7 18 | colorama==0.4.3 19 | coverage==5.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' 20 | cryptography==3.1.1 21 | decorator==4.4.2 22 | deprecated==1.2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 23 | docutils==0.15.2; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' 24 | filelock==3.0.12 25 | flake8==3.8.3 26 | flask-limiter==1.4 27 | flask-talisman==0.7.0 28 | flask==1.1.2 29 | gunicorn==20.0.4 30 | idna==2.10; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 31 | imagesize==1.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 32 | iniconfig==1.0.1 33 | ipython-genutils==0.2.0 34 | ipython==7.18.1 35 | isort==5.5.3; python_version >= '3.6' and python_version < '4.0' 36 | itsdangerous==1.1.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 37 | jedi==0.17.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 38 | jinja2==2.11.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 39 | jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' 40 | lazy-object-proxy==1.4.3; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 41 | limits==1.5.1 42 | m2r==0.2.1 43 | markupsafe==1.1.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 44 | mccabe==0.6.1 45 | mistune==0.8.4 46 | more-itertools==8.5.0; python_version >= '3.5' 47 | multidict==4.7.6; python_version >= '3.5' 48 | mypy-extensions==0.4.3 49 | mypy==0.782 50 | packaging==20.4; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 51 | parso==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 52 | pem==20.1.0 53 | pexpect==4.8.0; sys_platform != 'win32' 54 | pickleshare==0.7.5 55 | pluggy==0.13.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 56 | prompt-toolkit==3.0.7; python_full_version >= '3.6.1' 57 | ptyprocess==0.6.0 58 | py==1.9.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 59 | pyasn1==0.4.8 60 | pycodestyle==2.6.0 61 | pycparser==2.20; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 62 | pyee==7.0.4 63 | pyflakes==2.2.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 64 | pygithub==1.53 65 | pygments==2.7.1; python_version >= '3.5' 66 | pyjwt==1.7.1 67 | pylint==2.6.0 68 | pyparsing==2.4.7; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3' 69 | pytest-cov==2.10.1 70 | pytest-mypy==0.7.0 71 | pytest==6.0.2 72 | python-dateutil==2.8.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 73 | pytz==2020.1 74 | pyyaml==5.3.1 75 | requests==2.24.0 76 | rsa==4.5; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4' 77 | s3transfer==0.3.3 78 | six==1.15.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' 79 | slackclient==2.3.0 80 | slackeventsapi==2.2.1 81 | snowballstemmer==2.0.0 82 | sphinx-autodoc-typehints==1.11.0 83 | sphinx-rtd-theme==0.5.0 84 | sphinx==3.2.1 85 | sphinxcontrib-applehelp==1.0.2; python_version >= '3.5' 86 | sphinxcontrib-devhelp==1.0.2; python_version >= '3.5' 87 | sphinxcontrib-htmlhelp==1.0.3; python_version >= '3.5' 88 | sphinxcontrib-jsmath==1.0.1; python_version >= '3.5' 89 | sphinxcontrib-qthelp==1.0.3; python_version >= '3.5' 90 | sphinxcontrib-serializinghtml==1.1.4; python_version >= '3.5' 91 | structlog==20.1.0 92 | toml==0.10.1 93 | traitlets==5.0.4; python_version >= '3.7' 94 | typed-ast==1.4.1 95 | typing-extensions==3.7.4.3 96 | tzlocal==2.1 97 | urllib3==1.25.10; python_version != '3.4' 98 | watchtower==0.7.3 99 | wcwidth==0.2.5 100 | werkzeug==1.0.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' 101 | wrapt==1.12.1 102 | yarl==1.6.0; python_version >= '3.5' 103 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | """Contain the dictionaries of configurations for all needed services.""" 2 | import os 3 | 4 | 5 | class Config: 6 | """ 7 | Load important informations from environmental variables. 8 | 9 | We load the information (secret keys, access keys, paths to public/private 10 | keys, etc.) from the environment. Pipenv already loads from the environment 11 | and from the .env files. 12 | """ 13 | 14 | # Map name of env variable to python variable 15 | ENV_NAMES = { 16 | 'SLACK_SIGNING_SECRET': 'slack_signing_secret', 17 | 'SLACK_API_TOKEN': 'slack_api_token', 18 | 'SLACK_NOTIFICATION_CHANNEL': 'slack_notification_channel', 19 | 'SLACK_ANNOUNCEMENT_CHANNEL': 'slack_announcement_channel', 20 | 21 | 'GITHUB_APP_ID': 'github_app_id', 22 | 'GITHUB_ORG_NAME': 'github_org_name', 23 | 'GITHUB_DEFAULT_TEAM_NAME': 'github_team_all', 24 | 'GITHUB_ADMIN_TEAM_NAME': 'github_team_admin', 25 | 'GITHUB_LEADS_TEAM_NAME': 'github_team_leads', 26 | 'GITHUB_WEBHOOK_ENDPT': 'github_webhook_endpt', 27 | 'GITHUB_WEBHOOK_SECRET': 'github_webhook_secret', 28 | 'GITHUB_KEY': 'github_key', 29 | 30 | 'AWS_ACCESS_KEYID': 'aws_access_keyid', 31 | 'AWS_SECRET_KEY': 'aws_secret_key', 32 | 'AWS_USERS_TABLE': 'aws_users_tablename', 33 | 'AWS_TEAMS_TABLE': 'aws_teams_tablename', 34 | 'AWS_REGION': 'aws_region', 35 | 'AWS_LOCAL': 'aws_local', 36 | 37 | 'GCP_SERVICE_ACCOUNT_CREDENTIALS': 'gcp_service_account_credentials', 38 | 'GCP_SERVICE_ACCOUNT_SUBJECT': 'gcp_service_account_subject' 39 | } 40 | OPTIONALS = { 41 | 'AWS_LOCAL': 'False', 42 | 'GITHUB_DEFAULT_TEAM_NAME': 'all', 43 | 'GITHUB_ADMIN_TEAM_NAME': '', 44 | 'GITHUB_LEADS_TEAM_NAME': '', 45 | 'GCP_SERVICE_ACCOUNT_CREDENTIALS': '', 46 | 'GCP_SERVICE_ACCOUNT_SUBJECT': '', 47 | } 48 | 49 | def __init__(self): 50 | """ 51 | Load environmental variables into self. 52 | 53 | :raises: MissingConfigError exception if any of the env variables 54 | aren't found 55 | """ 56 | self._set_attrs() 57 | missing_config_fields = [] 58 | 59 | for var_name, var in self.ENV_NAMES.items(): 60 | try: 61 | data = os.environ[var_name] 62 | if len(data) == 0: 63 | if var_name in self.OPTIONALS: 64 | data = self.OPTIONALS[var_name] 65 | else: 66 | missing_config_fields.append(var_name) 67 | setattr(self, var, data) 68 | except KeyError: 69 | if var_name in self.OPTIONALS: 70 | data = self.OPTIONALS[var_name] 71 | setattr(self, var, data) 72 | else: 73 | missing_config_fields.append(var_name) 74 | 75 | if missing_config_fields: 76 | raise MissingConfigError(missing_config_fields) 77 | 78 | self.aws_local = self.aws_local == 'True' 79 | self.github_key = self.github_key\ 80 | .replace('\\n', '\n')\ 81 | .replace('\\-', '-') 82 | 83 | def _set_attrs(self): 84 | """Add attributes so that mypy doesn't complain.""" 85 | self.creds_path = '' 86 | 87 | self.slack_signing_secret = '' 88 | self.slack_api_token = '' 89 | self.slack_notification_channel = '' 90 | self.slack_announcement_channel = '' 91 | 92 | self.github_app_id = '' 93 | self.github_org_name = '' 94 | self.github_team_all = '' 95 | self.github_team_admin = '' 96 | self.github_team_leads = '' 97 | self.github_webhook_endpt = '' 98 | self.github_webhook_secret = '' 99 | self.github_key = '' 100 | 101 | self.aws_access_keyid = '' 102 | self.aws_secret_key = '' 103 | self.aws_users_tablename = '' 104 | self.aws_teams_tablename = '' 105 | self.aws_region = '' 106 | self.aws_local: bool = False 107 | 108 | self.gcp_service_account_credentials = '' 109 | self.gcp_service_account_subject = '' 110 | 111 | 112 | class MissingConfigError(Exception): 113 | """Exception representing an error while loading credentials.""" 114 | 115 | def __init__(self, missing_config_fields): 116 | """ 117 | Initialize a new MissingConfigError. 118 | 119 | :param missing_config_fields: the missing config variables 120 | """ 121 | self.error = 'Please set the following env variables:\n' + \ 122 | '\n'.join(missing_config_fields) 123 | -------------------------------------------------------------------------------- /interface/github_app.py: -------------------------------------------------------------------------------- 1 | """Interface to Github App API.""" 2 | import jwt 3 | import requests 4 | 5 | from datetime import datetime, timedelta 6 | from interface.exceptions.github import GithubAPIException 7 | import logging 8 | 9 | 10 | class GithubAppInterface: 11 | """Interface class for interacting with Github App API.""" 12 | 13 | def __init__(self, app_auth_factory): 14 | """ 15 | Initialize GithubAppInterface. 16 | 17 | :param app_auth_factory: Factory for creating auth objects 18 | """ 19 | self.app_auth_factory = app_auth_factory 20 | self.auth = app_auth_factory.create() 21 | 22 | def get_app_details(self): 23 | """ 24 | Retrieve app details from Github Apps API. 25 | 26 | See 27 | https://developer.github.com/v3/apps/#get-the-authenticated-github-app 28 | for details. 29 | 30 | :return: Decoded JSON object containing app details 31 | """ 32 | logging.info("Attempting to retrieve Github App details") 33 | url = "https://api.github.com/app" 34 | headers = self._gen_headers() 35 | r = requests.get(url=url, headers=headers) 36 | if r.status_code != 200: 37 | logging.error("Failed to get Github App details with message " 38 | f"{r.text} and error code {r.status_code}") 39 | raise GithubAPIException(r.text) 40 | logging.info("Successfully retrieved Github App details: " 41 | f"{r.json()}") 42 | return r.json() 43 | 44 | def create_api_token(self): 45 | """ 46 | Create installation token to make Github API requests. 47 | 48 | See 49 | https://developer.github.com/v3/apps/#find-installations and 50 | https://developer.github.com/v3/apps/#create-a-new-installation-token 51 | for details. 52 | 53 | :return: Authenticated API token 54 | """ 55 | logging.info("Attempting to get list of installations") 56 | url = "https://api.github.com/app/installations" 57 | headers = self._gen_headers() 58 | r = requests.get(url=url, headers=headers) 59 | if r.status_code != 200: 60 | logging.error("Failed to get list of Github App installations " 61 | f"with error message {r.text} " 62 | f"and code {r.status_code}") 63 | raise GithubAPIException(r.text) 64 | installation_id = r.json()[0]['id'] 65 | 66 | logging.info("Attempting to create new installation token") 67 | url = f"https://api.github.com/app/installations/" \ 68 | f"{installation_id}/access_tokens" 69 | r = requests.post(url=url, headers=headers) 70 | if r.status_code != 201: 71 | logging.error("Failed to create new installation token " 72 | f"with error message {r.text} " 73 | f"and code {r.status_code}") 74 | raise GithubAPIException(r.text) 75 | return r.json()['token'] 76 | 77 | def _gen_headers(self): 78 | if self.auth.is_expired(): 79 | logging.info("GithubAppAuth expired, creating new instance") 80 | self.auth = self.app_auth_factory.create() 81 | return { 82 | 'Authorization': f'Bearer {self.auth.token}', 83 | 'Accept': 'application/vnd.github.machine-man-preview+json' 84 | } 85 | 86 | class GithubAppAuth: 87 | """Class to encapsulate JWT encoding for Github App API.""" 88 | 89 | def __init__(self, app_id, private_key): 90 | """Initialize Github App authentication.""" 91 | self.expiry = (datetime.utcnow() + timedelta(minutes=1)) 92 | payload = { 93 | 'iat': datetime.utcnow(), 94 | 'exp': self.expiry, 95 | 'iss': app_id 96 | } 97 | self.token = jwt.encode(payload, 98 | private_key, 99 | algorithm='RS256') \ 100 | .decode('utf-8') 101 | 102 | def is_expired(self): 103 | """Check if Github App token is expired.""" 104 | return datetime.utcnow() >= self.expiry 105 | 106 | 107 | class DefaultGithubAppAuthFactory: 108 | """Factory for creating GithubAppAuth objects.""" 109 | 110 | def __init__(self, app_id, private_key): 111 | """ 112 | Initialize a Github App API auth factory. 113 | 114 | :param app_id: Github Apps ID 115 | :param private_key: Private key from application 116 | """ 117 | self.app_id = app_id 118 | self.private_key = private_key 119 | self.auth = GithubAppInterface.GithubAppAuth 120 | 121 | def create(self): 122 | """Create an instance of GithubAppAuth.""" 123 | return self.auth(self.app_id, self.private_key) 124 | -------------------------------------------------------------------------------- /docs/Scripts.rst: -------------------------------------------------------------------------------- 1 | Development Scripts 2 | =================== 3 | 4 | There are a few scripts in the ``scripts/`` directory that aid in the 5 | development of this project. 6 | 7 | build_check.sh 8 | -------------- 9 | 10 | .. code:: sh 11 | 12 | scripts/build_check.sh 13 | 14 | This is just the list of commands run to check the code for violations 15 | of Python style. It also runs the tests, and is the script that is run 16 | in our Github CI. Make sure to run before submitting a pull request! 17 | 18 | This script also checks to see if the user is running DynamoDB locally, 19 | and if so, would include tests for it; if not, the tests that use 20 | DynamoDB will be deselected. 21 | 22 | See `git hooks <#makefile-for-git-hooks>`__. 23 | 24 | port_busy.py 25 | ------------ 26 | 27 | .. code:: sh 28 | 29 | pipenv run python scripts/port_busy.py 8000 30 | 31 | This is to check if a port is busy on the machine you are running on. 32 | 33 | Used in place of ``nmap`` for automatically checking if the port used 34 | for local instances of DynamoDB is in use. 35 | 36 | - Exits with 0 if the port is in use. 37 | - Exits with 1 if there is an issue connecting with the port you 38 | provided. 39 | - Exits with 2 if the port you provided couldn't be converted to an 40 | integer. 41 | - Exits with 3 if you didn't provide exactly 1 argument. 42 | - Exits with 4 if the port is not already in use. 43 | 44 | update.sh 45 | --------- 46 | 47 | .. code:: sh 48 | 49 | scripts/update.sh 50 | 51 | This should be run whenever any change to ``Pipfile`` or 52 | ``Pipfile.lock`` occurs on your local copy of a branch. It updates any 53 | changed dependencies into your virtual environment. This is equivalent 54 | to the user running: 55 | 56 | .. code:: sh 57 | 58 | pipenv sync --dev 59 | 60 | Which, coincidentally, require the same number of characters to be 61 | typed. The script should ideally be run after any instance of 62 | ``git pull``. 63 | 64 | See `git hooks <#makefile-for-git-hooks>`__. 65 | 66 | download_dynamodb_and_run.sh 67 | ---------------------------- 68 | 69 | .. code:: sh 70 | 71 | scripts/download_dynamodb_and_run.sh 72 | 73 | This script downloads a copy of the latest local version of DynamoDB and 74 | forks the process. It also sets up the environment in which you should 75 | run it in using ``scripts/setup_localaws.sh``. 76 | 77 | Please do not use this script; it is meant to be run by Github CI. 78 | Unless you enjoy having to download and run multiple DynamoDB processes. 79 | 80 | setup_localaws.sh 81 | ----------------- 82 | 83 | .. code:: sh 84 | 85 | scripts/setup_localaws.sh 86 | 87 | This script automatically sets up your environment to better benefit a 88 | local instance of DynamoDB. Only should be run once by users (though 89 | running it multiple times would not hurt too too much). It requires 90 | ``aws`` to be installed through ``pipenv``. 91 | 92 | docker_build.sh 93 | --------------- 94 | 95 | .. code:: sh 96 | 97 | scripts/docker_build.sh 98 | 99 | This script builds a docker image ``rocket2-dev-img``, according to the 100 | ``Dockerfile``. Equivalent to: 101 | 102 | .. code:: sh 103 | 104 | docker build -t rocket2-dev-img . 105 | 106 | Make sure you have docker installed on your system beforehand. 107 | 108 | docker_run_local.sh 109 | ------------------- 110 | 111 | .. code:: sh 112 | 113 | scripts/docker_run_local.sh 114 | 115 | This script runs a local docker image on your system, port 5000. 116 | Equivalent to: 117 | 118 | .. code:: sh 119 | 120 | docker run --rm -it -p 0.0.0.0:5000:5000 rocket2-dev-img 121 | 122 | Make sure you have already built a ``rocket2-dev-img``, or have run 123 | ``scripts/docker_build.sh`` before-hand. ``docker`` must also be 124 | installed. 125 | 126 | Makefile for Git Hooks 127 | ---------------------- 128 | 129 | .. code:: sh 130 | 131 | cd scripts 132 | make 133 | 134 | This script simply installs the pre-commit hooks and post-merge hooks. 135 | ``build_check.sh`` is copied to ``.git/hooks/pre-commit``, and 136 | ``update.sh`` is copied to ``.git/hooks/post-merge``. 137 | 138 | After installation, every time you try to make a commit, all the tests 139 | will be run automatically to ensure compliance. Every time you perform a 140 | ``pull`` or ``merge`` or ``rebase``, ``pipenv`` will try to sync all 141 | packages and dependencies. 142 | 143 | Makefile for Documentation 144 | -------------------------- 145 | 146 | .. code:: sh 147 | 148 | make clean html 149 | 150 | This script builds all documentation and places the html into 151 | ``_build/`` directory. Should mostly be used to test your documentation 152 | locally. Should be run within a ``pipenv shell`` environment. 153 | 154 | We use Python ``sphinx`` to generate documentation from reStructuredText 155 | and Markdown files in this project. To configure (and change versions 156 | for the documentation), edit ``conf.py``. ``docs/index.rst`` is the 157 | index for all documentation. 158 | -------------------------------------------------------------------------------- /tests/utils/slack_parse_test.py: -------------------------------------------------------------------------------- 1 | """Some tests for utility functions in slack parsing utility.""" 2 | import utils.slack_parse as util 3 | from app.model import User, Team, Permissions 4 | from unittest import TestCase 5 | 6 | 7 | class TestSlackParseHelpers(TestCase): 8 | """Test the functions that help make the parsing work.""" 9 | 10 | def test_regularize_char_standard(self): 11 | """Test how this function reacts to normal operation.""" 12 | self.assertEqual(util.regularize_char('a'), 'a') 13 | self.assertEqual(util.regularize_char(' '), ' ') 14 | self.assertEqual(util.regularize_char('\''), '\'') 15 | self.assertEqual(util.regularize_char('‘'), '\'') 16 | self.assertEqual(util.regularize_char('’'), '\'') 17 | self.assertEqual(util.regularize_char('“'), '"') 18 | self.assertEqual(util.regularize_char('”'), '"') 19 | 20 | def test_escaped_id_conversion(self): 21 | """Test how this function reacts to normal operation.""" 22 | CMDS = [ 23 | # Normal operation 24 | ('/rocket user edit --username <@U1234|user> --name "User"', 25 | '/rocket user edit --username U1234 --name "User"'), 26 | # No users 27 | ('/rocket user view', 28 | '/rocket user view'), 29 | # Multiple users 30 | ('/rocket foo <@U1234|user> <@U4321|ruse> <@U3412|sure> -h', 31 | '/rocket foo U1234 U4321 U3412 -h') 32 | ] 33 | 34 | for inp, expect in CMDS: 35 | self.assertEqual(util.escaped_id_to_id(inp), expect) 36 | 37 | def test_ios_dash(self): 38 | """Test how this function reacts to normal operation.""" 39 | CMDS = [ 40 | # Normal operation with iOS 41 | ('/rocket user edit —username U1234 —name "User"', 42 | '/rocket user edit --username U1234 --name "User"'), 43 | ('/rocket user edit —name "Steven Universe"', 44 | '/rocket user edit --name "Steven Universe"'), 45 | ('/rocket user edit ——name "Steven Universe"', 46 | '/rocket user edit ----name "Steven Universe"'), 47 | ('/rocket foo U1234 U4321 U3412 -h', 48 | '/rocket foo U1234 U4321 U3412 -h'), 49 | # Normal operation without iOS 50 | ('/rocket user edit --username U1234 --name "User"', 51 | '/rocket user edit --username U1234 --name "User"'), 52 | ('/rocket user edit --name "Steven Universe"', 53 | '/rocket user edit --name "Steven Universe"'), 54 | ('/rocket foo U1234 U4321 U3412 -h', 55 | '/rocket foo U1234 U4321 U3412 -h') 56 | ] 57 | 58 | for inp, expect in CMDS: 59 | self.assertEqual(util.ios_dash(inp), expect) 60 | 61 | def test_check_credentials_admin(self): 62 | """Test checking to see if user is admin.""" 63 | user = User("USFAS689") 64 | user.permissions_level = Permissions.admin 65 | self.assertTrue(util.check_permissions(user, None)) 66 | 67 | def test_check_credentials_not_admin(self): 68 | """Test checking to see if user is not admin.""" 69 | user = User("USFAS689") 70 | user.permissions_level = Permissions.member 71 | self.assertFalse(util.check_permissions(user, None)) 72 | 73 | def test_check_credentials_lead(self): 74 | """Test checking to see if user is lead for certain team.""" 75 | user = User("USFAS689") 76 | user.github_id = "IDGithub" 77 | team = Team("brussels", "team", "id") 78 | team.add_member(user.github_id) 79 | team.add_team_lead(user.github_id) 80 | user.permissions_level = Permissions.team_lead 81 | self.assertTrue(util.check_permissions(user, team)) 82 | 83 | def test_check_credentials_not_lead(self): 84 | """Test checking to see if user is lead for certain team.""" 85 | user = User("USFAS689") 86 | user.github_id = "IDGithub" 87 | team = Team("brussels", "team", "id") 88 | team.add_member(user.github_id) 89 | user.permissions_level = Permissions.team_lead 90 | self.assertFalse(util.check_permissions(user, team)) 91 | 92 | def test_check_string_is_not_slack_id(self): 93 | """Test checking to see if a string is not a slack id.""" 94 | string_to_test = "ABCDEFG" 95 | self.assertFalse(util.is_slack_id(string_to_test)) 96 | 97 | def test_check_string_is_slack_id(self): 98 | """Test checking to see if string is a slack id.""" 99 | string_to_test = "UFJ42EU67" 100 | self.assertTrue(util.is_slack_id(string_to_test)) 101 | 102 | def test_escape_email(self): 103 | """Test parsing escaped emails.""" 104 | email = "" 105 | ret = util.escape_email(email) 106 | self.assertEqual(ret, "email@a.com") 107 | 108 | def test_escape_normal_email(self): 109 | email = 'robert@bobheadxi.dev' 110 | self.assertEqual(util.escape_email(email), email) 111 | -------------------------------------------------------------------------------- /tests/memorydb.py: -------------------------------------------------------------------------------- 1 | from db.facade import DBFacade 2 | from app.model import User, Team, Permissions 3 | from typing import TypeVar, List, Type, Tuple, cast, Set 4 | 5 | T = TypeVar('T', User, Team) 6 | 7 | 8 | # Convert DB field names to python attribute names 9 | USER_ATTRS = { 10 | 'slack_id': 'slack_id', 11 | 'permission_level': 'permissions_level', 12 | 'email': 'email', 13 | 'name': 'name', 14 | 'github': 'github_username', 15 | 'github_user_id': 'github_id', 16 | 'major': 'major', 17 | 'position': 'position', 18 | 'bio': 'biography', 19 | 'image_url': 'image_url', 20 | 'karma': 'karma' 21 | } 22 | 23 | 24 | TEAM_ATTRS = { 25 | 'github_team_id': 'github_team_id', 26 | 'github_team_name': 'github_team_name', 27 | 'displayname': 'displayname', 28 | 'platform': 'platform', 29 | 'members': 'members', 30 | 'team_leads': 'team_leads' 31 | } 32 | 33 | 34 | def field_is_set(Model: Type[T], field: str) -> bool: 35 | if Model is Team: 36 | return field in ['team_leads', 'members'] 37 | else: 38 | return False 39 | 40 | 41 | def field_to_attr(Model: Type[T], field: str) -> str: 42 | if Model is User: 43 | return USER_ATTRS[field] 44 | elif Model is Team: 45 | return TEAM_ATTRS[field] 46 | return field 47 | 48 | 49 | def filter_by_matching_field(ls: List[T], 50 | Model: Type[T], 51 | field: str, 52 | v: str) -> List[T]: 53 | r = [] 54 | is_set = field_is_set(Model, field) 55 | attr = field_to_attr(Model, field) 56 | 57 | # Special case for handling permission levels 58 | if attr == 'permissions_level': 59 | v = Permissions[v] # type: ignore 60 | 61 | for x in ls: 62 | if is_set and v in getattr(x, attr): 63 | r.append(x) 64 | elif not is_set and v == getattr(x, attr): 65 | r.append(x) 66 | return r 67 | 68 | 69 | def get_key(m: T) -> str: 70 | if isinstance(m, User): 71 | return cast(User, m).slack_id 72 | elif isinstance(m, Team): 73 | return cast(Team, m).github_team_id 74 | 75 | 76 | class MemoryDB(DBFacade): 77 | """ 78 | An in-memory database. 79 | 80 | To be used only in testing. **Do not attempt to use it in production.** 81 | Used when a test requires a database, but when we aren't specifically 82 | testing database functionalities. 83 | 84 | **Stored objects can be mutated by external references if you don't drop 85 | the reference after storing.** 86 | """ 87 | 88 | def __init__(self, 89 | users: List[User] = [], 90 | teams: List[Team] = []): 91 | """ 92 | Initialize with lists of objects. 93 | 94 | :param users: list of users to initialize the db 95 | :param teams: list of teams to initialize the db 96 | """ 97 | self.users = {u.slack_id: u for u in users} 98 | self.teams = {t.github_team_id: t for t in teams} 99 | 100 | def get_db(self, Model: Type[T]): 101 | if Model is User: 102 | return self.users 103 | elif Model is Team: 104 | return self.teams 105 | raise LookupError(f'Unknown model {Model}') 106 | 107 | def store(self, obj: T) -> bool: 108 | Model = obj.__class__ 109 | if Model.is_valid(obj): 110 | key = get_key(obj) 111 | self.get_db(Model)[key] = obj 112 | return True 113 | return False 114 | 115 | def retrieve(self, Model: Type[T], k: str) -> T: 116 | d = self.get_db(Model) 117 | if k in d: 118 | return cast(T, d[k]) 119 | else: 120 | raise LookupError(f'{Model.__name__}(id={k}) not found') 121 | 122 | def bulk_retrieve(self, 123 | Model: Type[T], 124 | ks: List[str]) -> List[T]: 125 | r = [] 126 | for k in ks: 127 | try: 128 | m = self.retrieve(Model, k) 129 | r.append(m) 130 | except LookupError: 131 | pass 132 | return r 133 | 134 | def query(self, 135 | Model: Type[T], 136 | params: List[Tuple[str, str]] = []) -> List[T]: 137 | d = list(self.get_db(Model).values()) 138 | for field, val in params: 139 | d = filter_by_matching_field(d, Model, field, val) 140 | return d 141 | 142 | def query_or(self, 143 | Model: Type[T], 144 | params: List[Tuple[str, str]] = []) -> List[T]: 145 | if len(params) == 0: 146 | return self.query(Model) 147 | 148 | d = list(self.get_db(Model).values()) 149 | r: Set[T] = set() 150 | for field, val in params: 151 | r = r.union(set(filter_by_matching_field(d, Model, field, val))) 152 | return list(r) 153 | 154 | def delete(self, Model: Type[T], k: str): 155 | d = self.get_db(Model) 156 | if k in d: 157 | d.pop(k) 158 | -------------------------------------------------------------------------------- /tests/interface/gcp_test.py: -------------------------------------------------------------------------------- 1 | """Test GCPInterface Class.""" 2 | from interface.gcp import GCPInterface, \ 3 | new_create_permission_body, new_share_message 4 | from googleapiclient.discovery import Resource 5 | from unittest import mock, TestCase 6 | 7 | 8 | class TestGCPInterface(TestCase): 9 | """Test Case for GCPInterface class.""" 10 | 11 | def setUp(self): 12 | self.mock_drive = mock.MagicMock(Resource) 13 | self.gcp = GCPInterface(self.mock_drive, 14 | subject="team@ubclaunchpad.com") 15 | 16 | def test_ensure_drive_permissions(self): 17 | # Mocks for files 18 | mock_files_get = mock.MagicMock() 19 | mock_files_get.execute = mock.MagicMock(return_value={ 20 | "parents": [ 21 | "parent-drive", 22 | ] 23 | }) 24 | 25 | mock_files = mock.MagicMock() 26 | mock_files.get = mock.MagicMock(return_value=mock_files_get) 27 | 28 | # Mocks for permissions 29 | mock_perms_list_parent = mock.MagicMock() 30 | mock_perms_list_parent.execute = mock.MagicMock(return_value={ 31 | "permissions": [ 32 | { 33 | # should not be removed (inherited) 34 | "id": "99", 35 | "emailAddress": "inherited-permission@ubclaunchpad.com", 36 | }, 37 | ] 38 | }) 39 | mock_perms_list_target = mock.MagicMock() 40 | mock_perms_list_target.execute = mock.MagicMock(return_value={ 41 | "permissions": [ 42 | { 43 | # should not be removed or created (exists in email list) 44 | "id": "1", 45 | "emailAddress": "not-team@ubclaunchpad.com", 46 | }, 47 | { 48 | # should be removed (does not exist in email list) 49 | "id": "2", 50 | # see gcp_utils.standardize_email 51 | "emailAddress": "strat.Egy@ubclaunchpad.com", 52 | }, 53 | { 54 | # should not be removed (actor) 55 | "id": "3", 56 | "emailAddress": "team@ubclaunchpad.com", 57 | }, 58 | { 59 | # should not be removed (inherited) 60 | "id": "99", 61 | "emailAddress": "inherited-permission@ubclaunchpad.com", 62 | }, 63 | ] 64 | }) 65 | mock_perms_create = mock.MagicMock() 66 | mock_perms_create.execute = mock.MagicMock(return_value={}) 67 | mock_perms_delete = mock.MagicMock() 68 | mock_perms_delete.execute = mock.MagicMock(return_value={}) 69 | 70 | def perms_list_effect(**kwargs): 71 | if kwargs['fileId'] == 'target-drive': 72 | return mock_perms_list_target 73 | if kwargs['fileId'] == 'parent-drive': 74 | return mock_perms_list_parent 75 | 76 | mock_perms = mock.MagicMock() 77 | mock_perms.list = mock.MagicMock(side_effect=perms_list_effect) 78 | mock_perms.list_next = mock.MagicMock(return_value=None) 79 | mock_perms.create = mock.MagicMock(return_value=mock_perms_create) 80 | mock_perms.delete = mock.MagicMock(return_value=mock_perms_delete) 81 | 82 | # Create Google Drive API 83 | self.mock_drive.files = mock.MagicMock(return_value=mock_files) 84 | self.mock_drive.permissions = mock.MagicMock(return_value=mock_perms) 85 | self.gcp.ensure_drive_permissions('team', 'target-drive', [ 86 | 'robert@bobheadxi.dev', 87 | 'not-team@ubclaunchpad.com', 88 | ]) 89 | 90 | # initial parent search 91 | mock_files.get.assert_called_with(fileId='target-drive', 92 | fields=mock.ANY) 93 | mock_files_get.execute.assert_called() 94 | # perms listing 95 | mock_perms.list.assert_has_calls([ 96 | mock.call(fileId='parent-drive', 97 | fields=mock.ANY), 98 | mock.call(fileId='target-drive', 99 | fields=mock.ANY), 100 | ]) 101 | mock_perms_list_parent.execute.assert_called() 102 | mock_perms_list_target.execute.assert_called() 103 | # one email already exists, share to the new one 104 | mock_perms.create\ 105 | .assert_called_with(fileId='target-drive', 106 | body=new_create_permission_body( 107 | 'robert@bobheadxi.dev'), 108 | emailMessage=new_share_message('team'), 109 | sendNotificationEmail=True) 110 | mock_perms_create.execute.assert_called() 111 | # one email should no longer be shared, it is removed 112 | mock_perms.delete.assert_called_with( 113 | fileId='target-drive', permissionId='2') 114 | mock_perms_delete.execute.assert_called() 115 | -------------------------------------------------------------------------------- /app/model/user.py: -------------------------------------------------------------------------------- 1 | """Data model to represent an individual user.""" 2 | from typing import Dict, Any, TypeVar, Type 3 | from app.model.permissions import Permissions 4 | from app.model.base import RocketModel 5 | 6 | T = TypeVar('T', bound='User') 7 | 8 | 9 | class User(RocketModel): 10 | """Represent a user with related fields and methods.""" 11 | 12 | def __init__(self, slack_id: str): 13 | """Initialize the user with a given Slack ID.""" 14 | self.slack_id = slack_id 15 | self.name = "" 16 | self.email = "" 17 | self.github_username = "" 18 | self.github_id = "" 19 | self.major = "" 20 | self.position = "" 21 | self.biography = "" 22 | self.image_url = "" 23 | self.permissions_level = Permissions.member 24 | self.karma = 1 25 | 26 | def get_attachment(self) -> Dict[str, Any]: 27 | """Return slack-formatted attachment (dictionary) for user.""" 28 | # TODO: Refactor into another file to preserve purity 29 | text_pairs = [ 30 | ('Slack ID', self.slack_id), 31 | ('Name', self.name), 32 | ('Email', self.email), 33 | ('Github Username', self.github_username), 34 | ('Github ID', self.github_id), 35 | ('Major', self.major), 36 | ('Position', self.position), 37 | ('Biography', self.biography), 38 | ('Image URL', self.image_url), 39 | ('Permissions Level', str(self.permissions_level)), 40 | ('Karma', self.karma) 41 | ] 42 | 43 | fields = [{'title': t, 'value': v if v else 'n/a', 'short': True} 44 | for t, v in text_pairs] 45 | fallback = str('\n'.join(map(str, text_pairs))) 46 | 47 | return {'fallback': fallback, 'fields': fields} 48 | 49 | @classmethod 50 | def to_dict(cls: Type[T], user: T) -> Dict[str, Any]: 51 | """ 52 | Convert user object to dict object. 53 | 54 | The difference with the in-built ``self.__dict__`` is that this is more 55 | compatible with storing into NoSQL databases like DynamoDB. 56 | 57 | :param user: the user object 58 | :return: the dictionary representing the user 59 | """ 60 | def place_if_filled(name: str, field: Any): 61 | """Populate ``udict`` if ``field`` isn't empty.""" 62 | if field: 63 | udict[name] = field 64 | 65 | udict = { 66 | 'slack_id': user.slack_id, 67 | 'permission_level': user.permissions_level.name 68 | } 69 | place_if_filled('email', user.email) 70 | place_if_filled('name', user.name) 71 | place_if_filled('github', user.github_username) 72 | place_if_filled('github_user_id', user.github_id) 73 | place_if_filled('major', user.major) 74 | place_if_filled('position', user.position) 75 | place_if_filled('bio', user.biography) 76 | place_if_filled('image_url', user.image_url) 77 | place_if_filled('karma', user.karma) 78 | 79 | return udict 80 | 81 | @classmethod 82 | def from_dict(cls: Type[T], d: Dict[str, Any]) -> T: 83 | """ 84 | Convert dict response object to user model. 85 | 86 | :param d: the dictionary representing a user 87 | :return: the converted user model. 88 | """ 89 | user = cls(d['slack_id']) 90 | user.email = d.get('email', '') 91 | user.name = d.get('name', '') 92 | user.github_username = d.get('github', '') 93 | user.github_id = d.get('github_user_id', '') 94 | user.major = d.get('major', '') 95 | user.position = d.get('position', '') 96 | user.biography = d.get('bio', '') 97 | user.image_url = d.get('image_url', '') 98 | user.permissions_level =\ 99 | Permissions[d.get('permission_level', 'member')] 100 | user.karma = int(d.get('karma', 1)) 101 | return user 102 | 103 | @classmethod 104 | def is_valid(cls: Type[T], user: T) -> bool: 105 | """ 106 | Return true if this user has no missing required fields. 107 | 108 | Required fields for database to accept: 109 | - ``slack_id`` 110 | - ``permissions_level`` 111 | 112 | :param user: user to check 113 | :return: true if this user has no missing required fields 114 | """ 115 | return len(user.slack_id) > 0 116 | 117 | def __eq__(self, other: object) -> bool: 118 | """Return true if this user has the same attributes as the other.""" 119 | return isinstance(other, User) and\ 120 | str(self.__dict__) == str(other.__dict__) 121 | 122 | def __ne__(self, other: object) -> bool: 123 | """Return the opposite of what is returned in self.__eq__(other).""" 124 | return not self == other 125 | 126 | def __str__(self) -> str: 127 | """Print information on the user class.""" 128 | return str(self.__dict__) 129 | 130 | def __hash__(self) -> int: 131 | """Hash the user class using a dictionary.""" 132 | return self.__str__().__hash__() 133 | -------------------------------------------------------------------------------- /app/controller/webhook/github/events/organization.py: -------------------------------------------------------------------------------- 1 | """Handle GitHub organization events.""" 2 | import logging 3 | from app.model import User, Team 4 | from app.controller import ResponseTuple 5 | from db.utils import get_team_by_name 6 | from typing import Dict, Any, List 7 | from app.controller.webhook.github.events.base import GitHubEventHandler 8 | 9 | 10 | class OrganizationEventHandler(GitHubEventHandler): 11 | """Encapsulate the handler methods for GitHub organization events.""" 12 | 13 | invite_text = 'user {} invited to {}' 14 | supported_action_list = ['member_removed', 'member_added', 15 | 'member_invited'] 16 | 17 | def handle(self, payload: Dict[str, Any]) -> ResponseTuple: 18 | """ 19 | Handle when a user is added, removed, or invited to an organization. 20 | 21 | If the member is removed, they are removed as a user from rocket's db 22 | if they have not been removed already. 23 | 24 | If the member is added or invited, do nothing. 25 | """ 26 | action = payload["action"] 27 | github_user = payload["membership"]["user"] 28 | github_id = str(github_user["id"]) 29 | github_username = github_user["login"] 30 | organization = payload["organization"]["login"] 31 | logging.info("Github Organization webhook triggered with" 32 | f"{{action: {action}, user: {github_username}, " 33 | f"user_id: {github_id}, organization: {organization}}}") 34 | member_list = self._facade. \ 35 | query(User, [('github_user_id', github_id)]) 36 | if action == "member_removed": 37 | return self.handle_remove(member_list, github_id, github_username) 38 | elif action == "member_added": 39 | return self.handle_added(github_id, github_username, organization) 40 | elif action == "member_invited": 41 | return self.handle_invited(github_username, organization) 42 | else: 43 | logging.error("organization webhook triggered," 44 | f" invalid action specified: {str(payload)}") 45 | return "Unsupported action triggered, ignoring.", 202 46 | 47 | def handle_remove(self, 48 | member_list: List[User], 49 | github_id: str, 50 | github_username: str) -> ResponseTuple: 51 | """Help organization function if payload action is remove.""" 52 | if len(member_list) == 1: 53 | slack_ids_string = "" 54 | for member in member_list: 55 | slack_id = member.slack_id 56 | self._facade.delete(User, slack_id) 57 | logging.info(f"deleted slack user {slack_id}") 58 | slack_ids_string += f" {slack_id}" 59 | return f"deleted slack ID{slack_ids_string}", 200 60 | elif len(member_list) > 1: 61 | logging.error("Error: found github ID connected to" 62 | " multiple slack IDs") 63 | return ("Error: found github ID connected to multiple slack" 64 | " IDs", 200) 65 | else: 66 | logging.error(f"could not find user {github_id}") 67 | return f"could not find user {github_username}", 200 68 | 69 | def handle_added(self, 70 | github_id: str, 71 | github_username: str, 72 | organization: str) -> ResponseTuple: 73 | """Help organization function if payload action is added.""" 74 | logging.info(f"user {github_username} added to {organization}") 75 | all_name = self._conf.github_team_all 76 | 77 | try: 78 | # Try to add the user to the 'all' team if it exists 79 | team_all = get_team_by_name(self._facade, all_name) 80 | self._gh.add_team_member(github_username, team_all.github_team_id) 81 | team_all.add_member(github_id) 82 | self._facade.store(team_all) 83 | except LookupError: 84 | # If that team doesn't exist, make it exist 85 | t_id = str(self._gh.org_create_team(self._conf.github_team_all)) 86 | self._gh.add_team_member(github_username, t_id) 87 | logging.info(f'team {all_name} created for {organization}') 88 | team = Team(t_id, all_name, all_name) 89 | team.add_member(github_id) 90 | self._facade.store(team) 91 | except RuntimeError as e: 92 | # If there are any other kinds of errors, we log it 93 | logging.error(e) 94 | return '', 200 95 | 96 | logging.info(f'user {github_username} added to team {all_name}') 97 | return f"user {github_username} added to {organization}", 200 98 | 99 | def handle_invited(self, 100 | github_username: str, 101 | organization: str) -> ResponseTuple: 102 | """Help organization function if payload action is invited.""" 103 | logging.info(f'user {github_username} invited to {organization}') 104 | return self.invite_text.format(github_username, organization), 200 105 | -------------------------------------------------------------------------------- /interface/slack.py: -------------------------------------------------------------------------------- 1 | """Utility classes for interacting with Slack API.""" 2 | from slack import WebClient 3 | from slack.web.base_client import SlackResponse 4 | from typing import Dict, Any, List, cast 5 | import logging 6 | 7 | 8 | class Bot: 9 | """Utility class for calling Slack APIs.""" 10 | 11 | def __init__(self, sc: WebClient, slack_channel: str = ''): 12 | """Initialize Bot by creating a WebClient Object.""" 13 | logging.info("Initializing Slack client interface") 14 | self.sc = sc 15 | self.slack_channel = slack_channel 16 | 17 | def send_dm(self, message: str, slack_user_id: str): 18 | """Send direct message to user with id of slack_user_id.""" 19 | logging.debug(f"Sending direct message to {slack_user_id}") 20 | response = cast(SlackResponse, self.sc.chat_postMessage( 21 | channel=slack_user_id, 22 | text=message, 23 | as_user=True 24 | )) 25 | if not response['ok']: 26 | logging.error(f"Direct message to {slack_user_id} failed with " 27 | f"error: {response['error']}") 28 | raise SlackAPIError(response['error']) 29 | 30 | def send_to_channel(self, 31 | message: str, 32 | channel_name: str, 33 | attachments: List[Any] = []): 34 | """Send message to channel with name channel_name.""" 35 | logging.debug(f"Sending message to channel {channel_name}") 36 | response = cast(SlackResponse, self.sc.chat_postMessage( 37 | channel=channel_name, 38 | attachments=attachments, 39 | text=message 40 | )) 41 | if not response['ok']: 42 | logging.error(f"Message to channel {channel_name} failed with " 43 | f"error: {response['error']}") 44 | raise SlackAPIError(response['error']) 45 | 46 | def get_channel_users(self, channel_id: str) -> Dict[str, Any]: 47 | """Retrieve list of user IDs from channel with channel_id.""" 48 | logging.debug(f"Retrieving user IDs from channel {channel_id}") 49 | response = cast(SlackResponse, self.sc.conversations_members( 50 | channel=channel_id 51 | )) 52 | if not response['ok']: 53 | logging.error("User retrieval " 54 | f"from channel {channel_id} failed with " 55 | f"error: {response['error']}") 56 | raise SlackAPIError(response['error']) 57 | else: 58 | return cast(Dict[str, Any], response["members"]) 59 | 60 | def get_channel_names(self) -> List[str]: 61 | """Retrieve list of channel names.""" 62 | return list(map(lambda c: str(c['name']), self.get_channels())) 63 | 64 | def get_channels(self) -> List[Any]: 65 | """Retrieve list of channel objects.""" 66 | resp = cast(SlackResponse, self.sc.conversations_list()) 67 | if not resp['ok']: 68 | logging.error(f"Channel retrieval failed with " 69 | f"error: {resp['error']}") 70 | raise SlackAPIError(resp['error']) 71 | else: 72 | return cast(List[Any], resp['channels']) 73 | 74 | def create_channel(self, channel_name): 75 | """ 76 | Create a channel with the given name. 77 | 78 | :return name of newly created channel 79 | """ 80 | logging.debug("Attempting to create channel with name {}". 81 | format(channel_name)) 82 | response = cast(SlackResponse, self.sc.channels_create( 83 | name=channel_name, 84 | validate=True 85 | )) 86 | if not response['ok']: 87 | if response['error'] == "name_taken": 88 | logging.warning("Channel with name {} " 89 | "already exists!". 90 | format(channel_name)) 91 | return channel_name 92 | else: 93 | logging.error("Channel creation " 94 | "with name {} failed with error: {}". 95 | format(channel_name, response['error'])) 96 | raise SlackAPIError(response['error']) 97 | else: 98 | return response["name"] 99 | 100 | def send_event_notif(self, message): 101 | """ 102 | Send a message to the slack bot channel, usually for webhook notifs. 103 | 104 | :param message to send to configured bot channel 105 | """ 106 | try: 107 | self.send_to_channel(message, self.slack_channel, []) 108 | logging.info("Webhook notif successfully sent to {} channel". 109 | format(self.slack_channel)) 110 | except SlackAPIError as se: 111 | logging.error("Webhook notif failed to send due to {} error.". 112 | format(se.error)) 113 | 114 | 115 | class SlackAPIError(Exception): 116 | """Exception representing an error while calling Slack API.""" 117 | 118 | def __init__(self, error): 119 | """ 120 | Initialize a new SlackAPIError. 121 | 122 | :param error: Error string returned from Slack API. 123 | """ 124 | self.error = error 125 | -------------------------------------------------------------------------------- /app/controller/command/parser.py: -------------------------------------------------------------------------------- 1 | """Handle Rocket 2 commands.""" 2 | from app.controller import ResponseTuple 3 | from app.controller.command.commands import UserCommand, TeamCommand,\ 4 | ExportCommand, TokenCommand, KarmaCommand,\ 5 | MentionCommand, IQuitCommand 6 | from app.controller.command.commands.base import Command 7 | from app.controller.command.commands.token import TokenCommandConfig 8 | from db.facade import DBFacade 9 | from interface.slack import Bot 10 | from interface.github import GithubInterface 11 | from interface.gcp import GCPInterface 12 | from interface.cloudwatch_metrics import CWMetrics 13 | from typing import Dict, Optional 14 | import utils.slack_parse as util 15 | import logging 16 | import time 17 | from utils.slack_msg_fmt import wrap_slack_code 18 | from config import Config 19 | import requests 20 | 21 | 22 | class CommandParser: 23 | """Manage the different command parsers for Rocket 2 commands.""" 24 | 25 | def __init__(self, 26 | config: Config, 27 | db_facade: DBFacade, 28 | bot: Bot, 29 | gh_interface: GithubInterface, 30 | token_config: TokenCommandConfig, 31 | metrics: CWMetrics, 32 | gcp: Optional[GCPInterface] = None): 33 | """Initialize the dictionary of command handlers.""" 34 | self.commands: Dict[str, Command] = {} 35 | self.__facade = db_facade 36 | self.__bot = bot 37 | self.__github = gh_interface 38 | self.__gcp = gcp 39 | self.__metrics = metrics 40 | self.commands["user"] = UserCommand(self.__facade, 41 | self.__github, 42 | self.__gcp) 43 | self.commands["team"] = TeamCommand(config, self.__facade, 44 | self.__github, 45 | self.__bot, 46 | gcp=self.__gcp) 47 | self.commands["export"] = ExportCommand(self.__facade) 48 | self.commands["token"] = TokenCommand(self.__facade, token_config) 49 | self.commands["karma"] = KarmaCommand(self.__facade) 50 | self.commands["mention"] = MentionCommand(self.__facade) 51 | self.commands["i-quit"] = IQuitCommand(self.__facade) 52 | 53 | def handle_app_command(self, 54 | cmd_txt: str, 55 | user: str, 56 | response_url: str): 57 | """ 58 | Handle a command call to rocket. 59 | 60 | :param cmd_txt: the command itself 61 | :param user: slack ID of user who executed the command 62 | :return: tuple where first element is the response text (or a 63 | ``flask.Response`` object), and the second element 64 | is the response status code 65 | """ 66 | start_time_ms = time.time() * 1000 67 | 68 | # Slightly hacky way to deal with Apple platform 69 | # smart punctuation messing with argparse. 70 | cmd_txt = ''.join(map(util.regularize_char, cmd_txt)) 71 | cmd_txt = util.escaped_id_to_id(cmd_txt) 72 | cmd_txt = util.ios_dash(cmd_txt) 73 | s = cmd_txt.split(' ', 1) 74 | cmd_name = 'help' 75 | if s[0] == 'help' or s[0] is None: 76 | logging.info('Help command was called') 77 | resp, _ = self.get_help() 78 | elif s[0] in self.commands: 79 | resp, _ = self.commands[s[0]].handle(cmd_txt, user) 80 | 81 | # Hack to only grab first 2 command/subcommand pair 82 | s = cmd_txt.split(' ') 83 | if len(s) == 2 and s[1].startswith('-'): 84 | cmd_name = s[0] 85 | else: 86 | cmd_name = ' '.join(s[0:2]) 87 | elif util.is_slack_id(s[0]): 88 | logging.info('mention command activated') 89 | resp, _ = self.commands['mention'].handle(cmd_txt, user) 90 | cmd_name = 'mention' 91 | else: 92 | logging.error("app command triggered incorrectly") 93 | resp, _ = self.get_help() 94 | 95 | if isinstance(resp, str): 96 | # Wrap response if response is just some text 97 | resp = {'text': resp} 98 | 99 | # Submit metrics 100 | duration_taken_ms = time.time() * 1000 - start_time_ms 101 | self.__metrics.submit_cmd_mstime(cmd_name, duration_taken_ms) 102 | 103 | if response_url != "": 104 | requests.post(url=response_url, json=resp) 105 | else: 106 | return resp, 200 107 | 108 | def get_help(self) -> ResponseTuple: 109 | """ 110 | Get help messages and return a formatted string for messaging. 111 | 112 | :return: Preformatted ``flask.Response`` object containing help 113 | messages 114 | """ 115 | wrapped = wrap_slack_code('/rocket [command] -h') 116 | message = f'''Displaying all available commands. 117 | To read about a specific command, use {wrapped}. 118 | For arguments containing spaces, please enclose them with quotations.''' 119 | for cmd in self.commands.values(): 120 | cmd_name = cmd.command_name 121 | message += f"\n> *{cmd_name}:* {cmd.desc}" 122 | return message, 200 123 | -------------------------------------------------------------------------------- /db/facade.py: -------------------------------------------------------------------------------- 1 | """Database Facade.""" 2 | from app.model import User, Team 3 | from typing import List, Tuple, TypeVar, Type 4 | from abc import ABC, abstractmethod 5 | 6 | T = TypeVar('T', User, Team) 7 | 8 | 9 | class DBFacade(ABC): 10 | """ 11 | A database facade that gives an overall API for any databases. 12 | 13 | Currently, we plan on having DynamoDB, but other databases, such as MongoDB 14 | or Postgres are also being considered. Please use this class instead of 15 | ``db/dynamodb.py``, because we might change the databases, but the facade 16 | would stay the same. 17 | """ 18 | 19 | @abstractmethod 20 | def store(self, obj: T) -> bool: 21 | """ 22 | Store object into the correct table. 23 | 24 | Object can be of type :class:`app.model.User` or 25 | :class:`app.model.Team`. 26 | 27 | :param obj: Object to store in database 28 | :return: True if object was stored, and false otherwise 29 | """ 30 | raise NotImplementedError 31 | 32 | @abstractmethod 33 | def retrieve(self, Model: Type[T], k: str) -> T: 34 | """ 35 | Retrieve a model from the database. 36 | 37 | :param Model: the actual class you want to retrieve 38 | :param k: retrieve based on this key (or ID) 39 | :raises: LookupError if key is not found 40 | :return: a model ``Model`` if key is found 41 | """ 42 | raise NotImplementedError 43 | 44 | @abstractmethod 45 | def bulk_retrieve(self, Model: Type[T], ks: List[str]) -> List[T]: 46 | """ 47 | Retrieve a list of models from the database. 48 | 49 | Keys not found in the database will be skipped. Should be at least as 50 | fast as multiple calls to ``.retrieve``. 51 | 52 | :param Model: the actual class you want to retrieve 53 | :param ks: retrieve based on this key (or ID) 54 | :return: a list of models ``Model`` 55 | """ 56 | raise NotImplementedError 57 | 58 | @abstractmethod 59 | def query(self, 60 | Model: Type[T], 61 | params: List[Tuple[str, str]] = []) -> List[T]: 62 | """ 63 | Query a table using a list of parameters. 64 | 65 | Returns a list of ``Model`` that have **all** of the attributes 66 | specified in the parameters. Every item in parameters is a tuple, where 67 | the first element is the user attribute, and the second is the value. 68 | 69 | Example:: 70 | 71 | ddb = DynamoDb(config) 72 | users = ddb.query(User, [('platform', 'slack')]) 73 | 74 | If you try to query a table without any parameters, the function will 75 | return all objects of that table.:: 76 | 77 | teams = ddb.query(Team) 78 | 79 | Attributes that are sets (e.g. ``team.member``) would be treated 80 | differently. This function would check to see if the entry 81 | **contains** a certain element. You can specify multiple elements, 82 | but they must be in different parameters (one element per tuple).:: 83 | 84 | teams = ddb.query(Team, [('members', 'abc123'), 85 | ('members', '231abc')]) 86 | 87 | :param Model: type of list elements you'd want 88 | :param params: list of tuples to match 89 | :return: a list of ``Model`` that fit the query parameters 90 | """ 91 | raise NotImplementedError 92 | 93 | @abstractmethod 94 | def query_or(self, 95 | Model: Type[T], 96 | params: List[Tuple[str, str]] = []) -> List[T]: 97 | """ 98 | Query a table using a list of parameters. 99 | 100 | Returns a list of ``Model`` that have **one** of the attributes 101 | specified in the parameters. Some might say that this is a **union** of 102 | the parameters. Every item in parameters is a tuple, where 103 | the first element is the user attribute, and the second is the value. 104 | 105 | Example:: 106 | 107 | ddb = DynamoDb(config) 108 | users = ddb.query_or(User, [('platform', 'slack')]) 109 | 110 | If you try to query a table without any parameters, the function will 111 | return all objects of that table.:: 112 | 113 | teams = ddb.query_or(Team) 114 | 115 | Attributes that are sets (e.g. ``team.member``) would be treated 116 | differently. This function would check to see if the entry 117 | **contains** a certain element. You can specify multiple elements, 118 | but they must be in different parameters (one element per tuple).:: 119 | 120 | teams = ddb.query_or(Team, [('members', 'abc123'), 121 | ('members', '231abc')]) 122 | 123 | The above would get you the teams that contain either member ``abc123`` 124 | or ``231abc``. 125 | 126 | :param Model: type of list elements you'd want 127 | :param params: list of tuples to match 128 | :return: a list of ``Model`` that fit the query parameters 129 | """ 130 | raise NotImplementedError 131 | 132 | @abstractmethod 133 | def delete(self, Model: Type[T], k: str): 134 | """ 135 | Remove an object from a table. 136 | 137 | :param Model: table type to remove the object from 138 | :param k: ID or key of the object to remove (must be primary key) 139 | """ 140 | raise NotImplementedError 141 | -------------------------------------------------------------------------------- /tests/memorydb_test.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | from uuid import uuid4 3 | from typing import List 4 | import random 5 | from tests.memorydb import MemoryDB, field_to_attr 6 | from app.model import User, Team 7 | import tests.util as util 8 | 9 | 10 | def makeUsers(amount: int = 20) -> List[User]: 11 | r = [] 12 | for _ in range(amount): 13 | u = User(str(uuid4())) 14 | u.github_username = u.slack_id 15 | r.append(u) 16 | return r 17 | 18 | 19 | def makeTeams() -> List[Team]: 20 | t0 = Team('t0', 'TZ', 'T Zero Blasters') 21 | t0.platform = 'iOS' 22 | t0.team_leads = set(['u0', 'u1', 'u2']) 23 | t0.members = set(['u0', 'u1', 'u2']) 24 | t1 = Team('t1', 'T1', 'T 1 Blasters') 25 | t1.platform = 'iOS' 26 | t1.team_leads = set(['u0', 'u2']) 27 | t1.members = set(['u0', 'u2', 'u3']) 28 | return [t0, t1] 29 | 30 | 31 | class TestFieldToAttr(TestCase): 32 | def test_field_case(self): 33 | self.assertEqual(field_to_attr(TestFieldToAttr, 'some'), 34 | 'some') 35 | 36 | 37 | class TestMemoryDB(TestCase): 38 | def __init__(self, *args, **kwargs): 39 | super().__init__(*args, **kwargs) 40 | self.admin = util.create_test_admin('Uadmin') 41 | self.users = {u.slack_id: u for u in makeUsers(20)} 42 | self.users['Uadmin'] = self.admin 43 | self.teams = {t.github_team_id: t for t in makeTeams()} 44 | 45 | def setUp(self): 46 | self.db = MemoryDB(users=list(self.users.values()), 47 | teams=list(self.teams.values())) 48 | 49 | def test_get_db_lookup_error(self): 50 | with self.assertRaises(LookupError): 51 | self.db.get_db(TestMemoryDB) 52 | 53 | def test_users_dont_affect_DB(self): 54 | """ 55 | DB modifications shouldn't affect dict outside. 56 | 57 | Models themselves being modified are okay. But modifying the 58 | composition of the DB (which objects are in it) is not. This test makes 59 | sure that deleting a user from the DB does not delete it from the user 60 | dictionary. 61 | """ 62 | slack_id = random.choice(list(self.users.keys())) 63 | self.db.users.pop(slack_id) 64 | self.assertIn(slack_id, self.users) 65 | 66 | def test_store_valid_user(self): 67 | u = User('u3') 68 | self.assertTrue(self.db.store(u)) 69 | 70 | def test_store_invalid_user(self): 71 | u = User('') 72 | self.assertFalse(self.db.store(u)) 73 | 74 | def test_retrieve_users_randomly(self): 75 | ks = list(self.users.keys()) 76 | for _ in range(10): 77 | slack_id = random.choice(ks) 78 | u = self.db.retrieve(User, slack_id) 79 | self.assertEqual(u.github_username, 80 | self.users[slack_id].github_username) 81 | 82 | def test_retrieve_nonexistant_user(self): 83 | with self.assertRaises(LookupError): 84 | self.db.retrieve(User, 'bad user bad bad') 85 | 86 | def test_bulk_retrieve(self): 87 | selection = random.sample(list(self.users.keys()), k=10) 88 | us = self.db.bulk_retrieve(User, selection) 89 | self.assertEqual(len(us), 10) 90 | for u in us: 91 | self.assertEqual(u.github_username, 92 | self.users[u.slack_id].github_username) 93 | 94 | def test_bulk_retrieve_nothing(self): 95 | selection = [str(i) for i in range(100)] 96 | us = self.db.bulk_retrieve(User, selection) 97 | self.assertEqual(us, []) 98 | 99 | def test_query_team_name(self): 100 | ts = self.db.query(Team, [('github_team_name', 'T1')]) 101 | self.assertEqual(len(ts), 1) 102 | self.assertEqual(ts[0], self.teams['t1']) 103 | 104 | def test_query_multi_params(self): 105 | ts = self.db.query( 106 | Team, 107 | [('members', 'u0'), ('team_leads', 'u1')]) 108 | self.assertEqual(len(ts), 1) 109 | self.assertEqual(ts[0], self.teams['t0']) 110 | 111 | def test_query_multi_teams(self): 112 | ts = self.db.query(Team, [('members', 'u0')]) 113 | self.assertCountEqual(ts, [self.teams['t0'], self.teams['t1']]) 114 | 115 | def test_scan_query(self): 116 | us = self.db.query(User) 117 | self.assertCountEqual(us, list(self.users.values())) 118 | 119 | def test_query_all_admins(self): 120 | admins = self.db.query(User, [('permission_level', 'admin')]) 121 | self.assertIn(self.admin, admins) 122 | 123 | def test_scan_teams(self): 124 | ts = self.db.query_or(Team) 125 | self.assertCountEqual(ts, list(self.teams.values())) 126 | 127 | def test_bulk_retrieve_using_query(self): 128 | selection = random.sample(list(self.users.items()), k=10) 129 | rand_vals = [v for _, v in selection] 130 | q_string = [('slack_id', k) for k, _ in selection] 131 | us = self.db.query_or(User, q_string) 132 | self.assertCountEqual(us, rand_vals) 133 | self.assertEqual(len(us), 10) 134 | 135 | def test_delete_user(self): 136 | slack_id = random.choice(list(self.users.keys())) 137 | self.db.delete(User, slack_id) 138 | with self.assertRaises(LookupError): 139 | self.db.retrieve(User, slack_id) 140 | 141 | def test_displayname(self): 142 | ts = self.db.query(Team, [('displayname', 'T Zero Blasters')]) 143 | self.assertEqual(len(ts), 1) 144 | self.assertEqual(ts[0], self.teams['t0']) 145 | -------------------------------------------------------------------------------- /app/controller/command/commands/karma.py: -------------------------------------------------------------------------------- 1 | """Command for parsing karma.""" 2 | import logging 3 | import shlex 4 | from app.controller.command.commands.base import Command 5 | from argparse import ArgumentParser, _SubParsersAction 6 | from app.model import User, Permissions 7 | from app.controller import ResponseTuple 8 | 9 | 10 | class KarmaCommand(Command): 11 | """karma command parser.""" 12 | 13 | command_name = "karma" 14 | help = "karma command reference:\n\n /rocket karma"\ 15 | "\n\nOptions:\n\n" \ 16 | "user" 17 | lookup_error = "User doesn't exist" 18 | desc = "for dealing with " + command_name 19 | permission_error = "You do not have the sufficient " \ 20 | "permission level for this command!" 21 | karma_default_amount = 1 22 | 23 | def __init__(self, db_facade): 24 | """Initialize karma command.""" 25 | super().__init__() 26 | logging.info("Starting karma command initializer") 27 | self.parser = ArgumentParser(prog="/rocket") 28 | self.parser.add_argument("karma") 29 | self.subparser = self.init_subparsers() 30 | self.facade = db_facade 31 | 32 | def init_subparsers(self) -> _SubParsersAction: 33 | """ 34 | Initialize subparsers for karma command. 35 | 36 | :meta private: 37 | """ 38 | subparsers: _SubParsersAction = \ 39 | self.parser.add_subparsers(dest="which") 40 | 41 | """Parser for set command.""" 42 | parser_set = subparsers.add_parser( 43 | "set", description="Manually sets a user's karma") 44 | parser_set.add_argument("username", metavar="USERNAME", 45 | type=str, action='store', 46 | help="slack id of kuser's karma to set") 47 | parser_set.add_argument("amount", metavar="amount", 48 | type=int, action='store', 49 | help="Amount of karma to set into user") 50 | 51 | """Parser for reset command.""" 52 | parser_reset = subparsers.add_parser( 53 | "reset", description="Reset a user's karma") 54 | parser_reset.add_argument("-a", "--all", action="store_true", 55 | help="Use to reset all user's karma amount") 56 | 57 | """Parser for view command.""" 58 | parser_view = subparsers.add_parser( 59 | "view", description="View a user's karma") 60 | parser_view.add_argument("username", metavar="USERNAME", 61 | type=str, action='store', 62 | help="slack id of user karma to view") 63 | return subparsers 64 | 65 | def handle(self, command, user_id): 66 | """Handle command by splitting into substrings.""" 67 | logging.info('Handling karma Command') 68 | command_arg = shlex.split(command) 69 | args = None 70 | 71 | try: 72 | args = self.parser.parse_args(command_arg) 73 | except SystemExit: 74 | return self.get_help(), 200 75 | 76 | if args.which == "set": 77 | return self.set_helper(user_id, args.username, args.amount) 78 | elif args.which == "reset": 79 | return self.reset_helper(user_id, args.all) 80 | elif args.which == "view": 81 | return self.view_helper(user_id, args.username) 82 | else: 83 | return self.get_help(), 200 84 | 85 | def set_helper(self, 86 | user_id: str, 87 | slack_id: str, 88 | amount: int) -> ResponseTuple: 89 | """Manually sets a user's karma.""" 90 | try: 91 | user = self.facade.retrieve(User, user_id) 92 | if user.permissions_level == Permissions.admin: 93 | user = self.facade.retrieve(User, slack_id) 94 | user.karma = amount 95 | self.facade.store(user) 96 | return f"set {user.name}'s karma to {amount}", 200 97 | else: 98 | return self.permission_error, 200 99 | except LookupError: 100 | return self.lookup_error, 200 101 | 102 | def reset_helper(self, 103 | user_id: str, 104 | reset_all: bool) -> ResponseTuple: 105 | """Reset all users' karma.""" 106 | try: 107 | user = self.facade.retrieve(User, user_id) 108 | if not user.permissions_level == Permissions.admin: 109 | return self.permission_error, 200 110 | if reset_all: 111 | user_list = self.facade.query(User, []) 112 | for user in user_list: 113 | user.karma = self.karma_default_amount 114 | self.facade.store(user) 115 | return ( 116 | "reset all users karma to" 117 | f"{self.karma_default_amount}", 118 | 200 119 | ) 120 | else: 121 | return self.get_help(), 200 122 | except LookupError: 123 | return self.lookup_error, 200 124 | 125 | def view_helper(self, 126 | user_id: str, 127 | slack_id: str) -> ResponseTuple: 128 | """Allow user to view how much karma someone has.""" 129 | try: 130 | user = self.facade.retrieve(User, slack_id) 131 | return f"{user.name} has {user.karma} karma", 200 132 | except LookupError: 133 | return self.lookup_error, 200 134 | -------------------------------------------------------------------------------- /docs/Config.rst: -------------------------------------------------------------------------------- 1 | Configuration Reference 2 | ======================= 3 | 4 | We use environmental variables for all of our configuration-related 5 | things. A sample ``.env`` file (which is what ``pipenv`` looks for when 6 | it tries to launch) can be found at ``sample-env``. Here is how each 7 | variable works. **Note: all variables are strings**. 8 | 9 | For variables that require newlines (such as signing keys), replace the 10 | newlines with ``\n``. You can use the following command on most systems 11 | to generate such a string: 12 | 13 | .. code:: bash 14 | 15 | awk '{printf "%s\\n", $0}' $FILE 16 | 17 | For JSON variables, you can just remove the newlines: 18 | 19 | .. code:: bash 20 | 21 | awk '{printf "%s", $0}' $FILE 22 | 23 | SLACK_SIGNING_SECRET 24 | -------------------- 25 | 26 | Signing secret of the slack app. Can be found in the basic information 27 | tab of your slack app (api.slack.com/apps). 28 | 29 | SLACK_API_TOKEN 30 | --------------- 31 | 32 | The Slack API token of your Slack bot. Can be found under OAuth & 33 | Permissions tab of your slack app (under the name "Bot user OAuth access 34 | token"). 35 | 36 | The following permission scopes are required: 37 | 38 | - ``channels:read`` 39 | - ``channels:manage`` 40 | - ``chats:write`` 41 | - ``users.profile:read`` 42 | - ``users:read`` 43 | - ``commands`` 44 | - ``groups:read`` 45 | - ``im:write`` 46 | 47 | You must also configure a slash command integration as well (under 48 | "Slash commands") for the URL path ``/slack/commands`` of your Rocket 49 | instance. 50 | 51 | SLACK_NOFICIATION_CHANNEL 52 | ------------------------- 53 | 54 | Name of the channel you want to have our rocket 2 slack bot to make 55 | service notifications in. 56 | 57 | SLACK_ANNOUNCEMENT_CHANNEL 58 | -------------------------- 59 | 60 | Name of the channel you want to have our rocket 2 slack bot to make 61 | announcements in. 62 | 63 | GITHUB_APP_ID 64 | ------------- 65 | 66 | The ID of your Github app (found under your Github organization settings 67 | -> Developer Settings -> Github Apps -> Edit). 68 | 69 | GITHUB_ORG_NAME 70 | --------------- 71 | 72 | The name of your Github organization (the string in the URL whenever you 73 | go to the organization. 74 | 75 | GITHUB_DEFAULT_TEAM_NAME 76 | ------------------------ 77 | 78 | The name of the GitHub team in your organization that all users should 79 | be added to. Optional, defaults to ``all``. 80 | 81 | GITHUB_ADMIN_TEAM_NAME 82 | ---------------------- 83 | 84 | The name of the GitHub team in your organization that should be automatically 85 | promoted to Rocket administrators. Optional. 86 | 87 | Note that this does not mean all Rocket administrators will be added to this 88 | team. 89 | 90 | GITHUB_LEADS_TEAM_NAME 91 | ---------------------- 92 | 93 | The name of the GitHub team in your organization that should be automatically 94 | promoted to Rocket team leads. Optional. 95 | 96 | Note that this does not mean all Rocket team leads will be added to this team. 97 | 98 | GITHUB_WEBHOOK_ENDPT 99 | -------------------- 100 | 101 | The path GitHub posts webhooks to. Note that the following events must 102 | be enabled (configured in GitHub app settings > "Permissions & events" > 103 | "Subscribe to events"): 104 | 105 | - Membership 106 | - Organization 107 | - Team 108 | - Team add 109 | 110 | When configuring webhooks, provide the URL path ``/slack/commands`` of 111 | your Rocket instance. 112 | 113 | GITHUB_WEBHOOK_SECRET 114 | --------------------- 115 | 116 | A random string of characters you provide to Github to help further 117 | obfuscate and verify that the webhook is indeed coming from Github. 118 | 119 | GITHUB_KEY 120 | ---------- 121 | 122 | The Github app signing key (can be found under Github organization 123 | settings -> Developer Settings -> Github Apps -> Edit (at the bottom you 124 | generate and download the key)). Paste the contents of the file as a 125 | string. See `deployment `__ for 126 | troubleshooting. 127 | 128 | The following permissions must be set to "Read & Write" for the 129 | associated GitHub app (configured in GitHub app settings > "Permissions 130 | & events" > "Organization permissions"): 131 | 132 | - Organization members 133 | 134 | AWS_ACCESS_KEYID 135 | ---------------- 136 | 137 | The AWS access key id. 138 | 139 | AWS_SECRET_KEY 140 | -------------- 141 | 142 | The AWS secret key. 143 | 144 | AWS_*_TABLE 145 | ----------- 146 | 147 | The names of the various tables (leave these as they are). 148 | 149 | AWS_REGION 150 | ---------- 151 | 152 | The region where the AWS instance is located (leave these as they are). 153 | 154 | AWS_LOCAL 155 | --------- 156 | 157 | Point all AWS DynamoDB requests to ``http://localhost:8000``. Optional, 158 | and defaults to ``False``. 159 | 160 | GCP_SERVICE_ACCOUNT_CREDENTIALS 161 | ------------------------------- 162 | 163 | Service Account credentials for Google Cloud API access. Optional, and 164 | defaults to disabling related features. 165 | 166 | Required scopes when credentials are provided: 167 | 168 | - ``https://www.googleapis.com/auth/drive`` - used for synchronizing 169 | Drive folder permissions 170 | 171 | For GSuite users, refer to `this 172 | guide `__ 173 | to set up service account access to your domain. 174 | 175 | GCP_SERVICE_ACCOUNT_SUBJECT 176 | --------------------------- 177 | 178 | User to emulate for GCP requests. Optional, and defaults to using your 179 | service account's identity. This feature requires domain-wide authority 180 | to be delegated to your service account - refer to `this 181 | guide `__. 182 | -------------------------------------------------------------------------------- /tests/app/controller/command/commands/export_test.py: -------------------------------------------------------------------------------- 1 | from app.controller.command.commands import ExportCommand 2 | from unittest import TestCase 3 | from app.model import User, Team, Permissions 4 | from tests.memorydb import MemoryDB 5 | from tests.util import create_test_admin 6 | 7 | 8 | class TestExportCommand(TestCase): 9 | def setUp(self): 10 | self.u0 = User('U0G9QF9C6') 11 | self.u0.email = 'immabaddy@gmail.com' 12 | self.u0.github_id = '305834954' 13 | 14 | self.u1 = User('Utheomadude') 15 | self.u1.email = 'theounderstars@yahoo.com' 16 | self.u1.github_id = '349850564' 17 | 18 | self.admin = create_test_admin('Uadmin') 19 | 20 | self.lead = User('Ualley') 21 | self.lead.email = 'alead@ubclaunchpad.com' 22 | self.lead.github_id = '2384858' 23 | self.lead.permissions_level = Permissions.team_lead 24 | 25 | self.t0 = Team('305849', 'butter-batter', 'Butter Batters') 26 | self.t0.add_member(self.u0.github_id) 27 | self.t0.add_member(self.lead.github_id) 28 | self.t0.add_team_lead(self.lead.github_id) 29 | 30 | self.t1 = Team('320484', 'aqua-scepter', 'Aqua Scepter') 31 | self.t1.add_member(self.u1.github_id) 32 | 33 | self.t2 = Team('22234', 'tiger-dear', 'Shakespearean') 34 | 35 | self.db = MemoryDB(users=[self.u0, self.u1, self.admin, self.lead], 36 | teams=[self.t0, self.t1, self.t2]) 37 | 38 | self.cmd = ExportCommand(self.db) 39 | 40 | def test_get_help_from_bad_syntax(self): 41 | resp, _ = self.cmd.handle('export emails hanrse', self.admin.slack_id) 42 | self.assertEqual(resp, self.cmd.get_help('emails')) 43 | 44 | def test_get_help_bad_syntax_all(self): 45 | resp, _ = self.cmd.handle('export blah', self.admin.slack_id) 46 | self.assertEqual(resp, self.cmd.get_help()) 47 | 48 | def test_lookup_error_user_calling(self): 49 | resp, _ = self.cmd.handle('export emails', 'blah blah') 50 | self.assertEqual(resp, ExportCommand.lookup_error) 51 | 52 | def test_get_all_emails(self): 53 | resp, _ = self.cmd.handle('export emails', self.admin.slack_id) 54 | self.assertIn(self.u0.email, resp) 55 | self.assertIn(self.u1.email, resp) 56 | self.assertIn(self.admin.email, resp) 57 | self.assertIn(self.lead.email, resp) 58 | self.assertIn(ExportCommand.no_emails_missing_msg, resp) 59 | 60 | def test_lead_get_all_emails(self): 61 | resp, _ = self.cmd.handle('export emails', self.lead.slack_id) 62 | self.assertIn(self.u0.email, resp) 63 | self.assertIn(self.u1.email, resp) 64 | self.assertIn(self.admin.email, resp) 65 | self.assertIn(self.lead.email, resp) 66 | self.assertIn(ExportCommand.no_emails_missing_msg, resp) 67 | 68 | def test_member_get_all_emails(self): 69 | resp, _ = self.cmd.handle('export emails', self.u0.slack_id) 70 | self.assertIn(ExportCommand.permission_error, resp) 71 | 72 | def test_get_team_emails(self): 73 | resp, _ = self.cmd.handle( 74 | f'export emails --team {self.t0.github_team_name}', 75 | self.admin.slack_id) 76 | self.assertIn(self.u0.email, resp) 77 | self.assertIn(self.lead.email, resp) 78 | self.assertIn(ExportCommand.no_emails_missing_msg, resp) 79 | 80 | def test_lead_get_team_emails(self): 81 | resp, _ = self.cmd.handle( 82 | f'export emails --team {self.t0.github_team_name}', 83 | self.lead.slack_id) 84 | self.assertIn(self.u0.email, resp) 85 | self.assertIn(self.lead.email, resp) 86 | self.assertIn(ExportCommand.no_emails_missing_msg, resp) 87 | 88 | def test_member_get_team_emails(self): 89 | resp, _ = self.cmd.handle( 90 | f'export emails --team {self.t0.github_team_name}', 91 | self.u1.slack_id) 92 | self.assertIn(ExportCommand.permission_error, resp) 93 | 94 | def test_lead_get_team_emails_one_missing(self): 95 | self.u0.email = '' 96 | resp, _ = self.cmd.handle( 97 | f'export emails --team {self.t0.github_team_name}', 98 | self.lead.slack_id) 99 | self.assertIn(self.u0.slack_id, resp) 100 | self.assertIn(self.lead.email, resp) 101 | self.assertIn('Members who don\'t have an email:', resp) 102 | self.assertNotIn(ExportCommand.no_emails_missing_msg, resp) 103 | 104 | def test_get_emails_no_users_in_team(self): 105 | resp, _ = self.cmd.handle( 106 | f'export emails --team {self.t2.github_team_name}', 107 | self.admin.slack_id) 108 | self.assertEqual(resp, ExportCommand.no_user_msg) 109 | 110 | def test_get_all_emails_char_limit_reached(self): 111 | old_lim = ExportCommand.MAX_CHAR_LIMIT 112 | ExportCommand.MAX_CHAR_LIMIT = 30 113 | resp, _ = self.cmd.handle('export emails', self.admin.slack_id) 114 | self.assertIn(ExportCommand.char_limit_exceed_msg, resp) 115 | 116 | # reset things because python doesn't do that 117 | ExportCommand.MAX_CHAR_LIMIT = old_lim 118 | 119 | def test_get_all_emails_one_missing_char_limit_reached(self): 120 | old_lim = ExportCommand.MAX_CHAR_LIMIT 121 | ExportCommand.MAX_CHAR_LIMIT = 30 122 | self.u0.email = '' 123 | resp, _ = self.cmd.handle('export emails', self.lead.slack_id) 124 | self.assertIn(self.u0.slack_id, resp) 125 | self.assertIn('Members who don\'t have an email:', resp) 126 | self.assertNotIn(ExportCommand.no_emails_missing_msg, resp) 127 | 128 | # reset things because python doesn't do that 129 | ExportCommand.MAX_CHAR_LIMIT = old_lim 130 | --------------------------------------------------------------------------------