Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export interface ScmInstallation {
* `repositories` is still empty.
*/
reposLoading?: boolean;
/**
* Props forwarded to the settings button. Use to disable or annotate it
* while per-integration config is still being fetched.
*/
settingsButtonProps?: Omit<ButtonProps, 'onClick'>;
/**
* Props forwarded to the uninstall button. Use to disable or annotate it
* when the viewer lacks the required access.
Expand Down Expand Up @@ -401,7 +406,8 @@ function InstallationActions({
onUninstall,
onSettings,
}: InstallationActionsProps) {
const {manageUrl, overflowMenuItems, uninstallButtonProps} = installation;
const {manageUrl, overflowMenuItems, settingsButtonProps, uninstallButtonProps} =
installation;
return (
<Fragment>
{manageUrl && (
Expand Down Expand Up @@ -436,6 +442,7 @@ function InstallationActions({
size="xs"
variant="transparent"
icon={<IconSliders />}
{...settingsButtonProps}
onClick={() => onSettings(installation)}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,4 +196,43 @@ describe('OrganizationRepositoriesV2', () => {

expect(await screen.findByRole('button', {name: 'Uninstall'})).toBeDisabled();
});

it('shows the settings button as disabled while the integration config is loading', async () => {
MockApiClient.addMockResponse({
url: '/organizations/org-slug/config/integrations/',
body: {providers: [GITHUB_PROVIDER]},
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/integrations/',
body: [GITHUB_INTEGRATION],
});
MockApiClient.addMockResponse({
url: `/organizations/org-slug/integrations/${GITHUB_INTEGRATION.id}/`,
body: GITHUB_INTEGRATION,
asyncDelay: 10_000,
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/repos/',
body: [],
});
MockApiClient.addMockResponse({
url: '/organizations/org-slug/code-mappings/',
body: [],
});

render(<OrganizationRepositoriesV2 />);

await screen.findByText('my-org');
expect(screen.getByRole('button', {name: 'Integration settings'})).toBeDisabled();
});

it('enables the settings button once the integration config has loaded', async () => {
setupDefaultMocks();

render(<OrganizationRepositoriesV2 />);

expect(
await screen.findByRole('button', {name: 'Integration settings'})
).toBeEnabled();
});
});
11 changes: 11 additions & 0 deletions static/app/views/settings/organizationRepositoriesV2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {useRepoSearch} from 'sentry/views/settings/organizationRepositories/useR
import {organizationIntegrationsQueryOptions} from 'sentry/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions';

import {useDeleteIntegration} from './useDeleteIntegration';
import {useInstallationSettings} from './useInstallationSettings';

const SCM_PROVIDER_ORDER = [
'github',
Expand Down Expand Up @@ -125,6 +126,11 @@ export function OrganizationRepositoriesV2() {
codeMappingsQuery.hasNextPage ||
codeMappingsQuery.isFetchingNextPage;

const {configByIntegrationId, openInstallationSettings} = useInstallationSettings({
scmIntegrations,
hasAccess,
});

const installationsByProviderKey = useMemo(() => {
const installations = scmIntegrations.map<ScmInstallation>(integration => ({
integration,
Expand All @@ -133,6 +139,9 @@ export function OrganizationRepositoriesV2() {
manageUrl: getProviderConfigUrl(integration) ?? undefined,
mappedProjectSlugsByRepoId,
mappingsLoading,
settingsButtonProps: {
disabled: configByIntegrationId[integration.id] === undefined,
},
Comment on lines +142 to +144
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: If the API call to fetch an integration's configuration fails, the settings button becomes permanently disabled because configByIntegrationId[integration.id] is undefined.
Severity: MEDIUM

Suggested Fix

The UI should differentiate between a loading state and an error state. Instead of just checking if configByIntegrationId[integration.id] is undefined, check the status of the corresponding query. If there's an error, display an error message to the user and potentially allow them to retry the action, rather than permanently disabling the button.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: static/app/views/settings/organizationRepositoriesV2/index.tsx#L142-L144

Potential issue: The `combine` function in `useInstallationSettings.tsx` returns
`undefined` for an integration if its corresponding API call fails. In `index.tsx`, the
settings button's `disabled` prop is set to `configByIntegrationId[integration.id] ===
undefined`. While this correctly disables the button during the initial loading state,
it also causes the button to become permanently disabled if the API call fails after
retries. The user is not shown any error message and has no way to recover or retry the
action. The `openInstallationSettings` function has a fallback `?? integration` which
suggests an intent to handle this case, but this code is unreachable as the button that
triggers it is disabled.

Did we get this right? 👍 / 👎 to inform future reviews.

uninstallButtonProps: hasAccess
? undefined
: {
Expand All @@ -151,6 +160,7 @@ export function OrganizationRepositoriesV2() {
reposLoading,
mappedProjectSlugsByRepoId,
mappingsLoading,
configByIntegrationId,
hasAccess,
]);

Expand Down Expand Up @@ -238,6 +248,7 @@ export function OrganizationRepositoriesV2() {
installations={installationsByProviderKey[provider.key]!}
repoMatches={repoMatches}
onUninstall={inst => handleDeleteIntegration(inst.integration)}
onSettings={inst => openInstallationSettings(inst.integration)}
/>
))
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {Fragment} from 'react';
import {mutationOptions, useQueries, useQueryClient} from '@tanstack/react-query';

import {Alert} from '@sentry/scraps/alert';
import {DrawerBody, DrawerHeader, useDrawer} from '@sentry/scraps/drawer';
import {Container, Flex, Stack} from '@sentry/scraps/layout';

import {BackendJsonAutoSaveForm} from 'sentry/components/backendJsonFormAdapter/backendJsonAutoSaveForm';
import type {FieldValue} from 'sentry/components/backendJsonFormAdapter/types';
import {t} from 'sentry/locale';
import type {OrganizationIntegration} from 'sentry/types/integrations';
import {apiOptions} from 'sentry/utils/api/apiOptions';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {getIntegrationIcon} from 'sentry/utils/integrationUtil';
import {fetchMutation} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';

const NO_ACCESS_REASON = t(
'You must be an organization owner, manager, or admin to change these settings.'
);

interface UseInstallationSettingsOptions {
hasAccess: boolean;
scmIntegrations: OrganizationIntegration[];
}

interface UseInstallationSettingsResult {
/**
* Full integration config keyed by integration ID, populated once each
* per-integration config query resolves. Values are `undefined` while
* their query is still in-flight.
*/
configByIntegrationId: Record<string, OrganizationIntegration | undefined>;
/**
* Opens a settings drawer for the given integration, rendering each
* `configOrganization` field as an auto-saving form. Fields are disabled
* with a permission tooltip when `hasAccess` is false.
*/
openInstallationSettings: (integration: OrganizationIntegration) => void;
}

