diff --git a/src/sentry/api/bases/external_actor.py b/src/sentry/api/bases/external_actor.py index 575ad832ab48ae..e2fcda32b669a6 100644 --- a/src/sentry/api/bases/external_actor.py +++ b/src/sentry/api/bases/external_actor.py @@ -25,6 +25,7 @@ AVAILABLE_PROVIDERS = { ExternalProviders.GITHUB, + ExternalProviders.GITHUB_ENTERPRISE, ExternalProviders.GITLAB, ExternalProviders.SLACK, ExternalProviders.MSTEAMS, diff --git a/src/sentry/api/validators/project_codeowners.py b/src/sentry/api/validators/project_codeowners.py index e1fc0c56e6e2f4..87a5ad6064bac7 100644 --- a/src/sentry/api/validators/project_codeowners.py +++ b/src/sentry/api/validators/project_codeowners.py @@ -46,7 +46,11 @@ def validate_codeowners_associations( external_actors = ExternalActor.objects.filter( external_name__in=usernames + team_names, organization_id=project.organization_id, - provider__in=[ExternalProviders.GITHUB.value, ExternalProviders.GITLAB.value], + provider__in=[ + ExternalProviders.GITHUB.value, + ExternalProviders.GITHUB_ENTERPRISE.value, + ExternalProviders.GITLAB.value, + ], ) # Convert CODEOWNERS into IssueOwner syntax diff --git a/src/sentry/integrations/github_enterprise/client.py b/src/sentry/integrations/github_enterprise/client.py index ba3448c3a74ba3..8b4d2876455312 100644 --- a/src/sentry/integrations/github_enterprise/client.py +++ b/src/sentry/integrations/github_enterprise/client.py @@ -7,12 +7,20 @@ class GitHubEnterpriseAppsClient(GitHubClientMixin): integration_name = "github_enterprise" def __init__(self, base_url, integration, app_id, private_key, verify_ssl): - self.base_url = f"https://{base_url}/api/v3" + self.base_url = f"https://{base_url}" self.integration = integration self.app_id = app_id self.private_key = private_key super().__init__(verify_ssl=verify_ssl) + def build_url(self, path: str) -> str: + if path.startswith("/"): + if path == "/graphql": + path = "/api/graphql" + else: + path = "/api/v3/{}".format(path.lstrip("/")) + return super().build_url(path) + def _get_installation_id(self) -> str: return self.integration.metadata["installation_id"] diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index da5138a947cc48..3118f40473ec4f 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import Any +from datetime import timezone +from typing import Any, Mapping, Sequence from urllib.parse import urlparse from django import forms from django.http import HttpResponse from django.utils.translation import gettext_lazy as _ +from isodate import parse_datetime from rest_framework.request import Request from sentry import http @@ -21,7 +23,9 @@ from sentry.integrations.github.issues import GitHubIssueBasic from sentry.integrations.github.utils import get_jwt from sentry.integrations.mixins import RepositoryMixin +from sentry.integrations.mixins.commit_context import CommitContextMixin from sentry.models.integrations.integration import Integration +from sentry.models.repository import Repository from sentry.pipeline import NestedPipelineView, PipelineView from sentry.services.hybrid_cloud.organization import RpcOrganizationSummary from sentry.shared_integrations.constants import ERR_INTERNAL, ERR_UNAUTHORIZED @@ -58,6 +62,19 @@ """, IntegrationFeatures.ISSUE_BASIC, ), + FeatureDescription( + """ + Link your Sentry stack traces back to your GitHub source code with stack + trace linking. + """, + IntegrationFeatures.STACKTRACE_LINK, + ), + FeatureDescription( + """ + Import your GitHub [CODEOWNERS file](https://docs.sentry.io/product/integrations/source-code-mgmt/github/#code-owners) and use it alongside your ownership rules to assign Sentry issues. + """, + IntegrationFeatures.CODEOWNERS, + ), ] @@ -109,8 +126,11 @@ } -class GitHubEnterpriseIntegration(IntegrationInstallation, GitHubIssueBasic, RepositoryMixin): +class GitHubEnterpriseIntegration( + IntegrationInstallation, GitHubIssueBasic, RepositoryMixin, CommitContextMixin +): repo_search = True + codeowners_locations = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"] def get_client(self): base_url = self.model.metadata["domain_name"].split("/")[0] @@ -163,6 +183,59 @@ def message_from_error(self, exc): else: return ERR_INTERNAL + def format_source_url(self, repo: Repository, filepath: str, branch: str) -> str: + # Must format the url ourselves since `check_file` is a head request + # "https://github.example.org/octokit/octokit.rb/blob/master/README.md" + return f"{repo.url}/blob/{branch}/{filepath}" + + def get_commit_context( + self, repo: Repository, filepath: str, ref: str, event_frame: Mapping[str, Any] + ) -> Mapping[str, str] | None: + lineno = event_frame.get("lineno", 0) + if not lineno: + return None + try: + blame_range: Sequence[Mapping[str, Any]] | None = self.get_blame_for_file( + repo, filepath, ref, lineno + ) + + if blame_range is None: + return None + except ApiError as e: + raise e + + try: + commit: Mapping[str, Any] = max( + ( + blame + for blame in blame_range + if blame.get("startingLine", 0) <= lineno <= blame.get("endingLine", 0) + and blame.get("commit", {}).get("committedDate") + ), + key=lambda blame: parse_datetime(blame.get("commit", {}).get("committedDate")), + default={}, + ) + if not commit: + return None + except (ValueError, IndexError): + return None + + commitInfo = commit.get("commit") + if not commitInfo: + return None + else: + committed_date = parse_datetime(commitInfo.get("committedDate")).astimezone( + timezone.utc + ) + + return { + "commitId": commitInfo.get("oid"), + "committedDate": committed_date, + "commitMessage": commitInfo.get("message"), + "commitAuthorName": commitInfo.get("author", {}).get("name"), + "commitAuthorEmail": commitInfo.get("author", {}).get("email"), + } + class InstallationForm(forms.Form): url = forms.CharField( @@ -273,7 +346,14 @@ class GitHubEnterpriseIntegrationProvider(GitHubIntegrationProvider): name = "GitHub Enterprise" metadata = metadata integration_cls = GitHubEnterpriseIntegration - features = frozenset([IntegrationFeatures.COMMITS, IntegrationFeatures.ISSUE_BASIC]) + features = frozenset( + [ + IntegrationFeatures.COMMITS, + IntegrationFeatures.ISSUE_BASIC, + IntegrationFeatures.STACKTRACE_LINK, + IntegrationFeatures.CODEOWNERS, + ] + ) def _make_identity_pipeline_view(self): """ diff --git a/src/sentry/models/integrations/external_actor.py b/src/sentry/models/integrations/external_actor.py index 9d8e75db8d1d5a..97f4c53dcbb8ae 100644 --- a/src/sentry/models/integrations/external_actor.py +++ b/src/sentry/models/integrations/external_actor.py @@ -33,6 +33,7 @@ class ExternalActor(DefaultFieldsModel): (ExternalProviders.MSTEAMS, "msteams"), (ExternalProviders.PAGERDUTY, "pagerduty"), (ExternalProviders.GITHUB, "github"), + (ExternalProviders.GITHUB_ENTERPRISE, "github_enterprise"), (ExternalProviders.GITLAB, "gitlab"), # TODO: do migration to delete this from database (ExternalProviders.CUSTOM, "custom_scm"), diff --git a/src/sentry/types/integrations.py b/src/sentry/types/integrations.py index f522369fd34e4b..99d139e3e6ac4d 100644 --- a/src/sentry/types/integrations.py +++ b/src/sentry/types/integrations.py @@ -15,6 +15,7 @@ class ExternalProviders(ValueEqualityEnum): DISCORD = 140 OPSGENIE = 150 GITHUB = 200 + GITHUB_ENTERPRISE = 201 GITLAB = 210 # TODO: do migration to delete this from database @@ -33,6 +34,7 @@ class ExternalProviderEnum(Enum): DISCORD = "discord" OPSGENIE = "opsgenie" GITHUB = "github" + GITHUB_ENTERPRISE = "github_enterprise" GITLAB = "gitlab" CUSTOM = "custom_scm" @@ -45,6 +47,7 @@ class ExternalProviderEnum(Enum): ExternalProviderEnum.DISCORD: ExternalProviders.DISCORD, ExternalProviderEnum.OPSGENIE: ExternalProviders.OPSGENIE, ExternalProviderEnum.GITHUB: ExternalProviders.GITHUB, + ExternalProviderEnum.GITHUB_ENTERPRISE: ExternalProviders.GITHUB_ENTERPRISE, ExternalProviderEnum.GITLAB: ExternalProviders.GITLAB, ExternalProviderEnum.CUSTOM: ExternalProviders.CUSTOM, } @@ -57,6 +60,7 @@ class ExternalProviderEnum(Enum): ExternalProviders.DISCORD: ExternalProviderEnum.DISCORD.value, ExternalProviders.OPSGENIE: ExternalProviderEnum.OPSGENIE.value, ExternalProviders.GITHUB: ExternalProviderEnum.GITHUB.value, + ExternalProviders.GITHUB_ENTERPRISE: ExternalProviderEnum.GITHUB_ENTERPRISE.value, ExternalProviders.GITLAB: ExternalProviderEnum.GITLAB.value, ExternalProviders.CUSTOM: ExternalProviderEnum.CUSTOM.value, } diff --git a/tests/sentry/integrations/github_enterprise/test_client.py b/tests/sentry/integrations/github_enterprise/test_client.py new file mode 100644 index 00000000000000..29d4eabd4aa29d --- /dev/null +++ b/tests/sentry/integrations/github_enterprise/test_client.py @@ -0,0 +1,152 @@ +import base64 +from unittest import mock + +import pytest +import responses + +from sentry.integrations.github_enterprise.integration import GitHubEnterpriseIntegration +from sentry.models.repository import Repository +from sentry.shared_integrations.exceptions import ApiError +from sentry.testutils.cases import TestCase + +GITHUB_CODEOWNERS = { + "filepath": "CODEOWNERS", + "html_url": "https://github.example.org/Test-Organization/foo/blob/master/CODEOWNERS", + "raw": "docs/* @jianyuan @getsentry/ecosystem\n* @jianyuan\n", +} + + +class GitHubAppsClientTest(TestCase): + base_url = "https://github.example.org/api/v3" + + def setUp(self): + super().setUp() + + patcher_1 = mock.patch( + "sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1" + ) + patcher_1.start() + self.addCleanup(patcher_1.stop) + + patcher_2 = mock.patch( + "sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1" + ) + patcher_2.start() + self.addCleanup(patcher_2.stop) + + integration = self.create_integration( + organization=self.organization, + provider="github_enterprise", + name="Github Test Org", + external_id="1", + metadata={ + "access_token": None, + "expires_at": None, + "icon": "https://github.example.org/avatar.png", + "domain_name": "github.example.org/Test-Organization", + "account_type": "Organization", + "installation_id": "install_id_1", + "installation": { + "client_id": "client_id", + "client_secret": "client_secret", + "id": "2", + "name": "test-app", + "private_key": "private_key", + "url": "github.example.org", + "webhook_secret": "webhook_secret", + "verify_ssl": True, + }, + }, + ) + self.repo = Repository.objects.create( + organization_id=self.organization.id, + name="Test-Organization/foo", + url="https://github.example.org/Test-Organization/foo", + provider="integrations:github_enterprise", + external_id=123, + integration_id=integration.id, + ) + install = integration.get_installation(organization_id=self.organization.id) + assert isinstance(install, GitHubEnterpriseIntegration) + self.install = install + self.gh_client = self.install.get_client() + responses.add( + method=responses.POST, + url=f"{self.base_url}/app/installations/install_id_1/access_tokens", + body='{"token": "12345token", "expires_at": "2030-01-01T00:00:00Z"}', + status=200, + content_type="application/json", + ) + + @responses.activate + def test_check_file(self): + path = "src/sentry/integrations/github/client.py" + version = "master" + url = f"{self.base_url}/repos/{self.repo.name}/contents/{path}?ref={version}" + + responses.add( + method=responses.HEAD, + url=url, + json={"text": 200}, + ) + + resp = self.gh_client.check_file(self.repo, path, version) + assert resp.status_code == 200 + + @responses.activate + def test_check_no_file(self): + path = "src/santry/integrations/github/client.py" + version = "master" + url = f"{self.base_url}/repos/{self.repo.name}/contents/{path}?ref={version}" + + responses.add(method=responses.HEAD, url=url, status=404) + + with pytest.raises(ApiError): + self.gh_client.check_file(self.repo, path, version) + assert responses.calls[1].response.status_code == 404 + + @responses.activate + def test_get_stacktrace_link(self): + path = "/src/sentry/integrations/github/client.py" + version = "master" + url = f"{self.base_url}/repos/{self.repo.name}/contents/{path.lstrip('/')}?ref={version}" + + responses.add( + method=responses.HEAD, + url=url, + json={"text": 200}, + ) + + source_url = self.install.get_stacktrace_link(self.repo, path, "master", version) + assert ( + source_url + == "https://github.example.org/Test-Organization/foo/blob/master/src/sentry/integrations/github/client.py" + ) + + @mock.patch( + "sentry.integrations.github.integration.GitHubIntegration.check_file", + return_value=GITHUB_CODEOWNERS["html_url"], + ) + @responses.activate + def test_get_codeowner_file(self, mock_check_file): + self.config = self.create_code_mapping( + repo=self.repo, + project=self.project, + ) + url = f"{self.base_url}/repos/{self.repo.name}/contents/CODEOWNERS?ref=master" + + responses.add( + method=responses.HEAD, + url=url, + json={"text": 200}, + ) + responses.add( + method=responses.GET, + url=url, + json={"content": base64.b64encode(GITHUB_CODEOWNERS["raw"].encode()).decode("ascii")}, + ) + result = self.install.get_codeowner_file( + self.config.repository, ref=self.config.default_branch + ) + + assert result == GITHUB_CODEOWNERS diff --git a/tests/sentry/integrations/github_enterprise/test_integration.py b/tests/sentry/integrations/github_enterprise/test_integration.py index 72cf6fd0ef111a..30430c42f523af 100644 --- a/tests/sentry/integrations/github_enterprise/test_integration.py +++ b/tests/sentry/integrations/github_enterprise/test_integration.py @@ -1,14 +1,18 @@ +from datetime import datetime, timedelta, timezone from unittest.mock import patch from urllib.parse import parse_qs, urlencode, urlparse import responses +from isodate import parse_datetime from sentry.integrations.github_enterprise import GitHubEnterpriseIntegrationProvider from sentry.models.identity import Identity, IdentityProvider, IdentityStatus from sentry.models.integrations.integration import Integration from sentry.models.integrations.organization_integration import OrganizationIntegration +from sentry.models.repository import Repository +from sentry.silo.base import SiloMode from sentry.testutils.cases import IntegrationTestCase -from sentry.testutils.silo import control_silo_test +from sentry.testutils.silo import assume_test_silo_mode, control_silo_test @control_silo_test(stable=True) @@ -183,3 +187,235 @@ def test_get_repositories_search_param(self, mock_jwtm, _): {"identifier": "test/example", "name": "example", "default_branch": "main"}, {"identifier": "test/exhaust", "name": "exhaust", "default_branch": "main"}, ] + + @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") + @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_get_stacktrace_link_file_exists(self, get_jwt, _): + self.assert_setup_flow() + integration = Integration.objects.get(provider=self.provider.key) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="Test-Organization/foo", + url="https://github.example.org/Test-Organization/foo", + provider="integrations:github_enterprise", + external_id=123, + config={"name": "Test-Organization/foo"}, + integration_id=integration.id, + ) + + path = "README.md" + version = "1234567" + default = "master" + responses.add( + responses.HEAD, + self.base_url + f"/repos/{repo.name}/contents/{path}?ref={version}", + ) + installation = integration.get_installation(self.organization.id) + result = installation.get_stacktrace_link(repo, path, default, version) + + assert result == "https://github.example.org/Test-Organization/foo/blob/1234567/README.md" + + @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") + @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_get_stacktrace_link_file_doesnt_exists(self, get_jwt, _): + self.assert_setup_flow() + integration = Integration.objects.get(provider=self.provider.key) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="Test-Organization/foo", + url="https://github.example.org/Test-Organization/foo", + provider="integrations:github_enterprise", + external_id=123, + config={"name": "Test-Organization/foo"}, + integration_id=integration.id, + ) + path = "README.md" + version = "master" + default = "master" + responses.add( + responses.HEAD, + self.base_url + f"/repos/{repo.name}/contents/{path}?ref={version}", + status=404, + ) + installation = integration.get_installation(self.organization.id) + result = installation.get_stacktrace_link(repo, path, default, version) + + assert not result + + @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") + @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_get_stacktrace_link_no_org_integration(self, get_jwt, _): + self.assert_setup_flow() + integration = Integration.objects.get(provider=self.provider.key) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="Test-Organization/foo", + url="https://github.example.org/Test-Organization/foo", + provider="integrations:github_enterprise", + external_id=123, + config={"name": "Test-Organization/foo"}, + integration_id=integration.id, + ) + path = "README.md" + version = "master" + default = "master" + responses.add( + responses.HEAD, + self.base_url + f"/repos/{repo.name}/contents/{path}?ref={version}", + status=404, + ) + OrganizationIntegration.objects.get( + integration=integration, organization_id=self.organization.id + ).delete() + installation = integration.get_installation(self.organization.id) + result = installation.get_stacktrace_link(repo, path, default, version) + + assert not result + + @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") + @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_get_stacktrace_link_use_default_if_version_404(self, get_jwt, _): + self.assert_setup_flow() + integration = Integration.objects.get(provider=self.provider.key) + + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="Test-Organization/foo", + url="https://github.example.org/Test-Organization/foo", + provider="integrations:github_enterprise", + external_id=123, + config={"name": "Test-Organization/foo"}, + integration_id=integration.id, + ) + path = "README.md" + version = "12345678" + default = "master" + responses.add( + responses.HEAD, + self.base_url + f"/repos/{repo.name}/contents/{path}?ref={version}", + status=404, + ) + responses.add( + responses.HEAD, + self.base_url + f"/repos/{repo.name}/contents/{path}?ref={default}", + ) + installation = integration.get_installation(self.organization.id) + result = installation.get_stacktrace_link(repo, path, default, version) + + assert result == "https://github.example.org/Test-Organization/foo/blob/master/README.md" + + @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") + @patch("sentry.integrations.github_enterprise.client.get_jwt", return_value="jwt_token_1") + @responses.activate + def test_get_commit_context(self, get_jwt, _): + self.assert_setup_flow() + integration = Integration.objects.get(provider=self.provider.key) + with assume_test_silo_mode(SiloMode.REGION): + repo = Repository.objects.create( + organization_id=self.organization.id, + name="Test-Organization/foo", + url="https://github.example.org/Test-Organization/foo", + provider="integrations:github_enterprise", + external_id=123, + config={"name": "Test-Organization/foo"}, + integration_id=integration.id, + ) + + installation = integration.get_installation(self.organization.id) + + filepath = "sentry/tasks.py" + event_frame = { + "function": "handle_set_commits", + "abs_path": "/usr/src/sentry/src/sentry/tasks.py", + "module": "sentry.tasks", + "in_app": True, + "lineno": 30, + "filename": "sentry/tasks.py", + } + ref = "master" + query = f"""query {{ + repository(name: "foo", owner: "Test-Organization") {{ + ref(qualifiedName: "{ref}") {{ + target {{ + ... on Commit {{ + blame(path: "{filepath}") {{ + ranges {{ + commit {{ + oid + author {{ + name + email + }} + message + committedDate + }} + startingLine + endingLine + age + }} + }} + }} + }} + }} + }} + }}""" + commit_date = (datetime.now(tz=timezone.utc) - timedelta(days=4)).strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + responses.add( + method=responses.POST, + url="https://github.example.org/api/graphql", + json={ + "query": query, + "data": { + "repository": { + "ref": { + "target": { + "blame": { + "ranges": [ + { + "commit": { + "oid": "d42409d56517157c48bf3bd97d3f75974dde19fb", + "author": { + "date": commit_date, + "email": "nisanthan.nanthakumar@sentry.io", + "name": "Nisanthan Nanthakumar", + }, + "message": "Add installation instructions", + "committedDate": commit_date, + }, + "startingLine": 30, + "endingLine": 30, + "age": 3, + } + ] + } + } + } + } + }, + }, + content_type="application/json", + ) + commit_context = installation.get_commit_context(repo, filepath, ref, event_frame) + + commit_context_expected = { + "commitId": "d42409d56517157c48bf3bd97d3f75974dde19fb", + "committedDate": parse_datetime(commit_date), + "commitMessage": "Add installation instructions", + "commitAuthorName": "Nisanthan Nanthakumar", + "commitAuthorEmail": "nisanthan.nanthakumar@sentry.io", + } + + assert commit_context == commit_context_expected