Skip to content
Draft
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
8 changes: 8 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@
from sentry.integrations.api.endpoints.organization_integration_details import (
OrganizationIntegrationDetailsEndpoint,
)
from sentry.integrations.api.endpoints.organization_integration_direct_enable import (
OrganizationIntegrationDirectEnableEndpoint,
)
from sentry.integrations.api.endpoints.organization_integration_issues import (
OrganizationIntegrationIssuesEndpoint,
)
Expand Down Expand Up @@ -1968,6 +1971,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
OrganizationIntegrationsEndpoint.as_view(),
name="sentry-api-0-organization-integrations",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/integrations/direct-enable/(?P<provider_key>[^/]+)/$",
OrganizationIntegrationDirectEnableEndpoint.as_view(),
name="sentry-api-0-organization-integration-direct-enable",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/integrations/coding-agents/$",
OrganizationCodingAgentsEndpoint.as_view(),
Expand Down
1 change: 1 addition & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2233,6 +2233,7 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]:
"sentry.integrations.opsgenie.OpsgenieIntegrationProvider",
"sentry.integrations.cursor.integration.CursorAgentIntegrationProvider",
"sentry.integrations.claude_code.integration.ClaudeCodeAgentIntegrationProvider",
"sentry.integrations.github_copilot.integration.GithubCopilotIntegrationProvider",
"sentry.integrations.perforce.integration.PerforceIntegrationProvider",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ def get(self, request: Request, organization: Organization) -> Response:
if integration.provider != "github_copilot"
]

if features.has("organizations:integrations-github-copilot-agent", organization):
github_copilot_installed = any(i.provider == "github_copilot" for i in integrations)
if github_copilot_installed and features.has(
"organizations:integrations-github-copilot-agent", organization
):
has_identity = False
if request.user and request.user.id:
try:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations

import logging

from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import cell_silo_endpoint
from sentry.api.bases.organization import OrganizationEndpoint, OrganizationIntegrationsPermission
from sentry.api.serializers import serialize
from sentry.constants import ObjectStatus
from sentry.exceptions import NotRegistered
from sentry.integrations.manager import default_manager as integrations
from sentry.integrations.pipeline import ensure_integration
from sentry.integrations.services.integration import integration_service
from sentry.models.organization import Organization

logger = logging.getLogger(__name__)


@cell_silo_endpoint
class OrganizationIntegrationDirectEnableEndpoint(OrganizationEndpoint):
owner = ApiOwner.INTEGRATIONS
publish_status = {
"POST": ApiPublishStatus.PRIVATE,
}
permission_classes = (OrganizationIntegrationsPermission,)

def post(self, request: Request, organization: Organization, provider_key: str) -> Response:
"""Directly install an integration that requires no pipeline configuration."""
try:
provider = integrations.get(provider_key)
except NotRegistered:
return Response({"detail": "Provider not found."}, status=404)

if not (provider.metadata and provider.metadata.aspects.get("directEnable")):
return Response(
{"detail": "Direct enable is not supported for this integration."}, status=400
)

if not provider.allow_multiple:
existing = integration_service.get_integrations(
organization_id=organization.id,
providers=[provider_key],
status=ObjectStatus.ACTIVE,
)
if existing:
return Response({"detail": "Integration is already enabled."}, status=400)

data = provider.build_integration({})
integration = ensure_integration(provider.key, data)

user = request.user if request.user.is_authenticated else None
org_integration = integration.add_organization(organization, user)
if org_integration is None:
return Response({"detail": "Could not create the integration."}, status=400)

return Response(serialize(org_integration, request.user))
6 changes: 6 additions & 0 deletions src/sentry/integrations/github_copilot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from sentry.integrations.github_copilot.client import GithubCopilotAgentClient
from sentry.integrations.github_copilot.integration import (
GithubCopilotIntegration,
GithubCopilotIntegrationProvider,
)

__all__ = [
"GithubCopilotAgentClient",
"GithubCopilotIntegration",
"GithubCopilotIntegrationProvider",
]
72 changes: 72 additions & 0 deletions src/sentry/integrations/github_copilot/integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from __future__ import annotations

import uuid
from collections.abc import Mapping
from typing import Any

from django.utils.translation import gettext_lazy as _

from sentry.integrations.base import (
FeatureDescription,
IntegrationData,
IntegrationFeatures,
IntegrationInstallation,
IntegrationMetadata,
)
from sentry.integrations.coding_agent.integration import CodingAgentIntegrationProvider

DESCRIPTION = """
Allow users in your Sentry organization to send issues to GitHub Copilot agents.
Each user authenticates with their own GitHub account — no org-wide credentials are required.
"""

FEATURES = [
FeatureDescription(
"Allow users to send Seer root cause analysis to GitHub Copilot agents.",
IntegrationFeatures.CODING_AGENT,
),
]

metadata = IntegrationMetadata(
description=DESCRIPTION.strip(),
features=FEATURES,
author="The Sentry Team",
noun=_("Integration"),
issue_url="https://github.com/getsentry/sentry/issues/new?assignees=&labels=Component:%20Integrations&template=bug.yml&title=GitHub%20Copilot%20Integration%20Problem",
source_url="https://github.com/getsentry/sentry/tree/master/src/sentry/integrations/github_copilot",
aspects={"directEnable": True},
)


class GithubCopilotIntegration(IntegrationInstallation):
"""
Minimal installation — GitHub Copilot uses per-user OAuth tokens,
not org-wide credentials, so there is nothing to configure here.
"""

def get_client(self) -> None:
raise NotImplementedError("GitHub Copilot uses per-user OAuth, not an org-level client")


