From 8f2560f20648bcef017dbf30323b862bee7966ad Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Tue, 9 Jun 2026 15:38:54 -0400 Subject: [PATCH 1/2] feat(incidents): Deep-link to Linear create-sub-issue form for action items Instead of opening the parent Linear issue (which users often mistake for the action item itself), the "Create Action Item" button now opens Linear's create-issue form with the parent pre-set. This makes it structurally clear that a new sub-issue is being created. Falls back to the parent issue URL when the parent issue ID is unavailable. Co-Authored-By: Claude Agent transcript: https://claudescope.sentry.dev/share/ycOzRtfQeeoLwZGHoeHa3tXl5KnGp52Mdhr-DApa2Sk --- .../components/ActionItemsList.test.tsx | 46 +++++++++++++++++++ .../components/ActionItemsList.tsx | 31 +++++++++++-- frontend/src/routes/$incidentId/index.tsx | 1 + .../queries/incidentDetailQueryOptions.ts | 1 + src/firetower/incidents/serializers.py | 2 + .../incidents/tests/test_serializers.py | 3 ++ 6 files changed, 79 insertions(+), 5 deletions(-) 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..2919c258 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -62,12 +62,31 @@ 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 +146,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 +173,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: From b3b3cca7d187322bd48feadcb1e7b3044d45bc0f Mon Sep 17 00:00:00 2001 From: Richard Gibert Date: Tue, 9 Jun 2026 15:41:05 -0400 Subject: [PATCH 2/2] style(incidents): Fix prettier formatting in ActionItemsList Co-Authored-By: Claude --- .../src/routes/$incidentId/components/ActionItemsList.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx index 2919c258..a78620c1 100644 --- a/frontend/src/routes/$incidentId/components/ActionItemsList.tsx +++ b/frontend/src/routes/$incidentId/components/ActionItemsList.tsx @@ -62,10 +62,7 @@ function ActionItemCard({item}: {item: ActionItem}) { ); } -function buildLinearCreateUrl( - linearUrl: string, - parentIssueId: string -): string | null { +function buildLinearCreateUrl(linearUrl: string, parentIssueId: string): string | null { try { const url = new URL(linearUrl); const workspace = url.pathname.split('/')[1];