From 2f9fb62e94e8c26bdc284491666e90b188c8f9c9 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 5 Feb 2026 16:35:54 -0500 Subject: [PATCH 1/5] Remove JiraService and jira pip dependency --- pyproject.toml | 1 - .../integrations/services/__init__.py | 3 +- src/firetower/integrations/services/jira.py | 140 --------------- src/firetower/integrations/test_jira.py | 166 ------------------ uv.lock | 62 ------- 5 files changed, 1 insertion(+), 371 deletions(-) delete mode 100644 src/firetower/integrations/services/jira.py delete mode 100644 src/firetower/integrations/test_jira.py diff --git a/pyproject.toml b/pyproject.toml index a0865b49..058293c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ dependencies = [ "django-kubernetes>=1.1.0", "djangorestframework>=3.15.2", "google-auth>=2.37.0", - "jira>=3.5.0", "psycopg[binary]>=3.2.11", "pyserde[toml]>=0.28.0", "slack-bolt>=1.27.0", diff --git a/src/firetower/integrations/services/__init__.py b/src/firetower/integrations/services/__init__.py index 568cfcb7..847856a5 100644 --- a/src/firetower/integrations/services/__init__.py +++ b/src/firetower/integrations/services/__init__.py @@ -1,6 +1,5 @@ """Services package for external integrations.""" -from .jira import JiraService from .slack import SlackService -__all__ = ["JiraService", "SlackService"] +__all__ = ["SlackService"] diff --git a/src/firetower/integrations/services/jira.py b/src/firetower/integrations/services/jira.py deleted file mode 100644 index 5aa58104..00000000 --- a/src/firetower/integrations/services/jira.py +++ /dev/null @@ -1,140 +0,0 @@ -""" -Jira integration service for fetching incident data. - -This service provides a simple interface to interact with Jira's REST API -and transform Jira issues into our incident data format. -""" - -import re -from typing import Any - -from django.conf import settings -from jira import JIRA - - -class JiraService: - """ - Service class for interacting with Jira API. - - Provides methods to fetch incident data from Jira and transform it - into a format suitable for the Firetower application. - """ - - def __init__(self) -> None: - """Initialize the Jira service.""" - # Get Jira configuration from Django settings - jira_config = settings.JIRA - - # Validate required settings - if not jira_config["ACCOUNT"] or not jira_config["API_KEY"]: - raise ValueError("Jira credentials not configured in settings.JIRA") - - # Store config for later use - self.domain = jira_config["DOMAIN"] - self.project_key = settings.PROJECT_KEY - self.severity_field_id = jira_config["SEVERITY_FIELD"] - - # Initialize Jira client with basic auth - self.client = JIRA( - self.domain, basic_auth=(jira_config["ACCOUNT"], jira_config["API_KEY"]) - ) - - def _extract_severity(self, issue: Any) -> str | None: - """Extract severity from Jira custom field.""" - severity_field = getattr(issue.fields, self.severity_field_id, None) - return getattr(severity_field, "value", None) if severity_field else None - - def get_incident(self, incident_key: str) -> dict[str, Any]: - """ - Fetch a single incident by its Jira key. - - Args: - incident_key (str): Jira issue key (e.g., 'INC-1247') - - Returns: - dict: Incident data or None if not found - """ - issue = self.client.issue(incident_key) - - return { - "id": issue.key, - "title": issue.fields.summary, - "description": getattr(issue.fields, "description", "") or "", - "status": issue.fields.status.name, - "severity": self._extract_severity(issue), - "assignee": issue.fields.assignee.displayName - if issue.fields.assignee - else None, - "assignee_email": issue.fields.assignee.emailAddress - if issue.fields.assignee - else None, - "reporter": issue.fields.reporter.displayName - if issue.fields.reporter - else None, - "reporter_email": issue.fields.reporter.emailAddress - if issue.fields.reporter - else None, - "created_at": issue.fields.created, - "updated_at": issue.fields.updated, - } - - def get_incidents( - self, statuses: list[str] | None = None, max_results: int = 50 - ) -> list[dict[str, Any]]: - """ - Fetch a list of incidents from the Jira project. - - Args: - statuses (list[str], optional): Filter by status values (e.g., ['Active', 'Mitigated']) - max_results (int): Maximum number of incidents to return (default: 50) - - Returns: - list: List of incident data dictionaries - """ - jql_parts = [f'project = "{self.project_key}"'] - - if statuses: - # Validate each status - for status in statuses: - if not re.match(r"^[A-Za-z\s]+$", status): - raise ValueError( - f"Invalid status format: {status}. Only alphabetical characters and spaces allowed." - ) - - # Build IN clause for multiple statuses - status_list = ", ".join(f'"{s}"' for s in statuses) - jql_parts.append(f"status IN ({status_list})") - - jql_query = " AND ".join(jql_parts) - jql_query += " ORDER BY created DESC" - - issues = self.client.search_issues( - jql_query, maxResults=max_results, expand="changelog" - ) - - incidents = [] - for issue in issues: - incident_data = { - "id": issue.key, - "title": issue.fields.summary, - "description": getattr(issue.fields, "description", "") or "", - "status": issue.fields.status.name, - "severity": self._extract_severity(issue), - "assignee": issue.fields.assignee.displayName - if issue.fields.assignee - else None, - "assignee_email": issue.fields.assignee.emailAddress - if issue.fields.assignee - else None, - "reporter": issue.fields.reporter.displayName - if issue.fields.reporter - else None, - "reporter_email": issue.fields.reporter.emailAddress - if issue.fields.reporter - else None, - "created_at": issue.fields.created, - "updated_at": issue.fields.updated, - } - incidents.append(incident_data) - - return incidents diff --git a/src/firetower/integrations/test_jira.py b/src/firetower/integrations/test_jira.py deleted file mode 100644 index 855a053d..00000000 --- a/src/firetower/integrations/test_jira.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Basic pytest tests for Jira integration service. -""" - -import os -from unittest.mock import patch - -import pytest - -from .services.jira import JiraService - -# Set up Django settings -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "firetower.settings") - -import django -from django.conf import settings - -# Setup Django -django.setup() - - -class TestJiraService: - """Test suite for JiraService""" - - def test_initialization_requires_credentials(self): - """Test that JiraService initialization validates required credentials.""" - # Mock settings to have empty credentials - mock_jira_config = { - "ACCOUNT": "", - "API_KEY": "", - "DOMAIN": "https://test.atlassian.net", - "PROJECT_KEY": "INC", - "SEVERITY_FIELD": "customfield_10001", - } - - with patch.object(settings, "JIRA", mock_jira_config): - with pytest.raises(ValueError, match="Jira credentials not configured"): - JiraService() - - def test_initialization_success_with_valid_credentials(self): - """Test that JiraService initializes successfully with valid credentials.""" - mock_jira_config = { - "ACCOUNT": "test@example.com", - "API_KEY": "test-api-key", - "DOMAIN": "https://test.atlassian.net", - "SEVERITY_FIELD": "customfield_10001", - } - - with patch.object(settings, "JIRA", mock_jira_config): - with patch.object(settings, "PROJECT_KEY", "INC"): - with patch( - "firetower.integrations.services.jira.JIRA" - ) as mock_jira_client: - service = JiraService() - - # Verify the service was created and JIRA client was initialized - assert service.domain == "https://test.atlassian.net" - assert service.project_key == "INC" - assert service.severity_field_id == "customfield_10001" - mock_jira_client.assert_called_once() - - def test_extract_severity_with_valid_field(self): - """Test severity extraction from Jira issue with valid severity field.""" - mock_jira_config = { - "ACCOUNT": "test@example.com", - "API_KEY": "test-api-key", - "DOMAIN": "https://test.atlassian.net", - "PROJECT_KEY": "INC", - "SEVERITY_FIELD": "customfield_10001", - } - - with patch.object(settings, "JIRA", mock_jira_config): - with patch("firetower.integrations.services.jira.JIRA"): - service = JiraService() - - # Mock issue with severity field - mock_issue = type("MockIssue", (), {})() - mock_issue.fields = type("MockFields", (), {})() - mock_severity = type("MockSeverity", (), {"value": "P1"})() - setattr(mock_issue.fields, "customfield_10001", mock_severity) - - severity = service._extract_severity(mock_issue) - assert severity == "P1" - - def test_extract_severity_with_missing_field(self): - """Test severity extraction when severity field is missing.""" - mock_jira_config = { - "ACCOUNT": "test@example.com", - "API_KEY": "test-api-key", - "DOMAIN": "https://test.atlassian.net", - "PROJECT_KEY": "INC", - "SEVERITY_FIELD": "customfield_10001", - } - - with patch.object(settings, "JIRA", mock_jira_config): - with patch("firetower.integrations.services.jira.JIRA"): - service = JiraService() - - # Mock issue without severity field - mock_issue = type("MockIssue", (), {})() - mock_issue.fields = type("MockFields", (), {})() - - severity = service._extract_severity(mock_issue) - assert severity is None - - def test_get_incidents_validates_status_format(self): - """Test that get_incidents validates status parameter format.""" - mock_jira_config = { - "ACCOUNT": "test@example.com", - "API_KEY": "test-api-key", - "DOMAIN": "https://test.atlassian.net", - "PROJECT_KEY": "INC", - "SEVERITY_FIELD": "customfield_10001", - } - - with patch.object(settings, "JIRA", mock_jira_config): - with patch("firetower.integrations.services.jira.JIRA"): - service = JiraService() - - # Test with invalid characters in status - with pytest.raises(ValueError, match="Invalid status format"): - service.get_incidents(statuses=["Active; DROP TABLE incidents;"]) - - # Test with numbers in status - with pytest.raises(ValueError, match="Invalid status format"): - service.get_incidents(statuses=["Status123"]) - - def test_get_incidents_builds_correct_jql_query(self): - """Test that get_incidents builds the correct JQL query.""" - mock_jira_config = { - "ACCOUNT": "test@example.com", - "API_KEY": "test-api-key", - "DOMAIN": "https://test.atlassian.net", - "SEVERITY_FIELD": "customfield_10001", - } - - with patch.object(settings, "JIRA", mock_jira_config): - with patch.object(settings, "PROJECT_KEY", "TESTINC"): - with patch( - "firetower.integrations.services.jira.JIRA" - ) as mock_jira_client: - mock_client_instance = mock_jira_client.return_value - mock_client_instance.search_issues.return_value = [] - - service = JiraService() - - # Test without status filter - service.get_incidents() - expected_jql = 'project = "TESTINC" ORDER BY created DESC' - mock_client_instance.search_issues.assert_called_with( - expected_jql, maxResults=50, expand="changelog" - ) - - # Test with single status filter - service.get_incidents(statuses=["Active"]) - expected_jql_single = 'project = "TESTINC" AND status IN ("Active") ORDER BY created DESC' - mock_client_instance.search_issues.assert_called_with( - expected_jql_single, maxResults=50, expand="changelog" - ) - - # Test with multiple status filters - service.get_incidents(statuses=["Active", "Mitigated"]) - expected_jql_multiple = 'project = "TESTINC" AND status IN ("Active", "Mitigated") ORDER BY created DESC' - mock_client_instance.search_issues.assert_called_with( - expected_jql_multiple, maxResults=50, expand="changelog" - ) diff --git a/uv.lock b/uv.lock index b3e61fdb..71531f2a 100644 --- a/uv.lock +++ b/uv.lock @@ -394,15 +394,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/9d/6b95ba537a2dc8068142d6d89bf6ed973e32c632bd3f35aa402f5b8149fc/ddtrace-3.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:8ff71b1f1490310ef4409317e2850662370fe14e48ff9b784531ce46b17f0e1c", size = 5208658, upload-time = "2025-11-07T22:55:08.169Z" }, ] -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -522,7 +513,6 @@ dependencies = [ { name = "django-kubernetes" }, { name = "djangorestframework" }, { name = "google-auth" }, - { name = "jira" }, { name = "psycopg", extra = ["binary"] }, { name = "pyserde", extra = ["toml"] }, { name = "slack-bolt" }, @@ -557,7 +547,6 @@ requires-dist = [ { name = "django-kubernetes", specifier = ">=1.1.0" }, { name = "djangorestframework", specifier = ">=3.15.2" }, { name = "google-auth", specifier = ">=2.37.0" }, - { name = "jira", specifier = ">=3.5.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.11" }, { name = "pyserde", extras = ["toml"], specifier = ">=0.28.0" }, { name = "slack-bolt", specifier = ">=1.27.0" }, @@ -708,23 +697,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] -[[package]] -name = "jira" -version = "3.10.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "defusedxml" }, - { name = "packaging" }, - { name = "requests" }, - { name = "requests-oauthlib" }, - { name = "requests-toolbelt" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/65/73/ee4daa7cf4eea457180de0ea78b730b44bb5ad2829dae49cf708a1460819/jira-3.10.5.tar.gz", hash = "sha256:2d09ae3bf4741a2787dd889dfea5926a5d509aac3b28ab3b98c098709e6ee72d", size = 105870, upload-time = "2025-07-28T12:18:22.796Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/57/ad078d7379e390798559446607e413fc953c7510711462ab34194dba5924/jira-3.10.5-py3-none-any.whl", hash = "sha256:d4da1385c924ee693d6cc9838e56a34e31d74f0d6899934ef35bbd0d2d33997f", size = 79250, upload-time = "2025-07-28T12:18:21.368Z" }, -] - [[package]] name = "legacy-cgi" version = "2.6.4" @@ -868,15 +840,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] -[[package]] -name = "oauthlib" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, -] - [[package]] name = "opentelemetry-api" version = "1.38.0" @@ -1199,31 +1162,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, -] - -[[package]] -name = "requests-toolbelt" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, -] - [[package]] name = "rich" version = "14.2.0" From 56e8e7e2170113631acc829169b13beab5d3a49b Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 5 Feb 2026 16:36:10 -0500 Subject: [PATCH 2/5] Remove JIRA external link type and add data migration --- .../0014_remove_jira_external_link_type.py | 31 +++++++++++++++++++ src/firetower/incidents/models.py | 1 - src/firetower/incidents/serializers.py | 2 +- src/firetower/incidents/tests/test_models.py | 6 ++-- .../incidents/tests/test_serializers.py | 2 +- src/firetower/incidents/tests/test_views.py | 24 +++++++------- 6 files changed, 48 insertions(+), 18 deletions(-) create mode 100644 src/firetower/incidents/migrations/0014_remove_jira_external_link_type.py diff --git a/src/firetower/incidents/migrations/0014_remove_jira_external_link_type.py b/src/firetower/incidents/migrations/0014_remove_jira_external_link_type.py new file mode 100644 index 00000000..2b3799fa --- /dev/null +++ b/src/firetower/incidents/migrations/0014_remove_jira_external_link_type.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +def delete_jira_links(apps, schema_editor): + ExternalLink = apps.get_model("incidents", "ExternalLink") + ExternalLink.objects.filter(type="JIRA").delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("incidents", "0013_add_tag_approved_field"), + ] + + operations = [ + migrations.RunPython(delete_jira_links, migrations.RunPython.noop), + migrations.AlterField( + model_name="externallink", + name="type", + field=models.CharField( + choices=[ + ("SLACK", "Slack"), + ("DATADOG", "Datadog"), + ("PAGERDUTY", "PagerDuty"), + ("STATUSPAGE", "Statuspage"), + ("NOTION", "Notion"), + ("LINEAR", "Linear"), + ], + max_length=20, + ), + ), + ] diff --git a/src/firetower/incidents/models.py b/src/firetower/incidents/models.py index 26ba48cf..55c6f543 100644 --- a/src/firetower/incidents/models.py +++ b/src/firetower/incidents/models.py @@ -81,7 +81,6 @@ class TagType(models.TextChoices): class ExternalLinkType(models.TextChoices): SLACK = "SLACK", "Slack" - JIRA = "JIRA", "Jira" DATADOG = "DATADOG", "Datadog" PAGERDUTY = "PAGERDUTY", "PagerDuty" STATUSPAGE = "STATUSPAGE", "Statuspage" diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index 871a374a..d3e4a1f2 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -313,7 +313,7 @@ class IncidentWriteSerializer(serializers.ModelSerializer): root_cause_tags, impact_type_tags captain/reporter: Email address of the user - external_links format: {"slack": "url", "jira": "url", ...} + external_links format: {"slack": "url", "linear": "url", ...} - Merges with existing links (only updates provided links) - Use null to delete a specific link: {"slack": null} - Omit external_links field to leave existing links unchanged diff --git a/src/firetower/incidents/tests/test_models.py b/src/firetower/incidents/tests/test_models.py index 59706dd7..616bbdae 100644 --- a/src/firetower/incidents/tests/test_models.py +++ b/src/firetower/incidents/tests/test_models.py @@ -394,13 +394,13 @@ def test_external_link_multiple_types(self): incident=incident, type=ExternalLinkType.SLACK, url="https://slack.com" ) - jira = ExternalLink.objects.create( - incident=incident, type=ExternalLinkType.JIRA, url="https://jira.com" + linear = ExternalLink.objects.create( + incident=incident, type=ExternalLinkType.LINEAR, url="https://linear.app" ) assert incident.external_links.count() == 2 assert slack in incident.external_links.all() - assert jira in incident.external_links.all() + assert linear in incident.external_links.all() def test_external_link_str(self): """Test external link string representation""" diff --git a/src/firetower/incidents/tests/test_serializers.py b/src/firetower/incidents/tests/test_serializers.py index b4be67ba..ca8e99fc 100644 --- a/src/firetower/incidents/tests/test_serializers.py +++ b/src/firetower/incidents/tests/test_serializers.py @@ -141,5 +141,5 @@ def test_incident_detail_serialization(self): assert ( data["external_links"]["slack"] == "https://slack.com/channels/incident-123" ) - assert "jira" not in data["external_links"] # Not set, so not included + assert "linear" not in data["external_links"] # Not set, so not included assert len(data["external_links"]) == 1 diff --git a/src/firetower/incidents/tests/test_views.py b/src/firetower/incidents/tests/test_views.py index 97f52d51..e103e574 100644 --- a/src/firetower/incidents/tests/test_views.py +++ b/src/firetower/incidents/tests/test_views.py @@ -829,7 +829,7 @@ def test_create_incident_with_external_links(self): "reporter": self.reporter.email, "external_links": { "slack": "https://slack.com/channel/123", - "jira": "https://jira.company.com/browse/INC-1", + "linear": "https://linear.app/team/issue/ENG-1", }, } @@ -843,7 +843,7 @@ def test_create_incident_with_external_links(self): data = response.json() assert data["external_links"]["slack"] == "https://slack.com/channel/123" - assert data["external_links"]["jira"] == "https://jira.company.com/browse/INC-1" + assert data["external_links"]["linear"] == "https://linear.app/team/issue/ENG-1" assert "datadog" not in data["external_links"] def test_create_incident_with_tags(self): @@ -896,8 +896,8 @@ def test_add_external_link_via_patch(self): self.client.force_authenticate(user=self.captain) - # Add jira link, should keep slack - payload = {"external_links": {"jira": "https://jira.com/new"}} + # Add linear link, should keep slack + payload = {"external_links": {"linear": "https://linear.app/new"}} response = self.client.patch( f"/api/incidents/{incident.incident_number}/", payload, format="json" ) @@ -909,7 +909,7 @@ def test_add_external_link_via_patch(self): data = response.json() assert data["external_links"]["slack"] == "https://slack.com/original" - assert data["external_links"]["jira"] == "https://jira.com/new" + assert data["external_links"]["linear"] == "https://linear.app/new" def test_update_existing_external_link(self): """Test updating an existing external link via PATCH""" @@ -958,13 +958,13 @@ def test_delete_external_link_with_null(self): ) ExternalLink.objects.create( incident=incident, - type=ExternalLinkType.JIRA, - url="https://jira.com/test", + type=ExternalLinkType.LINEAR, + url="https://linear.app/test", ) self.client.force_authenticate(user=self.captain) - # Delete slack link, keep jira + # Delete slack link, keep linear payload = {"external_links": {"slack": None}} response = self.client.patch( f"/api/incidents/{incident.incident_number}/", payload, format="json" @@ -972,12 +972,12 @@ def test_delete_external_link_with_null(self): assert response.status_code == 200 - # Verify slack deleted, jira remains + # Verify slack deleted, linear remains response = self.client.get(f"/api/incidents/{incident.incident_number}/") data = response.json() assert "slack" not in data["external_links"] - assert data["external_links"]["jira"] == "https://jira.com/test" + assert data["external_links"]["linear"] == "https://linear.app/test" def test_invalid_external_link_type(self): """Test that invalid link types are rejected""" @@ -1047,7 +1047,7 @@ def test_patch_without_external_links_preserves_existing(self): "reporter": self.reporter.email, "external_links": { "slack": "https://slack.com/channel", - "jira": "https://jira.example.com/issue", + "linear": "https://linear.app/issue", }, } response = self.client.post("/api/incidents/", payload, format="json") @@ -1070,7 +1070,7 @@ def test_patch_without_external_links_preserves_existing(self): data = response.json() assert len(data["external_links"]) == 2 assert data["external_links"]["slack"] == "https://slack.com/channel" - assert data["external_links"]["jira"] == "https://jira.example.com/issue" + assert data["external_links"]["linear"] == "https://linear.app/issue" def test_update_affected_service_tags_via_patch(self): """Test setting affected_service_tags via PATCH""" From c2e9c44cfd7c6333c10bc17eab8d4a2b9428d5c3 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 5 Feb 2026 16:36:15 -0500 Subject: [PATCH 3/5] Remove Jira from frontend components --- frontend/public/jira.svg | 1 - frontend/src/routes/$incidentId/components/LinksList.tsx | 4 ---- .../routes/$incidentId/queries/incidentDetailQueryOptions.ts | 1 - 3 files changed, 6 deletions(-) delete mode 100644 frontend/public/jira.svg diff --git a/frontend/public/jira.svg b/frontend/public/jira.svg deleted file mode 100644 index 417c09c5..00000000 --- a/frontend/public/jira.svg +++ /dev/null @@ -1 +0,0 @@ -Jira \ No newline at end of file diff --git a/frontend/src/routes/$incidentId/components/LinksList.tsx b/frontend/src/routes/$incidentId/components/LinksList.tsx index 4ffcde6c..420ea8f8 100644 --- a/frontend/src/routes/$incidentId/components/LinksList.tsx +++ b/frontend/src/routes/$incidentId/components/LinksList.tsx @@ -7,10 +7,6 @@ const linkConfigs = { label: 'Datadog notebook', icon: '/datadog.svg', }, - jira: { - label: 'Jira', - icon: '/jira.svg', - }, linear: { label: 'Linear', icon: '/linear.svg', diff --git a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts index 03a8aed4..e011258a 100644 --- a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts +++ b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts @@ -13,7 +13,6 @@ const ParticipantSchema = z.object({ const ExternalLinksSchema = z.object({ slack: z.string().optional(), - jira: z.string().optional(), datadog: z.string().optional(), pagerduty: z.string().optional(), statuspage: z.string().optional(), From fc3cef6356406fb9393e8e9adf17717e72d77ec1 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 26 Mar 2026 15:45:10 -0400 Subject: [PATCH 4/5] Renumber migration to 0015 to resolve conflict with 0014_add_total_downtime --- ...rnal_link_type.py => 0015_remove_jira_external_link_type.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/firetower/incidents/migrations/{0014_remove_jira_external_link_type.py => 0015_remove_jira_external_link_type.py} (94%) diff --git a/src/firetower/incidents/migrations/0014_remove_jira_external_link_type.py b/src/firetower/incidents/migrations/0015_remove_jira_external_link_type.py similarity index 94% rename from src/firetower/incidents/migrations/0014_remove_jira_external_link_type.py rename to src/firetower/incidents/migrations/0015_remove_jira_external_link_type.py index 2b3799fa..616aadfc 100644 --- a/src/firetower/incidents/migrations/0014_remove_jira_external_link_type.py +++ b/src/firetower/incidents/migrations/0015_remove_jira_external_link_type.py @@ -8,7 +8,7 @@ def delete_jira_links(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ("incidents", "0013_add_tag_approved_field"), + ("incidents", "0014_add_total_downtime"), ] operations = [ From e10f9c7d07aa425c3e68a4230389689ca0e4bb20 Mon Sep 17 00:00:00 2001 From: Spencer Murray Date: Thu, 26 Mar 2026 15:50:11 -0400 Subject: [PATCH 5/5] Remove unused Jira config fields, only domain is still needed --- src/firetower/config.py | 6 ------ src/firetower/settings.py | 3 --- 2 files changed, 9 deletions(-) diff --git a/src/firetower/config.py b/src/firetower/config.py index eda802a5..28e95bc6 100644 --- a/src/firetower/config.py +++ b/src/firetower/config.py @@ -29,9 +29,6 @@ class DatadogConfig: @deserialize class JIRAConfig: domain: str - account: str - api_key: str - severity_field: str @deserialize @@ -108,9 +105,6 @@ def __init__(self) -> None: ) self.jira = JIRAConfig( domain="", - account="", - api_key="", - severity_field="", ) self.slack = SlackConfig( bot_token="", diff --git a/src/firetower/settings.py b/src/firetower/settings.py index 3913be76..b2d54975 100644 --- a/src/firetower/settings.py +++ b/src/firetower/settings.py @@ -201,9 +201,6 @@ def cmd_needs_dummy_config() -> bool: # Jira Integration Configuration JIRA = { "DOMAIN": config.jira.domain, - "ACCOUNT": config.jira.account, - "API_KEY": config.jira.api_key, - "SEVERITY_FIELD": config.jira.severity_field, } # Slack Integration Configuration