Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
23910ec
Add Linear action item tracking via link to firetower on issue
spalmurray Apr 1, 2026
e5e6219
add base url to ci toml
spalmurray Apr 1, 2026
7bc7563
Add trailing slash to incident URL for exact Linear attachment matching
spalmurray Apr 7, 2026
39d1c4d
Remove duplicate firetower_base_url declarations
spalmurray Apr 7, 2026
23b720f
Cache _get_incident result and add IncidentPermission to SyncActionIt…
spalmurray Apr 7, 2026
b24dce6
Scope action item update_or_create to include incident
spalmurray Apr 7, 2026
93607c2
Add Bearer prefix to Linear API Authorization header
spalmurray Apr 7, 2026
f8252c9
Fix trailing slash mismatch and update_or_create lookup for action items
spalmurray Apr 7, 2026
b6dac66
Paginate Linear attachments query to fetch all results
spalmurray Apr 7, 2026
c08f8f5
Add trailing slash to incident URLs to prevent prefix matching
spalmurray Apr 7, 2026
7d1b984
Rename admin method to avoid shadowing sync_action_items_from_linear …
spalmurray Apr 7, 2026
1397f35
Add explicit permission_classes to ActionItemListView
spalmurray Apr 7, 2026
f48df06
Add pagination safety guards in get_issues_by_attachment_url
spalmurray Apr 7, 2026
7df9f91
Update throttle timestamp on Linear API failure to prevent retry storms
spalmurray Apr 8, 2026
b1182a0
Move sync endpoint to /api/ path
spalmurray Apr 8, 2026
0646622
Remove Jira redirect logic from incident detail view
spalmurray Apr 13, 2026
1e4d71b
Replace Linear api_key config with client_id and client_secret for OAuth
spalmurray Apr 9, 2026
44ff23d
Add LinearOAuthToken model for storing OAuth credentials
spalmurray Apr 9, 2026
5d21355
Implement Linear OAuth 2.0 client credentials flow and update attachm…
spalmurray Apr 9, 2026
edbb80f
Use expires_in from Linear token response instead of hardcoded lifetime
spalmurray Apr 13, 2026
2bd882c
Extract _make_graphql_request helper to deduplicate requests.post calls
spalmurray Apr 13, 2026
744b8bd
Use is not None check for expires_in to handle zero values
spalmurray Apr 13, 2026
ffd6fd7
Add field-level encryption to LinearOAuthToken access_token
spalmurray Apr 13, 2026
49ce2d8
Add salt_key config for encrypted fields with fallback to SECRET_KEY
spalmurray Apr 13, 2026
ee28694
Apply dual review fixes: use incident_number, add permission check, r…
spalmurray Apr 13, 2026
404bfb7
Make salt_key required, use Linear user ID for external profiles, laz…
spalmurray Apr 13, 2026
07e541d
Only create ExternalProfile with real Linear ID, skip sync when Linea…
spalmurray Apr 13, 2026
a4cd886
Remove pinned_regions that was re-introduced during rebase
spalmurray Apr 14, 2026
4bc74d3
Add Linear config to CI toml so action item tests run
spalmurray Apr 14, 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
6 changes: 6 additions & 0 deletions config.ci.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
project_key = "INC"
django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69"
salt_key = "ci-test-salt-key"
sentry_dsn = ""
firetower_base_url = "http://localhost:5173"
region_grouping = []
Expand All @@ -26,6 +27,11 @@ incident_feed_channel_id = ""
always_invited_ids = []
incident_guide_message = ""

[linear]
client_id = "ci-test-client-id"
client_secret = "ci-test-client-secret"
action_item_sync_throttle_seconds = 300

[auth]
iap_enabled = false
iap_audience = ""
7 changes: 7 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
project_key = "INC"
django_secret_key = "django-insecure-gmj)qc*_dk&^i1=z7oy(ew7%5*fz^yowp8=4=0882_d=i3hl69"
# Salt for django-fernet-encrypted-fields. In prod, use a unique value: python -c "import secrets; print(secrets.token_urlsafe(32))"
salt_key = ""
sentry_dsn = "https://your-sentry-dsn@o1.ingest.us.sentry.io/project-id"
firetower_base_url = "http://localhost:5173"
region_grouping = [["region-a", "region-b"], ["region-c", "region-d"]]
Expand Down Expand Up @@ -29,6 +31,11 @@ incident_guide_message = "This is the message posted whenever a new incident sla
iap_enabled = false
iap_audience = ""

[linear]
client_id = ""
client_secret = ""
action_item_sync_throttle_seconds = 300

[datadog]
api_key = ""
app_key = ""
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ dependencies = [
"ddtrace==3.18.1",
"django>=5.2.9,<6",
"django-cors-headers>=4.9.0",
"django-fernet-encrypted-fields>=0.3.1",
"django-kubernetes>=1.1.0",
"djangorestframework>=3.15.2",
"google-auth>=2.37.0",
Expand Down
15 changes: 13 additions & 2 deletions src/firetower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
incident_guide_message: str = ""


@deserialize
class LinearConfig:
client_id: str
client_secret: str
action_item_sync_throttle_seconds: int


@deserialize
class AuthConfig:
iap_enabled: bool
Expand All @@ -61,12 +68,14 @@
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
salt_key: str

Check warning on line 78 in src/firetower/config.py

View workflow job for this annotation

GitHub Actions / warden: code-review

Missing validation for empty salt_key when Linear OAuth is enabled

The `salt_key` field is required but can be set to an empty string, which would use a weak or default encryption key for OAuth tokens stored via `django-fernet-encrypted-fields`. Similar to the existing IAP validation pattern (lines 297-301 in settings.py), there should be validation that ensures `salt_key` is non-empty when Linear integration is enabled, since the LinearOAuthToken model uses EncryptedTextField.
hooks_enabled: bool = (
False # TODO: remove after hooks migration is complete and always enable
)
Expand Down Expand Up @@ -132,10 +141,12 @@
iap_enabled=False,
iap_audience="",
)
self.linear = None
self.datadog = None
self.project_key = ""
self.firetower_base_url = ""
Comment thread
cursor[bot] marked this conversation as resolved.
self.django_secret_key = ""
self.salt_key = ""
self.sentry_dsn = ""
self.region_grouping: list[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)
Comment thread
cursor[bot] marked this conversation as resolved.
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
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(max_length=500)),
("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)
Comment thread
spalmurray marked this conversation as resolved.
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(max_length=500)
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