Skip to content

Commit 7a2b95c

Browse files
billyvgsentry-junior[bot]ryan953claude
authored
fix(seer): Route coding agent handoff CTA through seer/settings/ (#117622)
## Problem The Cursor/Claude **coding agent integration CTA** (`codingAgentIntegrationCta.tsx`, used by `CursorIntegrationCta` and `ClaudeCodeIntegrationCta` on the project Seer settings page) wired its *"Set Seer to hand off to …"* button through the legacy `POST .../seer/preferences/` endpoint via `useUpdateProjectSeerPreferences`. That endpoint looks up repos by `(provider, owner, name, external_id)` and strips whitespace from `owner`/`name`. For GitLab orgs with spaces in repo names (e.g. `"My Group / My Repo"`), every handoff setup returned `{"detail": "Invalid repository"}` (HTTP 400) — even though the CTA isn't editing repos. It only re-sent the existing `repositories` to *preserve* them through the coupled legacy payload. ## Fix Switch the write to `useUpdateSeerSettings` (`PUT .../seer/settings/`), which accepts `agent`, `integrationId`, `stoppingPoint`, and `autoCreatePr` with **no repository lookup**, making it immune to the whitespace bug. The defensive repo re-send is dropped since the settings endpoint never touches repository associations. Payload mapping for handoff setup: ``` {agent: config.target, integrationId, stoppingPoint: 'root_cause', autoCreatePr: false} ``` This matches the handoff mapping established in #117526 for `ProjectSeerGeneralForm`. ## Scope Only the CTA's handoff *write* changes. The `useProjectSeerPreferences` read (used for the `isConfigured` check) and the `useUpdateProject` automation-enable call are unchanged. ## Tests Updated `cursorIntegrationCta.spec.tsx` and `claudeCodeIntegrationCta.spec.tsx` so the handoff-setup tests assert `PUT .../seer/settings/` with the new payload instead of `POST .../seer/preferences/`. The read-side preferences mocks are unchanged. ## Stacking Stacked on #117526 (which introduces `useUpdateSeerSettings`). Merge that first. Refs #117489 Fixes https://linear.app/getsentry/issue/CW-1527/failed-to-launch-coding-agent-with-anthropic-api-key --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Ryan Albrecht <ryan.albrecht@sentry.io> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b8e7388 commit 7a2b95c

5 files changed

Lines changed: 221 additions & 248 deletions

File tree

static/app/components/events/autofix/claudeCodeIntegrationCta.spec.tsx

Lines changed: 66 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,37 @@ describe('ClaudeCodeIntegrationCta', () => {
2323
});
2424
}
2525