export function useInstallationSettings({
scmIntegrations,
hasAccess,
}: UseInstallationSettingsOptions): UseInstallationSettingsResult {
const organization = useOrganization();
const {openDrawer} = useDrawer();
const queryClient = useQueryClient();

const configByIntegrationId = useQueries({
queries: scmIntegrations.map(integration =>
apiOptions.as<OrganizationIntegration>()(
'/organizations/$organizationIdOrSlug/integrations/$integrationId/',
{
path: {
organizationIdOrSlug: organization.slug,
integrationId: integration.id,
},
staleTime: 60_000,
}
)
),
combine: results =>
Object.fromEntries(
scmIntegrations.map((integration, i) => [integration.id, results[i]?.data])
),
});

const openInstallationSettings = (integration: OrganizationIntegration) => {
const integrationOptions = apiOptions.as<OrganizationIntegration>()(
'/organizations/$organizationIdOrSlug/integrations/$integrationId/',
{
path: {organizationIdOrSlug: organization.slug, integrationId: integration.id},
staleTime: 60_000,
}
);
const integrationEndpoint = getApiUrl(
'/organizations/$organizationIdOrSlug/integrations/$integrationId/',
{path: {organizationIdOrSlug: organization.slug, integrationId: integration.id}}
);
const integrationMutationOptions = mutationOptions({
mutationFn: (data: Record<string, unknown>) =>
fetchMutation({method: 'POST', url: integrationEndpoint, data}),
onSuccess: () =>
queryClient.invalidateQueries({queryKey: integrationOptions.queryKey}),
});

const resolvedIntegration = configByIntegrationId[integration.id] ?? integration;
const fields = hasAccess
? (resolvedIntegration.configOrganization ?? [])
: (resolvedIntegration.configOrganization?.map(f => ({
...f,
disabled: true,
disabledReason: NO_ACCESS_REASON,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just double checked and I don’t think we map disabledReason correctly to the new form system. I’ll fix this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

})) ?? []);

openDrawer(
() => (
<Fragment>
<DrawerHeader>
<Flex align="center" gap="sm">
{getIntegrationIcon(integration.provider.key, 'sm')}
{t('%s Settings', integration.name)}
</Flex>
</DrawerHeader>
<DrawerBody>
{!hasAccess && (
<Alert.Container>
<Alert variant="warning">{NO_ACCESS_REASON}</Alert>
</Alert.Container>
)}
<Stack gap="0">
{fields.map((fieldConfig, index) => (
<Container
key={fieldConfig.name}
padding="xl 0"
borderBottom={index < fields.length - 1 ? 'secondary' : undefined}
>
<BackendJsonAutoSaveForm
field={fieldConfig}
initialValue={
resolvedIntegration.configData?.[fieldConfig.name] as FieldValue<
typeof fieldConfig
>
}
mutationOptions={integrationMutationOptions}
/>
</Container>
))}
</Stack>
</DrawerBody>
</Fragment>
),
{
ariaLabel: t('Integration settings for %s', integration.name),
drawerKey: `integration-settings-${integration.id}`,
}
);
};

return {configByIntegrationId, openInstallationSettings};
}
Loading