mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
1030 lines
40 KiB
Python
1030 lines
40 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from unittest import mock
|
|
from unittest.mock import patch
|
|
from urllib.parse import urlencode, urlparse
|
|
|
|
import pytest
|
|
import responses
|
|
from django.urls import reverse
|
|
|
|
import sentry
|
|
from sentry.api.utils import generate_organization_url
|
|
from sentry.constants import ObjectStatus
|
|
from sentry.integrations.github import (
|
|
API_ERRORS,
|
|
MINIMUM_REQUESTS,
|
|
GitHubIntegrationProvider,
|
|
client,
|
|
)
|
|
from sentry.integrations.mixins.commit_context import CommitInfo, FileBlameInfo, SourceLineInfo
|
|
from sentry.integrations.utils.code_mapping import Repo, RepoTree
|
|
from sentry.models.integrations.integration import Integration
|
|
from sentry.models.integrations.organization_integration import OrganizationIntegration
|
|
from sentry.models.project import Project
|
|
from sentry.models.repository import Repository
|
|
from sentry.plugins.base import plugins
|
|
from sentry.plugins.bases.issue2 import IssueTrackingPlugin2
|
|
from sentry.shared_integrations.exceptions import ApiError
|
|
from sentry.silo.base import SiloMode
|
|
from sentry.testutils.cases import IntegrationTestCase
|
|
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
|
|
from sentry.utils.cache import cache
|
|
|
|
TREE_RESPONSES = {
|
|
"xyz": {"status_code": 200, "body": {"tree": [{"path": "src/xyz.py", "type": "blob"}]}},
|
|
"foo": {
|
|
"status_code": 200,
|
|
"body": {
|
|
# The latest sha for a specific branch
|
|
"sha": "a4e587563cb5dbb46192b5962cbadc8c532a8455",
|
|
"tree": [
|
|
{
|
|
"path": ".artifacts",
|
|
"mode": "040000",
|
|
"type": "tree", # A directory
|
|
"sha": "44813f92a105143eff565d14d2054c2ea90eb62e",
|
|
"url": "https://api.github.com/repos/Test-Organization/foo/git/trees/44813f92a105143eff565d14d2054c2ea90eb62e",
|
|
},
|
|
{
|
|
"path": "src/sentry/api/endpoints/auth_login.py",
|
|
"mode": "100644",
|
|
"type": "blob", # A file
|
|
"sha": "517899e22ada047336cab4ecbbf8c27b151f190c",
|
|
"size": 2711,
|
|
"url": "https://api.github.com/repos/Test-Organization/foo/git/blobs/517899e22ada047336cab4ecbbf8c27b151f190c",
|
|
},
|
|
],
|
|
"url": "https://api.github.com/repos/Test-Organization/foo/git/trees/a4e587563cb5dbb46192b5962cbadc8c532a8455",
|
|
"truncated": False, # If this is True, we have reached the limit of what we can get with the recursive option
|
|
},
|
|
},
|
|
"bar": {
|
|
"status_code": 409,
|
|
"body": {"message": "Git Repository is empty."},
|
|
},
|
|
"baz": {
|
|
"status_code": 404,
|
|
"body": {"message": "Not Found"},
|
|
},
|
|
}
|
|
|
|
|
|
class GitHubPlugin(IssueTrackingPlugin2):
|
|
slug = "github"
|
|
name = "GitHub Mock Plugin"
|
|
conf_key = slug
|
|
|
|
|
|
@control_silo_test
|
|
class GitHubIntegrationTest(IntegrationTestCase):
|
|
provider = GitHubIntegrationProvider
|
|
base_url = "https://api.github.com"
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
self.installation_id = "install_1"
|
|
self.user_id = "user_1"
|
|
self.app_id = "app_1"
|
|
self.access_token = "xxxxx-xxxxxxxxx-xxxxxxxxxx-xxxxxxxxxxxx"
|
|
self.expires_at = "3000-01-01T00:00:00Z"
|
|
|
|
self._stub_github()
|
|
plugins.register(GitHubPlugin)
|
|
|
|
def tearDown(self):
|
|
responses.reset()
|
|
plugins.unregister(GitHubPlugin)
|
|
super().tearDown()
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def stub_get_jwt(self):
|
|
with mock.patch.object(client, "get_jwt", return_value="jwt_token_1"):
|
|
yield
|
|
|
|
def _stub_github(self):
|
|
"""This stubs the calls related to a Github App"""
|
|
self.gh_org = "Test-Organization"
|
|
pp = 1
|
|
|
|
responses.add(
|
|
responses.POST,
|
|
self.base_url + f"/app/installations/{self.installation_id}/access_tokens",
|
|
json={"token": self.access_token, "expires_at": self.expires_at},
|
|
)
|
|
|
|
repositories: dict[str, Any] = {
|
|
"xyz": {
|
|
"full_name": "Test-Organization/xyz",
|
|
"default_branch": "master",
|
|
},
|
|
"foo": {
|
|
"id": 1296269,
|
|
"name": "foo",
|
|
"full_name": "Test-Organization/foo",
|
|
"default_branch": "master",
|
|
},
|
|
"bar": {
|
|
"id": 9876574,
|
|
"name": "bar",
|
|
"full_name": "Test-Organization/bar",
|
|
"default_branch": "main",
|
|
},
|
|
"baz": {
|
|
"id": 1276555,
|
|
"name": "baz",
|
|
"full_name": "Test-Organization/baz",
|
|
"default_branch": "master",
|
|
},
|
|
"archived": {
|
|
"archived": True,
|
|
},
|
|
}
|
|
self.repositories = repositories
|
|
len_repos = len(repositories)
|
|
api_url = f"{self.base_url}/installation/repositories"
|
|
first = f'<{api_url}?per_page={pp}&page=1>; rel="first"'
|
|
last = f'<{api_url}?per_page={pp}&page={len_repos}>; rel="last"'
|
|
|
|
def gen_link(page: int, text: str) -> str:
|
|
return f'<{api_url}?per_page={pp}&page={page}>; rel="{text}"'
|
|
|
|
responses.add(
|
|
responses.GET,
|
|
url=api_url,
|
|
match=[responses.matchers.query_param_matcher({"per_page": pp})],
|
|
json={"total_count": len_repos, "repositories": [repositories["foo"]]},
|
|
headers={"link": ", ".join([gen_link(2, "next"), last])},
|
|
)
|
|
responses.add(
|
|
responses.GET,
|
|
url=self.base_url + "/installation/repositories",
|
|
match=[responses.matchers.query_param_matcher({"per_page": pp, "page": 2})],
|
|
json={"total_count": len_repos, "repositories": [repositories["bar"]]},
|
|
headers={"link": ", ".join([gen_link(1, "prev"), gen_link(3, "next"), last, first])},
|
|
)
|
|
responses.add(
|
|
responses.GET,
|
|
url=self.base_url + "/installation/repositories",
|
|
match=[responses.matchers.query_param_matcher({"per_page": pp, "page": 3})],
|
|
json={"total_count": len_repos, "repositories": [repositories["baz"]]},
|
|
headers={"link": ", ".join([gen_link(2, "prev"), first])},
|
|
)
|
|
# This is for when we're not testing the pagination logic
|
|
responses.add(
|
|
responses.GET,
|
|
url=self.base_url + "/installation/repositories",
|
|
match=[responses.matchers.query_param_matcher({"per_page": 100})],
|
|
json={
|
|
"total_count": len(repositories),
|
|
"repositories": [repo for repo in repositories.values()],
|
|
},
|
|
)
|
|
|
|
responses.add(
|
|
responses.GET,
|
|
self.base_url + f"/app/installations/{self.installation_id}",
|
|
json={
|
|
"id": self.installation_id,
|
|
"app_id": self.app_id,
|
|
"account": {
|
|
"login": "Test Organization",
|
|
"avatar_url": "http://example.com/avatar.png",
|
|
"html_url": "https://github.com/Test-Organization",
|
|
"type": "Organization",
|
|
},
|
|
},
|
|
)
|
|
|
|
responses.add(responses.GET, self.base_url + "/repos/Test-Organization/foo/hooks", json=[])
|
|
|
|
# Logic to get a tree for a repo
|
|
# https://api.github.com/repos/getsentry/sentry/git/trees/master?recursive=1
|
|
for repo_name, values in TREE_RESPONSES.items():
|
|
responses.add(
|
|
responses.GET,
|
|
f"{self.base_url}/repos/Test-Organization/{repo_name}/git/trees/{repositories[repo_name]['default_branch']}?recursive=1",
|
|
json=values["body"],
|
|
status=values["status_code"],
|
|
)
|
|
|
|
def assert_setup_flow(self):
|
|
resp = self.client.get(self.init_path)
|
|
assert resp.status_code == 302
|
|
redirect = urlparse(resp["Location"])
|
|
assert redirect.scheme == "https"
|
|
assert redirect.netloc == "github.com"
|
|
assert redirect.path == "/apps/sentry-test-app"
|
|
|
|
# App installation ID is provided
|
|
resp = self.client.get(
|
|
"{}?{}".format(self.setup_path, urlencode({"installation_id": self.installation_id}))
|
|
)
|
|
|
|
auth_header = responses.calls[0].request.headers["Authorization"]
|
|
assert auth_header == "Bearer jwt_token_1"
|
|
|
|
self.assertDialogSuccess(resp)
|
|
return resp
|
|
|
|
@responses.activate
|
|
def test_plugin_migration(self):
|
|
with assume_test_silo_mode(SiloMode.REGION):
|
|
accessible_repo = Repository.objects.create(
|
|
organization_id=self.organization.id,
|
|
name="Test-Organization/foo",
|
|
url="https://github.com/Test-Organization/foo",
|
|
provider="github",
|
|
external_id=123,
|
|
config={"name": "Test-Organization/foo"},
|
|
)
|
|
|
|
inaccessible_repo = Repository.objects.create(
|
|
organization_id=self.organization.id,
|
|
name="Not-My-Org/other",
|
|
provider="github",
|
|
external_id=321,
|
|
config={"name": "Not-My-Org/other"},
|
|
)
|
|
|
|
with self.tasks():
|
|
self.assert_setup_flow()
|
|
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
|
|
with assume_test_silo_mode(SiloMode.REGION):
|
|
# Updates the existing Repository to belong to the new Integration
|
|
assert Repository.objects.get(id=accessible_repo.id).integration_id == integration.id
|
|
# Doesn't touch Repositories not accessible by the new Integration
|
|
assert Repository.objects.get(id=inaccessible_repo.id).integration_id is None
|
|
|
|
@responses.activate
|
|
def test_basic_flow(self):
|
|
with self.tasks():
|
|
self.assert_setup_flow()
|
|
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
|
|
assert integration.external_id == self.installation_id
|
|
assert integration.name == "Test Organization"
|
|
assert integration.metadata == {
|
|
"access_token": self.access_token,
|
|
# The metadata doesn't get saved with the timezone "Z" character
|
|
"expires_at": self.expires_at[:-1],
|
|
"icon": "http://example.com/avatar.png",
|
|
"domain_name": "github.com/Test-Organization",
|
|
"account_type": "Organization",
|
|
}
|
|
oi = OrganizationIntegration.objects.get(
|
|
integration=integration, organization_id=self.organization.id
|
|
)
|
|
assert oi.config == {}
|
|
|
|
@responses.activate
|
|
def test_github_installed_on_another_org(self):
|
|
self._stub_github()
|
|
# First installation should be successful
|
|
self.assert_setup_flow()
|
|
|
|
# Second installation attempt for same Github account should fail
|
|
self.organization_2 = self.create_organization(name="petal", owner=self.user)
|
|
# Use the same Github installation_id
|
|
self.init_path_2 = "{}?{}".format(
|
|
reverse(
|
|
"sentry-organization-integrations-setup",
|
|
kwargs={
|
|
"organization_slug": self.organization_2.slug,
|
|
"provider_id": self.provider.key,
|
|
},
|
|
),
|
|
urlencode({"installation_id": self.installation_id}),
|
|
)
|
|
with self.feature({"organizations:customer-domains": [self.organization_2.slug]}):
|
|
resp = self.client.get(self.init_path_2)
|
|
self.assertTemplateUsed(
|
|
resp, "sentry/integrations/github-integration-exists-on-another-org.html"
|
|
)
|
|
assert (
|
|
b'{"success":false,"data":{"error":"Github installed on another Sentry organization."}}'
|
|
in resp.content
|
|
)
|
|
assert (
|
|
b"It seems that your GitHub account has been installed on another Sentry organization. Please uninstall and try again."
|
|
in resp.content
|
|
)
|
|
assert b'window.opener.postMessage({"success":false' in resp.content
|
|
assert (
|
|
f', "{generate_organization_url(self.organization_2.slug)}");'.encode()
|
|
in resp.content
|
|
)
|
|
|
|
# Delete the Integration
|
|
integration = Integration.objects.get(external_id=self.installation_id)
|
|
for oi in OrganizationIntegration.objects.filter(
|
|
organization_id=self.organization.id, integration=integration
|
|
):
|
|
oi.delete()
|
|
integration.delete()
|
|
|
|
# Try again and should be successful
|
|
resp = self.client.get(self.init_path_2)
|
|
self.assertDialogSuccess(resp)
|
|
integration = Integration.objects.get(external_id=self.installation_id)
|
|
assert integration.provider == "github"
|
|
assert OrganizationIntegration.objects.filter(
|
|
organization_id=self.organization_2.id, integration=integration
|
|
).exists()
|
|
|
|
@responses.activate
|
|
def test_installation_not_found(self):
|
|
# Add a 404 for an org to responses
|
|
responses.replace(
|
|
responses.GET, self.base_url + f"/app/installations/{self.installation_id}", status=404
|
|
)
|
|
# Attempt to install integration
|
|
resp = self.client.get(
|
|
"{}?{}".format(self.setup_path, urlencode({"installation_id": self.installation_id}))
|
|
)
|
|
assert b"The GitHub installation could not be found." in resp.content
|
|
|
|
@responses.activate
|
|
def test_reinstall_flow(self):
|
|
self._stub_github()
|
|
self.assert_setup_flow()
|
|
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
integration.update(status=ObjectStatus.DISABLED)
|
|
assert integration.status == ObjectStatus.DISABLED
|
|
assert integration.external_id == self.installation_id
|
|
|
|
resp = self.client.get(
|
|
"{}?{}".format(self.init_path, urlencode({"reinstall_id": integration.id}))
|
|
)
|
|
|
|
assert resp.status_code == 302
|
|
redirect = urlparse(resp["Location"])
|
|
assert redirect.scheme == "https"
|
|
assert redirect.netloc == "github.com"
|
|
assert redirect.path == "/apps/sentry-test-app"
|
|
|
|
# New Installation
|
|
self.installation_id = "install_2"
|
|
|
|
self._stub_github()
|
|
|
|
resp = self.client.get(
|
|
"{}?{}".format(self.setup_path, urlencode({"installation_id": self.installation_id}))
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
|
|
auth_header = responses.calls[0].request.headers["Authorization"]
|
|
assert auth_header == "Bearer jwt_token_1"
|
|
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
assert integration.status == ObjectStatus.ACTIVE
|
|
assert integration.external_id == self.installation_id
|
|
|
|
@responses.activate
|
|
def test_disable_plugin_when_fully_migrated(self):
|
|
self._stub_github()
|
|
|
|
with assume_test_silo_mode(SiloMode.REGION):
|
|
project = Project.objects.create(organization_id=self.organization.id)
|
|
|
|
plugin = plugins.get("github")
|
|
plugin.enable(project)
|
|
|
|
# Accessible to new Integration - mocked in _stub_github
|
|
Repository.objects.create(
|
|
organization_id=self.organization.id,
|
|
name="Test-Organization/foo",
|
|
url="https://github.com/Test-Organization/foo",
|
|
provider="github",
|
|
external_id="123",
|
|
config={"name": "Test-Organization/foo"},
|
|
)
|
|
|
|
# Enabled before
|
|
assert "github" in [p.slug for p in plugins.for_project(project)]
|
|
|
|
with self.tasks():
|
|
self.assert_setup_flow()
|
|
|
|
# Disabled after Integration installed
|
|
assert "github" not in [p.slug for p in plugins.for_project(project)]
|
|
|
|
@responses.activate
|
|
def test_get_repositories_search_param(self):
|
|
with self.tasks():
|
|
self.assert_setup_flow()
|
|
|
|
querystring = urlencode({"q": "org:Test Organization ex"})
|
|
responses.add(
|
|
responses.GET,
|
|
f"{self.base_url}/search/repositories?{querystring}",
|
|
json={
|
|
"items": [
|
|
{"name": "example", "full_name": "test/example", "default_branch": "master"},
|
|
{"name": "exhaust", "full_name": "test/exhaust", "default_branch": "master"},
|
|
]
|
|
},
|
|
)
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
installation = integration.get_installation(self.organization.id)
|
|
# This searches for any repositories matching the term 'ex'
|
|
result = installation.get_repositories("ex")
|
|
assert result == [
|
|
{"identifier": "test/example", "name": "example", "default_branch": "master"},
|
|
{"identifier": "test/exhaust", "name": "exhaust", "default_branch": "master"},
|
|
]
|
|
|
|
@responses.activate
|
|
def test_get_repositories_all_and_pagination(self):
|
|
"""Fetch all repositories and test the pagination logic."""
|
|
with self.tasks():
|
|
self.assert_setup_flow()
|
|
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
installation = integration.get_installation(self.organization.id)
|
|
|
|
with patch.object(sentry.integrations.github.client.GitHubClientMixin, "page_size", 1):
|
|
result = installation.get_repositories(fetch_max_pages=True)
|
|
assert result == [
|
|
{"name": "foo", "identifier": "Test-Organization/foo", "default_branch": "master"},
|
|
{"name": "bar", "identifier": "Test-Organization/bar", "default_branch": "main"},
|
|
{"name": "baz", "identifier": "Test-Organization/baz", "default_branch": "master"},
|
|
]
|
|
|
|
@responses.activate
|
|
def test_get_repositories_only_first_page(self):
|
|
"""Fetch all repositories and test the pagination logic."""
|
|
with self.tasks():
|
|
self.assert_setup_flow()
|
|
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
installation = integration.get_installation(self.organization.id)
|
|
|
|
with patch.object(sentry.integrations.github.client.GitHubClientMixin, "page_size", 1):
|
|
result = installation.get_repositories()
|
|
assert result == [
|
|
{"name": "foo", "identifier": "Test-Organization/foo", "default_branch": "master"},
|
|
]
|
|
|
|
@responses.activate
|
|
def test_get_stacktrace_link_file_exists(self):
|
|
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.com/Test-Organization/foo",
|
|
provider="integrations:github",
|
|
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.com/Test-Organization/foo/blob/1234567/README.md"
|
|
|
|
@responses.activate
|
|
def test_get_stacktrace_link_file_doesnt_exists(self):
|
|
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.com/Test-Organization/foo",
|
|
provider="integrations:github",
|
|
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
|
|
|
|
@responses.activate
|
|
def test_get_stacktrace_link_no_org_integration(self):
|
|
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.com/Test-Organization/foo",
|
|
provider="integrations:github",
|
|
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
|
|
|
|
@responses.activate
|
|
def test_get_stacktrace_link_use_default_if_version_404(self):
|
|
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.com/Test-Organization/foo",
|
|
provider="integrations:github",
|
|
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.com/Test-Organization/foo/blob/master/README.md"
|
|
|
|
@responses.activate
|
|
def test_get_message_from_error(self):
|
|
self.assert_setup_flow()
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
installation = integration.get_installation(self.organization.id)
|
|
base_error = f"Error Communicating with GitHub (HTTP 404): {API_ERRORS[404]}"
|
|
assert (
|
|
installation.message_from_error(
|
|
ApiError("Not Found", code=404, url="https://api.github.com/repos/scefali")
|
|
)
|
|
== base_error
|
|
)
|
|
url = "https://api.github.com/repos/scefali/sentry-integration-example/compare/2adcab794f6f57efa8aa84de68a724e728395792...e208ee2d71e8426522f95efbdae8630fa66499ab"
|
|
assert (
|
|
installation.message_from_error(ApiError("Not Found", code=404, url=url))
|
|
== base_error
|
|
+ f" Please also confirm that the commits associated with the following URL have been pushed to GitHub: {url}"
|
|
)
|
|
|
|
@responses.activate
|
|
def test_github_prevent_install_until_pending_deletion_is_complete(self):
|
|
self._stub_github()
|
|
# First installation should be successful
|
|
self.assert_setup_flow()
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
oi = OrganizationIntegration.objects.get(
|
|
integration=integration, organization_id=self.organization.id
|
|
)
|
|
# set installation to pending deletion
|
|
oi.status = ObjectStatus.PENDING_DELETION
|
|
oi.save()
|
|
|
|
# New Installation
|
|
self.installation_id = "install_2"
|
|
|
|
self._stub_github()
|
|
|
|
with self.feature({"organizations:customer-domains": [self.organization.slug]}):
|
|
resp = self.client.get(
|
|
"{}?{}".format(self.init_path, urlencode({"installation_id": self.installation_id}))
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
self.assertTemplateUsed(resp, "sentry/integrations/integration-pending-deletion.html")
|
|
|
|
assert b'window.opener.postMessage({"success":false' in resp.content
|
|
assert f', "{generate_organization_url(self.organization.slug)}");'.encode() in resp.content
|
|
|
|
# Assert payload returned to main window
|
|
assert (
|
|
b'{"success":false,"data":{"error":"GitHub installation pending deletion."}}'
|
|
in resp.content
|
|
)
|
|
|
|
# Delete the original Integration
|
|
oi.delete()
|
|
integration.delete()
|
|
|
|
# Try again and should be successful
|
|
resp = self.client.get(
|
|
"{}?{}".format(self.init_path, urlencode({"installation_id": self.installation_id}))
|
|
)
|
|
self.assertDialogSuccess(resp)
|
|
integration = Integration.objects.get(external_id=self.installation_id)
|
|
assert integration.provider == "github"
|
|
assert OrganizationIntegration.objects.filter(
|
|
organization_id=self.organization.id, integration=integration
|
|
).exists()
|
|
|
|
def set_rate_limit(
|
|
self, remaining=MINIMUM_REQUESTS + 100, limit=5000, json_body=None, status=200
|
|
):
|
|
"""Helper class to set the rate limit.
|
|
A status code different than 200 requires a json_body
|
|
"""
|
|
response_json = (
|
|
json_body
|
|
if status != 200
|
|
else {
|
|
"resources": {
|
|
"core": {"limit": limit, "remaining": remaining, "used": "foo", "reset": 123},
|
|
"graphql": {
|
|
"limit": limit,
|
|
"remaining": remaining,
|
|
"used": "foo",
|
|
"reset": 123,
|
|
},
|
|
}
|
|
}
|
|
)
|
|
# upsert: it calls add() if not existant, otherwise, it calls replace
|
|
responses.upsert(
|
|
responses.GET, "https://api.github.com/rate_limit", json=response_json, status=status
|
|
)
|
|
|
|
def get_installation_helper(self):
|
|
with self.tasks():
|
|
self.assert_setup_flow() # This somehow creates the integration
|
|
|
|
integration = Integration.objects.get(provider=self.provider.key)
|
|
installation = integration.get_installation(self.organization.id)
|
|
return installation
|
|
|
|
def _expected_trees(self, repo_info_list=None):
|
|
result = {}
|
|
# bar and baz are defined to fail, thus, do not show up in the default case
|
|
list = repo_info_list or [
|
|
("xyz", "master", ["src/xyz.py"]),
|
|
("foo", "master", ["src/sentry/api/endpoints/auth_login.py"]),
|
|
]
|
|
for repo, branch, files in list:
|
|
result[f"{self.gh_org}/{repo}"] = RepoTree(Repo(f"{self.gh_org}/{repo}", branch), files)
|
|
return result
|
|
|
|
def _expected_cached_repos(self):
|
|
return [
|
|
{"full_name": f"{self.gh_org}/xyz", "default_branch": "master"},
|
|
{"full_name": f"{self.gh_org}/foo", "default_branch": "master"},
|
|
{"full_name": f"{self.gh_org}/bar", "default_branch": "main"},
|
|
{"full_name": f"{self.gh_org}/baz", "default_branch": "master"},
|
|
]
|
|
|
|
@responses.activate
|
|
def test_get_trees_for_org_works(self):
|
|
"""Fetch the tree representation of a repo"""
|
|
installation = self.get_installation_helper()
|
|
cache.clear()
|
|
self.set_rate_limit()
|
|
expected_trees = self._expected_trees()
|
|
repos_key = "githubtrees:repositories:Test-Organization"
|
|
repo_key = lambda x: f"github:repo:Test-Organization/{x}:source-code"
|
|
# Check that the cache is clear
|
|
assert cache.get(repos_key) is None
|
|
assert cache.get(repo_key("foo")) is None
|
|
|
|
trees = installation.get_trees_for_org()
|
|
|
|
assert cache.get(repos_key) == self._expected_cached_repos()
|
|
assert cache.get(repo_key("foo")) == ["src/sentry/api/endpoints/auth_login.py"]
|
|
assert trees == expected_trees
|
|
|
|
# Calling a second time should produce the same results
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == expected_trees
|
|
|
|
@responses.activate
|
|
def test_get_trees_for_org_prevent_exhaustion_some_repos(self):
|
|
"""Some repos will hit the network but the rest will grab from the cache."""
|
|
repos_key = "githubtrees:repositories:Test-Organization"
|
|
cache.clear()
|
|
installation = self.get_installation_helper()
|
|
expected_trees = self._expected_trees(
|
|
[
|
|
("xyz", "master", ["src/xyz.py"]),
|
|
# foo will have no files because we will hit the minimum remaining requests floor
|
|
("foo", "master", []),
|
|
("bar", "main", []),
|
|
("baz", "master", []),
|
|
]
|
|
)
|
|
|
|
with patch("sentry.integrations.github.client.MINIMUM_REQUESTS", new=5, autospec=False):
|
|
# We start with one request left before reaching the minimum remaining requests floor
|
|
self.set_rate_limit(remaining=6)
|
|
assert cache.get(repos_key) is None
|
|
trees = installation.get_trees_for_org()
|
|
|
|
assert trees == expected_trees
|
|
assert cache.get(repos_key) == self._expected_cached_repos()
|
|
|
|
# Another call should not make us loose the files for xyz
|
|
self.set_rate_limit(remaining=5)
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == expected_trees # xyz will have files but not foo
|
|
|
|
# We reset the remaining values
|
|
self.set_rate_limit(remaining=20)
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == self._expected_trees(
|
|
[
|
|
("xyz", "master", ["src/xyz.py"]),
|
|
# Now that the rate limit is reset we should get files for foo
|
|
("foo", "master", ["src/sentry/api/endpoints/auth_login.py"]),
|
|
]
|
|
)
|
|
|
|
@responses.activate
|
|
def test_get_trees_for_org_rate_limit_401(self):
|
|
"""Sometimes the rate limit API fails from the get go."""
|
|
# Generic test set up
|
|
cache.clear() # TODO: Investigate why it did not work in the setUp method
|
|
installation = self.get_installation_helper()
|
|
|
|
# None of the repos will have any files since rate limit will fail
|
|
# with a 401 response (which makes no sense)
|
|
self.set_rate_limit(json_body={"message": "Bad credentials"}, status=401)
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == self._expected_trees(
|
|
[
|
|
("xyz", "master", []),
|
|
("foo", "master", []),
|
|
("bar", "main", []),
|
|
("baz", "master", []),
|
|
]
|
|
)
|
|
|
|
# This time the rate limit will not fail, thus, it will fetch the trees
|
|
self.set_rate_limit()
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == self._expected_trees(
|
|
[
|
|
("xyz", "master", ["src/xyz.py"]),
|
|
("foo", "master", ["src/sentry/api/endpoints/auth_login.py"]),
|
|
]
|
|
)
|
|
|
|
# This time we will get a 401 but be will load from the cache (unlike the first time)
|
|
self.set_rate_limit(json_body={"message": "Bad credentials"}, status=401)
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == self._expected_trees(
|
|
[
|
|
("xyz", "master", ["src/xyz.py"]),
|
|
("foo", "master", ["src/sentry/api/endpoints/auth_login.py"]),
|
|
("bar", "main", []),
|
|
("baz", "master", []),
|
|
]
|
|
)
|
|
|
|
@responses.activate
|
|
def test_get_trees_for_org_makes_API_requests_before_MAX_CONNECTION_ERRORS_is_hit(self):
|
|
"""
|
|
If some requests fail, but `MAX_CONNECTION_ERRORS` isn't hit, requests will continue
|
|
to be made to the API.
|
|
"""
|
|
installation = self.get_installation_helper()
|
|
self.set_rate_limit()
|
|
|
|
# Given that below we mock MAX_CONNECTION_ERRORS to be 2, the error we hit here
|
|
# should NOT force the remaining repos to pull from the cache.
|
|
responses.replace(
|
|
responses.GET,
|
|
f"{self.base_url}/repos/Test-Organization/xyz/git/trees/master?recursive=1",
|
|
body=ApiError("Server Error"),
|
|
)
|
|
|
|
# Clear the cache so we can tell when we're pulling from it rather than from an
|
|
# API call
|
|
cache.clear()
|
|
|
|
with patch(
|
|
"sentry.integrations.github.client.MAX_CONNECTION_ERRORS",
|
|
new=2,
|
|
):
|
|
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == self._expected_trees(
|
|
[
|
|
# xyz is missing because its request errors
|
|
# foo has data because its API request is made in spite of xyz's error
|
|
("foo", "master", ["src/sentry/api/endpoints/auth_login.py"]),
|
|
# bar and baz are missing because their API requests throw errors for
|
|
# other reasons in the default mock responses
|
|
]
|
|
)
|
|
|
|
@responses.activate
|
|
def test_get_trees_for_org_falls_back_to_cache_once_MAX_CONNECTION_ERRORS_is_hit(self):
|
|
"""Once `MAX_CONNECTION_ERRORS` requests fail, the rest will grab from the cache."""
|
|
installation = self.get_installation_helper()
|
|
self.set_rate_limit()
|
|
|
|
# Given that below we mock MAX_CONNECTION_ERRORS to be 1, the error we hit here
|
|
# should force the remaining repos to pull from the cache.
|
|
responses.replace(
|
|
responses.GET,
|
|
f"{self.base_url}/repos/Test-Organization/xyz/git/trees/master?recursive=1",
|
|
body=ApiError("Server Error"),
|
|
)
|
|
|
|
# Clear the cache so we can tell when we're pulling from it rather than from an
|
|
# API call
|
|
cache.clear()
|
|
|
|
with patch(
|
|
"sentry.integrations.github.client.MAX_CONNECTION_ERRORS",
|
|
new=1,
|
|
):
|
|
|
|
trees = installation.get_trees_for_org()
|
|
assert trees == self._expected_trees(
|
|
[
|
|
# xyz isn't here because the request errors out.
|
|
# foo, bar, and baz are here but have no files, because xyz's error
|
|
# caused us to pull from the empty cache
|
|
("foo", "master", []),
|
|
("bar", "main", []),
|
|
("baz", "master", []),
|
|
]
|
|
)
|
|
|
|
@responses.activate
|
|
def test_get_commit_context_all_frames(self):
|
|
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.com/Test-Organization/foo",
|
|
provider="github",
|
|
external_id=123,
|
|
config={"name": "Test-Organization/foo"},
|
|
integration_id=integration.id,
|
|
)
|
|
|
|
self.set_rate_limit()
|
|
installation = integration.get_installation(self.organization.id)
|
|
|
|
file = SourceLineInfo(
|
|
path="src/github.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=repo,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
|
|
responses.add(
|
|
responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"data": {
|
|
"repository0": {
|
|
"ref0": {
|
|
"target": {
|
|
"blame0": {
|
|
"ranges": [
|
|
{
|
|
"commit": {
|
|
"oid": "123",
|
|
"author": {
|
|
"name": "Foo",
|
|
"email": "[email protected]",
|
|
},
|
|
"message": "hello",
|
|
"committedDate": "2023-01-01T00:00:00Z",
|
|
},
|
|
"startingLine": 10,
|
|
"endingLine": 15,
|
|
"age": 0,
|
|
},
|
|
]
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
content_type="application/json",
|
|
status=200,
|
|
)
|
|
|
|
response = installation.get_commit_context_all_frames([file], extra={})
|
|
|
|
assert response == [
|
|
FileBlameInfo(
|
|
**asdict(file),
|
|
commit=CommitInfo(
|
|
commitId="123",
|
|
commitMessage="hello",
|
|
committedDate=datetime(2023, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
commitAuthorEmail="[email protected]",
|
|
commitAuthorName="Foo",
|
|
),
|
|
)
|
|
]
|
|
|
|
@responses.activate
|
|
def test_source_url_matches(self):
|
|
installation = self.get_installation_helper()
|
|
|
|
test_cases = [
|
|
(
|
|
"https://github.com/Test-Organization/sentry/blob/master/src/sentry/integrations/github/integration.py",
|
|
True,
|
|
),
|
|
(
|
|
"https://notgithub.com/Test-Organization/sentry/blob/master/src/sentry/integrations/github/integration.py",
|
|
False,
|
|
),
|
|
("https://jianyuan.io", False),
|
|
]
|
|
for source_url, matches in test_cases:
|
|
assert installation.source_url_matches(source_url) == matches
|
|
|
|
@responses.activate
|
|
def test_extract_branch_from_source_url(self):
|
|
installation = self.get_installation_helper()
|
|
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/repo",
|
|
url="https://github.com/Test-Organization/repo",
|
|
provider="integrations:github",
|
|
external_id=123,
|
|
config={"name": "Test-Organization/repo"},
|
|
integration_id=integration.id,
|
|
)
|
|
source_url = "https://github.com/Test-Organization/repo/blob/master/src/sentry/integrations/github/integration.py"
|
|
|
|
assert installation.extract_branch_from_source_url(repo, source_url) == "master"
|
|
|
|
@responses.activate
|
|
def test_extract_source_path_from_source_url(self):
|
|
installation = self.get_installation_helper()
|
|
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/repo",
|
|
url="https://github.com/Test-Organization/repo",
|
|
provider="integrations:github",
|
|
external_id=123,
|
|
config={"name": "Test-Organization/repo"},
|
|
integration_id=integration.id,
|
|
)
|
|
source_url = "https://github.com/Test-Organization/repo/blob/master/src/sentry/integrations/github/integration.py"
|
|
|
|
assert (
|
|
installation.extract_source_path_from_source_url(repo, source_url)
|
|
== "src/sentry/integrations/github/integration.py"
|
|
)
|