1
1
import click
2
2
import configparser
3
+ import flask
4
+ import hashlib
5
+ import hmac
3
6
import os
4
7
import requests
5
8
import sys
9
+ import time
6
10
7
11
8
12
DEFAULT_SUCCESS_RETURN = 0
@@ -113,6 +117,11 @@ def delete_label(self, repository, name, **kwargs):
113
117
if response .status_code != 204 :
114
118
raise GitHubError (response )
115
119
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
+
116
125
###############################################################################
117
126
# Printing and logging
118
127
###############################################################################
@@ -352,6 +361,184 @@ def retrieve_github_client(ctx):
352
361
sys .exit (NO_GH_TOKEN_RETURN )
353
362
return ctx .obj ['GitHub' ]
354
363
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
+
355
542
###############################################################################
356
543
# Click commands
357
544
###############################################################################
@@ -361,8 +548,8 @@ def retrieve_github_client(ctx):
361
548
@click .option ('--config' , '-c' , type = click .Path (exists = True ),
362
549
help = 'Path of the auth config file.' )
363
550
@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 ' ,
366
553
prog_name = 'labelord' )
367
554
@click .pass_context
368
555
def cli (ctx , config , token ):
@@ -438,5 +625,19 @@ def run(ctx, mode, template_repo, dry_run, verbose, quiet, all_repos):
438
625
sys .exit (gh_error_return (error ))
439
626
440
627
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
+
441
642
if __name__ == '__main__' :
442
643
cli (obj = {})
0 commit comments