Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion config.ci.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
project_key = "INC"
firetower_base_url = "https://firetower.example.com"
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 Down
6 changes: 5 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
project_key = "INC"
firetower_base_url = "https://firetower.getsentry.net"
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 @@ -28,6 +28,10 @@ incident_guide_message = "This is the message posted whenever a new incident sla
iap_enabled = false
iap_audience = ""

[linear]
api_key = ""
action_item_sync_throttle_seconds = 300

[datadog]
api_key = ""
app_key = ""
12 changes: 10 additions & 2 deletions src/firetower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class SlackConfig:
incident_guide_message: str = ""


@deserialize
class LinearConfig:
api_key: str
action_item_sync_throttle_seconds: int


@deserialize
class AuthConfig:
iap_enabled: bool
Expand All @@ -61,12 +67,13 @@ class ConfigFile:
datadog: DatadogConfig | None
jira: JIRAConfig
slack: SlackConfig
linear: LinearConfig | None
auth: AuthConfig

project_key: str
firetower_base_url: str
django_secret_key: str
sentry_dsn: str
firetower_base_url: str
hooks_enabled: bool = (
False # TODO: remove after hooks migration is complete and always enable
)
Expand Down Expand Up @@ -132,10 +139,11 @@ def __init__(self) -> None:
iap_enabled=False,
iap_audience="",
)
self.linear = None
self.datadog = None
self.project_key = ""
self.firetower_base_url = ""
self.django_secret_key = ""
self.sentry_dsn = ""
self.pinned_regions: list[str] = []
self.firetower_base_url = ""
self.hooks_enabled = False
41 changes: 39 additions & 2 deletions src/firetower/incidents/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
from django.http import HttpRequest

from .models import ExternalLink, Incident, Tag
from .services import sync_incident_participants_from_slack
from .services import (
sync_action_items_from_linear,
sync_incident_participants_from_slack,
)


class ExternalLinkInline(admin.TabularInline):
Expand Down Expand Up @@ -36,7 +39,11 @@ class IncidentAdmin(admin.ModelAdmin):
"impact_type_tags",
]

actions = ["sync_participants_from_slack", "clear_milestones"]
actions = [
"sync_participants_from_slack",
"sync_action_items",
"clear_milestones",
]

inlines = [ExternalLinkInline]