26+
// The CTA reads handoff state from the project's seer setting. Only fires
27+
// once an integration exists, so the install-stage tests don't need it.
28+
const mockSeerSettings = (
29+
overrides: Partial<{
30+
agent: string;
31+
integrationId: string | null;
32+
}> = {}
33+
) =>
34+
MockApiClient.addMockResponse({
35+
url: `/projects/${organization.slug}/${project.slug}/seer/settings/`,
36+
method: 'GET',
37+
body: {
38+
projectId: project.id,
39+
projectSlug: project.slug,
40+
agent: 'seer',
41+
integrationId: null,
42+
stoppingPoint: 'root_cause',
43+
autoCreatePr: null,
44+
automationTuning: 'medium',
45+
scannerAutomation: true,
46+
reposCount: 0,
47+
...overrides,
48+
},
49+
});
50+
2651
beforeEach(() => {
2752
MockApiClient.clearMockResponses();
2853
localStorage.clear();
2954

3055
mockDetailedProject();
3156

32-
MockApiClient.addMockResponse({
33-
url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
34-
body: {
35-
code_mapping_repos: [],
36-
preference: null,
37-
},
38-
});
39-
4057
MockApiClient.addMockResponse({
4158
url: `/organizations/${organization.slug}/integrations/coding-agents/`,
4259
body: {
@@ -130,6 +147,9 @@ describe('ClaudeCodeIntegrationCta', () => {
130147
],
131148
},
132149
});
150+
151+
// Setting still points at Seer — handoff not configured for this agent.
152+
mockSeerSettings();
133153
});
134154

135155
it('shows configure stage when integration installed but not configured', async () => {
@@ -146,19 +166,11 @@ describe('ClaudeCodeIntegrationCta', () => {
146166
).toBeInTheDocument();
147167
});
148168

149-
it('configures handoff when setup button is clicked', async () => {
169+
it('configures handoff through the seer/settings/ endpoint when setup button is clicked', async () => {
150170
const updateMock = MockApiClient.addMockResponse({
151-
url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
152-
method: 'POST',
153-
body: {
154-
repositories: [],
155-
automated_run_stopping_point: 'root_cause',
156-
automation_handoff: {
157-
handoff_point: 'root_cause',
158-
target: CodingAgentProvider.CLAUDE_CODE_AGENT,
159-
integration_id: 456,
160-
},
161-
},
171+
url: `/projects/${organization.slug}/${project.slug}/seer/settings/`,
172+
method: 'PUT',
173+
body: {},
162174
});
163175

164176
render(<ClaudeCodeIntegrationCta project={project} />, {
@@ -172,17 +184,15 @@ describe('ClaudeCodeIntegrationCta', () => {
172184

173185
await waitFor(() => {
174186
expect(updateMock).toHaveBeenCalledWith(
175-
`/projects/${organization.slug}/${project.slug}/seer/preferences/`,
187+
`/projects/${organization.slug}/${project.slug}/seer/settings/`,
176188
expect.objectContaining({
177-
method: 'POST',
189+
method: 'PUT',
178190
data: {
179-
repositories: [],
180-
automated_run_stopping_point: 'root_cause',
181-
automation_handoff: {
182-
handoff_point: 'root_cause',
183-
target: CodingAgentProvider.CLAUDE_CODE_AGENT,
184-
integration_id: 456,
185-
},
191+
agent: CodingAgentProvider.CLAUDE_CODE_AGENT,
192+
integrationId: '456',
193+
stoppingPoint: 'root_cause',
194+
autoCreatePr: false,
195+
automationTuning: 'medium',
186196
},
187197
})
188198
);
@@ -217,18 +227,10 @@ describe('ClaudeCodeIntegrationCta', () => {
217227
body: {},
218228
});
219229

220-
const preferencesUpdateMock = MockApiClient.addMockResponse({
221-
url: `/projects/${organization.slug}/${projectWithAutomation.slug}/seer/preferences/`,
222-
method: 'POST',
223-
body: {
224-
repositories: [],
225-
automated_run_stopping_point: 'root_cause',
226-
automation_handoff: {
227-
handoff_point: 'root_cause',
228-
target: CodingAgentProvider.CLAUDE_CODE_AGENT,
229-
integration_id: 456,
230-
},
231-
},
230+
const settingsUpdateMock = MockApiClient.addMockResponse({
231+
url: `/projects/${organization.slug}/${projectWithAutomation.slug}/seer/settings/`,
232+
method: 'PUT',
233+
body: {},
232234
});
233235

234236
render(<ClaudeCodeIntegrationCta project={projectWithAutomation} />, {
@@ -243,10 +245,10 @@ describe('ClaudeCodeIntegrationCta', () => {
243245
expect(projectUpdateMock).not.toHaveBeenCalled();
244246

245247
await waitFor(() => {
246-
expect(preferencesUpdateMock).toHaveBeenCalledWith(
247-
`/projects/${organization.slug}/${projectWithAutomation.slug}/seer/preferences/`,
248+
expect(settingsUpdateMock).toHaveBeenCalledWith(
249+
`/projects/${organization.slug}/${projectWithAutomation.slug}/seer/settings/`,
248250
expect.objectContaining({
249-
method: 'POST',
251+
method: 'PUT',
250252
})
251253
);
252254
});
@@ -271,18 +273,10 @@ describe('ClaudeCodeIntegrationCta', () => {
271273
body: updatedProject,
272274
});
273275

274-
const preferencesUpdateMock = MockApiClient.addMockResponse({
275-
url: `/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/preferences/`,
276-
method: 'POST',
277-
body: {
278-
repositories: [],
279-
automated_run_stopping_point: 'root_cause',
280-
automation_handoff: {
281-
handoff_point: 'root_cause',
282-
target: CodingAgentProvider.CLAUDE_CODE_AGENT,
283-
integration_id: 456,
284-
},
285-
},
276+
const settingsUpdateMock = MockApiClient.addMockResponse({
277+
url: `/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/settings/`,
278+
method: 'PUT',
279+
body: {},
286280
});
287281

288282
const onUpdateSuccessSpy = jest.spyOn(ProjectsStore, 'onUpdateSuccess');
@@ -314,18 +308,16 @@ describe('ClaudeCodeIntegrationCta', () => {
314308
});
315309

316310
await waitFor(() => {
317-
expect(preferencesUpdateMock).toHaveBeenCalledWith(
318-
`/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/preferences/`,
311+
expect(settingsUpdateMock).toHaveBeenCalledWith(
312+
`/projects/${organization.slug}/${projectWithoutAutomation.slug}/seer/settings/`,
319313
expect.objectContaining({
320-
method: 'POST',
314+
method: 'PUT',
321315
data: {
322-
repositories: [],
323-
automated_run_stopping_point: 'root_cause',
324-
automation_handoff: {
325-
handoff_point: 'root_cause',
326-
target: CodingAgentProvider.CLAUDE_CODE_AGENT,
327-
integration_id: 456,
328-
},
316+
agent: CodingAgentProvider.CLAUDE_CODE_AGENT,
317+
integrationId: '456',
318+
stoppingPoint: 'root_cause',
319+
autoCreatePr: false,
320+
automationTuning: 'medium',
329321
},
330322
})
331323
);
@@ -350,20 +342,10 @@ describe('ClaudeCodeIntegrationCta', () => {
350342
},
351343
});
352344

353-
MockApiClient.addMockResponse({
354-
url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
355-
body: {
356-
code_mapping_repos: [],
357-
preference: {
358-
repositories: [],
359-
automated_run_stopping_point: 'root_cause',
360-
automation_handoff: {
361-
handoff_point: 'root_cause',
362-
target: CodingAgentProvider.CLAUDE_CODE_AGENT,
363-
integration_id: 456,
364-
},
365-
},
366-
},
345+
// Handoff is set to Claude, but the project's automation is disabled.
346+
mockSeerSettings({
347+
agent: CodingAgentProvider.CLAUDE_CODE_AGENT,
348+
integrationId: '456',
367349
});
368350
});
369351

@@ -404,20 +386,10 @@ describe('ClaudeCodeIntegrationCta', () => {
404386
},
405387
});
406388

407-
MockApiClient.addMockResponse({
408-
url: `/projects/${organization.slug}/${project.slug}/seer/preferences/`,
409-
body: {
410-
code_mapping_repos: [],
411-
preference: {
412-
repositories: [],
413-
automated_run_stopping_point: 'root_cause',
414-
automation_handoff: {
415-
handoff_point: 'root_cause',
416-
target: CodingAgentProvider.CLAUDE_CODE_AGENT,
417-
integration_id: 456,
418-
},
419-
},
420-
},
389+
// Handoff is configured to Claude.
390+
mockSeerSettings({
391+
agent: CodingAgentProvider.CLAUDE_CODE_AGENT,
392+
integrationId: '456',
421393
});
422394
});
423395

static/app/components/events/autofix/codingAgentIntegrationCta.tsx

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,23 @@
1-
import {useQuery} from '@tanstack/react-query';
1+
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
22

33
import {Button, LinkButton} from '@sentry/scraps/button';
44
import {Container, Flex} from '@sentry/scraps/layout';
55
import {ExternalLink, Link} from '@sentry/scraps/link';
66
import {Heading, Text} from '@sentry/scraps/text';
77

8-
import {useProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useProjectSeerPreferences';
9-
import {useUpdateProjectSeerPreferences} from 'sentry/components/events/autofix/preferences/hooks/useUpdateProjectSeerPreferences';
108
import type {SeerAutomationHandoffConfiguration} from 'sentry/components/events/autofix/types';
11-
import {organizationIntegrationsCodingAgents} from 'sentry/components/events/autofix/useAutofix';
129
import {Placeholder} from 'sentry/components/placeholder';
1310
import {t, tct} from 'sentry/locale';
1411
import {PluginIcon} from 'sentry/plugins/components/pluginIcon';
1512
import type {Project} from 'sentry/types/project';
1613
import {trackAnalytics} from 'sentry/utils/analytics';
1714
import {useDetailedProject} from 'sentry/utils/project/useDetailedProject';
1815
import {useUpdateProject} from 'sentry/utils/project/useUpdateProject';
16+
import {knownAgentIntegrationsQueryOptions} from 'sentry/utils/seer/preferredAgent';
17+
import {
18+
getMutateSeerProjectSettingsOptions,
19+
getSeerProjectSettingsQueryOptions,
20+
} from 'sentry/utils/seer/seerProjectSettings';
1921
import {useOrganization} from 'sentry/utils/useOrganization';
2022
import {useUser} from 'sentry/utils/useUser';
2123

@@ -40,6 +42,7 @@ export function makeCodingAgentIntegrationCta(config: AgentConfig) {
4042
return function CodingAgentIntegrationCta({project}: CodingAgentIntegrationCtaProps) {
4143
const organization = useOrganization();
4244
const user = useUser();
45+
const queryClient = useQueryClient();
4346

4447
const hasFeatureFlag =
4548
!config.featureFlag || organization.features.includes(config.featureFlag);
@@ -50,25 +53,34 @@ export function makeCodingAgentIntegrationCta(config: AgentConfig) {
5053
},
5154
{enabled: hasFeatureFlag}
5255
);
53-
const {data, isFetching: isLoadingPreferences} = useProjectSeerPreferences(project);
54-
const preference = data?.preference;
55-
const {mutate: updateProjectSeerPreferences, isPending: isUpdatingPreferences} =
56-
useUpdateProjectSeerPreferences(project);
57-
const {data: codingAgentIntegrations, isLoading: isLoadingIntegrations} = useQuery(
58-
organizationIntegrationsCodingAgents(organization)
56+
const {data: knownAgents, isLoading: isLoadingIntegrations} = useQuery(
57+
knownAgentIntegrationsQueryOptions({organization})
5958
);
60-
const {mutateAsync: updateProjectAutomation} = useUpdateProject(project);
6159

62-
const integration = codingAgentIntegrations?.integrations.find(
63-
i => i.provider === config.provider
60+
const integration = knownAgents?.find(i => i.provider === config.target);
61+
const hasIntegration = Boolean(integration);
62+
63+
// Only the configured/not-configured states need the project's Seer setting;
64+
// without an integration the CTA short-circuits to the install card, so skip
65+
// the fetch entirely until we know an integration exists.
66+
const {data: seerSettings, isLoading: isLoadingSettings} = useQuery({
67+
...getSeerProjectSettingsQueryOptions({organization, project}),
68+
enabled: hasIntegration,
69+
});
70+
const {mutate: updateSeerSettings, isPending: isUpdatingSettings} = useMutation(
71+
getMutateSeerProjectSettingsOptions({
72+
organization,
73+
project,
74+
queryClient,
75+
knownAgents,
76+
})
6477
);
78+
const {mutateAsync: updateProjectAutomation} = useUpdateProject(project);
6579

66-
const hasIntegration = Boolean(integration);
6780
const isAutomationEnabled =
6881
projectDetails?.seerScannerAutomation !== false &&
6982
projectDetails?.autofixAutomationTuning !== 'off';
70-
const isConfigured =
71-
preference?.automation_handoff?.target === config.target && isAutomationEnabled;
83+
const isConfigured = seerSettings?.agent === config.target && isAutomationEnabled;
7284

7385
const handleInstallClick = () => {
7486
trackAnalytics('coding_integration.install_clicked', {
@@ -104,14 +116,10 @@ export function makeCodingAgentIntegrationCta(config: AgentConfig) {
104116
});
105117
}
106118

107-
updateProjectSeerPreferences({
108-
repositories: preference?.repositories || [],
109-
automated_run_stopping_point: 'root_cause',
110-
automation_handoff: {
111-
handoff_point: 'root_cause',
112-
target: config.target,
113-
integration_id: parseInt(integration.id, 10),
114-
},
119+
updateSeerSettings({
120+
agentOption: `${config.target}::${integration.id}`,
121+
stoppingPoint: 'root_cause',
122+
autoCreatePr: false,
115123
});
116124
};
117125

@@ -121,9 +129,9 @@ export function makeCodingAgentIntegrationCta(config: AgentConfig) {
121129

122130
if (
123131
isLoadingProject ||
124-
isLoadingPreferences ||
132+
isLoadingSettings ||
125133
isLoadingIntegrations ||
126-
isUpdatingPreferences
134+
isUpdatingSettings
127135
) {
128136
return (
129137
<Container

0 commit comments

Comments
 (0)