Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ jobs:
--container nginx --image "${{ env.STATIC_IMAGE_REF }}" --port 80
--container firetower-backend --image "${{ env.BACKEND_IMAGE_REF }}"

- name: deploy-test-slack-bot
if: ${{ inputs.environment == 'test' }}
uses: "google-github-actions/deploy-cloudrun@v3"
with:
service: firetower-slack-app-test
project_id: ${{ secrets.GCP_PROJECT_SLUG }}
region: us-west1
image: ${{ env.BACKEND_IMAGE_REF }}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slack bot Cloud Run runs web server

High Severity

deploy-test-slack-bot deploys firetower-slack-app-test with only image: set, so the new service likely runs the container’s default CMD (/app/entrypoint.sh server) instead of the Slack bot process. This can make the Slack bot service come up “healthy” while never starting run_slack_bot.

Fix in Cursor Fix in Web


- name: deploy-prod-db-migration
if: ${{ github.ref == 'refs/heads/main' && ((!inputs.environment) || inputs.environment == 'prod') }}
uses: "google-github-actions/deploy-cloudrun@v3"
Expand Down
6 changes: 4 additions & 2 deletions config.ci.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ api_key = ""
severity_field = ""

[slack]
bot_token = ""
team_id = ""
bot_token = "test-bot-token"
team_id = "test-bot-id"
participant_sync_throttle_seconds = 300
signing_secret = "test-signing-secret"
app_token = "xapp-test-token"

[auth]
iap_enabled = false
Expand Down
2 changes: 2 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ severity_field = "customfield_11023"
bot_token = ""
team_id = "<slack-team-id>"
participant_sync_throttle_seconds = 300
signing_secret = ""
app_token = ""

[auth]
iap_enabled = false
Expand Down
4 changes: 3 additions & 1 deletion docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ if [ z"$1" = "zmigrate" ]; then
COMMAND="/app/.venv/bin/django-admin migrate --settings firetower.settings"
elif [ z"$1" = "zserver" ]; then
COMMAND="/app/.venv/bin/granian --interface wsgi --host 0.0.0.0 --port $PORT firetower.wsgi:application"
elif [ z"$1" = "zslack-bot" ]; then
COMMAND="/app/.venv/bin/django-admin run_slack_bot --settings firetower.settings"
else
echo "Usage: $0 (migrate|server)"
echo "Usage: $0 (migrate|server|slack-bot)"
exit 1
fi

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"jira>=3.5.0",
"psycopg[binary]>=3.2.11",
"pyserde[toml]>=0.28.0",
"slack-bolt>=1.27.0",
"slack-sdk>=3.31.0",
]

Expand Down
4 changes: 4 additions & 0 deletions src/firetower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ class SlackConfig:
bot_token: str
team_id: str
participant_sync_throttle_seconds: int
signing_secret: str
app_token: str
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing configs may fail to deserialize

High Severity

SlackConfig now requires signing_secret and app_token, which can break startup if any deployed TOML configs (or secrets-driven config generation) haven’t been updated to include these keys, since deserialization via serde is typically strict about required fields.

Fix in Cursor Fix in Web



@deserialize
Expand Down Expand Up @@ -113,6 +115,8 @@ def __init__(self) -> None:
bot_token="",
team_id="",
participant_sync_throttle_seconds=0,
signing_secret="",
app_token="",
)
self.auth = AuthConfig(
iap_enabled=False,
Expand Down
26 changes: 13 additions & 13 deletions src/firetower/incidents/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,10 @@ def test_list_incidents_filter_by_created_after(self):
)
# Manually set created_at to test the filter
Incident.objects.filter(pk=inc1.pk).update(
created_at=datetime(2024, 1, 1, 0, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))
)
Incident.objects.filter(pk=inc2.pk).update(
created_at=datetime(2024, 6, 15, 12, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0))
)

self.client.force_authenticate(user=self.user)
Expand All @@ -209,10 +209,10 @@ def test_list_incidents_filter_by_created_before(self):
severity=IncidentSeverity.P1,
)
Incident.objects.filter(pk=inc1.pk).update(
created_at=datetime(2024, 1, 1, 0, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))
)
Incident.objects.filter(pk=inc2.pk).update(
created_at=datetime(2024, 6, 15, 12, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0))
)

self.client.force_authenticate(user=self.user)
Expand Down Expand Up @@ -240,13 +240,13 @@ def test_list_incidents_filter_by_date_range(self):
severity=IncidentSeverity.P1,
)
Incident.objects.filter(pk=inc1.pk).update(
created_at=datetime(2024, 1, 1, 0, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))
)
Incident.objects.filter(pk=inc2.pk).update(
created_at=datetime(2024, 6, 15, 12, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0))
)
Incident.objects.filter(pk=inc3.pk).update(
created_at=datetime(2024, 12, 1, 0, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 12, 1, 0, 0, 0))
)

self.client.force_authenticate(user=self.user)
Expand All @@ -266,7 +266,7 @@ def test_list_incidents_filter_by_datetime_with_time(self):
severity=IncidentSeverity.P1,
)
Incident.objects.filter(pk=inc.pk).update(
created_at=datetime(2024, 6, 15, 14, 30, 0)
created_at=django_timezone.make_aware(datetime(2024, 6, 15, 14, 30, 0))
)

self.client.force_authenticate(user=self.user)
Expand Down Expand Up @@ -831,10 +831,10 @@ def test_list_api_incidents_filter_by_date_range(self):
severity=IncidentSeverity.P1,
)
Incident.objects.filter(pk=inc1.pk).update(
created_at=datetime(2024, 1, 1, 0, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))
)
Incident.objects.filter(pk=inc2.pk).update(
created_at=datetime(2024, 6, 15, 12, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0))
)

self.client.force_authenticate(user=self.user)
Expand Down Expand Up @@ -938,13 +938,13 @@ def test_list_api_incidents_filter_by_severity_and_date(self):
severity=IncidentSeverity.P2,
)
Incident.objects.filter(pk=Incident.objects.get(title="P1 Old").pk).update(
created_at=datetime(2024, 1, 1, 0, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))
)
Incident.objects.filter(pk=Incident.objects.get(title="P2 Old").pk).update(
created_at=datetime(2024, 1, 1, 0, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))
)
Incident.objects.filter(pk=Incident.objects.get(title="P1 New").pk).update(
created_at=datetime(2024, 6, 15, 12, 0, 0)
created_at=django_timezone.make_aware(datetime(2024, 6, 15, 12, 0, 0))
)

self.client.force_authenticate(user=self.user)
Expand Down
17 changes: 10 additions & 7 deletions src/firetower/incidents/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.conf import settings
from django.db.models import Count, QuerySet
from django.shortcuts import get_object_or_404
from django.utils import timezone as django_timezone
from django.utils.dateparse import parse_datetime
from rest_framework import generics, serializers
from rest_framework.exceptions import ValidationError
Expand Down Expand Up @@ -40,13 +41,15 @@ def parse_date_param(value: str) -> datetime | None:
if not value:
return None
dt = parse_datetime(value)
if dt:
return dt
# Try parsing as date-only (YYYY-MM-DD)
try:
return datetime.fromisoformat(value)
except ValueError:
return None
if dt is None:
# Try parsing as date-only (YYYY-MM-DD)
try:
dt = datetime.fromisoformat(value)
except ValueError:
return None
if django_timezone.is_naive(dt):
dt = django_timezone.make_aware(dt)
return dt


