diff --git a/static/app/components/events/autofix/autofixSolution.tsx b/static/app/components/events/autofix/autofixSolution.tsx
index d2573234134721..2d3f83830838d7 100644
--- a/static/app/components/events/autofix/autofixSolution.tsx
+++ b/static/app/components/events/autofix/autofixSolution.tsx
@@ -398,6 +398,7 @@ export function formatSolutionText(
parts.push(
solution
+ .filter(event => event.is_active)
.map(event => {
const eventParts = [`### ${event.title}`];
@@ -430,7 +431,14 @@ function CopySolutionButton({
return null;
}
const text = formatSolutionText(solution, customSolution);
- return ;
+ return (
+
+ );
}
function AutofixSolutionDisplay({
diff --git a/static/app/components/events/autofix/utils.tsx b/static/app/components/events/autofix/utils.tsx
index 5d876f213e84c6..bd7c5340fad7c4 100644
--- a/static/app/components/events/autofix/utils.tsx
+++ b/static/app/components/events/autofix/utils.tsx
@@ -1 +1,130 @@
+import {formatRootCauseText} from 'sentry/components/events/autofix/autofixRootCause';
+import {formatSolutionText} from 'sentry/components/events/autofix/autofixSolution';
+import {
+ type AutofixChangesStep,
+ type AutofixCodebaseChange,
+ type AutofixData,
+ AutofixStatus,
+ AutofixStepType,
+} from 'sentry/components/events/autofix/types';
+
export const AUTOFIX_ROOT_CAUSE_STEP_ID = 'root_cause_analysis';
+
+export function getRootCauseDescription(autofixData: AutofixData) {
+ const rootCause = autofixData.steps?.find(
+ step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS
+ );
+ if (!rootCause) {
+ return null;
+ }
+ return rootCause.causes.at(0)?.description ?? null;
+}
+
+export function getRootCauseCopyText(autofixData: AutofixData) {
+ const rootCause = autofixData.steps?.find(
+ step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS
+ );
+ if (!rootCause) {
+ return null;
+ }
+
+ const cause = rootCause.causes.at(0);
+
+ if (!cause) {
+ return null;
+ }
+
+ return formatRootCauseText(cause);
+}
+
+export function getSolutionDescription(autofixData: AutofixData) {
+ const solution = autofixData.steps?.find(
+ step => step.type === AutofixStepType.SOLUTION
+ );
+ if (!solution) {
+ return null;
+ }
+
+ return solution.description ?? null;
+}
+
+export function getSolutionCopyText(autofixData: AutofixData) {
+ const solution = autofixData.steps?.find(
+ step => step.type === AutofixStepType.SOLUTION
+ );
+ if (!solution) {
+ return null;
+ }
+
+ return formatSolutionText(solution.solution, solution.custom_solution);
+}
+
+export function getSolutionIsLoading(autofixData: AutofixData) {
+ const solutionProgressStep = autofixData.steps?.find(
+ step => step.key === 'solution_processing'
+ );
+ return solutionProgressStep?.status === AutofixStatus.PROCESSING;
+}
+
+export function getCodeChangesDescription(autofixData: AutofixData) {
+ if (!autofixData) {
+ return null;
+ }
+
+ const changesStep = autofixData.steps?.find(
+ step => step.type === AutofixStepType.CHANGES
+ ) as AutofixChangesStep | undefined;
+
+ if (!changesStep) {
+ return null;
+ }
+
+ // If there are changes with PRs, show links to them
+ const changesWithPRs = changesStep.changes?.filter(
+ (change: AutofixCodebaseChange) => change.pull_request
+ );
+ if (changesWithPRs?.length) {
+ return changesWithPRs
+ .map(
+ (change: AutofixCodebaseChange) =>
+ `[View PR in ${change.repo_name}](${change.pull_request?.pr_url})`
+ )
+ .join('\n');
+ }
+
+ // If there are code changes but no PRs yet, show a summary
+ if (changesStep.changes?.length) {
+ // Group changes by repo
+ const changesByRepo: Record = {};
+ changesStep.changes.forEach((change: AutofixCodebaseChange) => {
+ changesByRepo[change.repo_name] = (changesByRepo[change.repo_name] || 0) + 1;
+ });
+
+ const changesSummary = Object.entries(changesByRepo)
+ .map(([repo, count]) => `${count} ${count === 1 ? 'change' : 'changes'} in ${repo}`)
+ .join(', ');
+
+ return `Proposed ${changesSummary}.`;
+ }
+
+ return null;
+}
+
+export const getCodeChangesIsLoading = (autofixData: AutofixData) => {
+ if (!autofixData) {
+ return false;
+ }
+
+ // Check if there's a specific changes processing step, similar to solution_processing
+ const changesProgressStep = autofixData.steps?.find(step => step.key === 'plan');
+ if (changesProgressStep?.status === AutofixStatus.PROCESSING) {
+ return true;
+ }
+
+ // Also check if the changes step itself is in processing state
+ const changesStep = autofixData.steps?.find(
+ step => step.type === AutofixStepType.CHANGES
+ );
+
+ return changesStep?.status === AutofixStatus.PROCESSING;
+};
diff --git a/static/app/components/group/groupSummary.tsx b/static/app/components/group/groupSummary.tsx
index abfaeb1a3b015e..cf117dd67377b3 100644
--- a/static/app/components/group/groupSummary.tsx
+++ b/static/app/components/group/groupSummary.tsx
@@ -22,7 +22,7 @@ const POSSIBLE_CAUSE_CONFIDENCE_THRESHOLD = 0.468;
const POSSIBLE_CAUSE_NOVELTY_THRESHOLD = 0.419;
// These thresholds were used when embedding the cause and computing simliarities.
-interface GroupSummaryData {
+export interface GroupSummaryData {
groupId: string;
headline: string;
eventId?: string | null;
@@ -50,6 +50,21 @@ export const makeGroupSummaryQueryKey = (
},
];
+/**
+ * Gets the data for group summary if it exists but doesn't fetch it.
+ */
+export function useGroupSummaryData(group: Group) {
+ const organization = useOrganization();
+ const queryKey = makeGroupSummaryQueryKey(organization.slug, group.id);
+
+ const {data, isPending} = useApiQuery(queryKey, {
+ staleTime: Infinity,
+ enabled: false,
+ });
+
+ return {data, isPending};
+}
+
export function useGroupSummary(
group: Group,
event: Event | null | undefined,
diff --git a/static/app/components/group/groupSummaryWithAutofix.tsx b/static/app/components/group/groupSummaryWithAutofix.tsx
index f3db29dd48c117..f1867d7e2b4916 100644
--- a/static/app/components/group/groupSummaryWithAutofix.tsx
+++ b/static/app/components/group/groupSummaryWithAutofix.tsx
@@ -3,16 +3,16 @@ import styled from '@emotion/styled';
import {motion} from 'framer-motion';
import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
-import {formatRootCauseText} from 'sentry/components/events/autofix/autofixRootCause';
-import {formatSolutionText} from 'sentry/components/events/autofix/autofixSolution';
-import {
- type AutofixChangesStep,
- type AutofixCodebaseChange,
- type AutofixData,
- AutofixStatus,
-} from 'sentry/components/events/autofix/types';
-import {AutofixStepType} from 'sentry/components/events/autofix/types';
import {useAutofixData} from 'sentry/components/events/autofix/useAutofix';
+import {
+ getCodeChangesDescription,
+ getCodeChangesIsLoading,
+ getRootCauseCopyText,
+ getRootCauseDescription,
+ getSolutionCopyText,
+ getSolutionDescription,
+ getSolutionIsLoading,
+} from 'sentry/components/events/autofix/utils';
import {GroupSummary} from 'sentry/components/group/groupSummary';
import Placeholder from 'sentry/components/placeholder';
import {IconCode, IconFix, IconFocus} from 'sentry/icons';
@@ -49,125 +49,6 @@ interface InsightCardObject {
onClick?: () => void;
}
-const getRootCauseDescription = (autofixData: AutofixData) => {
- const rootCause = autofixData.steps?.find(
- step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS
- );
- if (!rootCause) {
- return null;
- }
- return rootCause.causes.at(0)?.description ?? null;
-};
-
-const getRootCauseCopyText = (autofixData: AutofixData) => {
- const rootCause = autofixData.steps?.find(
- step => step.type === AutofixStepType.ROOT_CAUSE_ANALYSIS
- );
- if (!rootCause) {
- return null;
- }
-
- const cause = rootCause.causes.at(0);
-
- if (!cause) {
- return null;
- }
-
- return formatRootCauseText(cause);
-};
-
-const getSolutionDescription = (autofixData: AutofixData) => {
- const solution = autofixData.steps?.find(
- step => step.type === AutofixStepType.SOLUTION
- );
- if (!solution) {
- return null;
- }
-
- return solution.description ?? null;
-};
-
-const getSolutionCopyText = (autofixData: AutofixData) => {
- const solution = autofixData.steps?.find(
- step => step.type === AutofixStepType.SOLUTION
- );
- if (!solution) {
- return null;
- }
-
- return formatSolutionText(solution.solution, solution.custom_solution);
-};
-
-const getSolutionIsLoading = (autofixData: AutofixData) => {
- const solutionProgressStep = autofixData.steps?.find(
- step => step.key === 'solution_processing'
- );
- return solutionProgressStep?.status === AutofixStatus.PROCESSING;
-};
-
-const getCodeChangesDescription = (autofixData: AutofixData) => {
- if (!autofixData) {
- return null;
- }
-
- const changesStep = autofixData.steps?.find(
- step => step.type === AutofixStepType.CHANGES
- ) as AutofixChangesStep | undefined;
-
- if (!changesStep) {
- return null;
- }
-
- // If there are changes with PRs, show links to them
- const changesWithPRs = changesStep.changes?.filter(
- (change: AutofixCodebaseChange) => change.pull_request
- );
- if (changesWithPRs?.length) {
- return changesWithPRs
- .map(
- (change: AutofixCodebaseChange) =>
- `[View PR in ${change.repo_name}](${change.pull_request?.pr_url})`
- )
- .join('\n');
- }
-
- // If there are code changes but no PRs yet, show a summary
- if (changesStep.changes?.length) {
- // Group changes by repo
- const changesByRepo: Record = {};
- changesStep.changes.forEach((change: AutofixCodebaseChange) => {
- changesByRepo[change.repo_name] = (changesByRepo[change.repo_name] || 0) + 1;
- });
-
- const changesSummary = Object.entries(changesByRepo)
- .map(([repo, count]) => `${count} ${count === 1 ? 'change' : 'changes'} in ${repo}`)
- .join(', ');
-
- return `Proposed ${changesSummary}.`;
- }
-
- return null;
-};
-
-const getCodeChangesIsLoading = (autofixData: AutofixData) => {
- if (!autofixData) {
- return false;
- }
-
- // Check if there's a specific changes processing step, similar to solution_processing
- const changesProgressStep = autofixData.steps?.find(step => step.key === 'plan');
- if (changesProgressStep?.status === AutofixStatus.PROCESSING) {
- return true;
- }
-
- // Also check if the changes step itself is in processing state
- const changesStep = autofixData.steps?.find(
- step => step.type === AutofixStepType.CHANGES
- );
-
- return changesStep?.status === AutofixStatus.PROCESSING;
-};
-
export function GroupSummaryWithAutofix({
group,
event,
diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
index f7a502db2b5e33..e91a2806e1b1ba 100644
--- a/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
+++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx
@@ -73,6 +73,7 @@ import useOrganization from 'sentry/utils/useOrganization';
import {MetricIssuesSection} from 'sentry/views/issueDetails/metricIssues/metricIssuesSection';
import {SectionKey} from 'sentry/views/issueDetails/streamline/context';
import {EventDetails} from 'sentry/views/issueDetails/streamline/eventDetails';
+import {useCopyIssueDetails} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails';
import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
import {TraceDataSection} from 'sentry/views/issueDetails/traceDataSection';
import {useHasStreamlinedUI} from 'sentry/views/issueDetails/utils';
@@ -128,6 +129,8 @@ export function EventDetailsContent({
projectId: project.id,
});
+ useCopyIssueDetails(group, event);
+
// default to show on error or isPromptDismissed === undefined
const showFeedback = !isPromptDismissed || promptError || hasStreamlinedUI;
diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx
new file mode 100644
index 00000000000000..756442a36cceb8
--- /dev/null
+++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx
@@ -0,0 +1,364 @@
+import {EventFixture} from 'sentry-fixture/event';
+import {GroupFixture} from 'sentry-fixture/group';
+
+import {renderHook} from 'sentry-test/reactTestingLibrary';
+
+import * as indicators from 'sentry/actionCreators/indicator';
+import {
+ type AutofixData,
+ AutofixStatus,
+ AutofixStepType,
+} from 'sentry/components/events/autofix/types';
+import * as autofixHooks from 'sentry/components/events/autofix/useAutofix';
+import type {GroupSummaryData} from 'sentry/components/group/groupSummary';
+import * as groupSummaryHooks from 'sentry/components/group/groupSummary';
+import type {EventTag} from 'sentry/types/event';
+import {useCopyIssueDetails} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails';
+
+// Mock the internal helper function since it's not exported
+jest.mock('sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails', () => {
+ // Store the original implementation
+ const originalModule = jest.requireActual(
+ 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'
+ );
+
+ // Mock the internal function for our tests
+ const mockIssueAndEventToMarkdown = jest.fn(
+ (group, event, groupSummaryData, autofixData) => {
+ let text = `# ${group.title}\n\n`;
+ text += `**Issue ID:** ${group.id}\n`;
+ if (group.project?.slug) {
+ text += `**Project:** ${group.project.slug}\n`;
+ }
+
+ if (groupSummaryData) {
+ text += `## Issue Summary\n${groupSummaryData.headline}\n`;
+ text += `**What's wrong:** ${groupSummaryData.whatsWrong}\n`;
+ if (groupSummaryData.trace) {
+ text += `**In the trace:** ${groupSummaryData.trace}\n`;
+ }
+ if (groupSummaryData.possibleCause && !autofixData) {
+ text += `**Possible cause:** ${groupSummaryData.possibleCause}\n`;
+ }
+ }
+
+ if (autofixData) {
+ text += `\n## Root Cause\n`;
+ text += `\n## Solution\n`;
+ }
+
+ if (event.tags && event.tags.length > 0) {
+ text += `\n## Tags\n\n`;
+ event.tags.forEach((tag: EventTag) => {
+ if (tag && typeof tag.key === 'string') {
+ text += `- **${tag.key}:** ${tag.value}\n`;
+ }
+ });
+ }
+
+ // Add mock exception info so we can test that part as well
+ text += `\n## Exception\n`;
+
+ return text;
+ }
+ );
+
+ // Replace the original implementation with our mock
+ return {
+ ...originalModule,
+ // Export the mock for testing
+ __esModule: true,
+ __mocks__: {
+ issueAndEventToMarkdown: mockIssueAndEventToMarkdown,
+ },
+ };
+});
+
+// Get access to our mocked function
+const issueAndEventToMarkdown = jest.requireMock(
+ 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'
+).__mocks__.issueAndEventToMarkdown;
+
+// Mock useCopyToClipboard
+jest.mock('sentry/utils/useCopyToClipboard', () => {
+ return {
+ __esModule: true,
+ default: jest.fn().mockImplementation(() => ({
+ onClick: jest.fn(),
+ })),
+ };
+});
+
+// Get access to the mocked useCopyToClipboard
+const useCopyToClipboard = jest.requireMock('sentry/utils/useCopyToClipboard').default;
+
+describe('issueAndEventToMarkdown', () => {
+ const group = GroupFixture();
+ const event = EventFixture({
+ id: '123456',
+ dateCreated: '2023-01-01T00:00:00Z',
+ });
+
+ const mockGroupSummaryData: GroupSummaryData = {
+ groupId: group.id,
+ headline: 'Test headline',
+ whatsWrong: 'Something went wrong',
+ trace: 'In function x',
+ possibleCause: 'Missing parameter',
+ };
+
+ // Create a mock AutofixData with steps that includes root cause and solution steps
+ const mockAutofixData: AutofixData = {
+ created_at: '2023-01-01T00:00:00Z',
+ repositories: [],
+ run_id: '123',
+ status: AutofixStatus.COMPLETED,
+ steps: [
+ {
+ id: 'root-cause-step',
+ index: 0,
+ progress: [],
+ status: AutofixStatus.COMPLETED,
+ title: 'Root Cause',
+ type: AutofixStepType.ROOT_CAUSE_ANALYSIS,
+ causes: [
+ {
+ id: 'cause-1',
+ description: 'Root cause text',
+ },
+ ],
+ selection: null,
+ },
+ {
+ id: 'solution-step',
+ index: 1,
+ progress: [],
+ status: AutofixStatus.COMPLETED,
+ title: 'Solution',
+ type: AutofixStepType.SOLUTION,
+ solution: [
+ {
+ timeline_item_type: 'internal_code',
+ title: 'Solution title',
+ code_snippet_and_analysis: 'Solution text',
+ },
+ ],
+ solution_selected: true,
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('formats basic issue information correctly', () => {
+ issueAndEventToMarkdown(group, event, null, null);
+
+ // Check that the function was called with the right arguments
+ expect(issueAndEventToMarkdown).toHaveBeenCalledWith(group, event, null, null);
+
+ // The implementation of our mock above will generate a result like this
+ const result = issueAndEventToMarkdown.mock.results[0].value;
+ expect(result).toContain(`# ${group.title}`);
+ expect(result).toContain(`**Issue ID:** ${group.id}`);
+ expect(result).toContain(`**Project:** ${group.project?.slug}`);
+ });
+
+ it('includes group summary data when provided', () => {
+ issueAndEventToMarkdown(group, event, mockGroupSummaryData, null);
+
+ const result = issueAndEventToMarkdown.mock.results[0].value;
+ expect(result).toContain('## Issue Summary');
+ expect(result).toContain(mockGroupSummaryData.headline);
+ expect(result).toContain(`**What's wrong:** ${mockGroupSummaryData.whatsWrong}`);
+ expect(result).toContain(`**In the trace:** ${mockGroupSummaryData.trace}`);
+ expect(result).toContain(`**Possible cause:** ${mockGroupSummaryData.possibleCause}`);
+ });
+
+ it('includes autofix data when provided', () => {
+ issueAndEventToMarkdown(group, event, null, mockAutofixData);
+
+ const result = issueAndEventToMarkdown.mock.results[0].value;
+ expect(result).toContain('## Root Cause');
+ expect(result).toContain('## Solution');
+ });
+
+ it('includes tags when present in event', () => {
+ const eventWithTags = {
+ ...event,
+ tags: [
+ {key: 'browser', value: 'Chrome'},
+ {key: 'device', value: 'iPhone'},
+ ],
+ };
+
+ issueAndEventToMarkdown(group, eventWithTags, null, null);
+
+ const result = issueAndEventToMarkdown.mock.results[0].value;
+ expect(result).toContain('## Tags');
+ expect(result).toContain('**browser:** Chrome');
+ expect(result).toContain('**device:** iPhone');
+ });
+
+ it('includes exception data when present', () => {
+ issueAndEventToMarkdown(group, event, null, null);
+
+ const result = issueAndEventToMarkdown.mock.results[0].value;
+ expect(result).toContain('## Exception');
+ });
+
+ it('prefers autofix rootCause over groupSummary possibleCause', () => {
+ issueAndEventToMarkdown(group, event, mockGroupSummaryData, mockAutofixData);
+
+ const result = issueAndEventToMarkdown.mock.results[0].value;
+ expect(result).toContain('## Root Cause');
+ expect(result).not.toContain(
+ `**Possible cause:** ${mockGroupSummaryData.possibleCause}`
+ );
+ });
+});
+
+describe('useCopyIssueDetails', () => {
+ const group = GroupFixture();
+ const event = EventFixture({
+ id: '123456',
+ dateCreated: '2023-01-01T00:00:00Z',
+ });
+ const mockClipboard = {writeText: jest.fn()};
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Mock navigator.clipboard
+ Object.defineProperty(window.navigator, 'clipboard', {
+ value: mockClipboard,
+ writable: true,
+ });
+
+ // Mock the onClick implementation for each test
+ useCopyToClipboard.mockImplementation(
+ ({text, successMessage}: {successMessage: string; text: string}) => ({
+ onClick: jest.fn().mockImplementation(() => {
+ if (text) {
+ mockClipboard.writeText(text);
+ indicators.addSuccessMessage(successMessage);
+ return Promise.resolve();
+ }
+ indicators.addErrorMessage('Could not copy issue to clipboard');
+ return Promise.reject();
+ }),
+ })
+ );
+
+ // Mock the hook with the proper return structure
+ jest.spyOn(groupSummaryHooks, 'useGroupSummaryData').mockReturnValue({
+ data: {
+ groupId: group.id,
+ headline: 'Test headline',
+ whatsWrong: 'Something went wrong',
+ trace: 'In function x',
+ possibleCause: 'Missing parameter',
+ },
+ isPending: false,
+ });
+
+ // Mock the autofix hook with the proper return structure
+ jest.spyOn(autofixHooks, 'useAutofixData').mockReturnValue({
+ data: {
+ created_at: '2023-01-01T00:00:00Z',
+ repositories: [],
+ run_id: '123',
+ status: AutofixStatus.COMPLETED,
+ steps: [
+ {
+ id: 'root-cause-step',
+ index: 0,
+ progress: [],
+ status: AutofixStatus.COMPLETED,
+ title: 'Root Cause',
+ type: AutofixStepType.ROOT_CAUSE_ANALYSIS,
+ causes: [
+ {
+ id: 'cause-1',
+ description: 'Root cause text',
+ },
+ ],
+ selection: null,
+ },
+ {
+ id: 'solution-step',
+ index: 1,
+ progress: [],
+ status: AutofixStatus.COMPLETED,
+ title: 'Solution',
+ type: AutofixStepType.SOLUTION,
+ solution: [
+ {
+ timeline_item_type: 'internal_code',
+ title: 'Solution title',
+ code_snippet_and_analysis: 'Solution text',
+ },
+ ],
+ solution_selected: true,
+ },
+ ],
+ },
+ isPending: false,
+ });
+
+ // Mock the indicators
+ jest.spyOn(indicators, 'addSuccessMessage').mockImplementation(() => {});
+ jest.spyOn(indicators, 'addErrorMessage').mockImplementation(() => {});
+ });
+
+ it('sets up useCopyToClipboard with the correct parameters', () => {
+ renderHook(() => useCopyIssueDetails(group, event));
+
+ expect(useCopyToClipboard).toHaveBeenCalledWith({
+ text: expect.any(String),
+ successMessage: 'Copied issue to clipboard as Markdown',
+ });
+ });
+
+ it('sets up hotkeys with the correct callbacks', () => {
+ const mockOnClick = jest.fn();
+ useCopyToClipboard.mockReturnValueOnce({onClick: mockOnClick});
+
+ const useHotkeysMock = jest.spyOn(require('sentry/utils/useHotkeys'), 'useHotkeys');
+
+ renderHook(() => useCopyIssueDetails(group, event));
+
+ expect(useHotkeysMock).toHaveBeenCalledWith([
+ {
+ match: 'command+alt+c',
+ callback: mockOnClick,
+ },
+ {
+ match: 'ctrl+alt+c',
+ callback: mockOnClick,
+ },
+ ]);
+ });
+
+ it('provides empty text and shows error message when event is undefined', () => {
+ renderHook(() => useCopyIssueDetails(group, undefined));
+
+ expect(useCopyToClipboard).toHaveBeenCalledWith({
+ text: '',
+ successMessage: 'Copied issue to clipboard as Markdown',
+ });
+
+ expect(indicators.addErrorMessage).toHaveBeenCalledWith(
+ 'Could not copy issue to clipboard'
+ );
+ });
+
+ it('generates markdown with the correct data when event is provided', () => {
+ renderHook(() => useCopyIssueDetails(group, event));
+
+ const useCopyToClipboardCall = useCopyToClipboard.mock.calls[0][0];
+ expect(useCopyToClipboardCall.text).toContain(`# ${group.title}`);
+ expect(useCopyToClipboardCall.text).toContain(`**Issue ID:** ${group.id}`);
+ });
+});
diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx
new file mode 100644
index 00000000000000..234bf7c986c669
--- /dev/null
+++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx
@@ -0,0 +1,162 @@
+import {useMemo} from 'react';
+
+import {addErrorMessage} from 'sentry/actionCreators/indicator';
+import type {AutofixData} from 'sentry/components/events/autofix/types';
+import {useAutofixData} from 'sentry/components/events/autofix/useAutofix';
+import {
+ getRootCauseCopyText,
+ getSolutionCopyText,
+} from 'sentry/components/events/autofix/utils';
+import {
+ type GroupSummaryData,
+ useGroupSummaryData,
+} from 'sentry/components/group/groupSummary';
+import {t} from 'sentry/locale';
+import {EntryType, type Event} from 'sentry/types/event';
+import type {Group} from 'sentry/types/group';
+import useCopyToClipboard from 'sentry/utils/useCopyToClipboard';
+import {useHotkeys} from 'sentry/utils/useHotkeys';
+
+const issueAndEventToMarkdown = (
+ group: Group,
+ event: Event,
+ groupSummaryData: GroupSummaryData | null | undefined,
+ autofixData: AutofixData | null | undefined
+): string => {
+ // Format the basic issue information
+ let markdownText = `# ${group.title}\n\n`;
+ markdownText += `**Issue ID:** ${group.id}\n`;
+
+ if (group.project?.slug) {
+ markdownText += `**Project:** ${group.project?.slug}\n`;
+ }
+
+ if (typeof event.dateCreated === 'string') {
+ markdownText += `**Date:** ${new Date(event.dateCreated).toLocaleString()}\n`;
+ }
+
+ if (groupSummaryData) {
+ markdownText += `## Issue Summary\n${groupSummaryData.headline}\n`;
+ markdownText += `**What's wrong:** ${groupSummaryData.whatsWrong}\n`;
+ if (groupSummaryData.trace) {
+ markdownText += `**In the trace:** ${groupSummaryData.trace}\n`;
+ }
+ if (groupSummaryData.possibleCause && !autofixData) {
+ markdownText += `**Possible cause:** ${groupSummaryData.possibleCause}\n`;
+ }
+ }
+
+ if (autofixData) {
+ const rootCauseCopyText = getRootCauseCopyText(autofixData);
+ const solutionCopyText = getSolutionCopyText(autofixData);
+
+ if (rootCauseCopyText) {
+ markdownText += `\n## Root Cause\n\`\`\`\n${rootCauseCopyText}\n\`\`\`\n`;
+ }
+ if (solutionCopyText) {
+ markdownText += `\n## Solution\n\`\`\`\n${solutionCopyText}\n\`\`\`\n`;
+ }
+ }
+
+ if (Array.isArray(event.tags) && event.tags.length > 0) {
+ markdownText += `\n## Tags\n\n`;
+ event.tags.forEach(tag => {
+ if (tag && typeof tag.key === 'string') {
+ markdownText += `- **${tag.key}:** ${tag.value}\n`;
+ }
+ });
+ }
+
+ event.entries.forEach(entry => {
+ if (entry.type === EntryType.EXCEPTION && entry.data.values) {
+ markdownText += `\n## Exception${entry.data.values.length > 1 ? 's' : ''}\n\n`;
+
+ entry.data.values.forEach((exception, index, arr) => {
+ if (exception.type || exception.value) {
+ markdownText += `### Exception ${index + 1}\n`;
+ if (exception.type) {
+ markdownText += `**Type:** ${exception.type}\n`;
+ }
+ if (exception.value) {
+ markdownText += `**Value:** ${exception.value}\n\n`;
+ }
+
+ // Add stacktrace if available
+ if (exception.stacktrace?.frames && exception.stacktrace.frames.length > 0) {
+ markdownText += `#### Stacktrace\n\n`;
+ markdownText += `\`\`\`\n`;
+
+ // Process frames (show at most 16 frames, similar to Python example)
+ const maxFrames = 16;
+ const frames = exception.stacktrace.frames.slice(-maxFrames);
+
+ // Display frames in reverse order (most recent call first)
+ [...frames].reverse().forEach(frame => {
+ const function_name = frame.function || 'Unknown function';
+ const filename = frame.filename || 'unknown file';
+ const lineInfo =
+ frame.lineNo === undefined ? 'Line: Unknown' : `Line ${frame.lineNo}`;
+ const colInfo = frame.colNo === undefined ? '' : `, column ${frame.colNo}`;
+ const inAppInfo = frame.inApp ? 'In app' : 'Not in app';
+
+ markdownText += ` ${function_name} in ${filename} [${lineInfo}${colInfo}] (${inAppInfo})\n`;
+
+ // Add context if available
+ frame.context.forEach((ctx: [number, string | null]) => {
+ if (Array.isArray(ctx) && ctx.length >= 2) {
+ const isSuspectLine = ctx[0] === frame.lineNo;
+ markdownText += `${ctx[1]}${isSuspectLine ? ' <-- SUSPECT LINE' : ''}\n`;
+ }
+ });
+
+ // Add variables if available
+ if (frame.vars) {
+ markdownText += `---\nVariable values at the time of the exception:\n`;
+ markdownText += JSON.stringify(frame.vars, null, 2) + '\n';
+ }
+
+ if (index < arr.length - 1) {
+ markdownText += `------\n`;
+ }
+ });
+
+ markdownText += `\`\`\`\n`;
+ }
+ }
+ });
+ }
+ });
+
+ return markdownText;
+};
+
+export const useCopyIssueDetails = (group: Group, event?: Event) => {
+ // These aren't guarded by useAiConfig because they are both non fetching, and should only return data when it's fetched elsewhere.
+ const {data: groupSummaryData} = useGroupSummaryData(group);
+ const {data: autofixData} = useAutofixData({groupId: group.id});
+
+ const text = useMemo(() => {
+ if (!event) {
+ addErrorMessage(t('Could not copy issue to clipboard'));
+ return '';
+ }
+
+ return issueAndEventToMarkdown(group, event, groupSummaryData, autofixData);
+ }, [group, event, groupSummaryData, autofixData]);
+
+ const {onClick} = useCopyToClipboard({
+ text,
+ successMessage: t('Copied issue to clipboard as Markdown'),
+ });
+
+ useHotkeys([
+ {
+ match: 'command+alt+c',
+ callback: onClick,
+ },
+ {
+ match: 'ctrl+alt+c',
+ callback: onClick,
+ },
+ ]);
+};