From adda4dacbae6ad6ddeaf1f53bdc15fc919539247 Mon Sep 17 00:00:00 2001 From: Guillaume Charest <1690085+gcharest@users.noreply.github.com> Date: Wed, 7 Aug 2024 18:59:57 -0400 Subject: [PATCH] Feat/isolate incident roles, docs and folders concept into modules (#617) * feat: split folder related functions into own module * feat: migrate folder related functions into own module * fix: remove commented code * chore: fmt * fix: add unit tests for update spreadsheet incident status * fix: remove alias * feat: migrate roles functions to own module * fix: update docstring for additional context * feat: migrate docs related functions into own module * chore: fmt --- app/modules/incident/incident_document.py | 43 ++ app/modules/incident/incident_folder.py | 246 +++++++++++ app/modules/incident/incident_helper.py | 396 +----------------- app/modules/incident/incident_roles.py | 124 ++++++ .../incident/test_incident_document.py | 114 +++++ .../modules/incident/test_incident_folder.py | 245 +++++++++++ .../modules/incident/test_incident_helper.py | 336 +++------------ .../modules/incident/test_incident_roles.py | 96 +++++ 8 files changed, 941 insertions(+), 659 deletions(-) create mode 100644 app/modules/incident/incident_document.py create mode 100644 app/modules/incident/incident_folder.py create mode 100644 app/modules/incident/incident_roles.py create mode 100644 app/tests/modules/incident/test_incident_document.py create mode 100644 app/tests/modules/incident/test_incident_folder.py create mode 100644 app/tests/modules/incident/test_incident_roles.py diff --git a/app/modules/incident/incident_document.py b/app/modules/incident/incident_document.py new file mode 100644 index 00000000..e45c074f --- /dev/null +++ b/app/modules/incident/incident_document.py @@ -0,0 +1,43 @@ +"""Module to manage the incident document used to track the details.""" + +from integrations.google_workspace import google_docs + + +def update_incident_document_status(document_id, new_status="Closed"): + """Update the status of the incident document. + + Args: + document_id (str): The ID of the document to update. + new_status (str, optional): The new status to set. Defaults to "Closed". + + Returns: + bool: True if the status was updated, False otherwise. + """ + # List of possible statuses to be replaced + possible_statuses = [ + "In Progress", + "Open", + "Ready to be Reviewed", + "Reviewed", + "Closed", + ] + + if new_status not in possible_statuses: + raise ValueError(f"Invalid status: {new_status}") + + # Replace all possible statuses with the new status + changes = [ + { + "replaceAllText": { + "containsText": {"text": f"Status: {status}", "matchCase": "false"}, + "replaceText": f"Status: {new_status}", + } + } + for status in possible_statuses + if status != new_status + ] + replies = google_docs.batch_update(document_id, changes)["replies"] + return any( + reply.get("replaceAllText", {}).get("occurrencesChanged", 0) > 0 + for reply in replies + ) diff --git a/app/modules/incident/incident_folder.py b/app/modules/incident/incident_folder.py new file mode 100644 index 00000000..c9cb5fe4 --- /dev/null +++ b/app/modules/incident/incident_folder.py @@ -0,0 +1,246 @@ +"""Module for managing SRE incident folders in Google Drive. + +Includes functions to manage the folders, the metadata, and the list of incidents in a Google Sheets spreadsheet. +""" + +import os +from slack_sdk.web import WebClient +from slack_bolt import Ack +from integrations.google_workspace import google_drive, sheets +import logging + +SRE_INCIDENT_FOLDER = os.environ.get("SRE_INCIDENT_FOLDER") +INCIDENT_LIST = os.environ.get("INCIDENT_LIST") + + +def list_folders(client: WebClient, body, ack: Ack): + ack() + folders = google_drive.list_folders_in_folder( + SRE_INCIDENT_FOLDER, "not name contains 'Templates'" + ) + folders.sort(key=lambda x: x["name"]) + blocks = { + "type": "modal", + "callback_id": "list_folders_view", + "title": {"type": "plain_text", "text": "SRE - Listing folders"}, + "close": {"type": "plain_text", "text": "Close"}, + "blocks": [ + item for sublist in list(map(folder_item, folders)) for item in sublist + ], + } + client.views_open(trigger_id=body["trigger_id"], view=blocks) + + +def delete_folder_metadata(client: WebClient, body, ack): + ack() + folder_id = body["view"]["private_metadata"] + key = body["actions"][0]["value"] + response = google_drive.delete_metadata(folder_id, key) + if not response: + logging.info(f"Failed to delete metadata `{key}` for folder `{folder_id}`") + else: + logging.info(f"Deleted metadata for key `{key}`") + body["actions"] = [{"value": folder_id}] + view_folder_metadata(client, body, ack) + + +def save_metadata(client: WebClient, body, ack, view): + ack() + folder_id = view["private_metadata"] + key = view["state"]["values"]["key"]["key"]["value"] + value = view["state"]["values"]["value"]["value"]["value"] + google_drive.add_metadata(folder_id, key, value) + body["actions"] = [{"value": folder_id}] + del body["view"] + view_folder_metadata(client, body, ack) + + +def view_folder_metadata(client, body, ack): + ack() + folder_id = body["actions"][0]["value"] + logging.info(f"Viewing metadata for folder {folder_id}") + folder = google_drive.list_metadata(folder_id) + blocks = { + "type": "modal", + "callback_id": "view_folder_metadata_modal", + "title": {"type": "plain_text", "text": "SRE - Showing metadata"}, + "submit": {"type": "plain_text", "text": "Return to folders"}, + "private_metadata": folder_id, + "blocks": ( + [ + { + "type": "section", + "text": { + "type": "plain_text", + "text": folder["name"], + }, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Add metadata"}, + "value": folder_id, + "action_id": "add_folder_metadata", + }, + }, + {"type": "divider"}, + ] + + metadata_items(folder) + ), + } + if "view" in body: + client.views_update( + view_id=body["view"]["id"], + view=blocks, + ) + else: + client.views_open(trigger_id=body["trigger_id"], view=blocks) + + +def add_folder_metadata(client: WebClient, body, ack): + ack() + folder_id = body["actions"][0]["value"] + blocks = { + "type": "modal", + "callback_id": "add_metadata_view", + "title": {"type": "plain_text", "text": "SRE - Add metadata"}, + "submit": {"type": "plain_text", "text": "Save metadata"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "private_metadata": folder_id, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Add metadata*", + }, + }, + { + "type": "input", + "block_id": "key", + "element": { + "type": "plain_text_input", + "action_id": "key", + "placeholder": {"type": "plain_text", "text": "Key"}, + }, + "label": { + "type": "plain_text", + "text": "Key", + }, + }, + { + "type": "input", + "block_id": "value", + "element": { + "type": "plain_text_input", + "action_id": "value", + "placeholder": {"type": "plain_text", "text": "Value"}, + }, + "label": { + "type": "plain_text", + "text": "Value", + }, + }, + ], + } + client.views_update( + view_id=body["view"]["id"], + view=blocks, + ) + + +def folder_item(folder): + return [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*{folder['name']}*"}, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Manage metadata", + "emoji": True, + }, + "value": f"{folder['id']}", + "action_id": "view_folder_metadata", + }, + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"", + } + ], + }, + {"type": "divider"}, + ] + + +def metadata_items(folder): + if "appProperties" not in folder or len(folder["appProperties"]) == 0: + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*No metadata found. Click the button above to add metadata.*", + }, + }, + ] + else: + return [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{key}*\n{value}", + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Delete metadata", + "emoji": True, + }, + "value": key, + "style": "danger", + "action_id": "delete_folder_metadata", + }, + } + for key, value in folder["appProperties"].items() + ] + + +def update_spreadsheet_incident_status(channel_name, status="Closed"): + """Update the status of an incident in the incident list spreadsheet. + + Args: + channel_name (str): The name of the channel to update. + status (str): The status to update the incident to. + + Returns: + bool: True if the status was updated successfully, False otherwise. + """ + valid_statuses = ["Open", "Closed", "In Progress", "Resolved"] + if status not in valid_statuses: + logging.warning("Invalid status %s", status) + return False + sheet_name = "Sheet1" + sheet = sheets.get_values(INCIDENT_LIST, range=sheet_name) + values = sheet.get("values", []) + if len(values) == 0: + logging.warning("No incident found for channel %s", channel_name) + return False + # Find the row with the search value + for i, row in enumerate(values): + if channel_name in row: + # Update the 4th column (index 3) of the found row + update_range = ( + f"{sheet_name}!D{i+1}" # Column D, Rows are 1-indexed in Sheets + ) + updated_sheet = sheets.batch_update_values( + INCIDENT_LIST, update_range, [[status]] + ) + if updated_sheet: + return True + return False diff --git a/app/modules/incident/incident_helper.py b/app/modules/incident/incident_helper.py index 2b9ce2c6..474d0404 100644 --- a/app/modules/incident/incident_helper.py +++ b/app/modules/incident/incident_helper.py @@ -3,16 +3,14 @@ import os from slack_sdk import WebClient from slack_bolt import Ack, Respond, App -from integrations.google_workspace import google_docs, google_drive, sheets +from integrations.google_workspace import google_docs, google_drive from integrations.slack import channels as slack_channels from integrations.sentinel import log_to_sentinel -from modules.incident import schedule_retro +from . import incident_folder, incident_roles, incident_document, schedule_retro INCIDENT_CHANNELS_PATTERN = r"^incident-\d{4}-" SRE_DRIVE_ID = os.environ.get("SRE_DRIVE_ID") SRE_INCIDENT_FOLDER = os.environ.get("SRE_INCIDENT_FOLDER") -INCIDENT_TEMPLATE = os.environ.get("INCIDENT_TEMPLATE") -INCIDENT_LIST = os.environ.get("INCIDENT_LIST") START_HEADING = "DO NOT REMOVE this line as the SRE bot needs it as a placeholder." END_HEADING = "Trigger" @@ -43,13 +41,13 @@ def register(bot: App): - bot.action("add_folder_metadata")(add_folder_metadata) - bot.action("view_folder_metadata")(view_folder_metadata) - bot.view("view_folder_metadata_modal")(list_folders) - bot.view("add_metadata_view")(save_metadata) - bot.action("delete_folder_metadata")(delete_folder_metadata) + bot.action("add_folder_metadata")(incident_folder.add_folder_metadata) + bot.action("view_folder_metadata")(incident_folder.view_folder_metadata) + bot.view("view_folder_metadata_modal")(incident_folder.list_folders) + bot.view("add_metadata_view")(incident_folder.save_metadata) + bot.action("delete_folder_metadata")(incident_folder.delete_folder_metadata) bot.action("archive_channel")(archive_channel_action) - bot.view("view_save_incident_roles")(save_incident_roles) + bot.view("view_save_incident_roles")(incident_roles.save_incident_roles) bot.view("view_save_event")(save_incident_retro) bot.action("confirm_click")(confirm_click) @@ -71,9 +69,9 @@ def handle_incident_command(args, client: WebClient, body, respond: Respond, ack case "help": respond(help_text) case "list-folders": - list_folders(client, body, ack) + incident_folder.list_folders(client, body, ack) case "roles": - manage_roles(client, body, ack, respond) + incident_roles.manage_roles(client, body, ack, respond) case "close": close_incident(client, body, ack, respond) case "stale": @@ -86,58 +84,6 @@ def handle_incident_command(args, client: WebClient, body, respond: Respond, ack ) -def add_folder_metadata(client: WebClient, body, ack): - ack() - folder_id = body["actions"][0]["value"] - blocks = { - "type": "modal", - "callback_id": "add_metadata_view", - "title": {"type": "plain_text", "text": "SRE - Add metadata"}, - "submit": {"type": "plain_text", "text": "Save metadata"}, - "close": {"type": "plain_text", "text": "Cancel"}, - "private_metadata": folder_id, - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Add metadata*", - }, - }, - { - "type": "input", - "block_id": "key", - "element": { - "type": "plain_text_input", - "action_id": "key", - "placeholder": {"type": "plain_text", "text": "Key"}, - }, - "label": { - "type": "plain_text", - "text": "Key", - }, - }, - { - "type": "input", - "block_id": "value", - "element": { - "type": "plain_text_input", - "action_id": "value", - "placeholder": {"type": "plain_text", "text": "Value"}, - }, - "label": { - "type": "plain_text", - "text": "Value", - }, - }, - ], - } - client.views_update( - view_id=body["view"]["id"], - view=blocks, - ) - - def archive_channel_action(client: WebClient, body, ack, respond): ack() channel_id = body["channel"]["id"] @@ -172,168 +118,6 @@ def archive_channel_action(client: WebClient, body, ack, respond): log_to_sentinel("incident_retro_scheduled", body) -def delete_folder_metadata(client: WebClient, body, ack): - ack() - folder_id = body["view"]["private_metadata"] - key = body["actions"][0]["value"] - response = google_drive.delete_metadata(folder_id, key) - if not response: - logging.info(f"Failed to delete metadata `{key}`.\nResponse: {str(response)}") - else: - logging.info(f"Response: {str(response)}") - body["actions"] = [{"value": folder_id}] - view_folder_metadata(client, body, ack) - - -def list_folders(client: WebClient, body, ack): - ack() - folders = google_drive.list_folders_in_folder( - SRE_INCIDENT_FOLDER, "not name contains 'Templates'" - ) - folders.sort(key=lambda x: x["name"]) - blocks = { - "type": "modal", - "callback_id": "list_folders_view", - "title": {"type": "plain_text", "text": "SRE - Listing folders"}, - "close": {"type": "plain_text", "text": "Close"}, - "blocks": [ - item for sublist in list(map(folder_item, folders)) for item in sublist - ], - } - client.views_open(trigger_id=body["trigger_id"], view=blocks) - - -def manage_roles(client: WebClient, body, ack, respond): - ack() - channel_name = body["channel_name"] - channel_name = channel_name[ - channel_name.startswith("incident-") and len("incident-") : - ] - documents = google_drive.get_file_by_name(channel_name) - - if len(documents) == 0: - respond( - f"No incident document found for `{channel_name}`. Please make sure the channel matches the document name." - ) - return - - document = documents[0] - current_ic = ( - document["appProperties"]["ic_id"] - if "appProperties" in document and "ic_id" in document["appProperties"] - else False - ) - current_ol = ( - document["appProperties"]["ol_id"] - if "appProperties" in document and "ol_id" in document["appProperties"] - else False - ) - - ic_element = { - "type": "users_select", - "placeholder": { - "type": "plain_text", - "text": "Select an incident commander", - }, - "action_id": "ic_select", - } - if current_ic: - ic_element["initial_user"] = current_ic - - ol_element = { - "type": "users_select", - "placeholder": { - "type": "plain_text", - "text": "Select an operations lead", - }, - "action_id": "ol_select", - } - if current_ol: - ol_element["initial_user"] = current_ol - - blocks = { - "type": "modal", - "callback_id": "view_save_incident_roles", - "title": {"type": "plain_text", "text": "SRE - Roles management"}, - "submit": {"type": "plain_text", "text": "Save roles"}, - "private_metadata": json.dumps( - { - "id": document["id"], - "ic_id": current_ic, - "ol_id": current_ol, - "channel_id": body["channel_id"], - } - ), - "blocks": ( - [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": f"Roles for {channel_name}", - }, - }, - {"type": "divider"}, - { - "type": "input", - "block_id": "ic_name", - "element": ic_element, - "label": { - "type": "plain_text", - "text": "Incident Commander", - }, - }, - {"type": "divider"}, - { - "type": "input", - "block_id": "ol_name", - "element": ol_element, - "label": { - "type": "plain_text", - "text": "Operations Lead", - }, - }, - ] - ), - } - client.views_open(trigger_id=body["trigger_id"], view=blocks) - - -def save_metadata(client: WebClient, body, ack, view): - ack() - folder_id = view["private_metadata"] - key = view["state"]["values"]["key"]["key"]["value"] - value = view["state"]["values"]["value"]["value"]["value"] - google_drive.add_metadata(folder_id, key, value) - body["actions"] = [{"value": folder_id}] - del body["view"] - view_folder_metadata(client, body, ack) - - -def save_incident_roles(client: WebClient, ack, view): - ack() - selected_ic = view["state"]["values"]["ic_name"]["ic_select"]["selected_user"] - selected_ol = view["state"]["values"]["ol_name"]["ol_select"]["selected_user"] - metadata = json.loads(view["private_metadata"]) - file_id = metadata["id"] - google_drive.add_metadata(file_id, "ic_id", selected_ic) - google_drive.add_metadata(file_id, "ol_id", selected_ol) - if metadata["ic_id"] != selected_ic: - client.chat_postMessage( - text=f"<@{selected_ic}> has been assigned as incident commander for this incident.", - channel=metadata["channel_id"], - ) - if metadata["ol_id"] != selected_ol: - client.chat_postMessage( - text=f"<@{selected_ol}> has been assigned as operations lead for this incident.", - channel=metadata["channel_id"], - ) - client.conversations_setTopic( - topic=f"IC: <@{selected_ic}> / OL: <@{selected_ol}>", - channel=metadata["channel_id"], - ) - - def close_incident(client: WebClient, body, ack, respond): ack() # get the current chanel id and name @@ -371,7 +155,7 @@ def close_incident(client: WebClient, body, ack, respond): # Update the document status to "Closed" if we can get the document if document_id != "": - close_incident_document(document_id) + incident_document.update_incident_document_status(document_id) else: warning_message = ( "Could not close the incident document - the document was not found." @@ -380,7 +164,7 @@ def close_incident(client: WebClient, body, ack, respond): respond(warning_message) # Update the spreadsheet with the current incident with status = closed - update_succeeded = update_spreadsheet_incident_status( + update_succeeded = incident_folder.update_spreadsheet_incident_status( return_channel_name(channel_name), "Closed" ) @@ -410,58 +194,6 @@ def close_incident(client: WebClient, body, ack, respond): ) -def close_incident_document(document_id): - # List of possible statuses to be replaced - possible_statuses = ["In Progress", "Open", "Ready to be Reviewed", "Reviewed"] - - # Replace all possible statuses with "Closed" - changes = [ - { - "replaceAllText": { - "containsText": {"text": f"Status: {status}", "matchCase": "false"}, - "replaceText": "Status: Closed", - } - } - for status in possible_statuses - ] - return google_docs.batch_update(document_id, changes) - - -def update_spreadsheet_incident_status(channel_name, status="Closed"): - """Update the status of an incident in the incident list spreadsheet. - - Args: - channel_name (str): The name of the channel to update. - status (str): The status to update the incident to. - - Returns: - bool: True if the status was updated successfully, False otherwise. - """ - valid_statuses = ["Open", "Closed", "In Progress", "Resolved"] - if status not in valid_statuses: - logging.warning("Invalid status %s", status) - return False - sheet_name = "Sheet1" - sheet = sheets.get_values(INCIDENT_LIST, range=sheet_name) - values = sheet.get("values", []) - if len(values) == 0: - logging.warning("No incident found for channel %s", channel_name) - return False - # Find the row with the search value - for i, row in enumerate(values): - if channel_name in row: - # Update the 4th column (index 3) of the found row - update_range = ( - f"{sheet_name}!D{i+1}" # Column D, Rows are 1-indexed in Sheets - ) - updated_sheet = sheets.batch_update_values( - INCIDENT_LIST, update_range, [[status]] - ) - if updated_sheet: - return True - return False - - def stale_incidents(client, body, ack): ack() @@ -753,46 +485,6 @@ def confirm_click(ack, body, client): logging.info(f"User {username} viewed the calendar event.") -def view_folder_metadata(client, body, ack): - ack() - folder_id = body["actions"][0]["value"] - logging.info(f"Viewing metadata for folder {folder_id}") - folder = google_drive.list_metadata(folder_id) - blocks = { - "type": "modal", - "callback_id": "view_folder_metadata_modal", - "title": {"type": "plain_text", "text": "SRE - Showing metadata"}, - "submit": {"type": "plain_text", "text": "Return to folders"}, - "private_metadata": folder_id, - "blocks": ( - [ - { - "type": "section", - "text": { - "type": "plain_text", - "text": folder["name"], - }, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Add metadata"}, - "value": folder_id, - "action_id": "add_folder_metadata", - }, - }, - {"type": "divider"}, - ] - + metadata_items(folder) - ), - } - if "view" in body: - client.views_update( - view_id=body["view"]["id"], - view=blocks, - ) - else: - client.views_open(trigger_id=body["trigger_id"], view=blocks) - - def channel_item(channel): return [ { @@ -819,70 +511,6 @@ def channel_item(channel): ] -def folder_item(folder): - return [ - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"*{folder['name']}*"}, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Manage metadata", - "emoji": True, - }, - "value": f"{folder['id']}", - "action_id": "view_folder_metadata", - }, - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f"", - } - ], - }, - {"type": "divider"}, - ] - - -def metadata_items(folder): - if "appProperties" not in folder or len(folder["appProperties"]) == 0: - return [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*No metadata found. Click the button above to add metadata.*", - }, - }, - ] - else: - return [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"*{key}*\n{value}", - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Delete metadata", - "emoji": True, - }, - "value": key, - "style": "danger", - "action_id": "delete_folder_metadata", - }, - } - for key, value in folder["appProperties"].items() - ] - - def return_channel_name(input_str: str): # return the channel name without the incident- prefix and appending a # to the channel name prefix = "incident-" diff --git a/app/modules/incident/incident_roles.py b/app/modules/incident/incident_roles.py new file mode 100644 index 00000000..f6bb786e --- /dev/null +++ b/app/modules/incident/incident_roles.py @@ -0,0 +1,124 @@ +import json +from slack_sdk.web import WebClient +from integrations.google_workspace import google_drive + + +def save_incident_roles(client: WebClient, ack, view): + ack() + selected_ic = view["state"]["values"]["ic_name"]["ic_select"]["selected_user"] + selected_ol = view["state"]["values"]["ol_name"]["ol_select"]["selected_user"] + metadata = json.loads(view["private_metadata"]) + file_id = metadata["id"] + google_drive.add_metadata(file_id, "ic_id", selected_ic) + google_drive.add_metadata(file_id, "ol_id", selected_ol) + if metadata["ic_id"] != selected_ic: + client.chat_postMessage( + text=f"<@{selected_ic}> has been assigned as incident commander for this incident.", + channel=metadata["channel_id"], + ) + if metadata["ol_id"] != selected_ol: + client.chat_postMessage( + text=f"<@{selected_ol}> has been assigned as operations lead for this incident.", + channel=metadata["channel_id"], + ) + client.conversations_setTopic( + topic=f"IC: <@{selected_ic}> / OL: <@{selected_ol}>", + channel=metadata["channel_id"], + ) + + +def manage_roles(client: WebClient, body, ack, respond): + ack() + channel_name = body["channel_name"] + channel_name = channel_name[ + channel_name.startswith("incident-") and len("incident-") : + ] + channel_name = channel_name[channel_name.startswith("dev-") and len("dev-") :] + documents = google_drive.get_file_by_name(channel_name) + + if len(documents) == 0: + respond( + f"No incident document found for `{channel_name}`. Please make sure the channel matches the document name." + ) + return + + document = documents[0] + current_ic = ( + document["appProperties"]["ic_id"] + if "appProperties" in document and "ic_id" in document["appProperties"] + else False + ) + current_ol = ( + document["appProperties"]["ol_id"] + if "appProperties" in document and "ol_id" in document["appProperties"] + else False + ) + + ic_element = { + "type": "users_select", + "placeholder": { + "type": "plain_text", + "text": "Select an incident commander", + }, + "action_id": "ic_select", + } + if current_ic: + ic_element["initial_user"] = current_ic + + ol_element = { + "type": "users_select", + "placeholder": { + "type": "plain_text", + "text": "Select an operations lead", + }, + "action_id": "ol_select", + } + if current_ol: + ol_element["initial_user"] = current_ol + + blocks = { + "type": "modal", + "callback_id": "view_save_incident_roles", + "title": {"type": "plain_text", "text": "SRE - Roles management"}, + "submit": {"type": "plain_text", "text": "Save roles"}, + "private_metadata": json.dumps( + { + "id": document["id"], + "ic_id": current_ic, + "ol_id": current_ol, + "channel_id": body["channel_id"], + } + ), + "blocks": ( + [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"Roles for {channel_name}", + }, + }, + {"type": "divider"}, + { + "type": "input", + "block_id": "ic_name", + "element": ic_element, + "label": { + "type": "plain_text", + "text": "Incident Commander", + }, + }, + {"type": "divider"}, + { + "type": "input", + "block_id": "ol_name", + "element": ol_element, + "label": { + "type": "plain_text", + "text": "Operations Lead", + }, + }, + ] + ), + } + client.views_open(trigger_id=body["trigger_id"], view=blocks) diff --git a/app/tests/modules/incident/test_incident_document.py b/app/tests/modules/incident/test_incident_document.py new file mode 100644 index 00000000..315708be --- /dev/null +++ b/app/tests/modules/incident/test_incident_document.py @@ -0,0 +1,114 @@ +from unittest.mock import patch + +import pytest +from modules.incident import incident_document + + +@patch("modules.incident.incident_document.google_docs") +def test_update_incident_document_status_changes_occurred(google_docs_mock): + document_id = "test_document_id" + new_status = "In Progress" + google_docs_mock.batch_update.return_value = { + "replies": [ + {"replaceAllText": {"occurrencesChanged": 1}}, + {"replaceAllText": {}}, + {"replaceAllText": {}}, + {"replaceAllText": {}}, + ] + } + + response = incident_document.update_incident_document_status( + document_id, new_status + ) + assert response is True + + expected_changes = [ + { + "replaceAllText": { + "containsText": {"text": "Status: Open", "matchCase": "false"}, + "replaceText": f"Status: {new_status}", + } + }, + { + "replaceAllText": { + "containsText": { + "text": "Status: Ready to be Reviewed", + "matchCase": "false", + }, + "replaceText": f"Status: {new_status}", + } + }, + { + "replaceAllText": { + "containsText": {"text": "Status: Reviewed", "matchCase": "false"}, + "replaceText": f"Status: {new_status}", + } + }, + { + "replaceAllText": { + "containsText": {"text": "Status: Closed", "matchCase": "false"}, + "replaceText": f"Status: {new_status}", + } + }, + ] + + google_docs_mock.batch_update.assert_called_once_with(document_id, expected_changes) + + +@patch("modules.incident.incident_document.google_docs") +def test_update_incident_document_status_no_changes_occurred(google_docs_mock): + document_id = "test_document_id" + new_status = "In Progress" + google_docs_mock.batch_update.return_value = { + "replies": [ + {"replaceAllText": {}}, + {"replaceAllText": {}}, + {"replaceAllText": {}}, + {"replaceAllText": {}}, + ] + } + + response = incident_document.update_incident_document_status( + document_id, new_status + ) + assert response is False + + expected_changes = [ + { + "replaceAllText": { + "containsText": {"text": "Status: Open", "matchCase": "false"}, + "replaceText": f"Status: {new_status}", + } + }, + { + "replaceAllText": { + "containsText": { + "text": "Status: Ready to be Reviewed", + "matchCase": "false", + }, + "replaceText": f"Status: {new_status}", + } + }, + { + "replaceAllText": { + "containsText": {"text": "Status: Reviewed", "matchCase": "false"}, + "replaceText": f"Status: {new_status}", + } + }, + { + "replaceAllText": { + "containsText": {"text": "Status: Closed", "matchCase": "false"}, + "replaceText": f"Status: {new_status}", + } + }, + ] + + google_docs_mock.batch_update.assert_called_once_with(document_id, expected_changes) + + +def test_update_incident_document_status_invalid_status(): + document_id = "test_document_id" + invalid_status = "Invalid Status" + + with pytest.raises(ValueError, match=f"Invalid status: {invalid_status}"): + incident_document.update_incident_document_status(document_id, invalid_status) diff --git a/app/tests/modules/incident/test_incident_folder.py b/app/tests/modules/incident/test_incident_folder.py new file mode 100644 index 00000000..ee96d6ff --- /dev/null +++ b/app/tests/modules/incident/test_incident_folder.py @@ -0,0 +1,245 @@ +from unittest.mock import patch, MagicMock, ANY + +from modules.incident import incident_folder + + +@patch("modules.incident.incident_folder.google_drive.list_folders_in_folder") +@patch("modules.incident.incident_folder.folder_item") +def test_list_folders(folder_item_mock, list_folders_in_folder_mock): + client = MagicMock() + body = {"trigger_id": "foo"} + ack = MagicMock() + list_folders_in_folder_mock.return_value = [{"id": "foo", "name": "bar"}] + folder_item_mock.return_value = [["folder item"]] + incident_folder.list_folders(client, body, ack) + list_folders_in_folder_mock.assert_called_once() + folder_item_mock.assert_called_once_with({"id": "foo", "name": "bar"}) + ack.assert_called_once() + client.views_open.assert_called_once_with(trigger_id="foo", view=ANY) + + +@patch("modules.incident.incident_folder.logging") +@patch("modules.incident.incident_folder.google_drive.delete_metadata") +@patch("modules.incident.incident_folder.view_folder_metadata") +def test_delete_folder_metadata( + view_folder_metadata_mock, delete_metadata_mock, logging_mock +): + client = MagicMock() + body = {"actions": [{"value": "foo"}], "view": {"private_metadata": "bar"}} + ack = MagicMock() + delete_metadata_mock.return_value = { + "name": "folder", + "appProperties": [{"key": "key", "value": "value"}], + } + incident_folder.delete_folder_metadata(client, body, ack) + + ack.assert_called_once() + delete_metadata_mock.assert_called_once_with("bar", "foo") + view_folder_metadata_mock.assert_called_once_with( + client, + {"actions": [{"value": "bar"}], "view": {"private_metadata": "bar"}}, + ack, + ) + logging_mock.info.assert_called_once_with("Deleted metadata for key `foo`") + + +@patch("modules.incident.incident_folder.logging") +@patch("modules.incident.incident_folder.google_drive.delete_metadata") +@patch("modules.incident.incident_folder.view_folder_metadata") +def test_delete_folder_metadata_failed( + view_folder_metadata_mock, delete_metadata_mock, logging_mock +): + client = MagicMock() + body = {"actions": [{"value": "foo"}], "view": {"private_metadata": "bar"}} + ack = MagicMock() + delete_metadata_mock.return_value = {} + incident_folder.delete_folder_metadata(client, body, ack) + + ack.assert_called_once() + delete_metadata_mock.assert_called_once_with("bar", "foo") + view_folder_metadata_mock.assert_called_once_with( + client, + {"actions": [{"value": "bar"}], "view": {"private_metadata": "bar"}}, + ack, + ) + logging_mock.info.assert_called_once_with( + "Failed to delete metadata `foo` for folder `bar`" + ) + + +@patch("modules.incident.incident_folder.google_drive.add_metadata") +@patch("modules.incident.incident_folder.view_folder_metadata") +def test_save_metadata(view_folder_metadata_mock, add_metadata_mock): + client = MagicMock() + body = {"actions": [{"value": "foo"}], "view": {"private_metadata": "bar"}} + view = { + "state": { + "values": { + "key": {"key": {"value": "key"}}, + "value": {"value": {"value": "value"}}, + } + }, + "private_metadata": "bar", + } + ack = MagicMock() + incident_folder.save_metadata(client, body, ack, view) + ack.assert_called_once() + add_metadata_mock.assert_called_once_with("bar", "key", "value") + view_folder_metadata_mock.assert_called_once_with( + client, + {"actions": [{"value": "bar"}]}, + ack, + ) + + +@patch("modules.incident.incident_folder.google_drive.list_metadata") +@patch("modules.incident.incident_folder.metadata_items") +def test_view_folder_metadata_open(metadata_items_mock, list_metadata_mock): + client = MagicMock() + body = {"actions": [{"value": "foo"}], "trigger_id": "trigger_id"} + ack = MagicMock() + list_metadata_mock.return_value = { + "name": "folder", + "appProperties": [{"key": "key", "value": "value"}], + } + + metadata_items_mock.return_value = [["metadata item"]] + incident_folder.view_folder_metadata(client, body, ack) + ack.assert_called_once() + list_metadata_mock.assert_called_once_with("foo") + metadata_items_mock.assert_called_once_with( + {"name": "folder", "appProperties": [{"key": "key", "value": "value"}]} + ) + client.views_open(trigger_id="trigger_id", view=ANY) + + +@patch("modules.incident.incident_folder.google_drive.list_metadata") +@patch("modules.incident.incident_folder.metadata_items") +def test_view_folder_metadata_update(metadata_items_mock, list_metadata_mock): + client = MagicMock() + body = {"actions": [{"value": "foo"}], "view": {"id": "view_id"}} + ack = MagicMock() + list_metadata_mock.return_value = { + "name": "folder", + "appProperties": [{"key": "key", "value": "value"}], + } + + metadata_items_mock.return_value = [["metadata item"]] + incident_folder.view_folder_metadata(client, body, ack) + ack.assert_called_once() + list_metadata_mock.assert_called_once_with("foo") + metadata_items_mock.assert_called_once_with( + {"name": "folder", "appProperties": [{"key": "key", "value": "value"}]} + ) + client.views_update(view_id="view_id", view=ANY) + + +def test_add_folder_metadata(): + client = MagicMock() + body = {"actions": [{"value": "foo"}], "view": {"id": "bar"}} + ack = MagicMock() + incident_folder.add_folder_metadata(client, body, ack) + ack.assert_called_once() + client.views_update.assert_called_once_with(view_id="bar", view=ANY) + + +def test_folder_item(): + assert incident_folder.folder_item({"id": "foo", "name": "bar"}) == [ + { + "accessory": { + "action_id": "view_folder_metadata", + "text": { + "emoji": True, + "text": "Manage metadata", + "type": "plain_text", + }, + "type": "button", + "value": "foo", + }, + "text": {"text": "*bar*", "type": "mrkdwn"}, + "type": "section", + }, + { + "elements": [ + { + "text": "", + "type": "mrkdwn", + } + ], + "type": "context", + }, + {"type": "divider"}, + ] + + +def test_metadata_items_empty(): + empty = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*No metadata found. Click the button above to add metadata.*", + }, + }, + ] + assert incident_folder.metadata_items({}) == empty + assert incident_folder.metadata_items({"appProperties": []}) == empty + + +def test_metadata_items(): + assert incident_folder.metadata_items({"appProperties": {"key": "value"}}) == [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*key*\nvalue", + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Delete metadata", + "emoji": True, + }, + "value": "key", + "style": "danger", + "action_id": "delete_folder_metadata", + }, + }, + ] + + +@patch("modules.incident.incident_folder.sheets") +@patch("modules.incident.incident_folder.logging") +def test_update_spreadsheet_incident_status_invalid_status(logging_mock, sheets_mock): + assert not incident_folder.update_spreadsheet_incident_status( + "foo", "InvalidStatus" + ) + logging_mock.warning.assert_called_once_with("Invalid status %s", "InvalidStatus") + + +@patch("modules.incident.incident_folder.sheets") +@patch("modules.incident.incident_folder.logging") +def test_update_spreadsheet_incident_status_empty_values(logging_mock, sheets_mock): + sheets_mock.get_values.return_value = {"values": []} + assert not incident_folder.update_spreadsheet_incident_status("foo", "Closed") + logging_mock.warning.assert_called_once_with( + "No incident found for channel %s", "foo" + ) + + +@patch("modules.incident.incident_folder.INCIDENT_LIST", "INCIDENT_LIST") +@patch("modules.incident.incident_folder.sheets") +def test_update_spreadsheet_incident_status_channel_found(sheets_mock): + sheets_mock.get_values.return_value = {"values": [["foo", "bar", "baz", "qux"]]} + sheets_mock.batch_update_values.return_value = True + assert incident_folder.update_spreadsheet_incident_status("foo", "Closed") + sheets_mock.batch_update_values.assert_called_once_with( + "INCIDENT_LIST", "Sheet1!D1", [["Closed"]] + ) + + +@patch("modules.incident.incident_folder.sheets") +def test_update_spreadsheet_incident_status_channel_not_found(sheets_mock): + sheets_mock.get_values.return_value = {"values": [["bar", "baz", "qux"]]} + assert not incident_folder.update_spreadsheet_incident_status("foo", "Closed") diff --git a/app/tests/modules/incident/test_incident_helper.py b/app/tests/modules/incident/test_incident_helper.py index 6a6b9132..be9e3b60 100644 --- a/app/tests/modules/incident/test_incident_helper.py +++ b/app/tests/modules/incident/test_incident_helper.py @@ -47,7 +47,7 @@ def test_handle_incident_command_with_help(): respond.assert_called_once_with(incident_helper.help_text) -@patch("modules.incident.incident_helper.list_folders") +@patch("modules.incident.incident_helper.incident_folder.list_folders") def test_handle_incident_command_with_list_folders(list_folders_mock): client = MagicMock() body = MagicMock() @@ -59,7 +59,7 @@ def test_handle_incident_command_with_list_folders(list_folders_mock): list_folders_mock.assert_called_once_with(client, body, ack) -@patch("modules.incident.incident_helper.manage_roles") +@patch("modules.incident.incident_helper.incident_roles.manage_roles") def test_handle_incident_command_with_roles(manage_roles_mock): client = MagicMock() body = MagicMock() @@ -90,15 +90,6 @@ def test_handle_incident_command_with_unknown_command(): ) -def test_add_folder_metadata(): - client = MagicMock() - body = {"actions": [{"value": "foo"}], "view": {"id": "bar"}} - ack = MagicMock() - incident_helper.add_folder_metadata(client, body, ack) - ack.assert_called_once() - client.views_update.assert_called_once_with(view_id="bar", view=ANY) - - @patch("modules.incident.incident_helper.log_to_sentinel") def test_archive_channel_action_ignore(mock_log_to_sentinel): client = MagicMock() @@ -123,15 +114,22 @@ def test_archive_channel_action_ignore(mock_log_to_sentinel): ) -@patch("modules.incident.incident_helper.close_incident_document") -@patch("modules.incident.incident_helper.update_spreadsheet_incident_status") +@patch( + "modules.incident.incident_helper.incident_document.update_incident_document_status" +) +@patch( + "modules.incident.incident_helper.incident_folder.update_spreadsheet_incident_status" +) @patch( "integrations.google_workspace.google_docs.extract_google_doc_id", return_value="dummy_document_id", ) @patch("modules.incident.incident_helper.log_to_sentinel") def test_archive_channel_action_archive( - mock_log_to_sentinel, mock_extract_id, mock_update_spreadsheet, mock_close_document + mock_log_to_sentinel, + mock_extract_id, + mock_update_spreadsheet, + mock_update_document_status, ): client = MagicMock() body = { @@ -161,136 +159,6 @@ def test_archive_channel_action_schedule_incident(mock_log_to_sentinel): assert ack.call_count == 1 -@patch("modules.incident.incident_helper.google_drive.delete_metadata") -@patch("modules.incident.incident_helper.view_folder_metadata") -def test_delete_folder_metadata(view_folder_metadata_mock, delete_metadata_mock): - client = MagicMock() - body = {"actions": [{"value": "foo"}], "view": {"private_metadata": "bar"}} - ack = MagicMock() - incident_helper.delete_folder_metadata(client, body, ack) - ack.assert_called_once() - delete_metadata_mock.assert_called_once_with("bar", "foo") - view_folder_metadata_mock.assert_called_once_with( - client, - {"actions": [{"value": "bar"}], "view": {"private_metadata": "bar"}}, - ack, - ) - - -@patch("modules.incident.incident_helper.google_drive.list_folders_in_folder") -@patch("modules.incident.incident_helper.folder_item") -def test_list_folders(folder_item_mock, list_folders_in_folder_mock): - client = MagicMock() - body = {"trigger_id": "foo"} - ack = MagicMock() - list_folders_in_folder_mock.return_value = [{"id": "foo", "name": "bar"}] - folder_item_mock.return_value = [["folder item"]] - incident_helper.list_folders(client, body, ack) - list_folders_in_folder_mock.assert_called_once() - folder_item_mock.assert_called_once_with({"id": "foo", "name": "bar"}) - ack.assert_called_once() - client.views_open.assert_called_once_with(trigger_id="foo", view=ANY) - - -@patch("modules.incident.incident_helper.google_drive.get_file_by_name") -def test_manage_roles(get_document_by_channel_name_mock): - client = MagicMock() - body = { - "channel_id": "channel_id", - "channel_name": "incident-channel_name", - "trigger_id": "trigger_id", - } - ack = MagicMock() - respond = MagicMock() - get_document_by_channel_name_mock.return_value = [ - {"id": "file_id", "appProperties": {"ic_id": "ic_id", "ol_id": "ol_id"}} - ] - incident_helper.manage_roles(client, body, ack, respond) - ack.assert_called_once() - get_document_by_channel_name_mock.assert_called_once_with("channel_name") - client.views_open.assert_called_once_with(trigger_id="trigger_id", view=ANY) - - -@patch("modules.incident.incident_helper.google_drive.get_file_by_name") -def test_manage_roles_with_no_result(get_document_by_channel_name_mock): - client = MagicMock() - body = { - "channel_id": "channel_id", - "channel_name": "incident-channel_name", - "trigger_id": "trigger_id", - } - ack = MagicMock() - respond = MagicMock() - get_document_by_channel_name_mock.return_value = [] - incident_helper.manage_roles(client, body, ack, respond) - ack.assert_called_once() - respond.assert_called_once_with( - "No incident document found for `channel_name`. Please make sure the channel matches the document name." - ) - - -@patch("modules.incident.incident_helper.google_drive.add_metadata") -def test_save_incident_roles(add_metadata_mock): - client = MagicMock() - ack = MagicMock() - view = { - "private_metadata": json.dumps( - { - "ic_id": "ic_id", - "ol_id": "ol_id", - "id": "file_id", - "channel_id": "channel_id", - } - ), - "state": { - "values": { - "ic_name": {"ic_select": {"selected_user": "selected_ic"}}, - "ol_name": {"ol_select": {"selected_user": "selected_ol"}}, - } - }, - } - incident_helper.save_incident_roles(client, ack, view) - ack.assert_called_once() - add_metadata_mock.assert_any_call("file_id", "ic_id", "selected_ic") - add_metadata_mock.assert_any_call("file_id", "ol_id", "selected_ol") - client.chat_postMessage.assert_any_call( - text="<@selected_ic> has been assigned as incident commander for this incident.", - channel="channel_id", - ) - client.chat_postMessage.assert_any_call( - text="<@selected_ol> has been assigned as operations lead for this incident.", - channel="channel_id", - ) - client.conversations_setTopic.assert_called_once_with( - topic="IC: <@selected_ic> / OL: <@selected_ol>", channel="channel_id" - ) - - -@patch("modules.incident.incident_helper.google_drive.add_metadata") -@patch("modules.incident.incident_helper.view_folder_metadata") -def test_save_metadata(view_folder_metadata_mock, add_metadata_mock): - client = MagicMock() - body = {"actions": [{"value": "foo"}], "view": {"private_metadata": "bar"}} - view = { - "state": { - "values": { - "key": {"key": {"value": "key"}}, - "value": {"value": {"value": "value"}}, - } - }, - "private_metadata": "bar", - } - ack = MagicMock() - incident_helper.save_metadata(client, body, ack, view) - ack.assert_called_once() - add_metadata_mock.assert_called_once_with("bar", "key", "value") - view_folder_metadata_mock.assert_called_once_with( - client, - {"actions": [{"value": "bar"}]}, - ack, - ) - - @patch("modules.incident.incident_helper.slack_channels.get_stale_channels") def test_stale_incidents(get_stale_channels_mock): client = MagicMock() @@ -306,48 +174,6 @@ def test_stale_incidents(get_stale_channels_mock): client.views_update.assert_called_once_with(view_id="view_id", view=ANY) -@patch("modules.incident.incident_helper.google_drive.list_metadata") -@patch("modules.incident.incident_helper.metadata_items") -def test_view_folder_metadata_open(metadata_items_mock, list_metadata_mock): - client = MagicMock() - body = {"actions": [{"value": "foo"}], "trigger_id": "trigger_id"} - ack = MagicMock() - list_metadata_mock.return_value = { - "name": "folder", - "appProperties": [{"key": "key", "value": "value"}], - } - - metadata_items_mock.return_value = [["metadata item"]] - incident_helper.view_folder_metadata(client, body, ack) - ack.assert_called_once() - list_metadata_mock.assert_called_once_with("foo") - metadata_items_mock.assert_called_once_with( - {"name": "folder", "appProperties": [{"key": "key", "value": "value"}]} - ) - client.views_open(trigger_id="trigger_id", view=ANY) - - -@patch("modules.incident.incident_helper.google_drive.list_metadata") -@patch("modules.incident.incident_helper.metadata_items") -def test_view_folder_metadata_update(metadata_items_mock, list_metadata_mock): - client = MagicMock() - body = {"actions": [{"value": "foo"}], "view": {"id": "view_id"}} - ack = MagicMock() - list_metadata_mock.return_value = { - "name": "folder", - "appProperties": [{"key": "key", "value": "value"}], - } - - metadata_items_mock.return_value = [["metadata item"]] - incident_helper.view_folder_metadata(client, body, ack) - ack.assert_called_once() - list_metadata_mock.assert_called_once_with("foo") - metadata_items_mock.assert_called_once_with( - {"name": "folder", "appProperties": [{"key": "key", "value": "value"}]} - ) - client.views_update(view_id="view_id", view=ANY) - - def test_channel_item(): assert incident_helper.channel_item( {"id": "id", "topic": {"value": "topic_value"}} @@ -372,79 +198,19 @@ def test_channel_item(): ] -def test_folder_item(): - assert incident_helper.folder_item({"id": "foo", "name": "bar"}) == [ - { - "accessory": { - "action_id": "view_folder_metadata", - "text": { - "emoji": True, - "text": "Manage metadata", - "type": "plain_text", - }, - "type": "button", - "value": "foo", - }, - "text": {"text": "*bar*", "type": "mrkdwn"}, - "type": "section", - }, - { - "elements": [ - { - "text": "", - "type": "mrkdwn", - } - ], - "type": "context", - }, - {"type": "divider"}, - ] - - -def test_metadata_items_empty(): - empty = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*No metadata found. Click the button above to add metadata.*", - }, - }, - ] - assert incident_helper.metadata_items({}) == empty - assert incident_helper.metadata_items({"appProperties": []}) == empty - - -def test_metadata_items(): - assert incident_helper.metadata_items({"appProperties": {"key": "value"}}) == [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*key*\nvalue", - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Delete metadata", - "emoji": True, - }, - "value": "key", - "style": "danger", - "action_id": "delete_folder_metadata", - }, - }, - ] - - -@patch("modules.incident.incident_helper.close_incident_document") -@patch("modules.incident.incident_helper.update_spreadsheet_incident_status") +@patch( + "modules.incident.incident_helper.incident_document.update_incident_document_status" +) +@patch( + "modules.incident.incident_helper.incident_folder.update_spreadsheet_incident_status" +) @patch( "integrations.google_workspace.google_docs.extract_google_doc_id", return_value="dummy_document_id", ) -def test_close_incident(mock_extract_id, mock_update_spreadsheet, mock_close_document): +def test_close_incident( + mock_extract_id, mock_update_spreadsheet, mock_update_document_status +): mock_client = MagicMock() mock_ack = MagicMock() mock_respond = MagicMock() @@ -484,20 +250,24 @@ def test_close_incident(mock_extract_id, mock_update_spreadsheet, mock_close_doc ) # Assert that the Google Drive document and spreadsheet update methods were called - mock_close_document.assert_called_once_with("dummy_document_id") + mock_update_document_status.assert_called_once_with("dummy_document_id") mock_update_spreadsheet.assert_called_once_with("#2024-01-12-test", "Closed") # Assert that the Slack client's conversations_archive method was called with the correct channel ID mock_client.conversations_archive.assert_called_once_with(channel="C12345") -@patch("modules.incident.incident_helper.close_incident_document") -@patch("modules.incident.incident_helper.update_spreadsheet_incident_status") +@patch( + "modules.incident.incident_helper.incident_document.update_incident_document_status" +) +@patch( + "modules.incident.incident_helper.incident_folder.update_spreadsheet_incident_status" +) @patch( "integrations.google_workspace.google_docs.extract_google_doc_id", return_value=None ) def test_close_incident_no_bookmarks( - mock_extract_id, mock_update_spreadsheet, mock_close_document + mock_extract_id, mock_update_spreadsheet, mock_update_document_status ): mock_client = MagicMock() mock_ack = MagicMock() @@ -520,17 +290,21 @@ def test_close_incident_no_bookmarks( # Assertions to ensure that document update functions are not called as there are no bookmarks mock_extract_id.assert_not_called() - mock_close_document.assert_not_called() + mock_update_document_status.assert_not_called() mock_update_spreadsheet.assert_called_once_with("#2024-01-12-test", "Closed") -@patch("modules.incident.incident_helper.close_incident_document") -@patch("modules.incident.incident_helper.update_spreadsheet_incident_status") +@patch( + "modules.incident.incident_helper.incident_document.update_incident_document_status" +) +@patch( + "modules.incident.incident_helper.incident_folder.update_spreadsheet_incident_status" +) @patch( "integrations.google_workspace.google_docs.extract_google_doc_id", return_value=None ) def test_close_incident_no_bookmarks_error( - mock_extract_id, mock_update_spreadsheet, mock_close_document + mock_extract_id, mock_update_spreadsheet, mock_update_document_status ): mock_client = MagicMock() mock_ack = MagicMock() @@ -553,7 +327,7 @@ def test_close_incident_no_bookmarks_error( # Assertions to ensure that document update functions are not called as there are no bookmarks mock_extract_id.assert_not_called() - mock_close_document.assert_not_called() + mock_update_document_status.assert_not_called() mock_update_spreadsheet.assert_called_once_with("#2024-01-12-test", "Closed") @@ -632,14 +406,18 @@ def test_close_incident_cant_send_private_message(caplog): ), "Expected error message not found in log records" -@patch("modules.incident.incident_helper.close_incident_document") -@patch("modules.incident.incident_helper.update_spreadsheet_incident_status") +@patch( + "modules.incident.incident_helper.incident_document.update_incident_document_status" +) +@patch( + "modules.incident.incident_helper.incident_folder.update_spreadsheet_incident_status" +) @patch( "integrations.google_workspace.google_docs.extract_google_doc_id", return_value="dummy_document_id", ) def test_conversations_archive_fail( - mock_extract_id, mock_update_spreadsheet, mock_close_document + mock_extract_id, mock_update_spreadsheet, mock_update_document_status ): mock_client = MagicMock() mock_ack = MagicMock() @@ -676,21 +454,25 @@ def test_conversations_archive_fail( # Assertions # Ensure that the Google Drive document update method was called even if archiving fails - mock_close_document.assert_called_once_with("dummy_document_id") + mock_update_document_status.assert_called_once_with("dummy_document_id") mock_update_spreadsheet.assert_called_once_with("#2024-01-12-test", "Closed") # Ensure that the client's conversations_archive method was called mock_client.conversations_archive.assert_called_once_with(channel="C12345") -@patch("modules.incident.incident_helper.close_incident_document") -@patch("modules.incident.incident_helper.update_spreadsheet_incident_status") +@patch( + "modules.incident.incident_helper.incident_document.update_incident_document_status" +) +@patch( + "modules.incident.incident_helper.incident_folder.update_spreadsheet_incident_status" +) @patch( "integrations.google_workspace.google_docs.extract_google_doc_id", return_value="dummy_document_id", ) def test_conversations_archive_fail_error_message( - mock_extract_id, mock_update_spreadsheet, mock_close_document, caplog + mock_extract_id, mock_update_spreadsheet, mock_update_document_status, caplog ): mock_client = MagicMock() mock_ack = MagicMock() @@ -727,7 +509,7 @@ def test_conversations_archive_fail_error_message( # Assertions # Ensure that the Google Drive document update method was called even if archiving fails - mock_close_document.assert_called_once_with("dummy_document_id") + mock_update_document_status.assert_called_once_with("dummy_document_id") mock_update_spreadsheet.assert_called_once_with("#2024-01-12-test", "Closed") # Ensure that the client's conversations_archive method was called @@ -739,14 +521,18 @@ def test_conversations_archive_fail_error_message( ) -@patch("modules.incident.incident_helper.close_incident_document") -@patch("modules.incident.incident_helper.update_spreadsheet_incident_status") +@patch( + "modules.incident.incident_helper.incident_document.update_incident_document_status" +) +@patch( + "modules.incident.incident_helper.incident_folder.update_spreadsheet_incident_status" +) @patch( "integrations.google_workspace.google_docs.extract_google_doc_id", return_value="dummy_document_id", ) def test_conversations_archive_succeeds_post_message_who_archived( - mock_extract_id, mock_update_spreadsheet, mock_close_document, caplog + mock_extract_id, mock_update_spreadsheet, mock_update_document_status, caplog ): mock_client = MagicMock() mock_ack = MagicMock() diff --git a/app/tests/modules/incident/test_incident_roles.py b/app/tests/modules/incident/test_incident_roles.py new file mode 100644 index 00000000..1c7e6ee5 --- /dev/null +++ b/app/tests/modules/incident/test_incident_roles.py @@ -0,0 +1,96 @@ +import json +from unittest.mock import MagicMock, patch, ANY +from modules.incident import incident_roles as incident_helper + + +@patch("modules.incident.incident_roles.google_drive.get_file_by_name") +def test_manage_roles(get_document_by_channel_name_mock): + client = MagicMock() + body = { + "channel_id": "channel_id", + "channel_name": "incident-channel_name", + "trigger_id": "trigger_id", + } + ack = MagicMock() + respond = MagicMock() + get_document_by_channel_name_mock.return_value = [ + {"id": "file_id", "appProperties": {"ic_id": "ic_id", "ol_id": "ol_id"}} + ] + incident_helper.manage_roles(client, body, ack, respond) + ack.assert_called_once() + get_document_by_channel_name_mock.assert_called_once_with("channel_name") + client.views_open.assert_called_once_with(trigger_id="trigger_id", view=ANY) + + +@patch("modules.incident.incident_roles.google_drive.get_file_by_name") +def test_manage_roles_with_no_result(get_document_by_channel_name_mock): + client = MagicMock() + body = { + "channel_id": "channel_id", + "channel_name": "incident-channel_name", + "trigger_id": "trigger_id", + } + ack = MagicMock() + respond = MagicMock() + get_document_by_channel_name_mock.return_value = [] + incident_helper.manage_roles(client, body, ack, respond) + ack.assert_called_once() + respond.assert_called_once_with( + "No incident document found for `channel_name`. Please make sure the channel matches the document name." + ) + + +@patch("modules.incident.incident_roles.google_drive.get_file_by_name") +def test_manage_roles_with_dev_prefix(get_document_by_channel_name_mock): + client = MagicMock() + body = { + "channel_id": "channel_id", + "channel_name": "incident-dev-channel_name", + "trigger_id": "trigger_id", + } + ack = MagicMock() + respond = MagicMock() + get_document_by_channel_name_mock.return_value = [ + {"id": "file_id", "appProperties": {"ic_id": "ic_id", "ol_id": "ol_id"}} + ] + incident_helper.manage_roles(client, body, ack, respond) + ack.assert_called_once() + get_document_by_channel_name_mock.assert_called_once_with("channel_name") + client.views_open.assert_called_once_with(trigger_id="trigger_id", view=ANY) + + +@patch("modules.incident.incident_roles.google_drive.add_metadata") +def test_save_incident_roles(add_metadata_mock): + client = MagicMock() + ack = MagicMock() + view = { + "private_metadata": json.dumps( + { + "ic_id": "ic_id", + "ol_id": "ol_id", + "id": "file_id", + "channel_id": "channel_id", + } + ), + "state": { + "values": { + "ic_name": {"ic_select": {"selected_user": "selected_ic"}}, + "ol_name": {"ol_select": {"selected_user": "selected_ol"}}, + } + }, + } + incident_helper.save_incident_roles(client, ack, view) + ack.assert_called_once() + add_metadata_mock.assert_any_call("file_id", "ic_id", "selected_ic") + add_metadata_mock.assert_any_call("file_id", "ol_id", "selected_ol") + client.chat_postMessage.assert_any_call( + text="<@selected_ic> has been assigned as incident commander for this incident.", + channel="channel_id", + ) + client.chat_postMessage.assert_any_call( + text="<@selected_ol> has been assigned as operations lead for this incident.", + channel="channel_id", + ) + client.conversations_setTopic.assert_called_once_with( + topic="IC: <@selected_ic> / OL: <@selected_ol>", channel="channel_id" + )