Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 = "http://localhost:5173"
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 = "http://localhost:5173"
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 @@ def _build_channel_topic(
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}/"

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


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