11import click
22import configparser
3+ import flask
4+ import hashlib
5+ import hmac
36import os
47import requests
58import sys
9+ import time
610
711
812DEFAULT_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
368555def 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+
441642if __name__ == '__main__' :
442643 cli (obj = {})
0 commit comments