Skip to content

Commit

Permalink
flask task solution
Browse files Browse the repository at this point in the history
  • Loading branch information
MarekSuchanek committed Oct 25, 2017
1 parent e3fa162 commit e36ca16
Show file tree
Hide file tree
Showing 10 changed files with 338 additions and 2 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ This Python app allows user to do via CLI:
* List labels for given repository
* Run update/replace labels for multiple projects (labels are specified in configuration file or by template repo)

App allows you run master-to-master replication web server which works with GitHub webhooks and manage labels for multiple repositories in even simpler way (try `run_server` command and see landing page for more information)!


For more information please use `--help`.

This project is reference solution for Labelord tasks series in [cvut/MI-PYT](https://github.com/cvut/MI-PYT).
Expand Down
3 changes: 3 additions & 0 deletions config.example.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# optionally you can provide token via
# GITHUB_TOKEN env variable
token = MY_GITHUB_TOKEN
# Secret used for securing webhooks
# see: https://developer.github.com/webhooks/securing/
webhook_secret = MY_WEBHOOK_SECRET

# Specify labels with list:
[labels]
Expand Down
205 changes: 203 additions & 2 deletions labelord.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import click
import configparser
import flask
import hashlib
import hmac
import os
import requests
import sys
import time


DEFAULT_SUCCESS_RETURN = 0
Expand Down Expand Up @@ -113,6 +117,11 @@ def delete_label(self, repository, name, **kwargs):
if response.status_code != 204:
raise GitHubError(response)

@staticmethod
def webhook_verify_signature(data, signature, secret, encoding='utf-8'):
h = hmac.new(secret.encode(encoding), data, hashlib.sha1)
return hmac.compare_digest('sha1=' + h.hexdigest(), signature)

###############################################################################
# Printing and logging
###############################################################################
Expand Down Expand Up @@ -352,6 +361,184 @@ def retrieve_github_client(ctx):
sys.exit(NO_GH_TOKEN_RETURN)
return ctx.obj['GitHub']

###############################################################################
# Flask task
###############################################################################


class LabelordChange:
CHANGE_TIMEOUT = 10

def __init__(self, action, name, color, new_name=None):
self.action = action
self.name = name
self.color = None if action == 'deleted' else color
self.old_name = new_name
self.timestamp = int(time.time())

@property
def tuple(self):
return self.action, self.name, self.color, self.old_name

def __eq__(self, other):
return self.tuple == other.tuple

def is_valid(self):
return self.timestamp > (int(time.time()) - self.CHANGE_TIMEOUT)


class LabelordWeb(flask.Flask):

def __init__(self, labelord_config, github, *args, **kwargs):
super().__init__(*args, **kwargs)
self.labelord_config = labelord_config
self.github = github
self.ignores = {}

def inject_session(self, session):
self.github.set_session(session)

def reload_config(self):
config_filename = os.environ.get('LABELORD_CONFIG', None)
self.labelord_config = create_config(
token=os.getenv('GITHUB_TOKEN', None),
config_filename=config_filename
)
self._check_config()
self.github.token = self.labelord_config.get('github', 'token')

@property
def repos(self):
return extract_repos(flask.current_app.labelord_config)

def _check_config(self):
if not self.labelord_config.has_option('github', 'token'):
click.echo('No GitHub token has been provided', err=True)
sys.exit(NO_GH_TOKEN_RETURN)
if not self.labelord_config.has_section('repos'):
click.echo('No repositories specification has been found',
err=True)
sys.exit(NO_REPOS_SPEC_RETURN)
if not self.labelord_config.has_option('github', 'webhook_secret'):
click.echo('No webhook secret has been provided', err=True)
sys.exit(NO_WEBHOOK_SECRET_RETURN)

def _init_error_handlers(self):
from werkzeug.exceptions import default_exceptions
for code in default_exceptions:
self.errorhandler(code)(LabelordWeb._error_page)

def finish_setup(self):
self._check_config()
self._init_error_handlers()

@staticmethod
def create_app(config=None, github=None):
cfg = config or create_config(
token=os.getenv('GITHUB_TOKEN', None),
config_filename=os.getenv('LABELORD_CONFIG', None)
)
gh = github or GitHub('') # dummy, but will be checked later
gh.token = cfg.get('github', 'token', fallback='')
return LabelordWeb(cfg, gh, import_name=__name__)

@staticmethod
def _error_page(error):
return flask.render_template('error.html', error=error), error.code

def cleanup_ignores(self):
for repo in self.ignores:
self.ignores[repo] = [c for c in self.ignores[repo]
if c.is_valid()]

def process_label_webhook_create(self, label, repo):
self.github.create_label(repo, label['name'], label['color'])

def process_label_webhook_delete(self, label, repo):
self.github.delete_label(repo, label['name'])

def process_label_webhook_edit(self, label, repo, changes):
name = old_name = label['name']
color = label['color']
if 'name' in changes:
old_name = changes['name']['from']
self.github.update_label(repo, name, color, old_name)

def process_label_webhook(self, data):
self.cleanup_ignores()
action = data['action']
label = data['label']
repo = data['repository']['full_name']
flask.current_app.logger.info(
'Processing LABEL webhook event with action {} from {} '
'with label {}'.format(action, repo, label)
)
if repo not in self.repos:
return # This repo is not being allowed in this app

change = LabelordChange(action, label['name'], label['color'])
if action == 'edited' and 'name' in data['changes']:
change.new_name = label['name']
change.name = data['changes']['name']['from']

if repo in self.ignores and change in self.ignores[repo]:
self.ignores[repo].remove(change)
return # This change was initiated by this service
for r in self.repos:
if r == repo:
continue
if r not in self.ignores:
self.ignores[r] = []
self.ignores[r].append(change)
try:
if action == 'created':
self.process_label_webhook_create(label, r)
elif action == 'deleted':
self.process_label_webhook_delete(label, r)
elif action == 'edited':
self.process_label_webhook_edit(label, r, data['changes'])
except GitHubError:
pass # Ignore GitHub errors


app = LabelordWeb.create_app()


@app.before_first_request
def finalize_setup():
flask.current_app.finish_setup()


@app.route('/', methods=['GET'])
def index():
repos = flask.current_app.repos
return flask.render_template('index.html', repos=repos)


@app.route('/', methods=['POST'])
def hook_accept():
headers = flask.request.headers
signature = headers.get('X-Hub-Signature', '')
event = headers.get('X-GitHub-Event', '')
data = flask.request.get_json()

if not flask.current_app.github.webhook_verify_signature(
flask.request.data, signature,
flask.current_app.labelord_config.get('github', 'webhook_secret')
):
flask.abort(401)

if event == 'label':
if data['repository']['full_name'] not in flask.current_app.repos:
flask.abort(400, 'Repository is not allowed in application')
flask.current_app.process_label_webhook(data)
return ''
if event == 'ping':
flask.current_app.logger.info('Accepting PING webhook event')
return ''
flask.abort(400, 'Event not supported')


###############################################################################
# Click commands
###############################################################################
Expand All @@ -361,8 +548,8 @@ def retrieve_github_client(ctx):
@click.option('--config', '-c', type=click.Path(exists=True),
help='Path of the auth config file.')
@click.option('--token', '-t', envvar='GITHUB_TOKEN',
help='GitHub API token.') # prompt would be better,
@click.version_option(version='0.1',
help='GitHub API token.')
@click.version_option(version='0.2',
prog_name='labelord')
@click.pass_context
def cli(ctx, config, token):
Expand Down Expand Up @@ -438,5 +625,19 @@ def run(ctx, mode, template_repo, dry_run, verbose, quiet, all_repos):
sys.exit(gh_error_return(error))


@cli.command(help='Run master-to-master replication server.')
@click.option('--host', '-h', default='127.0.0.1',
help='The interface to bind to.')
@click.option('--port', '-p', default=5000,
help='The port to bind to.')
@click.option('--debug', '-d', is_flag=True,
help='Turns on DEBUG mode.')
@click.pass_context
def run_server(ctx, host, port, debug):
app.labelord_config = ctx.obj['config']
app.github = retrieve_github_client(ctx)
app.run(host=host, port=port, debug=debug)


if __name__ == '__main__':
cli(obj={})
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
configparser
click
requests
flask
werkzeug
6 changes: 6 additions & 0 deletions static/bootstrap.min.css

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
html,
body {
height: 100%;
}
body{
padding-top: 60px;
}
.colorbox{
width: 1.5em;
height: 1.5em;
border: 1px solid black;
}
9 changes: 9 additions & 0 deletions templates/error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends "layout.html" %}
{% block body %}
<div class="jumbotron" id="landing-info">
<h1>Something went wrong!</h1>
<hr>
<h2>Error {{ error.code }}</h2>
<p>{{ error.description }}</p>
</div>
{% endblock %}
23 changes: 23 additions & 0 deletions templates/header.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<nav class="navbar navbar-default navbar-fixed-top navbar-inverse">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar"
aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-logo" href="{{ url_for('index') }}">
<span class="navbar-brand"><span class="octicon octicon-radio-tower"></span> Labelord server</span>
</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
<ul class="nav navbar-nav navbar-right">
<li>
<a href="{{ url_for('index') }}"><span class="octicon octicon-home"></span> Home</a>
</li>
</ul>
</div><!--/.nav-collapse -->
</div>
</nav>
52 changes: 52 additions & 0 deletions templates/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{% extends "layout.html" %}
{% block body %}
<div class="jumbotron" id="landing-info">
<h1>Welcome!</h1>
<hr>
<p>
<em>Labelord</em> is simple tool for management of issue labels for GitHub repositories.
Server part serves for master-to-master replication of labels thanks to webhooks. It propagates
labels changes within group of selected repositories. Cool huh?!
</p>

