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, + }, + ]); +};