|
@@ -6,7 +6,7 @@ from django.urls import reverse
|
|
|
|
|
|
import sentry
|
|
|
from sentry.constants import ObjectStatus
|
|
|
-from sentry.integrations.github import API_ERRORS, GitHubIntegrationProvider
|
|
|
+from sentry.integrations.github import API_ERRORS, MINIMUM_REQUESTS, GitHubIntegrationProvider
|
|
|
from sentry.integrations.utils.code_mapping import Repo, RepoTree
|
|
|
from sentry.models import Integration, OrganizationIntegration, Project, Repository
|
|
|
from sentry.plugins.base import plugins
|
|
@@ -585,6 +585,18 @@ class GitHubIntegrationTest(IntegrationTestCase):
|
|
|
organization=self.organization, integration=integration
|
|
|
).exists()
|
|
|
|
|
|
+ def set_rate_limit(self, remaining, limit=5000):
|
|
|
+ """Helper class to set the rate limit"""
|
|
|
+ responses.add(
|
|
|
+ method=responses.GET,
|
|
|
+ url="https://api.github.com/rate_limit",
|
|
|
+ json={
|
|
|
+ "resources": {
|
|
|
+ "core": {"limit": limit, "remaining": remaining, "used": "foo", "reset": 123}
|
|
|
+ }
|
|
|
+ },
|
|
|
+ )
|
|
|
+
|
|
|
def get_installation_helper(self):
|
|
|
with self.tasks():
|
|
|
self.assert_setup_flow() # This somehow creates the integration
|
|
@@ -593,36 +605,11 @@ class GitHubIntegrationTest(IntegrationTestCase):
|
|
|
installation = integration.get_installation(self.organization.id)
|
|
|
return installation
|
|
|
|
|
|
- @responses.activate
|
|
|
- def test_get_trees_for_org_handles_rate_limit_reached(self):
|
|
|
- """Test that we will not hit Github's API more than once when we reach the API rate limit"""
|
|
|
- responses.replace(
|
|
|
- responses.GET,
|
|
|
- f"{self.base_url}/repos/Test-Organization/foo/git/trees/master?recursive=1",
|
|
|
- json={"message": "API rate limit exceeded for installation ID 123456."},
|
|
|
- status=403,
|
|
|
- )
|
|
|
-
|
|
|
- installation = self.get_installation_helper()
|
|
|
- trees = installation.get_trees_for_org()
|
|
|
- key_prefix = "github:repo:Test-Organization"
|
|
|
- # We have access to the files because the rate limit has not been hit
|
|
|
- assert cache.get(f"{key_prefix}/xyz:source-code") == ["src/foo.py"]
|
|
|
- # These repos are None because foo hit the rate limit
|
|
|
- for repo in ("foo", "bar", "baz"):
|
|
|
- assert cache.get(f"{key_prefix}/{repo}:source-code") is None
|
|
|
- assert len(trees.keys()) == 1
|
|
|
-
|
|
|
- assert trees == {
|
|
|
- "Test-Organization/xyz": RepoTree(
|
|
|
- Repo("Test-Organization/xyz", "master"), ["src/foo.py"]
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
@responses.activate
|
|
|
def test_get_trees_for_org_works(self):
|
|
|
"""Fetch the tree representation of a repo"""
|
|
|
installation = self.get_installation_helper()
|
|
|
+ self.set_rate_limit(MINIMUM_REQUESTS + 50)
|
|
|
expected_trees = {
|
|
|
"Test-Organization/bar": RepoTree(Repo("Test-Organization/bar", "main"), []),
|
|
|
"Test-Organization/baz": RepoTree(Repo("Test-Organization/baz", "master"), []),
|
|
@@ -635,22 +622,54 @@ class GitHubIntegrationTest(IntegrationTestCase):
|
|
|
),
|
|
|
}
|
|
|
|
|
|
- assert not cache.get("githubtrees:repositories:Test-Organization")
|
|
|
+ repos_key = "githubtrees:repositories:Test-Organization"
|
|
|
+ repo_key = lambda x: f"github:repo:Test-Organization/{x}:source-code"
|
|
|
# Check that the cache is clear
|
|
|
- repo_key = "github:repo:Test-Organization/foo:source-code"
|
|
|
- assert cache.get("githubtrees:repositories:Test-Organization") is None
|
|
|
- assert cache.get(repo_key) is None
|
|
|
+ assert cache.get(repos_key) is None
|
|
|
+ assert cache.get(repo_key("foo")) is None
|
|
|
trees = installation.get_trees_for_org()
|
|
|
|
|
|
- assert cache.get("githubtrees:repositories:Test-Organization") == [
|
|
|
+ assert cache.get(repos_key) == [
|
|
|
{"full_name": "Test-Organization/xyz", "default_branch": "master"},
|
|
|
{"full_name": "Test-Organization/foo", "default_branch": "master"},
|
|
|
{"full_name": "Test-Organization/bar", "default_branch": "main"},
|
|
|
{"full_name": "Test-Organization/baz", "default_branch": "master"},
|
|
|
]
|
|
|
- assert cache.get(repo_key) == ["src/sentry/api/endpoints/auth_login.py"]
|
|
|
+ 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."""
|
|
|
+ gh_org = "Test-Organization"
|
|
|
+ installation = self.get_installation_helper()
|
|
|
+ expected_trees = {
|
|
|
+ f"{gh_org}/xyz": RepoTree(Repo(f"{gh_org}/xyz", "master"), ["src/foo.py"]),
|
|
|
+ # This will have no files because we will hit the minimum remaining requests floor
|
|
|
+ f"{gh_org}/foo": RepoTree(Repo(f"{gh_org}/foo", "master"), []),
|
|
|
+ f"{gh_org}/bar": RepoTree(Repo(f"{gh_org}/bar", "main"), []),
|
|
|
+ f"{gh_org}/baz": RepoTree(Repo(f"{gh_org}/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)
|
|
|
+ trees = installation.get_trees_for_org()
|
|
|
+ assert trees == expected_trees # xyz will have files but not foo
|
|
|
+
|
|
|
+ # 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()
|
|
|
+ # Now that the rate limit is reset we should get files for foo
|
|
|
+ expected_trees[f"{gh_org}/foo"] = RepoTree(
|
|
|
+ Repo(f"{gh_org}/foo", "master"), ["src/sentry/api/endpoints/auth_login.py"]
|
|
|
+ )
|
|
|
+ assert trees == expected_trees
|