gitea-redmine/gitea_redmine.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

398 lines
13 KiB
Python
Raw Normal View History

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 = [
2023-01-31 19:27:59 +01:00
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
2023-04-14 14:36:19 +02:00
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)
2023-04-14 14:36:19 +02:00
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
2024-01-16 12:21:15 +01:00
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'
2024-01-16 12:21:15 +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
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
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
2023-01-31 19:27:59 +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'
handle_pull_request_opened = make_handler(
assign_to('pull_request_user_username'),
set_status(REDMINE_STATUSES['Solution proposée'], unless=CLOSED_STATUSES),
2023-01-31 19:27:59 +01:00
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),
2023-01-31 19:27:59 +01:00
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(),
)
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(),
)
@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}