Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
01c2052
Add signing_secret to Slack config
spalmurray Feb 19, 2026
f124441
Add slack_app Django app with Bolt wiring and /inc help command
spalmurray Feb 19, 2026
2efed7a
Add Datadog metrics instrumentation for slash commands
spalmurray Feb 19, 2026
d2c55fa
Add signing_secret to CI config and fix ruff lint
spalmurray Feb 20, 2026
b0db045
tweaks
spalmurray Feb 20, 2026
de81cf3
Rename slack app in help command
spalmurray Feb 20, 2026
0ed965e
Remove HTTP-based slack event handling in favor of Socket Mode
spalmurray Feb 24, 2026
354e635
Mock Slack auth_test in tests instead of disabling token verification
spalmurray Mar 12, 2026
9131728
Change /inc to /ft (/testinc -> /ft-test)
spalmurray Mar 19, 2026
252db4d
Scope Slack auth_test patch and normalize metric tags
cursoragent Mar 20, 2026
cd94441
Add SlackService methods for channel management
spalmurray Feb 24, 2026
096ef3f
Add incident lifecycle hooks and wire into serializer
spalmurray Feb 24, 2026
6b9562a
Add /inc new command with modal for creating incidents
spalmurray Feb 24, 2026
216b9cb
Add get_incident_from_channel helper and on_title_changed hook
spalmurray Mar 25, 2026
534bcc6
Add mitigated, resolved, reopen, severity, and subject command handlers
spalmurray Mar 25, 2026
8da039c
Wire new command handlers into bolt.py routing and update help text
spalmurray Mar 25, 2026
3914408
Add tests for channel command handlers
spalmurray Mar 25, 2026
3ce2c2d
Address warden feedaback
spalmurray Mar 25, 2026
e1ceb7d
Handle validation errors and edge cases in command handlers
spalmurray Mar 25, 2026
9aad8b4
Fix handle_inc imports to use renamed handle_command
spalmurray Apr 7, 2026
27090ed
Use get_bolt_app() instead of nonexistent bolt_app in mitigated and r…
spalmurray Apr 7, 2026
296f264
Remove unused get_channel_history from SlackService
spalmurray Apr 7, 2026
4bdf867
Use IncidentStatus enum instead of raw strings in handlers
spalmurray Apr 7, 2026
b09c6ba
Use more precise URL filter in get_incident_from_channel
spalmurray Apr 7, 2026
2be7e4c
Move module-level mock_auth to a proper pytest fixture
spalmurray Apr 7, 2026
efe5f7c
Add /ft update command with modal for editing incident metadata
spalmurray Apr 8, 2026
9e4aefb
Add tests for /ft update command handler
spalmurray Apr 8, 2026
d8bad00
Add alias notes to help text
spalmurray Apr 14, 2026
f70653d
Add stub handlers for statuspage and dumpslack commands
spalmurray Apr 14, 2026
d6cbfb7
Fix severity fallback, document all aliases in help, remove signing_s…
spalmurray Apr 14, 2026
daac0d3
Notify user when mitigation notes fail to save
spalmurray Apr 14, 2026
8513540
Simplify help text by inlining aliases
spalmurray Apr 14, 2026
cc713c4
Fix CI failures in update_incident handler and slack app tests
spalmurray Apr 14, 2026
c05125f
Add /ft captain command to set incident captain from Slack
spalmurray Apr 14, 2026
b3aa43c
Reorder help text and simplify stub messages
spalmurray Apr 14, 2026
ec2e8bc
Add args to help usage line
spalmurray Apr 14, 2026
e958c3d
Fix stub command test assertions to match simplified messages
spalmurray Apr 14, 2026
c78df9c
Split handler tests into individual files
spalmurray Apr 14, 2026
0b713b0
Accept both mention format and plain user ID for captain command
spalmurray Apr 14, 2026
2b21ec0
Add debug logging for slash command parsing
spalmurray Apr 14, 2026
c79f7b0
Convert /ft captain from inline command to modal with user picker
spalmurray Apr 14, 2026
4acea15
Add captain selector to the new incident modal
spalmurray Apr 14, 2026
96c2ee9
Add captain selector to the update incident modal
spalmurray Apr 14, 2026
7bd340a
Remove debug logging for slash command parsing
spalmurray Apr 14, 2026
5b3fd46
Remove description append from mitigated handler, just post to Slack
spalmurray Apr 14, 2026
a6075ad
Match opsbot Slack message format for mitigated handler
spalmurray Apr 14, 2026
111a999
Update captain help text to reflect modal-based flow
spalmurray Apr 14, 2026
d04cee0
Couple review tweaks
spalmurray Apr 15, 2026
e3200fc
Tweak modals
spalmurray Apr 15, 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
83 changes: 81 additions & 2 deletions src/firetower/slack_app/bolt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,58 @@
from django.conf import settings
from slack_bolt import App

