Skip to content

Commit 6b1a01d

Browse files
committed
feat(commits): Cache calls to compare-commits
- Added a new org feature flag in `src/sentry/features/temporary.py`: - `organizations:integrations-github-fetch-commits-compare-cache` - Refactored commit fetching in `src/sentry/tasks/commits.py`: - Added `fetch_compare_commits(...)` helper to centralize compare-commits behavior. - Added cache key generation with `get_github_compare_commits_cache_key(...)`. - Added cache-backed reuse of GitHub compare-commits results (TTL: 120s), gated by the new feature flag. - Limited caching to GitHub providers (`integrations:github`, `integrations:github_enterprise`) and only when `start_sha` is present. - Added lifecycle extras for cache telemetry (`compare_commits_cache_enabled`, `compare_commits_cache_hit`). - `fetch_commits(...)` now evaluates the feature flag once and routes compare calls through the helper. - Added provider-level test coverage in `tests/sentry/integrations/github/test_repository.py`: - New test verifies repeated compare calls reuse cached patchset data and reduce API calls. - Added task-level cache behavior tests in `tests/sentry/tasks/test_commits.py`: - Cache disabled: compare called on each fetch. - Cache enabled: compare result reused across releases with the same compare range. - Cache key variance: different `end_sha` values do not share cache entries. - Included a follow-up typing fix in `src/sentry/tasks/commits.py`: - Narrowed `repo.provider` to `str` before cache-key creation to satisfy mypy (`str | None` -> `str`).
1 parent 195e0c3 commit 6b1a01d

File tree

4 files changed

+267
-4
lines changed

4 files changed

+267
-4
lines changed

src/sentry/features/temporary.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def register_temporary_features(manager: FeatureManager) -> None:
133133
manager.add("organizations:integrations-claude-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
134134
manager.add("organizations:integrations-cursor", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
135135
manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
136+
manager.add("organizations:integrations-github-fetch-commits-compare-cache", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
136137
manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
137138
manager.add("organizations:integrations-perforce", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True)
138139
# Project Management Integrations Feature Parity Flags

src/sentry/tasks/commits.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sentry_sdk import set_tag
88
from taskbroker_client.retry import Retry
99

10+
from sentry import features
1011
from sentry.constants import ObjectStatus
1112
from sentry.exceptions import InvalidIdentity, PluginError
1213
from sentry.integrations.source_code_management.metrics import (
@@ -28,11 +29,21 @@
2829
from sentry.users.models.user import User
2930
from sentry.users.services.user import RpcUser
3031
from sentry.users.services.user.service import user_service
32+
from sentry.utils.cache import cache
3133
from sentry.utils.email import MessageBuilder
34+
from sentry.utils.hashlib import md5_text
3235
from sentry.utils.http import absolute_uri
3336

3437
logger = logging.getLogger(__name__)
3538

39+
GITHUB_FETCH_COMMITS_COMPARE_CACHE_FEATURE = (
40+
"organizations:integrations-github-fetch-commits-compare-cache"
41+
)
42+
GITHUB_FETCH_COMMITS_COMPARE_CACHE_TTL_SECONDS = 120
43+
GITHUB_CACHEABLE_REPOSITORY_PROVIDERS = frozenset(
44+
("integrations:github", "integrations:github_enterprise")
45+
)
46+
3647

3748
def generate_invalid_identity_email(identity, commit_failure=False):
3849
new_context = {
@@ -63,6 +74,63 @@ def generate_fetch_commits_error_email(release, repo, error_message):
6374
# we're future proofing this function a bit so it could be used with other code
6475

6576

77+
def get_github_compare_commits_cache_key(
78+
organization_id: int, repository_id: int, provider: str, start_sha: str | None, end_sha: str
79+
) -> str:
80+
digest = md5_text(
81+
organization_id, repository_id, provider, start_sha or "", end_sha
82+
).hexdigest()
83+
return f"fetch-commits:compare-commits:v1:{digest}"
84+
85+
86+
def fetch_compare_commits(
87+
*,
88+
cache_enabled: bool,
89+
repo: Repository,
90+
provider,
91+
is_integration_repo_provider: bool,
92+
start_sha: str | None,
93+
end_sha: str,
94+
user: RpcUser | None,
95+
lifecycle,
96+
):
97+
cache_key = None
98+
provider = repo.provider
99+
if (
100+
cache_enabled
101+
and isinstance(provider, str)
102+
and provider in GITHUB_CACHEABLE_REPOSITORY_PROVIDERS
103+
and start_sha is not None
104+
):
105+
cache_key = get_github_compare_commits_cache_key(
106+
repo.organization_id, repo.id, provider, start_sha, end_sha
107+
)
108+
109+
if cache_key is not None:
110+
cached_repo_commits = cache.get(cache_key)
111+
lifecycle.add_extra("compare_commits_cache_enabled", True)
112+
if cached_repo_commits is not None:
113+
lifecycle.add_extra("compare_commits_cache_hit", True)
114+
return cached_repo_commits
115+
116+
lifecycle.add_extra("compare_commits_cache_hit", False)
117+
else:
118+
lifecycle.add_extra("compare_commits_cache_enabled", False)
119+
120+
if is_integration_repo_provider:
121+
repo_commits = provider.compare_commits(repo, start_sha, end_sha)
122+
else:
123+
repo_commits = provider.compare_commits(repo, start_sha, end_sha, actor=user)
124+
125+
if cache_key is not None:
126+
cache.set(
127+
cache_key,
128+
repo_commits,
129+
GITHUB_FETCH_COMMITS_COMPARE_CACHE_TTL_SECONDS,
130+
)
131+
return repo_commits
132+
133+
66134
def handle_invalid_identity(identity, commit_failure=False):
67135
# email the user
68136
msg = generate_invalid_identity_email(identity, commit_failure)
@@ -97,6 +165,11 @@ def fetch_commits(release_id: int, user_id: int, refs, prev_release_id=None, **k
97165
except Release.DoesNotExist:
98166
pass
99167

168+
organization = release.organization
169+
github_compare_commits_cache_enabled = features.has(
170+
GITHUB_FETCH_COMMITS_COMPARE_CACHE_FEATURE, organization, actor=user
171+
)
172+
100173
for ref in refs:
101174
repo = (
102175
Repository.objects.filter(
@@ -171,10 +244,16 @@ def fetch_commits(release_id: int, user_id: int, refs, prev_release_id=None, **k
171244
}
172245
)
173246
try:
174-
if is_integration_repo_provider:
175-
repo_commits = provider.compare_commits(repo, start_sha, end_sha)
176-
else:
177-
repo_commits = provider.compare_commits(repo, start_sha, end_sha, actor=user)
247+
repo_commits = fetch_compare_commits(
248+
cache_enabled=github_compare_commits_cache_enabled,
249+
repo=repo,
250+
provider=provider,
251+
is_integration_repo_provider=is_integration_repo_provider,
252+
start_sha=start_sha,
253+
end_sha=end_sha,
254+
user=user,
255+
lifecycle=lifecycle,
256+
)
178257
except NotImplementedError:
179258
pass
180259
except IntegrationResourceNotFoundError:

tests/sentry/integrations/github/test_repository.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,33 @@ def test_patchset_caching(self, get_jwt: mock.MagicMock) -> None:
206206
# Now that patchset was cached, github shouldn't have been called again
207207
assert len(responses.calls) == 1
208208

209+
@mock.patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1")
210+
@responses.activate
211+
def test_compare_commits_reuses_cached_patchset_across_calls(
212+
self, get_jwt: mock.MagicMock
213+
) -> None:
214+
responses.add(
215+
responses.GET,
216+
"https://api.github.com/repos/getsentry/example-repo/compare/xyz123...abcdef",
217+
json=orjson.loads(COMPARE_COMMITS_EXAMPLE),
218+
)
219+
responses.add(
220+
responses.GET,
221+
"https://api.github.com/repos/getsentry/example-repo/compare/xyz123...abcdef",
222+
json=orjson.loads(COMPARE_COMMITS_EXAMPLE),
223+
)
224+
responses.add(
225+
responses.GET,
226+
"https://api.github.com/repos/getsentry/example-repo/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e",
227+
json=orjson.loads(GET_COMMIT_EXAMPLE),
228+
)
229+
230+
first = self.provider.compare_commits(self.repository, "xyz123", "abcdef")
231+
second = self.provider.compare_commits(self.repository, "xyz123", "abcdef")
232+
233+
assert first == second
234+
assert len(responses.calls) == 3
235+
209236
@responses.activate
210237
def test_compare_commits_failure(self) -> None:
211238
responses.add(

tests/sentry/tasks/test_commits.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,19 @@
1717
from sentry.testutils.asserts import assert_slo_metric
1818
from sentry.testutils.cases import TestCase
1919
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
20+
from sentry.utils.cache import cache
2021
from social_auth.models import UserSocialAuth
2122

2223

2324
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
2425
class FetchCommitsTest(TestCase):
26+
def _github_compare_commits_result(self, repo_name: str, end_sha: str) -> list[dict[str, str]]:
27+
return [
28+
{"id": "62de626b7c7cfb8e77efb4273b1a3df4123e6216", "repository": repo_name},
29+
{"id": "58de626b7c7cfb8e77efb4273b1a3df4123e6345", "repository": repo_name},
30+
{"id": end_sha, "repository": repo_name},
31+
]
32+
2533
def _test_simple_action(self, user, org):
2634
repo = Repository.objects.create(name="example", provider="dummy", organization_id=org.id)
2735
release = Release.objects.create(organization_id=org.id, version="abcabcabc")
@@ -86,6 +94,154 @@ def test_duplicate_repositories(self, mock_record: MagicMock) -> None:
8694
Repository.objects.create(name="example", provider="dummy", organization_id=org.id)
8795
self._test_simple_action(user=self.user, org=org)
8896

97+
@patch("sentry.integrations.github.repository.GitHubRepositoryProvider.compare_commits")
98+
def test_github_compare_commits_cache_flag_disabled(
99+
self, mock_compare_commits: MagicMock, mock_record: MagicMock
100+
) -> None:
101+
self.login_as(user=self.user)
102+
cache.clear()
103+
104+
org = self.create_organization(owner=self.user, name="baz")
105+
repo = Repository.objects.create(
106+
name="example",
107+
provider="integrations:github",
108+
organization_id=org.id,
109+
)
110+
previous_release = Release.objects.create(organization_id=org.id, version="old-release")
111+
previous_commit = Commit.objects.create(
112+
organization_id=org.id, repository_id=repo.id, key="a" * 40
113+
)
114+
ReleaseHeadCommit.objects.create(
115+
organization_id=org.id,
116+
repository_id=repo.id,
117+
release=previous_release,
118+
commit=previous_commit,
119+
)
120+
121+
refs = [{"repository": repo.name, "commit": "b" * 40}]
122+
mock_compare_commits.return_value = self._github_compare_commits_result(repo.name, "b" * 40)
123+
124+
first_release = Release.objects.create(organization_id=org.id, version="new-release-1")
125+
second_release = Release.objects.create(organization_id=org.id, version="new-release-2")
126+
127+
with self.tasks():
128+
fetch_commits(
129+
release_id=first_release.id,
130+
user_id=self.user.id,
131+
refs=refs,
132+
previous_release_id=previous_release.id,
133+
)
134+
fetch_commits(
135+
release_id=second_release.id,
136+
user_id=self.user.id,
137+
refs=refs,
138+
previous_release_id=previous_release.id,
139+
)
140+
141+
assert mock_compare_commits.call_count == 2
142+
143+
@patch("sentry.integrations.github.repository.GitHubRepositoryProvider.compare_commits")
144+
def test_github_compare_commits_cache_flag_enabled(
145+
self, mock_compare_commits: MagicMock, mock_record: MagicMock
146+
) -> None:
147+
self.login_as(user=self.user)
148+
cache.clear()
149+
150+
org = self.create_organization(owner=self.user, name="baz")
151+
repo = Repository.objects.create(
152+
name="example",
153+
provider="integrations:github",
154+
organization_id=org.id,
155+
)
156+
previous_release = Release.objects.create(organization_id=org.id, version="old-release")
157+
previous_commit = Commit.objects.create(
158+
organization_id=org.id, repository_id=repo.id, key="a" * 40
159+
)
160+
ReleaseHeadCommit.objects.create(
161+
organization_id=org.id,
162+
repository_id=repo.id,
163+
release=previous_release,
164+
commit=previous_commit,
165+
)
166+
167+
refs = [{"repository": repo.name, "commit": "b" * 40}]
168+
mock_compare_commits.return_value = self._github_compare_commits_result(repo.name, "b" * 40)
169+
170+
first_release = Release.objects.create(organization_id=org.id, version="new-release-1")
171+
second_release = Release.objects.create(organization_id=org.id, version="new-release-2")
172+
173+
with self.feature(
174+
{"organizations:integrations-github-fetch-commits-compare-cache": [org.slug]}
175+
):
176+
with self.tasks():
177+
fetch_commits(
178+
release_id=first_release.id,
179+
user_id=self.user.id,
180+
refs=refs,
181+
previous_release_id=previous_release.id,
182+
)
183+
fetch_commits(
184+
release_id=second_release.id,
185+
user_id=self.user.id,
186+
refs=refs,
187+
previous_release_id=previous_release.id,
188+
)
189+
190+
assert mock_compare_commits.call_count == 1
191+
192+
@patch("sentry.integrations.github.repository.GitHubRepositoryProvider.compare_commits")
193+
def test_github_compare_commits_cache_key_variance_on_end_sha(
194+
self, mock_compare_commits: MagicMock, mock_record: MagicMock
195+
) -> None:
196+
self.login_as(user=self.user)
197+
cache.clear()
198+
199+
org = self.create_organization(owner=self.user, name="baz")
200+
repo = Repository.objects.create(
201+
name="example",
202+
provider="integrations:github",
203+
organization_id=org.id,
204+
)
205+
previous_release = Release.objects.create(organization_id=org.id, version="old-release")
206+
previous_commit = Commit.objects.create(
207+
organization_id=org.id, repository_id=repo.id, key="a" * 40
208+
)
209+
ReleaseHeadCommit.objects.create(
210+
organization_id=org.id,
211+
repository_id=repo.id,
212+
release=previous_release,
213+
commit=previous_commit,
214+
)
215+
216+
refs_first = [{"repository": repo.name, "commit": "b" * 40}]
217+
refs_second = [{"repository": repo.name, "commit": "c" * 40}]
218+
mock_compare_commits.side_effect = [
219+
self._github_compare_commits_result(repo.name, "b" * 40),
220+
self._github_compare_commits_result(repo.name, "c" * 40),
221+
]
222+
223+
first_release = Release.objects.create(organization_id=org.id, version="new-release-1")
224+
second_release = Release.objects.create(organization_id=org.id, version="new-release-2")
225+
226+
with self.feature(
227+
{"organizations:integrations-github-fetch-commits-compare-cache": [org.slug]}
228+
):
229+
with self.tasks():
230+
fetch_commits(
231+
release_id=first_release.id,
232+
user_id=self.user.id,
233+
refs=refs_first,
234+
previous_release_id=previous_release.id,
235+
)
236+
fetch_commits(
237+
release_id=second_release.id,
238+
user_id=self.user.id,
239+
refs=refs_second,
240+
previous_release_id=previous_release.id,
241+
)
242+
243+
assert mock_compare_commits.call_count == 2
244+
89245
def test_release_locked(self, mock_record_event: MagicMock) -> None:
90246
self.login_as(user=self.user)
91247
org = self.create_organization(owner=self.user, name="baz")

0 commit comments

Comments
 (0)