diff --git a/src/firetower/incidents/migrations/0019_schedule_archive_stale_channels.py b/src/firetower/incidents/migrations/0019_schedule_archive_stale_channels.py new file mode 100644 index 00000000..95b0eea0 --- /dev/null +++ b/src/firetower/incidents/migrations/0019_schedule_archive_stale_channels.py @@ -0,0 +1,28 @@ +from django.db import migrations + +from firetower.incidents.tasks import SCHEDULES + + +def create_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "archive_stale_channels" + Schedule.objects.get_or_create( + name=schedule_name, defaults=SCHEDULES[schedule_name] + ) + + +def delete_schedule(apps, schema_editor): + Schedule = apps.get_model("django_q", "Schedule") + schedule_name = "archive_stale_channels" + Schedule.objects.filter(name=schedule_name).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "0018_add_action_item_model"), + ("django_q", "0018_task_success_index"), + ] + + operations = [ + migrations.RunPython(create_schedule, delete_schedule), + ] diff --git a/src/firetower/incidents/tasks.py b/src/firetower/incidents/tasks.py index 6df99481..b1b86159 100644 --- a/src/firetower/incidents/tasks.py +++ b/src/firetower/incidents/tasks.py @@ -1,19 +1,31 @@ import functools import logging import re +import time from typing import Protocol from datadog import statsd from django_q.tasks import Schedule -from firetower.incidents.models import Incident +from firetower.incidents.models import ( + ExternalLink, + ExternalLinkType, + Incident, + IncidentStatus, +) +from firetower.integrations.services.slack import SlackService SCHEDULES = { "schedule_demo": { "func": "firetower.incidents.tasks.schedule_demo", - "schedule_type": Schedule.MINUTES, # Minutes + "schedule_type": Schedule.MINUTES, "minutes": 5, - "repeats": -1, # repeat indefinitely + "repeats": -1, + }, + "archive_stale_channels": { + "func": "firetower.incidents.tasks.archive_stale_channels", + "schedule_type": Schedule.DAILY, + "repeats": -1, }, } @@ -60,6 +72,124 @@ def wrapper() -> None: return wrapper +ARCHIVE_NOTICE = ( + "This channel is being archived by Firetower because all message history " + "has been removed by the workspace retention policy and there doesn't " + "appear to be any active discussions." +) + +ARCHIVE_CHANNEL_DELAY_SECONDS = 2 + + +@datadog_log +def archive_stale_channels() -> None: + slack = SlackService() + if not slack.client: + logger.error( + "Slack client not initialized -- disabling archive_stale_channels schedule" + ) + Schedule.objects.filter(name="archive_stale_channels").update(repeats=0) + return + + own_bot_id = slack.bot_id + if not own_bot_id: + logger.error("Could not determine own bot ID, aborting archive run") + return + + terminal_statuses = [IncidentStatus.DONE, IncidentStatus.CANCELLED] + links = ExternalLink.objects.filter( + type=ExternalLinkType.SLACK, + incident__status__in=terminal_statuses, + ).select_related("incident") + + scanned = 0 + archived = 0 + skipped = 0 + errored = 0 + + for i, link in enumerate(links): + if i > 0: + time.sleep(ARCHIVE_CHANNEL_DELAY_SECONDS) + + scanned += 1 + channel_id = slack.parse_channel_id_from_url(link.url) + if not channel_id: + skipped += 1 + continue + + try: + info = slack.get_channel_info(channel_id) + if info is None: + logger.warning( + f"Could not fetch info for channel {channel_id} " + f"(incident {link.incident.incident_number}), skipping" + ) + skipped += 1 + continue + + if info.get("is_archived"): + skipped += 1 + continue + + messages = slack.get_channel_history(channel_id) + non_own_messages = [ + msg for msg in messages if msg.get("bot_id") != own_bot_id + ] + if non_own_messages: + skipped += 1 + continue + + has_thread_activity = False + for msg in messages: + if msg.get("reply_count", 0) > 0: + replies = slack.get_thread_replies(channel_id, msg["ts"]) + if replies: + has_thread_activity = True + break + if has_thread_activity: + skipped += 1 + continue + + notice_ts = slack.post_message(channel_id, ARCHIVE_NOTICE) + if not notice_ts: + logger.error( + f"Failed to post archive notice to channel {channel_id} " + f"(incident {link.incident.incident_number}), skipping archive" + ) + errored += 1 + continue + + try: + if not slack.archive_channel(channel_id): + raise RuntimeError( + f"archive_channel returned False for {channel_id}" + ) + archived += 1 + logger.info( + f"Archived stale channel {channel_id} " + f"(incident {link.incident.incident_number})" + ) + except Exception: + errored += 1 + logger.exception( + f"Failed to archive channel {channel_id} " + f"(incident {link.incident.incident_number}), " + f"deleting notice" + ) + slack.delete_message(channel_id, notice_ts) + except Exception: + errored += 1 + logger.exception( + f"Error processing channel {channel_id} " + f"(incident {link.incident.incident_number})" + ) + + logger.info( + f"archive_stale_channels complete: " + f"scanned={scanned} archived={archived} skipped={skipped} errored={errored}" + ) + + @datadog_log def schedule_demo() -> None: incident = Incident.objects.order_by("-created_at").first() diff --git a/src/firetower/incidents/tests/test_tasks.py b/src/firetower/incidents/tests/test_tasks.py index 109a0f84..ef3948ca 100644 --- a/src/firetower/incidents/tests/test_tasks.py +++ b/src/firetower/incidents/tests/test_tasks.py @@ -1,8 +1,21 @@ from unittest.mock import MagicMock, call, patch import pytest - -from firetower.incidents.tasks import datadog_log, schedule_demo +from django_q.models import Schedule + +from firetower.incidents.models import ( + ExternalLink, + ExternalLinkType, + Incident, + IncidentSeverity, + IncidentStatus, +) +from firetower.incidents.tasks import ( + ARCHIVE_NOTICE, + archive_stale_channels, + datadog_log, + schedule_demo, +) class TestDatadogLogTaskName: @@ -139,3 +152,459 @@ def test_shows_title_for_public_incident(self, mock_incident_cls, mock_statsd): logged = mock_logger.info.call_args[0][0] assert "Public outage" in logged + + +@pytest.mark.django_db +class TestArchiveStaleChannels: + @pytest.fixture(autouse=True) + def _no_sleep(self): + with patch("firetower.incidents.tasks.time.sleep"): + yield + + def _make_incident(self, **kwargs): + defaults = { + "title": "Test Incident", + "status": IncidentStatus.DONE, + "severity": IncidentSeverity.P2, + } + defaults.update(kwargs) + return Incident.objects.create(**defaults) + + def _make_link(self, incident, channel_id="C12345"): + return ExternalLink.objects.create( + incident=incident, + type=ExternalLinkType.SLACK, + url=f"https://sentry.slack.com/archives/{channel_id}", + ) + + OWN_BOT_ID = "B_FIRETOWER" + + def test_archives_channel_with_no_history(self): + incident = self._make_incident() + self._make_link(incident, "C_EMPTY") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_EMPTY" + mock_slack.get_channel_info.return_value = { + "id": "C_EMPTY", + "name": "inc-2000", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [] + mock_slack.archive_channel.return_value = True + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.get_channel_history.assert_called_once_with("C_EMPTY") + mock_slack.post_message.assert_called_once_with("C_EMPTY", ARCHIVE_NOTICE) + mock_slack.archive_channel.assert_called_once_with("C_EMPTY") + + def test_skips_channel_with_human_messages(self): + incident = self._make_incident() + self._make_link(incident, "C_ACTIVE") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_ACTIVE" + mock_slack.get_channel_info.return_value = { + "id": "C_ACTIVE", + "name": "inc-2001", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [ + {"type": "message", "user": "U123", "text": "still here", "ts": "1.0"} + ] + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.post_message.assert_not_called() + mock_slack.archive_channel.assert_not_called() + + def test_archives_channel_with_only_own_bot_messages(self): + incident = self._make_incident() + self._make_link(incident, "C_BOTONLY") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_BOTONLY" + mock_slack.get_channel_info.return_value = { + "id": "C_BOTONLY", + "name": "inc-2001", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [ + { + "type": "message", + "bot_id": self.OWN_BOT_ID, + "text": ARCHIVE_NOTICE, + "ts": "1.0", + } + ] + mock_slack.post_message.return_value = "2.0" + mock_slack.archive_channel.return_value = True + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.archive_channel.assert_called_once_with("C_BOTONLY") + + def test_skips_channel_with_other_bot_messages(self): + incident = self._make_incident() + self._make_link(incident, "C_OTHERBOT") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_OTHERBOT" + mock_slack.get_channel_info.return_value = { + "id": "C_OTHERBOT", + "name": "inc-2001", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [ + { + "type": "message", + "bot_id": "B_SOMEONE_ELSE", + "text": "alert from another bot", + "ts": "1.0", + } + ] + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.post_message.assert_not_called() + mock_slack.archive_channel.assert_not_called() + + def test_skips_already_archived_channel(self): + incident = self._make_incident() + self._make_link(incident, "C_ARCHIVED") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.parse_channel_id_from_url.return_value = "C_ARCHIVED" + mock_slack.get_channel_info.return_value = { + "id": "C_ARCHIVED", + "name": "inc-2002", + "is_private": False, + "is_archived": True, + } + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.get_channel_history.assert_not_called() + mock_slack.archive_channel.assert_not_called() + + def test_skips_channel_on_api_error(self): + incident = self._make_incident() + self._make_link(incident, "C_ERROR") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.parse_channel_id_from_url.return_value = "C_ERROR" + mock_slack.get_channel_info.return_value = None + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.get_channel_history.assert_not_called() + mock_slack.archive_channel.assert_not_called() + + def test_disables_schedule_when_no_client(self): + schedule = Schedule.objects.get(name="archive_stale_channels") + assert schedule.repeats == -1 + + mock_slack = MagicMock() + mock_slack.client = None + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + schedule.refresh_from_db() + assert schedule.repeats == 0 + + def test_continues_on_single_channel_exception(self): + inc1 = self._make_incident() + inc2 = self._make_incident() + self._make_link(inc1, "C_BAD") + self._make_link(inc2, "C_GOOD") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.side_effect = ( + lambda url: "C_BAD" if "C_BAD" in url else "C_GOOD" + ) + mock_slack.get_channel_info.side_effect = lambda cid: ( + (_ for _ in ()).throw(Exception("boom")) + if cid == "C_BAD" + else { + "id": "C_GOOD", + "name": "inc-x", + "is_private": False, + "is_archived": False, + } + ) + mock_slack.get_channel_history.return_value = [] + mock_slack.archive_channel.return_value = True + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.archive_channel.assert_called_once_with("C_GOOD") + + def test_deletes_notice_on_failed_archive(self): + incident = self._make_incident() + self._make_link(incident, "C_FAIL") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_FAIL" + mock_slack.get_channel_info.return_value = { + "id": "C_FAIL", + "name": "inc-2010", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [] + mock_slack.post_message.return_value = "1234.5678" + mock_slack.archive_channel.return_value = False + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.post_message.assert_called_once_with("C_FAIL", ARCHIVE_NOTICE) + mock_slack.delete_message.assert_called_once_with("C_FAIL", "1234.5678") + + def test_skips_channel_on_history_api_error(self): + incident = self._make_incident() + self._make_link(incident, "C_BROKEN") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_BROKEN" + mock_slack.get_channel_info.return_value = { + "id": "C_BROKEN", + "name": "inc-2011", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.side_effect = Exception("API error") + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.post_message.assert_not_called() + mock_slack.archive_channel.assert_not_called() + + def test_deletes_notice_on_archive_exception(self): + incident = self._make_incident() + self._make_link(incident, "C_THROW") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_THROW" + mock_slack.get_channel_info.return_value = { + "id": "C_THROW", + "name": "inc-2013", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [] + mock_slack.post_message.return_value = "99.99" + mock_slack.archive_channel.side_effect = ConnectionError("network timeout") + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.delete_message.assert_called_once_with("C_THROW", "99.99") + + def test_skips_archive_when_post_message_fails(self): + incident = self._make_incident() + self._make_link(incident, "C_NOPOST") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_NOPOST" + mock_slack.get_channel_info.return_value = { + "id": "C_NOPOST", + "name": "inc-2012", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [] + mock_slack.post_message.return_value = None + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.archive_channel.assert_not_called() + + def test_skips_active_incident_channels(self): + active = self._make_incident(status=IncidentStatus.ACTIVE) + self._make_link(active, "C_ACTIVE_INC") + mitigated = self._make_incident(status=IncidentStatus.MITIGATED) + self._make_link(mitigated, "C_MITIGATED_INC") + postmortem = self._make_incident(status=IncidentStatus.POSTMORTEM) + self._make_link(postmortem, "C_POSTMORTEM_INC") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.parse_channel_id_from_url.assert_not_called() + mock_slack.archive_channel.assert_not_called() + + def test_includes_cancelled_incident_channels(self): + incident = self._make_incident(status=IncidentStatus.CANCELLED) + self._make_link(incident, "C_CANCELLED") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_CANCELLED" + mock_slack.get_channel_info.return_value = { + "id": "C_CANCELLED", + "name": "inc-3000", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [] + mock_slack.get_thread_replies.return_value = [] + mock_slack.archive_channel.return_value = True + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.archive_channel.assert_called_once_with("C_CANCELLED") + + def test_fetches_full_history_not_limited(self): + incident = self._make_incident() + self._make_link(incident, "C_DEEPHISTORY") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_DEEPHISTORY" + mock_slack.get_channel_info.return_value = { + "id": "C_DEEPHISTORY", + "name": "inc-4000", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [ + {"type": "message", "bot_id": self.OWN_BOT_ID, "text": "bot1", "ts": "5.0"}, + {"type": "message", "bot_id": self.OWN_BOT_ID, "text": "bot2", "ts": "4.0"}, + {"type": "message", "bot_id": self.OWN_BOT_ID, "text": "bot3", "ts": "3.0"}, + {"type": "message", "bot_id": self.OWN_BOT_ID, "text": "bot4", "ts": "2.0"}, + {"type": "message", "bot_id": self.OWN_BOT_ID, "text": "bot5", "ts": "1.5"}, + { + "type": "message", + "user": "U_HUMAN", + "text": "old human msg", + "ts": "1.0", + }, + ] + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.get_channel_history.assert_called_once_with("C_DEEPHISTORY") + mock_slack.archive_channel.assert_not_called() + + def test_skips_channel_with_human_thread_replies(self): + incident = self._make_incident() + self._make_link(incident, "C_THREADS") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_THREADS" + mock_slack.get_channel_info.return_value = { + "id": "C_THREADS", + "name": "inc-5000", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [ + { + "type": "message", + "bot_id": self.OWN_BOT_ID, + "text": "bot msg with thread", + "ts": "1.0", + "reply_count": 2, + } + ] + mock_slack.get_thread_replies.return_value = [ + {"type": "message", "user": "U_HUMAN", "text": "reply", "ts": "1.1"} + ] + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.get_thread_replies.assert_called_once_with("C_THREADS", "1.0") + mock_slack.archive_channel.assert_not_called() + + def test_archives_channel_with_bot_only_threads(self): + incident = self._make_incident() + self._make_link(incident, "C_BOTTHREADS") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = self.OWN_BOT_ID + mock_slack.parse_channel_id_from_url.return_value = "C_BOTTHREADS" + mock_slack.get_channel_info.return_value = { + "id": "C_BOTTHREADS", + "name": "inc-5001", + "is_private": False, + "is_archived": False, + } + mock_slack.get_channel_history.return_value = [ + { + "type": "message", + "bot_id": self.OWN_BOT_ID, + "text": "bot msg", + "ts": "1.0", + "reply_count": 1, + } + ] + mock_slack.get_thread_replies.return_value = [] + mock_slack.post_message.return_value = "2.0" + mock_slack.archive_channel.return_value = True + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.archive_channel.assert_called_once_with("C_BOTTHREADS") + + def test_aborts_when_bot_id_is_none(self): + incident = self._make_incident() + self._make_link(incident, "C_NOBOT") + + mock_slack = MagicMock() + mock_slack.client = True + mock_slack.bot_id = None + + with patch("firetower.incidents.tasks.SlackService", return_value=mock_slack): + archive_stale_channels.__wrapped__() + + mock_slack.parse_channel_id_from_url.assert_not_called() + mock_slack.archive_channel.assert_not_called() diff --git a/src/firetower/integrations/services/slack.py b/src/firetower/integrations/services/slack.py index 692b0332..356252b9 100644 --- a/src/firetower/integrations/services/slack.py +++ b/src/firetower/integrations/services/slack.py @@ -61,10 +61,25 @@ def __init__(self) -> None: ) self.client = WebClient(token=self.bot_token) if self.bot_token else None + self._bot_id: str | None = None if self.client is None: logger.warning("Slack client not initialized - missing bot token") + @property + def bot_id(self) -> str | None: + if self._bot_id is not None: + return self._bot_id + if not self.client: + return None + try: + response = self.client.auth_test() + self._bot_id = response.get("bot_id") + return self._bot_id + except SlackApiError as e: + logger.error(f"Error fetching bot identity: {e}") + return None + def get_user_profile_by_email(self, email: str) -> dict | None: """ Get user profile information from Slack by email. @@ -311,6 +326,21 @@ def pin_message(self, channel_id: str, message_ts: str) -> bool: ) return False + def delete_message(self, channel_id: str, message_ts: str) -> bool: + if not self.client: + logger.warning("Cannot delete message - Slack client not initialized") + return False + + try: + self.client.chat_delete(channel=channel_id, ts=message_ts) + return True + except SlackApiError as e: + logger.error( + f"Error deleting message: {e}", + extra={"channel_id": channel_id, "ts": message_ts}, + ) + return False + def add_bookmark(self, channel_id: str, title: str, link: str) -> bool: if not self.client: logger.warning("Cannot add bookmark - Slack client not initialized") @@ -359,6 +389,7 @@ def get_channel_info(self, channel_id: str) -> dict | None: "id": channel.get("id", ""), "name": channel.get("name", ""), "is_private": channel.get("is_private", False), + "is_archived": channel.get("is_archived", False), } except SlackApiError as e: logger.error( @@ -419,10 +450,19 @@ def get_user_info(self, slack_user_id: str) -> dict | None: ) return None - def get_channel_history(self, channel_id: str) -> list[dict[str, Any]]: - """Return all messages from a channel, paginating automatically.""" + def get_channel_history( + self, channel_id: str, limit: int | None = None + ) -> list[dict[str, Any]]: + """Return messages from a channel. When *limit* is set, fetch at most + that many messages in a single API call (no pagination). When *limit* + is ``None``, paginate to retrieve all messages.""" if not self.client: return [] + if limit is not None: + response = self.client.conversations_history( + channel=channel_id, limit=limit + ) + return response.get("messages", []) messages: list[dict[str, Any]] = [] cursor: str | None = None while True: @@ -446,6 +486,22 @@ def get_channel_history(self, channel_id: str) -> list[dict[str, Any]]: break return messages + def archive_channel(self, channel_id: str) -> bool: + if not self.client: + logger.warning("Cannot archive channel - Slack client not initialized") + return False + + try: + logger.info(f"Archiving channel {channel_id}") + self.client.conversations_archive(channel=channel_id) + return True + except SlackApiError as e: + logger.error( + f"Error archiving channel: {e}", + extra={"channel_id": channel_id}, + ) + return False + def get_thread_replies( self, channel_id: str, thread_ts: str ) -> list[dict[str, Any]]: diff --git a/src/firetower/integrations/tests/test_slack.py b/src/firetower/integrations/tests/test_slack.py index ca5c0989..f51ee27e 100644 --- a/src/firetower/integrations/tests/test_slack.py +++ b/src/firetower/integrations/tests/test_slack.py @@ -5,6 +5,7 @@ import os from unittest.mock import MagicMock, patch +import pytest from slack_sdk.errors import SlackApiError from firetower.integrations.services.slack import SlackService, is_slack_guest @@ -591,6 +592,106 @@ def test_pin_message_api_error(self): mock_client.pins_add.side_effect = SlackApiError("error", mock_response) assert service.pin_message("C12345", "1234567890.123456") is False + def test_archive_channel_success(self): + service, mock_client = self._make_service() + assert service.archive_channel("C12345") is True + mock_client.conversations_archive.assert_called_once_with(channel="C12345") + + def test_archive_channel_no_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.archive_channel("C12345") is False + + def test_archive_channel_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_client.conversations_archive.side_effect = SlackApiError( + "error", mock_response + ) + assert service.archive_channel("C12345") is False + + def test_get_channel_history_with_limit(self): + service, mock_client = self._make_service() + mock_client.conversations_history.return_value = { + "ok": True, + "messages": [{"type": "message", "text": "hello", "ts": "1.0"}], + } + + messages = service.get_channel_history("C123", limit=1) + + assert len(messages) == 1 + mock_client.conversations_history.assert_called_once_with( + channel="C123", limit=1 + ) + + def test_get_channel_history_with_limit_no_pagination(self): + service, mock_client = self._make_service() + mock_client.conversations_history.return_value = { + "ok": True, + "has_more": True, + "messages": [{"type": "message", "text": "hello", "ts": "1.0"}], + "response_metadata": {"next_cursor": "cur1"}, + } + + messages = service.get_channel_history("C123", limit=1) + + assert len(messages) == 1 + assert mock_client.conversations_history.call_count == 1 + + def test_get_channel_history_with_limit_empty(self): + service, mock_client = self._make_service() + mock_client.conversations_history.return_value = { + "ok": True, + "messages": [], + } + + messages = service.get_channel_history("C123", limit=1) + + assert messages == [] + + def test_get_channel_history_with_limit_raises_on_error(self): + service, mock_client = self._make_service() + mock_client.conversations_history.side_effect = Exception("timeout") + + with pytest.raises(Exception, match="timeout"): + service.get_channel_history("C123", limit=1) + + def test_get_channel_info_includes_is_archived(self): + service, mock_client = self._make_service() + mock_client.conversations_info.return_value = { + "channel": { + "id": "C12345", + "name": "inc-2000", + "is_private": False, + "is_archived": True, + } + } + + info = service.get_channel_info("C12345") + assert info is not None + assert info["is_archived"] is True + + def test_bot_id_returns_cached_value(self): + service, mock_client = self._make_service() + mock_client.auth_test.return_value = {"bot_id": "B_TEST"} + + assert service.bot_id == "B_TEST" + assert service.bot_id == "B_TEST" + mock_client.auth_test.assert_called_once() + + def test_bot_id_returns_none_without_client(self): + mock_slack_config = {"BOT_TOKEN": None, "TEAM_ID": "sentry"} + with patch.object(settings, "SLACK", mock_slack_config): + service = SlackService() + assert service.bot_id is None + + def test_bot_id_returns_none_on_api_error(self): + service, mock_client = self._make_service() + mock_response = MagicMock() + mock_client.auth_test.side_effect = SlackApiError("error", mock_response) + assert service.bot_id is None + class TestIsSlackGuest: def test_returns_true_for_restricted_user(self):