From da1a267a809654084be8bd9ec317f2beffa5dd31 Mon Sep 17 00:00:00 2001 From: artembo Date: Fri, 17 Jun 2022 00:53:55 +0300 Subject: [PATCH] Refactor docbot service To make the project more flexible and extendable, tarantoolbot.py file was split on the several files. Bottle framework was replaced with Flask as it is easier to connect to Elastic APM server. - `app.py` contains Flask config, WSGI application and endpoints. - `github.py` has GitHub class to interact with its API. - `handles.py` contains handlers which parse request data and transfers data to processors - `processors.py` keeps GitHum commit and issue messages processors - `settigns.py` - project config - `utils.py` - project helpers Added possibility to add any repository to the bot through doc_repo_urls mapping in `settings.py`. Added `deploy` workflow to push the project to Dokku server. The workflow pushes code with `--force` flag, so it will override project in Dokku even if Dokku repo has different branch or commits order. `Dockerfile` was refactored for using lightweight python-3.10.4 image based on Alpine. To add new repository to the bot, do the following steps: - add @TarantoolBot user to the source project and to the corresponding doc project as well. - add source to docs repos mapping to `settings.py` in doc_repo_urls dictionary. - push the changes to master, the project will be deployed. - set up webhook to the source repository and point it to docbot service. Closes #13 Closes #16 Part of tarantool/infra#77 --- .github/workflows/deploy.yml | 21 +++ .gitignore | 5 + .travis.yml | 28 ---- Dockerfile | 18 +-- Procfile | 2 +- docbot/__init__.py | 0 docbot/app.py | 23 +++ docbot/github.py | 39 ++++++ docbot/handlers.py | 44 ++++++ docbot/processors.py | 79 +++++++++++ docbot/settings.py | 16 +++ docbot/utils.py | 18 +++ requirements.txt | 17 ++- runtime.txt | 2 +- tarantoolbot.py | 262 ----------------------------------- travis_key.enc | Bin 3248 -> 0 bytes write_credentials.sh | 7 - 17 files changed, 268 insertions(+), 313 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore delete mode 100644 .travis.yml create mode 100644 docbot/__init__.py create mode 100644 docbot/app.py create mode 100644 docbot/github.py create mode 100644 docbot/handlers.py create mode 100644 docbot/processors.py create mode 100644 docbot/settings.py create mode 100644 docbot/utils.py delete mode 100644 tarantoolbot.py delete mode 100644 travis_key.enc delete mode 100755 write_credentials.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3bd475a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,21 @@ +name: Deploy to Dokku + +on: + push: + branches: + - master +jobs: + deploy: + runs-on: ubuntu-20.04-self-hosted + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - id: deploy + name: Deploy to dokku + uses: idoberko2/dokku-deploy-github-action@v1 + with: + ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} + dokku-host: ${{ secrets.DOCBOT_HOST }} + app-name: ${{ secrets.DOCBOT_APP }} + git-push-flags: '--force' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82f30bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv +.idea +__pycache__ +*.pyc +*.pyi diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index fbc6dc4..0000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -sudo: required -services: - - docker - -branches: - only: - - master - -env: CONTAINER_NAME=tarantool/docbot:latest - -script: - - docker build -t ${CONTAINER_NAME} . - -after_success: - - docker login -u ${DOCKERHUB_USERNAME} -p ${DOCKERHUB_PASSWORD} - - docker push ${CONTAINER_NAME} - - - "( apt-get update -y && apt-get install openssh-client -y ) || ( which ssh-agent - || yum install openssh -y )" - - - openssl aes-256-cbc -K $encrypted_2272aa30aa52_key -iv $encrypted_2272aa30aa52_iv - -in travis_key.enc -out travis_key -d - - - eval $(ssh-agent -s) - - chmod 600 travis_key - - ssh-add travis_key - - - ssh -o "StrictHostKeyChecking no" docbot-deployer@try.tarantool.org diff --git a/Dockerfile b/Dockerfile index 5a0546d..eb4bc95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,13 @@ -FROM python:2 +FROM python:3.10.4-alpine -RUN mkdir -p /usr/src/bot -WORKDIR /usr/src/bot +COPY requirements.txt /tmp +RUN pip install --upgrade pip --no-cache-dir && \ + pip install -r tmp/requirements.txt --no-cache-dir -RUN pip install --upgrade pip - -COPY requirements.txt /usr/src/bot -COPY tarantoolbot.py /usr/src/bot -COPY write_credentials.sh /usr/src/bot - -RUN pip install -r requirements.txt +COPY docbot /app/docbot ENV PORT=5000 EXPOSE 5000 -CMD gunicorn --bind 0.0.0.0:$PORT tarantoolbot:app +WORKDIR /app +CMD gunicorn --bind 0.0.0.0:$PORT docbot.app:app diff --git a/Procfile b/Procfile index 9321b17..9180f6d 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: gunicorn --bind 0.0.0.0:$PORT tarantoolbot:app \ No newline at end of file +web: gunicorn --bind 0.0.0.0:$PORT docbot.app:app diff --git a/docbot/__init__.py b/docbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docbot/app.py b/docbot/app.py new file mode 100644 index 0000000..5f56060 --- /dev/null +++ b/docbot/app.py @@ -0,0 +1,23 @@ +from elasticapm.contrib.flask import ElasticAPM +from flask import Flask, request + +from .handlers import webhook_handler, list_events_handler + +app = Flask(__name__) + +app.config['ELASTIC_APM'] = { + 'SERVICE_NAME': 'docbot', +} +apm = ElasticAPM(app) + + +@app.route("/", methods=['GET']) +def index() -> str: + return list_events_handler() + + +@app.route("/", methods=['POST']) +def webhook() -> str: + data: dict = request.json + event: str = request.headers.get('X-GitHub-Event') + return webhook_handler(data, event) diff --git a/docbot/github.py b/docbot/github.py new file mode 100644 index 0000000..90121df --- /dev/null +++ b/docbot/github.py @@ -0,0 +1,39 @@ +import requests + +from .utils import create_event + + +class GitHub: + def __init__(self, token: str) -> None: + self.token: str = token + + def send_comment(self, body, issue_api, to): + create_event(to, 'send_comment', body) + body = {'body': '@{}: {}'.format(to, body)} + url = '{}/comments'.format(issue_api) + status_code = self._send_request(url, body) + print('Sent comment: {}'.format(status_code)) + + def get_comments(self, issue_api) -> dict: + body = {'since': '1970-01-01T00:00:00Z'} + url = '{}/comments'.format(issue_api) + r = self._send_request(url, body, method='get') + return r.json() + + def create_issue(self, title, description, src_url, author, doc_repo_url): + create_event(author, 'create_issue', title) + description = '{}\nRequested by @{} in {}.'.format(description, author, + src_url) + body = {'title': title, 'body': description} + url = '{}/issues'.format(doc_repo_url) + status_code = self._send_request(url, body) + print('Created issue: {}'.format(status_code)) + + def _send_request(self, url, body, method='post'): + headers = { + 'Authorization': 'token {}'.format(self.token), + } + r = requests.request(method, url, json=body, headers=headers) + print(r.status_code) + r.raise_for_status() + return r diff --git a/docbot/handlers.py b/docbot/handlers.py new file mode 100644 index 0000000..f32bebf --- /dev/null +++ b/docbot/handlers.py @@ -0,0 +1,44 @@ +from .utils import last_events +from . import settings +from .processors import process_issue_comment, process_issue_state_change, \ + process_commit + + +def webhook_handler(data: dict, event: str) -> str: + if event is None or data is None: + return 'Event or data was not found' + + if 'issue' in data: + issue = data['issue'] + if 'state' not in issue: + return 'Event is not needed.' + issue_state = issue['state'] + issue_api = issue['url'] + issue_url = issue['html_url'] + issue_repo = data['repository']['full_name'] + + if event == 'issue_comment': + return process_issue_comment(data, issue_state, + issue_api) + elif event == 'issues': + doc_repo_url = settings.doc_repo_urls.get(issue_repo) + return process_issue_state_change(data, issue_api, + issue_url, doc_repo_url) + else: + return 'Event "{}" is not needed.'.format(event) + elif event == 'push': + repo = data['repository'] + branch = '/'.join(data['ref'].split('/')[2:]) + is_master_push = repo['master_branch'] == branch + issue_repo = settings.doc_repo_urls.get(repo['full_name']) + for c in data['commits']: + process_commit(c, is_master_push, issue_repo) + return 'Webhook was processed' + else: + return 'Event is not needed.' + + +def list_events_handler() -> str: + return ('

