Skip to content

Commit e36ca16

Browse files
committed
flask task solution
1 parent e3fa162 commit e36ca16

10 files changed

+338
-2
lines changed

README.md

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ This Python app allows user to do via CLI:
1010
* List labels for given repository
1111
* Run update/replace labels for multiple projects (labels are specified in configuration file or by template repo)
1212

13+
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)!
14+
15+
1316
For more information please use `--help`.
1417

1518
This project is reference solution for Labelord tasks series in [cvut/MI-PYT](https://github.com/cvut/MI-PYT).

config.example.cfg

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
# optionally you can provide token via
1111
# GITHUB_TOKEN env variable
1212
token = MY_GITHUB_TOKEN
13+
# Secret used for securing webhooks
14+
# see: https://developer.github.com/webhooks/securing/
15+
webhook_secret = MY_WEBHOOK_SECRET
1316

1417
# Specify labels with list:
1518
[labels]

labelord.py

+203-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import click
22
import configparser
3+
import flask
4+
import hashlib
5+
import hmac
36
import os
47
import requests
58
import sys
9+
import time
610

711

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

120+
@staticmethod
121+
def webhook_verify_signature(data, signature, secret, encoding='utf-8'):
122+
h = hmac.new(secret.encode(encoding), data, hashlib.sha1)
123+
return hmac.compare_digest('sha1=' + h.hexdigest(), signature)
124+
116125
###############################################################################
117126
# Printing and logging
118127
###############################################################################
@@ -352,6 +361,184 @@ def retrieve_github_client(ctx):
352361
sys.exit(NO_GH_TOKEN_RETURN)
353362
return ctx.obj['GitHub']
354363

364+
###############################################################################
365+
# Flask task
366+
###############################################################################
367+
368+
369+
class LabelordChange:
370+
CHANGE_TIMEOUT = 10
371+
372+
def __init__(self, action, name, color, new_name=None):
373+
self.action = action
374+
self.name = name
375+
self.color = None if action == 'deleted' else color
376+
self.old_name = new_name
377+
self.timestamp = int(time.time())
378+
379+
@property
380+
def tuple(self):
381+
return self.action, self.name, self.color, self.old_name
382+
383+
def __eq__(self, other):
384+
return self.tuple == other.tuple
385+
386+
def is_valid(self):
387+
return self.timestamp > (int(time.time()) - self.CHANGE_TIMEOUT)
388+
389+
390+
class LabelordWeb(flask.Flask):
391+
392+
def __init__(self, labelord_config, github, *args, **kwargs):
393+
super().__init__(*args, **kwargs)
394+
self.labelord_config = labelord_config
395+
self.github = github
396+
self.ignores = {}
397+
398+
def inject_session(self, session):
399+
self.github.set_session(session)
400+
401+
def reload_config(self):
402+
config_filename = os.environ.get('LABELORD_CONFIG', None)
403+
self.labelord_config = create_config(
404+
token=os.getenv('GITHUB_TOKEN', None),
405+
config_filename=config_filename
406+
)
407+
self._check_config()
408+
self.github.token = self.labelord_config.get('github', 'token')
409+
410+
@property
411+
def repos(self):
412+
return extract_repos(flask.current_app.labelord_config)
413+
414+
def _check_config(self):
415+
if not self.labelord_config.has_option('github', 'token'):
416+
click.echo('No GitHub token has been provided', err=True)
417+
sys.exit(NO_GH_TOKEN_RETURN)
418+
if not self.labelord_config.has_section('repos'):
419+
click.echo('No repositories specification has been found',
420+
err=True)
421+
sys.exit(NO_REPOS_SPEC_RETURN)
422+
if not self.labelord_config.has_option('github', 'webhook_secret'):
423+
click.echo('No webhook secret has been provided', err=True)
424+
sys.exit(NO_WEBHOOK_SECRET_RETURN)
425+
426+
def _init_error_handlers(self):
427+
from werkzeug.exceptions import default_exceptions
428+
for code in default_exceptions:
429+
self.errorhandler(code)(LabelordWeb._error_page)
430+
431+
def finish_setup(self):
432+
self._check_config()
433+
self._init_error_handlers()
434+
435+
@staticmethod
436+
def create_app(config=None, github=None):
437+
cfg = config or create_config(
438+
token=os.getenv('GITHUB_TOKEN', None),
439+
config_filename=os.getenv('LABELORD_CONFIG', None)
440+
)
441+
gh = github or GitHub('') # dummy, but will be checked later
442+
gh.token = cfg.get('github', 'token', fallback='')
443+
return LabelordWeb(cfg, gh, import_name=__name__)
444+
445+
@staticmethod
446+
def _error_page(error):
447+
return flask.render_template('error.html', error=error), error.code
448+
449+
def cleanup_ignores(self):
450+
for repo in self.ignores:
451+
self.ignores[repo] = [c for c in self.ignores[repo]
452+
if c.is_valid()]
453+
454+
def process_label_webhook_create(self, label, repo):
455+
self.github.create_label(repo, label['name'], label['color'])
456+
457+
def process_label_webhook_delete(self, label, repo):
458+
self.github.delete_label(repo, label['name'])
459+
460+
def process_label_webhook_edit(self, label, repo, changes):
461+
name = old_name = label['name']
462+
color = label['color']
463+
if 'name' in changes:
464+
old_name = changes['name']['from']
465+
self.github.update_label(repo, name, color, old_name)
466+
467+
def process_label_webhook(self, data):
468+
self.cleanup_ignores()
469+
action = data['action']
470+
label = data['label']
471+
repo = data['repository']['full_name']
472+
flask.current_app.logger.info(
473+
'Processing LABEL webhook event with action {} from {} '
474+
'with label {}'.format(action, repo, label)
475+
)
476+
if repo not in self.repos:
477+
return # This repo is not being allowed in this app
478+
479+
change = LabelordChange(action, label['name'], label['color'])
480+
if action == 'edited' and 'name' in data['changes']:
481+
change.new_name = label['name']
482+
change.name = data['changes']['name']['from']
483+
484+
if repo in self.ignores and change in self.ignores[repo]:
485+
self.ignores[repo].remove(change)
486+
return # This change was initiated by this service
487+
for r in self.repos:
488+
if r == repo:
489+
continue
490+
if r not in self.ignores:
491+
self.ignores[r] = []
492+
self.ignores[r].append(change)
493+
try:
494+
if action == 'created':
495+
self.process_label_webhook_create(label, r)
496+
elif action == 'deleted':
497+
self.process_label_webhook_delete(label, r)
498+
elif action == 'edited':
499+
self.process_label_webhook_edit(label, r, data['changes'])
500+
except GitHubError:
501+
pass # Ignore GitHub errors
502+
503+
504+
app = LabelordWeb.create_app()
505+
506+
507+
@app.before_first_request
508+
def finalize_setup():
509+
flask.current_app.finish_setup()
510+
511+
512+
@app.route('/', methods=['GET'])
513+
def index():
514+
repos = flask.current_app.repos
515+
return flask.render_template('index.html', repos=repos)
516+
517+
518+
@app.route('/', methods=['POST'])
519+
def hook_accept():
520+
headers = flask.request.headers
521+
signature = headers.get('X-Hub-Signature', '')
522+
event = headers.get('X-GitHub-Event', '')
523+
data = flask.request.get_json()
524+
525+
if not flask.current_app.github.webhook_verify_signature(
526+
flask.request.data, signature,
527+
flask.current_app.labelord_config.get('github', 'webhook_secret')
528+
):
529+
flask.abort(401)
530+
531+
if event == 'label':
532+
if data['repository']['full_name'] not in flask.current_app.repos:
533+
flask.abort(400, 'Repository is not allowed in application')
534+
flask.current_app.process_label_webhook(data)
535+
return ''
536+
if event == 'ping':
537+
flask.current_app.logger.info('Accepting PING webhook event')
538+
return ''
539+
flask.abort(400, 'Event not supported')
540+
541+
355542
###############################################################################
356543
# Click commands
357544
###############################################################################
@@ -361,8 +548,8 @@ def retrieve_github_client(ctx):
361548
@click.option('--config', '-c', type=click.Path(exists=True),
362549
help='Path of the auth config file.')
363550
@click.option('--token', '-t', envvar='GITHUB_TOKEN',
364-
help='GitHub API token.') # prompt would be better,
365-
@click.version_option(version='0.1',
551+
help='GitHub API token.')
552+
@click.version_option(version='0.2',
366553
prog_name='labelord')
367554
@click.pass_context
368555
def cli(ctx, config, token):
@@ -438,5 +625,19 @@ def run(ctx, mode, template_repo, dry_run, verbose, quiet, all_repos):
438625
sys.exit(gh_error_return(error))
439626

440627

628+
@cli.command(help='Run master-to-master replication server.')
629+
@click.option('--host', '-h', default='127.0.0.1',
630+
help='The interface to bind to.')
631+
@click.option('--port', '-p', default=5000,
632+
help='The port to bind to.')
633+
@click.option('--debug', '-d', is_flag=True,
634+
help='Turns on DEBUG mode.')
635+
@click.pass_context
636+
def run_server(ctx, host, port, debug):
637+
app.labelord_config = ctx.obj['config']
638+
app.github = retrieve_github_client(ctx)
639+
app.run(host=host, port=port, debug=debug)
640+
641+
441642
if __name__ == '__main__':
442643
cli(obj={})

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
configparser
22
click
33
requests
4+
flask
5+
werkzeug

static/bootstrap.min.css

+6
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

static/style.css

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
html,
2+
body {
3+
height: 100%;
4+
}
5+
body{
6+
padding-top: 60px;
7+
}
8+
.colorbox{
9+
width: 1.5em;
10+
height: 1.5em;
11+
border: 1px solid black;
12+
}

templates/error.html

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% extends "layout.html" %}
2+
{% block body %}
3+
<div class="jumbotron" id="landing-info">
4+
<h1>Something went wrong!</h1>
5+
<hr>
6+
<h2>Error {{ error.code }}</h2>
7+
<p>{{ error.description }}</p>
8+
</div>
9+
{% endblock %}

