Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 0 additions & 14 deletions src/firetower/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,20 +126,6 @@ class LinearConfig:
"days from incident creation. Please prioritize this work or close "
"out the issue if it is no longer relevant."
)
parent_status_comment_completed: str = (
"Firetower set this issue to **Completed**. "
"Incident {{ incident.incident_number }} is {{ incident.status }} "
"and {% if total_action_items == 0 %}there are no action items."
"{% else %}all {{ total_action_items }} action "
"item{% if total_action_items != 1 %}s{% endif %} are complete.{% endif %}"
)
parent_status_comment_started: str = (
"Firetower set this issue to **Started**. "
"Incident {{ incident.incident_number }} is {{ incident.status }}. "
"{% if total_action_items == 0 %}There are no action items."
"{% else %}{{ completed_action_items }} of {{ total_action_items }} action "
"item{% if total_action_items != 1 %}s{% endif %} complete.{% endif %}"
)


@deserialize
Expand Down
34 changes: 22 additions & 12 deletions src/firetower/incidents/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
IncidentSeverity,
IncidentStatus,
)
from firetower.incidents.services import (
get_linear_parent_issue_state_id,
sync_linear_parent_issue_status,
)
from firetower.integrations.services import (
DatadogService,
LinearService,
Expand Down Expand Up @@ -1094,11 +1098,9 @@
LINEAR_PARENT_DESCRIPTION = (
"Add action items as sub-issues (child issues) of this ticket to have "
"them tracked by Firetower. "
"Do not update title, status or captain here, use Firetower for that.\n\n"
"Firetower will mark this ticket as completed once the incident is "
"resolved and all action items are done. "
"Firetower will reopen this ticket if the incident is reopened, or if "
"there are still unfinished action items. "
"Do not update title, status or captain here; use Firetower for those.\n\n"
"This ticket's status mirrors the associated Firetower incident and is "
"independent of its action items. "
"If you have questions, please reach out to #team-sre."
)

Expand Down Expand Up @@ -1151,19 +1153,17 @@
if not linear_config or not uuid:
return

team_id = str(linear_config.get("TEAM_ID", ""))
linear_service = _get_linear_service()

try:
states = linear_service.get_workflow_states(team_id) if team_id else None
started_state_id = states.get("started") if states else None
state_id = get_linear_parent_issue_state_id(incident, linear_service)
captain_linear_id = _resolve_linear_user_id(incident.captain, linear_service)
linear_service.update_issue(
uuid,
title=_linear_issue_title(incident, sync_identifiers=True),
description=LINEAR_PARENT_DESCRIPTION,
state_id=started_state_id,
state_id=state_id,
assignee_id=captain_linear_id,

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

View check run for this annotation

@sentry/warden / warden: find-bugs

Parent Linear state lookup depends on display names, not stable types

`get_linear_parent_issue_state_id()` resolves `in_progress`/`in_review`/`done` by workflow display name; if those exact names are missing, `state_id` is None and this update silently leaves the parent in its prior state—prefer type keys (`started`/`completed`) with name fallback only for started substates like In Review.
Comment thread
spalmurray marked this conversation as resolved.
priority=LINEAR_PRIORITY_BY_SEVERITY[incident.severity],
)
except Exception:
Expand Down Expand Up @@ -1255,27 +1255,28 @@
)
return

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

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

View check run for this annotation

@sentry/warden / warden: find-bugs

[NBY-9LQ] Parent Linear state lookup depends on display names, not stable types (additional location)

`get_linear_parent_issue_state_id()` resolves `in_progress`/`in_review`/`done` by workflow display name; if those exact names are missing, `state_id` is None and this update silently leaves the parent in its prior state—prefer type keys (`started`/`completed`) with name fallback only for started substates like In Review.
assignee_id=captain_linear_id,
priority=LINEAR_PRIORITY_BY_SEVERITY[incident.severity],
)
Expand Down Expand Up @@ -1801,6 +1802,15 @@

# --- Side effects ---

