Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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(
<ActionItemsList
incidentId="INC-1"
linearUrl="https://linear.app/myworkspace/issue/INC-1"
linearParentIssueId="abc-123-def"
/>
);

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(
<ActionItemsList
incidentId="INC-1"
linearUrl="https://linear.app/myworkspace/issue/INC-1"
/>
);

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();
});
});
28 changes: 23 additions & 5 deletions frontend/src/routes/$incidentId/components/ActionItemsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
<br />
<br />
Make sure to assign the issue to the appropriate team as issues in the
Expand All @@ -154,9 +170,11 @@ export function ActionItemsList({incidentId, linearUrl}: ActionItemsListProps) {
</ul>
</>
}
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)}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/routes/$incidentId/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function Incident() {
<ActionItemsList
incidentId={params.incidentId}
linearUrl={incident.external_links.linear}
linearParentIssueId={incident.linear_parent_issue_id}
/>
</section>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions src/firetower/incidents/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ class Meta:
"root_cause_tags",
"impact_type_tags",
"external_links",
"linear_parent_issue_id",
"created_at",
"updated_at",
"time_started",
Expand All @@ -168,6 +169,7 @@ class Meta:
]
read_only_fields = [
"id",
"linear_parent_issue_id",
"created_at",
"updated_at",
"time_started",
Expand Down
3 changes: 3 additions & 0 deletions src/firetower/incidents/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading