Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
66c848e
Linear action items parent-child model rewrite
spalmurray Apr 30, 2026
1897d02
Update Linear parent issue description with usage instructions
spalmurray Apr 30, 2026
cbd54bf
Rename _get_incident to get_object in SyncActionItemsView to match DR…
spalmurray Apr 30, 2026
ba86b2c
Set action_items_last_synced_at on API failure to prevent unlimited r…
spalmurray Apr 30, 2026
fa26db4
Batch assignee lookups to avoid N+1 queries during action item sync
spalmurray Apr 30, 2026
74d0daf
Fix tests to expect throttle timestamp on API failure
spalmurray Apr 30, 2026
0fc173c
Redact private incident titles in Linear parent issues
spalmurray Apr 30, 2026
7abd439
Add tests for private incident Linear title behavior
spalmurray Apr 30, 2026
1940df5
Fix visibility change early return and dualreview cleanup
spalmurray Apr 30, 2026
0079888
Allow action items to be linked to multiple incidents
spalmurray Apr 30, 2026
b757c99
Add test for same action item on multiple incidents
spalmurray Apr 30, 2026
634e209
Remove duplicate FIRETOWER_BASE_URL assignment
spalmurray Apr 30, 2026
ed4f395
Update Linear parent issue description text
spalmurray May 7, 2026
5c57e42
Claim matching Linear issue by identifier instead of always creating new
spalmurray May 7, 2026
64248aa
Update tests for claim-by-identifier flow
spalmurray May 7, 2026
9261066
Add sync_identifiers config flag for Linear issue ID matching
spalmurray May 7, 2026
57c6a1c
Wire sync_identifiers into settings.LINEAR and fix test coverage
spalmurray May 7, 2026
feb40e1
Add merge migration for conflicting 0015 migrations
spalmurray May 7, 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
8 changes: 8 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 @@ -20,6 +21,13 @@ 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
team_id = "ci-test-team-id"
project_id = ""

[auth]
iap_enabled = false
iap_audience = ""
Expand Down
11 changes: 11 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
project_key = "INC"
log_level = "INFO"
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 @@ -35,6 +37,15 @@ integration_key = ""
id = ""
integration_key = ""

[linear]
client_id = ""
client_secret = ""
action_item_sync_throttle_seconds = 300
team_id = ""
project_id = ""
# When true, claim the Linear issue matching the incident number (e.g. INC-2160) instead of creating a new one.
sync_identifiers = false

[notion]
integration_token = ""
database_id = ""
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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
18 changes: 16 additions & 2 deletions src/firetower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ class StatuspageConfig:
url: str


@deserialize
class LinearConfig:
client_id: str
client_secret: str
action_item_sync_throttle_seconds: int
team_id: str = ""
project_id: str = ""
sync_identifiers: bool = False


@deserialize
class AuthConfig:
iap_enabled: bool
Expand All @@ -78,14 +88,16 @@ class ConfigFile:

postgres: PostgresConfig
slack: SlackConfig
linear: LinearConfig | None
auth: AuthConfig
pagerduty: PagerDutyConfig | None
statuspage: StatuspageConfig | None

project_key: str
firetower_base_url: str
django_secret_key: str
sentry_dsn: str
firetower_base_url: str
salt_key: str
notion: NotionConfig | None = None
genai: GenAIConfig | None = None
log_level: str = "INFO"
Expand Down Expand Up @@ -148,14 +160,16 @@ def __init__(self) -> None:
iap_enabled=False,
iap_audience="",
)
self.linear = None
self.notion = None
self.genai = None
self.pagerduty = None
self.statuspage = None
self.project_key = ""
self.firetower_base_url = ""
self.django_secret_key = ""
self.salt_key = ""
self.sentry_dsn = ""
self.region_grouping: list[list[str]] = []
self.firetower_base_url = ""
self.log_level = "INFO"
self.hooks_enabled = False
44 changes: 42 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,39 @@ 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")

if message_parts:
self.message_user(request, f"Action item sync: {', '.join(message_parts)}")
else:
self.message_user(request, "No incidents selected.")

@admin.action(description="Clear all milestones")
def clear_milestones(
self, request: HttpRequest, queryset: QuerySet[Incident]
Expand Down
163 changes: 148 additions & 15 deletions src/firetower/incidents/hooks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
from dataclasses import dataclass
from typing import Any

from django.conf import settings
from django.contrib.auth.models import User
Expand All @@ -15,6 +16,7 @@
)
from firetower.integrations.services import (
DatadogService,
LinearService,
PagerDutyService,
SlackService,
)
Expand Down Expand Up @@ -566,6 +568,110 @@ def _create_troubleshooting_doc(incident: Incident, channel_id: str) -> None:
).delete()


def _linear_issue_title(incident: Incident) -> str:
if incident.is_private:
return f"[{incident.incident_number}] Private Incident"
return f"[{incident.incident_number}] {incident.title}"


LINEAR_PARENT_DESCRIPTION = (
"Relate action items to this ticket to have them tracked by Firetower. "
"Child issues or other relations (related, blocking, etc.) will all work. "
"Do not update title or captain here, use Firetower for that."
)


MAX_CLAIM_ATTEMPTS = 5


def _claim_linear_issue(
linear_service: LinearService,
incident: Incident,
team_id: str,
project_id: str | None,
) -> dict[str, Any] | None:
identifier = incident.incident_number

for _ in range(MAX_CLAIM_ATTEMPTS):
issue = linear_service.get_issue(identifier)
if issue:
return issue
linear_service.create_issue("Placeholder", "", team_id, project_id)

return None
Comment on lines +598 to +601
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The _claim_linear_issue function leaks placeholder issues in Linear if it fails to claim a specific issue identifier, creating junk data when SYNC_IDENTIFIERS is enabled.
Severity: MEDIUM

Suggested Fix

Implement a cleanup mechanism within the _claim_linear_issue function. If the function fails to claim the desired identifier after all attempts, it should iterate through any created placeholder issues and delete them from Linear before returning.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/firetower/incidents/hooks.py#L598-L601

Potential issue: The `_claim_linear_issue` function attempts to reserve a specific
Linear issue identifier by creating placeholder issues. If this process fails to acquire
the desired identifier after `MAX_CLAIM_ATTEMPTS`, the created placeholder issues are
not cleaned up from Linear. This occurs when the Firetower incident counter is out of
sync with Linear's counter. As a result, unused "Placeholder" issues can be permanently
leaked in the Linear project for each failed attempt, requiring manual cleanup. This
behavior is active only when the `SYNC_IDENTIFIERS` feature flag is enabled.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Off-by-one in claim loop misses last created issue

Medium Severity

_claim_linear_issue never calls get_issue after the final create_issue. If the last placeholder created in the loop is the one matching the desired identifier, the function returns None instead of returning the successfully claimed issue. This means the function handles gaps of at most MAX_CLAIM_ATTEMPTS - 1 (4) instead of MAX_CLAIM_ATTEMPTS (5). When the gap equals exactly 5, incident creation silently fails to link a Linear issue, and the ExternalLink is deleted as cleanup.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 57c6a1c. Configure here.

Comment on lines +593 to +601
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: When SYNC_IDENTIFIERS is enabled without a matching Linear team key, the issue claiming logic creates multiple junk placeholder issues in Linear before failing.
Severity: MEDIUM

Suggested Fix

The placeholder creation loop should be removed as it's based on a flawed assumption. Instead, the function should attempt to get_issue once. If it fails, it should immediately fail with a clear error message explaining that SYNC_IDENTIFIERS requires a pre-existing Linear issue with a matching identifier or a matching team key. This prevents creating junk issues and provides better user feedback.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/firetower/incidents/hooks.py#L593-L601

Potential issue: When `SYNC_IDENTIFIERS` is enabled, the `_claim_linear_issue` function
attempts to claim a Linear issue with an identifier matching the incident number (e.g.,
`INC-2000`). If no such issue exists, it enters a loop to create placeholder issues.
However, this logic is flawed. If the Linear team's key is different (e.g., `ENG`),
creating placeholders will generate identifiers like `ENG-201`, `ENG-202`, etc., which
will never match the target. This results in creating up to `MAX_CLAIM_ATTEMPTS` (5)
junk issues in Linear before the operation fails. Additionally, the loop has an
off-by-one error: it does not re-check for the issue after creating the final
placeholder, so even if the fifth placeholder had succeeded, the function would still
fail.



def _create_linear_parent_issue(incident: Incident) -> None:
team_id = str(settings.LINEAR.get("TEAM_ID", ""))
if not team_id:
return

linear_link, created = ExternalLink.objects.get_or_create(
incident=incident,
type=ExternalLinkType.LINEAR,
defaults={"url": ""},
)
if not created:
logger.info(f"Incident {incident.id} already has a Linear link, skipping")
return

try:
linear_service = LinearService()
project_id = str(settings.LINEAR.get("PROJECT_ID", "")) or None
sync_identifiers = settings.LINEAR.get("SYNC_IDENTIFIERS", False)
title = _linear_issue_title(incident)

if sync_identifiers:
issue = _claim_linear_issue(linear_service, incident, team_id, project_id)
if not issue:
linear_link.delete()
logger.warning(
f"Failed to claim Linear issue for incident {incident.id}"
)
return

states = linear_service.get_workflow_states(team_id)
started_state_id = states.get("started") if states else None
linear_service.update_issue(
issue["id"],
title=title,
description=LINEAR_PARENT_DESCRIPTION,
state_id=started_state_id,
)
else:
issue = linear_service.create_issue(
title, LINEAR_PARENT_DESCRIPTION, team_id, project_id
)
if not issue:
linear_link.delete()
logger.warning(
f"Failed to create Linear issue for incident {incident.id}"
)
return

linear_link.url = issue["url"]
linear_link.save(update_fields=["url"])

incident.linear_parent_issue_id = issue["id"]
incident.save(update_fields=["linear_parent_issue_id"])
except Exception:
linear_link.delete()
logger.exception(
f"Failed to create Linear parent issue for incident {incident.id}"
)
return

try:
incident_url = _build_incident_url(incident)
linear_service.create_attachment(
issue["id"], incident_url, f"Firetower: {incident.incident_number}"
)
except Exception:
logger.exception(
f"Failed to create Linear attachment for incident {incident.id}"
)


def on_incident_created(incident: Incident) -> None:
# Use get_or_create to atomically claim the ExternalLink row before calling
# the Slack API. If two concurrent requests both reach this point, only one
Expand Down Expand Up @@ -728,6 +834,13 @@ def on_incident_created(incident: Incident) -> None:
f"Failed to post feed channel message for incident {incident.id}"
)

try:
_create_linear_parent_issue(incident)
except Exception:
logger.exception(
f"Failed to create Linear parent issue for incident {incident.id}"
)


def on_status_changed(incident: Incident, old_status: str) -> None:
channel_id: str | None = None
Expand Down Expand Up @@ -808,31 +921,51 @@ def on_severity_changed(incident: Incident, old_severity: str) -> None:
def on_title_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))
if channel_id:
_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}")

if incident.linear_parent_issue_id:
try:
linear_service = LinearService()
linear_service.update_issue(
incident.linear_parent_issue_id,
title=_linear_issue_title(incident),
)
except Exception:
logger.exception(
f"Failed to update Linear issue title for incident {incident.id}"
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Identical Linear title-sync blocks duplicated across hooks

Low Severity

on_title_changed and on_visibility_changed contain identical code blocks that create a new LinearService, call update_issue with _linear_issue_title, and handle exceptions with the same log message. This duplicated logic could be extracted into a shared helper, reducing maintenance burden and risk of the two copies diverging.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b9abb82. Configure here.



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

visibility = "private" if incident.is_private else "public"
incident_url = _build_incident_url(incident)
message = (
f"This incident has been marked as *{visibility}* in Firetower. "
f"If you want to make this channel {visibility}, you will need a Slack admin to make the change.\n"
f"<{incident_url}|View in Firetower>"
)
_slack_service.post_message(channel_id, message)
if channel_id:
visibility = "private" if incident.is_private else "public"
incident_url = _build_incident_url(incident)
message = (
f"This incident has been marked as *{visibility}* in Firetower. "
f"If you want to make this channel {visibility}, you will need a Slack admin to make the change.\n"
f"<{incident_url}|View in Firetower>"
)
_slack_service.post_message(channel_id, message)
except Exception:
logger.exception(f"Error in on_visibility_changed for incident {incident.id}")

if incident.linear_parent_issue_id:
try:
linear_service = LinearService()
linear_service.update_issue(
incident.linear_parent_issue_id,
title=_linear_issue_title(incident),
)
except Exception:
logger.exception(
f"Failed to update Linear issue title for incident {incident.id}"
)


def on_captain_changed(incident: Incident) -> None:
try:
Expand Down
Loading