├── .gitattributes ├── .gitignore ├── LICENSE.txt ├── README.md ├── requirements.txt ├── tldr-bot.png ├── tldr-bot.service ├── tldr-bot.svg └── tldr_bot.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # File to source env vars 10 | .bootstraprc 11 | 12 | # Distribution / packaging 13 | .Python 14 | env/ 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Flask stuff: 52 | instance/ 53 | .webassets-cache 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # IPython Notebook 59 | .ipynb_checkpoints 60 | 61 | # pyenv 62 | .python-version 63 | 64 | 65 | # dotenv 66 | .env 67 | 68 | # virtualenv 69 | venv/ 70 | ENV/ 71 | 72 | 73 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © 2017—present the [tldr-pages team](https://github.com/orgs/tldr-pages/people) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tldr-bot 2 | 3 | A simple [bot](https://github.com/tldr-bot) that performs automation tasks on the main [tldr repository](https://github.com/tldr-pages/tldr). 4 | 5 | ## Quick start 6 | 7 | - Generate a GitHub personal access token with the `repo` scope and then set the needed environment variables: 8 | 9 | ```sh 10 | FLASK_APP=/path/to/app/tldr_bot.py 11 | BOT_TOKEN= 12 | BOT_USERNAME= 13 | REPO_SLUG=tldr-pages/tldr 14 | PORT= 15 | ``` 16 | 17 | Then run the app using Flask: 18 | 19 | ```sh 20 | flask run --port=$PORT 21 | ``` 22 | 23 | ## Run as a service 24 | 25 | See the [`tldr-bot.service`](/tldr-bot.service) file for an example systemd unit configuration. 26 | 27 | Typically, the service is running under uWSGI and fronted by nginx. So you need to set the proper nginx config too. 28 | 29 | ```sh 30 | location / { 31 | include uwsgi_params; 32 | uwsgi_pass 127.0.0.1:6129; 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | requests 3 | uwsgi 4 | -------------------------------------------------------------------------------- /tldr-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tldr-pages/tldr-bot/f1c7d2a33daa7c14d2de23b0a293e63fed8f5552/tldr-bot.png -------------------------------------------------------------------------------- /tldr-bot.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Job that runs the tldr-bot 3 | Wants=network-online.target 4 | After=network-online.target 5 | 6 | [Service] 7 | SyslogIdentifier=tldr-bot 8 | User=tldr-bot 9 | Group=tldr-bot 10 | Type=notify 11 | KillSignal=SIGQUIT 12 | NotifyAccess=all 13 | Restart=always 14 | RuntimeDirectory=uwsgi 15 | StandardError=syslog 16 | EnvironmentFile=/path/to/environment_file 17 | ExecStart=/usr/local/bin/uwsgi --socket localhost:${PORT} --wsgi-file ${APP_ROOT}/tldr_bot.py --callable app --single-interpreter 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /tldr-bot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | $ 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /tldr_bot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import requests 6 | from flask import Flask, request, make_response 7 | 8 | app = Flask(__name__) 9 | 10 | def post_comment(issue_id, body): 11 | url = '{}/repos/{}/issues/{}/comments'.format(GITHUB_API_URL, REPO_SLUG, issue_id) 12 | headers = {'Authorization': 'token ' + BOT_TOKEN} 13 | data = {'body': body} 14 | 15 | return requests.post(url, json=data, headers=headers) 16 | 17 | 18 | def delete_comment(comment_id): 19 | url = f"{GITHUB_API_URL}/repos/{REPO_SLUG}/issues/comments/{comment_id}" 20 | headers = {"Authorization": "token " + BOT_TOKEN} 21 | 22 | return requests.delete(url, headers=headers) 23 | 24 | 25 | def previous_comment(issue_id, identifier): 26 | url = '{}/repos/{}/issues/{}/comments'.format(GITHUB_API_URL, REPO_SLUG, issue_id) 27 | headers = {'Authorization': 'token ' + BOT_TOKEN} 28 | resp = requests.get(url, headers=headers) 29 | 30 | if resp.status_code != 200: 31 | # GitHub API error, let's say True in this case to avoid problems. 32 | return None 33 | 34 | comments = resp.json() 35 | for comment in comments: 36 | if comment['user']['login'] == BOT_USERNAME and identifier in comment['body']: 37 | return comment['id'] 38 | return None 39 | 40 | 41 | def check_already_commented(issue_id): 42 | url = '{}/repos/{}/issues/{}/comments'.format(GITHUB_API_URL, REPO_SLUG, issue_id) 43 | resp = requests.get(url) 44 | 45 | if resp.status_code != 200: 46 | # GitHub API error, let's say True in this case to avoid problems. 47 | return True 48 | 49 | comments = resp.json() 50 | return any(c['user']['login'] == BOT_USERNAME for c in comments) 51 | 52 | 53 | @app.route('/', methods=['POST']) # old route, to be removed 54 | @app.route('/comment', methods=['POST']) 55 | @app.route('/comment/recreate', methods=['POST']) 56 | @app.route('/comment/once', methods=['POST']) 57 | def comment(): 58 | data = request.get_json() 59 | 60 | if data is None: 61 | return make_response('Invalid JSON or inappropriate Content-Type.', 400) 62 | 63 | pr_id = data.get('pr_id') 64 | comment_body = data.get('body') 65 | 66 | # Check if request is valid. 67 | if pr_id is None or comment_body is None: 68 | return make_response('Missing required JSON fields', 400) 69 | 70 | # If needed, check if already commented. 71 | if request.path == '/comment/once' and check_already_commented(pr_id): 72 | return make_response('already commented', 200) 73 | 74 | if request.path == "/comment/recreate": 75 | # Determine the identifier from the comment body 76 | if "" in comment_body: 77 | identifier = "" 78 | elif "" in comment_body: 79 | identifier = "" 80 | else: 81 | identifier = None 82 | 83 | if identifier: 84 | comment_id = previous_comment(pr_id, identifier) 85 | else: 86 | comment_id = None 87 | 88 | if comment_id: 89 | # Delete previous comment. 90 | delete_resp = delete_comment(comment_id) 91 | if delete_resp.status_code != 204: 92 | return make_response(delete_resp.text, 500) 93 | 94 | # Post comment. 95 | resp = post_comment(pr_id, comment_body) 96 | 97 | if resp.status_code != 201: 98 | return make_response(resp.text, 500) 99 | 100 | return make_response('success', 200) 101 | 102 | 103 | @app.route('/status', methods=['GET']) 104 | def status(): 105 | # Health check entpoint for monitoring purposes. 106 | return make_response('ok', 200) 107 | 108 | 109 | ################################################################################ 110 | 111 | GITHUB_API_URL = 'https://api.github.com' 112 | BOT_TOKEN = os.getenv('BOT_TOKEN') 113 | BOT_USERNAME = os.getenv('BOT_USERNAME') 114 | REPO_SLUG = os.getenv('REPO_SLUG') 115 | 116 | if BOT_TOKEN is None or BOT_USERNAME is None or REPO_SLUG is None: 117 | print('Needed environment variables are not set.') 118 | sys.exit(1) 119 | --------------------------------------------------------------------------------