{}

{}
').format('TarantoolBot Journal', + ' '.join(last_events)) diff --git a/docbot/processors.py b/docbot/processors.py new file mode 100644 index 0000000..dcd07dc --- /dev/null +++ b/docbot/processors.py @@ -0,0 +1,79 @@ +from . import settings +from .github import GitHub +from .utils import create_event + +github = GitHub(settings.token) + + +def parse_comment(body): + if not body.startswith(settings.bot_name): + return None, None + offset = len(settings.bot_name) + for dr in settings.doc_requests: + if body.startswith(dr, offset): + offset += len(dr) + break + else: + return None, 'Invalid request type.' + if not body.startswith(settings.title_header, offset): + return None, 'Title not found.' + offset += len(settings.title_header) + pos = body.find('\n', offset) + if pos == -1: + pos = len(body) + return {'title': body[offset:pos], + 'description': body[pos:]}, None + + +def process_issue_comment(body, issue_state, issue_api) -> str: + action = body['action'] + if (action != 'created' and action != 'edited') or \ + issue_state != 'open': + return 'Not needed.' + comment = body['comment'] + author = comment['user']['login'] + comment, error = parse_comment(comment['body']) + if error: + print('Error during request processing: {}'.format(error)) + github.send_comment(error, issue_api, author) + elif comment: + print('Request is processed ok') + if action == 'edited': + github.send_comment('Accept edited.', issue_api, author) + else: + github.send_comment('Accept.', issue_api, author) + else: + print('Ignore non-request comments') + return 'Doc request is processed.' + + +def process_issue_state_change(body, issue_api, issue_url, doc_repo_url): + action = body['action'] + if action != 'closed': + return 'Not needed.' + comments = github.get_comments(issue_api) + for c in comments: + comment, error = parse_comment(c['body']) + if comment: + github.create_issue(comment["title"], comment["description"], + issue_url, c['user']['login'], doc_repo_url) + return 'Issue is processed.' + + +def process_commit(c, is_master_push, doc_repo_url): + body = c['message'] + request_pos = body.find(settings.bot_name) + if request_pos == -1: + return + request = body[request_pos:] + author = c['author']['username'] + comment, error = parse_comment(request) + if error: + print('Error during request processing: {}'.format(error)) + create_event(author, 'process_commit', error) + else: + create_event(author, 'process_commit', 'Accept') + if is_master_push: + github.create_issue(comment['title'], + comment['description'], c['url'], + author, doc_repo_url) diff --git a/docbot/settings.py b/docbot/settings.py new file mode 100644 index 0000000..bb4e979 --- /dev/null +++ b/docbot/settings.py @@ -0,0 +1,16 @@ +import os + +token = os.environ.get('GITHUB_TOKEN') +assert token is not None +doc_requests = [' document\r\n', ' document\n'] +bot_name = '@TarantoolBot' +title_header = 'Title:' +api = 'https://api.github.com/repos/tarantool/' +doc_repo_urls = { + f'tarantool/tarantool': f'{api}doc', + f'tarantool/tarantool-ee': f'{api}enterprise_doc', + f'tarantool/sdk': f'{api}enterprise_doc', + f'tarantool/tdg': f'{api}tdg-doc', + f'tarantool/tdg2': f'{api}tdg-doc', +} +LAST_EVENTS_SIZE = 30 diff --git a/docbot/utils.py b/docbot/utils.py new file mode 100644 index 0000000..c77367d --- /dev/null +++ b/docbot/utils.py @@ -0,0 +1,18 @@ +import datetime + +from . import settings + +last_events = [] + + +def create_event(author, action, body): + time = datetime.datetime.now().strftime('%b %d %H:%M:%S') + result = '' + result += '{}'.format(time) + result += '{}'.format(author) + result += '{}'.format(action) + result += '{}'.format(body) + result += '' + last_events.append(result) + if len(last_events) > settings.LAST_EVENTS_SIZE: + last_events.pop(0) diff --git a/requirements.txt b/requirements.txt index cc0039a..f30bd69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,14 @@ -bottle==0.12.20 -requests>=2.20.0 -gunicorn==19.9.0 +blinker==1.4 +certifi==2022.6.15 +charset-normalizer==2.0.12 +click==8.1.3 +elastic-apm==6.9.1 +Flask==2.1.2 +gunicorn==20.1.0 +idna==3.3 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.1 +requests==2.28.0 +urllib3==1.26.9 +Werkzeug==2.1.2 diff --git a/runtime.txt b/runtime.txt index f27f1cc..73e24dc 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-2.7.15 +python-3.10.4 diff --git a/tarantoolbot.py b/tarantoolbot.py deleted file mode 100644 index d9cad94..0000000 --- a/tarantoolbot.py +++ /dev/null @@ -1,262 +0,0 @@ -from bottle import run, post, get, request, default_app -import requests -import json -import datetime -import os - -doc_requests = [' document\r\n', ' document\n'] -bot_name = '@TarantoolBot' -title_header = 'Title:' -doc_repo_url = 'https://api.github.com/repos/tarantool/doc' - -if os.getenv('GITHUB_TOKEN'): - token = os.getenv('GITHUB_TOKEN') -else: - with open('credentials.json') as f: - credentials = json.loads(f.read()) - token = credentials['GitHub']['token'] - -LAST_EVENTS_SIZE = 30 -last_events = [] - -## -# Log an event at the end of the events queue. Once the queue -# reaches size LAST_EVENTS_SIZE, the oldest event is deleted. -# @param author GitHub login of the author. -# @param action What to log. -# @param body Message body. - - -def create_event(author, action, body): - time = datetime.datetime.now().strftime('%b %d %H:%M:%S') - result = '' - result += '{}'.format(time) - result += '{}'.format(author) - result += '{}'.format(action) - result += '{}'.format(body) - result += '' - last_events.append(result) - if len(last_events) > LAST_EVENTS_SIZE: - last_events.pop(0) - -## -# Bot to track documentation update requests -# and open issues in doc repository with a -# specified body and title, when an issue is -# closed. -# - -## -# Parse a comment content. Try to -# extract title and body. -# @param body String comment. -# -# @retval None, None - @A body is not a request. -# @retval None, not None - @A body is a request, -# but an error occured. The second retval -# is the error string. -# @retval not None - @A body is a well-formatted -# request. Returned value is a dictionary -# with 'title' and 'description'. - - -def parse_comment(body): - if not body.startswith(bot_name): - return None, None - offset = len(bot_name) - for dr in doc_requests: - if body.startswith(dr, offset): - offset += len(dr) - break - else: - return None, 'Invalid request type.' - if not body.startswith(title_header, offset): - return None, 'Title not found.' - offset += len(title_header) - pos = body.find('\n', offset) - if pos == -1: - pos = len(body) - return {'title': body[offset:pos], - 'description': body[pos:]}, None -## -# Send a comment to an issue. Sent comment is either -# parser error message, or notification about accepting -# a request. - - -def send_comment(body, issue_api, to): - create_event(to, 'send_comment', body) - body = {'body': '@{}: {}'.format(to, body)} - url = '{}/comments'.format(issue_api) - headers = { - 'Authorization': 'token {}'.format(token), - } - r = requests.post(url, json=body, headers=headers) - r.raise_for_status() - print('Sent comment: {}'.format(r.status_code)) - -## -# Get all the comments of an issue. - - -def get_comments(issue_api): - body = {'since': '1970-01-01T00:00:00Z'} - url = '{}/comments'.format(issue_api) - headers = { - 'Authorization': 'token {}'.format(token), - } - r = requests.get(url, json=body, headers=headers) - r.raise_for_status() - return r.json() - -## -# Open a new issue in a documentation repository. -# @param title Issue title. -# @param description Issue description. -# @param src_url Link to a webpage with the original issue or -# commit. -# @param author Github login of the request author. - - -def create_issue(title, description, src_url, author): - create_event(author, 'create_issue', title) - description = '{}\nRequested by @{} in {}.'.format(description, author, - src_url) - body = {'title': title, 'body': description} - url = '{}/issues'.format(doc_repo_url) - headers = { - 'Authorization': 'token {}'.format(token), - } - r = requests.post(url, json=body, headers=headers) - r.raise_for_status() - print('Created issue: {}'.format(r.status_code)) - -## -# Check that a new or edited comment for an issue is the request. -# If it is, then try to parse it and send to a caller -# notification about a result. -# @param body GitHub hook body. -# @param issue_state Issue status: closed, open, created. -# @param issue_api URL of the commented issue. Here a -# response is wrote. -# -# @retval Response to a GitHub hook. - - -def process_issue_comment(body, issue_state, issue_api): - action = body['action'] - if (action != 'created' and action != 'edited') or \ - issue_state != 'open': - return 'Not needed.' - comment = body['comment'] - author = comment['user']['login'] - comment, error = parse_comment(comment['body']) - if error: - print('Error during request processing: {}'.format(error)) - send_comment(error, issue_api, author) - elif comment: - print('Request is processed ok') - if action == 'edited': - send_comment('Accept edited.', issue_api, author) - else: - send_comment('Accept.', issue_api, author) - else: - print('Ignore non-request comments') - return 'Doc request is processed.' - -## -# Check that a just closed issue contains contains -# a well-formatted doc update request. If it does, then -# open a new issue in doc repository. For multiple doc requests -# multiple issues will be created. -# @param body GitHub hook body. -# @param issue_state Issue status: closed, open, created. -# @param issue_api API URL of the original issue. -# @param issue_url Public URL of the original issue. -# -# @retval Response to a GitHub hook. - - -def process_issue_state_change(body, issue_state, issue_api, issue_url): - action = body['action'] - if action != 'closed': - return 'Not needed.' - comments = get_comments(issue_api) - comment = None - for c in comments: - comment, error = parse_comment(c['body']) - if comment: - create_issue(comment["title"], comment["description"], - issue_url, c['user']['login']) - return 'Issue is processed.' - -## -# Process a commit event, triggered on any push. If in the commit -# message a request is found, then parse it and create an issue on -# documentation, if the push is done into master. -# @param c Commit object. -# @param is_master_push True if the commit is pushed into the -# master branch. - - -def process_commit(c, is_master_push): - body = c['message'] - request_pos = body.find(bot_name) - if request_pos == -1: - return - request = body[request_pos:] - author = c['author']['username'] - comment, error = parse_comment(request) - if error: - print('Error during request processing: {}'.format(error)) - create_event(author, 'process_commit', error) - else: - create_event(author, 'process_commit', 'Accept') - if is_master_push: - create_issue(comment['title'], comment['description'], - c['url'], author) - - -@post('/') -def index_post(): - r = request.json - t = request.get_header('X-GitHub-Event') - if 'issue' in r: - issue = r['issue'] - if not 'state' in issue: - return 'Event is not needed.' - issue_state = issue['state'] - issue_api = issue['url'] - issue_url = issue['html_url'] - - if t == 'issue_comment': - return process_issue_comment(r, issue_state, issue_api) - elif t == 'issues': - return process_issue_state_change(r, issue_state, - issue_api, issue_url) - else: - return 'Event "{}" is not needed.'.format(t) - elif t == 'push': - repo = r['repository'] - # Strip 'refs/heads/' from beginning. - branch = '/'.join(r['ref'].split('/')[2:]) - is_master_push = repo['master_branch'] == branch - for c in r['commits']: - process_commit(c, is_master_push) - else: - return 'Event is not needed.' - - -@get('/') -def index_get(): - return ('

{}

{}
').format('TarantoolBot Journal', - ' '.join(last_events)) - - -print('Starting bot') -if __name__ == "__main__": - run(host='0.0.0.0', port=5000) - -# allow to run under gunicorn -app = default_app() diff --git a/travis_key.enc b/travis_key.enc deleted file mode 100644 index a147052bfeb7db6a844ca268a09e3985822fdd74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3248 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 credentials.json -echo -e "\t\"GitHub\": {" >> credentials.json -echo -e "\t\t\"token\": \"${GITHUB_TOKEN}\"" >> credentials.json -echo -e "\t}\n}" >> credentials.json