-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Linear action items with parent-child issue model #162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
66c848e
1897d02
cbd54bf
ba86b2c
fa26db4
74d0daf
0fc173c
7abd439
1940df5
0079888
b757c99
634e209
ed4f395
5c57e42
64248aa
9261066
57c6a1c
feb40e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
@@ -15,6 +16,7 @@ | |
| ) | ||
| from firetower.integrations.services import ( | ||
| DatadogService, | ||
| LinearService, | ||
| PagerDutyService, | ||
| SlackService, | ||
| ) | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Off-by-one in claim loop misses last created issueMedium Severity
Reviewed by Cursor Bugbot for commit 57c6a1c. Configure here.
Comment on lines
+593
to
+601
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: When Suggested FixThe placeholder creation loop should be removed as it's based on a flawed assumption. Instead, the function should attempt to Prompt for AI Agent |
||
|
|
||
|
|
||
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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}" | ||
| ) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Identical Linear title-sync blocks duplicated across hooksLow Severity
Additional Locations (1)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: | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: The
_claim_linear_issuefunction leaks placeholder issues in Linear if it fails to claim a specific issue identifier, creating junk data whenSYNC_IDENTIFIERSis enabled.Severity: MEDIUM
Suggested Fix
Implement a cleanup mechanism within the
_claim_linear_issuefunction. 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