diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx index e6fe87dd..e4b6b4f4 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.test.tsx @@ -1,5 +1,6 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import {TooltipProvider} from 'components/Tooltip'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; @@ -151,4 +152,49 @@ describe('ActionItemsList', () => { expect(screen.getByRole('button', {name: 'Sync action items'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Create action item'})).toBeInTheDocument(); }); + + it('opens Linear create-sub-issue URL when parent issue ID is available', async () => { + mockApiGet.mockResolvedValue([]); + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + renderWithProviders( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Create action item'})); + await userEvent.click(screen.getByRole('button', {name: 'Create in Linear'})); + + expect(windowOpenSpy).toHaveBeenCalledWith( + 'https://linear.app/myworkspace/new?parentId=abc-123-def', + '_blank', + 'noopener,noreferrer' + ); + windowOpenSpy.mockRestore(); + }); + + it('falls back to parent issue URL when parent issue ID is not available', async () => { + mockApiGet.mockResolvedValue([]); + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + renderWithProviders( + + ); + + await userEvent.click(screen.getByRole('button', {name: 'Create action item'})); + await userEvent.click(screen.getByRole('button', {name: 'Create in Linear'})); + + expect(windowOpenSpy).toHaveBeenCalledWith( + 'https://linear.app/myworkspace/issue/INC-1', + '_blank', + 'noopener,noreferrer' + ); + windowOpenSpy.mockRestore(); + }); }); diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx index 19a93446..a78620c1 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -62,12 +62,28 @@ function ActionItemCard({item}: {item: ActionItem}) { ); } +function buildLinearCreateUrl(linearUrl: string, parentIssueId: string): string | null { + try { + const url = new URL(linearUrl); + const workspace = url.pathname.split('/')[1]; + if (!workspace) return null; + return `https://linear.app/${workspace}/new?parentId=${parentIssueId}`; + } catch { + return null; + } +} + interface ActionItemsListProps { incidentId: string; linearUrl?: string; + linearParentIssueId?: string | null; } -export function ActionItemsList({incidentId, linearUrl}: ActionItemsListProps) { +export function ActionItemsList({ + incidentId, + linearUrl, + linearParentIssueId, +}: ActionItemsListProps) { const queryClient = useQueryClient(); const syncMutation = useMutation( syncActionItemsMutationOptions(queryClient, incidentId) @@ -127,8 +143,8 @@ export function ActionItemsList({incidentId, linearUrl}: ActionItemsListProps) { title="Create Action Item" message={ <> - You'll be taken to the parent Linear issue for the incident. Create your - action item as a sub-issue or related issue there. + You'll be taken to Linear to create a sub-issue under the parent incident + issue.

Make sure to assign the issue to the appropriate team as issues in the @@ -154,9 +170,11 @@ export function ActionItemsList({incidentId, linearUrl}: ActionItemsListProps) { } - confirmLabel="Open Linear" + confirmLabel="Create in Linear" onConfirm={() => { - window.open(linearUrl, '_blank', 'noopener,noreferrer'); + const createUrl = + linearParentIssueId && buildLinearCreateUrl(linearUrl, linearParentIssueId); + window.open(createUrl || linearUrl, '_blank', 'noopener,noreferrer'); setShowCreateDialog(false); }} onCancel={() => setShowCreateDialog(false)} diff --git a/frontend/src/routes/$incidentId/index.tsx b/frontend/src/routes/$incidentId/index.tsx index 47a17280..60678fc7 100644 --- a/frontend/src/routes/$incidentId/index.tsx +++ b/frontend/src/routes/$incidentId/index.tsx @@ -66,6 +66,7 @@ function Incident() { diff --git a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts index bb094c60..ac2c5f9d 100644 --- a/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts +++ b/frontend/src/routes/$incidentId/queries/incidentDetailQueryOptions.ts @@ -39,6 +39,7 @@ const IncidentDetailSchema = z.object({ impact_type_tags: z.array(z.string()), participants: z.array(ParticipantSchema), external_links: ExternalLinksSchema, + linear_parent_issue_id: z.string().nullable(), time_started: z.string().nullable(), time_detected: z.string().nullable(), time_analyzed: z.string().nullable(), diff --git a/src/firetower/incidents/serializers.py b/src/firetower/incidents/serializers.py index de16ee16..4472ec7e 100644 --- a/src/firetower/incidents/serializers.py +++ b/src/firetower/incidents/serializers.py @@ -157,6 +157,7 @@ class Meta: "root_cause_tags", "impact_type_tags", "external_links", + "linear_parent_issue_id", "created_at", "updated_at", "time_started", @@ -168,6 +169,7 @@ class Meta: ] read_only_fields = [ "id", + "linear_parent_issue_id", "created_at", "updated_at", "time_started", diff --git a/src/firetower/incidents/tests/test_serializers.py b/src/firetower/incidents/tests/test_serializers.py index 13554f38..1f4ca32f 100644 --- a/src/firetower/incidents/tests/test_serializers.py +++ b/src/firetower/incidents/tests/test_serializers.py @@ -148,6 +148,9 @@ def test_incident_detail_serialization(self): assert "linear" not in data["external_links"] # Not set, so not included assert len(data["external_links"]) == 1 + # Check linear_parent_issue_id is exposed (null when not set) + assert data["linear_parent_issue_id"] is None + @pytest.mark.django_db class TestIncidentWriteSerializerHooks: