Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
b7e8bea
Add slack-bolt dependency
spalmurray Feb 19, 2026
8d977c2
Add signing_secret to Slack config
spalmurray Feb 19, 2026
331a0a5
Add slack_app Django app with Bolt wiring and /inc help command
spalmurray Feb 19, 2026
b431648
Add Datadog metrics instrumentation for slash commands
spalmurray Feb 19, 2026
d697a72
Add signing_secret to CI config and fix ruff lint
spalmurray Feb 20, 2026
f5de053
Add test values for slack bot
spalmurray Feb 20, 2026
750b4f6
tweaks
spalmurray Feb 20, 2026
31537fe
Rename slack app in help command
spalmurray Feb 20, 2026
da4e50a
Remove HTTP-based slack event handling in favor of Socket Mode
spalmurray Feb 24, 2026
b80b922
Add app_token config for Slack Socket Mode
spalmurray Feb 24, 2026
52435ac
Add run_slack_bot management command and Docker entrypoint
spalmurray Feb 24, 2026
e8c5542
Add slack bot deploy step to GitHub Actions
spalmurray Feb 24, 2026
5a49b4e
Mock Slack auth_test in tests instead of disabling token verification
spalmurray Mar 12, 2026
31a2fef
Add health check server for Cloud Run TCP startup probe
spalmurray Mar 12, 2026
5da2f14
Fix TZ warnings in date filters and tests
spalmurray Mar 16, 2026
c363495
Remove unused signing_secret from config
spalmurray Mar 16, 2026
315ee4b
Add prod slack app deploy workflow
spalmurray Mar 18, 2026
93ee7d8
Change /inc to /ft (/testinc -> /ft-test)
spalmurray Mar 19, 2026
2a89131
Scope Slack auth_test patch and normalize metric tags
cursoragent Mar 20, 2026
a1e8bdf
Add firetower_base_url config setting
spalmurray Feb 24, 2026
c07dddb
Add SlackService methods for channel management
spalmurray Feb 24, 2026
3a1c517
Add incident lifecycle hooks and wire into serializer
spalmurray Feb 24, 2026
b05807b
Add /inc new command with modal for creating incidents
spalmurray Feb 24, 2026
3270b58
Tweaks
spalmurray Mar 24, 2026
6c530f3
Fix topic overflow
spalmurray Mar 25, 2026
bbc0d56
Add get_incident_from_channel helper and on_title_changed hook
spalmurray Mar 25, 2026
576c10f
Add mitigated, resolved, reopen, severity, and subject command handlers
spalmurray Mar 25, 2026
3f7cf97
Wire new command handlers into bolt.py routing and update help text
spalmurray Mar 25, 2026
68c2b60
Add tests for channel command handlers
spalmurray Mar 25, 2026
f6c6cb1
Address warden feedaback
spalmurray Mar 25, 2026
1d8dc3c
Handle validation errors and edge cases in command handlers
spalmurray Mar 25, 2026
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
18 changes: 18 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 }}

- name: deploy-prod-db-migration
if: ${{ github.ref == 'refs/heads/main' && ((!inputs.environment) || inputs.environment == 'prod') }}
uses: "google-github-actions/deploy-cloudrun@v3"
Expand All @@ -102,3 +111,12 @@ jobs:
flags: >
--container nginx --image "${{ env.STATIC_IMAGE_REF }}" --port 80
--container firetower-backend --image "${{ env.BACKEND_IMAGE_REF }}"

- name: deploy-prod-slack-bot
if: ${{ github.ref == 'refs/heads/main' && ((!inputs.environment) || inputs.environment == 'prod') }}
uses: "google-github-actions/deploy-cloudrun@v3"
with:
service: firetower-slack-app
project_id: ${{ secrets.GCP_PROJECT_SLUG }}
region: us-west1
image: ${{ env.BACKEND_IMAGE_REF }}
6 changes: 4 additions & 2 deletions config.ci.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
project_key = "INC"
django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69"
sentry_dsn = ""
firetower_base_url = "http://localhost:5173"

# This is the actual essential part. Values match the container set up by GHA
[postgres]
Expand All @@ -16,9 +17,10 @@ api_key = ""
severity_field = ""

[slack]
bot_token = ""
team_id = ""
bot_token = "test-bot-token"
team_id = "test-bot-id"
participant_sync_throttle_seconds = 300
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
@@ -1,6 +1,7 @@
project_key = "INC"
django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69"
sentry_dsn = "https://your-sentry-dsn@o1.ingest.us.sentry.io/project-id"
firetower_base_url = "http://localhost:5173"

[postgres]
db = "firetower"
Expand All @@ -18,6 +19,7 @@ severity_field = "customfield_11023"
bot_token = ""
team_id = "<slack-team-id>"
participant_sync_throttle_seconds = 300
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 @@ -39,6 +39,7 @@ class SlackConfig:
bot_token: str
team_id: str
participant_sync_throttle_seconds: int
app_token: str


@deserialize
Expand All @@ -62,6 +63,7 @@ class ConfigFile:
project_key: str
django_secret_key: str
sentry_dsn: str
firetower_base_url: str
pinned_regions: list[str] = field(default_factory=list)

@classmethod
Expand Down Expand Up @@ -115,6 +117,7 @@ def __init__(self) -> None:
bot_token="",
team_id="",
participant_sync_throttle_seconds=0,
app_token="",
)
self.auth = AuthConfig(
iap_enabled=False,
Expand All @@ -125,3 +128,4 @@ def __init__(self) -> None:
self.django_secret_key = ""
self.sentry_dsn = ""
self.pinned_regions: list[str] = []
self.firetower_base_url = ""
17 changes: 10 additions & 7 deletions src/firetower/incidents/filters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from datetime import datetime

from django.db.models import QuerySet
from django.utils import timezone as django_timezone
from django.utils.dateparse import parse_datetime
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
Expand All @@ -13,13 +14,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
160 changes: 160 additions & 0 deletions src/firetower/incidents/hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import logging

from django.conf import settings
from django.contrib.auth.models import User

from firetower.auth.models import ExternalProfileType
from firetower.incidents.models import ExternalLink, ExternalLinkType, Incident
from firetower.integrations.services import SlackService

logger = logging.getLogger(__name__)
_slack_service = SlackService()


def _build_channel_name(incident: Incident) -> str:
return incident.incident_number.lower()


SLACK_TOPIC_MAX_LENGTH = 250


def _build_channel_topic(incident: Incident) -> str:
captain_name = ""
if incident.captain:
captain_name = incident.captain.get_full_name() or incident.captain.username
prefix = f"[{incident.severity}] {incident.incident_number} "
suffix = f" | IC: @{captain_name}"
max_title_len = max(SLACK_TOPIC_MAX_LENGTH - len(prefix) - len(suffix), 0)
title = incident.title
if len(title) > max_title_len:
title = title[: max_title_len - 1] + "\u2026" if max_title_len > 0 else ""
topic = f"{prefix}{title}{suffix}"
return topic[:SLACK_TOPIC_MAX_LENGTH]


def _get_channel_id(incident: Incident) -> str | None:
slack_link = incident.external_links.filter(type=ExternalLinkType.SLACK).first()
if not slack_link:
return None
return _slack_service.parse_channel_id_from_url(slack_link.url)


def _invite_user_to_channel(channel_id: str, user: User) -> None:
try:
slack_profile = user.external_profiles.filter(
type=ExternalProfileType.SLACK
).first()
if slack_profile:
_slack_service.invite_to_channel(channel_id, [slack_profile.external_id])
except Exception:
logger.exception(f"Failed to invite user {user.id} to channel {channel_id}")


def on_incident_created(incident: Incident) -> None:
try:
existing_slack_link = incident.external_links.filter(
type=ExternalLinkType.SLACK
).exists()
if existing_slack_link:
logger.info(
f"Incident {incident.id} already has a Slack link, skipping channel creation"
)
return

channel_id = _slack_service.create_channel(_build_channel_name(incident))
if not channel_id:
logger.warning(f"Failed to create Slack channel for incident {incident.id}")
return

channel_url = _slack_service.build_channel_url(channel_id)
ExternalLink.objects.create(
incident=incident,
type=ExternalLinkType.SLACK,
url=channel_url,
)

_slack_service.set_channel_topic(channel_id, _build_channel_topic(incident))

base_url = settings.FIRETOWER_BASE_URL
incident_url = f"{base_url}/incidents/{incident.incident_number}"
_slack_service.add_bookmark(channel_id, "Firetower Incident", incident_url)

_slack_service.post_message(
channel_id,
f"*{incident.incident_number}: {incident.title}*\n"
f"Severity: {incident.severity} | Status: {incident.status}",
)

if incident.captain:
_invite_user_to_channel(channel_id, incident.captain)

# TODO: Datadog notebook creation step will be added in RELENG-467
except Exception:
logger.exception(f"Error in on_incident_created for incident {incident.id}")


def on_status_changed(incident: Incident, old_status: str) -> None:
try:
channel_id = _get_channel_id(incident)
if not channel_id:
return

_slack_service.post_message(
channel_id,
f"Status changed: {old_status} -> {incident.status}",
)
_slack_service.set_channel_topic(channel_id, _build_channel_topic(incident))
except Exception:
logger.exception(f"Error in on_status_changed for incident {incident.id}")


def on_severity_changed(incident: Incident, old_severity: str) -> None:
try:
channel_id = _get_channel_id(incident)
if not channel_id:
return

_slack_service.post_message(
channel_id,
f"Severity changed: {old_severity} -> {incident.severity}",
)
_slack_service.set_channel_topic(channel_id, _build_channel_topic(incident))
except Exception:
logger.exception(f"Error in on_severity_changed for incident {incident.id}")


def on_title_changed(incident: Incident, old_title: str) -> None:
try:
channel_id = _get_channel_id(incident)
if not channel_id:
return

_slack_service.post_message(
channel_id,
f"Title changed: {old_title} -> {incident.title}",
)
_slack_service.set_channel_topic(channel_id, _build_channel_topic(incident))
except Exception:
logger.exception(f"Error in on_title_changed for incident {incident.id}")

Check warning on line 138 in src/firetower/incidents/hooks.py

View workflow job for this annotation

GitHub Actions / warden: code-review

New on_title_changed hook lacks unit tests

The new `on_title_changed` function follows the same pattern as `on_status_changed` and `on_severity_changed`, which both have dedicated test classes in `test_hooks.py`. However, `on_title_changed` is not imported in the test file and has no corresponding tests. While it's mocked in `test_channel_commands.py`, this only verifies callers don't crash—it doesn't test the hook's actual behavior (posting messages, updating topics, handling missing Slack links).
Comment on lines +126 to +138
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

New on_title_changed hook lacks unit tests

The new on_title_changed function follows the same pattern as on_status_changed and on_severity_changed, which both have dedicated test classes in test_hooks.py. However, on_title_changed is not imported in the test file and has no corresponding tests. While it's mocked in test_channel_commands.py, this only verifies callers don't crash—it doesn't test the hook's actual behavior (posting messages, updating topics, handling missing Slack links).

Identified by Warden [code-review] · HUE-HPX



def on_captain_changed(incident: Incident) -> None:
try:
channel_id = _get_channel_id(incident)
if not channel_id:
return

_slack_service.set_channel_topic(channel_id, _build_channel_topic(incident))

captain_name = ""
if incident.captain:
captain_name = incident.captain.get_full_name() or incident.captain.username
_slack_service.post_message(
channel_id,
f"Incident captain changed to @{captain_name}",
)

if incident.captain:
_invite_user_to_channel(channel_id, incident.captain)
except Exception:
logger.exception(f"Error in on_captain_changed for incident {incident.id}")
25 changes: 25 additions & 0 deletions src/firetower/incidents/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@

from firetower.auth.services import get_or_create_user_from_email

from .hooks import (
on_captain_changed,
on_incident_created,
on_severity_changed,
on_status_changed,
on_title_changed,
)
from .models import (
USER_ADDABLE_TAG_TYPES,
ExternalLink,
Expand Down Expand Up @@ -501,6 +508,10 @@ def create(self, validated_data: dict) -> Incident:
)
incident.impact_type_tags.set(tags)

# Runs synchronously — Slack API calls may add latency to the response.
# Consider deferring to a background task if this becomes a problem.
on_incident_created(incident)

return incident

def update(self, instance: Incident, validated_data: dict) -> Incident:
Expand All @@ -521,6 +532,11 @@ def update(self, instance: Incident, validated_data: dict) -> Incident:
root_cause_tag_names = validated_data.pop("root_cause_tag_names", None)
impact_type_tag_names = validated_data.pop("impact_type_tag_names", None)

old_status = instance.status
old_severity = instance.severity
old_captain_id = instance.captain_id
old_title = instance.title

# Update basic fields
instance = super().update(instance, validated_data)

Expand Down Expand Up @@ -574,6 +590,15 @@ def update(self, instance: Incident, validated_data: dict) -> Incident:
)
instance.impact_type_tags.set(tags)

if instance.status != old_status:
on_status_changed(instance, old_status)
if instance.severity != old_severity:
on_severity_changed(instance, old_severity)
if instance.captain_id != old_captain_id:
on_captain_changed(instance)
if instance.title != old_title:
on_title_changed(instance, old_title)

return instance


Expand Down
Loading
Loading