398 lines
13 KiB
Python
398 lines
13 KiB
Python
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']
|
|
|
|
REDMINE_CLIENT = redminelib.Redmine(REDMINE_URL, key=REDMINE_API_KEY, raise_attr_exception=False)
|
|
REDMINE_ASSIGNABLE_GROUP = int(os.environ.get('REDMINE_ASSIGNABLE_GROUP', 21))
|
|
REDMINE_EXCLUDED_PROJECTS = [
|
|
i.strip() for i in os.environ.get('REDMINE_EXCLUDED_PROJECTS', 'projets-clients').split(',')
|
|
]
|
|
|
|
REDMINE_STATUSES = {
|
|
'En cours': 2,
|
|
'Résolu': 3,
|
|
'Solution proposée': 12,
|
|
'Solution validée': 13,
|
|
'Solution déployée': 4,
|
|
'Fermé': 5,
|
|
'Rejeté': 6,
|
|
}
|
|
CLOSED_STATUSES = [
|
|
REDMINE_STATUSES['Résolu'],
|
|
REDMINE_STATUSES['Solution déployée'],
|
|
REDMINE_STATUSES['Fermé'],
|
|
REDMINE_STATUSES['Rejeté'],
|
|
]
|
|
|
|
REDMINE_TRACKERS = {
|
|
'Développement': 2,
|
|
'Bug': 1,
|
|
'Documentation': 6,
|
|
}
|
|
|
|
|
|
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
|
|
logging.info('Received payload %s', json.dumps(payload, indent=2))
|
|
handler, event = get_handler(payload)
|
|
if not handler:
|
|
logging.info('Skipping, no handler found for this webhook')
|
|
return {'status': 'success', 'detail': 'Skipped, unhandled webhook'}
|
|
|
|
haystack = payload['pull_request']['title']
|
|
try:
|
|
pr_branch = payload['pull_request']['head']['ref']
|
|
except KeyError:
|
|
pr_branch = ''
|
|
|
|
if pr_branch.startswith('hotfix/'):
|
|
logging.info('Skipping, hotfix branch.')
|
|
return {'status': 'success', 'detail': 'Skipped, hotfix branch'}
|
|
|
|
issues_ids = get_issues_ids(haystack, pr_branch)
|
|
|
|
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:
|
|
project = get_redmine_project(issue.project.id)
|
|
logging.info(f'Calling handler {event} for issue {issue.id}…')
|
|
handler(issue, payload, project)
|
|
logging.info('Done')
|
|
|
|
return {'status': 'success', 'detail': 'Event processed and forwarded to redmine'}
|
|
|
|
|
|
def get_handler(payload):
|
|
if payload.get('action') == 'opened' and payload.get('pull_request'):
|
|
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'
|
|
|
|
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
|
|
|
|
if payload.get('action') == 'review_requested':
|
|
return handle_pull_request_review_requested, 'pull_request.review_requested'
|
|
|
|
if payload.get('action') == 'edited' and payload.get('pull_request'):
|
|
return handle_pull_request_edited, 'pull_request.edited'
|
|
|
|
if (
|
|
payload.get('action') == 'closed'
|
|
and payload.get('pull_request')
|
|
and payload['pull_request'].get('merged')
|
|
):
|
|
return handle_pull_request_merged, 'pull_request.merged'
|
|
|
|
if payload.get('action') == 'closed' and payload.get('pull_request'):
|
|
return handle_pull_request_closed, 'pull_request.closed'
|
|
|
|
return None, None
|
|
|
|
|
|
ISSUE_REGEX = re.compile(r'#(\d+)')
|
|
PR_BRANCH_REGEX = re.compile(r'wip/(\d+).*')
|
|
|
|
|
|
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)))
|
|
|
|
|
|
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'])
|
|
|
|
|
|
def get_redmine_project(id):
|
|
return REDMINE_CLIENT.project.get(id)
|
|
|
|
|
|
class Abort(Exception):
|
|
pass
|
|
|
|
|
|
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
|
|
|
|
|
|
def make_handler(*actions):
|
|
def inner(issue, payload, project):
|
|
if is_excluded_project(project):
|
|
logging.info('Issue belongs to excluded project %s', project.id)
|
|
return False
|
|
flat_payload = flatten(payload)
|
|
for action in actions:
|
|
try:
|
|
action(issue, flat_payload)
|
|
except Abort:
|
|
return False
|
|
return True
|
|
|
|
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
|
|
|
|
|
|
def set_status(status_id, unless=[]):
|
|
def inner(issue, payload):
|
|
current_status = issue.status.id
|
|
if current_status not in unless:
|
|
if issue.tracker.id not in list(REDMINE_TRACKERS.values()):
|
|
issue.tracker_id = REDMINE_TRACKERS['Développement']
|
|
else:
|
|
# always assigne value, to get tracker_id attribute on the object
|
|
# for the unit tests.
|
|
issue.tracker_id = issue.tracker.id
|
|
issue.status_id = status_id
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
def skip_if_status(status):
|
|
def inner(issue, payload):
|
|
if issue.status.id == status:
|
|
raise Abort(f'Issue already in status {status}')
|
|
|
|
return inner
|
|
|
|
|
|
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
|
|
|
|
|
|
def set_value_if_empty(field, value):
|
|
def inner(issue, payload):
|
|
if not payload.get(field):
|
|
payload[field] = value
|
|
|
|
return inner
|
|
|
|
|
|
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'
|
|
|
|
handle_pull_request_opened = make_handler(
|
|
assign_to('pull_request_user_username'),
|
|
set_status(REDMINE_STATUSES['Solution proposée'], unless=CLOSED_STATUSES),
|
|
add_note(PULL_REQUEST_OPENED_NOTE),
|
|
save(),
|
|
)
|
|
handle_pull_request_opened_draft = make_handler(
|
|
assign_to('pull_request_user_username'),
|
|
set_status(REDMINE_STATUSES['En cours'], unless=CLOSED_STATUSES),
|
|
add_note(PULL_REQUEST_OPENED_NOTE),
|
|
save(),
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
handle_pull_request_merged = make_handler(
|
|
set_status(REDMINE_STATUSES['Résolu'], unless=CLOSED_STATUSES),
|
|
add_note(
|
|
'{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'
|
|
),
|
|
save(),
|
|
)
|
|
|
|
handle_pull_request_approved = make_handler(
|
|
skip_if_status(REDMINE_STATUSES['Solution validée']),
|
|
set_status(REDMINE_STATUSES['Solution proposée'], unless=CLOSED_STATUSES),
|
|
save(),
|
|
set_status(REDMINE_STATUSES['Solution validée'], unless=CLOSED_STATUSES),
|
|
add_note(
|
|
'{sender_full_name} ({sender_username}) a approuvé une pull request sur Gitea concernant cette demande :\n\n* URL : {pull_request_url}'
|
|
),
|
|
save(),
|
|
)
|
|
|
|
handle_pull_request_rejected = make_handler(
|
|
set_status(REDMINE_STATUSES['En cours'], unless=CLOSED_STATUSES),
|
|
add_note(
|
|
'{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}'
|
|
),
|
|
save(),
|
|
)
|
|
|
|
handle_pull_request_closed = make_handler(
|
|
set_status(REDMINE_STATUSES['En cours'], unless=CLOSED_STATUSES),
|
|
add_note(
|
|
'{sender_full_name} ({sender_username}) a fermé une pull request sur Gitea concernant cette demande.'
|
|
),
|
|
save(),
|
|
)
|
|
|
|
|
|
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(),
|
|
)
|
|
|
|
|
|
@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}
|