diff --git a/.env.example b/.env.example index 5f04b9ff..ca80d771 100644 --- a/.env.example +++ b/.env.example @@ -95,6 +95,7 @@ ENABLE_RAID=False RAID_DEFAULT_JIRA_QRAFT_USER_ID="XXXXXXXX" #gitleaks:allow RAID_JIRA_PROJECT_KEY="T2" +RAID_TOOLBOX_URL=https://toolbox.mycompany.com/login FF_SLACK_SKIP_CHECKS=true # Disable SSO redirect for local dev by setting to true. When SSO is disabled, go to /admin/ to login diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c68bc20e..bf4afafc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,13 @@ jobs: pdm run collectstatic pdm run tests-cov + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + flags: unittests + - name: Type check with mypy run: | pdm run lint-mypy diff --git a/docker-compose.yaml b/docker-compose.yaml index 8afa5f8f..97beddda 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,8 +2,6 @@ # This configuration file is for the **development** setup. # It will launch Postgres and Redis containers, not the app itself. -version: "3.1" - services: db: container_name: ff-db diff --git a/pdm.lock b/pdm.lock index b99cec42..f3d0abda 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "docs", "tests", "types", "docs-img"] strategy = ["cross_platform", "static_urls"] lock_version = "4.4.1" -content_hash = "sha256:c5dfc2093b498ae65246942e71dc670f808363ccf6c104884d8182e26093de12" +content_hash = "sha256:78d7bf38ebaa89258e7bb1b21cda0ca3f2bd8ac00b15ffe635c160be97b02890" [[package]] name = "aiohttp" @@ -1661,11 +1661,10 @@ files = [ [[package]] name = "jira" -version = "3.8.0" +version = "3.5.2" requires_python = ">=3.8" summary = "Python library for interacting with JIRA via REST APIs." dependencies = [ - "Pillow>=2.1.0", "defusedxml", "packaging", "requests-oauthlib>=1.1.0", @@ -1674,8 +1673,8 @@ dependencies = [ "typing-extensions>=3.7.4.2", ] files = [ - {url = "https://files.pythonhosted.org/packages/4f/52/bb617020064261ba31cc965e932943458b7facfd9691ad7f76a2b631f44f/jira-3.8.0-py3-none-any.whl", hash = "sha256:12190dc84dad00b8a6c0341f7e8a254b0f38785afdec022bd5941e1184a5a3fb"}, - {url = "https://files.pythonhosted.org/packages/78/b4/557e4c80c0ea12164ffeec0e29372c085bfb263faad53cef5e1455523bec/jira-3.8.0.tar.gz", hash = "sha256:63719c529a570aaa01c3373dbb5a104dab70381c5be447f6c27f997302fa335a"}, + {url = "https://files.pythonhosted.org/packages/57/e3/6e7dec954fed0a8d7f4b02d7f0a2f4628cfb9fc8ccfee699d7c1139db09b/jira-3.5.2-py3-none-any.whl", hash = "sha256:f97716cd1e35523d04cb75b742143f1f3aaf098eb0ab61ad33e91812beb9edcc"}, + {url = "https://files.pythonhosted.org/packages/ea/40/8db893f2d1c0fa6e64f8500d54b1e756f6ff00cada962c0ea8d2daab19a8/jira-3.5.2.tar.gz", hash = "sha256:d23a0e0c62c0d6926ac37c68bc0b362065ae19106fe5c79d4ba964fad80d6cf2"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 128d2366..63f2817d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "django-htmx>=1.17.2", "django-simple-menu>=2.1.3", "markdown>=3.5.1", - "jira>=3.5.2", + "jira>=3.5.2,<3.6.0", # 3.6.0 stop accepting user id for add_watcher, see https://github.com/pycontribs/jira/issues/1855 "drf-standardized-errors>=0.12.6", "aiohttp>=3.9.1", # Transitive dependency of slack_sdk "nh3>=0.2.15", diff --git a/src/firefighter/firefighter/settings/components/raid.py b/src/firefighter/firefighter/settings/components/raid.py index 5cf2c66d..d11b8e75 100644 --- a/src/firefighter/firefighter/settings/components/raid.py +++ b/src/firefighter/firefighter/settings/components/raid.py @@ -17,3 +17,6 @@ RAID_JIRA_USER_IDS: dict[str, str] = {} "Mapping of domain to default Jira user ID" + + RAID_TOOLBOX_URL: str = config("RAID_TOOLBOX_URL") + "Toolbox URL" diff --git a/src/firefighter/incidents/forms/edit.py b/src/firefighter/incidents/forms/edit.py new file mode 100644 index 00000000..a9ea49e2 --- /dev/null +++ b/src/firefighter/incidents/forms/edit.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from django import forms + +from firefighter.incidents.models import Environment + + +def initial_environments() -> Environment: + return Environment.objects.get(default=True) + + +class EditMetaForm(forms.Form): + title = forms.CharField( + label="Title", + max_length=128, + min_length=10, + widget=forms.TextInput(attrs={"placeholder": "What's going on?"}), + ) + description = forms.CharField( + label="Summary", + widget=forms.Textarea( + attrs={ + "placeholder": "Help people responding to the incident. This will be posted to #tech-incidents and on our internal status page.\nThis description can be edited later." + } + ), + min_length=10, + max_length=1200, + ) + environment = forms.ModelChoiceField( + label="Environment", + queryset=Environment.objects.all(), + initial=initial_environments, + ) diff --git a/src/firefighter/incidents/migrations/0004_incidentupdate_environment.py b/src/firefighter/incidents/migrations/0004_incidentupdate_environment.py new file mode 100644 index 00000000..6fbc0de6 --- /dev/null +++ b/src/firefighter/incidents/migrations/0004_incidentupdate_environment.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.14 on 2024-09-27 10:06 + +from django.db import migrations, models + +import firefighter.incidents.models.environment + + +class Migration(migrations.Migration): + + dependencies = [ + ("incidents", "0003_delete_featureteam"), + ] + + operations = [ + migrations.AddField( + model_name="incidentupdate", + name="environment", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.SET( + firefighter.incidents.models.environment.Environment.get_default + ), + to="incidents.environment", + ), + ), + ] diff --git a/src/firefighter/incidents/models/incident.py b/src/firefighter/incidents/models/incident.py index 5d147746..a84eaaaa 100644 --- a/src/firefighter/incidents/models/incident.py +++ b/src/firefighter/incidents/models/incident.py @@ -335,21 +335,27 @@ def can_be_closed(self) -> tuple[bool, list[tuple[str, str]]]: return True, [] if self.needs_postmortem: if self.status.value != IncidentStatus.POST_MORTEM: - cant_closed_reasons.append(( - "STATUS_NOT_POST_MORTEM", - f"Incident is not in PostMortem status, and needs one because of its priority and environment ({self.priority.name}/{self.environment.value}).", - )) + cant_closed_reasons.append( + ( + "STATUS_NOT_POST_MORTEM", + f"Incident is not in PostMortem status, and needs one because of its priority and environment ({self.priority.name}/{self.environment.value}).", + ) + ) elif self.status.value < IncidentStatus.FIXED: - cant_closed_reasons.append(( - "STATUS_NOT_MITIGATED", - f"Incident is not in {IncidentStatus.FIXED.label} status (currently {self.status.label}).", - )) + cant_closed_reasons.append( + ( + "STATUS_NOT_MITIGATED", + f"Incident is not in {IncidentStatus.FIXED.label} status (currently {self.status.label}).", + ) + ) missing_milestones = self.missing_milestones() if len(missing_milestones) > 0: - cant_closed_reasons.append(( - "MISSING_REQUIRED_KEY_EVENTS", - f"Missing key events: {', '.join(missing_milestones)}", - )) + cant_closed_reasons.append( + ( + "MISSING_REQUIRED_KEY_EVENTS", + f"Missing key events: {', '.join(missing_milestones)}", + ) + ) if len(cant_closed_reasons) > 0: return False, cant_closed_reasons @@ -534,7 +540,7 @@ def update_roles( return incident_update - def create_incident_update( + def create_incident_update( # noqa: PLR0913 self: Incident, message: str | None = None, status: int | None = None, @@ -544,6 +550,7 @@ def create_incident_update( event_type: str | None = None, title: str | None = None, description: str | None = None, + environment_id: str | None = None, event_ts: datetime | None = None, ) -> IncidentUpdate: updated_fields: list[str] = [] @@ -560,6 +567,7 @@ def _update_incident_field( _update_incident_field(self, "component_id", component_id, updated_fields) _update_incident_field(self, "title", title, updated_fields) _update_incident_field(self, "description", description, updated_fields) + _update_incident_field(self, "environment_id", environment_id, updated_fields) old_priority = self.priority if priority_id is not None else None @@ -573,6 +581,7 @@ def _update_incident_field( incident=self, status=status, # type: ignore priority_id=priority_id, + environment_id=environment_id, component_id=component_id, message=message, created_by=created_by, diff --git a/src/firefighter/incidents/models/incident_update.py b/src/firefighter/incidents/models/incident_update.py index d78ed0b8..10c0950a 100644 --- a/src/firefighter/incidents/models/incident_update.py +++ b/src/firefighter/incidents/models/incident_update.py @@ -12,6 +12,7 @@ from firefighter.incidents.enums import IncidentStatus from firefighter.incidents.models.component import Component +from firefighter.incidents.models.environment import Environment from firefighter.incidents.models.priority import Priority from firefighter.incidents.models.severity import Severity from firefighter.incidents.models.user import User @@ -65,6 +66,12 @@ class IncidentUpdate(models.Model): priority = models.ForeignKey[Priority | None, Priority | None]( Priority, null=True, blank=True, on_delete=models.SET(Priority.get_default) ) + environment = models.ForeignKey[Environment | None, Environment | None]( + Environment, + null=True, + blank=True, + on_delete=models.SET(Environment.get_default), + ) incident = models.ForeignKey["Incident", "Incident"]( "Incident", on_delete=models.CASCADE ) diff --git a/src/firefighter/incidents/templates/layouts/partials/incident_timeline.html b/src/firefighter/incidents/templates/layouts/partials/incident_timeline.html index 35ff6698..2959df7c 100644 --- a/src/firefighter/incidents/templates/layouts/partials/incident_timeline.html +++ b/src/firefighter/incidents/templates/layouts/partials/incident_timeline.html @@ -40,6 +40,26 @@
+ {% if incident_update.title %} +

