Skip to content

Commit a7216e5

Browse files
committed
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
1 parent 92fcae7 commit a7216e5

17 files changed

+268
-313
lines changed

.github/workflows/deploy.yml

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: Deploy to Dokku
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
jobs:
8+
deploy:
9+
runs-on: ubuntu-20.04-self-hosted
10+
steps:
11+
- uses: actions/checkout@v2
12+
with:
13+
fetch-depth: 0
14+
- id: deploy
15+
name: Deploy to dokku
16+
uses: idoberko2/dokku-deploy-github-action@v1
17+
with:
18+
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
19+
dokku-host: ${{ secrets.DOCBOT_HOST }}
20+
app-name: ${{ secrets.DOCBOT_APP }}
21+
git-push-flags: '--force'

.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
venv
2+
.idea
3+
__pycache__
4+
*.pyc
5+
*.pyi

.travis.yml

-28
This file was deleted.

Dockerfile

+7-11
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
FROM python:2
1+
FROM python:3.10.4-alpine
22

3-
RUN mkdir -p /usr/src/bot
4-
WORKDIR /usr/src/bot
3+
COPY requirements.txt /tmp
4+
RUN pip install --upgrade pip --no-cache-dir && \
5+
pip install -r tmp/requirements.txt --no-cache-dir
56

6-
RUN pip install --upgrade pip
7-
8-
COPY requirements.txt /usr/src/bot
9-
COPY tarantoolbot.py /usr/src/bot
10-
COPY write_credentials.sh /usr/src/bot
11-
12-
RUN pip install -r requirements.txt
7+
COPY docbot /app/docbot
138

149
ENV PORT=5000
1510
EXPOSE 5000
1611

17-
CMD gunicorn --bind 0.0.0.0:$PORT tarantoolbot:app
12+
WORKDIR /app
13+
CMD gunicorn --bind 0.0.0.0:$PORT docbot.app:app

Procfile

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
web: gunicorn --bind 0.0.0.0:$PORT tarantoolbot:app
1+
web: gunicorn --bind 0.0.0.0:$PORT docbot.app:app

docbot/__init__.py

Whitespace-only changes.

docbot/app.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from elasticapm.contrib.flask import ElasticAPM
2+
from flask import Flask, request
3+
4+
from .handlers import webhook_handler, list_events_handler
5+
6+
app = Flask(__name__)
7+
8+
app.config['ELASTIC_APM'] = {
9+
'SERVICE_NAME': 'docbot',
10+
}
11+
apm = ElasticAPM(app)
12+
13+
14+
@app.route("/", methods=['GET'])
15+
def index() -> str:
16+
return list_events_handler()
17+
18+
19+
@app.route("/", methods=['POST'])
20+
def webhook() -> str:
21+
data: dict = request.json
22+
event: str = request.headers.get('X-GitHub-Event')
23+
return webhook_handler(data, event)

docbot/github.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import requests
2+
3+
from .utils import create_event
4+
5+
6+
class GitHub:
7+
def __init__(self, token: str) -> None:
8+
self.token: str = token
9+
10+
def send_comment(self, body, issue_api, to):
11+
create_event(to, 'send_comment', body)
12+
body = {'body': '@{}: {}'.format(to, body)}
13+
url = '{}/comments'.format(issue_api)
14+
status_code = self._send_request(url, body)
15+
print('Sent comment: {}'.format(status_code))
16+
17+
def get_comments(self, issue_api) -> dict:
18+
body = {'since': '1970-01-01T00:00:00Z'}
19+
url = '{}/comments'.format(issue_api)
20+
r = self._send_request(url, body, method='get')
21+
return r.json()
22+
23+
def create_issue(self, title, description, src_url, author, doc_repo_url):
24+
create_event(author, 'create_issue', title)
25+
description = '{}\nRequested by @{} in {}.'.format(description, author,
26+
src_url)
27+
body = {'title': title, 'body': description}
28+
url = '{}/issues'.format(doc_repo_url)
29+
status_code = self._send_request(url, body)
30+
print('Created issue: {}'.format(status_code))
31+
32+
def _send_request(self, url, body, method='post'):
33+
headers = {
34+
'Authorization': 'token {}'.format(self.token),
35+
}
36+
r = requests.request(method, url, json=body, headers=headers)
37+
print(r.status_code)
38+
r.raise_for_status()
39+
return r

docbot/handlers.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from .utils import last_events
2+
from . import settings
3+
from .processors import process_issue_comment, process_issue_state_change, \
4+
process_commit
5+
6+
7+
def webhook_handler(data: dict, event: str) -> str:
8+
if event is None or data is None:
9+
return 'Event or data was not found'
10+
11+
if 'issue' in data:
12+
issue = data['issue']
13+
if 'state' not in issue:
14+
return 'Event is not needed.'
15+
issue_state = issue['state']
16+
issue_api = issue['url']
17+
issue_url = issue['html_url']
18+
issue_repo = data['repository']['full_name']
19+
20+
if event == 'issue_comment':
21+
return process_issue_comment(data, issue_state,
22+
issue_api)
23+
elif event == 'issues':
24+
doc_repo_url = settings.doc_repo_urls.get(issue_repo)
25+
return process_issue_state_change(data, issue_api,
26+
issue_url, doc_repo_url)
27+
else:
28+
return 'Event "{}" is not needed.'.format(event)
29+
elif event == 'push':
30+
repo = data['repository']
31+
branch = '/'.join(data['ref'].split('/')[2:])
32+
is_master_push = repo['master_branch'] == branch
33+
issue_repo = settings.doc_repo_urls.get(repo['full_name'])
34+
for c in data['commits']:
35+
process_commit(c, is_master_push, issue_repo)
36+
return 'Webhook was processed'
37+
else:
38+
return 'Event is not needed.'
39+
40+
41+
def list_events_handler() -> str:
42+
return ('<h1>{}</h1><table border="1" cellspacing="2" ' +
43+
'cellpadding="2">{}</table>').format('TarantoolBot Journal',
44+
' '.join(last_events))

