mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
1630 lines
63 KiB
Python
1630 lines
63 KiB
Python
import re
|
|
from dataclasses import asdict
|
|
from datetime import datetime, timedelta, timezone
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
import responses
|
|
from django.core import mail
|
|
from django.test import override_settings
|
|
from requests import Request
|
|
from responses import matchers
|
|
|
|
from sentry.constants import ObjectStatus
|
|
from sentry.integrations.github.blame import create_blame_query, generate_file_path_mapping
|
|
from sentry.integrations.github.client import GitHubAppsClient
|
|
from sentry.integrations.github.integration import GitHubIntegration
|
|
from sentry.integrations.mixins.commit_context import CommitInfo, FileBlameInfo, SourceLineInfo
|
|
from sentry.integrations.notify_disable import notify_disable
|
|
from sentry.integrations.request_buffer import IntegrationRequestBuffer
|
|
from sentry.models.repository import Repository
|
|
from sentry.shared_integrations.exceptions import ApiError, ApiRateLimitedError
|
|
from sentry.shared_integrations.response.base import BaseApiResponse
|
|
from sentry.silo.base import SiloMode
|
|
from sentry.silo.util import PROXY_BASE_PATH, PROXY_OI_HEADER, PROXY_SIGNATURE_HEADER
|
|
from sentry.testutils.cases import TestCase
|
|
from sentry.testutils.helpers.datetime import freeze_time
|
|
from sentry.testutils.silo import control_silo_test, region_silo_test
|
|
from sentry.utils import json
|
|
from sentry.utils.cache import cache
|
|
from tests.sentry.integrations.test_helpers import add_control_silo_proxy_response
|
|
|
|
GITHUB_CODEOWNERS = {
|
|
"filepath": "CODEOWNERS",
|
|
"html_url": "https://github.com/org/reponame/CODEOWNERS",
|
|
"raw": "docs/* @NisanthanNanthakumar @getsentry/ecosystem\n* @NisanthanNanthakumar\n",
|
|
}
|
|
|
|
|
|
@region_silo_test
|
|
class GitHubAppsClientTest(TestCase):
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
def setUp(self, get_jwt):
|
|
ten_days = datetime.utcnow() + timedelta(days=10)
|
|
self.integration = self.create_integration(
|
|
organization=self.organization,
|
|
provider="github",
|
|
name="Github Test Org",
|
|
external_id="1",
|
|
metadata={
|
|
"access_token": "12345token",
|
|
"expires_at": ten_days.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
},
|
|
)
|
|
self.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,
|
|
integration_id=self.integration.id,
|
|
)
|
|
install = self.integration.get_installation(organization_id=self.organization.id)
|
|
assert isinstance(install, GitHubIntegration)
|
|
self.install = install
|
|
self.github_client = self.install.get_client()
|
|
|
|
@responses.activate
|
|
def test_get_rate_limit(self):
|
|
responses.add(
|
|
method=responses.GET,
|
|
url="https://api.github.com/rate_limit",
|
|
json={
|
|
"resources": {
|
|
"core": {"limit": 5000, "remaining": 4999, "reset": 1372700873, "used": 1},
|
|
"search": {"limit": 30, "remaining": 18, "reset": 1372697452, "used": 12},
|
|
"graphql": {"limit": 5000, "remaining": 4993, "reset": 1372700389, "used": 7},
|
|
},
|
|
},
|
|
)
|
|
with mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1"):
|
|
gh_rate_limit = self.github_client.get_rate_limit()
|
|
assert gh_rate_limit.limit == 5000
|
|
assert gh_rate_limit.remaining == 4999
|
|
assert gh_rate_limit.used == 1
|
|
assert gh_rate_limit.next_window() == "17:47:53"
|
|
|
|
gh_rate_limit = self.github_client.get_rate_limit("search")
|
|
assert gh_rate_limit.limit == 30
|
|
assert gh_rate_limit.remaining == 18
|
|
assert gh_rate_limit.used == 12
|
|
assert gh_rate_limit.next_window() == "16:50:52"
|
|
|
|
gh_rate_limit = self.github_client.get_rate_limit("graphql")
|
|
assert gh_rate_limit.limit == 5000
|
|
assert gh_rate_limit.remaining == 4993
|
|
assert gh_rate_limit.used == 7
|
|
assert gh_rate_limit.next_window() == "17:39:49"
|
|
|
|
@responses.activate
|
|
def test_get_rate_limit_non_existent_resource(self):
|
|
with pytest.raises(AssertionError):
|
|
self.github_client.get_rate_limit("foo")
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_check_file(self, get_jwt):
|
|
path = "src/sentry/integrations/github/client.py"
|
|
version = "master"
|
|
url = f"https://api.github.com/repos/{self.repo.name}/contents/{path}?ref={version}"
|
|
|
|
responses.add(
|
|
method=responses.HEAD,
|
|
url=url,
|
|
json={"text": 200},
|
|
)
|
|
|
|
resp = self.github_client.check_file(self.repo, path, version)
|
|
assert isinstance(resp, BaseApiResponse)
|
|
assert resp.status_code == 200
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_check_no_file(self, get_jwt):
|
|
path = "src/santry/integrations/github/client.py"
|
|
version = "master"
|
|
url = f"https://api.github.com/repos/{self.repo.name}/contents/{path}?ref={version}"
|
|
|
|
responses.add(method=responses.HEAD, url=url, status=404)
|
|
|
|
with pytest.raises(ApiError):
|
|
self.github_client.check_file(self.repo, path, version)
|
|
assert responses.calls[0].response.status_code == 404
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_stacktrace_link(self, get_jwt):
|
|
path = "/src/sentry/integrations/github/client.py"
|
|
version = "master"
|
|
url = "https://api.github.com/repos/{}/contents/{}?ref={}".format(
|
|
self.repo.name, path.lstrip("/"), 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.com/Test-Organization/foo/blob/master/src/sentry/integrations/github/client.py"
|
|
)
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_with_pagination(self, get_jwt):
|
|
url = f"https://api.github.com/repos/{self.repo.name}/assignees?per_page={self.github_client.page_size}"
|
|
|
|
responses.add(
|
|
method=responses.GET,
|
|
url=url,
|
|
json={"text": 200},
|
|
headers={"link": f'<{url}&page=2>; rel="next", <{url}&page=4>; rel="last"'},
|
|
)
|
|
# For simplicity, we're skipping the `first` and `prev` links from the following responses
|
|
responses.add(
|
|
method=responses.GET,
|
|
url=f"{url}&page=2",
|
|
json={"text": 200},
|
|
headers={"link": f'<{url}&page=3>; rel="next", <{url}&page=4>; rel="last"'},
|
|
)
|
|
responses.add(
|
|
method=responses.GET,
|
|
url=f"{url}&page=3",
|
|
json={"text": 200},
|
|
headers={"link": f'<{url}&page=4>; rel="next", <{url}&page=4>; rel="last"'},
|
|
)
|
|
responses.add(
|
|
method=responses.GET,
|
|
url=f"{url}&page=4",
|
|
json={"text": 200},
|
|
# To help understanding, the last page only contains the `first` and `prev` links
|
|
# The code only cares about the `next` value which is not included here
|
|
headers={"link": f'<{url}&page=1>; rel="first", <{url}&page=3>; rel="prev"'},
|
|
)
|
|
self.github_client.get_with_pagination(f"/repos/{self.repo.name}/assignees")
|
|
assert len(responses.calls) == 4
|
|
assert responses.calls[0].response.status_code == 200
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_with_pagination_only_one_page(self, get_jwt):
|
|
url = f"https://api.github.com/repos/{self.repo.name}/assignees?per_page={self.github_client.page_size}"
|
|
|
|
# No link in the headers because there are no more pages
|
|
responses.add(method=responses.GET, url=url, json={}, headers={})
|
|
self.github_client.get_with_pagination(f"/repos/{self.repo.name}/assignees")
|
|
assert len(responses.calls) == 1
|
|
assert responses.calls[0].response.status_code == 200
|
|
|
|
@mock.patch(
|
|
"sentry.integrations.github.integration.GitHubIntegration.check_file",
|
|
return_value=GITHUB_CODEOWNERS["html_url"],
|
|
)
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_codeowner_file(self, mock_jwt, mock_check_file):
|
|
self.config = self.create_code_mapping(
|
|
repo=self.repo,
|
|
project=self.project,
|
|
)
|
|
|
|
responses.add(
|
|
method=responses.GET,
|
|
url=f"https://api.github.com/repos/{self.repo.name}/contents/CODEOWNERS?ref=master",
|
|
body="docs/* @NisanthanNanthakumar @getsentry/ecosystem\n* @NisanthanNanthakumar\n",
|
|
)
|
|
result = self.install.get_codeowner_file(
|
|
self.config.repository, ref=self.config.default_branch
|
|
)
|
|
assert (
|
|
responses.calls[0].request.headers["Content-Type"] == "application/raw; charset=utf-8"
|
|
)
|
|
assert result == GITHUB_CODEOWNERS
|
|
|
|
@responses.activate
|
|
def test_get_cached_repo_files_caching_functionality(self):
|
|
"""Fetch files for repo. Test caching logic."""
|
|
responses.add(
|
|
method=responses.GET,
|
|
url=f"https://api.github.com/repos/{self.repo.name}/git/trees/master?recursive=1",
|
|
status=200,
|
|
json={"tree": [{"path": "src/foo.py", "type": "blob"}], "truncated": False},
|
|
)
|
|
repo_key = f"github:repo:{self.repo.name}:source-code"
|
|
assert cache.get(repo_key) is None
|
|
with mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1"):
|
|
files = self.github_client.get_cached_repo_files(self.repo.name, "master")
|
|
assert cache.get(repo_key) == files
|
|
# Calling a second time should work
|
|
files = self.github_client.get_cached_repo_files(self.repo.name, "master")
|
|
assert cache.get(repo_key) == files
|
|
# Calling again after the cache has been cleared should still work
|
|
cache.delete(repo_key)
|
|
files = self.github_client.get_cached_repo_files(self.repo.name, "master")
|
|
assert cache.get(repo_key) == files
|
|
|
|
@responses.activate
|
|
def test_get_cached_repo_files_with_all_files(self):
|
|
"""Fetch files for repo. All files rather than just source code files"""
|
|
responses.add(
|
|
method=responses.GET,
|
|
url=f"https://api.github.com/repos/{self.repo.name}/git/trees/master?recursive=1",
|
|
status=200,
|
|
json={
|
|
"tree": [
|
|
{"type": "blob", "path": "src/foo.py"},
|
|
{"type": "blob", "path": "README"},
|
|
]
|
|
},
|
|
)
|
|
repo_key = f"github:repo:{self.repo.name}:all"
|
|
assert cache.get(repo_key) is None
|
|
with mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1"):
|
|
files = self.github_client.get_cached_repo_files(self.repo.name, "master")
|
|
assert files == ["src/foo.py"]
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_update_comment(self, get_jwt):
|
|
responses.add(
|
|
method=responses.POST,
|
|
url=f"https://api.github.com/repos/{self.repo.name}/issues/1/comments",
|
|
status=201,
|
|
json={
|
|
"id": 1,
|
|
"node_id": "MDEyOklzc3VlQ29tbWVudDE=",
|
|
"url": f"https://api.github.com/repos/{self.repo.name}/issues/comments/1",
|
|
"html_url": f"https://github.com/{self.repo.name}/issues/1#issuecomment-1",
|
|
"body": "hello",
|
|
"created_at": "2023-05-23T17:00:00Z",
|
|
"updated_at": "2023-05-23T17:00:00Z",
|
|
"issue_url": f"https://api.github.com/repos/{self.repo.name}/issues/1",
|
|
"author_association": "COLLABORATOR",
|
|
},
|
|
)
|
|
self.github_client.create_comment(repo=self.repo.name, issue_id="1", data={"body": "hello"})
|
|
|
|
responses.add(
|
|
method=responses.PATCH,
|
|
url=f"https://api.github.com/repos/{self.repo.name}/issues/comments/1",
|
|
json={
|
|
"id": 1,
|
|
"node_id": "MDEyOklzc3VlQ29tbWVudDE=",
|
|
"url": f"https://api.github.com/repos/{self.repo.name}/issues/comments/1",
|
|
"html_url": f"https://github.com/{self.repo.name}/issues/1#issuecomment-1",
|
|
"body": "world",
|
|
"created_at": "2011-04-14T16:00:49Z",
|
|
"updated_at": "2011-04-14T16:00:49Z",
|
|
"issue_url": f"https://api.github.com/repos/{self.repo.name}/issues/1",
|
|
"author_association": "COLLABORATOR",
|
|
},
|
|
)
|
|
|
|
self.github_client.update_comment(
|
|
repo=self.repo.name, comment_id="1", data={"body": "world"}
|
|
)
|
|
assert responses.calls[1].response.status_code == 200
|
|
assert responses.calls[1].request.body == b'{"body": "world"}'
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_comment_reactions(self, get_jwt):
|
|
comment_reactions = {
|
|
"reactions": {
|
|
"url": "abcdef",
|
|
"hooray": 1,
|
|
"+1": 2,
|
|
"-1": 0,
|
|
}
|
|
}
|
|
responses.add(
|
|
responses.GET,
|
|
f"https://api.github.com/repos/{self.repo.name}/issues/comments/2",
|
|
json=comment_reactions,
|
|
)
|
|
|
|
reactions = self.github_client.get_comment_reactions(repo=self.repo.name, comment_id="2")
|
|
stored_reactions = comment_reactions["reactions"]
|
|
del stored_reactions["url"]
|
|
assert reactions == stored_reactions
|
|
|
|
@responses.activate
|
|
def test_disable_email(self):
|
|
with self.tasks():
|
|
notify_disable(
|
|
self.organization, self.integration.provider, self.github_client._get_redis_key()
|
|
)
|
|
assert len(mail.outbox) == 1
|
|
msg = mail.outbox[0]
|
|
assert msg.subject == "Action required: re-authenticate or fix your Github integration"
|
|
assert (
|
|
self.organization.absolute_url(
|
|
f"/settings/{self.organization.slug}/integrations/{self.integration.provider}"
|
|
)
|
|
in msg.body
|
|
)
|
|
assert (
|
|
self.organization.absolute_url(
|
|
f"/settings/{self.organization.slug}/integrations/{self.integration.provider}/?referrer=disabled-integration"
|
|
)
|
|
in msg.body
|
|
)
|
|
|
|
|
|
@control_silo_test
|
|
class GithubProxyClientTest(TestCase):
|
|
jwt = "my_cool_jwt"
|
|
access_token = "access_token"
|
|
|
|
def setUp(self):
|
|
self.integration = self.create_integration(
|
|
organization=self.organization,
|
|
provider="github",
|
|
name="github-test",
|
|
external_id="github:1",
|
|
metadata={"access_token": None, "expires_at": None},
|
|
status=ObjectStatus.ACTIVE,
|
|
)
|
|
self.installation = self.integration.get_installation(organization_id=self.organization.id)
|
|
self.gh_client = self.installation.get_client()
|
|
self.installation_id = self.gh_client._get_installation_id()
|
|
self.expires_at = (datetime.today() + timedelta(weeks=2)).isoformat()[:19] + "Z"
|
|
responses.add(
|
|
method=responses.POST,
|
|
url=f"https://api.github.com/app/installations/{self.installation_id}/access_tokens",
|
|
json={"token": self.access_token, "expires_at": self.expires_at},
|
|
match=[matchers.header_matcher({"Authorization": f"Bearer {self.jwt}"})],
|
|
status=200,
|
|
)
|
|
project = self.create_project(organization=self.organization)
|
|
self.repo = self.create_repo(
|
|
project=project,
|
|
name="Test-Organization/foo",
|
|
provider="integrations:github",
|
|
integration_id=self.integration.id,
|
|
url="https://github.com/Test-Organization/foo",
|
|
)
|
|
|
|
@responses.activate
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt)
|
|
def test__refresh_access_token(self, mock_jwt):
|
|
assert self.integration.metadata == {"access_token": None, "expires_at": None}
|
|
|
|
self.gh_client._refresh_access_token()
|
|
assert mock_jwt.called
|
|
|
|
self.integration.refresh_from_db()
|
|
assert self.integration.metadata == {
|
|
"access_token": self.access_token,
|
|
"expires_at": self.expires_at.rstrip("Z"),
|
|
}
|
|
|
|
@responses.activate
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt)
|
|
def test__get_token(self, mock_jwt):
|
|
access_token_request = Request(
|
|
url=f"{self.gh_client.base_url}/repos/test-repo/issues"
|
|
).prepare()
|
|
jwt_request = Request(
|
|
url=f"{self.gh_client.base_url}/app/installations/{self.installation_id}"
|
|
).prepare()
|
|
|
|
with mock.patch(
|
|
"sentry.integrations.github.client.GithubProxyClient._refresh_access_token",
|
|
wraps=self.gh_client._refresh_access_token,
|
|
) as mock_refresh_token:
|
|
# Regular API requests should use access tokens
|
|
token = self.gh_client._get_token(prepared_request=access_token_request)
|
|
self.integration.refresh_from_db()
|
|
assert mock_jwt.called
|
|
assert mock_refresh_token.called
|
|
assert token == self.access_token == self.integration.metadata["access_token"]
|
|
|
|
# If the access token isn't expired, don't refresh it with an API call
|
|
mock_refresh_token.reset_mock()
|
|
mock_jwt.reset_mock()
|
|
token = self.gh_client._get_token(prepared_request=access_token_request)
|
|
assert not mock_refresh_token.called
|
|
assert not mock_jwt.called
|
|
assert token == self.access_token == self.integration.metadata["access_token"]
|
|
|
|
# Meta, app-installation requests should use jwts
|
|
token = self.gh_client._get_token(prepared_request=jwt_request)
|
|
assert mock_jwt.called
|
|
assert not mock_refresh_token.called
|
|
assert token == self.jwt
|
|
|
|
@responses.activate
|
|
@mock.patch("sentry.integrations.github.client.GithubProxyClient._get_token", return_value=None)
|
|
def test_authorize_request_invalid(self, mock_get_invalid_token):
|
|
request = Request(url=f"{self.gh_client.base_url}/repos/test-repo/issues").prepare()
|
|
|
|
self.gh_client.integration = None
|
|
self.gh_client.authorize_request(prepared_request=request)
|
|
assert "Authorization" not in request.headers
|
|
|
|
self.gh_client.integration = self.integration
|
|
self.gh_client.authorize_request(prepared_request=request)
|
|
assert mock_get_invalid_token.called
|
|
assert "Authorization" not in request.headers
|
|
|
|
@responses.activate
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=jwt)
|
|
def test_authorize_request_valid(self, mock_jwt):
|
|
access_token_request = Request(
|
|
url=f"{self.gh_client.base_url}/repos/test-repo/issues"
|
|
).prepare()
|
|
jwt_request = Request(
|
|
url=f"{self.gh_client.base_url}/app/installations/{self.installation_id}"
|
|
).prepare()
|
|
|
|
# First request should refresh the token and add headers
|
|
self.gh_client.authorize_request(prepared_request=access_token_request)
|
|
assert mock_jwt.called
|
|
assert access_token_request.headers["Accept"] == "application/vnd.github+json"
|
|
assert self.access_token in access_token_request.headers["Authorization"]
|
|
|
|
mock_jwt.reset_mock()
|
|
access_token_request.headers.clear()
|
|
|
|
# Following requests should just add headers
|
|
self.gh_client.authorize_request(prepared_request=access_token_request)
|
|
assert not mock_jwt.called
|
|
assert access_token_request.headers["Accept"] == "application/vnd.github+json"
|
|
assert self.access_token in access_token_request.headers["Authorization"]
|
|
|
|
# JWT-authorized requests should be identified by request path
|
|
self.gh_client.authorize_request(prepared_request=jwt_request)
|
|
assert mock_jwt.called
|
|
assert jwt_request.headers["Accept"] == "application/vnd.github+json"
|
|
assert jwt_request.headers["Authorization"] == f"Bearer {self.jwt}"
|
|
|
|
@responses.activate
|
|
@mock.patch(
|
|
"sentry.integrations.github.client.GithubProxyClient._get_token", return_value=access_token
|
|
)
|
|
def test_integration_proxy_is_active(self, mock_get_token):
|
|
class GithubProxyTestClient(GitHubAppsClient):
|
|
_use_proxy_url_for_tests = True
|
|
|
|
def assert_proxy_request(self, request, is_proxy=True):
|
|
assert (PROXY_BASE_PATH in request.url) == is_proxy
|
|
assert (PROXY_OI_HEADER in request.headers) == is_proxy
|
|
assert (PROXY_SIGNATURE_HEADER in request.headers) == is_proxy
|
|
# The following GitHub headers don't appear in proxied requests
|
|
assert ("Authorization" in request.headers) != is_proxy
|
|
assert ("Accept" in request.headers) != is_proxy
|
|
if is_proxy:
|
|
assert request.headers[PROXY_OI_HEADER] is not None
|
|
|
|
expected_proxy_path = "repos/test-repo/issues"
|
|
control_proxy_responses = add_control_silo_proxy_response(
|
|
method=responses.GET,
|
|
path=expected_proxy_path,
|
|
json={"ok": True},
|
|
status=200,
|
|
)
|
|
|
|
github_responses = responses.add(
|
|
method=responses.GET,
|
|
url=re.compile(rf"\S+{expected_proxy_path}$"),
|
|
json={"ok": True},
|
|
status=200,
|
|
)
|
|
|
|
with override_settings(SILO_MODE=SiloMode.MONOLITH):
|
|
client = GithubProxyTestClient(integration=self.integration)
|
|
client.get_issues("test-repo")
|
|
request = responses.calls[0].request
|
|
|
|
assert github_responses.call_count == 1
|
|
assert "/repos/test-repo/issues" in request.url
|
|
assert client.base_url in request.url
|
|
client.assert_proxy_request(request, is_proxy=False)
|
|
|
|
responses.calls.reset()
|
|
with override_settings(SILO_MODE=SiloMode.CONTROL):
|
|
client = GithubProxyTestClient(integration=self.integration)
|
|
client.get_issues("test-repo")
|
|
request = responses.calls[0].request
|
|
|
|
assert github_responses.call_count == 2
|
|
assert "/repos/test-repo/issues" in request.url
|
|
assert client.base_url in request.url
|
|
client.assert_proxy_request(request, is_proxy=False)
|
|
|
|
responses.calls.reset()
|
|
assert control_proxy_responses.call_count == 0
|
|
with override_settings(SILO_MODE=SiloMode.REGION):
|
|
client = GithubProxyTestClient(integration=self.integration)
|
|
client.get_issues("test-repo")
|
|
request = responses.calls[0].request
|
|
|
|
assert control_proxy_responses.call_count == 1
|
|
assert client.base_url not in request.url
|
|
client.assert_proxy_request(request, is_proxy=True)
|
|
|
|
|
|
@region_silo_test
|
|
class GitHubClientFileBlameBase(TestCase):
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
def setUp(self, get_jwt):
|
|
integration = self.create_integration(
|
|
organization=self.organization,
|
|
provider="github",
|
|
name="Github Test Org",
|
|
external_id="1",
|
|
metadata={"access_token": None, "expires_at": None},
|
|
)
|
|
self.repo_1 = 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,
|
|
integration_id=integration.id,
|
|
)
|
|
self.repo_2 = Repository.objects.create(
|
|
organization_id=self.organization.id,
|
|
name="Test-Organization/bar",
|
|
url="https://github.com/Test-Organization/bar",
|
|
provider="integrations:github",
|
|
external_id=456,
|
|
integration_id=integration.id,
|
|
)
|
|
self.repo_3 = Repository.objects.create(
|
|
organization_id=self.organization.id,
|
|
name="Test-Organization/other",
|
|
url="https://github.com/Test-Organization/other",
|
|
provider="integrations:github",
|
|
external_id=789,
|
|
integration_id=integration.id,
|
|
)
|
|
install = integration.get_installation(organization_id=self.organization.id)
|
|
assert isinstance(install, GitHubIntegration)
|
|
self.install = install
|
|
self.github_client = self.install.get_client()
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/app/installations/1/access_tokens",
|
|
body='{"token": "12345token", "expires_at": "2030-01-01T00:00:00Z"}',
|
|
status=200,
|
|
content_type="application/json",
|
|
)
|
|
responses.add(
|
|
method=responses.GET,
|
|
url="https://api.github.com/rate_limit",
|
|
body=json.dumps(
|
|
{
|
|
"resources": {
|
|
"graphql": {
|
|
"limit": 5000,
|
|
"used": 1,
|
|
"remaining": 4999,
|
|
"reset": 1613064000,
|
|
}
|
|
}
|
|
}
|
|
),
|
|
status=200,
|
|
content_type="application/json",
|
|
)
|
|
|
|
|
|
@region_silo_test
|
|
class GitHubClientFileBlameIntegrationDisableTest(TestCase):
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
def setUp(self, get_jwt):
|
|
ten_days = datetime.utcnow() + timedelta(days=10)
|
|
self.integration = self.create_integration(
|
|
organization=self.organization,
|
|
provider="github",
|
|
name="Github Test Org",
|
|
external_id="1",
|
|
metadata={
|
|
"access_token": "12345token",
|
|
"expires_at": ten_days.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
},
|
|
)
|
|
self.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,
|
|
integration_id=self.integration.id,
|
|
)
|
|
install = self.integration.get_installation(organization_id=self.organization.id)
|
|
assert isinstance(install, GitHubIntegration)
|
|
self.install = install
|
|
self.github_client = self.install.get_client()
|
|
self.file = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=ApiError)
|
|
@responses.activate
|
|
def test_fatal_and_disable_integration(self, get_jwt):
|
|
"""
|
|
fatal fast shut off with disable flag on, integration should be broken and disabled
|
|
"""
|
|
responses.add(
|
|
responses.POST,
|
|
status=403,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"message": "This installation has been suspended",
|
|
"documentation_url": "https://docs.github.com/rest/reference/apps#create-an-installation-access-token-for-an-app",
|
|
},
|
|
)
|
|
|
|
self.github_client.integration = None
|
|
with pytest.raises(Exception):
|
|
self.github_client.get_blame_for_files([self.file], extra={})
|
|
|
|
buffer = IntegrationRequestBuffer(self.github_client._get_redis_key())
|
|
self.integration.refresh_from_db()
|
|
assert self.integration.status == ObjectStatus.DISABLED
|
|
assert [len(item) == 0 for item in buffer._get_broken_range_from_buffer()]
|
|
assert len(buffer._get_all_from_buffer()) == 0
|
|
|
|
@responses.activate
|
|
def test_error_integration(self):
|
|
"""
|
|
recieve two errors and errors are recorded, integration is not broken yet so no disable
|
|
"""
|
|
responses.add(
|
|
method=responses.GET,
|
|
url="https://api.github.com/rate_limit",
|
|
body=json.dumps(
|
|
{
|
|
"resources": {
|
|
"graphql": {
|
|
"limit": 5000,
|
|
"used": 1,
|
|
"remaining": 4999,
|
|
"reset": 1613064000,
|
|
}
|
|
}
|
|
}
|
|
),
|
|
status=200,
|
|
content_type="application/json",
|
|
)
|
|
|
|
responses.add(
|
|
responses.POST,
|
|
status=404,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"message": "Not found",
|
|
},
|
|
)
|
|
responses.add(
|
|
responses.POST,
|
|
status=404,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"message": "Not found",
|
|
},
|
|
)
|
|
self.github_client.integration = None
|
|
with pytest.raises(Exception):
|
|
self.github_client.get_blame_for_files([self.file], extra={})
|
|
with pytest.raises(Exception):
|
|
self.github_client.get_blame_for_files([self.file], extra={})
|
|
buffer = IntegrationRequestBuffer(self.github_client._get_redis_key())
|
|
assert (
|
|
int(buffer._get_all_from_buffer()[0]["error_count"]) == 2
|
|
) # 2 from graphql, 2 from rate_limt check
|
|
assert buffer.is_integration_broken() is False
|
|
|
|
@responses.activate
|
|
@freeze_time("2022-01-01 03:30:00")
|
|
def test_slow_integration_is_not_broken_or_disabled(self):
|
|
"""
|
|
slow test with disable flag on
|
|
put errors and success in buffer for 10 days, assert integration is not broken or disabled
|
|
"""
|
|
|
|
responses.add(
|
|
responses.POST,
|
|
status=404,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"message": "Not found",
|
|
},
|
|
)
|
|
buffer = IntegrationRequestBuffer(self.github_client._get_redis_key())
|
|
now = datetime.now() - timedelta(hours=1)
|
|
for i in reversed(range(10)):
|
|
with freeze_time(now - timedelta(days=i)):
|
|
buffer.record_error()
|
|
buffer.record_success()
|
|
self.github_client.integration = None
|
|
with pytest.raises(Exception):
|
|
self.github_client.get_blame_for_files([self.file], extra={})
|
|
assert buffer.is_integration_broken() is False
|
|
self.integration.refresh_from_db()
|
|
assert self.integration.status == ObjectStatus.ACTIVE
|
|
|
|
@responses.activate
|
|
@freeze_time("2022-01-01 03:30:00")
|
|
def test_a_slow_integration_is_broken(self):
|
|
"""
|
|
slow shut off with disable flag on
|
|
put errors in buffer for 10 days, assert integration is broken and disabled
|
|
"""
|
|
responses.add(
|
|
responses.POST,
|
|
status=404,
|
|
url="https://api.github.com/graphql",
|
|
json={"message": "Not found"},
|
|
)
|
|
buffer = IntegrationRequestBuffer(self.github_client._get_redis_key())
|
|
now = datetime.now() - timedelta(hours=1)
|
|
for i in reversed(range(10)):
|
|
with freeze_time(now - timedelta(days=i)):
|
|
buffer.record_error()
|
|
self.github_client.integration = None
|
|
assert self.integration.status == ObjectStatus.ACTIVE
|
|
with pytest.raises(Exception):
|
|
self.github_client.get_blame_for_files([self.file], extra={})
|
|
self.integration.refresh_from_db()
|
|
assert self.integration.status == ObjectStatus.DISABLED
|
|
|
|
|
|
@region_silo_test
|
|
class GitHubClientFileBlameQueryBuilderTest(GitHubClientFileBlameBase):
|
|
"""
|
|
Tests that get_blame_for_files builds the correct GraphQL query
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_blame_for_files_same_repo(self, get_jwt):
|
|
"""
|
|
When all files are in the same repo, only one repository object should be
|
|
queried and files blames within the repo should be deduped
|
|
"""
|
|
file1 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
file2 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=15,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
file3 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_2.py",
|
|
lineno=20,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
query = """query {
|
|
repository0: repository(name: "foo", owner: "Test-Organization") {
|
|
ref0: ref(qualifiedName: "master") {
|
|
target {
|
|
... on Commit {
|
|
blame0: blame(path: "src/sentry/integrations/github/client_1.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
blame1: blame(path: "src/sentry/integrations/github/client_2.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}"""
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"query": query,
|
|
"data": {},
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.github_client.get_blame_for_files([file1, file2, file3], extra={})
|
|
assert json.loads(responses.calls[1].request.body)["query"] == query
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_blame_for_files_different_repos(self, get_jwt):
|
|
"""
|
|
When files are in different repos, multiple repository objects should be
|
|
queried. Files within the same repo and branch should be deduped.
|
|
"""
|
|
file1 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
file2 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_2.py",
|
|
lineno=15,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
file3 = SourceLineInfo(
|
|
path="src/getsentry/file.py",
|
|
lineno=20,
|
|
ref="master",
|
|
repo=self.repo_2,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
query = """query {
|
|
repository0: repository(name: "foo", owner: "Test-Organization") {
|
|
ref0: ref(qualifiedName: "master") {
|
|
target {
|
|
... on Commit {
|
|
blame0: blame(path: "src/sentry/integrations/github/client_1.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
blame1: blame(path: "src/sentry/integrations/github/client_2.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
repository1: repository(name: "bar", owner: "Test-Organization") {
|
|
ref0: ref(qualifiedName: "master") {
|
|
target {
|
|
... on Commit {
|
|
blame0: blame(path: "src/getsentry/file.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}"""
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"query": query,
|
|
"data": {},
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.github_client.get_blame_for_files([file1, file2, file3], extra={})
|
|
assert json.loads(responses.calls[1].request.body)["query"] == query
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_blame_for_files_different_refs(self, get_jwt):
|
|
"""
|
|
When files are in the same repo but different branches, query multiple
|
|
ref objects. Files should still be deduped.
|
|
"""
|
|
file1 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
file2 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client.py",
|
|
lineno=15,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
file3 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client.py",
|
|
lineno=20,
|
|
ref="staging",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
query = """query {
|
|
repository0: repository(name: "foo", owner: "Test-Organization") {
|
|
ref0: ref(qualifiedName: "master") {
|
|
target {
|
|
... on Commit {
|
|
blame0: blame(path: "src/sentry/integrations/github/client.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ref1: ref(qualifiedName: "staging") {
|
|
target {
|
|
... on Commit {
|
|
blame0: blame(path: "src/sentry/integrations/github/client.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}"""
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"query": query,
|
|
"data": {},
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.github_client.get_blame_for_files([file1, file2, file3], extra={})
|
|
assert json.loads(responses.calls[1].request.body)["query"] == query
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_trim_file_path_for_query(self, get_jwt):
|
|
"""
|
|
When file path has hanging forward slashes, trims them for the request.
|
|
The GitHub GraphQL API will return empty responses otherwise.
|
|
"""
|
|
file1 = SourceLineInfo(
|
|
path="/src/sentry/integrations/github/client.py/",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
|
|
query = """query {
|
|
repository0: repository(name: "foo", owner: "Test-Organization") {
|
|
ref0: ref(qualifiedName: "master") {
|
|
target {
|
|
... on Commit {
|
|
blame0: blame(path: "src/sentry/integrations/github/client.py") {
|
|
ranges {
|
|
commit {
|
|
oid
|
|
author {
|
|
name
|
|
email
|
|
}
|
|
message
|
|
committedDate
|
|
}
|
|
startingLine
|
|
endingLine
|
|
age
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}"""
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"query": query,
|
|
"data": {},
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
self.github_client.get_blame_for_files([file1], extra={})
|
|
assert json.loads(responses.calls[1].request.body)["query"] == query
|
|
|
|
|
|
@region_silo_test
|
|
class GitHubClientFileBlameResponseTest(GitHubClientFileBlameBase):
|
|
"""
|
|
Tests that get_blame_for_files handles the GraphQL response correctly
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
|
|
self.file1 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
self.file2 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=20,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
self.file3 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_2.py",
|
|
lineno=20,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
|
|
self.data = {
|
|
"repository0": {
|
|
"ref0": {
|
|
"target": {
|
|
"blame0": {
|
|
"ranges": [
|
|
{
|
|
"commit": {
|
|
"oid": "987",
|
|
"author": {
|
|
"name": "not this one",
|
|
"email": "[email protected]",
|
|
},
|
|
"message": "bye",
|
|
"committedDate": "2022-01-01T00:00:00Z",
|
|
},
|
|
"startingLine": 1,
|
|
"endingLine": 9,
|
|
"age": 0,
|
|
},
|
|
{
|
|
"commit": {
|
|
"oid": "123",
|
|
"author": {"name": "foo1", "email": "[email protected]"},
|
|
"message": "hello",
|
|
"committedDate": "2022-01-01T00:00:00Z",
|
|
},
|
|
"startingLine": 10,
|
|
"endingLine": 15,
|
|
"age": 0,
|
|
},
|
|
{
|
|
"commit": {
|
|
"oid": "456",
|
|
"author": {"name": "foo2", "email": "[email protected]"},
|
|
"message": "hello",
|
|
"committedDate": "2021-01-01T00:00:00Z",
|
|
},
|
|
"startingLine": 16,
|
|
"endingLine": 21,
|
|
"age": 0,
|
|
},
|
|
]
|
|
},
|
|
"blame1": {
|
|
"ranges": [
|
|
{
|
|
"commit": {
|
|
"oid": "789",
|
|
"author": {"name": "foo3", "email": "[email protected]"},
|
|
"message": "hello",
|
|
"committedDate": "2020-01-01T00:00:00Z",
|
|
},
|
|
"startingLine": 20,
|
|
"endingLine": 20,
|
|
"age": 0,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_blame_for_files_full_response(self, get_jwt):
|
|
"""
|
|
Tests that the correct commits are selected from the blame range when a full response is returned.
|
|
"""
|
|
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"data": self.data,
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
response = self.github_client.get_blame_for_files(
|
|
[self.file1, self.file2, self.file3], extra={}
|
|
)
|
|
self.assertEqual(
|
|
response,
|
|
[
|
|
FileBlameInfo(
|
|
**asdict(self.file1),
|
|
commit=CommitInfo(
|
|
commitId="123",
|
|
commitAuthorName="foo1",
|
|
commitAuthorEmail="[email protected]",
|
|
commitMessage="hello",
|
|
committedDate=datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
),
|
|
),
|
|
FileBlameInfo(
|
|
**asdict(self.file2),
|
|
commit=CommitInfo(
|
|
commitId="456",
|
|
commitAuthorName="foo2",
|
|
commitAuthorEmail="[email protected]",
|
|
commitMessage="hello",
|
|
committedDate=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
),
|
|
),
|
|
FileBlameInfo(
|
|
**asdict(self.file3),
|
|
commit=CommitInfo(
|
|
commitId="789",
|
|
commitAuthorName="foo3",
|
|
commitAuthorEmail="[email protected]",
|
|
commitMessage="hello",
|
|
committedDate=datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_cached_blame_for_files_full_response(self, get_jwt):
|
|
"""
|
|
Tests that the cached commits are returned with full response
|
|
"""
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"data": self.data,
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
data = create_blame_query(
|
|
generate_file_path_mapping([self.file1, self.file2, self.file3]), extra={}
|
|
)
|
|
cache_key = self.github_client.get_cache_key("/graphql", data)
|
|
assert self.github_client.check_cache(cache_key) is None
|
|
response = self.github_client.get_blame_for_files(
|
|
[self.file1, self.file2, self.file3], extra={}
|
|
)
|
|
|
|
self.assertEqual(
|
|
response,
|
|
[
|
|
FileBlameInfo(
|
|
**asdict(self.file1),
|
|
commit=CommitInfo(
|
|
commitId="123",
|
|
commitAuthorName="foo1",
|
|
commitAuthorEmail="[email protected]",
|
|
commitMessage="hello",
|
|
committedDate=datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
),
|
|
),
|
|
FileBlameInfo(
|
|
**asdict(self.file2),
|
|
commit=CommitInfo(
|
|
commitId="456",
|
|
commitAuthorName="foo2",
|
|
commitAuthorEmail="[email protected]",
|
|
commitMessage="hello",
|
|
committedDate=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
),
|
|
),
|
|
FileBlameInfo(
|
|
**asdict(self.file3),
|
|
commit=CommitInfo(
|
|
commitId="789",
|
|
commitAuthorName="foo3",
|
|
commitAuthorEmail="[email protected]",
|
|
commitMessage="hello",
|
|
committedDate=datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
assert self.github_client.check_cache(cache_key)["data"] == self.data
|
|
# Calling a second time should work
|
|
response = self.github_client.get_blame_for_files(
|
|
[self.file1, self.file2, self.file3], extra={}
|
|
)
|
|
assert self.github_client.check_cache(cache_key)["data"] == self.data
|
|
# Calling again after the cache has been cleared should still work
|
|
cache.delete(cache_key)
|
|
response = self.github_client.get_blame_for_files(
|
|
[self.file1, self.file2, self.file3], extra={}
|
|
)
|
|
assert self.github_client.check_cache(cache_key)["data"] == self.data
|
|
assert (
|
|
self.github_client.get_blame_for_files([self.file1, self.file2], extra={}) != response
|
|
)
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_blame_for_files_response_partial_data(self, get_jwt):
|
|
"""
|
|
Tests that commits are still returned when some data is missing from the response
|
|
"""
|
|
file1 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
file2 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_2.py",
|
|
lineno=15,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
file3 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client.py",
|
|
lineno=20,
|
|
ref="master",
|
|
repo=self.repo_2,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
file4 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client.py",
|
|
lineno=25,
|
|
ref="master",
|
|
repo=self.repo_3,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
data = {
|
|
"repository0": {
|
|
"ref0": {
|
|
"target": {
|
|
"blame0": {
|
|
"ranges": [
|
|
{
|
|
"commit": {
|
|
"oid": "123",
|
|
"author": None,
|
|
"message": None,
|
|
"committedDate": "2022-01-01T00:00:00Z",
|
|
},
|
|
"startingLine": 10,
|
|
"endingLine": 15,
|
|
"age": 0,
|
|
}
|
|
]
|
|
},
|
|
"blame1": {"ranges": []},
|
|
}
|
|
}
|
|
},
|
|
"repository1": {"ref0": None},
|
|
"repository2": None,
|
|
}
|
|
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"data": data,
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
response = self.github_client.get_blame_for_files([file1, file2, file3, file4], extra={})
|
|
self.assertEqual(
|
|
response,
|
|
[
|
|
FileBlameInfo(
|
|
**asdict(file1),
|
|
commit=CommitInfo(
|
|
commitId="123",
|
|
commitAuthorName=None,
|
|
commitAuthorEmail=None,
|
|
commitMessage=None,
|
|
committedDate=datetime(2022, 1, 1, 0, 0, 0, tzinfo=timezone.utc),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
@mock.patch("sentry.integrations.github.client.logger.error")
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_get_blame_for_files_invalid_commit(self, get_jwt, mock_logger_error):
|
|
"""
|
|
Tests commits that have invalid data are skipped and logged
|
|
"""
|
|
file1 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
file2 = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_2.py",
|
|
lineno=15,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type:ignore
|
|
)
|
|
data = {
|
|
"repository0": {
|
|
"ref0": {
|
|
"target": {
|
|
"blame0": {
|
|
"ranges": [
|
|
{
|
|
"commit": {
|
|
"oid": None,
|
|
"author": None,
|
|
"message": None,
|
|
"committedDate": "2022-01-01T00:00:00Z",
|
|
},
|
|
"startingLine": 10,
|
|
"endingLine": 15,
|
|
"age": 0,
|
|
}
|
|
]
|
|
},
|
|
"blame1": {
|
|
"ranges": [
|
|
{
|
|
"commit": {
|
|
"oid": "123",
|
|
"author": None,
|
|
"message": None,
|
|
"committedDate": None,
|
|
},
|
|
"startingLine": 10,
|
|
"endingLine": 15,
|
|
"age": 0,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"data": data,
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
response = self.github_client.get_blame_for_files([file1, file2], extra={})
|
|
self.assertEqual(response, [])
|
|
|
|
mock_logger_error.assert_has_calls(
|
|
[
|
|
mock.call(
|
|
"get_blame_for_files.extract_commits_from_blame.invalid_commit_response",
|
|
extra={
|
|
"provider": "github",
|
|
"organization_integration_id": self.github_client.org_integration_id,
|
|
"file_lineno": file1.lineno,
|
|
"file_path": file1.path,
|
|
"branch_name": file1.ref,
|
|
"repo_name": file1.repo.name,
|
|
"reason": "Missing property oid",
|
|
},
|
|
),
|
|
mock.call(
|
|
"get_blame_for_files.extract_commits_from_blame.invalid_commit_response",
|
|
extra={
|
|
"provider": "github",
|
|
"organization_integration_id": self.github_client.org_integration_id,
|
|
"file_lineno": file2.lineno,
|
|
"file_path": file2.path,
|
|
"branch_name": file2.ref,
|
|
"repo_name": file2.repo.name,
|
|
"commit_id": "123",
|
|
"reason": "Missing property committedDate",
|
|
},
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
@region_silo_test
|
|
class GitHubClientFileBlameRateLimitTest(GitHubClientFileBlameBase):
|
|
"""
|
|
Tests that rate limits are handled correctly
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.file = SourceLineInfo(
|
|
path="src/sentry/integrations/github/client_1.py",
|
|
lineno=10,
|
|
ref="master",
|
|
repo=self.repo_1,
|
|
code_mapping=None, # type: ignore
|
|
)
|
|
responses.reset()
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/app/installations/1/access_tokens",
|
|
body='{"token": "12345token", "expires_at": "2030-01-01T00:00:00Z"}',
|
|
status=200,
|
|
content_type="application/json",
|
|
)
|
|
responses.add(
|
|
method=responses.GET,
|
|
url="https://api.github.com/rate_limit",
|
|
body=json.dumps(
|
|
{
|
|
"resources": {
|
|
"graphql": {
|
|
"limit": 5000,
|
|
"used": 4900,
|
|
"remaining": 100,
|
|
"reset": 1613064000,
|
|
}
|
|
}
|
|
}
|
|
),
|
|
status=200,
|
|
content_type="application/json",
|
|
)
|
|
|
|
@mock.patch("sentry.integrations.github.client.logger.error")
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_rate_limit_exceeded(self, get_jwt, mock_logger_error):
|
|
with pytest.raises(ApiRateLimitedError):
|
|
self.github_client.get_blame_for_files([self.file], extra={})
|
|
mock_logger_error.assert_called_with(
|
|
"sentry.integrations.github.get_blame_for_files.rate_limit",
|
|
extra={
|
|
"provider": "github",
|
|
"specific_resource": "graphql",
|
|
"remaining": 100,
|
|
"next_window": "17:20:00",
|
|
"organization_integration_id": self.github_client.org_integration_id,
|
|
},
|
|
)
|
|
|
|
@mock.patch("sentry.integrations.github.client.get_jwt", return_value=b"jwt_token_1")
|
|
@responses.activate
|
|
def test_no_rate_limiting(self, get_jwt):
|
|
"""
|
|
Tests that no error is thrown when GitHub isn't enforcing rate limits
|
|
"""
|
|
responses.reset()
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/app/installations/1/access_tokens",
|
|
body='{"token": "12345token", "expires_at": "2030-01-01T00:00:00Z"}',
|
|
status=200,
|
|
content_type="application/json",
|
|
)
|
|
responses.add(
|
|
method=responses.GET,
|
|
url="https://api.github.com/rate_limit",
|
|
status=404,
|
|
)
|
|
responses.add(
|
|
method=responses.POST,
|
|
url="https://api.github.com/graphql",
|
|
json={
|
|
"data": {},
|
|
},
|
|
content_type="application/json",
|
|
)
|
|
|
|
assert self.github_client.get_blame_for_files([self.file], extra={}) == []
|