+ Title update +

+

+ Title changed to: {{ incident_update.title }} +

+ {% endif %} + {% if incident_update.description or incident_update.description|length > 0 %} +

+ Description update +

+
+
+ {{ incident_update.description | urlize | linebreaksbr}} +
+
+ {% endif %} {% if incident_update.event_type or incident_update.event_type|length > 0 %}

Key event: {{ incident_update.event_type|title }} @@ -65,6 +85,17 @@ {% include "./status_pill.html" with status=incident_update.status IncidentStatus=IncidentStatus only %}

{% endif %} + {% if incident_update.environment %} +

+ Environment update +

+ {% endif %} + {% if incident_update.environment or incident_update.environment|length > 0 %} +

+ Environment changed to: + {% include "./environment_pill.html" with environment=incident_update.environment only %} +

+ {% endif %} {% if incident_update.severity %}

Severity update @@ -84,8 +115,6 @@

Component update

- {% endif %} - {% if incident_update.component %}

Component impacted changed to: {{ incident_update.component}} diff --git a/src/firefighter/incidents/templates/pages/incident_detail.html b/src/firefighter/incidents/templates/pages/incident_detail.html index bf89767a..0c39ecc6 100644 --- a/src/firefighter/incidents/templates/pages/incident_detail.html +++ b/src/firefighter/incidents/templates/pages/incident_detail.html @@ -189,7 +189,7 @@