docbot/processors.py

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from . import settings
2+
from .github import GitHub
3+
from .utils import create_event
4+
5+
github = GitHub(settings.token)
6+
7+
8+
def parse_comment(body):
9+
if not body.startswith(settings.bot_name):
10+
return None, None
11+
offset = len(settings.bot_name)
12+
for dr in settings.doc_requests:
13+
if body.startswith(dr, offset):
14+
offset += len(dr)
15+
break
16+
else:
17+
return None, 'Invalid request type.'
18+
if not body.startswith(settings.title_header, offset):
19+
return None, 'Title not found.'
20+
offset += len(settings.title_header)
21+
pos = body.find('\n', offset)
22+
if pos == -1:
23+
pos = len(body)
24+
return {'title': body[offset:pos],
25+
'description': body[pos:]}, None
26+
27+
28+
def process_issue_comment(body, issue_state, issue_api) -> str:
29+
action = body['action']
30+
if (action != 'created' and action != 'edited') or \
31+
issue_state != 'open':
32+
return 'Not needed.'
33+
comment = body['comment']
34+
author = comment['user']['login']
35+
comment, error = parse_comment(comment['body'])
36+
if error:
37+
print('Error during request processing: {}'.format(error))
38+
github.send_comment(error, issue_api, author)
39+
elif comment:
40+
print('Request is processed ok')
41+
if action == 'edited':
42+
github.send_comment('Accept edited.', issue_api, author)
43+
else:
44+
github.send_comment('Accept.', issue_api, author)
45+
else:
46+
print('Ignore non-request comments')
47+
return 'Doc request is processed.'
48+
49+
50+
def process_issue_state_change(body, issue_api, issue_url, doc_repo_url):
51+
action = body['action']
52+
if action != 'closed':
53+
return 'Not needed.'
54+
comments = github.get_comments(issue_api)
55+
for c in comments:
56+
comment, error = parse_comment(c['body'])
57+
if comment:
58+
github.create_issue(comment["title"], comment["description"],
59+
issue_url, c['user']['login'], doc_repo_url)
60+
return 'Issue is processed.'
61+
62+
63+
def process_commit(c, is_master_push, doc_repo_url):
64+
body = c['message']
65+
request_pos = body.find(settings.bot_name)
66+
if request_pos == -1:
67+
return
68+
request = body[request_pos:]
69+
author = c['author']['username']
70+
comment, error = parse_comment(request)
71+
if error:
72+
print('Error during request processing: {}'.format(error))
73+
create_event(author, 'process_commit', error)
74+
else:
75+
create_event(author, 'process_commit', 'Accept')
76+
if is_master_push:
77+
github.create_issue(comment['title'],
78+
comment['description'], c['url'],
79+
author, doc_repo_url)

docbot/settings.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import os
2+
3+
token = os.environ.get('GITHUB_TOKEN')
4+
assert token is not None
5+
doc_requests = [' document\r\n', ' document\n']
6+
bot_name = '@TarantoolBot'
7+
title_header = 'Title:'
8+
api = 'https://api.github.com/repos/tarantool/'
9+
doc_repo_urls = {
10+
f'tarantool/tarantool': f'{api}doc',
11+
f'tarantool/tarantool-ee': f'{api}enterprise_doc',
12+
f'tarantool/sdk': f'{api}enterprise_doc',
13+
f'tarantool/tdg': f'{api}tdg-doc',
14+
f'tarantool/tdg2': f'{api}tdg-doc',
15+
}
16+
LAST_EVENTS_SIZE = 30

docbot/utils.py

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import datetime
2+
3+
from . import settings
4+
5+
last_events = []
6+
7+
8+
def create_event(author, action, body):
9+
time = datetime.datetime.now().strftime('%b %d %H:%M:%S')
10+
result = '<tr>'
11+
result += '<td>{}</td>'.format(time)
12+
result += '<td>{}</td>'.format(author)
13+
result += '<td>{}</td>'.format(action)
14+
result += '<td>{}</td>'.format(body)
15+
result += '</tr>'
16+
last_events.append(result)
17+
if len(last_events) > settings.LAST_EVENTS_SIZE:
18+
last_events.pop(0)

requirements.txt

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1-
bottle==0.12.13
2-
requests>=2.20.0
3-
gunicorn==19.9.0
1+
blinker==1.4
2+
certifi==2022.6.15
3+
charset-normalizer==2.0.12
4+
click==8.1.3
5+
elastic-apm==6.9.1
6+
Flask==2.1.2
7+
gunicorn==20.1.0
8+
idna==3.3
9+
itsdangerous==2.1.2
10+
Jinja2==3.1.2
11+
MarkupSafe==2.1.1
12+
requests==2.28.0
13+
urllib3==1.26.9
14+
Werkzeug==2.1.2

runtime.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
python-2.7.15
1+
python-3.10.4

0 commit comments

Comments
 (0)