diff --git a/src/sentry/integrations/slack/message_builder/types.py b/src/sentry/integrations/slack/message_builder/types.py index 10c348ddce43..8e51ca7b0d45 100644 --- a/src/sentry/integrations/slack/message_builder/types.py +++ b/src/sentry/integrations/slack/message_builder/types.py @@ -33,6 +33,8 @@ class SlackAction(StrEnum): SEER_AGENT_WRITE_APPROVE = "seer_agent_write_approve" SEER_AGENT_WRITE_REJECT = "seer_agent_write_reject" SEER_INVESTIGATION_START = "seer_investigation_start" + SEER_VIEW_BILLING = "seer_view_billing" + SEER_REQUEST_CREDITS = "seer_request_credits" INCIDENT_COLOR_MAPPING = { diff --git a/src/sentry/integrations/slack/webhooks/action.py b/src/sentry/integrations/slack/webhooks/action.py index bc7280a9618f..4d9dbcac37a2 100644 --- a/src/sentry/integrations/slack/webhooks/action.py +++ b/src/sentry/integrations/slack/webhooks/action.py @@ -893,6 +893,8 @@ def post(self, request: Request) -> Response: SlackAction.SEER_AUTOFIX_VIEW_IN_SENTRY.value, SlackAction.SEER_AUTOFIX_VIEW_PR.value, SlackAction.SEER_INVESTIGATION_START.value, + SlackAction.SEER_VIEW_BILLING.value, + SlackAction.SEER_REQUEST_CREDITS.value, }: return self.respond() diff --git a/src/sentry/notifications/platform/slack/renderers/seer.py b/src/sentry/notifications/platform/slack/renderers/seer.py index 0012542bb4d4..56d42477e53a 100644 --- a/src/sentry/notifications/platform/slack/renderers/seer.py +++ b/src/sentry/notifications/platform/slack/renderers/seer.py @@ -29,8 +29,10 @@ from sentry.notifications.platform.slack.provider import SlackRenderable from sentry.notifications.platform.templates.seer import ( SeerAgentError, + SeerAgentOutOfCredits, SeerAgentResponse, SeerAutofixError, + SeerAutofixOutOfCredits, SeerAutofixStopped, SeerAutofixTrigger, SeerAutofixUpdate, @@ -48,6 +50,7 @@ ) from sentry.seer.agent.client_models import RepoPRState from sentry.seer.autofix.utils import AutofixStoppingPoint, CodingAgentProviderType +from sentry.seer.credits import OUT_OF_CREDITS if TYPE_CHECKING: from sentry.models.group import Group @@ -106,8 +109,10 @@ class AutofixStageConfig(TypedDict): NotificationSource.SEER_AUTOFIX_STOPPED, NotificationSource.SEER_AUTOFIX_SUCCESS, NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, NotificationSource.SEER_AGENT_RESPONSE, NotificationSource.SEER_AGENT_ERROR, + NotificationSource.SEER_AGENT_OUT_OF_CREDITS, NotificationSource.SEER_INVESTIGATION_STARTED, NotificationSource.SEER_INVESTIGATION_ERROR, NotificationSource.SEER_INVESTIGATION_STATUS, @@ -142,6 +147,8 @@ def render[DataT: NotificationData]( ) elif isinstance(data, SeerAutofixUpdate): return cls._render_autofix_update(data, link_decorator) + elif isinstance(data, (SeerAutofixOutOfCredits, SeerAgentOutOfCredits)): + return cls._render_out_of_credits(data, link_decorator) elif isinstance(data, SeerAgentError): return cls._render_agent_error(data) elif isinstance(data, SeerAgentResponse): @@ -200,6 +207,57 @@ def _render_handoff_button( ), ) + @classmethod + def _render_out_of_credits( + cls, + data: SeerAutofixOutOfCredits | SeerAgentOutOfCredits, + link_decorator: NotificationLinkDecorator, + ) -> SlackRenderable: + from sentry.integrations.slack.message_builder.routing import encode_action_id + from sentry.integrations.slack.message_builder.types import SlackAction + from sentry.models.organization import Organization + + message = data.message + blocks: list[Block] = [SectionBlock(text=message)] + + style: str | None + url: str | None + if data.has_billing_access: + try: + organization = Organization.objects.get_from_cache(id=data.organization_id) + except Organization.DoesNotExist: + return SlackRenderable(blocks=blocks, text=message) + text = "Configure auto-reload" + action = SlackAction.SEER_VIEW_BILLING + style = "primary" + url = link_decorator.decorate_url( + organization.absolute_url(f"/settings/{organization.slug}/billing/overview/") + ) + else: + # TODO(alexsohn): this click does nothing until the handler emails the admins. + text = "Contact Admin" + action = SlackAction.SEER_REQUEST_CREDITS + style = None + url = None + + blocks.append( + ActionsBlock( + elements=[ + ButtonElement( + text=text, + style=style, + url=url, + action_id=encode_action_id( + action=action.value, + organization_id=data.organization_id, + project_id=None, + ), + ) + ] + ) + ) + return SlackRenderable(blocks=blocks, text=message) + @classmethod def _render_autofix_error(cls, data: SeerAutofixError) -> SlackRenderable: return SlackRenderable( @@ -463,6 +521,8 @@ def _render_agent_pr_state(pr: RepoPRState) -> str: return ( f"Updating {pr_text}..." if pr.pr_number else f"Creating a PR in {repo_text}..." ) + case "error" if pr.pr_creation_error_code == OUT_OF_CREDITS: + return f"Couldn't push to {pr_text}: your organization is out of Seer credits" case "error": return f"Latest push failed for {pr_text}" case "completed": diff --git a/src/sentry/notifications/platform/templates/seer.py b/src/sentry/notifications/platform/templates/seer.py index 54f20af93201..de2962bd094e 100644 --- a/src/sentry/notifications/platform/templates/seer.py +++ b/src/sentry/notifications/platform/templates/seer.py @@ -82,6 +82,42 @@ def render(self, data: SeerAutofixStopped) -> NotificationRenderedTemplate: ) +# NO_RELOADS is for billing members whose auto-reload is already on. +AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Ask your admin to increase your daily token limit or set up auto-reload." +AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE = "Your organization does not have enough credits to continue your Autofix run. Set up auto-reload to keep your flow uninterrupted." +AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE = "Your organization does not have enough credits to continue your Autofix run, and you have no auto-reloads remaining. Adjust your spend and turn on auto-reload." + + +class SeerAutofixOutOfCredits(NotificationData): + source: NotificationSource = NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS + has_billing_access: bool = False + auto_reload_enabled: bool = False + + @property + def message(self) -> str: + if not self.has_billing_access: + return AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE + if self.auto_reload_enabled: + return AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE + return AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE + + +@template_registry.register(NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS) +class SeerAutofixOutOfCreditsTemplate(NotificationTemplate[SeerAutofixOutOfCredits]): + category = NotificationCategory.SEER + example_data = SeerAutofixOutOfCredits( + source=NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, + organization_id=1, + ) + hide_from_debugger = True + + def render(self, data: SeerAutofixOutOfCredits) -> NotificationRenderedTemplate: + return NotificationRenderedTemplate( + subject=data.message, + body=[ParagraphSection(blocks=[PlainTextBlock(text=data.message)])], + ) + + class SeerAutofixCodeChange(TypedDict): repo_name: str diff: str @@ -216,6 +252,38 @@ def render(self, data: SeerAgentError) -> NotificationRenderedTemplate: ) +AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE = "Your organization does not have enough credits left for you to message Seer. Ask your admin to set up auto-reload." +AGENT_OUT_OF_CREDITS_BILLING_MESSAGE = "Your organization does not have enough credits left for you to message Seer. Adjust your spend and turn on auto-reload." +AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE = "Your organization does not have enough credits left for you to message Seer, and you have no auto-reloads remaining. Adjust your spend and turn on auto-reload." + + +class SeerAgentOutOfCredits(NotificationData): + source: NotificationSource = NotificationSource.SEER_AGENT_OUT_OF_CREDITS + has_billing_access: bool = False + auto_reload_enabled: bool = False + + @property + def message(self) -> str: + if not self.has_billing_access: + return AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE + if self.auto_reload_enabled: + return AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE + return AGENT_OUT_OF_CREDITS_BILLING_MESSAGE + + +@template_registry.register(NotificationSource.SEER_AGENT_OUT_OF_CREDITS) +class SeerAgentOutOfCreditsTemplate(NotificationTemplate[SeerAgentOutOfCredits]): + category = NotificationCategory.SEER + example_data = SeerAgentOutOfCredits(organization_id=1) + hide_from_debugger = True + + def render(self, data: SeerAgentOutOfCredits) -> NotificationRenderedTemplate: + return NotificationRenderedTemplate( + subject=data.message, + body=[ParagraphSection(blocks=[PlainTextBlock(text=data.message)])], + ) + + class SeerAgentResponse(NotificationData): """Notification data for Agent completion response in Slack.""" diff --git a/src/sentry/notifications/platform/types.py b/src/sentry/notifications/platform/types.py index 3d3ae1307c48..f52312dc1631 100644 --- a/src/sentry/notifications/platform/types.py +++ b/src/sentry/notifications/platform/types.py @@ -70,9 +70,11 @@ class NotificationSource(StrEnum): SEER_AUTOFIX_TRIGGER = "seer-autofix-trigger" SEER_AUTOFIX_FOOTER = "seer-autofix-footer" SEER_AUTOFIX_SUCCESS = "seer-autofix-success" + SEER_AUTOFIX_OUT_OF_CREDITS = "seer-autofix-out-of-credits" SEER_AGENT_RESPONSE = "seer-agent-response" SEER_AGENT_ERROR = "seer-agent-error" SEER_AGENT_WRITE_APPROVAL = "seer-agent-write-approval" + SEER_AGENT_OUT_OF_CREDITS = "seer-agent-out-of-credits" SEER_INVESTIGATION_STARTED = "seer-investigation-started" SEER_INVESTIGATION_ERROR = "seer-investigation-error" SEER_INVESTIGATION_STATUS = "seer-investigation-status" @@ -139,9 +141,11 @@ class NotificationSource(StrEnum): NotificationSource.SEER_AUTOFIX_STOPPED, NotificationSource.SEER_AUTOFIX_SUCCESS, NotificationSource.SEER_AUTOFIX_UPDATE, + NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS, NotificationSource.SEER_AGENT_RESPONSE, NotificationSource.SEER_AGENT_ERROR, NotificationSource.SEER_AGENT_WRITE_APPROVAL, + NotificationSource.SEER_AGENT_OUT_OF_CREDITS, NotificationSource.SEER_INVESTIGATION_STARTED, NotificationSource.SEER_INVESTIGATION_ERROR, NotificationSource.SEER_INVESTIGATION_STATUS, diff --git a/src/sentry/seer/credits.py b/src/sentry/seer/credits.py index bbb1e0da7ee9..cab2f106f286 100644 --- a/src/sentry/seer/credits.py +++ b/src/sentry/seer/credits.py @@ -11,6 +11,9 @@ from sentry import features, seer_credit from sentry.models.organization import Organization +# Seer's failure_reason and pr_creation_error_code when the org ran out of credits. +OUT_OF_CREDITS = "out_of_credits" + class SeerOperation(StrEnum): AGENT_CHAT_MESSAGE = "agent_chat_message" diff --git a/tests/sentry/integrations/slack/webhooks/actions/test_seer_request_credits.py b/tests/sentry/integrations/slack/webhooks/actions/test_seer_request_credits.py new file mode 100644 index 000000000000..b4285a57a7cb --- /dev/null +++ b/tests/sentry/integrations/slack/webhooks/actions/test_seer_request_credits.py @@ -0,0 +1,23 @@ +from sentry.integrations.slack.message_builder.routing import encode_action_id +from sentry.integrations.slack.message_builder.types import SlackAction +from tests.sentry.integrations.slack.webhooks.actions import BaseEventTest + + +class SeerRequestCreditsTest(BaseEventTest): + def test_click_is_acknowledged(self) -> None: + resp = self.post_webhook_block_kit( + action_data=[ + { + "type": "button", + "action_id": encode_action_id( + action=SlackAction.SEER_REQUEST_CREDITS, + organization_id=self.organization.id, + project_id=None, + ), + "text": {"type": "plain_text", "text": "Contact Admin"}, + } + ] + ) + + assert resp.status_code == 200, resp.content + assert resp.content == b"" diff --git a/tests/sentry/notifications/platform/slack/renderers/test_seer.py b/tests/sentry/notifications/platform/slack/renderers/test_seer.py index 4d89849566fd..06187512da0b 100644 --- a/tests/sentry/notifications/platform/slack/renderers/test_seer.py +++ b/tests/sentry/notifications/platform/slack/renderers/test_seer.py @@ -25,10 +25,20 @@ SeerAgentWriteApprovalSlackRenderer, ) from sentry.notifications.platform.templates.seer import ( + AGENT_OUT_OF_CREDITS_BILLING_MESSAGE, + AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE, + AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, SeerAgentError, + SeerAgentOutOfCredits, + SeerAgentOutOfCreditsTemplate, SeerAgentResponse, SeerAgentWriteApproval, SeerAutofixCodeChange, + SeerAutofixOutOfCredits, + SeerAutofixOutOfCreditsTemplate, SeerAutofixPullRequest, SeerAutofixUpdate, SeerInvestigationConclusionTemplate, @@ -46,6 +56,7 @@ NotificationProviderKey, NotificationRenderedTemplate, ) +from sentry.seer.agent.client_models import RepoPRState from sentry.seer.autofix.utils import AutofixStoppingPoint from sentry.testutils.cases import TestCase from sentry.testutils.helpers.options import override_options @@ -331,6 +342,119 @@ def test_render_autofix_update_solution_no_next_trigger_when_coding_disabled( assert isinstance(actions_block.elements[0], LinkButtonElement) +def assert_out_of_credits_message(renderable: SlackRenderable, message: str) -> None: + assert renderable["text"] == message + section = renderable["blocks"][0] + assert isinstance(section, SectionBlock) + assert section.text is not None + assert section.text.text == message + + +def assert_configure_auto_reload_button( + renderable: SlackRenderable, *, organization_id: int, billing_slug: str +) -> None: + blocks = renderable["blocks"] + assert len(blocks) == 2 + assert isinstance(blocks[1], ActionsBlock) + assert len(blocks[1].elements) == 1 + button = blocks[1].elements[0] + assert isinstance(button, ButtonElement) + assert button.text is not None + assert button.text.text == "Configure auto-reload" + assert button.url is not None + assert f"/settings/{billing_slug}/billing/overview/" in button.url + assert button.action_id is not None + routing = decode_action_id(button.action_id) + assert routing.action == SlackAction.SEER_VIEW_BILLING.value + assert routing.organization_id == organization_id + + +def assert_contact_admin_button(renderable: SlackRenderable, *, organization_id: int) -> None: + blocks = renderable["blocks"] + assert len(blocks) == 2 + assert isinstance(blocks[1], ActionsBlock) + assert len(blocks[1].elements) == 1 + button = blocks[1].elements[0] + assert isinstance(button, ButtonElement) + assert button.url is None + assert button.text is not None + assert button.text.text == "Contact Admin" + assert button.action_id is not None + routing = decode_action_id(button.action_id) + assert routing.action == SlackAction.SEER_REQUEST_CREDITS.value + assert routing.organization_id == organization_id + + +class SeerSlackRendererAutofixOutOfCreditsTest(TestCase): + def _render(self, **kwargs: bool) -> SlackRenderable: + data = SeerAutofixOutOfCredits(organization_id=self.organization.id, **kwargs) + return render_seer( + data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) + ) + + def test_member_can_contact_admin(self) -> None: + renderable = self._render() + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE) + assert_contact_admin_button(renderable, organization_id=self.organization.id) + + def test_billing_member_can_configure_auto_reload(self) -> None: + renderable = self._render(has_billing_access=True) + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE) + assert_configure_auto_reload_button( + renderable, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) + + def test_billing_member_with_auto_reload_on(self) -> None: + renderable = self._render(has_billing_access=True, auto_reload_enabled=True) + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE) + assert_configure_auto_reload_button( + renderable, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) + + def test_missing_organization(self) -> None: + data = SeerAutofixOutOfCredits(organization_id=0, has_billing_access=True) + renderable = render_seer( + data=data, rendered_template=SeerAutofixOutOfCreditsTemplate().render(data) + ) + assert_out_of_credits_message(renderable, AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE) + assert len(renderable["blocks"]) == 1 + + +class SeerSlackRendererAgentOutOfCreditsTest(TestCase): + def _render(self, **kwargs: bool) -> SlackRenderable: + data = SeerAgentOutOfCredits(organization_id=self.organization.id, **kwargs) + return render_seer( + data=data, rendered_template=SeerAgentOutOfCreditsTemplate().render(data) + ) + + def test_member_can_contact_admin(self) -> None: + renderable = self._render() + assert_out_of_credits_message(renderable, AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE) + assert_contact_admin_button(renderable, organization_id=self.organization.id) + + def test_billing_member_can_configure_auto_reload(self) -> None: + renderable = self._render(has_billing_access=True) + assert_out_of_credits_message(renderable, AGENT_OUT_OF_CREDITS_BILLING_MESSAGE) + assert_configure_auto_reload_button( + renderable, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) + + def test_billing_member_with_auto_reload_on(self) -> None: + renderable = self._render(has_billing_access=True, auto_reload_enabled=True) + assert_out_of_credits_message(renderable, AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE) + assert_configure_auto_reload_button( + renderable, + organization_id=self.organization.id, + billing_slug=self.organization.slug, + ) + + class SeerSlackRendererAgentErrorTest(TestCase): def test_render_agent_error(self) -> None: data = SeerAgentError( @@ -454,6 +578,16 @@ def test_render_agent_response_with_missing_scope_footer(self) -> None: assert "Reinstall" in footer_text assert "I can't read the whole thread" in footer_text + def test_render_agent_pr_state_out_of_credits(self) -> None: + pr = RepoPRState( + repo_name="acme/web", + pr_creation_status="error", + pr_creation_error_code="out_of_credits", + ) + assert SeerSlackRenderer._render_agent_pr_state(pr) == ( + "Couldn't push to `acme/web`: your organization is out of Seer credits" + ) + def test_render_agent_response_no_footer_when_url_not_set(self) -> None: data = self._create_agent_response(summary="Some analysis") assert data.missing_scope_settings_url is None diff --git a/tests/sentry/notifications/platform/templates/test_seer.py b/tests/sentry/notifications/platform/templates/test_seer.py index e4c761a6be3a..51d005b45710 100644 --- a/tests/sentry/notifications/platform/templates/test_seer.py +++ b/tests/sentry/notifications/platform/templates/test_seer.py @@ -5,7 +5,17 @@ from fixtures.seer.webhooks import MOCK_GROUP_ID, MOCK_RUN_ID from sentry.constants import ENABLE_SEER_CODING_DEFAULT from sentry.notifications.platform.templates.seer import ( + AGENT_OUT_OF_CREDITS_BILLING_MESSAGE, + AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE, + AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE, + AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE, SeerAgentError, + SeerAgentOutOfCredits, + SeerAgentOutOfCreditsTemplate, + SeerAutofixOutOfCredits, + SeerAutofixOutOfCreditsTemplate, SeerAutofixTrigger, SeerAutofixUpdate, SeerInvestigationConclusionTemplate, @@ -142,6 +152,58 @@ def test_render_custom_title(self) -> None: assert rendered.body[0].blocks[0].text == "Timeout." +@pytest.mark.parametrize( + ("has_billing_access", "auto_reload_enabled", "message"), + [ + (False, False, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE), + (False, True, AUTOFIX_OUT_OF_CREDITS_MEMBER_MESSAGE), + (True, False, AUTOFIX_OUT_OF_CREDITS_BILLING_MESSAGE), + (True, True, AUTOFIX_OUT_OF_CREDITS_NO_RELOADS_MESSAGE), + ], +) +def test_autofix_out_of_credits_template( + has_billing_access: bool, auto_reload_enabled: bool, message: str +) -> None: + rendered = SeerAutofixOutOfCreditsTemplate().render( + SeerAutofixOutOfCredits( + organization_id=1, + has_billing_access=has_billing_access, + auto_reload_enabled=auto_reload_enabled, + ) + ) + + assert rendered.subject == message + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == message + + +@pytest.mark.parametrize( + ("has_billing_access", "auto_reload_enabled", "message"), + [ + (False, False, AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE), + (False, True, AGENT_OUT_OF_CREDITS_MEMBER_MESSAGE), + (True, False, AGENT_OUT_OF_CREDITS_BILLING_MESSAGE), + (True, True, AGENT_OUT_OF_CREDITS_NO_RELOADS_MESSAGE), + ], +) +def test_agent_out_of_credits_template( + has_billing_access: bool, auto_reload_enabled: bool, message: str +) -> None: + rendered = SeerAgentOutOfCreditsTemplate().render( + SeerAgentOutOfCredits( + organization_id=1, + has_billing_access=has_billing_access, + auto_reload_enabled=auto_reload_enabled, + ) + ) + + assert rendered.subject == message + assert len(rendered.body) == 1 + assert isinstance(rendered.body[0], ParagraphSection) + assert rendered.body[0].blocks[0].text == message + + class SeerInvestigationStartedTemplateTest(TestCase): def test_render(self) -> None: rendered = SeerInvestigationStartedTemplate().render( diff --git a/tests/sentry/notifications/test_apps.py b/tests/sentry/notifications/test_apps.py index 37010ded24ba..27e285c8a88b 100644 --- a/tests/sentry/notifications/test_apps.py +++ b/tests/sentry/notifications/test_apps.py @@ -57,4 +57,6 @@ def test_registers_platform_renderers(self) -> None: (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_STARTED), (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_STATUS), (NotificationProviderKey.SLACK, NotificationSource.SEER_INVESTIGATION_CONCLUSION), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AUTOFIX_OUT_OF_CREDITS), + (NotificationProviderKey.SLACK, NotificationSource.SEER_AGENT_OUT_OF_CREDITS), }