from firetower.slack_app.handlers.captain import (
handle_captain_command,
handle_captain_submission,
)
from firetower.slack_app.handlers.dumpslack import handle_dumpslack_command
from firetower.slack_app.handlers.help import handle_help_command
from firetower.slack_app.handlers.mitigated import (
handle_mitigated_command,
handle_mitigated_submission,
)
from firetower.slack_app.handlers.new_incident import (
handle_new_command,
handle_new_incident_submission,
handle_tag_options,
)
from firetower.slack_app.handlers.reopen import handle_reopen_command
from firetower.slack_app.handlers.resolved import (
handle_resolved_command,
handle_resolved_submission,
)
from firetower.slack_app.handlers.severity import handle_severity_command
from firetower.slack_app.handlers.statuspage import handle_statuspage_command
from firetower.slack_app.handlers.subject import handle_subject_command
from firetower.slack_app.handlers.update_incident import (
handle_update_command,
handle_update_incident_submission,
)

logger = logging.getLogger(__name__)

METRICS_PREFIX = "slack_app.commands"

KNOWN_SUBCOMMANDS = {
"help",
"new",
"mitigated",
"mit",
"resolved",
"fixed",
"reopen",
"severity",
"sev",
"setseverity",
"subject",
"update",
"edit",
"captain",
"ic",
"statuspage",
"dumpslack",
}

_bolt_app: App | None = None


Expand All @@ -31,9 +72,15 @@ def get_bolt_app() -> App:


def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None:
subcommand = (body.get("text") or "").strip().lower()
raw_text = (body.get("text") or "").strip()
parts = raw_text.split(None, 1)
subcommand = parts[0].lower() if parts else ""
args = parts[1] if len(parts) > 1 else ""

