2022-11-02 10:35:06 +01:00
import json
import logging
import os
import re
import flask
import redminelib
logging . basicConfig (
level = logging . INFO , format = ' %(asctime)s [ %(levelname)s ] %(message)s ' , handlers = [ logging . StreamHandler ( ) ]
)
INCOMING_WEBHOOK_SECRET = os . environ [ ' INCOMING_WEBHOOK_SECRET ' ]
REDMINE_URL = os . environ [ ' REDMINE_URL ' ]
REDMINE_API_KEY = os . environ [ ' REDMINE_API_KEY ' ]
2023-04-14 15:00:08 +02:00
REDMINE_CLIENT = redminelib . Redmine ( REDMINE_URL , key = REDMINE_API_KEY , raise_attr_exception = False )
2022-11-02 10:35:06 +01:00
REDMINE_ASSIGNABLE_GROUP = int ( os . environ . get ( ' REDMINE_ASSIGNABLE_GROUP ' , 21 ) )
2023-01-18 15:36:05 +01:00
REDMINE_EXCLUDED_PROJECTS = [
2023-01-31 19:27:59 +01:00
i . strip ( ) for i in os . environ . get ( ' REDMINE_EXCLUDED_PROJECTS ' , ' projets-clients ' ) . split ( ' , ' )
2023-01-18 15:36:05 +01:00
]
2022-11-02 10:35:06 +01:00
REDMINE_STATUSES = {
' En cours ' : 2 ,
' Résolu ' : 3 ,
' Solution proposée ' : 12 ,
' Solution validée ' : 13 ,
2022-11-29 14:30:47 +01:00
' Solution déployée ' : 4 ,
' Fermé ' : 5 ,
' Rejeté ' : 6 ,
2022-11-02 10:35:06 +01:00
}
2022-11-29 14:30:47 +01:00
CLOSED_STATUSES = [
REDMINE_STATUSES [ ' Résolu ' ] ,
REDMINE_STATUSES [ ' Solution déployée ' ] ,
REDMINE_STATUSES [ ' Fermé ' ] ,
REDMINE_STATUSES [ ' Rejeté ' ] ,
]
2023-04-24 16:26:36 +02:00
REDMINE_TRACKERS = {
' Développement ' : 2 ,
2023-05-02 13:03:43 +02:00
' Bug ' : 1 ,
' Documentation ' : 6 ,
2023-04-24 16:26:36 +02:00
}
2022-11-02 10:35:06 +01:00
app = flask . Flask ( __name__ )
@app.route ( ' /incoming-webhook/<token> ' , methods = [ ' POST ' ] )
def incoming_webhook ( token ) :
if token != INCOMING_WEBHOOK_SECRET :
logging . warning ( ' Received invalid token ' )
return { ' status ' : ' error ' , ' detail ' : ' Invalid token ' } , 403
payload = flask . request . json
2023-04-14 14:36:19 +02:00
logging . info ( ' Received payload %s ' , json . dumps ( payload , indent = 2 ) )
2022-11-02 10:35:06 +01:00
handler , event = get_handler ( payload )
if not handler :
logging . info ( ' Skipping, no handler found for this webhook ' )
return { ' status ' : ' success ' , ' detail ' : ' Skipped, unhandled webhook ' }
2023-01-31 09:23:26 +01:00
haystack = payload [ ' pull_request ' ] [ ' title ' ]
2023-01-19 10:10:53 +01:00
try :
pr_branch = payload [ ' pull_request ' ] [ ' head ' ] [ ' ref ' ]
except KeyError :
pr_branch = ' '
2023-04-26 11:55:21 +02:00
if pr_branch . startswith ( ' hotfix/ ' ) :
logging . info ( ' Skipping, hotfix branch. ' )
return { ' status ' : ' success ' , ' detail ' : ' Skipped, hotfix branch ' }
2023-01-19 10:10:53 +01:00
issues_ids = get_issues_ids ( haystack , pr_branch )
2022-11-02 10:35:06 +01:00
issues = [ ]
for id in issues_ids :
try :
issues . append ( get_redmine_issue ( id ) )
except redminelib . exceptions . ResourceNotFoundError :
logging . warning ( f ' Unknown redmine issue { id } ' )
if not issues :
return { ' status ' : ' success ' , ' detail ' : ' Skipped, no valid redmine issues linked ' }
for issue in issues :
2023-01-18 15:36:05 +01:00
project = get_redmine_project ( issue . project . id )
2022-11-02 10:35:06 +01:00
logging . info ( f ' Calling handler { event } for issue { issue . id } … ' )
2023-01-18 15:36:05 +01:00
handler ( issue , payload , project )
2023-04-14 14:36:19 +02:00
logging . info ( ' Done ' )
2022-11-02 10:35:06 +01:00
return { ' status ' : ' success ' , ' detail ' : ' Event processed and forwarded to redmine ' }
def get_handler ( payload ) :
if payload . get ( ' action ' ) == ' opened ' and payload . get ( ' pull_request ' ) :
2022-11-22 14:16:06 +01:00
if payload [ ' pull_request ' ] [ ' title ' ] . lower ( ) . startswith ( ' wip: ' ) :
return handle_pull_request_opened_draft , ' pull_request.opened '
else :
return handle_pull_request_opened , ' pull_request.opened '
2022-11-02 10:35:06 +01:00
if payload . get ( ' action ' ) == ' reviewed ' :
if payload [ ' review ' ] [ ' type ' ] == ' pull_request_review_rejected ' :
return handle_pull_request_rejected , ' pull_request.rejected '
if payload [ ' review ' ] [ ' type ' ] == ' pull_request_review_approved ' :
return handle_pull_request_approved , ' pull_request.approved '
return None , None
2024-01-16 12:21:15 +01:00
if payload . get ( ' action ' ) == ' review_requested ' :
return handle_pull_request_review_requested , ' pull_request.review_requested '
2022-11-02 10:35:06 +01:00
if payload . get ( ' action ' ) == ' edited ' and payload . get ( ' pull_request ' ) :
return handle_pull_request_edited , ' pull_request.edited '
2024-01-16 12:21:15 +01:00
2022-11-02 10:35:06 +01:00
if (
payload . get ( ' action ' ) == ' closed '
and payload . get ( ' pull_request ' )
and payload [ ' pull_request ' ] . get ( ' merged ' )
) :
return handle_pull_request_merged , ' pull_request.merged '
2024-01-16 12:21:15 +01:00
2023-04-24 16:16:52 +02:00
if payload . get ( ' action ' ) == ' closed ' and payload . get ( ' pull_request ' ) :
return handle_pull_request_closed , ' pull_request.closed '
2024-01-16 12:21:15 +01:00
2022-11-02 10:35:06 +01:00
return None , None
ISSUE_REGEX = re . compile ( r ' #( \ d+) ' )
2023-01-19 10:10:53 +01:00
PR_BRANCH_REGEX = re . compile ( r ' wip/( \ d+).* ' )
2022-11-02 10:35:06 +01:00
2023-01-19 10:10:53 +01:00
def get_issues_ids ( text , branch_name ) :
""" Extract issues number from their #1234 notation or the wip/xxx branch name """
ids = [ int ( i ) for i in ISSUE_REGEX . findall ( text ) ]
ids + = [ int ( i ) for i in PR_BRANCH_REGEX . findall ( branch_name or ' ' ) ]
return list ( sorted ( set ( ids ) ) )
2022-11-02 10:35:06 +01:00
def noop ( payload ) :
pass
def get_redmine_user ( username ) :
for user in REDMINE_CLIENT . user . filter ( group_id = REDMINE_ASSIGNABLE_GROUP ) :
# 21 is the Entrouvert group
# We cannot easily find a redmine user through the API using
# only its username. We need to loop on possible candida
if user . login == username :
return user
raise Exception ( f ' No redmine user found for username { username } in group { REDMINE_ASSIGNABLE_GROUP } ' )
def get_redmine_issue ( id ) :
return REDMINE_CLIENT . issue . get ( id , includes = [ ' journals ' ] )
2023-01-18 15:36:05 +01:00
def get_redmine_project ( id ) :
return REDMINE_CLIENT . project . get ( id )
2022-11-02 10:35:06 +01:00
class Abort ( Exception ) :
pass
2023-01-18 15:36:05 +01:00
def is_excluded_project ( project ) :
# easiest case, the project itself is excluded
if project . identifier in REDMINE_EXCLUDED_PROJECTS :
return True
# now, if the project has a parent, we must recursively check
# if the parent is excluded as well
if hasattr ( project , ' parent ' ) and project . parent and project . parent . id :
# since some project may have grandparents, we must still check for this case
parent = get_redmine_project ( project . parent . id )
return is_excluded_project ( parent )
return False
2022-11-02 10:35:06 +01:00
def make_handler ( * actions ) :
2023-01-18 15:36:05 +01:00
def inner ( issue , payload , project ) :
if is_excluded_project ( project ) :
logging . info ( ' Issue belongs to excluded project %s ' , project . id )
2023-02-02 21:26:10 +01:00
return False
2022-11-02 10:35:06 +01:00
flat_payload = flatten ( payload )
for action in actions :
try :
action ( issue , flat_payload )
except Abort :
2023-02-02 21:26:10 +01:00
return False
return True
2022-11-02 10:35:06 +01:00
return inner
def flatten ( d , parent_key = ' ' , sep = ' _ ' ) :
items = [ ]
for k , v in d . items ( ) :
new_key = parent_key + sep + k if parent_key else k
if isinstance ( v , dict ) :
items . extend ( flatten ( v , new_key , sep = sep ) . items ( ) )
else :
items . append ( ( new_key , v ) )
return dict ( items )
def assign_to ( user_field ) :
def inner ( issue , payload ) :
redmine_user = get_redmine_user ( payload [ user_field ] )
issue . assigned_to_id = redmine_user . id
return inner
2022-11-29 14:30:47 +01:00
def set_status ( status_id , unless = [ ] ) :
2022-11-02 10:35:06 +01:00
def inner ( issue , payload ) :
2022-11-29 14:30:47 +01:00
current_status = issue . status . id
if current_status not in unless :
2023-06-12 13:06:34 +02:00
if issue . tracker . id not in list ( REDMINE_TRACKERS . values ( ) ) :
2023-05-02 13:03:43 +02:00
issue . tracker_id = REDMINE_TRACKERS [ ' Développement ' ]
2023-06-12 13:06:34 +02:00
else :
# always assigne value, to get tracker_id attribute on the object
# for the unit tests.
issue . tracker_id = issue . tracker . id
2022-11-29 14:30:47 +01:00
issue . status_id = status_id
2022-11-02 10:35:06 +01:00
return inner
def add_note ( template ) :
def inner ( issue , payload ) :
issue . notes = template . format ( * * payload )
return inner
def save ( ) :
def inner ( issue , payload ) :
issue . save ( )
return inner
def skip_if_not_draft ( issue , payload ) :
if not payload [ ' pull_request_title ' ] . lower ( ) . startswith ( ' wip: ' ) :
raise Abort ( ' Not a WIP: PR ' )
def skip_if_notes_include ( field , reason ) :
def inner ( issue , payload ) :
for entry in issue . journals :
if payload [ field ] in entry . notes :
raise Abort ( reason )
return inner
2023-02-02 21:26:10 +01:00
def skip_unless_notes_include ( text , reason ) :
def inner ( issue , payload ) :
for entry in issue . journals :
if text in entry . notes :
break
else :
raise Abort ( reason )
return inner
2022-11-02 10:35:06 +01:00
def skip_if_status ( status ) :
def inner ( issue , payload ) :
if issue . status . id == status :
raise Abort ( f ' Issue already in status { status } ' )
return inner
2023-02-02 21:26:10 +01:00
def skip_if_not_status ( status ) :
def inner ( issue , payload ) :
if issue . status . id != status :
raise Abort ( f ' Issue not in status { status } ' )
return inner
2022-11-21 14:37:05 +01:00
def set_value_if_empty ( field , value ) :
def inner ( issue , payload ) :
if not payload . get ( field ) :
payload [ field ] = value
return inner
2023-01-31 19:27:59 +01:00
2022-11-22 14:16:06 +01:00
PULL_REQUEST_OPENED_NOTE = ' {pull_request_user_full_name} ( {pull_request_user_username} ) a ouvert une pull request sur Gitea concernant cette demande : \n \n * URL : {pull_request_url} \n * Titre : {pull_request_title} \n * Modifications : {pull_request_url} /files '
2022-11-21 14:37:05 +01:00
2022-11-02 10:35:06 +01:00
handle_pull_request_opened = make_handler (
2022-11-22 14:16:06 +01:00
assign_to ( ' pull_request_user_username ' ) ,
2022-11-29 14:30:47 +01:00
set_status ( REDMINE_STATUSES [ ' Solution proposée ' ] , unless = CLOSED_STATUSES ) ,
2023-01-31 19:27:59 +01:00
add_note ( PULL_REQUEST_OPENED_NOTE ) ,
2022-11-22 14:16:06 +01:00
save ( ) ,
)
handle_pull_request_opened_draft = make_handler (
2022-11-02 10:35:06 +01:00
assign_to ( ' pull_request_user_username ' ) ,
2022-11-29 14:30:47 +01:00
set_status ( REDMINE_STATUSES [ ' En cours ' ] , unless = CLOSED_STATUSES ) ,
2023-01-31 19:27:59 +01:00
add_note ( PULL_REQUEST_OPENED_NOTE ) ,
2022-11-02 10:35:06 +01:00
save ( ) ,
)
2023-02-02 21:26:10 +01:00
def handle_pull_request_edited ( issue , payload , project ) :
if payload [ ' pull_request ' ] [ ' title ' ] . lower ( ) . startswith ( ' wip: ' ) :
func = make_handler (
skip_if_status ( REDMINE_STATUSES [ ' En cours ' ] ) ,
skip_if_not_draft ,
set_status ( REDMINE_STATUSES [ ' En cours ' ] , unless = CLOSED_STATUSES ) ,
add_note (
' {pull_request_user_full_name} ( {pull_request_user_username} ) a commencé à travailler sur une pull request sur Gitea concernant cette demande : \n \n * URL : {pull_request_url} \n * Titre : {pull_request_title} \n * Modifications : {pull_request_url} /files '
) ,
save ( ) ,
)
return func ( issue , payload , project )
# maybe it was wip and is no more
func = make_handler (
skip_if_not_status ( REDMINE_STATUSES [ ' En cours ' ] ) ,
skip_unless_notes_include ( ' Titre : WIP ' , ' Pull request do not mention a draft title ' ) ,
set_status ( REDMINE_STATUSES [ ' Solution proposée ' ] ) ,
save ( ) ,
)
if func ( issue , payload , project ) is True :
# the issue was changed, stop now.
return
# default behaviour
func = make_handler (
skip_if_notes_include ( ' pull_request_url ' , ' Pull request already linked in issue ' ) ,
assign_to ( ' pull_request_user_username ' ) ,
set_status ( REDMINE_STATUSES [ ' En cours ' ] , unless = CLOSED_STATUSES ) ,
add_note (
' {pull_request_user_full_name} ( {pull_request_user_username} ) a lié une pull request sur Gitea concernant cette demande : \n \n * URL : {pull_request_url} \n * Titre : {pull_request_title} \n * Modifications : {pull_request_url} /files '
) ,
save ( ) ,
)
return func ( issue , payload , project )
2022-11-02 10:35:06 +01:00
handle_pull_request_merged = make_handler (
2022-11-29 14:30:47 +01:00
set_status ( REDMINE_STATUSES [ ' Résolu ' ] , unless = CLOSED_STATUSES ) ,
2022-11-02 10:35:06 +01:00
add_note (
2022-11-21 14:41:42 +01:00
' {sender_full_name} ( {sender_username} ) a mergé une pull request sur Gitea concernant cette demande : \n \n * URL : {pull_request_url} \n * Titre : {pull_request_title} \n * Modifications : {pull_request_url} /files '
2022-11-02 10:35:06 +01:00
) ,
save ( ) ,
)
handle_pull_request_approved = make_handler (
2023-01-09 15:20:59 +01:00
skip_if_status ( REDMINE_STATUSES [ ' Solution validée ' ] ) ,
2022-11-29 14:30:47 +01:00
set_status ( REDMINE_STATUSES [ ' Solution proposée ' ] , unless = CLOSED_STATUSES ) ,
2022-11-02 10:35:06 +01:00
save ( ) ,
2022-11-29 14:30:47 +01:00
set_status ( REDMINE_STATUSES [ ' Solution validée ' ] , unless = CLOSED_STATUSES ) ,
2022-11-02 10:35:06 +01:00
add_note (
2022-11-29 14:48:46 +01:00
' {sender_full_name} ( {sender_username} ) a approuvé une pull request sur Gitea concernant cette demande : \n \n * URL : {pull_request_url} '
2022-11-02 10:35:06 +01:00
) ,
save ( ) ,
)
handle_pull_request_rejected = make_handler (
2022-11-29 14:30:47 +01:00
set_status ( REDMINE_STATUSES [ ' En cours ' ] , unless = CLOSED_STATUSES ) ,
2022-11-02 10:35:06 +01:00
add_note (
2022-11-29 14:48:46 +01:00
' {sender_full_name} ( {sender_username} ) a relu et demandé des modifications sur une pull request sur Gitea concernant cette demande : \n \n * URL : {pull_request_url} '
2022-11-02 10:35:06 +01:00
) ,
save ( ) ,
)
2023-04-24 16:16:52 +02:00
handle_pull_request_closed = make_handler (
set_status ( REDMINE_STATUSES [ ' En cours ' ] , unless = CLOSED_STATUSES ) ,
add_note (
2023-05-23 11:07:03 +02:00
' {sender_full_name} ( {sender_username} ) a fermé une pull request sur Gitea concernant cette demande. '
2023-04-24 16:16:52 +02:00
) ,
save ( ) ,
)
2023-06-20 19:46:26 +02:00
2024-01-16 12:21:15 +01:00
handle_pull_request_review_requested = make_handler (
set_status ( REDMINE_STATUSES [ ' Solution proposée ' ] , unless = CLOSED_STATUSES ) ,
add_note (
' {sender_full_name} ( {sender_username} ) a demandé une relecture de {requested_reviewer_full_name} ( {requested_reviewer_username} ) sur une pull request sur Gitea concernant cette demande : \n \n * URL : {pull_request_url} '
) ,
save ( ) ,
)
2023-06-20 19:46:26 +02:00
@app.route ( ' /redmine-issue/<issue_id>/ ' , methods = [ ' GET ' ] )
def redmine_issue ( issue_id ) :
try :
issue = get_redmine_issue ( issue_id )
except redminelib . exceptions . ResourceNotFoundError :
logging . warning ( ' Unknown redmine issue %s ' , issue_id )
return { ' status ' : ' error ' , ' detail ' : ' Not found ' } , 404
if issue . is_private :
logging . warning ( ' Private redmine issue %s ' , issue_id )
return { ' status ' : ' error ' , ' detail ' : ' Private issue ' } , 403
return { ' status ' : ' success ' , ' subject ' : issue . subject }