From 28f0633a00906fe7ac9c191c93ab798c2f068714 Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Tue, 18 Mar 2025 13:04:44 -0700 Subject: [PATCH 1/7] wip --- .../groupEventDetails/groupEventDetails.tsx | 2 + .../streamline/hooks/useCopyIssueDetails.tsx | 123 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx diff --git a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx index c4457547e26712..139518059e48f1 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx @@ -28,6 +28,7 @@ import GroupEventDetailsContent from 'sentry/views/issueDetails/groupEventDetail import {GroupEventDetailsLoading} from 'sentry/views/issueDetails/groupEventDetails/groupEventDetailsLoading'; import GroupEventHeader from 'sentry/views/issueDetails/groupEventHeader'; import GroupSidebar from 'sentry/views/issueDetails/groupSidebar'; +import {useCopyIssueDetails} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'; import {useGroup} from 'sentry/views/issueDetails/useGroup'; import {useGroupEvent} from 'sentry/views/issueDetails/useGroupEvent'; @@ -78,6 +79,7 @@ function GroupEventDetails() { [event] ); const hasStreamlinedUI = useHasStreamlinedUI(); + useCopyIssueDetails(group, eventWithMeta); // load the data useSentryAppComponentsData({projectId: project?.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..9b76f868013298 --- /dev/null +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -0,0 +1,123 @@ +import * as Sentry from '@sentry/react'; + +import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {t} from 'sentry/locale'; +import {EntryType, type Event} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; +import {useHotkeys} from 'sentry/utils/useHotkeys'; + +const issueAndEventToMarkdown = (group: Group, event: Event): 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 (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) { + markdownText += `\n## Exception\n\n`; + + entry.data.values?.forEach((exception, index) => { + 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'; + } + + markdownText += `------\n`; + }); + + markdownText += `\`\`\`\n`; + } + } + }); + } + }); + + return markdownText; +}; + +export const useCopyIssueDetails = (group?: Group, event?: Event) => { + const copyIssueDetails = () => { + if (!group || !event) { + addErrorMessage(t('Could not copy issue to clipboard')); + return; + } + + const text = issueAndEventToMarkdown(group, event); + navigator.clipboard + .writeText(text) + .then(() => { + addSuccessMessage(t('Copied issue to clipboard as Markdown')); + }) + .catch(err => { + Sentry.captureException(err); + addErrorMessage(t('Could not copy issue to clipboard')); + }); + }; + + useHotkeys([ + { + match: 'command+alt+c', + callback: () => copyIssueDetails(), + }, + { + match: 'ctrl+alt+c', + callback: () => copyIssueDetails(), + }, + ]); + + return {copyIssueDetails}; +}; From bc61866b7a5c58057b35e5255ff3fcebd910c46a Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Tue, 18 Mar 2025 13:56:18 -0700 Subject: [PATCH 2/7] done --- .../events/autofix/autofixSolution.tsx | 10 +- .../app/components/events/autofix/utils.tsx | 129 +++++++ static/app/components/group/groupSummary.tsx | 25 +- .../group/groupSummaryWithAutofix.tsx | 137 +------- .../groupEventDetails/groupEventDetails.tsx | 2 - .../groupEventDetailsContent.tsx | 3 + .../hooks/useCopyIssueDetails.spec.tsx | 330 ++++++++++++++++++ .../streamline/hooks/useCopyIssueDetails.tsx | 61 +++- 8 files changed, 557 insertions(+), 140 deletions(-) create mode 100644 static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx 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..756f3d2911e87b 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,29 @@ export const makeGroupSummaryQueryKey = ( }, ]; +/** + * Gets the data for group summary if it exists but doesn't fetch it. + */ +export function useGroupSummaryData( + group: Group, + event: Event | null | undefined, + forceEvent = false +) { + const organization = useOrganization(); + const queryKey = makeGroupSummaryQueryKey( + organization.slug, + group.id, + forceEvent ? event?.id : undefined + ); + + 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/groupEventDetails.tsx b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx index 139518059e48f1..c4457547e26712 100644 --- a/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx +++ b/static/app/views/issueDetails/groupEventDetails/groupEventDetails.tsx @@ -28,7 +28,6 @@ import GroupEventDetailsContent from 'sentry/views/issueDetails/groupEventDetail import {GroupEventDetailsLoading} from 'sentry/views/issueDetails/groupEventDetails/groupEventDetailsLoading'; import GroupEventHeader from 'sentry/views/issueDetails/groupEventHeader'; import GroupSidebar from 'sentry/views/issueDetails/groupSidebar'; -import {useCopyIssueDetails} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'; import {useGroup} from 'sentry/views/issueDetails/useGroup'; import {useGroupEvent} from 'sentry/views/issueDetails/useGroupEvent'; @@ -79,7 +78,6 @@ function GroupEventDetails() { [event] ); const hasStreamlinedUI = useHasStreamlinedUI(); - useCopyIssueDetails(group, eventWithMeta); // load the data useSentryAppComponentsData({projectId: project?.id}); 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..e40d8b8907964c --- /dev/null +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -0,0 +1,330 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {GroupFixture} from 'sentry-fixture/group'; + +import {renderHook, waitFor} 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; + +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 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('returns copyIssueDetails function', () => { + const {result} = renderHook(() => useCopyIssueDetails(group, event)); + + expect(result.current).toHaveProperty('copyIssueDetails'); + expect(typeof result.current.copyIssueDetails).toBe('function'); + }); + + it('copies markdown text to clipboard when copyIssueDetails is called', async () => { + mockClipboard.writeText.mockResolvedValue(undefined); + + const {result} = renderHook(() => useCopyIssueDetails(group, event)); + result.current.copyIssueDetails(); + + expect(mockClipboard.writeText).toHaveBeenCalled(); + await waitFor(() => { + expect(indicators.addSuccessMessage).toHaveBeenCalledWith( + 'Copied issue to clipboard as Markdown' + ); + }); + }); + + it('shows error message when clipboard API fails', async () => { + mockClipboard.writeText.mockRejectedValue(new Error('Clipboard error')); + + const {result} = renderHook(() => useCopyIssueDetails(group, event)); + result.current.copyIssueDetails(); + + await waitFor(() => { + expect(indicators.addErrorMessage).toHaveBeenCalledWith( + 'Could not copy issue to clipboard' + ); + }); + }); + + it('shows error message when event is undefined', () => { + const {result} = renderHook(() => useCopyIssueDetails(group, undefined)); + result.current.copyIssueDetails(); + + expect(indicators.addErrorMessage).toHaveBeenCalledWith( + 'Could not copy issue to clipboard' + ); + expect(mockClipboard.writeText).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx index 9b76f868013298..946890cdce31aa 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -1,12 +1,27 @@ import * as Sentry from '@sentry/react'; import {addErrorMessage, addSuccessMessage} 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 {useHotkeys} from 'sentry/utils/useHotkeys'; -const issueAndEventToMarkdown = (group: Group, event: Event): string => { +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`; @@ -19,6 +34,29 @@ const issueAndEventToMarkdown = (group: Group, event: Event): 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 => { @@ -29,10 +67,10 @@ const issueAndEventToMarkdown = (group: Group, event: Event): string => { } event.entries.forEach(entry => { - if (entry.type === EntryType.EXCEPTION) { - markdownText += `\n## Exception\n\n`; + 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) => { + entry.data.values.forEach((exception, index, arr) => { if (exception.type || exception.value) { markdownText += `### Exception ${index + 1}\n`; if (exception.type) { @@ -76,7 +114,9 @@ const issueAndEventToMarkdown = (group: Group, event: Event): string => { markdownText += JSON.stringify(frame.vars, null, 2) + '\n'; } - markdownText += `------\n`; + if (index < arr.length - 1) { + markdownText += `------\n`; + } }); markdownText += `\`\`\`\n`; @@ -89,14 +129,19 @@ const issueAndEventToMarkdown = (group: Group, event: Event): string => { return markdownText; }; -export const useCopyIssueDetails = (group?: Group, event?: Event) => { +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, event); + const {data: autofixData} = useAutofixData({groupId: group.id}); + const copyIssueDetails = () => { - if (!group || !event) { + if (!event) { addErrorMessage(t('Could not copy issue to clipboard')); return; } - const text = issueAndEventToMarkdown(group, event); + const text = issueAndEventToMarkdown(group, event, groupSummaryData, autofixData); + navigator.clipboard .writeText(text) .then(() => { From 9ac4b16f8d84713e63a9a06ec045d6fa4a49a391 Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Tue, 18 Mar 2025 14:02:08 -0700 Subject: [PATCH 3/7] use a callback here --- .../streamline/hooks/useCopyIssueDetails.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx index 946890cdce31aa..74cdd7542709f1 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -1,3 +1,4 @@ +import {useCallback} from 'react'; import * as Sentry from '@sentry/react'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; @@ -134,7 +135,7 @@ export const useCopyIssueDetails = (group: Group, event?: Event) => { const {data: groupSummaryData} = useGroupSummaryData(group, event); const {data: autofixData} = useAutofixData({groupId: group.id}); - const copyIssueDetails = () => { + const copyIssueDetails = useCallback(() => { if (!event) { addErrorMessage(t('Could not copy issue to clipboard')); return; @@ -151,16 +152,16 @@ export const useCopyIssueDetails = (group: Group, event?: Event) => { Sentry.captureException(err); addErrorMessage(t('Could not copy issue to clipboard')); }); - }; + }, [group, event, groupSummaryData, autofixData]); useHotkeys([ { match: 'command+alt+c', - callback: () => copyIssueDetails(), + callback: copyIssueDetails, }, { match: 'ctrl+alt+c', - callback: () => copyIssueDetails(), + callback: copyIssueDetails, }, ]); From 94706c1f5adb7d05db5456c6a794fabc63df3cbc Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Tue, 18 Mar 2025 14:09:59 -0700 Subject: [PATCH 4/7] remove field from group Summary hook --- static/app/components/group/groupSummary.tsx | 12 ++---------- .../streamline/hooks/useCopyIssueDetails.tsx | 2 +- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/static/app/components/group/groupSummary.tsx b/static/app/components/group/groupSummary.tsx index 756f3d2911e87b..cf117dd67377b3 100644 --- a/static/app/components/group/groupSummary.tsx +++ b/static/app/components/group/groupSummary.tsx @@ -53,17 +53,9 @@ export const makeGroupSummaryQueryKey = ( /** * Gets the data for group summary if it exists but doesn't fetch it. */ -export function useGroupSummaryData( - group: Group, - event: Event | null | undefined, - forceEvent = false -) { +export function useGroupSummaryData(group: Group) { const organization = useOrganization(); - const queryKey = makeGroupSummaryQueryKey( - organization.slug, - group.id, - forceEvent ? event?.id : undefined - ); + const queryKey = makeGroupSummaryQueryKey(organization.slug, group.id); const {data, isPending} = useApiQuery(queryKey, { staleTime: Infinity, diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx index 74cdd7542709f1..0714531d49ead9 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -132,7 +132,7 @@ const issueAndEventToMarkdown = ( 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, event); + const {data: groupSummaryData} = useGroupSummaryData(group); const {data: autofixData} = useAutofixData({groupId: group.id}); const copyIssueDetails = useCallback(() => { From aed197bfb152e68426f77db5bab715d0f6e1342c Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Tue, 18 Mar 2025 14:57:51 -0700 Subject: [PATCH 5/7] use copy hook --- .../hooks/useCopyIssueDetails.spec.tsx | 94 +++++++++++++------ .../streamline/hooks/useCopyIssueDetails.tsx | 33 +++---- 2 files changed, 77 insertions(+), 50 deletions(-) diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx index e40d8b8907964c..756442a36cceb8 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -1,7 +1,7 @@ import {EventFixture} from 'sentry-fixture/event'; import {GroupFixture} from 'sentry-fixture/group'; -import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary'; +import {renderHook} from 'sentry-test/reactTestingLibrary'; import * as indicators from 'sentry/actionCreators/indicator'; import { @@ -79,6 +79,19 @@ 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({ @@ -223,6 +236,21 @@ describe('useCopyIssueDetails', () => { 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: { @@ -284,47 +312,53 @@ describe('useCopyIssueDetails', () => { jest.spyOn(indicators, 'addErrorMessage').mockImplementation(() => {}); }); - it('returns copyIssueDetails function', () => { - const {result} = renderHook(() => useCopyIssueDetails(group, event)); + it('sets up useCopyToClipboard with the correct parameters', () => { + renderHook(() => useCopyIssueDetails(group, event)); - expect(result.current).toHaveProperty('copyIssueDetails'); - expect(typeof result.current.copyIssueDetails).toBe('function'); + expect(useCopyToClipboard).toHaveBeenCalledWith({ + text: expect.any(String), + successMessage: 'Copied issue to clipboard as Markdown', + }); }); - it('copies markdown text to clipboard when copyIssueDetails is called', async () => { - mockClipboard.writeText.mockResolvedValue(undefined); + it('sets up hotkeys with the correct callbacks', () => { + const mockOnClick = jest.fn(); + useCopyToClipboard.mockReturnValueOnce({onClick: mockOnClick}); - const {result} = renderHook(() => useCopyIssueDetails(group, event)); - result.current.copyIssueDetails(); + const useHotkeysMock = jest.spyOn(require('sentry/utils/useHotkeys'), 'useHotkeys'); - expect(mockClipboard.writeText).toHaveBeenCalled(); - await waitFor(() => { - expect(indicators.addSuccessMessage).toHaveBeenCalledWith( - 'Copied issue to clipboard as Markdown' - ); - }); - }); + renderHook(() => useCopyIssueDetails(group, event)); - it('shows error message when clipboard API fails', async () => { - mockClipboard.writeText.mockRejectedValue(new Error('Clipboard error')); + expect(useHotkeysMock).toHaveBeenCalledWith([ + { + match: 'command+alt+c', + callback: mockOnClick, + }, + { + match: 'ctrl+alt+c', + callback: mockOnClick, + }, + ]); + }); - const {result} = renderHook(() => useCopyIssueDetails(group, event)); - result.current.copyIssueDetails(); + it('provides empty text and shows error message when event is undefined', () => { + renderHook(() => useCopyIssueDetails(group, undefined)); - await waitFor(() => { - expect(indicators.addErrorMessage).toHaveBeenCalledWith( - 'Could not copy issue to clipboard' - ); + expect(useCopyToClipboard).toHaveBeenCalledWith({ + text: '', + successMessage: 'Copied issue to clipboard as Markdown', }); - }); - - it('shows error message when event is undefined', () => { - const {result} = renderHook(() => useCopyIssueDetails(group, undefined)); - result.current.copyIssueDetails(); expect(indicators.addErrorMessage).toHaveBeenCalledWith( 'Could not copy issue to clipboard' ); - expect(mockClipboard.writeText).not.toHaveBeenCalled(); + }); + + 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 index 0714531d49ead9..234bf7c986c669 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -1,7 +1,6 @@ -import {useCallback} from 'react'; -import * as Sentry from '@sentry/react'; +import {useMemo} from 'react'; -import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; +import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {AutofixData} from 'sentry/components/events/autofix/types'; import {useAutofixData} from 'sentry/components/events/autofix/useAutofix'; import { @@ -15,6 +14,7 @@ import { 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 = ( @@ -135,35 +135,28 @@ export const useCopyIssueDetails = (group: Group, event?: Event) => { const {data: groupSummaryData} = useGroupSummaryData(group); const {data: autofixData} = useAutofixData({groupId: group.id}); - const copyIssueDetails = useCallback(() => { + const text = useMemo(() => { if (!event) { addErrorMessage(t('Could not copy issue to clipboard')); - return; + return ''; } - const text = issueAndEventToMarkdown(group, event, groupSummaryData, autofixData); - - navigator.clipboard - .writeText(text) - .then(() => { - addSuccessMessage(t('Copied issue to clipboard as Markdown')); - }) - .catch(err => { - Sentry.captureException(err); - addErrorMessage(t('Could not copy issue to clipboard')); - }); + 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: copyIssueDetails, + callback: onClick, }, { match: 'ctrl+alt+c', - callback: copyIssueDetails, + callback: onClick, }, ]); - - return {copyIssueDetails}; }; From 89f3b4fa78125abf105ee9ece941c12ceb90a668 Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Wed, 19 Mar 2025 14:21:12 -0700 Subject: [PATCH 6/7] pr review feedback --- .../hooks/useCopyIssueDetails.spec.tsx | 438 +++++++----------- .../streamline/hooks/useCopyIssueDetails.tsx | 17 +- 2 files changed, 186 insertions(+), 269 deletions(-) diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx index 756442a36cceb8..65f6fa68b93530 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -12,87 +12,28 @@ import { 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, - }, - }; -}); +import {EntryType} from 'sentry/types/event'; +import { + issueAndEventToMarkdown, + useCopyIssueDetails, +} from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'; -// 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(), - })), - }; -}); +jest.spyOn( + require('sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'), + 'issueAndEventToMarkdown' +); + +jest.mock('sentry/utils/useCopyToClipboard'); -// Get access to the mocked useCopyToClipboard +// Get the mocked function, this is to type the mock correctly const useCopyToClipboard = jest.requireMock('sentry/utils/useCopyToClipboard').default; -describe('issueAndEventToMarkdown', () => { +useCopyToClipboard.mockImplementation(() => ({ + onClick: jest.fn(), + label: 'Copy', +})); + +describe('useCopyIssueDetails', () => { const group = GroupFixture(); const event = EventFixture({ id: '123456', @@ -148,217 +89,198 @@ describe('issueAndEventToMarkdown', () => { ], }; - beforeEach(() => { - jest.clearAllMocks(); - }); + describe('issueAndEventToMarkdown', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); - it('formats basic issue information correctly', () => { - issueAndEventToMarkdown(group, event, null, null); + it('formats basic issue information correctly', () => { + const result = issueAndEventToMarkdown(group, event, null, null); - // Check that the function was called with the right arguments - expect(issueAndEventToMarkdown).toHaveBeenCalledWith(group, event, null, null); + expect(result).toContain(`# ${group.title}`); + expect(result).toContain(`**Issue ID:** ${group.id}`); + expect(result).toContain(`**Project:** ${group.project?.slug}`); + }); - // 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', () => { + const result = issueAndEventToMarkdown(group, event, mockGroupSummaryData, null); - it('includes group summary data when provided', () => { - issueAndEventToMarkdown(group, event, mockGroupSummaryData, null); + 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}` + ); + }); - 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', () => { + const result = issueAndEventToMarkdown(group, event, null, mockAutofixData); - it('includes autofix data when provided', () => { - issueAndEventToMarkdown(group, event, null, mockAutofixData); + expect(result).toContain('## Root Cause'); + expect(result).toContain('## Solution'); + }); - 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'}, + ], + }; - 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'); - }); + const result = issueAndEventToMarkdown(group, eventWithTags, null, null); - it('includes exception data when present', () => { - issueAndEventToMarkdown(group, event, null, null); + expect(result).toContain('## Tags'); + expect(result).toContain('**browser:** Chrome'); + expect(result).toContain('**device:** iPhone'); + }); - const result = issueAndEventToMarkdown.mock.results[0].value; - expect(result).toContain('## Exception'); - }); + it('includes exception data when present', () => { + // Create an event fixture with exception entries + const eventWithException = EventFixture({ + ...event, + entries: [ + { + type: EntryType.EXCEPTION, + data: { + values: [ + { + type: 'TypeError', + value: 'Cannot read property of undefined', + stacktrace: { + frames: [ + { + function: 'testFunction', + filename: 'test.js', + lineNo: 42, + colNo: 13, + inApp: true, + context: [[42, 'const value = obj.property;']], + }, + ], + }, + }, + ], + }, + }, + ], + }); - it('prefers autofix rootCause over groupSummary possibleCause', () => { - issueAndEventToMarkdown(group, event, mockGroupSummaryData, mockAutofixData); + const result = issueAndEventToMarkdown(group, eventWithException, null, null); - const result = issueAndEventToMarkdown.mock.results[0].value; - expect(result).toContain('## Root Cause'); - expect(result).not.toContain( - `**Possible cause:** ${mockGroupSummaryData.possibleCause}` - ); - }); -}); + expect(result).toContain('## Exception'); + expect(result).toContain('**Type:** TypeError'); + expect(result).toContain('**Value:** Cannot read property of undefined'); + expect(result).toContain('#### Stacktrace'); + }); -describe('useCopyIssueDetails', () => { - const group = GroupFixture(); - const event = EventFixture({ - id: '123456', - dateCreated: '2023-01-01T00:00:00Z', + it('prefers autofix rootCause over groupSummary possibleCause', () => { + const result = issueAndEventToMarkdown( + group, + event, + mockGroupSummaryData, + mockAutofixData + ); + + expect(result).toContain('## Root Cause'); + expect(result).not.toContain( + `**Possible cause:** ${mockGroupSummaryData.possibleCause}` + ); + }); }); - const mockClipboard = {writeText: jest.fn()}; - beforeEach(() => { - jest.clearAllMocks(); + describe('useCopyIssueDetails', () => { + const mockClipboard = {writeText: jest.fn()}; - // Mock navigator.clipboard - Object.defineProperty(window.navigator, 'clipboard', { - value: mockClipboard, - writable: true, - }); + beforeEach(() => { + jest.clearAllMocks(); - // Mock the onClick implementation for each test - useCopyToClipboard.mockImplementation( - ({text, successMessage}: {successMessage: string; text: string}) => ({ + // Mock navigator.clipboard + Object.defineProperty(window.navigator, 'clipboard', { + value: mockClipboard, + writable: true, + }); + + // Mock the onClick implementation for each test + useCopyToClipboard.mockImplementation(() => ({ 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(); + mockClipboard.writeText('test'); + indicators.addSuccessMessage('Copied issue to clipboard as Markdown'); + return Promise.resolve(); }), - }) - ); - - // 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, + label: 'Copy', + })); + + // Mock the hook with the proper return structure + jest.spyOn(groupSummaryHooks, 'useGroupSummaryData').mockReturnValue({ + data: mockGroupSummaryData, + isPending: false, + }); + + // Mock the autofix hook with the proper return structure + jest.spyOn(autofixHooks, 'useAutofixData').mockReturnValue({ + data: mockAutofixData, + isPending: false, + }); + + // Mock the indicators + jest.spyOn(indicators, 'addSuccessMessage').mockImplementation(() => {}); + jest.spyOn(indicators, 'addErrorMessage').mockImplementation(() => {}); }); - // 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)); + 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', + expect(useCopyToClipboard).toHaveBeenCalledWith({ + text: expect.any(String), + successMessage: 'Copied issue to clipboard as Markdown', + errorMessage: 'Could not copy issue to clipboard', + }); }); - }); - it('sets up hotkeys with the correct callbacks', () => { - const mockOnClick = jest.fn(); - useCopyToClipboard.mockReturnValueOnce({onClick: mockOnClick}); + 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, - }, - ]); - }); + const useHotkeysMock = jest.spyOn(require('sentry/utils/useHotkeys'), 'useHotkeys'); - it('provides empty text and shows error message when event is undefined', () => { - renderHook(() => useCopyIssueDetails(group, undefined)); + renderHook(() => useCopyIssueDetails(group, event)); - expect(useCopyToClipboard).toHaveBeenCalledWith({ - text: '', - successMessage: 'Copied issue to clipboard as Markdown', + expect(useHotkeysMock).toHaveBeenCalledWith([ + { + match: 'command+alt+c', + callback: mockOnClick, + }, + { + match: 'ctrl+alt+c', + callback: mockOnClick, + }, + ]); }); - expect(indicators.addErrorMessage).toHaveBeenCalledWith( - 'Could not copy issue to clipboard' - ); - }); + it('provides partial data when event is undefined', () => { + renderHook(() => useCopyIssueDetails(group, undefined)); + + const useCopyToClipboardCall = useCopyToClipboard.mock.calls[0][0]; + expect(useCopyToClipboardCall.text).toContain(`# ${group.title}`); + expect(useCopyToClipboardCall.text).toContain(`**Issue ID:** ${group.id}`); + expect(useCopyToClipboardCall.text).toContain( + `**Project:** ${group.project?.slug}` + ); + expect(useCopyToClipboardCall.text).toContain('## Issue Summary'); + expect(useCopyToClipboardCall.text).toContain('## Root Cause'); + expect(useCopyToClipboardCall.text).toContain('## Solution'); + expect(useCopyToClipboardCall.text).not.toContain('## Exception'); + }); - it('generates markdown with the correct data when event is provided', () => { - renderHook(() => useCopyIssueDetails(group, event)); + 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}`); + 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 index 234bf7c986c669..ea89292b1b3ec9 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.tsx @@ -1,6 +1,5 @@ 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 { @@ -17,9 +16,9 @@ import type {Group} from 'sentry/types/group'; import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; import {useHotkeys} from 'sentry/utils/useHotkeys'; -const issueAndEventToMarkdown = ( +export const issueAndEventToMarkdown = ( group: Group, - event: Event, + event: Event | null | undefined, groupSummaryData: GroupSummaryData | null | undefined, autofixData: AutofixData | null | undefined ): string => { @@ -31,7 +30,7 @@ const issueAndEventToMarkdown = ( markdownText += `**Project:** ${group.project?.slug}\n`; } - if (typeof event.dateCreated === 'string') { + if (event && typeof event.dateCreated === 'string') { markdownText += `**Date:** ${new Date(event.dateCreated).toLocaleString()}\n`; } @@ -58,7 +57,7 @@ const issueAndEventToMarkdown = ( } } - if (Array.isArray(event.tags) && event.tags.length > 0) { + if (event && Array.isArray(event.tags) && event.tags.length > 0) { markdownText += `\n## Tags\n\n`; event.tags.forEach(tag => { if (tag && typeof tag.key === 'string') { @@ -67,7 +66,7 @@ const issueAndEventToMarkdown = ( }); } - event.entries.forEach(entry => { + event?.entries.forEach(entry => { if (entry.type === EntryType.EXCEPTION && entry.data.values) { markdownText += `\n## Exception${entry.data.values.length > 1 ? 's' : ''}\n\n`; @@ -136,17 +135,13 @@ export const useCopyIssueDetails = (group: Group, event?: Event) => { 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'), + errorMessage: t('Could not copy issue to clipboard'), }); useHotkeys([ From 3e5faf611d2af6b942f0ff7421991494ac0a4dbf Mon Sep 17 00:00:00 2001 From: Jenn Mueng Date: Wed, 19 Mar 2025 14:26:57 -0700 Subject: [PATCH 7/7] pr review feedback 2 --- .../hooks/useCopyIssueDetails.spec.tsx | 77 ++++++++----------- 1 file changed, 31 insertions(+), 46 deletions(-) diff --git a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx index 65f6fa68b93530..d7226a17902cb0 100644 --- a/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx +++ b/static/app/views/issueDetails/streamline/hooks/useCopyIssueDetails.spec.tsx @@ -13,26 +13,14 @@ 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 {EntryType} from 'sentry/types/event'; +import * as copyToClipboardModule from 'sentry/utils/useCopyToClipboard'; import { issueAndEventToMarkdown, useCopyIssueDetails, } from 'sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'; -jest.spyOn( - require('sentry/views/issueDetails/streamline/hooks/useCopyIssueDetails'), - 'issueAndEventToMarkdown' -); - jest.mock('sentry/utils/useCopyToClipboard'); -// Get the mocked function, this is to type the mock correctly -const useCopyToClipboard = jest.requireMock('sentry/utils/useCopyToClipboard').default; - -useCopyToClipboard.mockImplementation(() => ({ - onClick: jest.fn(), - label: 'Copy', -})); - describe('useCopyIssueDetails', () => { const group = GroupFixture(); const event = EventFixture({ @@ -193,39 +181,42 @@ describe('useCopyIssueDetails', () => { describe('useCopyIssueDetails', () => { const mockClipboard = {writeText: jest.fn()}; + const mockOnClick = jest.fn().mockImplementation(() => { + mockClipboard.writeText('test'); + indicators.addSuccessMessage('Copied issue to clipboard as Markdown'); + return Promise.resolve(); + }); + const mockCopyToClipboard = jest.fn(); + let generatedText: string; beforeEach(() => { jest.clearAllMocks(); - // Mock navigator.clipboard Object.defineProperty(window.navigator, 'clipboard', { value: mockClipboard, writable: true, }); - // Mock the onClick implementation for each test - useCopyToClipboard.mockImplementation(() => ({ - onClick: jest.fn().mockImplementation(() => { - mockClipboard.writeText('test'); - indicators.addSuccessMessage('Copied issue to clipboard as Markdown'); - return Promise.resolve(); - }), - label: 'Copy', - })); - - // Mock the hook with the proper return structure + mockCopyToClipboard.mockImplementation(({text}) => { + generatedText = text; + return { + onClick: mockOnClick, + label: 'Copy', + }; + }); + + jest.mocked(copyToClipboardModule.default).mockImplementation(mockCopyToClipboard); + jest.spyOn(groupSummaryHooks, 'useGroupSummaryData').mockReturnValue({ data: mockGroupSummaryData, isPending: false, }); - // Mock the autofix hook with the proper return structure jest.spyOn(autofixHooks, 'useAutofixData').mockReturnValue({ data: mockAutofixData, isPending: false, }); - // Mock the indicators jest.spyOn(indicators, 'addSuccessMessage').mockImplementation(() => {}); jest.spyOn(indicators, 'addErrorMessage').mockImplementation(() => {}); }); @@ -233,7 +224,8 @@ describe('useCopyIssueDetails', () => { it('sets up useCopyToClipboard with the correct parameters', () => { renderHook(() => useCopyIssueDetails(group, event)); - expect(useCopyToClipboard).toHaveBeenCalledWith({ + // Check that the hook was called with the expected parameters + expect(mockCopyToClipboard).toHaveBeenCalledWith({ text: expect.any(String), successMessage: 'Copied issue to clipboard as Markdown', errorMessage: 'Could not copy issue to clipboard', @@ -241,9 +233,6 @@ describe('useCopyIssueDetails', () => { }); 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)); @@ -251,11 +240,11 @@ describe('useCopyIssueDetails', () => { expect(useHotkeysMock).toHaveBeenCalledWith([ { match: 'command+alt+c', - callback: mockOnClick, + callback: expect.any(Function), }, { match: 'ctrl+alt+c', - callback: mockOnClick, + callback: expect.any(Function), }, ]); }); @@ -263,24 +252,20 @@ describe('useCopyIssueDetails', () => { it('provides partial data when event is undefined', () => { renderHook(() => useCopyIssueDetails(group, undefined)); - const useCopyToClipboardCall = useCopyToClipboard.mock.calls[0][0]; - expect(useCopyToClipboardCall.text).toContain(`# ${group.title}`); - expect(useCopyToClipboardCall.text).toContain(`**Issue ID:** ${group.id}`); - expect(useCopyToClipboardCall.text).toContain( - `**Project:** ${group.project?.slug}` - ); - expect(useCopyToClipboardCall.text).toContain('## Issue Summary'); - expect(useCopyToClipboardCall.text).toContain('## Root Cause'); - expect(useCopyToClipboardCall.text).toContain('## Solution'); - expect(useCopyToClipboardCall.text).not.toContain('## Exception'); + expect(generatedText).toContain(`# ${group.title}`); + expect(generatedText).toContain(`**Issue ID:** ${group.id}`); + expect(generatedText).toContain(`**Project:** ${group.project?.slug}`); + expect(generatedText).toContain('## Issue Summary'); + expect(generatedText).toContain('## Root Cause'); + expect(generatedText).toContain('## Solution'); + expect(generatedText).not.toContain('## Exception'); }); 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}`); + expect(generatedText).toContain(`# ${group.title}`); + expect(generatedText).toContain(`**Issue ID:** ${group.id}`); }); }); });