diff --git a/static/app/components/events/autofix/autofixRootCause.spec.tsx b/static/app/components/events/autofix/autofixRootCause.spec.tsx index 889e5c5d9c8225..7687b700b662a0 100644 --- a/static/app/components/events/autofix/autofixRootCause.spec.tsx +++ b/static/app/components/events/autofix/autofixRootCause.spec.tsx @@ -150,7 +150,7 @@ describe('AutofixRootCause', () => { await userEvent.click(await screen.findByText('Send to Cursor')); expect(JSON.parse(localStorage.getItem('autofix:rootCauseActionPreference')!)).toBe( - 'cursor:cursor-integration-id' + 'agent:cursor-integration-id' ); }); @@ -204,7 +204,7 @@ describe('AutofixRootCause', () => { localStorage.setItem( 'autofix:rootCauseActionPreference', - JSON.stringify('cursor:cursor-integration-id') + JSON.stringify('agent:cursor-integration-id') ); render(); @@ -222,6 +222,33 @@ describe('AutofixRootCause', () => { expect(await screen.findByText('Find Solution with Seer')).toBeInTheDocument(); }); + it('shows Cursor as primary when using legacy cursor: prefix (backwards compatibility)', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/coding-agents/', + body: { + integrations: [ + { + id: 'cursor-integration-id', + name: 'Cursor', + provider: 'cursor', + }, + ], + }, + }); + + // Use the legacy 'cursor:' prefix that existing users may have stored + localStorage.setItem( + 'autofix:rootCauseActionPreference', + JSON.stringify('cursor:cursor-integration-id') + ); + + render(); + + expect( + await screen.findByRole('button', {name: 'Send to Cursor'}) + ).toBeInTheDocument(); + }); + it('both options accessible in dropdown', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/integrations/coding-agents/', diff --git a/static/app/components/events/autofix/autofixRootCause.tsx b/static/app/components/events/autofix/autofixRootCause.tsx index 7d31f067eba5cf..5c1d5bb82ee60c 100644 --- a/static/app/components/events/autofix/autofixRootCause.tsx +++ b/static/app/components/events/autofix/autofixRootCause.tsx @@ -249,7 +249,7 @@ function CopyRootCauseButton({ } function SolutionActionButton({ - cursorIntegrations, + codingAgentIntegrations, preferredAction, primaryButtonPriority, isSelectingRootCause, @@ -259,9 +259,9 @@ function SolutionActionButton({ handleLaunchCodingAgent, findSolutionTitle, }: { - cursorIntegrations: CodingAgentIntegration[]; + codingAgentIntegrations: CodingAgentIntegration[]; findSolutionTitle: string; - handleLaunchCodingAgent: (integrationId: string, integrationName: string) => void; + handleLaunchCodingAgent: (integration: CodingAgentIntegration) => void; isLaunchingAgent: boolean; isLoadingAgents: boolean; isSelectingRootCause: boolean; @@ -269,8 +269,14 @@ function SolutionActionButton({ primaryButtonPriority: React.ComponentProps['priority']; submitFindSolution: () => void; }) { - const preferredIntegration = preferredAction.startsWith('cursor:') - ? cursorIntegrations.find(i => i.id === preferredAction.replace('cursor:', '')) + // Support both 'agent:' (new) and 'cursor:' (legacy) prefixes for backwards compatibility + const isAgentPreference = + preferredAction.startsWith('agent:') || preferredAction.startsWith('cursor:'); + const preferredIntegration = isAgentPreference + ? codingAgentIntegrations.find(i => { + const key = preferredAction.replace(/^(agent|cursor):/, ''); + return i.id === key || (i.id === null && i.provider === key); + }) : null; const effectivePreference = @@ -282,11 +288,12 @@ function SolutionActionButton({ // Check if there are duplicate names among integrations (need to show ID to distinguish) const hasDuplicateNames = - cursorIntegrations.length > 1 && - new Set(cursorIntegrations.map(i => i.name)).size < cursorIntegrations.length; + codingAgentIntegrations.length > 1 && + new Set(codingAgentIntegrations.map(i => i.name)).size < + codingAgentIntegrations.length; // If no integrations, show simple Seer button - if (cursorIntegrations.length === 0) { + if (codingAgentIntegrations.length === 0) { return ( )} diff --git a/static/app/components/events/autofix/cursorIntegrationCta.tsx b/static/app/components/events/autofix/cursorIntegrationCta.tsx index 264c8f81d6b2cb..93072ecd7139eb 100644 --- a/static/app/components/events/autofix/cursorIntegrationCta.tsx +++ b/static/app/components/events/autofix/cursorIntegrationCta.tsx @@ -44,7 +44,7 @@ export function CursorIntegrationCta({project}: CursorIntegrationCtaProps) { const isConfigured = Boolean(preference?.automation_handoff) && isAutomationEnabled; const handleSetupClick = useCallback(async () => { - if (!cursorIntegration) { + if (!cursorIntegration?.id) { throw new Error('Cursor integration not found'); } diff --git a/static/app/components/events/autofix/githubCopilotIntegrationCta.tsx b/static/app/components/events/autofix/githubCopilotIntegrationCta.tsx new file mode 100644 index 00000000000000..ef6585258fbf63 --- /dev/null +++ b/static/app/components/events/autofix/githubCopilotIntegrationCta.tsx @@ -0,0 +1,111 @@ +import {Container, Flex} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {LinkButton} from 'sentry/components/core/button/linkButton'; +import {ExternalLink} from 'sentry/components/core/link'; +import {useCodingAgentIntegrations} from 'sentry/components/events/autofix/useAutofix'; +import Placeholder from 'sentry/components/placeholder'; +import {t, tct} from 'sentry/locale'; +import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; +import useOrganization from 'sentry/utils/useOrganization'; + +export function GithubCopilotIntegrationCta() { + const organization = useOrganization(); + + const {data: codingAgentIntegrations, isLoading: isLoadingIntegrations} = + useCodingAgentIntegrations(); + + const githubCopilotIntegration = codingAgentIntegrations?.integrations.find( + integration => integration.provider === 'github_copilot' + ); + + const hasGithubCopilotFeatureFlag = organization.features.includes( + 'integrations-github-copilot-agent' + ); + const hasGithubCopilotIntegration = Boolean(githubCopilotIntegration); + + if (!hasGithubCopilotFeatureFlag) { + return null; + } + + if (isLoadingIntegrations) { + return ( + + + + ); + } + + if (!hasGithubCopilotIntegration) { + return ( + + + + + GitHub Copilot Integration + + + + {tct( + 'Connect GitHub Copilot to hand off Seer root cause analysis to GitHub Copilot coding agent for seamless code fixes. [docsLink:Read the docs] to learn more.', + { + docsLink: ( + + ), + } + )} + +
+ + {t('Install GitHub Copilot Integration')} + +
+
+
+ ); + } + + return ( + + + + + GitHub Copilot Integration + + + + {tct( + 'GitHub Copilot integration is installed. You can trigger GitHub Copilot from Issue Fix to create pull requests. [docsLink:Read the docs] to learn more.', + { + docsLink: ( + + ), + } + )} + + + + ); +} diff --git a/static/app/components/events/autofix/types.ts b/static/app/components/events/autofix/types.ts index 7f4906b786e56e..c352d66e041719 100644 --- a/static/app/components/events/autofix/types.ts +++ b/static/app/components/events/autofix/types.ts @@ -63,6 +63,7 @@ export enum CodingAgentStatus { export enum CodingAgentProvider { CURSOR_BACKGROUND_AGENT = 'cursor_background_agent', + GITHUB_COPILOT_AGENT = 'github_copilot_agent', } export interface CodingAgentState { diff --git a/static/app/components/events/autofix/useAutofix.tsx b/static/app/components/events/autofix/useAutofix.tsx index 4a4f9965bc61e2..7a9260d30edd45 100644 --- a/static/app/components/events/autofix/useAutofix.tsx +++ b/static/app/components/events/autofix/useAutofix.tsx @@ -311,9 +311,10 @@ export const useAiAutofix = ( }; export type CodingAgentIntegration = { - id: string; + id: string | null; name: string; provider: string; + requires_identity?: boolean; }; export function useCodingAgentIntegrations() { @@ -328,7 +329,8 @@ export function useCodingAgentIntegrations() { interface LaunchCodingAgentParams { agentName: string; - integrationId: string; + integrationId: string | null; + provider: string; instruction?: string; triggerSource?: 'root_cause' | 'solution'; } @@ -353,21 +355,37 @@ function getErrorMessage(error: RequestError, agentName: string): string { return t('Failed to launch %s', agentName); } +function needsGitHubAuth(error: RequestError): boolean { + const detail = error.responseJSON?.detail; + return ( + typeof detail === 'string' && + detail.toLowerCase().includes('github') && + detail.toLowerCase().includes('authorization') + ); +} + export function useLaunchCodingAgent(groupId: string, runId: string) { const organization = useOrganization(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (params: LaunchCodingAgentParams) => { + const data: Record = { + run_id: parseInt(runId, 10), + trigger_source: params.triggerSource, + instruction: params.instruction, + }; + + if (params.integrationId === null) { + data.provider = params.provider; + } else { + data.integration_id = parseInt(params.integrationId, 10); + } + return fetchMutation({ url: `/organizations/${organization.slug}/integrations/coding-agents/`, method: 'POST', - data: { - integration_id: parseInt(params.integrationId, 10), - run_id: parseInt(runId, 10), - trigger_source: params.triggerSource, - instruction: params.instruction, - }, + data, }); }, onSuccess: (data, params) => { @@ -399,6 +417,15 @@ export function useLaunchCodingAgent(groupId: string, runId: string) { }); }, onError: (error, params) => { + if (needsGitHubAuth(error)) { + addErrorMessage( + t('Please connect your GitHub account. Redirecting to authorization...') + ); + setTimeout(() => { + window.open('/remote/github-copilot/oauth/', '_blank'); + }, 1000); + return; + } const message = getErrorMessage(error, params.agentName); addErrorMessage(message); }, diff --git a/static/app/components/events/autofix/v2/nextSteps.tsx b/static/app/components/events/autofix/v2/nextSteps.tsx index d20f21cd18d103..825357ea6ad1c8 100644 --- a/static/app/components/events/autofix/v2/nextSteps.tsx +++ b/static/app/components/events/autofix/v2/nextSteps.tsx @@ -116,7 +116,8 @@ function StepButton({ isBusy: boolean; onStepClick: () => void; step: AutofixExplorerStep; - codingAgentIntegrations?: Array<{id: string; name: string; provider: string}>; + // TODO(autofix): Handle GitHub Copilot in explore autofix + codingAgentIntegrations?: Array<{id: string | null; name: string; provider: string}>; isLoading?: boolean; onCodingAgentHandoff?: (integrationId: number) => void; }) { @@ -136,17 +137,19 @@ function StepButton({ ); } - // Build dropdown items for coding agent integrations - const dropdownItems = codingAgentIntegrations.map(integration => ({ - key: `agent:${integration.id}`, - label: ( - - - {t('Send to %s', integration.name)} - - ), - onAction: () => onCodingAgentHandoff?.(parseInt(integration.id, 10)), - })); + // Build dropdown items for coding agent integrations (filter out those without IDs for now) + const dropdownItems = codingAgentIntegrations + .filter(integration => integration.id !== null) + .map(integration => ({ + key: `agent:${integration.id}`, + label: ( + + + {t('Send to %s', integration.name)} + + ), + onAction: () => onCodingAgentHandoff?.(parseInt(integration.id!, 10)), + })); return ( diff --git a/static/app/plugins/components/pluginIcon.tsx b/static/app/plugins/components/pluginIcon.tsx index 6e91a0b1219fdd..1223d74e1fcf50 100644 --- a/static/app/plugins/components/pluginIcon.tsx +++ b/static/app/plugins/components/pluginIcon.tsx @@ -51,6 +51,7 @@ const PLUGIN_ICONS = { bitbucket_server: bitbucketserver, discord, github, + github_copilot: github, github_enterprise: githubEnterprise, gitlab, heroku, diff --git a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx index bd3ff2e3d16b89..26512b37d4f462 100644 --- a/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/seerNotices.tsx @@ -167,7 +167,7 @@ export function SeerNotices({groupId, hasGithubIntegration, project}: SeerNotice ]; const handleSetupCursorHandoff = useCallback(async () => { - if (!cursorIntegration) { + if (!cursorIntegration?.id) { return; } diff --git a/static/app/views/settings/projectSeer/index.tsx b/static/app/views/settings/projectSeer/index.tsx index 20539f0c92f528..2d6dfb5e53e410 100644 --- a/static/app/views/settings/projectSeer/index.tsx +++ b/static/app/views/settings/projectSeer/index.tsx @@ -10,6 +10,7 @@ import FeatureDisabled from 'sentry/components/acl/featureDisabled'; import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Link} from 'sentry/components/core/link'; import {CursorIntegrationCta} from 'sentry/components/events/autofix/cursorIntegrationCta'; +import {GithubCopilotIntegrationCta} from 'sentry/components/events/autofix/githubCopilotIntegrationCta'; import { makeProjectSeerPreferencesQueryKey, useProjectSeerPreferences, @@ -232,7 +233,7 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { value: 'root_cause' | 'solution' | 'code_changes' | 'open_pr' | 'cursor_handoff' ) => { if (value === 'cursor_handoff') { - if (!cursorIntegration) { + if (!cursorIntegration?.id) { throw new Error('Cursor integration not found'); } updateProjectSeerPreferences({ @@ -333,7 +334,7 @@ function ProjectSeerGeneralForm({project}: {project: Project}) { const handleCursorHandoffChange = useCallback( (value: boolean) => { if (value) { - if (!cursorIntegration) { + if (!cursorIntegration?.id) { addErrorMessage( t('Cursor integration not found. Please refresh the page and try again.') ); @@ -656,6 +657,7 @@ function ProjectSeer({ /> +