metric_subcommand = (
(subcommand or "help") if subcommand in ("", "help", "new") else "unknown"
(subcommand or "help")
if subcommand in KNOWN_SUBCOMMANDS or subcommand == ""
else "unknown"
)
tags = [f"subcommand:{metric_subcommand}"]
statsd.increment(f"{METRICS_PREFIX}.submitted", tags=tags)
Expand All @@ -43,6 +90,34 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None:
handle_new_command(ack, body, command, respond)
elif subcommand in ("help", ""):
handle_help_command(ack, command, respond)
elif subcommand in ("mitigated", "mit"):
handle_mitigated_command(ack, body, command, respond)
elif subcommand in ("resolved", "fixed"):
handle_resolved_command(ack, body, command, respond)
elif subcommand == "reopen":
handle_reopen_command(ack, body, command, respond)
elif subcommand in ("severity", "sev", "setseverity"):
if not args:
ack()
cmd = command.get("command", "/ft")
respond(f"Usage: `{cmd} severity <P0-P4>`")
else:
handle_severity_command(ack, body, command, respond, new_severity=args)
elif subcommand in ("update", "edit"):
handle_update_command(ack, body, command, respond)
elif subcommand == "subject":
if not args:
ack()
cmd = command.get("command", "/ft")
respond(f"Usage: `{cmd} subject <new title>`")
else:
handle_subject_command(ack, body, command, respond, new_subject=args)
elif subcommand in ("captain", "ic"):
handle_captain_command(ack, body, command, respond)
elif subcommand == "statuspage":
handle_statuspage_command(ack, command, respond)
elif subcommand == "dumpslack":
handle_dumpslack_command(ack, command, respond)
else:
ack()
cmd = command.get("command", "/ft")
Expand All @@ -59,6 +134,10 @@ def handle_command(ack: Any, body: dict, command: dict, respond: Any) -> None:
def _register_views(app: App) -> None:
"""Register view handlers (modals, etc.) on the Bolt app."""
app.view("new_incident_modal")(handle_new_incident_submission)
app.view("update_incident_modal")(handle_update_incident_submission)
app.view("mitigated_incident_modal")(handle_mitigated_submission)
app.view("resolved_incident_modal")(handle_resolved_submission)
app.view("captain_incident_modal")(handle_captain_submission)
for action_id in (
"impact_type_tags",
"affected_service_tags",
Expand Down
117 changes: 117 additions & 0 deletions src/firetower/slack_app/handlers/captain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import logging
from typing import Any

from firetower.auth.models import ExternalProfileType
from firetower.auth.services import get_or_create_user_from_slack_id
from firetower.incidents.serializers import IncidentWriteSerializer
from firetower.slack_app.handlers.utils import get_incident_from_channel

logger = logging.getLogger(__name__)


def _build_captain_modal(
incident_number: str, channel_id: str, captain_slack_id: str | None
) -> dict:
captain_element: dict = {
"type": "users_select",
"action_id": "captain_select",
"placeholder": {"type": "plain_text", "text": "Select incident captain"},
}
if captain_slack_id:
captain_element["initial_user"] = captain_slack_id

return {
"type": "modal",
"callback_id": "captain_incident_modal",
"private_metadata": channel_id,
"title": {"type": "plain_text", "text": incident_number},
"submit": {"type": "plain_text", "text": "Update"},
"close": {"type": "plain_text", "text": "Cancel"},
"blocks": [
{
"type": "input",
"block_id": "captain_block",
"optional": True,
"element": captain_element,
"label": {"type": "plain_text", "text": "Incident Captain"},
},
],
}


def handle_captain_command(ack: Any, body: dict, command: dict, respond: Any) -> None:
ack()
channel_id = body.get("channel_id", "")
incident = get_incident_from_channel(channel_id)
if not incident:
respond("Could not find an incident associated with this channel.")
return

trigger_id = body.get("trigger_id")
if not trigger_id:
respond("Could not open modal — missing trigger_id.")
return

captain_slack_id = None
if incident.captain:
slack_profile = incident.captain.external_profiles.filter(
type=ExternalProfileType.SLACK
).first()
if slack_profile:
captain_slack_id = slack_profile.external_id

from firetower.slack_app.bolt import get_bolt_app # noqa: PLC0415

get_bolt_app().client.views_open(
trigger_id=trigger_id,
view=_build_captain_modal(
incident.incident_number, channel_id, captain_slack_id
),

Check warning on line 69 in src/firetower/slack_app/handlers/captain.py

View check run for this annotation

@sentry/warden / warden: django-access-review

Missing authorization check allows any Slack channel member to modify incident captain

The handle_captain_command and handle_captain_submission functions retrieve incidents via get_incident_from_channel() which only checks if the channel_id matches an incident's Slack link URL. There is no verification that the invoking Slack user has permission to modify the incident according to Firetower's access control model. While the REST API enforces is_visible_to_user() checks (which restrict private incident access to captain, reporter, participants, and superusers), the Slack handlers bypass this entirely. Any user with access to post in the Slack channel can change the incident captain.
)


def handle_captain_submission(ack: Any, body: dict, view: dict, client: Any) -> None:
values = view.get("state", {}).get("values", {})
channel_id = view.get("private_metadata", "")

captain_slack_id = (
values.get("captain_block", {}).get("captain_select", {}).get("selected_user")
)

ack()

incident = get_incident_from_channel(channel_id)
if not incident:
logger.error("Captain submission: no incident for channel %s", channel_id)
return

if not captain_slack_id:
client.chat_postMessage(
channel=channel_id,
text=f"*{incident.incident_number}* captain was not changed.",
)
return

captain_user = get_or_create_user_from_slack_id(captain_slack_id)
if not captain_user:
logger.error(
"Could not resolve Slack user %s to a Firetower user", captain_slack_id
)
client.chat_postMessage(
channel=channel_id,
text="Failed to resolve the selected captain to a Firetower user.",
)
return

serializer = IncidentWriteSerializer(
instance=incident, data={"captain": captain_user.email}, partial=True
)
if not serializer.is_valid():
logger.error("Captain update failed: %s", serializer.errors)
client.chat_postMessage(
channel=channel_id,
text=f"Failed to update captain: {serializer.errors}",
)
return

serializer.save()
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
spalmurray marked this conversation as resolved.
7 changes: 7 additions & 0 deletions src/firetower/slack_app/handlers/dumpslack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from typing import Any


def handle_dumpslack_command(ack: Any, command: dict, respond: Any) -> None:
ack()
cmd = command.get("command", "/ft")
respond(f"`{cmd} dumpslack` is not yet implemented.")
13 changes: 11 additions & 2 deletions src/firetower/slack_app/handlers/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ def handle_help_command(ack: Any, command: dict, respond: Any) -> None:
cmd = command.get("command", "/ft")
respond(
f"*Firetower Slack App*\n"
f"Usage: `{cmd} <command>`\n\n"
f"Usage: `{cmd} <command> [args]`\n\n"
f"Available commands:\n"
f" `{cmd} new` - Create a new incident\n"
f" `{cmd} help` - Show this help message\n"
f" `{cmd} new` - Create a new incident\n"
f" `{cmd} severity <P0-P4>` - Change incident severity (alias: `{cmd} sev`)\n"
f" `{cmd} subject <title>` - Change incident title\n"
f" `{cmd} captain` - Set incident captain (alias: `{cmd} ic`)\n"
f" `{cmd} update` - Interactively update incident metadata (alias: `{cmd} edit`)\n"
f" `{cmd} mitigated` - Mark incident as mitigated (alias: `{cmd} mit`)\n"
f" `{cmd} resolved` - Mark incident as resolved (alias: `{cmd} fixed`)\n"
f" `{cmd} statuspage` - Create or update a statuspage post (not yet implemented)\n"
f" `{cmd} dumpslack` - Dump slack channel history (not yet implemented)\n"
f" `{cmd} reopen` - Reopen an incident\n"
)
120 changes: 120 additions & 0 deletions src/firetower/slack_app/handlers/mitigated.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import logging
from typing import Any

from django.conf import settings

from firetower.incidents.models import IncidentStatus
from firetower.incidents.serializers import IncidentWriteSerializer
from firetower.slack_app.handlers.utils import get_incident_from_channel

logger = logging.getLogger(__name__)


def _build_mitigated_modal(incident_number: str, channel_id: str) -> dict:
return {
"type": "modal",
"callback_id": "mitigated_incident_modal",
"private_metadata": channel_id,
"title": {"type": "plain_text", "text": incident_number},
"submit": {"type": "plain_text", "text": "Submit"},
"close": {"type": "plain_text", "text": "Cancel"},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Mark this incident as mitigated. Please provide the current impact and any remaining action items.",
},
},
{
"type": "input",
"block_id": "impact_block",
"element": {
"type": "plain_text_input",
"action_id": "impact_update",
"multiline": True,
"placeholder": {
"type": "plain_text",
"text": "What is the current impact after mitigation?",
},
},
"label": {
"type": "plain_text",
"text": "Current impact post-mitigation",
},
},
{
"type": "input",
"block_id": "todo_block",
"element": {
"type": "plain_text_input",
"action_id": "todo_update",
"multiline": True,
"placeholder": {
"type": "plain_text",
"text": "What still needs to be done?",
},
},
"label": {"type": "plain_text", "text": "Remaining action items"},
},
],
}