class GithubCopilotIntegrationProvider(CodingAgentIntegrationProvider):
key = "github_copilot"
name = "GitHub Copilot"
metadata = metadata
integration_cls = GithubCopilotIntegration
requires_feature_flag = False

def get_agent_name(self) -> str:
return "GitHub Copilot"

def get_agent_key(self) -> str:
return "github_copilot"

def get_pipeline_views(self) -> list[Any]:
return []

def build_integration(self, state: Mapping[str, Any]) -> IntegrationData:
return {
"name": "GitHub Copilot",
"external_id": str(uuid.uuid4()),
"metadata": {},
}
1 change: 1 addition & 0 deletions static/app/types/integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ type IntegrationAspects = {
configure_integration?: {
title: string;
};
directEnable?: boolean;
disable_dialog?: IntegrationDialog;
externalInstall?: {
buttonText: string;
Expand Down
2 changes: 2 additions & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/integrations/$integrationId/repos/'
| '/organizations/$organizationIdOrSlug/integrations/$integrationId/serverless-functions/'
| '/organizations/$organizationIdOrSlug/integrations/coding-agents/'
| '/organizations/$organizationIdOrSlug/integrations/direct-enable/$providerKey/'
| '/organizations/$organizationIdOrSlug/intercom-jwt/'
| '/organizations/$organizationIdOrSlug/invite-requests/'
| '/organizations/$organizationIdOrSlug/invite-requests/$memberId/'
Expand Down Expand Up @@ -465,6 +466,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/org-auth-tokens/'
| '/organizations/$organizationIdOrSlug/org-auth-tokens/$tokenId/'
| '/organizations/$organizationIdOrSlug/pinned-searches/'
| '/organizations/$organizationIdOrSlug/pipeline/$pipelineName/'
| '/organizations/$organizationIdOrSlug/plugins/'
| '/organizations/$organizationIdOrSlug/plugins/$pluginSlug/deprecation-info/'
| '/organizations/$organizationIdOrSlug/plugins/configs/'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {OrganizationFixture} from 'sentry-fixture/organization';

import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import {DirectEnableButton} from 'sentry/views/settings/organizationIntegrations/directEnableButton';

describe('DirectEnableButton', () => {
const organization = OrganizationFixture();

const defaultProps = {
providerSlug: 'github_copilot',
userHasAccess: true,
buttonProps: {
size: 'sm' as const,
priority: 'primary' as const,
},
};

it('renders Enable Integration button', () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/integrations/direct-enable/github_copilot/`,
method: 'POST',
body: {provider: {key: 'github_copilot'}},
});

render(<DirectEnableButton {...defaultProps} />, {organization});

expect(screen.getByRole('button', {name: 'Enable Integration'})).toBeInTheDocument();
});

it('calls the direct-enable endpoint on click', async () => {
const mockPost = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/integrations/direct-enable/github_copilot/`,
method: 'POST',
body: {provider: {key: 'github_copilot'}},
});

render(<DirectEnableButton {...defaultProps} />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Enable Integration'}));

await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1));
});

it('disables button when user does not have access', () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/integrations/direct-enable/github_copilot/`,
method: 'POST',
body: {},
});

render(<DirectEnableButton {...defaultProps} userHasAccess={false} />, {
organization,
});

expect(screen.getByRole('button', {name: 'Enable Integration'})).toBeDisabled();
});

it('shows error message on failure', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/integrations/direct-enable/github_copilot/`,
method: 'POST',
statusCode: 400,
body: {detail: 'Integration is already enabled.'},
});

render(<DirectEnableButton {...defaultProps} />, {organization});

await userEvent.click(screen.getByRole('button', {name: 'Enable Integration'}));

expect(await screen.findByText('Failed to enable integration.')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {useMutation} from '@tanstack/react-query';

import {Button} from '@sentry/scraps/button';

import {addErrorMessage} from 'sentry/actionCreators/indicator';
import {t} from 'sentry/locale';
import {getApiUrl} from 'sentry/utils/api/getApiUrl';
import {fetchMutation, useQueryClient} from 'sentry/utils/queryClient';
import {useOrganization} from 'sentry/utils/useOrganization';

import type {AddIntegrationButton} from './addIntegrationButton';

interface DirectEnableButtonProps {
buttonProps: Pick<
React.ComponentProps<typeof AddIntegrationButton>,
'size' | 'priority' | 'disabled' | 'style' | 'data-test-id' | 'icon' | 'buttonText'
>;
providerSlug: string;
userHasAccess: boolean;
}

export function DirectEnableButton({
providerSlug,
buttonProps,
userHasAccess,
}: DirectEnableButtonProps) {
const organization = useOrganization();
const queryClient = useQueryClient();

const {mutate: enable, isPending} = useMutation({
mutationFn: () =>
fetchMutation({
url: `/organizations/${organization.slug}/integrations/direct-enable/${providerSlug}/`,
method: 'POST',
data: {},
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [
getApiUrl(`/organizations/$organizationIdOrSlug/integrations/`, {
path: {organizationIdOrSlug: organization.slug},
}),
],
});
queryClient.invalidateQueries({
queryKey: [
getApiUrl(`/organizations/$organizationIdOrSlug/config/integrations/`, {
path: {organizationIdOrSlug: organization.slug},
}),
],
});
},
onError: () => addErrorMessage(t('Failed to enable integration.')),
});

return (
<Button
{...buttonProps}
disabled={buttonProps.disabled || !userHasAccess || isPending}
busy={isPending}
onClick={() => enable()}
>
{t('Enable Integration')}
</Button>
);
}
Loading
Loading