- {% include "../layouts/partials/user_card.html" with user=role.user title=role.role.name only %} + {% include "../layouts/partials/user_card.html" with user=role.user title=role.role_type.name only %} {% include "../layouts/partials/user_tooltip.html" with user=role.user only %} {% endfor %} @@ -207,6 +207,12 @@

{{impact.impact_level.value_label}}: {{impact.impact_level.name}}

+ {% empty %} +
+

+ No impacts defined at the moment. +

+
{% endfor %} {% endfill %} {% endcomponent %} diff --git a/src/firefighter/raid/client.py b/src/firefighter/raid/client.py index cd7233b9..486dacb8 100644 --- a/src/firefighter/raid/client.py +++ b/src/firefighter/raid/client.py @@ -24,6 +24,7 @@ logger = logging.getLogger(__name__) RAID_JIRA_PROJECT_KEY: Final[str] = settings.RAID_JIRA_PROJECT_KEY +TOOLBOX_URL: Final[str] = settings.RAID_TOOLBOX_URL # XXX Do not hardcode this, it should be a setting or fetched from Jira RAID_JIRA_WORKFLOW_NAME: Final[str] = "Incident workflow - v2023.03.13" TARGET_STATUS_NAME: Final[str] = "Closed" @@ -75,7 +76,7 @@ def create_issue( # noqa: PLR0912, PLR0913, C901, PLR0917 extra_args["customfield_10895"] = str(zendesk_ticket_id) if seller_contract_id: description_addendum.append( - f"Seller link to BO: https://bo.monechelle.com/provider/catalog/listproducts?provider_id={seller_contract_id}" + f"Seller link to TOOLBOX: {TOOLBOX_URL}?seller_id={seller_contract_id}" ) extra_args["customfield_10908"] = str(seller_contract_id) if is_seller_in_golden_list: diff --git a/src/firefighter/raid/messages.py b/src/firefighter/raid/messages.py index 709c7747..b30dfcf8 100644 --- a/src/firefighter/raid/messages.py +++ b/src/firefighter/raid/messages.py @@ -3,6 +3,7 @@ import textwrap from typing import TYPE_CHECKING +from django.conf import settings from slack_sdk.models.blocks.basic_components import MarkdownTextObject from slack_sdk.models.blocks.blocks import ( Block, @@ -13,12 +14,10 @@ from firefighter.slack.messages.base import SlackMessageSurface from firefighter.slack.slack_templating import user_slack_handle_or_name -if TYPE_CHECKING: - from django.conf import settings +RAID_JIRA_API_URL: str = settings.RAID_JIRA_API_URL +if TYPE_CHECKING: from firefighter.incidents.models.user import User - - RAID_JIRA_API_URL: str = settings.RAID_JIRA_API_URL from firefighter.raid.models import JiraTicket diff --git a/src/firefighter/slack/messages/slack_messages.py b/src/firefighter/slack/messages/slack_messages.py index f2453386..9b57ea8a 100644 --- a/src/firefighter/slack/messages/slack_messages.py +++ b/src/firefighter/slack/messages/slack_messages.py @@ -359,18 +359,20 @@ def get_blocks(self) -> list[Block]: if len(fields) == 0: fields.append("_No changes detected._") - blocks.extend([ - DividerBlock(), - SectionBlock( - block_id="message_role_update", - fields=fields, - accessory=ButtonElement( - text="Update", - value=str(self.incident.id), - action_id=UpdateRolesModal.open_action, + blocks.extend( + [ + DividerBlock(), + SectionBlock( + block_id="message_role_update", + fields=fields, + accessory=ButtonElement( + text="Update", + value=str(self.incident.id), + action_id=UpdateRolesModal.open_action, + ), ), - ), - ]) + ] + ) if not self.first_update: blocks.append( ContextBlock( @@ -457,6 +459,16 @@ def get_blocks(self) -> list[Block]: blocks.append( slack_block_quote(self.incident_update.message), ) + if self.incident_update.title and self.incident_update.title != "": + blocks.append( + SectionBlock( + text=f"New title: *{shorten(self.incident_update.title, 2985)}*" + ), + ) + if self.incident_update.description and self.incident_update.description != "": + blocks.append( + slack_block_quote(self.incident_update.description), + ) fields = [] if self.in_channel: if self.incident_update.status: @@ -477,24 +489,32 @@ def get_blocks(self) -> list[Block]: text=f":package: *Component:* {self.incident.component.group.name} - {self.incident.component.name}" ) ) + if self.incident_update.environment: + fields.append( + MarkdownTextObject( + text=f":round_pushpin: *Environment:* {self.incident_update.environment.value}" + ) + ) if len(fields) > 0: - blocks.extend([ - DividerBlock(), - SectionBlock( - block_id="message_status_update", - fields=fields, - accessory=( - ButtonElement( - text="Update", - value=str(self.incident.id), - action_id=UpdateStatusModal.open_action, - ) - if self.in_channel - else None + blocks.extend( + [ + DividerBlock(), + SectionBlock( + block_id="message_status_update", + fields=fields, + accessory=( + ButtonElement( + text="Update", + value=str(self.incident.id), + action_id=UpdateStatusModal.open_action, + ) + if self.in_channel + else None + ), ), - ), - ]) + ] + ) if self.incident_update.created_by: blocks.append( @@ -683,13 +703,15 @@ def get_blocks(self) -> list[Block]: ] if self.incident.status >= IncidentStatus.FIXED: - blocks.extend([ - SectionBlock( - text=MarkdownTextObject( - text=f":white_check_mark: *UPDATE*: Incident #{self.incident.conversation.name} has been mitigated, you can resume your deployments." + blocks.extend( + [ + SectionBlock( + text=MarkdownTextObject( + text=f":white_check_mark: *UPDATE*: Incident #{self.incident.conversation.name} has been mitigated, you can resume your deployments." + ) ) - ) - ]) + ] + ) return blocks def get_text(self) -> str: diff --git a/src/firefighter/slack/views/events/commands.py b/src/firefighter/slack/views/events/commands.py index 77258096..ec98eb33 100644 --- a/src/firefighter/slack/views/events/commands.py +++ b/src/firefighter/slack/views/events/commands.py @@ -16,6 +16,7 @@ from firefighter.slack.views.modals import ( modal_close, modal_dowgrade_workflow, + modal_edit, modal_open, modal_postmortem, modal_send_sos, @@ -106,6 +107,8 @@ def manage_incident(ack: Ack, respond: Respond, body: dict[str, Any]) -> None: modal_open.open_modal_aio(ack, body) elif command == "update": modal_update.open_modal_aio(ack, body) + elif command == "edit": + modal_edit.open_modal_aio(ack, body) elif command == "close": modal_close.open_modal_aio(ack=ack, body=body) elif command == "status": diff --git a/src/firefighter/slack/views/modals/__init__.py b/src/firefighter/slack/views/modals/__init__.py index 2566b07d..8eb35b58 100644 --- a/src/firefighter/slack/views/modals/__init__.py +++ b/src/firefighter/slack/views/modals/__init__.py @@ -7,6 +7,7 @@ DowngradeWorkflowModal, modal_dowgrade_workflow, ) +from firefighter.slack.views.modals.edit import EditMetaModal, modal_edit from firefighter.slack.views.modals.key_event_message import ( # XXX(dugab) move and rename (not a modal but a surface...) KeyEvents, ) @@ -39,6 +40,7 @@ selectable_modals: list[type[SlackModal]] = [ UpdateModal, + EditMetaModal, UpdateRolesModal, OnCallModal, CloseModal, diff --git a/src/firefighter/slack/views/modals/edit.py b/src/firefighter/slack/views/modals/edit.py new file mode 100644 index 00000000..b3312909 --- /dev/null +++ b/src/firefighter/slack/views/modals/edit.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from slack_sdk.models.views import View + +from firefighter.incidents.forms.edit import EditMetaForm +from firefighter.slack.views.modals.base_modal.base import ModalForm + +if TYPE_CHECKING: + from slack_bolt.context.ack.ack import Ack + + from firefighter.incidents.models.incident import Incident + from firefighter.incidents.models.user import User + from firefighter.slack.views.modals.base_modal.form_utils import ( + SlackFormAttributesDict, + ) + +logger = logging.getLogger(__name__) + + +class EditMetaFormSlack(EditMetaForm): + slack_fields: SlackFormAttributesDict = { + "title": { + "input": { + "multiline": False, + "placeholder": "Short, punchy description of what's happening.", + }, + "block": {"hint": None}, + }, + "description": { + "input": { + "multiline": True, + "placeholder": "Help people responding to the incident. This will be posted to #tech-incidents and on our internal status page.\nThis description can be edited later.", + }, + "block": {"hint": None}, + }, + "environment": { + "input": { + "placeholder": "Select an environment", + }, + "widget": { + "label_from_instance": lambda obj: f"{obj.value} - {obj.description}", + }, + }, + } + + +class EditMetaModal(ModalForm[EditMetaFormSlack]): + open_action: str = "open_modal_incident_edit" + update_action: str = "update_modal_incident_edit" + push_action: str = "push_modal_incident_edit" + open_shortcut = "modal_edit" + callback_id: str = "incident_edit_incident" + + form_class = EditMetaFormSlack + + def build_modal_fn(self, incident: Incident, **kwargs: Any) -> View: + blocks = self.get_form_class()( + initial={ + "title": incident.title, + "description": incident.description, + "environment": incident.environment, + }, + ).slack_blocks() + + return View( + type="modal", + title=f"Update incident #{incident.id}"[:24], + submit="Update incident"[:24], + callback_id=self.callback_id, + private_metadata=str(incident.id), + blocks=blocks, + ) + + def handle_modal_fn( # type: ignore + self, ack: Ack, body: dict[str, Any], incident: Incident, user: User + ): + + slack_form = self.handle_form_errors( + ack, + body, + forms_kwargs={ + "initial": { + "title": incident.title, + "description": incident.description, + "environment": incident.environment, + } + }, + ) + if slack_form is None: + return + form: EditMetaFormSlack = slack_form.form + if len(form.cleaned_data) == 0: + # XXX We should have a prompt for empty forms + + return + update_kwargs: dict[str, Any] = {} + for changed_key in form.changed_data: + if changed_key == "environment": + update_kwargs[f"{changed_key}_id"] = form.cleaned_data[changed_key].id + if changed_key in {"description", "title"}: + update_kwargs[changed_key] = form.cleaned_data[changed_key] + if len(update_kwargs) == 0: + logger.warning("No update to incident status") + return + self._trigger_incident_workflow(incident, user, **update_kwargs) + + @staticmethod + def _trigger_incident_workflow( + incident: Incident, user: User, **kwargs: Any + ) -> None: + incident.create_incident_update(created_by=user, **kwargs) + + +modal_edit = EditMetaModal() diff --git a/src/firefighter/slack/views/modals/update.py b/src/firefighter/slack/views/modals/update.py index 0e559076..c1a24ce9 100644 --- a/src/firefighter/slack/views/modals/update.py +++ b/src/firefighter/slack/views/modals/update.py @@ -11,6 +11,7 @@ from firefighter.slack.views.modals.base_modal.mixins import ( IncidentSelectableModalMixin, ) +from firefighter.slack.views.modals.edit import EditMetaModal from firefighter.slack.views.modals.update_roles import UpdateRolesModal from firefighter.slack.views.modals.update_status import UpdateStatusModal @@ -46,6 +47,10 @@ def build_modal_fn(self, incident: Incident, **kwargs: Any) -> View: text="Update roles", action_id=UpdateRolesModal.push_action, ), + ButtonElement( + text="Edit meta", + action_id=EditMetaModal.push_action, + ), ], ) ],