<p>
This project has started as a semester work during advanced Python course
<a href="https://github.com/cvut/MI-PYT/" target="_blank">MI-PYT @ FIT CTU in Prague</a>.
</p>

<hr>

<h2>Instructions</h2>

Create webhook @GitHub for your repositories with:
<dl class="dl-horizontal">
<dt>Payload URL</dt>
<dd><code>{{ url_for('hook_accept', _external=True) }}</code></dd>
<dt>Content type</dt>
<dd><code>application/json</code></dd>
<dt>Secret</dt>
<dd>The secret from config file!</dd>
<dt>Content type</dt>
<dd>Select event <strong>Label</strong> or more...</dd>
</dl>

<hr>

<h2>Repositories</h2>
<p>
Application is running currently for following repositories (from config file):
</p>

<table class="table table-striped">
{% for repo in repos %}
<tr>
<td>{{ repo }}</td>
<td align="right">
<a href="https://github.com/{{ repo }}" target="_blank" class="btn btn-primary"><span
class="octicon octicon-octoface"></span> GitHub</a>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
25 changes: 25 additions & 0 deletions templates/layout.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="author" content="Marek Suchánek">
<title>Labelord</title>

<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/octicons/4.4.0/octicons.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/octicons/4.4.0/font/octicons.min.css"/>
</head>
<body>
<main>
<div id="content">
<div class="container">
{% include "header.html" %}

{% block body %}{% endblock %}
</div>
</div>
</main>
{% block scripts %}{% endblock %}
</body>
</html>

0 comments on commit e36ca16

Please sign in to comment.