diff --git a/.github/workflows/ci-on_pr_main_bash.yml b/.github/workflows/ci-on_pr_main_bash.yml index 620c182..c3fe5c0 100644 --- a/.github/workflows/ci-on_pr_main_bash.yml +++ b/.github/workflows/ci-on_pr_main_bash.yml @@ -23,6 +23,7 @@ env: TBTT_OVH_APP_SECRET: b TBTT_OVH_CONSUMER_KEY: c TBTT_GODP_AUTH_TOKEN: d + TBTT_NOTION_API_KEY: e jobs: lint-and-test: diff --git a/config/github.json b/config/github.json index bafe66f..be6e3c8 100644 --- a/config/github.json +++ b/config/github.json @@ -19,5 +19,17 @@ "board_views": { "default": 3, "dev-team-escalation": 3 + }, + "repoNameToReadable": { + "TB-TT": "TB-TT", + "nodejs-treeshaker": "RUCSS", + "saas-director": "SaaS Director", + "monies": "Monies", + "imagify": "Imagify app", + "rocket-cdn": "RocketCDN", + "wp-rocket.me": "WP Rocket website", + "imagify-website": "Imagify website", + "rocketcdn-website": "RocketCDN website" + } } diff --git a/config/notion.json b/config/notion.json new file mode 100644 index 0000000..27e6790 --- /dev/null +++ b/config/notion.json @@ -0,0 +1,3 @@ +{ + "release-note-db-id": "9bf76ffc56074d1d9ca81b65ce00f4da" +} \ No newline at end of file diff --git a/config/slack.json b/config/slack.json index d92e6c4..f3d5979 100644 --- a/config/slack.json +++ b/config/slack.json @@ -1,3 +1,6 @@ { - "dev-team-escalation-channel": "C056ZJMHG0P" + "dev-team-escalation-channel": "C056ZJMHG0P", + "engineering-service-team-channel": "C069W48E47N", + "release-channel" : "C05PGTQHHJ9", + "ops-channel": "C88N0811V" } \ No newline at end of file diff --git a/sources/TechTeamBot.py b/sources/TechTeamBot.py index 41ba53d..e18c735 100644 --- a/sources/TechTeamBot.py +++ b/sources/TechTeamBot.py @@ -47,6 +47,7 @@ def __setup_keys(self): self.__load_key("TBTT_OVH_APP_SECRET", cst.APP_CONFIG_TOKEN_OVH_APP_SECRET) self.__load_key("TBTT_OVH_CONSUMER_KEY", cst.APP_CONFIG_TOKEN_OVH_CONSUMER_KEY) self.__load_key("TBTT_GODP_AUTH_TOKEN", cst.APP_CONFIG_TOKEN_GODP_AUTH_TOKEN) + self.__load_key("TBTT_NOTION_API_KEY", cst.APP_CONFIG_TOKEN_NOTION_API_KEY) def __setup_slack_interaction_endpoint(self): """ diff --git a/sources/factories/NotionFactory.py b/sources/factories/NotionFactory.py new file mode 100644 index 0000000..516d117 --- /dev/null +++ b/sources/factories/NotionFactory.py @@ -0,0 +1,132 @@ +""" + This module defines the factory for Notion API +""" +from datetime import date +import json +from pathlib import Path +import requests +from flask import current_app +import sources.utils.Constants as cst +from sources.models.GithubReleaseParam import GithubReleaseParam + + +class NotionFactory(): + """ + Class managing the API for OVH + + """ + def __init__(self): + """ + The factory instanciates the objects it needed to complete the processing of the request. + """ + self.api_key = None + with open(Path(__file__).parent.parent.parent / "config" / "notion.json", encoding='utf-8') as file_notion_config: + self.notion_config = json.load(file_notion_config) + + def _get_notion_api_key(self, app_context): + """ + Return the Notion API key and creates it if needed + """ + if self.api_key is None: + app_context.push() + self.api_key = current_app.config[cst.APP_CONFIG_TOKEN_NOTION_API_KEY] + return self.api_key + + def _create_notion_db_row(self, app_context, db_id, properties, children): + """ + Requests the creation of a row in a Notion DB through the API. + Properties should match the DB columns, and children is the content of the created page. + """ + headers = { + 'Authorization': 'Bearer ' + self._get_notion_api_key(app_context), + 'Content-Type': 'application/json', + 'Notion-Version': '2022-06-28', + } + + data = { + 'parent': {'database_id': db_id}, + 'properties': properties, + 'children': children + } + + response = requests.post( + 'https://api.notion.com/v1/pages', + headers=headers, + json=data, + timeout=3000 + ) + + if response.status_code != 200: + raise ValueError('Notion API could not create the DB row.') + page_id = response.json().get('id') + page_url = f'https://www.notion.so/{page_id}' + return page_url + + def create_release_note(self, app_context, release_params: GithubReleaseParam): + """ + Creates a release note in Notion for a GitHub release. + """ + today = date.today() + + properties = { + "Version": { + "title": [ + { + "text": { + "content": release_params.version + } + } + ] + }, + "Product": { + "select": { + "name": release_params.repository_name + } + }, + 'date_property': { + 'date': { + 'start': today.strftime("%Y-%m-%d") + } + } + } + + content = [ + { + "object": "block", + "heading_2": { + "rich_text": [ + { + "text": { + "content": "Complete Changelog" + } + } + ] + } + }, + { + "object": "block", + "paragraph": { + "rich_text": [ + { + "text": { + "content": release_params.body + }, + } + ], + "color": "default" + } + }, + { + "object": "block", + "heading_2": { + "rich_text": [ + { + "text": { + "content": "User notes" + } + } + ] + } + } + ] + return self._create_notion_db_row(app_context, self.notion_config["release-note-db-id"], properties, content) diff --git a/sources/factories/SlackMessageFactory.py b/sources/factories/SlackMessageFactory.py index 366fdc6..b22dfad 100644 --- a/sources/factories/SlackMessageFactory.py +++ b/sources/factories/SlackMessageFactory.py @@ -24,7 +24,7 @@ def __init__(self): self.update_message_url = 'https://slack.com/api/chat.update' self.search_message_url = 'https://slack.com/api/search.messages' - def post_message(self, app_context, channel, text): + def post_message(self, app_context, channel, text, blocks=None): """ Sends a message 'text' to the 'channel' as the app. """ @@ -33,6 +33,10 @@ def post_message(self, app_context, channel, text): request_open_view_payload = {} request_open_view_payload['channel'] = channel request_open_view_payload['text'] = text + + if blocks is not None: + request_open_view_payload['blocks'] = blocks + result = requests.post(url=self.post_message_url, headers=request_open_view_header, json=request_open_view_payload, timeout=3000) @@ -99,4 +103,43 @@ def get_channel(self, flow): """ if 'dev-team-escalation' == flow: return self.slack_config["dev-team-escalation-channel"] + if 'engineering-service-team' == flow: + return self.slack_config["engineering-service-team-channel"] + if 'releases' == flow: + return self.slack_config["release-channel"] + if 'ops' == flow: + return self.slack_config["ops-channel"] raise ValueError('Unknown flow for get_channel.') + + def get_release_note_review_blocks(self, text): + """ + Build the interactive messages for the release note publication flow + """ + blocks = [ + { + "type": "section", + "text": { + "type": "plain_text", + "text": text, + "emoji": True + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Publish the release note in the release channel?" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Yes!", + "emoji": True + }, + "value": text, + "action_id": "publish-release-note" + } + } + ] + return blocks diff --git a/sources/handlers/DeployHandler.py b/sources/handlers/DeployHandler.py index cacf729..7ac5bce 100644 --- a/sources/handlers/DeployHandler.py +++ b/sources/handlers/DeployHandler.py @@ -2,9 +2,9 @@ This module defines the handler for deployment with the group.One Deploy Proxy This handler is just a API call factory, as there is no special business logic. """ +import json import requests from flask import current_app -import json import sources.utils.Constants as cst from sources.models.DeployHandlerParam import DeployHandlerParam @@ -32,6 +32,9 @@ def _get_godp_token(self, app_context): return self.__godp_token def deploy_commit(self, app_context, task_params: DeployHandlerParam): + """ + Triggers a deployment by calling the GODP API endpoint + """ app_context.push() request_header = {"Content-type": "application/json", "Authorization": "Bearer " + self._get_godp_token(app_context)} diff --git a/sources/handlers/GithubReleaseHandler.py b/sources/handlers/GithubReleaseHandler.py new file mode 100644 index 0000000..a11f4a5 --- /dev/null +++ b/sources/handlers/GithubReleaseHandler.py @@ -0,0 +1,42 @@ +""" + This module defines the handler for GitHub Release related logic. +""" +import json +from pathlib import Path +from sources.factories.SlackMessageFactory import SlackMessageFactory +from sources.factories.NotionFactory import NotionFactory + + +class GithubReleaseHandler(): + """ + Class managing the business logic related to Github releases + + """ + def __init__(self): + """ + The handler instanciates the objects it needed to complete the processing of the request. + """ + self.slack_message_factory = SlackMessageFactory() + self.notion_factory = NotionFactory() + with open(Path(__file__).parent.parent.parent / "config" / "github.json", encoding='utf-8') as file_github_config: + self.github_config = json.load(file_github_config) + + def process_release(self, app_context, release_params): + """ + Processing method when a github release is released + """ + # Replace the repository name by its readable name + repository_readable_name = self.github_config["repoNameToReadable"][release_params.repository_name] + release_params.repository_name = repository_readable_name + # Create a page in the Notion database + notion_url = self.notion_factory.create_release_note(app_context, release_params) + + # Send a message to Slack + text = "The draft release note for " + repository_readable_name + " " + release_params.version + text += " is available on Notion: " + notion_url + + blocks = self.slack_message_factory.get_release_note_review_blocks(text) + + self.slack_message_factory.post_message(app_context, + self.slack_message_factory.get_channel('ops'), + text, blocks) diff --git a/sources/handlers/GithubWebhookHandler.py b/sources/handlers/GithubWebhookHandler.py index 2b71ff8..8551dfe 100644 --- a/sources/handlers/GithubWebhookHandler.py +++ b/sources/handlers/GithubWebhookHandler.py @@ -6,6 +6,8 @@ from pathlib import Path from flask import current_app from sources.handlers.GithubTaskHandler import GithubTaskHandler +from sources.handlers.GithubReleaseHandler import GithubReleaseHandler +from sources.models.GithubReleaseParam import GithubReleaseParam class GithubWebhookHandler(): @@ -18,6 +20,7 @@ def __init__(self): The handler instanciates the objects it needed to complete the processing of the request. """ self.github_project_item_handler = GithubTaskHandler() + self.github_release_handler = GithubReleaseHandler() with open(Path(__file__).parent.parent.parent / "config" / "github.json", encoding='utf-8') as file_github_config: self.github_config = json.load(file_github_config) @@ -28,6 +31,8 @@ def process(self, payload_json): """ if "projects_v2_item" in payload_json: self.project_v2_item_update_callback(payload_json) + elif "release" in payload_json: + self.release_callback(payload_json) else: raise ValueError('Unknown webhook payload.') return {} @@ -53,3 +58,28 @@ def project_v2_item_update_callback(self, payload_json): "app_context": current_app.app_context(), "node_id": node_id}) current_app.logger.info("project_v2_item_update_callback: Starting processing thread...") thread.start() + + def release_callback(self, payload_json): + """ + Callback for webhooks linked to a Github release. + Filter out irrelevant webhooks. + Retrieve the relevant data in paylaod and start a thread for further processing + """ + # Keep only released actions + if "action" not in payload_json or "released" != payload_json["action"]: + current_app.logger.info("release_callback: Not a released release action.") + return + # Keep only changes on status or assignees + repository_name = payload_json["repository"]["name"] + if repository_name not in self.github_config["repoNameToReadable"]: + current_app.logger.info("release_callback: Release not linked to a tracked repository.") + return + + version = payload_json["release"]["tag_name"] + body = payload_json["release"]["body"] + + release_params = GithubReleaseParam(repository_name, version, body) + thread = Thread(target=self.github_release_handler.process_release, kwargs={ + "app_context": current_app.app_context(), "release_params": release_params}) + current_app.logger.info("release_callback: Starting processing thread...") + thread.start() diff --git a/sources/handlers/SlackBlockActionHandler.py b/sources/handlers/SlackBlockActionHandler.py new file mode 100644 index 0000000..89e4725 --- /dev/null +++ b/sources/handlers/SlackBlockActionHandler.py @@ -0,0 +1,44 @@ +""" + This module define the handler for Slack shortcuts. +""" + + +from flask import current_app +from sources.factories.SlackMessageFactory import SlackMessageFactory + + +class SlackBlockActionHandler(): + """ + Class to handle Slack block actions calls received by the app. + """ + + def __init__(self): + """ + The handler instanciates the objects it needed to complete the processing of the request. + """ + self.slack_message_factory = SlackMessageFactory() + + def process(self, payload_json): + """ + Method called to process a request of type "shortcut". It identifies the callback assigned to the Slack shortcut + and routes the request according to it to the right callback method. + """ + + # Retrieve the shortcut callback + callback = payload_json['action']['action_id'] + + # Process the paylaod according to the callback + if 'publish-release-note' == callback: + self.publish_release_note_callback(payload_json) + else: + raise ValueError('Unknown action callback.') + return {} + + def publish_release_note_callback(self, payload_json): + """ + Callback method to process the Slack action "publish-release-note" + The action value should be sent as a message to the release channel. + """ + self.slack_message_factory.post_message(current_app.app_context(), + self.slack_message_factory.get_channel('releases'), + payload_json['action']['value']) diff --git a/sources/listeners/SlackInteractionListener.py b/sources/listeners/SlackInteractionListener.py index f2d1b0e..fc7a2ad 100644 --- a/sources/listeners/SlackInteractionListener.py +++ b/sources/listeners/SlackInteractionListener.py @@ -8,6 +8,7 @@ from flask import request from sources.handlers.SlackShortcutHandler import SlackShortcutHandler from sources.handlers.SlackViewSubmissionHandler import SlackViewSubmissionHandler +from sources.handlers.SlackBlockActionHandler import SlackBlockActionHandler class SlackInteractionListener(): @@ -21,6 +22,7 @@ def __init__(self): """ self.slack_shortcut_handler = SlackShortcutHandler() self.slack_view_submission_handler = SlackViewSubmissionHandler() + self.slack_block_action_handler = SlackBlockActionHandler() @slack_sig_auth def __call__(self): @@ -42,6 +44,8 @@ def __call__(self): response_payload = self.slack_view_submission_handler.process(payload_json) elif 'shortcut' == payload_type: response_payload = self.slack_shortcut_handler.process(payload_json) + elif 'block_actions' == payload_type: + response_payload = self.slack_block_action_handler.process(payload_json) else: raise ValueError('Unknown payload type.') except ValueError as error: diff --git a/sources/models/GithubReleaseParam.py b/sources/models/GithubReleaseParam.py new file mode 100644 index 0000000..7a59e95 --- /dev/null +++ b/sources/models/GithubReleaseParam.py @@ -0,0 +1,15 @@ +""" + Defiens a dataclass for the parameters returned by Github after a release through webhook +""" + +from dataclasses import dataclass + + +@dataclass +class GithubReleaseParam: + """ + Dataclass for all the parameters of a GitHub release + """ + repository_name: str + version: str + body: str diff --git a/sources/utils/Constants.py b/sources/utils/Constants.py index bb73752..04e0a56 100644 --- a/sources/utils/Constants.py +++ b/sources/utils/Constants.py @@ -11,3 +11,4 @@ APP_CONFIG_TOKEN_OVH_APP_SECRET = 'OVH_APP_SECRET' APP_CONFIG_TOKEN_OVH_CONSUMER_KEY = 'OVH_CONSUMER_KEY' APP_CONFIG_TOKEN_GODP_AUTH_TOKEN = 'GODP_AUTH_TOKEN' +APP_CONFIG_TOKEN_NOTION_API_KEY = 'NOTION_API_KEY'