# Status change: mirror the lifecycle state to the Linear parent ticket.
if old_status is not None:
try:
sync_linear_parent_issue_status(incident)
except Exception:
logger.exception(
f"Failed to sync Linear parent status for incident {incident.id}"
)

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

View check run for this annotation

@sentry/warden / warden: find-bugs

[NBY-9LQ] Parent Linear state lookup depends on display names, not stable types (additional location)

`get_linear_parent_issue_state_id()` resolves `in_progress`/`in_review`/`done` by workflow display name; if those exact names are missing, `state_id` is None and this update silently leaves the parent in its prior state—prefer type keys (`started`/`completed`) with name fallback only for started substates like In Review.

# Status change: trigger slack dump for resolve-like statuses
if (
old_status is not None
Expand Down
114 changes: 28 additions & 86 deletions src/firetower/incidents/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from django.contrib.auth.models import User
from django.db import transaction
from django.utils import timezone
from jinja2 import Environment, TemplateError

from firetower.auth.models import ExternalProfile, ExternalProfileType
from firetower.auth.services import (
Expand All @@ -15,7 +14,6 @@
)
from firetower.incidents.models import (
ActionItem,
ActionItemStatus,
ExternalLinkType,
Incident,
IncidentStatus,
Expand Down Expand Up @@ -184,104 +182,55 @@
return resolved


COMPLETED_STATUSES = {ActionItemStatus.DONE, ActionItemStatus.CANCELED}
LINEAR_STATE_BY_INCIDENT_STATUS: dict[str, str] = {
IncidentStatus.ACTIVE: "in_progress",
IncidentStatus.MITIGATED: "in_progress",
IncidentStatus.POSTMORTEM: "in_review",
IncidentStatus.DONE: "done",
IncidentStatus.CANCELED: "canceled",
}

_PARENT_STATUS_TEMPLATE_ENV = Environment(autoescape=False)


def _comment_parent_issue_status_change(
incident: Incident,
linear_service: LinearService,
target_state: str,
statuses: list[str],
) -> None:
if not settings.LINEAR or not incident.linear_parent_issue_id:
return

template_key = (
"PARENT_STATUS_COMMENT_COMPLETED"
if target_state == "completed"
else "PARENT_STATUS_COMMENT_STARTED"
)
template_source = settings.LINEAR.get(template_key, "")
if not template_source or not template_source.strip():
return

completed_action_items = sum(1 for s in statuses if s in COMPLETED_STATUSES)
try:
comment = _PARENT_STATUS_TEMPLATE_ENV.from_string(template_source).render(
incident=incident,
total_action_items=len(statuses),
completed_action_items=completed_action_items,
target_state=target_state,
)
except TemplateError:
logger.exception(
f"Failed to render parent status comment template for incident {incident.id}"
)
return

try:
Comment thread
cursor[bot] marked this conversation as resolved.
linear_service.create_comment(incident.linear_parent_issue_id, comment)
except Exception:
logger.exception(
f"Failed to post parent status comment for incident {incident.id}"
)


def _update_parent_issue_status(
def get_linear_parent_issue_state_id(
incident: Incident, linear_service: LinearService
) -> None:
if not settings.LINEAR or not incident.linear_parent_issue_id:
return
team_id = str(settings.LINEAR.get("TEAM_ID", ""))
if not team_id:
return
) -> str | None:
"""Return the Linear workflow state that represents an incident's status."""
linear_config = settings.LINEAR
if not linear_config:
return None

statuses = list(incident.action_items.values_list("status", flat=True))
incident_done = incident.status in (IncidentStatus.DONE, IncidentStatus.CANCELED)
all_complete = incident_done and (
not statuses or all(s in COMPLETED_STATUSES for s in statuses)
)
team_id = str(linear_config.get("TEAM_ID", ""))
target_state = LINEAR_STATE_BY_INCIDENT_STATUS.get(incident.status)
if not team_id or not target_state:
return None

states = linear_service.get_workflow_states(team_id)
if not states:
return
return states.get(target_state) if states else None

Check warning on line 208 in src/firetower/incidents/services.py

View check run for this annotation

@sentry/warden / warden: code-review

Parent status sync keys off Linear state names, not stable types

LINEAR_STATE_BY_INCIDENT_STATUS looks up name keys like in_progress/done/in_review, while the old path used stable Linear types (started/completed). If the team lacks those exact names—especially In Review—get_linear_parent_issue_state_id returns None and sync silently no-ops; prefer type keys where possible and fall back to names only for started substates.

Check warning on line 208 in src/firetower/incidents/services.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[NBY-9LQ] Parent Linear state lookup depends on display names, not stable types (additional location)

`get_linear_parent_issue_state_id()` resolves `in_progress`/`in_review`/`done` by workflow display name; if those exact names are missing, `state_id` is None and this update silently leaves the parent in its prior state—prefer type keys (`started`/`completed`) with name fallback only for started substates like In Review.
Comment thread
spalmurray marked this conversation as resolved.

target_state = "completed" if all_complete else "started"

parent_issue = linear_service.get_issue(incident.linear_parent_issue_id)
if not parent_issue:
def sync_linear_parent_issue_status(incident: Incident) -> None:
"""Set the Linear parent issue state from the incident, never its action items."""
if not settings.LINEAR or not incident.linear_parent_issue_id:
return

# Never override a manually-cancelled parent issue. Firetower only ever
# drives the parent to "started" or "completed", so a "canceled" state
# reflects a deliberate human decision and must not be reopened to
# "started" (or forced to "completed") on subsequent syncs.
current_state_type = parent_issue.get("state_type")
if current_state_type in ("canceled", target_state):
linear_service = _get_linear_service()
target_state = LINEAR_STATE_BY_INCIDENT_STATUS.get(incident.status)
state_id = get_linear_parent_issue_state_id(incident, linear_service)
if not target_state or not state_id:
return

state_id = states.get(target_state)
if not state_id:
parent_issue = linear_service.get_issue(incident.linear_parent_issue_id)
if not parent_issue or parent_issue.get("state_id") == state_id:
return

if linear_service.update_issue(incident.linear_parent_issue_id, state_id=state_id):
_comment_parent_issue_status_change(
incident, linear_service, target_state, statuses
)
return

# The underlying Linear error is logged by LinearService without any
# incident context, so name the incident and its parent here. Without this
# a persistently mislinked parent just emits an anonymous GraphQL error
# every sync, with nothing tying it back to the row that needs fixing.
logger.warning(
"Failed to set Linear parent %s to %s for incident %s (team %s)",
"Failed to set Linear parent %s to %s for incident %s",
incident.linear_parent_issue_id,
target_state,
incident.incident_number,
team_id,
)


Expand Down Expand Up @@ -397,13 +346,6 @@
incident.action_items_last_synced_at = timezone.now()
incident.save(update_fields=["action_items_last_synced_at"])

try:
_update_parent_issue_status(incident, linear_service)
except Exception:
logger.exception(
f"Failed to update Linear parent issue status for incident {incident.id}"
)

logger.info(
f"Action item sync complete for incident {incident.id}: "
f"{stats.created} created, {stats.updated} updated, {stats.deleted} deleted"
Expand Down
68 changes: 7 additions & 61 deletions src/firetower/incidents/tests/test_action_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,80 +322,22 @@ def fake_create_parent(inc):
assert stats.created == 1
assert incident.action_items.count() == 1

def test_auto_completes_parent_when_all_done(self, settings):
def test_syncing_action_items_does_not_change_parent_status(self, settings):
settings.LINEAR = {"TEAM_ID": "team-1"}
incident = self._make_incident(status=IncidentStatus.DONE)

children = [
_make_linear_issue(
id="id-1", identifier="ENG-1", title="T1", status="Done"
),
_make_linear_issue(
id="id-2", identifier="ENG-2", title="T2", status="Canceled"
),
]

with patch("firetower.incidents.services._get_linear_service") as mock_get:
mock_service = mock_get.return_value
mock_service.get_child_issues.return_value = children
mock_service.get_workflow_states.return_value = {
"completed": "state-done",
"backlog": "state-backlog",
}
mock_service.update_issue.return_value = True

sync_action_items_from_linear(incident, force=True)

mock_service.update_issue.assert_any_call(
"parent-issue-id", state_id="state-done"
id="id-1", identifier="ENG-1", title="T1", status="In Progress"
)

def test_sets_parent_to_started_when_incomplete_items(self, settings):
settings.LINEAR = {"TEAM_ID": "team-1"}
incident = self._make_incident(status=IncidentStatus.DONE)

children = [
_make_linear_issue(
id="id-1", identifier="ENG-1", title="T1", status="Done"
),
_make_linear_issue(
id="id-2", identifier="ENG-2", title="T2", status="In Progress"
),
]

with patch("firetower.incidents.services._get_linear_service") as mock_get:
mock_service = mock_get.return_value
mock_service.get_child_issues.return_value = children
mock_service.get_workflow_states.return_value = {
"completed": "state-done",
"started": "state-started",
}
mock_service.update_issue.return_value = True

sync_action_items_from_linear(incident, force=True)

mock_service.update_issue.assert_any_call(
"parent-issue-id", state_id="state-started"
)

def test_completes_parent_when_no_action_items(self, settings):
settings.LINEAR = {"TEAM_ID": "team-1"}
incident = self._make_incident(status=IncidentStatus.DONE)

with patch("firetower.incidents.services._get_linear_service") as mock_get:
mock_service = mock_get.return_value
mock_service.get_child_issues.return_value = []
mock_service.get_workflow_states.return_value = {
"completed": "state-done",
"backlog": "state-backlog",
}
mock_service.update_issue.return_value = True

sync_action_items_from_linear(incident, force=True)

mock_service.update_issue.assert_any_call(
"parent-issue-id", state_id="state-done"
)
mock_service.update_issue.assert_not_called()

def test_does_not_push_parent_assignee_on_sync(self, settings):
settings.LINEAR = {"TEAM_ID": "team-1"}
Expand Down Expand Up @@ -620,6 +562,7 @@ def test_get_workflow_states_caches(self):
{"id": "s3", "name": "In Progress", "type": "started"},
{"id": "s4", "name": "Done", "type": "completed"},
{"id": "s5", "name": "Canceled", "type": "canceled"},
{"id": "s6", "name": "In Review", "type": "started"},
]
}
}
Expand All @@ -629,6 +572,9 @@ def test_get_workflow_states_caches(self):
states = service.get_workflow_states("team-1")
assert states["completed"] == "s4"
assert states["backlog"] == "s1"
assert states["in_progress"] == "s3"
assert states["in_review"] == "s6"
assert states["done"] == "s4"

states2 = service.get_workflow_states("team-1")
assert states2 is states
Expand Down
4 changes: 2 additions & 2 deletions src/firetower/incidents/tests/test_allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ def test_updates_by_uuid_never_by_identifier(
):
incident = self._incident(settings)
linear = mock_get_linear.return_value
linear.get_workflow_states.return_value = {"started": "state-started"}
linear.get_workflow_states.return_value = {"in_progress": "state-in-progress"}
linear.update_issue.return_value = True

populate_linear_parent(
Expand All @@ -495,7 +495,7 @@ def test_updates_by_uuid_never_by_identifier(

linear.get_issue.assert_not_called()
assert linear.update_issue.call_args[0][0] == "uuid-verified"
assert linear.update_issue.call_args[1]["state_id"] == "state-started"
assert linear.update_issue.call_args[1]["state_id"] == "state-in-progress"
linear.create_attachment.assert_called_once()
assert linear.create_attachment.call_args[0][0] == "uuid-verified"
mock_slack.add_bookmark.assert_called_once_with(
Expand Down
Loading
Loading