diff --git a/static/app/components/events/autofix/types.spec.ts b/static/app/components/events/autofix/types.spec.ts new file mode 100644 index 00000000000000..e7ba028bde6896 --- /dev/null +++ b/static/app/components/events/autofix/types.spec.ts @@ -0,0 +1,84 @@ +import { + DiffFileType, + DiffLineType, + isFilePatch, + type FilePatch, +} from 'sentry/components/events/autofix/types'; + +function makeValidDiffLine(): FilePatch['hunks'][number]['lines'][number] { + return { + diff_line_no: 1, + line_type: DiffLineType.ADDED, + source_line_no: null, + target_line_no: 1, + value: '+console.log("hello")', + }; +} + +function makeValidHunk(): FilePatch['hunks'][number] { + return { + lines: [makeValidDiffLine()], + section_header: '@@ -1,3 +1,4 @@', + source_length: 3, + source_start: 1, + target_length: 4, + target_start: 1, + }; +} + +function makeValidFilePatch(): FilePatch { + return { + added: 1, + hunks: [makeValidHunk()], + path: 'src/index.ts', + removed: 0, + source_file: 'a/src/index.ts', + target_file: 'b/src/index.ts', + type: DiffFileType.MODIFIED, + }; +} + +describe('isFilePatch', () => { + it('returns true for a valid FilePatch', () => { + expect(isFilePatch(makeValidFilePatch())).toBe(true); + }); + + it('returns false for null and non-objects', () => { + expect(isFilePatch(null)).toBe(false); + expect(isFilePatch(undefined)).toBe(false); + expect(isFilePatch('string')).toBe(false); + expect(isFilePatch(42)).toBe(false); + }); + + it('returns false for objects missing required fields', () => { + const {added: _, ...noAdded} = makeValidFilePatch(); + expect(isFilePatch(noAdded)).toBe(false); + + expect(isFilePatch({...makeValidFilePatch(), path: 123})).toBe(false); + }); + + it('returns false when type is not a valid DiffFileType', () => { + expect(isFilePatch({...makeValidFilePatch(), type: 'X'})).toBe(false); + }); + + it('returns false when hunks contain invalid entries', () => { + expect( + isFilePatch({ + ...makeValidFilePatch(), + hunks: [{lines: [], source_start: 1}], + }) + ).toBe(false); + }); + + it('returns false when nested lines contain invalid DiffLine entries', () => { + const badHunk = { + ...makeValidHunk(), + lines: [{diff_line_no: 1, line_type: 'INVALID', source_line_no: null}], + }; + expect(isFilePatch({...makeValidFilePatch(), hunks: [badHunk]})).toBe(false); + }); + + it('returns true when hunks is an empty array', () => { + expect(isFilePatch({...makeValidFilePatch(), hunks: []})).toBe(true); + }); +}); diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts index 42b149cdb7e34a..cb806b28968406 100644 --- a/static/app/components/events/autofix/types.ts +++ b/static/app/components/events/autofix/types.ts @@ -1,6 +1,7 @@ import type {EventMetadata} from 'sentry/types/event'; import type {Group} from 'sentry/types/group'; import type {User} from 'sentry/types/user'; +import {isArrayOf} from 'sentry/types/utils'; export enum DiffFileType { ADDED = 'A', @@ -8,12 +9,28 @@ export enum DiffFileType { DELETED = 'D', } +function isDiffFileType(value: unknown): value is DiffFileType { + return ( + value === DiffFileType.ADDED || + value === DiffFileType.MODIFIED || + value === DiffFileType.DELETED + ); +} + export enum DiffLineType { ADDED = '+', REMOVED = '-', CONTEXT = ' ', } +function isDiffLineType(value: unknown): value is DiffLineType { + return ( + value === DiffLineType.ADDED || + value === DiffLineType.REMOVED || + value === DiffLineType.CONTEXT + ); +} + export enum AutofixStepType { DEFAULT = 'default', ROOT_CAUSE_ANALYSIS = 'root_cause_analysis', @@ -263,6 +280,22 @@ export type FilePatch = { type: DiffFileType; }; +export function isFilePatch(value: unknown): value is FilePatch { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return ( + typeof obj.added === 'number' && + isArrayOf(obj.hunks, isHunk) && + typeof obj.path === 'string' && + typeof obj.removed === 'number' && + typeof obj.source_file === 'string' && + typeof obj.target_file === 'string' && + isDiffFileType(obj.type) + ); +} + type Hunk = { lines: DiffLine[]; section_header: string; @@ -272,6 +305,21 @@ type Hunk = { target_start: number; }; +function isHunk(value: unknown): value is Hunk { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return ( + isArrayOf(obj.lines, isDiffLine) && + typeof obj.section_header === 'string' && + typeof obj.source_length === 'number' && + typeof obj.source_start === 'number' && + typeof obj.target_length === 'number' && + typeof obj.target_start === 'number' + ); +} + export type DiffLine = { diff_line_no: number | null; line_type: DiffLineType; @@ -280,6 +328,20 @@ export type DiffLine = { value: string; }; +function isDiffLine(value: unknown): value is DiffLine { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return ( + (typeof obj.diff_line_no === 'number' || obj.diff_line_no === null) && + isDiffLineType(obj.line_type) && + (typeof obj.source_line_no === 'number' || obj.source_line_no === null) && + (typeof obj.target_line_no === 'number' || obj.target_line_no === null) && + typeof obj.value === 'string' + ); +} + export interface AutofixRepoDefinition { name: string; owner: string; diff --git a/static/app/components/events/autofix/useExplorerAutofix.spec.tsx b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx new file mode 100644 index 00000000000000..f4a3ec2c4816ac --- /dev/null +++ b/static/app/components/events/autofix/useExplorerAutofix.spec.tsx @@ -0,0 +1,114 @@ +import { + isRootCauseArtifact, + isSolutionArtifact, + type RootCauseArtifact, + type SolutionArtifact, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import type {Artifact} from 'sentry/views/seerExplorer/types'; + +function makeValidArtifact(data: T): Artifact { + return { + key: 'artifact-1', + reason: 'Found a root cause', + data, + }; +} + +describe('isRootCauseArtifact', () => { + function makeValidRootCauseData(): RootCauseArtifact { + return { + one_line_description: 'Null pointer in handler', + five_whys: ['Why 1', 'Why 2'], + reproduction_steps: ['Step 1', 'Step 2'], + }; + } + + it('returns true for a valid RootCauseArtifact', () => { + expect(isRootCauseArtifact(makeValidArtifact(makeValidRootCauseData()))).toBe(true); + }); + + it('returns false for non-artifact objects', () => { + expect(isRootCauseArtifact(null)).toBe(false); + expect(isRootCauseArtifact({data: makeValidRootCauseData()})).toBe(false); + expect(isRootCauseArtifact({key: 'k', data: makeValidRootCauseData()})).toBe(false); + }); + + it('returns false when data is null', () => { + expect(isRootCauseArtifact({key: 'k', reason: 'r', data: null})).toBe(false); + }); + + it('returns false when data has wrong types', () => { + expect( + isRootCauseArtifact( + makeValidArtifact({ + one_line_description: 'ok', + five_whys: [1, 2], + reproduction_steps: ['Step 1'], + }) + ) + ).toBe(false); + + expect( + isRootCauseArtifact( + makeValidArtifact({ + one_line_description: 123, + five_whys: ['Why'], + reproduction_steps: ['Step'], + }) + ) + ).toBe(false); + }); +}); + +describe('isSolutionArtifact', () => { + function makeValidSolutionData(): SolutionArtifact { + return { + one_line_summary: 'Fix the null check', + steps: [{title: 'Step 1', description: 'Do the thing'}], + }; + } + + it('returns true for a valid SolutionArtifact', () => { + expect(isSolutionArtifact(makeValidArtifact(makeValidSolutionData()))).toBe(true); + }); + + it('returns false for non-artifact objects', () => { + expect(isSolutionArtifact(null)).toBe(false); + expect(isSolutionArtifact({data: makeValidSolutionData()})).toBe(false); + }); + + it('returns false when data is null', () => { + expect(isSolutionArtifact({key: 'k', reason: 'r', data: null})).toBe(false); + }); + + it('returns false when steps contains invalid objects', () => { + expect( + isSolutionArtifact( + makeValidArtifact({ + one_line_summary: 'Fix it', + steps: [{title: 'Missing description'}], + }) + ) + ).toBe(false); + + expect( + isSolutionArtifact( + makeValidArtifact({ + one_line_summary: 'Fix it', + steps: [{description: 'Missing title'}], + }) + ) + ).toBe(false); + }); + + it('returns true when steps is an empty array', () => { + expect( + isSolutionArtifact( + makeValidArtifact({ + one_line_summary: 'Fix it', + steps: [], + }) + ) + ).toBe(true); + }); +}); diff --git a/static/app/components/events/autofix/useExplorerAutofix.tsx b/static/app/components/events/autofix/useExplorerAutofix.tsx index e5e4fcee938022..ba550673a597a3 100644 --- a/static/app/components/events/autofix/useExplorerAutofix.tsx +++ b/static/app/components/events/autofix/useExplorerAutofix.tsx @@ -9,6 +9,8 @@ import { needsGitHubAuth, type CodingAgentIntegration, } from 'sentry/components/events/autofix/useAutofix'; +import {isArrayOf, isString} from 'sentry/types/utils'; +import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import getApiUrl from 'sentry/utils/api/getApiUrl'; import { @@ -22,12 +24,13 @@ import type RequestError from 'sentry/utils/requestError/requestError'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import {useUser} from 'sentry/utils/useUser'; -import type { - Artifact, - Block, - ExplorerCodingAgentState, - ExplorerFilePatch, - RepoPRState, +import { + isArtifact, + type Artifact, + type Block, + type ExplorerCodingAgentState, + type ExplorerFilePatch, + type RepoPRState, } from 'sentry/views/seerExplorer/types'; /** @@ -49,16 +52,52 @@ export interface RootCauseArtifact { reproduction_steps?: string[]; } +export function isRootCauseArtifact( + value: unknown +): value is Artifact { + if (!isArtifact(value)) { + return false; + } + const data = value.data; + if (data === null || typeof data !== 'object') { + return false; + } + return ( + isString(data.one_line_description) && + isArrayOf(data.five_whys, isString) && + (!defined(data.reproduction_steps) || isArrayOf(data.reproduction_steps, isString)) + ); +} + interface SolutionStep { description: string; title: string; } +function isSolutionStep(value: unknown): value is SolutionStep { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return isString(obj.title) && isString(obj.description); +} + export interface SolutionArtifact { one_line_summary: string; steps: SolutionStep[]; } +export function isSolutionArtifact(value: unknown): value is Artifact { + if (!isArtifact(value)) { + return false; + } + const data = value.data; + if (data === null || typeof data !== 'object') { + return false; + } + return isString(data.one_line_summary) && isArrayOf(data.steps, isSolutionStep); +} + export interface ImpactItem { evidence: string; impact_description: string; @@ -251,6 +290,80 @@ export function getOrderedArtifactKeys( }); } +const CODE_CHANGES_KEY = Symbol('codeChanges'); + +type ArtifactKey = string | typeof CODE_CHANGES_KEY; +type ArtifactOrExplorerFilePatches = Artifact | ExplorerFilePatch[] | RepoPRState[]; + +export function getOrderedAutofixArtifacts( + runState: ExplorerAutofixState | null +): ArtifactOrExplorerFilePatches[] { + const blocks = runState?.blocks ?? []; + if (!blocks.length) { + return []; + } + + type OrderedArtifact = { + artifact: Artifact; + index: number; + type: 'artifact'; + }; + + type OrderedExplorerFilePatch = { + index: number; + patches: Map; + type: 'patch'; + }; + + const artifactsByKey = new Map< + ArtifactKey, + OrderedArtifact | OrderedExplorerFilePatch + >(); + const mergedByFile = new Map(); + + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index]!; + + for (const artifact of block.artifacts ?? []) { + artifactsByKey.set(artifact.key, { + type: 'artifact', + index, + artifact, + }); + } + + if (block.merged_file_patches?.length) { + for (const patch of block.merged_file_patches) { + const key = `${patch.repo_name}:${patch.patch.path}`; + mergedByFile.set(key, patch); + } + artifactsByKey.set(CODE_CHANGES_KEY, { + type: 'patch', + index, + patches: mergedByFile, + }); + } + } + + const artifacts: ArtifactOrExplorerFilePatches[] = [...artifactsByKey.values()] + .sort((artifact1, artifact2) => artifact1.index - artifact2.index) + .map(artifact => { + if (artifact.type === 'artifact') { + return artifact.artifact; + } + return Array.from(artifact.patches.values()); + }); + + if (runState?.repo_pr_states) { + const states = Object.values(runState.repo_pr_states); + if (states.length) { + artifacts.push(states); + } + } + + return artifacts; +} + /** * Extract merged file patches from Explorer blocks. * Returns the latest merged patch (original → current) for each file. diff --git a/static/app/components/events/autofix/v2/artifactCards.tsx b/static/app/components/events/autofix/v2/artifactCards.tsx index 0855f735b5972e..1c62936a39dca6 100644 --- a/static/app/components/events/autofix/v2/artifactCards.tsx +++ b/static/app/components/events/autofix/v2/artifactCards.tsx @@ -736,14 +736,13 @@ export function CodeChangesCard({patches, prStates, onCreatePR}: CodeChangesCard {Array.from(patchesByRepo.entries()).map(([repoName, repoPatches]) => { const prState = prStates?.[repoName]; - const hasPR = prState?.pr_url; const isCreatingPR = prState?.pr_creation_status === 'creating'; return ( {repoName} - {hasPR ? ( + {prState?.pr_url ? ( {t('View PR #%s', prState.pr_number)} diff --git a/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx b/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx new file mode 100644 index 00000000000000..dfccf088352aa6 --- /dev/null +++ b/static/app/components/events/autofix/v3/autofixPreviews.spec.tsx @@ -0,0 +1,197 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import type { + RootCauseArtifact, + SolutionArtifact, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import type { + Artifact, + ExplorerFilePatch, + RepoPRState, +} from 'sentry/views/seerExplorer/types'; + +import { + CodeChangesPreview, + PullRequestsPreview, + RootCausePreview, + SolutionPreview, +} from './autofixPreviews'; + +describe('RootCausePreview', () => { + it('renders root cause title and description', () => { + const artifact: Artifact = { + key: 'root-cause', + reason: 'Found root cause', + data: { + one_line_description: 'Null pointer in user handler', + five_whys: ['why1', 'why2'], + reproduction_steps: ['step1'], + }, + }; + + render(); + + expect(screen.getByText('Root Cause')).toBeInTheDocument(); + expect(screen.getByText('Null pointer in user handler')).toBeInTheDocument(); + }); + + it('handles null data', () => { + const artifact: Artifact = { + key: 'root-cause', + reason: 'No data', + data: null, + }; + + render(); + + expect(screen.getByText('Root Cause')).toBeInTheDocument(); + }); +}); + +describe('SolutionPreview', () => { + it('renders implementation plan title and summary', () => { + const artifact: Artifact = { + key: 'solution', + reason: 'Found solution', + data: { + one_line_summary: 'Add null check before accessing user', + steps: [{title: 'Step 1', description: 'Add guard'}], + }, + }; + + render(); + + expect(screen.getByText('Implementation Plan')).toBeInTheDocument(); + expect(screen.getByText('Add null check before accessing user')).toBeInTheDocument(); + }); + + it('handles null data', () => { + const artifact: Artifact = { + key: 'solution', + reason: 'No data', + data: null, + }; + + render(); + + expect(screen.getByText('Implementation Plan')).toBeInTheDocument(); + }); +}); + +describe('CodeChangesPreview', () => { + function makePatch(repoName: string, path: string): ExplorerFilePatch { + return { + repo_name: repoName, + patch: { + path, + added: 1, + removed: 0, + hunks: [], + source_file: path, + target_file: path, + type: 'M', + }, + } as ExplorerFilePatch; + } + + it('renders single file in single repo', () => { + render(); + + expect(screen.getByText('Code Changes')).toBeInTheDocument(); + expect(screen.getByText('1 file changed in 1 repo')).toBeInTheDocument(); + }); + + it('renders multiple files in single repo', () => { + render( + + ); + + expect(screen.getByText('3 files changed in 1 repo')).toBeInTheDocument(); + }); + + it('renders multiple files in multiple repos', () => { + render( + + ); + + expect(screen.getByText('3 files changed in 2 repos')).toBeInTheDocument(); + }); + + it('renders empty array without file counts', () => { + render(); + + expect(screen.getByText('Code Changes')).toBeInTheDocument(); + expect(screen.queryByText(/file/)).not.toBeInTheDocument(); + }); +}); + +describe('PullRequestsPreview', () => { + function makePR(overrides: Partial = {}): RepoPRState { + return { + repo_name: 'org/repo', + pr_number: 42, + pr_url: 'https://github.com/org/repo/pull/42', + branch_name: 'fix/issue', + commit_sha: 'abc123', + pr_creation_error: null, + pr_creation_status: 'completed', + pr_id: 1, + title: 'Fix issue', + ...overrides, + }; + } + + it('renders PR links', () => { + render(); + + expect(screen.getByText('Pull Requests')).toBeInTheDocument(); + const link = screen.getByRole('link', {name: 'org/repo#42'}); + expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42'); + }); + + it('renders multiple PRs', () => { + render( + + ); + + expect(screen.getByRole('link', {name: 'org/repo-a#10'})).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'org/repo-b#20'})).toBeInTheDocument(); + }); + + it('skips PRs with missing fields', () => { + render( + + ); + + expect(screen.getByRole('link', {name: 'org/valid#55'})).toBeInTheDocument(); + expect(screen.queryByRole('link', {name: /repo#42/})).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/components/events/autofix/v3/autofixPreviews.tsx b/static/app/components/events/autofix/v3/autofixPreviews.tsx new file mode 100644 index 00000000000000..52da1b239d296f --- /dev/null +++ b/static/app/components/events/autofix/v3/autofixPreviews.tsx @@ -0,0 +1,124 @@ +import {useMemo, type ReactNode} from 'react'; + +import {Flex} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; + +import type { + RootCauseArtifact, + SolutionArtifact, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import {IconBug} from 'sentry/icons/iconBug'; +import {IconCode} from 'sentry/icons/iconCode'; +import {IconList} from 'sentry/icons/iconList'; +import {IconPullRequest} from 'sentry/icons/iconPullRequest'; +import {t, tn} from 'sentry/locale'; +import { + type Artifact, + type ExplorerFilePatch, + type RepoPRState, +} from 'sentry/views/seerExplorer/types'; + +interface RootCausePreviewProps { + artifact: Artifact; +} + +export function RootCausePreview({artifact}: RootCausePreviewProps) { + return ( + } title={t('Root Cause')}> + {artifact.data?.one_line_description} + + ); +} + +interface SolutionPreviewProps { + artifact: Artifact; +} + +export function SolutionPreview({artifact}: SolutionPreviewProps) { + return ( + } title={t('Implementation Plan')}> + {artifact.data?.one_line_summary} + + ); +} + +interface CodeChangesPreviewProps { + artifact: ExplorerFilePatch[]; +} + +export function CodeChangesPreview({artifact}: CodeChangesPreviewProps) { + const patchesForRepos = useMemo(() => { + const patchesByRepo = new Map(); + for (const patch of artifact) { + const existing = patchesByRepo.get(patch.repo_name) || []; + existing.push(patch); + patchesByRepo.set(patch.repo_name, existing); + } + return patchesByRepo; + }, [artifact]); + + const reposChanged = patchesForRepos.size; + + const filesChanged = useMemo(() => { + const changed = new Set(); + + for (const [repo, patchesForRepo] of patchesForRepos.entries()) { + for (const patch of patchesForRepo) { + changed.add(`${repo}:${patch.patch.path}`); + } + } + + return changed.size; + }, [patchesForRepos]); + + return ( + } title={t('Code Changes')}> + {reposChanged === 1 + ? tn('%s file changed in 1 repo', '%s files changed in 1 repo', filesChanged) + : reposChanged > 1 + ? t('%s files changed in %s repos', filesChanged, reposChanged) + : null} + + ); +} + +interface PullRequestsPreviewProps { + artifact: RepoPRState[]; +} + +export function PullRequestsPreview({artifact}: PullRequestsPreviewProps) { + return ( + } title={t('Pull Requests')}> + {artifact.map(pullRequest => { + if (!pullRequest.repo_name || !pullRequest.pr_number || !pullRequest.pr_url) { + return null; + } + const label = `${pullRequest.repo_name}#${pullRequest.pr_number}`; + return ( + + {label} + + ); + })} + + ); +} + +interface ArtifactCardProps { + children: ReactNode; + icon: ReactNode; + title: ReactNode; +} + +function ArtifactCard({children, icon, title}: ArtifactCardProps) { + return ( + + + {icon} + {title} + + {children} + + ); +} diff --git a/static/app/types/utils.tsx b/static/app/types/utils.tsx index 5e2c359c0f3475..139add49d0291a 100644 --- a/static/app/types/utils.tsx +++ b/static/app/types/utils.tsx @@ -20,3 +20,14 @@ export type DeepPartial = [P in keyof T]?: DeepPartial; } : T; + +export function isArrayOf( + value: unknown, + predicate: (x: unknown) => x is T +): value is T[] { + return Array.isArray(value) && value.every(predicate); +} + +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} diff --git a/static/app/views/issueDetails/streamline/sidebar/autofixSection.spec.tsx b/static/app/views/issueDetails/streamline/sidebar/autofixSection.spec.tsx new file mode 100644 index 00000000000000..568fbe71954650 --- /dev/null +++ b/static/app/views/issueDetails/streamline/sidebar/autofixSection.spec.tsx @@ -0,0 +1,416 @@ +import {AutofixSetupFixture} from 'sentry-fixture/autofixSetupFixture'; +import {EventFixture} from 'sentry-fixture/event'; +import {FrameFixture} from 'sentry-fixture/frame'; +import {GroupFixture} from 'sentry-fixture/group'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; + +import {DiffFileType} from 'sentry/components/events/autofix/types'; +import {EntryType} from 'sentry/types/event'; +import {IssueCategory, type Group} from 'sentry/types/group'; +import type {Project} from 'sentry/types/project'; + +import {AutofixSection} from './autofixSection'; + +jest.mock('sentry/utils/regions'); + +describe('AutofixSection', () => { + const mockEvent = EventFixture({ + entries: [ + { + type: EntryType.EXCEPTION, + data: {values: [{stacktrace: {frames: [FrameFixture()]}}]}, + }, + ], + }); + const mockProject = ProjectFixture(); + const organization = OrganizationFixture({ + hideAiFeatures: false, + features: ['gen-ai-features'], + }); + + let mockGroup: ReturnType; + + beforeEach(() => { + mockGroup = GroupFixture(); + MockApiClient.clearMockResponses(); + + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/setup/`, + body: AutofixSetupFixture({ + integration: {ok: true, reason: null}, + githubWriteIntegration: {ok: true, repos: []}, + }), + }); + }); + + it('renders Seer section title when AI features are enabled', () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: {autofix: null}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, + method: 'POST', + body: {whatsWrong: 'Something broke', possibleCause: 'Bad code'}, + }); + + render(, { + organization, + }); + + expect(screen.getByText('Seer')).toBeInTheDocument(); + }); + + it('renders Resources section when AI features are disabled', () => { + const customOrganization = OrganizationFixture({ + hideAiFeatures: true, + features: ['gen-ai-features'], + }); + + const performanceGroup: Group = { + ...mockGroup, + issueCategory: IssueCategory.PERFORMANCE, + title: 'ChunkLoadError', + platform: 'javascript', + }; + + const javascriptProject: Project = {...mockProject, platform: 'javascript'}; + + render( + , + {organization: customOrganization} + ); + + expect(screen.getByText('Resources')).toBeInTheDocument(); + }); + + it('returns null when AI features are disabled and no resources exist', () => { + const customOrganization = OrganizationFixture({ + hideAiFeatures: true, + features: ['gen-ai-features'], + }); + + const {container} = render( + , + {organization: customOrganization} + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders group summary when no autofix artifacts exist', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: {autofix: null}, + }); + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/summarize/`, + method: 'POST', + body: {whatsWrong: 'Test what happened', possibleCause: 'Test cause'}, + }); + + render(, { + organization, + }); + + expect(await screen.findByText('Test cause')).toBeInTheDocument(); + }); + + it('renders root cause artifact when autofix returns root cause', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'completed', + updated_at: new Date().toISOString(), + blocks: [ + { + id: 'block-1', + message: {content: 'Found root cause', role: 'assistant'}, + timestamp: new Date().toISOString(), + artifacts: [ + { + key: 'root_cause', + reason: 'Identified the issue', + data: { + one_line_description: 'Null pointer in user handler', + five_whys: ['why1'], + reproduction_steps: ['step1'], + }, + }, + ], + }, + ], + }, + }, + }); + + render(, { + organization, + }); + + expect(await screen.findByText('Root Cause')).toBeInTheDocument(); + expect(screen.getByText('Null pointer in user handler')).toBeInTheDocument(); + }); + + it('renders solution artifact', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'completed', + updated_at: new Date().toISOString(), + blocks: [ + { + id: 'block-1', + message: {content: 'Found solution', role: 'assistant'}, + timestamp: new Date().toISOString(), + artifacts: [ + { + key: 'solution', + reason: 'Proposed a fix', + data: { + one_line_summary: 'Add null check before accessing user', + steps: [{title: 'Step 1', description: 'Add guard clause'}], + }, + }, + ], + }, + ], + }, + }, + }); + + render(, { + organization, + }); + + expect(await screen.findByText('Implementation Plan')).toBeInTheDocument(); + expect(screen.getByText('Add null check before accessing user')).toBeInTheDocument(); + }); + + it('renders code changes preview from merged file patches', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'completed', + updated_at: new Date().toISOString(), + blocks: [ + { + id: 'block-1', + message: {content: 'Made changes', role: 'assistant'}, + timestamp: new Date().toISOString(), + merged_file_patches: [ + { + repo_name: 'org/repo', + patch: { + path: 'src/app.py', + added: 5, + removed: 2, + hunks: [], + source_file: 'src/app.py', + target_file: 'src/app.py', + type: DiffFileType.MODIFIED, + }, + }, + { + repo_name: 'org/repo', + patch: { + path: 'src/utils.py', + added: 3, + removed: 0, + hunks: [], + source_file: 'src/utils.py', + target_file: 'src/utils.py', + type: DiffFileType.MODIFIED, + }, + }, + ], + }, + ], + }, + }, + }); + + render(, { + organization, + }); + + expect(await screen.findByText('Code Changes')).toBeInTheDocument(); + expect(screen.getByText('2 files changed in 1 repo')).toBeInTheDocument(); + }); + + it('renders pull request previews from repo_pr_states', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'completed', + updated_at: new Date().toISOString(), + blocks: [ + { + id: 'block-1', + message: {content: 'Created PR', role: 'assistant'}, + timestamp: new Date().toISOString(), + merged_file_patches: [ + { + repo_name: 'org/repo', + patch: { + path: 'src/app.py', + added: 1, + removed: 0, + hunks: [], + source_file: 'src/app.py', + target_file: 'src/app.py', + type: DiffFileType.MODIFIED, + }, + }, + ], + }, + ], + repo_pr_states: { + 'org/repo': { + repo_name: 'org/repo', + pr_number: 42, + pr_url: 'https://github.com/org/repo/pull/42', + branch_name: 'fix/issue', + commit_sha: 'abc123', + pr_creation_error: null, + pr_creation_status: 'completed', + pr_id: 1, + title: 'Fix null pointer', + }, + }, + }, + }, + }); + + render(, { + organization, + }); + + expect(await screen.findByText('Pull Requests')).toBeInTheDocument(); + const link = screen.getByRole('link', {name: 'org/repo#42'}); + expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42'); + }); + + it('shows loading placeholder while autofix data is pending', () => { + // Don't add autofix mock response so the query stays pending + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: {autofix: null}, + statusCode: 200, + }); + + render(, { + organization, + }); + + // The Seer title should still render + expect(screen.getByText('Seer')).toBeInTheDocument(); + }); + + it('renders multiple artifact types in order', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: { + autofix: { + run_id: 1, + status: 'completed', + updated_at: new Date().toISOString(), + blocks: [ + { + id: 'block-1', + message: {content: 'Analysis complete', role: 'assistant'}, + timestamp: new Date().toISOString(), + artifacts: [ + { + key: 'root_cause', + reason: 'Found root cause', + data: { + one_line_description: 'Missing null check', + five_whys: ['why'], + reproduction_steps: ['step'], + }, + }, + { + key: 'solution', + reason: 'Proposed fix', + data: { + one_line_summary: 'Add validation', + steps: [{title: 'Validate', description: 'Add check'}], + }, + }, + ], + merged_file_patches: [ + { + repo_name: 'org/repo', + patch: { + path: 'src/handler.py', + added: 2, + removed: 1, + hunks: [], + source_file: 'src/handler.py', + target_file: 'src/handler.py', + type: DiffFileType.MODIFIED, + }, + }, + ], + }, + ], + }, + }, + }); + + render(, { + organization, + }); + + expect(await screen.findByText('Root Cause')).toBeInTheDocument(); + expect(screen.getByText('Implementation Plan')).toBeInTheDocument(); + expect(screen.getByText('Code Changes')).toBeInTheDocument(); + }); + + it('renders resources when no summary and no autofix artifacts', async () => { + const performanceGroup: Group = { + ...mockGroup, + issueCategory: IssueCategory.PERFORMANCE, + title: 'ChunkLoadError', + platform: 'javascript', + }; + + const javascriptProject: Project = {...mockProject, platform: 'javascript'}; + + MockApiClient.addMockResponse({ + url: `/organizations/${mockProject.organization.slug}/issues/${mockGroup.id}/autofix/`, + body: {autofix: null}, + }); + + render( + , + {organization} + ); + + await waitFor(() => { + expect( + screen.getByRole('button', {name: 'How to fix ChunkLoadErrors'}) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx new file mode 100644 index 00000000000000..62ac8db99c601b --- /dev/null +++ b/static/app/views/issueDetails/streamline/sidebar/autofixSection.tsx @@ -0,0 +1,163 @@ +import {useMemo} from 'react'; + +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import { + getOrderedAutofixArtifacts, + isRootCauseArtifact, + isSolutionArtifact, + useExplorerAutofix, +} from 'sentry/components/events/autofix/useExplorerAutofix'; +import { + CodeChangesPreview, + PullRequestsPreview, + RootCausePreview, + SolutionPreview, +} from 'sentry/components/events/autofix/v3/autofixPreviews'; +import {GroupSummary} from 'sentry/components/group/groupSummary'; +import Placeholder from 'sentry/components/placeholder'; +import {IconSeer} from 'sentry/icons/iconSeer'; +import {t} from 'sentry/locale'; +import type {Event} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; +import type {Project} from 'sentry/types/project'; +import {isArrayOf} from 'sentry/types/utils'; +import {getConfigForIssueType} from 'sentry/utils/issueTypeConfig'; +import type {IssueTypeConfig} from 'sentry/utils/issueTypeConfig/types'; +import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; +import {SidebarFoldSection} from 'sentry/views/issueDetails/streamline/foldSection'; +import {useAiConfig} from 'sentry/views/issueDetails/streamline/hooks/useAiConfig'; +import Resources from 'sentry/views/issueDetails/streamline/sidebar/resources'; +import {isExplorerFilePatch, isRepoPRState} from 'sentry/views/seerExplorer/types'; + +interface AutofixSectionProps { + group: Group; + project: Project; + event?: Event; +} + +export function AutofixSection({group, project, event}: AutofixSectionProps) { + const aiConfig = useAiConfig(group, project); + + const issueTypeConfig = getConfigForIssueType(group, project); + + const issueTypeSupportsSeer = Boolean( + issueTypeConfig.autofix || issueTypeConfig.issueSummary + ); + + if (!aiConfig.areAiFeaturesAllowed || !issueTypeSupportsSeer) { + if (!issueTypeConfig.resources) { + return null; + } + + return ( + + {t('Resources')} + + } + sectionKey={SectionKey.SEER} + preventCollapse={false} + > + + + ); + } + + return ( + + {t('Seer')} + + + } + sectionKey={SectionKey.SEER} + preventCollapse={false} + > + + + ); +} + +interface AutofixContentProps { + aiConfig: ReturnType; + group: Group; + issueTypeConfig: IssueTypeConfig; + project: Project; + event?: Event; +} + +function AutofixContent({ + aiConfig, + group, + issueTypeConfig, + project, + event, +}: AutofixContentProps) { + const autofix = useExplorerAutofix(group.id); + const artifacts = useMemo( + () => getOrderedAutofixArtifacts(autofix.runState), + [autofix.runState] + ); + + if (autofix.isLoading) { + return ; + } + + if (artifacts.length) { + return ( + + {artifacts.map(artifact => { + // there should only be 1 artifact of each type + if (isRootCauseArtifact(artifact)) { + return ; + } + + if (isSolutionArtifact(artifact)) { + return ; + } + + if (isArrayOf(artifact, isExplorerFilePatch) && artifact.length) { + return ; + } + + if (isArrayOf(artifact, isRepoPRState) && artifact.length) { + return ; + } + + // TODO: maybe send a log? + return null; + })} + + ); + } + + if (aiConfig.hasSummary) { + return ; + } + + if (issueTypeConfig.resources) { + return ( + + ); + } + + return null; +} diff --git a/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx b/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx index 63e7d8ecb6c174..9426824365c2f8 100644 --- a/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/sidebar.tsx @@ -22,6 +22,7 @@ import { } from 'sentry/views/issueDetails/issueDetailsTour'; import {useIssueDetails} from 'sentry/views/issueDetails/streamline/context'; import StreamlinedActivitySection from 'sentry/views/issueDetails/streamline/sidebar/activitySection'; +import {AutofixSection} from 'sentry/views/issueDetails/streamline/sidebar/autofixSection'; import {DetectorSection} from 'sentry/views/issueDetails/streamline/sidebar/detectorSection'; import {ExternalIssueSidebarList} from 'sentry/views/issueDetails/streamline/sidebar/externalIssueSidebarList'; import FirstLastSeenSection from 'sentry/views/issueDetails/streamline/sidebar/firstLastSeenSection'; @@ -94,7 +95,11 @@ export default function StreamlinedSidebar({group, event, project}: Props) { {showSeerSection && ( - + {organization.features.includes('autofix-on-explorer-v2') ? ( + + ) : ( + + )} )} {event && ( diff --git a/static/app/views/seerExplorer/prWidget.tsx b/static/app/views/seerExplorer/prWidget.tsx index 1a66c986b1f842..36122a49b36ded 100644 --- a/static/app/views/seerExplorer/prWidget.tsx +++ b/static/app/views/seerExplorer/prWidget.tsx @@ -217,7 +217,7 @@ export function usePRWidgetData({ )} e.stopPropagation()} diff --git a/static/app/views/seerExplorer/types.spec.ts b/static/app/views/seerExplorer/types.spec.ts new file mode 100644 index 00000000000000..9cd5069b92b5ac --- /dev/null +++ b/static/app/views/seerExplorer/types.spec.ts @@ -0,0 +1,148 @@ +import { + DiffFileType, + DiffLineType, + type FilePatch, +} from 'sentry/components/events/autofix/types'; + +import {isArtifact, isExplorerFilePatch, isRepoPRState, type RepoPRState} from './types'; + +function makeValidFilePatch(): FilePatch { + return { + added: 1, + hunks: [ + { + lines: [ + { + diff_line_no: 1, + line_type: DiffLineType.ADDED, + source_line_no: null, + target_line_no: 1, + value: '+hello', + }, + ], + section_header: '@@ -1,3 +1,4 @@', + source_length: 3, + source_start: 1, + target_length: 4, + target_start: 1, + }, + ], + path: 'src/index.ts', + removed: 0, + source_file: 'a/src/index.ts', + target_file: 'b/src/index.ts', + type: DiffFileType.MODIFIED, + }; +} + +describe('isExplorerFilePatch', () => { + it('returns true for a valid ExplorerFilePatch', () => { + expect( + isExplorerFilePatch({patch: makeValidFilePatch(), repo_name: 'getsentry/sentry'}) + ).toBe(true); + }); + + it('returns false for null and non-objects', () => { + expect(isExplorerFilePatch(null)).toBe(false); + expect(isExplorerFilePatch(undefined)).toBe(false); + expect(isExplorerFilePatch('string')).toBe(false); + }); + + it('returns false when patch is invalid', () => { + expect(isExplorerFilePatch({patch: {}, repo_name: 'repo'})).toBe(false); + }); + + it('returns false when repo_name is missing or wrong type', () => { + expect(isExplorerFilePatch({patch: makeValidFilePatch()})).toBe(false); + expect(isExplorerFilePatch({patch: makeValidFilePatch(), repo_name: 123})).toBe( + false + ); + }); +}); + +describe('isRepoPRState', () => { + function makeValidRepoPRState(): RepoPRState { + return { + branch_name: 'fix/bug', + commit_sha: 'abc123', + pr_creation_error: null, + pr_creation_status: 'completed' as const, + pr_id: 42, + pr_number: 100, + pr_url: 'https://github.com/org/repo/pull/100', + repo_name: 'org/repo', + title: 'Fix the bug', + }; + } + + it('returns true for a valid RepoPRState with all fields populated', () => { + expect(isRepoPRState(makeValidRepoPRState())).toBe(true); + }); + + it('returns true when nullable fields are null', () => { + expect( + isRepoPRState({ + branch_name: null, + commit_sha: null, + pr_creation_error: null, + pr_creation_status: null, + pr_id: null, + pr_number: null, + pr_url: null, + repo_name: 'org/repo', + title: null, + }) + ).toBe(true); + }); + + it('returns false for null and non-objects', () => { + expect(isRepoPRState(null)).toBe(false); + expect(isRepoPRState(undefined)).toBe(false); + expect(isRepoPRState(42)).toBe(false); + }); + + it('returns false when repo_name is missing', () => { + const {repo_name: _, ...noRepoName} = makeValidRepoPRState(); + expect(isRepoPRState(noRepoName)).toBe(false); + }); + + it('returns false when a nullable string field has wrong type', () => { + expect(isRepoPRState({...makeValidRepoPRState(), branch_name: 123})).toBe(false); + expect(isRepoPRState({...makeValidRepoPRState(), pr_url: true})).toBe(false); + }); + + it('returns false when a nullable number field has wrong type', () => { + expect(isRepoPRState({...makeValidRepoPRState(), pr_id: 'not-a-number'})).toBe(false); + expect(isRepoPRState({...makeValidRepoPRState(), pr_number: true})).toBe(false); + }); +}); + +describe('isArtifact', () => { + it('returns true for a valid Artifact with data', () => { + expect(isArtifact({key: 'k', reason: 'r', data: {foo: 'bar'}})).toBe(true); + }); + + it('returns true for a valid Artifact with null data', () => { + expect(isArtifact({key: 'k', reason: 'r', data: null})).toBe(true); + }); + + it('returns false for null and non-objects', () => { + expect(isArtifact(null)).toBe(false); + expect(isArtifact(undefined)).toBe(false); + expect(isArtifact('string')).toBe(false); + }); + + it('returns false when key is missing or not a string', () => { + expect(isArtifact({reason: 'r', data: null})).toBe(false); + expect(isArtifact({key: 123, reason: 'r', data: null})).toBe(false); + }); + + it('returns false when reason is missing or not a string', () => { + expect(isArtifact({key: 'k', data: null})).toBe(false); + expect(isArtifact({key: 'k', reason: 42, data: null})).toBe(false); + }); + + it('returns false when data property is missing entirely', () => { + expect(isArtifact({key: 'k', reason: 'r'})).toBe(false); + }); +}); diff --git a/static/app/views/seerExplorer/types.tsx b/static/app/views/seerExplorer/types.tsx index eff8bb3530b6a1..3f797e4469ea29 100644 --- a/static/app/views/seerExplorer/types.tsx +++ b/static/app/views/seerExplorer/types.tsx @@ -1,4 +1,4 @@ -import type {FilePatch} from 'sentry/components/events/autofix/types'; +import {isFilePatch, type FilePatch} from 'sentry/components/events/autofix/types'; export interface TodoItem { content: string; @@ -10,24 +10,58 @@ export interface ExplorerFilePatch { repo_name: string; } +export function isExplorerFilePatch(value: unknown): value is ExplorerFilePatch { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return isFilePatch(obj.patch) && typeof obj.repo_name === 'string'; +} + export interface RepoPRState { + branch_name: string | null; + commit_sha: string | null; + pr_creation_error: string | null; + pr_creation_status: 'creating' | 'completed' | 'error' | null; + pr_id: number | null; + pr_number: number | null; + pr_url: string | null; repo_name: string; - branch_name?: string; - commit_sha?: string; - pr_creation_error?: string; - pr_creation_status?: 'creating' | 'completed' | 'error'; - pr_id?: number; - pr_number?: number; - pr_url?: string; - title?: string; + title: string | null; } -export interface Artifact { - data: Record | null; +export function isRepoPRState(value: unknown): value is RepoPRState { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return ( + typeof obj.repo_name === 'string' && + (obj.branch_name === null || typeof obj.branch_name === 'string') && + (obj.commit_sha === null || typeof obj.commit_sha === 'string') && + (obj.pr_creation_error === null || typeof obj.pr_creation_error === 'string') && + (obj.pr_creation_status === null || typeof obj.pr_creation_status === 'string') && + (obj.pr_id === null || typeof obj.pr_id === 'number') && + (obj.pr_number === null || typeof obj.pr_number === 'number') && + (obj.pr_url === null || typeof obj.pr_url === 'string') && + (obj.title === null || typeof obj.title === 'string') + ); +} + +export interface Artifact> { + data: T | null; key: string; reason: string; } +export function isArtifact(value: unknown): value is Artifact { + if (value === null || typeof value !== 'object') { + return false; + } + const obj = value as Record; + return 'data' in obj && typeof obj.key === 'string' && typeof obj.reason === 'string'; +} + export interface Block { id: string; message: Message;