def handle_mitigated_command(ack: Any, body: dict, command: dict, respond: Any) -> None:
ack()
channel_id = body.get("channel_id", "")
incident = get_incident_from_channel(channel_id)
if not incident:
respond("Could not find an incident associated with this channel.")
return

trigger_id = body.get("trigger_id")
if not trigger_id:
respond("Could not open modal — missing trigger_id.")
return

from firetower.slack_app.bolt import get_bolt_app # noqa: PLC0415

get_bolt_app().client.views_open(
trigger_id=trigger_id,
view=_build_mitigated_modal(incident.incident_number, channel_id),
)


def handle_mitigated_submission(ack: Any, body: dict, view: dict, client: Any) -> None:
ack()
values = view.get("state", {}).get("values", {})
channel_id = view.get("private_metadata", "")

impact = values.get("impact_block", {}).get("impact_update", {}).get("value", "")
todo = values.get("todo_block", {}).get("todo_update", {}).get("value", "")

incident = get_incident_from_channel(channel_id)
if not incident:
logger.error("Mitigated submission: no incident for channel %s", channel_id)
return

serializer = IncidentWriteSerializer(
instance=incident, data={"status": IncidentStatus.MITIGATED}, partial=True
Comment thread
sentry-warden[bot] marked this conversation as resolved.
)
if not serializer.is_valid():
logger.error("Mitigated status update failed: %s", serializer.errors)
client.chat_postMessage(
channel=channel_id,
text=f"Failed to update incident status: {serializer.errors}",
)
return
serializer.save()

incident_url = f"{settings.FIRETOWER_BASE_URL}/{incident.incident_number}"
client.chat_postMessage(
channel=channel_id,
text=(
f"<{incident_url}|{incident.incident_number}> has been marked Mitigated.\n"
f"*Current Impact*:\n"
f"```{impact}```\n"
f"*Remaining Action Items*:\n"
f"```{todo}```"
),
)
Loading
Loading