templates/header.html

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<nav class="navbar navbar-default navbar-fixed-top navbar-inverse">
2+
<div class="container">
3+
<div class="navbar-header">
4+
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar"
5+
aria-expanded="false" aria-controls="navbar">
6+
<span class="sr-only">Toggle navigation</span>
7+
<span class="icon-bar"></span>
8+
<span class="icon-bar"></span>
9+
<span class="icon-bar"></span>
10+
</button>
11+
<a class="navbar-logo" href="{{ url_for('index') }}">
12+
<span class="navbar-brand"><span class="octicon octicon-radio-tower"></span> Labelord server</span>
13+
</a>
14+
</div>
15+
<div id="navbar" class="navbar-collapse collapse">
16+
<ul class="nav navbar-nav navbar-right">
17+
<li>
18+
<a href="{{ url_for('index') }}"><span class="octicon octicon-home"></span> Home</a>
19+
</li>
20+
</ul>
21+
</div><!--/.nav-collapse -->
22+
</div>
23+
</nav>

templates/index.html

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
{% extends "layout.html" %}
2+
{% block body %}
3+
<div class="jumbotron" id="landing-info">
4+
<h1>Welcome!</h1>
5+
<hr>
6+
<p>
7+
<em>Labelord</em> is simple tool for management of issue labels for GitHub repositories.
8+
Server part serves for master-to-master replication of labels thanks to webhooks. It propagates
9+
labels changes within group of selected repositories. Cool huh?!
10+
</p>
11+
12+
<p>
13+
This project has started as a semester work during advanced Python course
14+
<a href="https://github.com/cvut/MI-PYT/" target="_blank">MI-PYT @ FIT CTU in Prague</a>.
15+
</p>
16+
17+
<hr>
18+
19+
<h2>Instructions</h2>
20+
21+
Create webhook @GitHub for your repositories with:
22+
<dl class="dl-horizontal">
23+
<dt>Payload URL</dt>
24+
<dd><code>{{ url_for('hook_accept', _external=True) }}</code></dd>
25+
<dt>Content type</dt>
26+
<dd><code>application/json</code></dd>
27+
<dt>Secret</dt>
28+
<dd>The secret from config file!</dd>
29+
<dt>Content type</dt>
30+
<dd>Select event <strong>Label</strong> or more...</dd>
31+
</dl>
32+
33+
<hr>
34+
35+
<h2>Repositories</h2>
36+
<p>
37+
Application is running currently for following repositories (from config file):
38+
</p>
39+
40+
<table class="table table-striped">
41+
{% for repo in repos %}
42+
<tr>
43+
<td>{{ repo }}</td>
44+
<td align="right">
45+
<a href="https://github.com/{{ repo }}" target="_blank" class="btn btn-primary"><span
46+
class="octicon octicon-octoface"></span> GitHub</a>
47+
</td>
48+
</tr>
49+
{% endfor %}
50+
</table>
51+
</div>
52+
{% endblock %}

templates/layout.html

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<html lang="en">
2+
<head>
3+
<meta charset="utf-8">
4+
<meta name="author" content="Marek Suchánek">
5+
<title>Labelord</title>
6+
7+
<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap.min.css') }}">
8+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
9+
10+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/octicons/4.4.0/octicons.min.css"/>
11+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/octicons/4.4.0/font/octicons.min.css"/>
12+
</head>
13+
<body>
14+
<main>
15+
<div id="content">
16+
<div class="container">
17+
{% include "header.html" %}
18+
19+
{% block body %}{% endblock %}
20+
</div>
21+
</div>
22+
</main>
23+
{% block scripts %}{% endblock %}
24+
</body>
25+
</html>

0 commit comments

Comments
 (0)