Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
363897d
Checkpoint
cmanallen Mar 17, 2026
9f6a02e
Another checkpoint
cmanallen Mar 17, 2026
b0c955a
API client refactor
cmanallen Mar 18, 2026
4dc4fb3
Complete refactor to new raw response type handling
cmanallen Mar 18, 2026
2a0be41
Simplify API
cmanallen Mar 18, 2026
7154bd0
Minor code quality improvements
cmanallen Mar 18, 2026
f750631
Pagination params are sent in the params not the headers
cmanallen Mar 18, 2026
5ff99d3
Comment headers
cmanallen Mar 18, 2026
d936a19
Test rewrite
cmanallen Mar 18, 2026
ec30a79
Add initial rate-limiting behavior
cmanallen Mar 19, 2026
2347d9e
Merge branch 'master' into cmanallen/scm-rate-limits
cmanallen Mar 20, 2026
52e27cf
Translate get_archive_link to new format
cmanallen Mar 20, 2026
4d5003e
Update test coverage
cmanallen Mar 20, 2026
274c5bd
Revert "Add initial rate-limiting behavior"
cmanallen Mar 20, 2026
d548aaf
Force raise for status
cmanallen Mar 20, 2026
4c2b7c3
Remove weird AI artifacts
cmanallen Mar 20, 2026
0011f36
Fix typing
cmanallen Mar 20, 2026
b8b85fc
Add rate-limit handlers
cmanallen Mar 23, 2026
3f9a027
Merge branch 'master' into cmanallen/scm-rate-limits
cmanallen Mar 23, 2026
b1e041d
Remove unnecessary assert
cmanallen Mar 23, 2026
8d7bd04
Delete dead integration code
cmanallen Mar 23, 2026
ea2d07f
Lingering changes
cmanallen Mar 23, 2026
40337be
Simplify calling convention
cmanallen Mar 23, 2026
93ff061
Disable redirects
cmanallen Mar 23, 2026
5c90974
Use proper date foratting
cmanallen Mar 23, 2026
72780b1
Use BaseApiResponse
cmanallen Mar 23, 2026
502a4f1
Use the location header
cmanallen Mar 23, 2026
77669b6
Remove url attribute
cmanallen Mar 23, 2026
759828e
Re-add headers key
cmanallen Mar 23, 2026
b038fbc
Get JSON data
cmanallen Mar 23, 2026
1277e5f
Remove force_raise_for_status
cmanallen Mar 23, 2026
df9ebbc
Remove default pagination parameters because some actions do not requ…
cmanallen Mar 23, 2026
4ae7fa7
Merge branch 'cmanallen/scm-rate-limits' into cmanallen/scm-github-dy…
cmanallen Mar 23, 2026
96f79ba
Add rate-limit helpers
cmanallen Mar 23, 2026
ec820be
Add rate-limit implementation using redis cluster
cmanallen Mar 23, 2026
46e0353
Update redis implementation
cmanallen Mar 23, 2026
b976cf8
Add integration coverage
cmanallen Mar 23, 2026
f045018
Move rate-limiter behavior into a class
cmanallen Mar 24, 2026
14bd3fe
Fix typing
cmanallen Mar 24, 2026
a9ec3a1
Update coverage
cmanallen Mar 24, 2026
b887b44
Add rate-limiter implementation
cmanallen Mar 24, 2026
04bc724
Update mock to be API compatible
cmanallen Mar 24, 2026
8afb17d
Remove dead code
cmanallen Mar 24, 2026
d6f2d3d
Add entry point in actions.py
cmanallen Mar 24, 2026
d2ce5c5
Add docstring and assert invariant
cmanallen Mar 25, 2026
a927c83
Consistent naming
cmanallen Mar 25, 2026
b49f43e
GitHubProvider enforces RateLimitProvider parameter must be specified
cmanallen Mar 25, 2026
c03875f
Update codeowners
cmanallen Mar 25, 2026
f93d7d1
Add DynamicRateLimiter docstring
cmanallen Mar 25, 2026
d4709a1
Remove scm test module
cmanallen Mar 25, 2026
2875d1e
More docstrings
cmanallen Mar 25, 2026
a9f8aae
feat(scm): Add dynamic rate limits for GitHub provider (#111450)
cmanallen Mar 25, 2026
c15fecd
Merge branch 'master' into cmanallen/scm-rate-limits
cmanallen Mar 25, 2026
310ade5
Catch ApiError as well
cmanallen Mar 25, 2026
4eea75d
Add Redis failure handling
cmanallen Mar 25, 2026
b96afc6
Fix graphql handling
cmanallen Mar 25, 2026
b82c7e9
Add graphql transform coverage
cmanallen Mar 25, 2026
6a5eb8e
Merge branch 'cmanallen/scm-github-dynamic-rate-limits' into cmanalle…
cmanallen Mar 25, 2026
33fa804
Check status code and headers before transforming
cmanallen Mar 26, 2026
05b7ba7
Add status_code handling
cmanallen Mar 26, 2026
8c2d26d
Fix typing
cmanallen Mar 26, 2026
da19da5
Merge branch 'master' into cmanallen/scm-rate-limits
cmanallen Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get
/tests/sentry/integrations/vsts/ @getsentry/ecosystem @getsentry/scm
/tests/sentry/integrations/vsts_extension/ @getsentry/ecosystem @getsentry/scm
/tests/sentry/integrations/perforce/ @getsentry/ecosystem @getsentry/scm
/tests/sentry/scm/ @getsentry/scm
## End of SCM

# End of Coding Workflows
Expand Down
14 changes: 0 additions & 14 deletions .github/codeowners-coverage-baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2328,20 +2328,6 @@ tests/sentry/runner/commands/test_run.py
tests/sentry/runner/commands/unsetsync.yaml
tests/sentry/runner/commands/valid_patch.yaml
tests/sentry/runner/test_initializer.py
tests/sentry/scm/endpoints/test_scm_rpc.py
tests/sentry/scm/integration/test_github_provider_integration.py
tests/sentry/scm/integration/test_helpers_integration.py
tests/sentry/scm/integration/test_ipc_integration.py
tests/sentry/scm/integration/test_scm_actions_integration.py
tests/sentry/scm/test_fixtures.py
tests/sentry/scm/unit/private/test_ipc.py
tests/sentry/scm/unit/test_github_provider.py
tests/sentry/scm/unit/test_gitlab_provider.py
tests/sentry/scm/unit/test_helpers.py
tests/sentry/scm/unit/test_scm_actions.py
tests/sentry/scm/unit/test_scm_utils.py
tests/sentry/scm/unit/test_stream.py
tests/sentry/scm/unit/test_stream_producer.py
tests/sentry/security/__init__.py
tests/sentry/security/test_csp.py
tests/sentry/security/test_utils.py
Expand Down
1 change: 1 addition & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ def env(
SENTRY_SESSION_STORE_REDIS_CLUSTER = "default"
SENTRY_AUTH_IDPMIGRATION_REDIS_CLUSTER = "default"
SENTRY_SNOWFLAKE_REDIS_CLUSTER = "default"
SENTRY_SCM_REDIS_CLUSTER = "default"

# Hosts that are allowed to use system token authentication.
# http://en.wikipedia.org/wiki/Reserved_IP_addresses
Expand Down
273 changes: 2 additions & 271 deletions src/sentry/integrations/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,85 +53,6 @@
# many requests left for other features that need to reach Github
MINIMUM_REQUESTS = 200

GET_PULL_REQUEST_COMMENTS_QUERY = """
query GetPullRequestComments(
$owner: String!,
$repo: String!,
$prNumber: Int!,
$commentsAfter: String,
$includeComments: Boolean!,
$reviewThreadsAfter: String,
$includeThreads: Boolean!
) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
comments(first: 100, after: $commentsAfter) @include(if: $includeComments) {
nodes {
id
body
isMinimized
author { login databaseId __typename }
}
pageInfo { hasNextPage endCursor }
}
reviewThreads(first: 100, after: $reviewThreadsAfter) @include(if: $includeThreads) {
nodes {
id
isCollapsed
isOutdated
isResolved
comments(last: 100) {
nodes {
id
fullDatabaseId
url
body
isMinimized
path
startLine
line
diffHunk
createdAt
updatedAt
reactions(last: 10) {
nodes { content }
totalCount
}
author { login databaseId __typename }
}
}
}
pageInfo { hasNextPage endCursor }
}
}
}
}
"""

MINIMIZE_COMMENT_MUTATION = """
mutation MinimizeComment($commentId: ID!, $reason: ReportedContentClassifiers!) {
minimizeComment(input: {subjectId: $commentId, classifier: $reason}) {
minimizedComment { isMinimized }
}
}
"""

RESOLVE_REVIEW_THREAD_MUTATION = """
mutation ResolveReviewThread($threadId: ID!) {
resolveReviewThread(input: {threadId: $threadId}) {
thread { isResolved }
}
}
"""

DELETE_PULL_REQUEST_REVIEW_COMMENT_MUTATION = """
mutation DeletePullRequestReviewComment($commentNodeId: ID!) {
deletePullRequestReviewComment(input: {id: $commentNodeId}) {
clientMutationId
}
}
"""

JWT_AUTH_ROUTES = ("/app/installations", "access_tokens")


Expand Down Expand Up @@ -430,18 +351,11 @@ def repo_hooks(self, repo: str) -> Sequence[Any]:
"""
return self.get(f"/repos/{repo}/hooks")

def get_commits(
self, repo: str, sha: str | None = None, path: str | None = None
) -> Sequence[Any]:
def get_commits(self, repo: str) -> Sequence[Any]:
"""
https://docs.github.com/en/rest/commits/commits#list-commits
"""
params: dict[str, str] = {}
if sha:
params["sha"] = sha
if path:
params["path"] = path
return self.get(f"/repos/{repo}/commits", params=params)
return self.get(f"/repos/{repo}/commits")

def get_commit(self, repo: str, sha: str) -> Any:
"""
Expand Down Expand Up @@ -579,104 +493,6 @@ def get_tree(self, repo_full_name: str, tree_sha: str) -> list[dict[str, Any]]:
)
return contents["tree"]

def get_tree_full(
self, repo_full_name: str, tree_sha: str, recursive: bool = True
) -> dict[str, Any]:
"""https://docs.github.com/en/rest/git/trees#get-a-tree

Returns the full API response including the ``truncated`` flag.
"""
params: dict[str, int] = {}
if recursive:
params["recursive"] = 1
contents: dict[str, Any] = self.get(
f"/repos/{repo_full_name}/git/trees/{tree_sha}",
params=params,
)
return contents

def get_branch(self, repo: str, branch: str) -> Any:
"""https://docs.github.com/en/rest/branches/branches#get-a-branch"""
return self.get(f"/repos/{repo}/branches/{branch}")

def get_git_ref(self, repo: str, ref: str) -> Any:
"""https://docs.github.com/en/rest/git/refs#get-a-reference"""
return self.get(f"/repos/{repo}/git/refs/heads/{ref}")

def create_git_ref(self, repo: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/git/refs#create-a-reference"""
return self.post(f"/repos/{repo}/git/refs", data=data)

def update_git_ref(self, repo: str, ref: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/git/refs#update-a-reference"""
return self.patch(f"/repos/{repo}/git/refs/heads/{ref}", data=data)

def create_git_blob(self, repo: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/git/blobs#create-a-blob"""
return self.post(f"/repos/{repo}/git/blobs", data=data)

def get_file_content(self, repo: str, path: str, ref: str | None = None) -> Any:
"""https://docs.github.com/en/rest/repos/contents#get-repository-content"""
params = {}
if ref:
params["ref"] = ref
return self.get(f"/repos/{repo}/contents/{path}", params=params)

def get_git_commit(self, repo: str, sha: str) -> Any:
"""https://docs.github.com/en/rest/git/commits#get-a-commit-object"""
return self.get(f"/repos/{repo}/git/commits/{sha}")

def create_git_tree(self, repo: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/git/trees#create-a-tree"""
return self.post(f"/repos/{repo}/git/trees", data=data)

def create_git_commit(self, repo: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/git/commits#create-a-commit"""
return self.post(f"/repos/{repo}/git/commits", data=data)

def list_pull_requests(self, repo: str, state: str = "open", head: str | None = None) -> Any:
"""https://docs.github.com/en/rest/pulls/pulls#list-pull-requests"""
params: dict[str, Any] = {"state": state}
if head:
params["head"] = head
return self.get(f"/repos/{repo}/pulls", params=params)

def get_pull_request_commits(self, repo: str, pull_number: str) -> Any:
"""https://docs.github.com/en/rest/pulls/pulls#list-commits-on-a-pull-request"""
return self.get(f"/repos/{repo}/pulls/{pull_number}/commits")

def get_pull_request_diff(self, repo: str, pull_number: str) -> Any:
"""https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request (diff format)"""
return self.get(
f"/repos/{repo}/pulls/{pull_number}",
headers={"Accept": "application/vnd.github.v3.diff"},
allow_text=True,
)

def create_pull_request(self, repo: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request"""
return self.post(f"/repos/{repo}/pulls", data=data)

def update_pull_request(self, repo: str, pull_number: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/pulls/pulls#update-a-pull-request"""
return self.patch(f"/repos/{repo}/pulls/{pull_number}", data=data)

def create_review_request(self, repo: str, pull_number: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/pulls/review-requests#request-reviewers-for-a-pull-request"""
return self.post(f"/repos/{repo}/pulls/{pull_number}/requested_reviewers", data=data)

def create_review_comment(self, repo: str, pull_number: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/pulls/comments#create-a-review-comment-for-a-pull-request"""
return self.post(f"/repos/{repo}/pulls/{pull_number}/comments", data=data)

def create_review(self, repo: str, pull_number: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/pulls/reviews#create-a-review-for-a-pull-request"""
return self.post(f"/repos/{repo}/pulls/{pull_number}/reviews", data=data)

def update_check_run(self, repo: str, check_run_id: str, data: dict[str, Any]) -> Any:
"""https://docs.github.com/en/rest/checks/runs#update-a-check-run"""
return self.patch(f"/repos/{repo}/check-runs/{check_run_id}", data=data)

# Used by RepoTreesIntegration
def should_count_api_error(self, error: ApiError, extra: dict[str, str]) -> bool:
"""
Expand Down Expand Up @@ -895,18 +711,6 @@ def create_comment_reaction(self, repo: str, comment_id: str, reaction: GitHubRe
endpoint = f"/repos/{repo}/issues/comments/{comment_id}/reactions"
return self.post(endpoint, data={"content": reaction.value})

def delete_issue_comment(self, repo: str, comment_id: str) -> None:
"""
https://docs.github.com/en/rest/issues/comments#delete-an-issue-comment
"""
self.delete(f"/repos/{repo}/issues/comments/{comment_id}")

def delete_comment_reaction(self, repo: str, comment_id: str, reaction_id: str) -> None:
"""
https://docs.github.com/en/rest/reactions/reactions#delete-an-issue-comment-reaction
"""
self.delete(f"/repos/{repo}/issues/comments/{comment_id}/reactions/{reaction_id}")

def get_user(self, gh_username: str) -> Any:
"""
https://docs.github.com/en/rest/users/users#get-a-user
Expand Down Expand Up @@ -947,79 +751,6 @@ def get_file(

return b64decode(contents["content"]).decode("utf-8")

def _graphql(
self,
query: str,
variables: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Execute a GraphQL query/mutation against GitHub's API."""
payload: dict[str, Any] = {"query": query}
if variables:
payload["variables"] = variables

response = self.post(path="/graphql", data=payload, allow_text=False)

if not isinstance(response, Mapping) or (
"data" not in response and "errors" not in response
):
raise ApiError("GraphQL response is not in expected format")

errors = response.get("errors", [])
if errors:
if any(error.get("type") == "RATE_LIMITED" for error in errors):
raise ApiRateLimitedError("GitHub rate limit exceeded")
if not response.get("data"):
err_message = "\n".join(e.get("message", "") for e in errors)
raise ApiError(err_message)

return response.get("data", {})

def get_pull_request_comments_graphql(
self,
owner: str,
repo: str,
pr_number: int,
*,
comments_after: str | None = None,
include_comments: bool = True,
review_threads_after: str | None = None,
include_threads: bool = True,
) -> dict[str, Any]:
"""Fetch PR comments and review threads via GraphQL with independent pagination."""
variables: dict[str, Any] = {
"owner": owner,
"repo": repo,
"prNumber": pr_number,
"includeComments": include_comments,
"includeThreads": include_threads,
}
if comments_after is not None:
variables["commentsAfter"] = comments_after
if review_threads_after is not None:
variables["reviewThreadsAfter"] = review_threads_after
return self._graphql(GET_PULL_REQUEST_COMMENTS_QUERY, variables)

def minimize_comment(self, comment_node_id: str, reason: str) -> dict[str, Any]:
"""Minimize (collapse) a comment by its GraphQL node ID."""
return self._graphql(
MINIMIZE_COMMENT_MUTATION,
{"commentId": comment_node_id, "reason": reason},
)

def resolve_review_thread(self, thread_node_id: str) -> dict[str, Any]:
"""Resolve a review thread by its GraphQL node ID."""
return self._graphql(
RESOLVE_REVIEW_THREAD_MUTATION,
{"threadId": thread_node_id},
)

def delete_pull_request_review_comment(self, comment_node_id: str) -> dict[str, Any]:
"""Delete a pull request review comment by its GraphQL node ID."""
return self._graphql(
DELETE_PULL_REQUEST_REVIEW_COMMENT_MUTATION,
{"commentNodeId": comment_node_id},
)

def get_blame_for_files(
self, files: Sequence[SourceLineInfo], extra: dict[str, Any]
) -> list[FileBlameInfo]:
Expand Down
4 changes: 3 additions & 1 deletion src/sentry/scm/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
UpdateCheckRunProtocol,
UpdatePullRequestProtocol,
)
from sentry.scm.private.rate_limit import RateLimitProvider
from sentry.scm.types import (
SHA,
ActionResult,
Expand Down Expand Up @@ -144,14 +145,15 @@ def make_from_integration(
integration: Integration | RpcIntegration,
*,
referrer: Referrer = "shared",
rate_limit_provider: RateLimitProvider | None = None,
record_count: Callable[[str, int, dict[str, str]], None] = record_count_metric,
) -> Self:
provider = initialize_provider(
organization_id,
repository.id,
fetch_repository=lambda _, __: map_repository_model_to_repository(repository),
fetch_service_provider=lambda oid, repo: map_integration_to_provider(
oid, integration, repo
oid, integration, repo, rate_limit_provider=rate_limit_provider
),
)

Expand Down
Loading
Loading