diff --git a/helpers_git.py b/helpers_git.py new file mode 100644 index 0000000..a29838a --- /dev/null +++ b/helpers_git.py @@ -0,0 +1,47 @@ +import subprocess + +def resolve_git_ref_to_sha1(ref_name): + print(f'Resolving {ref_name} to Git SHA1...') + sha1 = subprocess.getoutput(f'git rev-parse {ref_name}') + print(f'{ref_name} = {sha1}') + return sha1 + + +def get_commit_message_for_ref(ref_name): + print(f'Getting commit message for {ref_name}...') + msg = subprocess.getoutput(f'git log -n1 --pretty=tformat:%s%b {ref_name}') + print(f'Message = {msg}') + return msg + + +def get_author_email_for_ref(ref_name): + print(f'Getting author email for {ref_name}...') + email = subprocess.getoutput(f'git --no-pager show -s --format=%ae {ref_name}') + print(f'Author email = {email}') + return email + + +def get_committer_email_for_ref(ref_name): + print(f'Getting committer email for {ref_name}...') + email = subprocess.getoutput(f'git --no-pager show -s --format=%ce {ref_name}') + print(f'Committer email = {email}') + return email + + +def generate_diff(base_branch, current_commit_id, repo_url=''): + remote_name = subprocess.getoutput('git remote') + if remote_name == '': + raise Exception(f'Can not get remote name. Out put of git remote command is: {remote_name}') + base_sha1 = resolve_git_ref_to_sha1(f'{remote_name}/{base_branch}') + diff = f'{base_sha1}..{current_commit_id}' + + # Add a list of commits that you are about to promote + cmd = f'git log --pretty=format:"%h %<(27)%ai %<(20)%an %s" --graph {diff}' + diff_info = f'Change log for changes to approve:\n```\n{cmd}\n' + diff_info += subprocess.getoutput(cmd) + diff_info += '\n```\n\n' + + if 'github' in repo_url or 'gitlab' in repo_url: + diff_info += f'\nFull diff for changes to approve: {repo_url}/compare/{diff}\n\n' + + return diff_info \ No newline at end of file diff --git a/helpers_slack.py b/helpers_slack.py new file mode 100644 index 0000000..3854b2e --- /dev/null +++ b/helpers_slack.py @@ -0,0 +1,44 @@ +import os + +from slack_bolt import App +from slack_sdk.errors import SlackApiError + +def init_app(slack_bot_token, approve_action_id, cancel_action_id): + app = App(token=slack_bot_token) + + @app.action(approve_action_id) + def approve_request(ack, respond, body): + # Acknowledge action request + ack() + print(body) + username = body['user']['username'] + original_text = body['message']['blocks'][0]['text']['text'] + respond(original_text + f'\n\nApproved by {username}') + os._exit(0) + + @app.action(cancel_action_id) + def approve_request(ack, respond, body): + # Acknowledge action request + ack() + print(body) + username = body['user']['username'] + original_text = body['message']['blocks'][0]['text']['text'] + respond(original_text + f'\n\nCanceled by {username}') + os._exit(1) + + @app.middleware + def middleware_func(logger, body, next): + logger.info(f"request body: {body}") + next() + + return app + +def user_id_by_email(app, email): + try: + result = app.client.users_lookupByEmail(email=email) + return result['user']['id'] + except SlackApiError as err: + if err.response['error'] == 'users_not_found': + return None + + return None \ No newline at end of file diff --git a/helpers_time.py b/helpers_time.py new file mode 100644 index 0000000..989b5d3 --- /dev/null +++ b/helpers_time.py @@ -0,0 +1,33 @@ +import pytz +from datetime import datetime + +def is_business_hours(timezone): + tz = pytz.timezone(timezone) + now = datetime.now(tz) + return True if now.hour > 8 and now.hour < 19 else False + + +def is_friday_evening(timezone): + tz = pytz.timezone(timezone) + now = datetime.now(tz) + return True if now.isoweekday() == 5 and now.hour > 14 else False + + +def current_time(timezone): + tz = pytz.timezone(timezone) + now = datetime.now(tz) + return "{}:{}:{}".format(now.hour, now.minute, now.second) + + +def generate_time_based_message(prod_branch, branches, timezone): + if prod_branch not in ' '.join(branches): + return '' + message = f'\nIt is {current_time()} in {timezone}.' + if is_friday_evening(): + return (message + + ' Deploying to production during Friday afternoon hours?' + + ' *This a sure way to screw up your evening and potentialy weekend!*\n' + + ' Make sure you are around to deal with consecuences') + if is_business_hours(): + return message + ' *Business hours - think twice before deploying to production!*\n' + return message + ' A good time to attempt deploy\n' \ No newline at end of file diff --git a/main.py b/main.py index 670fd0e..e0e0ddb 100644 --- a/main.py +++ b/main.py @@ -1,59 +1,70 @@ import os -import logging -from time import sleep +import uuid +import helpers_slack +import helpers_git +import helpers_time +from time import sleep, timezone -from slack_bolt import App, logger from slack_bolt.adapter.socket_mode import SocketModeHandler -from slack_sdk.web import client -# Install the Slack app and get xoxb- token in advance -app = App(token=os.environ["SLACK_BOT_TOKEN"]) -# ID of channel you want to post message to -channel_name = "magic-button-test" #TODO: os.environ["SLACK_CHANNEL_NAME"] +if __name__ == "__main__": -# GitHub Project Name -github_project_name = "test-api" #TODO: os.environ["GIT_REPO_NAME"] + # Get job parameters + build_job_name = os.environ['BUILD_JOB_NAME'] + build_job_url = os.environ['BUILD_JOB_URL'] + current_commit_id = os.environ['CURRENT_GIT_COMMIT'] + repo_name = os.environ['REPOSITORY_NAME'] + repo_url = os.environ['REPOSITORY_URL'] + slack_channel_name = os.environ['SLACK_CHANNEL_NAME'] + branches_to_promote = os.environ['BRANCHES_TO_PROMOTE'].split() + timeout_minutes = int(os.environ['TIMEOUT_MINUTES']) + timezone = os.environ['TIMEZONE'] + production_branch = os.environ['PRODUCTION_BRANCH'] + slack_bot_token = os.environ["SLACK_BOT_TOKEN"] -@app.action(github_project_name + "_yes") -def approve_request(ack, say): - # Acknowledge action request - ack() - say("Great! I will do! Request approved 👍 !") - #TODO: Replase existing message and remove buttons for prevert second click - os._exit(0) + ok_id = uuid.uuid4().hex + nok_id = uuid.uuid4().hex + app = helpers_slack.init_app(slack_bot_token, ok_id, nok_id) -@app.action(github_project_name + "_no") -def approve_request(ack, say): - # Acknowledge action request - ack() - say("Up to you! Request NOT approved :thumbsdown: !") - #TODO: Replase existing message and remove buttons for prevert second click - os._exit(1) + committer_email = helpers_git.get_committer_email_for_ref(current_commit_id) + committer_slack_id = helpers_slack.user_id_by_email(app, committer_email) + commiter_id = committer_slack_id if committer_slack_id is not None else committer_email + author_email = helpers_git.get_author_email_for_ref(current_commit_id) + author_slack_id = helpers_slack.user_id_by_email(app, author_email) + author_id = author_slack_id if author_slack_id is not None else author_email + commit_msg = helpers_git.get_commit_message_for_ref(current_commit_id) + text_for_request = f'Job `{build_job_name}` requires approval to proceed.\n' + text_for_request += 'If approved will promote commit(s) below to branch ' + text_for_request += ' and '.join(f'`{branch}`' for branch in branches_to_promote) + text_for_request += f' in repository `{repo_name}`' + text_for_request += f'\nJob will be autocanceled in {timeout_minutes} minutes if no action taken.\n\n' + text_for_request += 'Details:\n' + text_for_request += f'Job URL: {build_job_url}\n' + text_for_request += f'Commit message: `{commit_msg}`; commit id `{current_commit_id}`\n\n' + text_for_request += f'Committer: <@{commiter_id}>\n' + text_for_request += f'Author: <@{author_id}>\n\n' -@app.middleware -def middleware_func(logger, body, next): - logger.info(f"request body: {body}") - next() + for branch in branches_to_promote: + text_for_request += helpers_git.generate_diff(branch, current_commit_id, repo_url) + text_for_request += helpers_time.generate_time_based_message(production_branch, branches_to_promote, timezone) + + # Truncate too long messages to prevent Slack from posting them as several messages + # We saw Slack splitting message into two after 3800 but haven't found any documentation + # So number is more or less made up and needs further verification. + if len(text_for_request) > 3500: + text_for_request = text_for_request[:3500] + text_for_request += '\ntoo long message - the rest was truncated. Use link above to see full diff' -if __name__ == "__main__": - # message = app.client.chat_postMessage( - # channel=channel_id, - # text="Test Message" - # ) - # print(message) - # logging.info(f'Message resp: {message}') - # SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"], trace_enabled=True).connect() blocks_json = [ { "type": "section", "text": { - "type": "plain_text", - "text": f'Do you want to deploy project {github_project_name}? I will wait 30s', - "emoji": True + "type": "mrkdwn", + "text": text_for_request } }, { @@ -63,12 +74,12 @@ def middleware_func(logger, body, next): "type": "button", "text": { "type": "plain_text", - "text": "Yes", + "text": "Approve", "emoji": True }, "value": "click_me_123", "style": "primary", - "action_id": github_project_name + "_yes", + "action_id": ok_id, "confirm": { "title": { "type": "plain_text", @@ -92,21 +103,21 @@ def middleware_func(logger, body, next): "type": "button", "text": { "type": "plain_text", - "text": "No", + "text": "Cancel", "emoji": True }, "value": "click_me_123", "style": "danger", - "action_id": github_project_name + "_no" + "action_id": nok_id } ] } ] message_deploy = app.client.chat_postMessage( - channel=channel_name, + channel=slack_channel_name, text="My Text", blocks=blocks_json ) - sleep(30) # Time in seconds + sleep(timeout_minutes*60) # Time in seconds SocketModeHandler(app).close() diff --git a/requirements.txt b/requirements.txt index c338e46..5dd88bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -slack_bolt == 1.9.0 \ No newline at end of file +slack_bolt == 1.9.0 +pytz==2021.1 \ No newline at end of file diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..2a44f26 --- /dev/null +++ b/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +export BUILD_JOB_NAME=local-test +export BUILD_JOB_URL=http://whatever.com +export CURRENT_GIT_COMMIT=$(git rev-parse HEAD) +export REPOSITORY_NAME=$(basename $(git rev-parse --show-toplevel)) +export REPOSITORY_URL=https://github.com/fivexl/magic-button +export BRANCHES_TO_PROMOTE=test +export TIMEOUT_MINUTES=10 +export TIMEZONE=$(cat /etc/timezone) +export PRODUCTION_BRANCH=release +export SLACK_CHANNEL_NAME=magic-button-test + +python3 main.py \ No newline at end of file