Skip to content

Commit d85b540

Browse files
authored
fix(linear): track only sub-issues as action items and re-enable parent reopen (#262)
## Summary Follow-up to #261. A user flagged that Firetower was treating loosely-**related** Linear issues as incident action items, which then dragged the parent issue's auto-status around — the confusing behavior #261 tried to fix by making parent status one-way (complete-only, never reopen). This change addresses the root cause instead: 1. **Only sub-issues (children) of the Linear parent are tracked as action items.** Related / blocking / duplicate relations are no longer synced. `get_related_issues` / `_fetch_relations` / `LINEAR_RELATION_TYPE_MAP` are removed. 2. **Re-enable the two-way parent status sync** (reverting #261's one-way change). The parent advances to **Completed** when the incident is resolved (DONE/CANCELED) and all sub-issues are done, and reopens to **Started** when the incident is reopened or sub-issues remain. 3. **Restored the "Started" status-change comment** and its config key `PARENT_STATUS_COMMENT_STARTED` (still present in the prod `config.toml`). 4. Updated `LINEAR_PARENT_DESCRIPTION` to say only sub-issues are tracked and that the ticket will both auto-complete and reopen. The captain-change assignee sync from #261 is left untouched. The `relation_type` field on `ActionItem` is intentionally kept (now always `child`).
1 parent 682d993 commit d85b540

8 files changed

Lines changed: 190 additions & 539 deletions

File tree

src/firetower/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ class LinearConfig:
100100
"{% else %}all {{ total_action_items }} action "
101101
"item{% if total_action_items != 1 %}s{% endif %} are complete.{% endif %}"
102102
)
103+
parent_status_comment_started: str = (
104+
"Firetower set this issue to **Started**. "
105+
"Incident {{ incident.incident_number }} is {{ incident.status }}. "
106+
"{% if total_action_items == 0 %}There are no action items."
107+
"{% else %}{{ completed_action_items }} of {{ total_action_items }} action "
108+
"item{% if total_action_items != 1 %}s{% endif %} complete.{% endif %}"
109+
)
103110

104111

105112
@deserialize

src/firetower/incidents/hooks.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -998,11 +998,13 @@ def _sync_linear_assignee(incident: Incident) -> None:
998998

999999

10001000
LINEAR_PARENT_DESCRIPTION = (
1001-
"Relate action items to this ticket to have them tracked by Firetower. "
1002-
"Child issues or other relations (related, blocking, etc.) will all work. "
1001+
"Add action items as sub-issues (child issues) of this ticket to have "
1002+
"them tracked by Firetower. "
10031003
"Do not update title, status or captain here, use Firetower for that.\n\n"
1004-
"Firetower will mark this ticket completed once the incident is resolved and "
1005-
"all action items are done. "
1004+
"Firetower will mark this ticket as completed once the incident is "
1005+
"resolved and all action items are done. "
1006+
"Firetower will reopen this ticket if the incident is reopened, or if "
1007+
"there are still unfinished action items. "
10061008
"If you have questions, please reach out to #team-sre."
10071009
)
10081010

src/firetower/incidents/services.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -189,26 +189,31 @@ def _resolve_assignees(
189189
_PARENT_STATUS_TEMPLATE_ENV = Environment(autoescape=False)
190190

191191

192-
def _comment_parent_issue_completed(
192+
def _comment_parent_issue_status_change(
193193
incident: Incident,
194194
linear_service: LinearService,
195+
target_state: str,
195196
statuses: list[str],
196197
) -> None:
197198
if not settings.LINEAR or not incident.linear_parent_issue_id:
198199
return
199200

200-
template_source = settings.LINEAR.get("PARENT_STATUS_COMMENT_COMPLETED", "")
201+
template_key = (
202+
"PARENT_STATUS_COMMENT_COMPLETED"
203+
if target_state == "completed"
204+
else "PARENT_STATUS_COMMENT_STARTED"
205+
)
206+
template_source = settings.LINEAR.get(template_key, "")
201207
if not template_source or not template_source.strip():
202208
return
203209

204-
completed_action_items = sum(
205-
1 for s in statuses if s in {ActionItemStatus.DONE, ActionItemStatus.CANCELED}
206-
)
210+
completed_action_items = sum(1 for s in statuses if s in COMPLETED_STATUSES)
207211
try:
208212
comment = _PARENT_STATUS_TEMPLATE_ENV.from_string(template_source).render(
209213
incident=incident,
210214
total_action_items=len(statuses),
211215
completed_action_items=completed_action_items,
216+
target_state=target_state,
212217
)
213218
except TemplateError:
214219
logger.exception(
@@ -239,22 +244,31 @@ def _update_parent_issue_status(
239244
not statuses or all(s in COMPLETED_STATUSES for s in statuses)
240245
)
241246

242-
if not all_complete:
243-
return
244-
245247
states = linear_service.get_workflow_states(team_id)
246248
if not states:
247249
return
248250

251+
target_state = "completed" if all_complete else "started"
252+
249253
parent_issue = linear_service.get_issue(incident.linear_parent_issue_id)
250-
if not parent_issue or parent_issue.get("state_type") == "completed":
254+
if not parent_issue:
255+
return
256+
257+
# Never override a manually-cancelled parent issue. Firetower only ever
258+
# drives the parent to "started" or "completed", so a "canceled" state
259+
# reflects a deliberate human decision and must not be reopened to
260+
# "started" (or forced to "completed") on subsequent syncs.
261+
current_state_type = parent_issue.get("state_type")
262+
if current_state_type in ("canceled", target_state):
251263
return
252264

253-
state_id = states.get("completed")
265+
state_id = states.get(target_state)
254266
if state_id and linear_service.update_issue(
255267
incident.linear_parent_issue_id, state_id=state_id
256268
):
257-
_comment_parent_issue_completed(incident, linear_service, statuses)
269+
_comment_parent_issue_status_change(
270+
incident, linear_service, target_state, statuses
271+
)
258272

259273

260274
def sync_action_items_from_linear(
@@ -297,21 +311,9 @@ def sync_action_items_from_linear(
297311
incident.save(update_fields=["action_items_last_synced_at"])
298312
return stats
299313

300-
related = linear_service.get_related_issues(parent_id)
301-
if related is None:
302-
error_msg = f"Failed to fetch related issues for incident {incident.id}"
303-
logger.warning(error_msg)
304-
stats.errors.append(error_msg)
305-
incident.action_items_last_synced_at = timezone.now()
306-
incident.save(update_fields=["action_items_last_synced_at"])
307-
return stats
308-
309314
all_issues: dict[str, dict] = {}
310315
for issue in children:
311316
all_issues[issue["id"]] = issue
312-
for issue in related:
313-
if issue["id"] not in all_issues:
314-
all_issues[issue["id"]] = issue
315317

316318
logger.info(f"Syncing {len(all_issues)} Linear issues to incident {incident.id}")
317319

0 commit comments

Comments
 (0)