Expand Down Expand Up @@ -112,6 +119,36 @@ def sync_participants_from_slack(

self.message_user(request, f"Participant sync: {', '.join(message_parts)}")

@admin.action(description="Sync action items from Linear")
def sync_action_items(
self, request: HttpRequest, queryset: QuerySet[Incident]
) -> None:
success_count = 0
skipped_count = 0
error_count = 0

for incident in queryset:
try:
stats = sync_action_items_from_linear(incident, force=True)
if stats.errors:
error_count += 1
elif stats.skipped:
skipped_count += 1
else:
success_count += 1
except Exception:
error_count += 1

message_parts = []
if success_count:
message_parts.append(f"{success_count} synced successfully")
if skipped_count:
message_parts.append(f"{skipped_count} skipped")
if error_count:
message_parts.append(f"{error_count} failed")

self.message_user(request, f"Action item sync: {', '.join(message_parts)}")

@admin.action(description="Clear all milestones")
def clear_milestones(
self, request: HttpRequest, queryset: QuerySet[Incident]
Expand Down
4 changes: 2 additions & 2 deletions src/firetower/incidents/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
incident: Incident, captain_slack_id: str | None = None
) -> str:
base_url = settings.FIRETOWER_BASE_URL
incident_url = f"{base_url}/{incident.incident_number}"
incident_url = f"{base_url}/{incident.incident_number}/"

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

View check run for this annotation

@sentry/warden / warden: code-review

URL format inconsistency may break Linear attachment matching

The changes add trailing slashes to incident URLs in `hooks.py` (lines 31 and 62) and `services.py` (line 166), but `slack_app/handlers/new_incident.py:315` still constructs URLs without a trailing slash. This inconsistency means the incident URL posted in Slack DMs will not match the URL format used for Linear attachment queries, potentially causing action items to not sync correctly when users attach Linear issues to the Slack-posted link.

ic_part = ""
if incident.captain:
Expand Down Expand Up @@ -59,7 +59,7 @@


def _build_incident_url(incident: Incident) -> str:
return f"{settings.FIRETOWER_BASE_URL}/{incident.incident_number}"
return f"{settings.FIRETOWER_BASE_URL}/{incident.incident_number}/"


def _get_channel_id(incident: Incident) -> str | None:
Expand Down
Empty file.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Generated by Django 5.2.12 on 2026-04-01 19:53

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("incidents", "0014_add_total_downtime"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name="incident",
name="action_items_last_synced_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.CreateModel(
name="ActionItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("linear_issue_id", models.CharField(max_length=255, unique=True)),
("linear_identifier", models.CharField(max_length=25)),
("title", models.CharField(max_length=500)),
(
"status",
models.CharField(
choices=[
("Todo", "Todo"),
("In Progress", "In Progress"),
("Done", "Done"),
("Cancelled", "Cancelled"),
],
default="Todo",
max_length=20,
),
),
("url", models.URLField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"assignee",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="action_items",
to=settings.AUTH_USER_MODEL,
),
),
(
"incident",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="action_items",
to="incidents.incident",
),
),
],
options={
"ordering": ["created_at"],
},
),
]
36 changes: 36 additions & 0 deletions src/firetower/incidents/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ class Incident(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
participants_last_synced_at = models.DateTimeField(null=True, blank=True)
action_items_last_synced_at = models.DateTimeField(null=True, blank=True)

# Milestone timestamps (for postmortem)
total_downtime = models.IntegerField(
Expand Down Expand Up @@ -326,6 +327,41 @@ def __str__(self) -> str:
return f"{self.incident_number}: {self.title}"


class ActionItemStatus(models.TextChoices):
TODO = "Todo", "Todo"
IN_PROGRESS = "In Progress", "In Progress"
DONE = "Done", "Done"
CANCELLED = "Cancelled", "Cancelled"


class ActionItem(models.Model):
incident = models.ForeignKey(
"Incident", on_delete=models.CASCADE, related_name="action_items"
)
linear_issue_id = models.CharField(max_length=255, unique=True)
linear_identifier = models.CharField(max_length=25)
title = models.CharField(max_length=500)
status = models.CharField(
max_length=20, choices=ActionItemStatus.choices, default=ActionItemStatus.TODO
)
assignee = models.ForeignKey(
"auth.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="action_items",
)
url = models.URLField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)

class Meta:
ordering = ["created_at"]

def __str__(self) -> str:
return f"{self.linear_identifier}: {self.title}"


class ExternalLink(models.Model):
"""
Links to external resources related to an incident.
Expand Down
27 changes: 27 additions & 0 deletions src/firetower/incidents/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from .models import (
USER_ADDABLE_TAG_TYPES,
ActionItem,
ExternalLink,
ExternalLinkType,
Incident,
Expand Down Expand Up @@ -637,6 +638,32 @@ def create(self, validated_data: dict[str, Any]) -> Tag:
raise serializers.ValidationError(e.message_dict)


class ActionItemSerializer(serializers.ModelSerializer):
assignee_name = serializers.SerializerMethodField()
assignee_avatar_url = serializers.SerializerMethodField()

class Meta:
model = ActionItem
fields = [
"linear_identifier",
"title",
"status",
"assignee_name",
"assignee_avatar_url",
"url",
]

def get_assignee_name(self, obj: ActionItem) -> str | None:
if obj.assignee:
return obj.assignee.get_full_name() or obj.assignee.username
return None

def get_assignee_avatar_url(self, obj: ActionItem) -> str | None:
if obj.assignee and hasattr(obj.assignee, "userprofile"):
return obj.assignee.userprofile.avatar_url or None
return None


class IncidentOrRedirectReadSerializer(serializers.Serializer):
def to_representation(self, instance: IncidentOrRedirect) -> dict[str, Any]:
serializer = IncidentDetailUISerializer()
Expand Down
Loading
Loading