gitea-redmine/gitea_redmine.py

306 lines
9.8 KiB
Python

import functools
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)
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é'],
]
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
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 = ''
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(f' 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') == 'edited' and payload.get('pull_request'):
if payload['pull_request']['title'].lower().startswith('wip:'):
return handle_pull_request_draft, 'pull_request.draft'
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'
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
flat_payload = flatten(payload)
for action in actions:
try:
action(issue, flat_payload)
except Abort:
return
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:
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_if_status(status):
def inner(issue, payload):
if issue.status.id == status:
raise Abort(f'Issue already 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(),
)
handle_pull_request_edited = 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(),
)
handle_pull_request_draft = 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(),
)
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(),
)