def filter_by_date_range(
Expand Down
3 changes: 3 additions & 0 deletions src/firetower/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ def cmd_needs_dummy_config() -> bool:
"firetower.auth",
"firetower.incidents",
"firetower.integrations",
"firetower.slack_app",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -208,6 +209,8 @@ def cmd_needs_dummy_config() -> bool:
SLACK = {
"BOT_TOKEN": config.slack.bot_token,
"TEAM_ID": config.slack.team_id,
"SIGNING_SECRET": config.slack.signing_secret,
"APP_TOKEN": config.slack.app_token,
}

PARTICIPANT_SYNC_THROTTLE_SECONDS = int(config.slack.participant_sync_throttle_seconds)
Expand Down
Empty file.
6 changes: 6 additions & 0 deletions src/firetower/slack_app/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class SlackAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "firetower.slack_app"
39 changes: 39 additions & 0 deletions src/firetower/slack_app/bolt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import logging
from typing import Any

from datadog import statsd
from django.conf import settings
from slack_bolt import App

from firetower.slack_app.handlers.help import handle_help_command

logger = logging.getLogger(__name__)

METRICS_PREFIX = "slack_app.commands"

slack_config = settings.SLACK

bolt_app = App(token=slack_config["BOT_TOKEN"], token_verification_enabled=False)


@bolt_app.command("/inc")
@bolt_app.command("/testinc")
def handle_inc(ack: Any, body: dict, command: dict, respond: Any) -> None:
subcommand = (body.get("text") or "").strip().lower()
tags = [f"subcommand:{subcommand or 'help'}"]
statsd.increment(f"{METRICS_PREFIX}.submitted", tags=tags)

try:
if subcommand in ("help", ""):
handle_help_command(ack, command, respond)
else:
ack()
cmd = command.get("command", "/inc")
respond(f"Unknown command: `{cmd} {subcommand}`. Try `{cmd} help`.")
statsd.increment(f"{METRICS_PREFIX}.completed", tags=tags)
except Exception:
logger.exception(
"Slash command failed: %s %s", command.get("command", "/inc"), subcommand
)
statsd.increment(f"{METRICS_PREFIX}.failed", tags=tags)
raise
Empty file.
12 changes: 12 additions & 0 deletions src/firetower/slack_app/handlers/help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from typing import Any


def handle_help_command(ack: Any, command: dict, respond: Any) -> None:
ack()
cmd = command.get("command", "/inc")
respond(
f"*Firetower Slack App*\n"
f"Usage: `{cmd} <command>`\n\n"
f"Available commands:\n"
f" `{cmd} help` - Show this help message\n"
)
Empty file.
Empty file.
20 changes: 20 additions & 0 deletions src/firetower/slack_app/management/commands/run_slack_bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import logging
from typing import Any

from django.conf import settings
from django.core.management.base import BaseCommand
from slack_bolt.adapter.socket_mode import SocketModeHandler

from firetower.slack_app.bolt import bolt_app

logger = logging.getLogger(__name__)


class Command(BaseCommand):
help = "Start the Slack bot in Socket Mode"

def handle(self, *args: Any, **options: Any) -> None:
app_token = settings.SLACK["APP_TOKEN"]
handler = SocketModeHandler(app=bolt_app, app_token=app_token)
logger.info("Starting Slack bot in Socket Mode")
handler.start()
Empty file.
103 changes: 103 additions & 0 deletions src/firetower/slack_app/tests/test_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from unittest.mock import MagicMock, call, patch

import pytest

from firetower.slack_app.bolt import handle_inc


class TestHandleInc:
def _make_body(self, text="", command="/inc"):
return {"text": text, "command": command}

def _make_command(self, command="/inc", text=""):
return {"command": command, "text": text}

@patch("firetower.slack_app.bolt.statsd")
def test_help_returns_help_text(self, mock_statsd):
ack = MagicMock()
respond = MagicMock()
body = self._make_body(text="help")
command = self._make_command()

handle_inc(ack=ack, body=body, command=command, respond=respond)

ack.assert_called_once()
respond.assert_called_once()
response_text = respond.call_args[0][0]
assert "Firetower Slack App" in response_text
assert "/inc help" in response_text

@patch("firetower.slack_app.bolt.statsd")
def test_empty_text_returns_help(self, mock_statsd):
ack = MagicMock()
respond = MagicMock()
body = self._make_body(text="")
command = self._make_command()

handle_inc(ack=ack, body=body, command=command, respond=respond)

ack.assert_called_once()
respond.assert_called_once()
response_text = respond.call_args[0][0]
assert "Firetower Slack App" in response_text

@patch("firetower.slack_app.bolt.statsd")
def test_unknown_subcommand_returns_error(self, mock_statsd):
ack = MagicMock()
respond = MagicMock()
body = self._make_body(text="unknown")
command = self._make_command()

handle_inc(ack=ack, body=body, command=command, respond=respond)

ack.assert_called_once()
respond.assert_called_once()
response_text = respond.call_args[0][0]
assert "Unknown command" in response_text
assert "/inc unknown" in response_text

@patch("firetower.slack_app.bolt.statsd")
def test_help_uses_testinc_command(self, mock_statsd):
ack = MagicMock()
respond = MagicMock()
body = self._make_body(text="help", command="/testinc")
command = self._make_command(command="/testinc")

handle_inc(ack=ack, body=body, command=command, respond=respond)

ack.assert_called_once()
response_text = respond.call_args[0][0]
assert "/testinc help" in response_text

@patch("firetower.slack_app.bolt.statsd")
def test_emits_submitted_and_completed_metrics(self, mock_statsd):
ack = MagicMock()
respond = MagicMock()
body = self._make_body(text="help")
command = self._make_command()

handle_inc(ack=ack, body=body, command=command, respond=respond)

mock_statsd.increment.assert_has_calls(
[
call("slack_app.commands.submitted", tags=["subcommand:help"]),
call("slack_app.commands.completed", tags=["subcommand:help"]),
]
)

@patch("firetower.slack_app.bolt.statsd")
def test_emits_failed_metric_on_error(self, mock_statsd):
ack = MagicMock()
respond = MagicMock(side_effect=RuntimeError("boom"))
body = self._make_body(text="help")
command = self._make_command()

with pytest.raises(RuntimeError):
handle_inc(ack=ack, body=body, command=command, respond=respond)

mock_statsd.increment.assert_any_call(
"slack_app.commands.submitted", tags=["subcommand:help"]
)
mock_statsd.increment.assert_any_call(
"slack_app.commands.failed", tags=["subcommand:help"]
)
Loading