diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 0970a4a0dc6e7e..01884dee888063 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -134,6 +134,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:integrations-claude-code", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-copilot-agent", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:integrations-github-platform-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + manager.add("organizations:integrations-scm-migration", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:scm-repositories-v2", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) manager.add("organizations:github-repo-auto-sync-webhook", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) manager.add("organizations:integrations-slack-staging", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) diff --git a/src/sentry/integrations/api/endpoints/organization_repository_platforms.py b/src/sentry/integrations/api/endpoints/organization_repository_platforms.py index 770761326ac80c..6842cc02268250 100644 --- a/src/sentry/integrations/api/endpoints/organization_repository_platforms.py +++ b/src/sentry/integrations/api/endpoints/organization_repository_platforms.py @@ -61,7 +61,7 @@ def get(self, request: Request, organization: Organization, repo: Repository) -> client = GitHubApiClient(integration=integration, org_integration_id=org_integration.id) try: - platforms = detect_platforms(client=client, repo=repo.name) + platforms = detect_platforms(client=client, repo=repo.name, repository=repo) except (ApiError, ValueError): logger.exception( "integrations.github.platform_detection_failed", diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index e8199bba94ee8d..8fb1c08f7439c6 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -65,6 +65,8 @@ from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.types import PipelineStepResult from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView +from sentry.scm.cases import source_urls as source_urls_case +from sentry.scm.cases._common import use_scm_for_org_id from sentry.shared_integrations.constants import ERR_INTERNAL, ERR_UNAUTHORIZED from sentry.shared_integrations.exceptions import ( ApiError, @@ -256,17 +258,21 @@ def source_url_matches(self, url: str) -> bool: return url.startswith("https://{}".format(self.model.metadata["domain_name"])) def format_source_url(self, repo: Repository, filepath: str, branch: str | None) -> str: + if use_scm_for_org_id(repo.organization_id): + return source_urls_case.format_source_url(repo, filepath, branch) # Must format the url ourselves since `check_file` is a head request # "https://github.com/octokit/octokit.rb/blob/master/README.md" return f"https://github.com/{repo.name}/blob/{branch}/{filepath}" def extract_branch_from_source_url(self, repo: Repository, url: str) -> str: + # NOTE: URL parsing is provider-specific; no scm.types protocol covers it. if not repo.url: return "" branch, _ = parse_github_blob_url(repo.url, url) return branch def extract_source_path_from_source_url(self, repo: Repository, url: str) -> str: + # NOTE: URL parsing is provider-specific; no scm.types protocol covers it. if not repo.url: return "" _, source_path = parse_github_blob_url(repo.url, url) @@ -296,6 +302,8 @@ def get_repositories( This fetches all repositories accessible to the Github App https://docs.github.com/en/rest/apps/installations#list-repositories-accessible-to-the-app-installation """ + # NOTE: not migrated to sentry_scm. scm.types has no ListRepositoriesProtocol + # for "list integration-accessible repos". Revisit once sentry_scm adds one. client = self.get_client() def to_repo_info(raw_repos: Iterable[Mapping[str, Any]]) -> list[RepositoryInfo]: @@ -362,6 +370,8 @@ def has_repo_access(self, repo: RpcRepository) -> bool: return True def search_issues(self, query: str | None, **kwargs) -> dict[str, Any]: + # NOTE: not migrated to sentry_scm. scm.types has no SearchIssuesProtocol; + # search query syntax is provider-specific. if query is None: query = "" resp = self.get_client().search_issues(query) diff --git a/src/sentry/integrations/github/issue_sync.py b/src/sentry/integrations/github/issue_sync.py index 1bab8febb74605..e9aaaba1b39a2c 100644 --- a/src/sentry/integrations/github/issue_sync.py +++ b/src/sentry/integrations/github/issue_sync.py @@ -5,11 +5,15 @@ from typing import Any from sentry import features +from sentry.constants import ObjectStatus from sentry.integrations.mixins.issues import IssueSyncIntegration, ResolveSyncAction from sentry.integrations.models.external_actor import ExternalActor from sentry.integrations.models.external_issue import ExternalIssue from sentry.integrations.services.integration import integration_service from sentry.integrations.types import EXTERNAL_PROVIDERS_REVERSE, ExternalProviderEnum +from sentry.models.repository import Repository +from sentry.scm.cases import issue_sync as issue_sync_case +from sentry.scm.cases._common import use_scm_for_org_id from sentry.shared_integrations.exceptions import ApiError from sentry.users.services.user.model import RpcUser from sentry.users.services.user.service import user_service @@ -66,6 +70,8 @@ def sync_assignee_outbound( Propagate a sentry issue's assignee to a linked GitHub issue's assignee. If assign=True, we're assigning the issue. Otherwise, deassign. """ + # NOTE: scm.types has no UpdateIssueProtocol covering assignees, so this + # path stays on the GitHub client. Revisit once sentry_scm adds one. client = self.get_client() repo_id, issue_num = self.split_external_issue_key(external_issue.key) @@ -126,6 +132,8 @@ def sync_status_outbound( Propagate a sentry issue's status to a linked GitHub issue's status. For GitHub, we only support open/closed states. """ + # NOTE: scm.types has no UpdateIssueProtocol covering issue state, so this + # path stays on the GitHub client. Revisit once sentry_scm adds one. client = self.get_client() repo_id, issue_num = self.split_external_issue_key(external_issue.key) @@ -222,6 +230,8 @@ def create_comment_attribution(self, user_id, comment_text): return f"{attribution}{quoted_text}" def update_comment(self, issue_id, user_id, group_note): + # NOTE: scm.types has no UpdateIssueCommentProtocol, so this path stays on + # the GitHub client. Revisit once sentry_scm adds one. quoted_comment = self.create_comment_attribution(user_id, group_note.data["text"]) repo, issue_number = issue_id.rsplit("#", 1) @@ -237,4 +247,14 @@ def create_comment(self, issue_id, user_id, group_note): repo, issue_number = issue_id.rsplit("#", 1) + if use_scm_for_org_id(self.organization_id): + repo_model = Repository.objects.filter( + name=repo, + integration_id=self.model.id, + organization_id=self.organization_id, + status=ObjectStatus.ACTIVE, + ).first() + if repo_model is not None: + return issue_sync_case.create_comment(repo_model, issue_number, quoted_comment) + return self.get_client().create_comment(repo, issue_number, {"body": quoted_comment}) diff --git a/src/sentry/integrations/github/issues.py b/src/sentry/integrations/github/issues.py index b74cd1f02e3c2f..7d37de0ffb87bd 100644 --- a/src/sentry/integrations/github/issues.py +++ b/src/sentry/integrations/github/issues.py @@ -16,6 +16,8 @@ from sentry.models.group import Group from sentry.models.repository import Repository from sentry.organizations.services.organization.service import organization_service +from sentry.scm.cases import issues as issues_case +from sentry.scm.cases._common import use_scm_for_org_id from sentry.services.eventstore.models import Event, GroupEvent from sentry.shared_integrations.exceptions import ( ApiError, @@ -124,7 +126,6 @@ def get_group_description(self, group: Group, event: Event | GroupEvent, **kwarg def after_link_issue(self, external_issue: ExternalIssue, **kwargs: Any) -> None: data = kwargs["data"] - client = self.get_client() repo, issue_num = external_issue.key.split("#") if not repo: @@ -134,11 +135,31 @@ def after_link_issue(self, external_issue: ExternalIssue, **kwargs: Any) -> None raise IntegrationFormError({"externalIssue": "Issue number is required"}) comment = data.get("comment") - if comment: + if not comment: + return + + if use_scm_for_org_id(self.organization_id): + repo_model = Repository.objects.filter( + name=repo, + integration_id=self.model.id, + organization_id=self.organization_id, + status=ObjectStatus.ACTIVE, + ).first() + if repo_model is None: + raise IntegrationFormError( + {"repo": f"Given repository, {repo} does not belong to this installation"} + ) try: - client.create_comment(repo=repo, issue_id=issue_num, data={"body": comment}) + issues_case.create_issue_comment(repo_model, issue_num, comment) except ApiError as e: raise IntegrationError(self.message_from_error(e)) + return + + client = self.get_client() + try: + client.create_comment(repo=repo, issue_id=issue_num, data={"body": comment}) + except ApiError as e: + raise IntegrationError(self.message_from_error(e)) def get_persisted_default_config_fields(self) -> Sequence[str]: return ["repo"] @@ -220,18 +241,17 @@ def get_create_issue_config( ] def create_issue(self, data: Mapping[str, Any], **kwargs: Any) -> Mapping[str, Any]: - client = self.get_client() repo = data.get("repo") if not repo: raise IntegrationFormError({"repo": "Repository is required"}) - # Check the repository belongs to the integration - if not Repository.objects.filter( + repo_model = Repository.objects.filter( name=repo, integration_id=self.model.id, organization_id=self.organization_id, status=ObjectStatus.ACTIVE, - ).exists(): + ).first() + if repo_model is None: raise IntegrationFormError( {"repo": f"Given repository, {repo} does not belong to this installation"} ) @@ -243,6 +263,18 @@ def create_issue(self, data: Mapping[str, Any], **kwargs: Any) -> Mapping[str, A if not data.get("description"): raise IntegrationFormError({"description": "Description is required"}) + if use_scm_for_org_id(self.organization_id): + try: + return issues_case.create_issue( + repo_model, + title=data["title"], + body=data["description"], + assignee=data.get("assignee"), + labels=data.get("labels"), + ) + except ApiError as e: + self.raise_error(e) + issue_data = { "title": data["title"], "body": data["description"], @@ -254,6 +286,7 @@ def create_issue(self, data: Mapping[str, Any], **kwargs: Any) -> Mapping[str, A if data.get("labels"): issue_data["labels"] = data["labels"] + client = self.get_client() try: issue = client.create_issue(repo=repo, data=issue_data) except ApiError as e: @@ -324,17 +357,17 @@ def get_issue(self, issue_id: str, **kwargs: Any) -> Mapping[str, Any]: data = kwargs["data"] repo = data.get("repo") issue_num = data.get("externalIssue") - client = self.get_client() if not repo: raise IntegrationFormError({"repo": "Repository is required"}) - if not Repository.objects.filter( + repo_model = Repository.objects.filter( name=repo, integration_id=self.model.id, organization_id=self.organization_id, status=ObjectStatus.ACTIVE, - ).exists(): + ).first() + if repo_model is None: raise IntegrationFormError( {"repo": f"Given repository, {repo} does not belong to this installation"} ) @@ -342,6 +375,13 @@ def get_issue(self, issue_id: str, **kwargs: Any) -> Mapping[str, Any]: if not issue_num: raise IntegrationFormError({"externalIssue": "Issue number is required"}) + if use_scm_for_org_id(self.organization_id): + try: + return issues_case.get_issue(repo_model, issue_num) + except ApiError as e: + raise IntegrationError(self.message_from_error(e)) + + client = self.get_client() try: issue = client.get_issue(repo, issue_num) except ApiError as e: @@ -356,6 +396,19 @@ def get_issue(self, issue_id: str, **kwargs: Any) -> Mapping[str, Any]: } def get_allowed_assignees(self, repo: str) -> Sequence[tuple[str, str]]: + if use_scm_for_org_id(self.organization_id): + repo_model = Repository.objects.filter( + name=repo, + integration_id=self.model.id, + organization_id=self.organization_id, + status=ObjectStatus.ACTIVE, + ).first() + if repo_model is not None: + try: + return issues_case.get_repository_assignees(repo_model) + except Exception as e: + self.raise_error(e) + client = self.get_client() try: response = client.get_assignees(repo) @@ -367,6 +420,19 @@ def get_allowed_assignees(self, repo: str) -> Sequence[tuple[str, str]]: return (("", "Unassigned"),) + users def get_repo_labels(self, owner: str, repo: str) -> Sequence[tuple[str, str]]: + if use_scm_for_org_id(self.organization_id): + repo_model = Repository.objects.filter( + name=f"{owner}/{repo}", + integration_id=self.model.id, + organization_id=self.organization_id, + status=ObjectStatus.ACTIVE, + ).first() + if repo_model is not None: + try: + return issues_case.get_repository_labels(repo_model) + except Exception as e: + self.raise_error(e) + client = self.get_client() try: response = client.get_labels(owner, repo) diff --git a/src/sentry/integrations/github/platform_detection.py b/src/sentry/integrations/github/platform_detection.py index 192fdf5ba17c91..4e4db2e0f2233d 100644 --- a/src/sentry/integrations/github/platform_detection.py +++ b/src/sentry/integrations/github/platform_detection.py @@ -9,6 +9,9 @@ from yaml import YAMLError +from sentry.models.repository import Repository +from sentry.scm.cases import platform_detection as platform_detection_case +from sentry.scm.cases._common import use_scm_for_org_id from sentry.shared_integrations.exceptions import ApiError from sentry.utils import json from sentry.utils.yaml import safe_load @@ -932,9 +935,16 @@ def _ref_params(ref: str | None) -> dict[str, str]: def _get_repo_file_content( - client: GitHubBaseClient, repo: str, path: str, ref: str | None = None + client: GitHubBaseClient, + repo: str, + path: str, + ref: str | None = None, + repository: Repository | None = None, ) -> str | None: """Fetch a file's content from a GitHub repo. Returns None if not found.""" + if repository is not None and use_scm_for_org_id(repository.organization_id): + return platform_detection_case.read_file(repository, path, ref) + try: response = client.get( f"/repos/{repo}/contents/{path}", @@ -946,13 +956,19 @@ def _get_repo_file_content( def _get_root_entries( - client: GitHubBaseClient, repo: str, ref: str | None = None + client: GitHubBaseClient, + repo: str, + ref: str | None = None, + repository: Repository | None = None, ) -> tuple[set[str] | None, set[str] | None]: """Fetch the root-level file and directory names in a single API call. Returns (None, None) on API failure so callers can fall back to fetching files individually rather than assuming the repo root is empty. """ + if repository is not None and use_scm_for_org_id(repository.organization_id): + return platform_detection_case.list_root_entries(repository, ref) + try: response = client.get(f"/repos/{repo}/contents", params=_ref_params(ref)) files = {item["name"] for item in response if item.get("type") == "file" and "name" in item} @@ -1122,6 +1138,7 @@ def detect_platforms( client: GitHubBaseClient, repo: str, ref: str | None = None, + repository: Repository | None = None, ) -> list[DetectedPlatform]: """ Detect Sentry platforms for a GitHub repository. @@ -1136,8 +1153,10 @@ def detect_platforms( Results are ranked by bytes (descending), then priority (descending). Superseded frameworks (e.g. React when Next.js is present) are removed. """ + # NOTE: get_languages stays on the GitHub client. scm.types has no protocol + # for repository languages (GitHub Linguist is provider-specific). languages = client.get_languages(repo) - root_files, root_dirs = _get_root_entries(client, repo, ref) + root_files, root_dirs = _get_root_entries(client, repo, ref, repository=repository) # Find the top language and only process its base platform to limit # API calls — only one suggestion is shown to the user anyway. @@ -1181,7 +1200,7 @@ def detect_platforms( # Fetch file contents in one pass file_contents: dict[str, str] = {} for path in needed_paths: - content = _get_repo_file_content(client, repo, path, ref) + content = _get_repo_file_content(client, repo, path, ref, repository=repository) if content is not None: file_contents[path] = content diff --git a/src/sentry/integrations/github/repository.py b/src/sentry/integrations/github/repository.py index face59abfd17cd..871c3cdfd522be 100644 --- a/src/sentry/integrations/github/repository.py +++ b/src/sentry/integrations/github/repository.py @@ -15,6 +15,8 @@ from sentry.organizations.services.organization.model import RpcOrganization from sentry.plugins.providers import IntegrationRepositoryProvider from sentry.plugins.providers.integration_repository import RepositoryConfig +from sentry.scm.cases import repository as repository_case +from sentry.scm.cases._common import use_scm_for_org_id from sentry.shared_integrations.exceptions import ApiError, IntegrationError if TYPE_CHECKING: @@ -87,6 +89,9 @@ def _get_installation_and_client(self, repo: Repository) -> tuple[IntegrationIns def fetch_recent_commits( self, repo: Repository, end_sha: str, *, actor: Any | None = None ) -> Sequence[Mapping[str, Any]]: + if use_scm_for_org_id(repo.organization_id): + return repository_case.fetch_recent_commits(repo, end_sha) + installation, client = self._get_installation_and_client(repo) # use config name because that is kept in sync via webhooks name = repo.config["name"] @@ -105,6 +110,9 @@ def fetch_commits_for_compare_range( *, actor: Any | None = None, ) -> Sequence[Mapping[str, Any]]: + if use_scm_for_org_id(repo.organization_id): + return repository_case.fetch_commits_for_compare_range(repo, start_sha, end_sha) + installation, client = self._get_installation_and_client(repo) # use config name because that is kept in sync via webhooks name = repo.config["name"] @@ -188,6 +196,8 @@ def _transform_patchset(self, diff: Sequence[Mapping[str, Any]]) -> Sequence[Map return changes def pull_request_url(self, repo: Repository, pull_request: PullRequest) -> str: + if use_scm_for_org_id(repo.organization_id): + return repository_case.pull_request_url(repo, pull_request) return f"{repo.url}/pull/{pull_request.key}" def repository_external_slug(self, repo: Repository) -> str: diff --git a/src/sentry/integrations/github/search.py b/src/sentry/integrations/github/search.py index 3752e6e1093c87..e792dcff80092a 100644 --- a/src/sentry/integrations/github/search.py +++ b/src/sentry/integrations/github/search.py @@ -1,3 +1,6 @@ +# NOTE: not migrated to sentry_scm. scm.types has no SearchIssuesProtocol or +# SearchRepositoriesProtocol; search query syntax is provider-specific. Revisit +# once sentry_scm adds generic search protocols. from typing import TypeVar from rest_framework.response import Response diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index a6ba19ed581fee..065df226da7317 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -1,3 +1,6 @@ +# NOTE: not migrated to sentry_scm. The task is built around `client.get_repos()` +# (list integration-accessible repos), which has no scm.types protocol. Revisit +# once sentry_scm adds a ListRepositoriesProtocol. import logging from collections.abc import Mapping from typing import Any, TypedDict diff --git a/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_comments.py b/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_comments.py index 5e37a07194f09e..d3ed69141c74d0 100644 --- a/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_comments.py +++ b/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_comments.py @@ -26,6 +26,8 @@ PullRequestCommentsErrorResponse, ReviewComment, ) +from sentry.scm.cases import preprod as preprod_case +from sentry.scm.cases._common import use_scm_for_org from sentry.shared_integrations.exceptions import ApiError logger = logging.getLogger(__name__) @@ -67,26 +69,41 @@ def get( if not features.has("organizations:pr-page", organization, actor=request.user): return Response({"error": "Feature not enabled"}, status=403) - client = get_github_client(organization, repo_name) - if not client: - logger.warning( - "No GitHub client found for organization", - extra={"organization_id": organization.id}, - ) - error_data = PullRequestCommentsErrorResponse( - error="integration_not_found", - message="No GitHub integration found for this repository", - details="Unable to find a GitHub integration for the specified repository", - ) - return Response(error_data.dict(), status=404) - try: - general_comments_raw = self._fetch_pr_general_comments( - organization.id, client, repo_name, pr_number - ) - review_comments_raw = self._fetch_pr_review_comments( - organization.id, client, repo_name, pr_number - ) + if use_scm_for_org(organization): + general_comments_raw = preprod_case.get_issue_comments( + organization, repo_name, pr_number + ) + review_comments_raw = preprod_case.get_pull_request_comments( + organization, repo_name, pr_number + ) + if general_comments_raw is None or review_comments_raw is None: + error_data = PullRequestCommentsErrorResponse( + error="integration_not_found", + message="No GitHub integration found for this repository", + details="Unable to find a GitHub integration for the specified repository", + ) + return Response(error_data.dict(), status=404) + else: + client = get_github_client(organization, repo_name) + if not client: + logger.warning( + "No GitHub client found for organization", + extra={"organization_id": organization.id}, + ) + error_data = PullRequestCommentsErrorResponse( + error="integration_not_found", + message="No GitHub integration found for this repository", + details="Unable to find a GitHub integration for the specified repository", + ) + return Response(error_data.dict(), status=404) + + general_comments_raw = self._fetch_pr_general_comments( + organization.id, client, repo_name, pr_number + ) + review_comments_raw = self._fetch_pr_review_comments( + organization.id, client, repo_name, pr_number + ) # Parse general comments general_comments = [IssueComment.parse_obj(c) for c in general_comments_raw] diff --git a/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_details.py b/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_details.py index 459323e7ae0bbb..8b135e2512a6d3 100644 --- a/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_details.py +++ b/src/sentry/preprod/api/endpoints/pull_request/organization_pullrequest_details.py @@ -15,6 +15,8 @@ from sentry.preprod.integration_utils import get_github_client from sentry.preprod.pull_request.adapters import PullRequestDataAdapter from sentry.preprod.pull_request.types import PullRequestWithFiles +from sentry.scm.cases import preprod as preprod_case +from sentry.scm.cases._common import use_scm_for_org from sentry.shared_integrations.exceptions import ApiError logger = logging.getLogger(__name__) @@ -46,6 +48,35 @@ def get( if not features.has("organizations:pr-page", organization, actor=request.user): return Response({"error": "Feature not enabled"}, status=403) + if use_scm_for_org(organization): + try: + pr_details = preprod_case.get_pull_request(organization, repo_name, pr_number) + pr_files = preprod_case.get_pull_request_files(organization, repo_name, pr_number) + except ApiError: + logger.exception( + "GitHub API error when fetching PR data", + extra={"organization_id": organization.id, "pr_number": pr_number}, + ) + error_data = PullRequestDataAdapter.create_error_response( + error="api_error", + message="Failed to fetch pull request data from GitHub", + details="A problem occurred when communicating with GitHub. Please try again later.", + ) + return Response(error_data.dict(), status=502) + + if pr_details is None or pr_files is None: + error_data = PullRequestDataAdapter.create_error_response( + error="integration_not_found", + message="No GitHub integration found for this repository", + details="Unable to find a GitHub integration for the specified repository", + ) + return Response(error_data.dict(), status=404) + + normalized_data = PullRequestDataAdapter.from_github_pr_data( + pr_details, list(pr_files), organization.id + ) + return Response(normalized_data.dict(), status=200) + client = get_github_client(organization, repo_name) if not client: logger.warning( diff --git a/src/sentry/scm/cases/__init__.py b/src/sentry/scm/cases/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/scm/cases/_common.py b/src/sentry/scm/cases/_common.py new file mode 100644 index 00000000000000..9e71195b4fa534 --- /dev/null +++ b/src/sentry/scm/cases/_common.py @@ -0,0 +1,27 @@ +from scm.manager import SourceCodeManager +from scm.types import Referrer + +from sentry import features +from sentry.models.organization import Organization +from sentry.models.repository import Repository +from sentry.organizations.services.organization import organization_service +from sentry.scm import factory + +SCM_MIGRATION_FLAG = "organizations:integrations-scm-migration" + + +def manager_for_repository(repository: Repository, referrer: Referrer) -> SourceCodeManager: + return factory.new(repository.organization_id, repository.id, referrer) + + +def use_scm_for_org(organization: Organization) -> bool: + return features.has(SCM_MIGRATION_FLAG, organization) + + +def use_scm_for_org_id(organization_id: int) -> bool: + org_context = organization_service.get_organization_by_id( + id=organization_id, include_projects=False, include_teams=False + ) + if org_context is None: + return False + return features.has(SCM_MIGRATION_FLAG, org_context.organization) diff --git a/src/sentry/scm/cases/issue_sync.py b/src/sentry/scm/cases/issue_sync.py new file mode 100644 index 00000000000000..9c1a7177a0c606 --- /dev/null +++ b/src/sentry/scm/cases/issue_sync.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from scm.types import CreateIssueCommentProtocol + +from sentry.models.repository import Repository +from sentry.scm.cases._common import manager_for_repository +from sentry.scm.errors import SCMCapabilityUnsupported + +_REFERRER = "sentry.integrations.github.issue_sync" + + +def create_comment(repository: Repository, issue_id: str, body: str) -> Mapping[str, Any]: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, CreateIssueCommentProtocol): + raise SCMCapabilityUnsupported("create_issue_comment", repository.provider) + + result = manager.create_issue_comment(issue_id=issue_id, body=body) + return result["data"] diff --git a/src/sentry/scm/cases/issues.py b/src/sentry/scm/cases/issues.py new file mode 100644 index 00000000000000..6d60fc430b7c82 --- /dev/null +++ b/src/sentry/scm/cases/issues.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from typing import Any + +from scm.types import ( + CreateIssueCommentProtocol, + CreateIssueProtocol, + GetIssueProtocol, + GetRepositoryAssigneesProtocol, + GetRepositoryLabelsProtocol, +) + +from sentry.models.repository import Repository +from sentry.scm.cases._common import manager_for_repository +from sentry.scm.errors import SCMCapabilityUnsupported + +_REFERRER = "sentry.integrations.github.issues" + + +def create_issue( + repository: Repository, + title: str, + body: str, + assignee: str | None = None, + labels: Sequence[str] | None = None, +) -> Mapping[str, Any]: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, CreateIssueProtocol): + raise SCMCapabilityUnsupported("create_issue", repository.provider) + + result = manager.create_issue( + title=title, + body=body, + assignees=[assignee] if assignee else None, + labels=list(labels) if labels else None, + ) + issue = result["data"] + return { + "key": issue["id"], + "title": issue["title"], + "description": issue["body"], + "url": issue["html_url"], + "repo": repository.name, + } + + +def get_issue(repository: Repository, issue_id: str) -> Mapping[str, Any]: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetIssueProtocol): + raise SCMCapabilityUnsupported("get_issue", repository.provider) + + result = manager.get_issue(issue_id) + issue = result["data"] + return { + "key": issue["id"], + "title": issue["title"], + "description": issue["body"], + "url": issue["html_url"], + "repo": repository.name, + } + + +def create_issue_comment(repository: Repository, issue_id: str, body: str) -> None: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, CreateIssueCommentProtocol): + raise SCMCapabilityUnsupported("create_issue_comment", repository.provider) + + manager.create_issue_comment(issue_id=issue_id, body=body) + + +def get_repository_assignees(repository: Repository) -> Sequence[tuple[str, str]]: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetRepositoryAssigneesProtocol): + return [] + + paginated = manager.get_repository_assignees() + users = tuple((author["username"], author["username"]) for author in paginated["data"]) + return (("", "Unassigned"),) + users + + +def get_repository_labels(repository: Repository) -> Sequence[tuple[str, str]]: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetRepositoryLabelsProtocol): + return [] + + paginated = manager.get_repository_labels() + + def natural_sort_pair(pair: tuple[str, str]) -> list[str | int]: + return [ + int(text) if text.isdecimal() else text.lower() + for text in re.split("([0-9]+)", pair[0]) + ] + + return tuple( + sorted( + [(label["name"], label["name"]) for label in paginated["data"]], + key=natural_sort_pair, + ) + ) diff --git a/src/sentry/scm/cases/platform_detection.py b/src/sentry/scm/cases/platform_detection.py new file mode 100644 index 00000000000000..508694429e6780 --- /dev/null +++ b/src/sentry/scm/cases/platform_detection.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from base64 import b64decode +from collections.abc import Sequence + +from scm.errors import SCMError +from scm.types import GetDirectoryContentsProtocol, GetFileContentProtocol + +from sentry.models.repository import Repository +from sentry.scm.cases._common import manager_for_repository + +_REFERRER = "sentry.integrations.github.platform_detection" + + +def read_file(repository: Repository, path: str, ref: str | None = None) -> str | None: + """Fetch a file's content from the repo. Returns None on any failure.""" + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetFileContentProtocol): + return None + + try: + result = manager.get_file_content(path, ref or "") + except SCMError: + return None + + file = result["data"] + try: + return b64decode(file["content"]).decode("utf-8") + except (KeyError, TypeError, UnicodeDecodeError, ValueError): + return None + + +def list_root_entries( + repository: Repository, ref: str | None = None +) -> tuple[set[str] | None, set[str] | None]: + """Fetch root-level files and directory names. Returns (None, None) on failure.""" + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetDirectoryContentsProtocol): + return None, None + + try: + paginated = manager.get_directory_contents("", ref=ref) + except SCMError: + return None, None + + entries: Sequence = paginated["data"] + files = {e["path"] for e in entries if e.get("type") == "file"} + dirs = {e["path"] for e in entries if e.get("type") == "directory"} + return files, dirs diff --git a/src/sentry/scm/cases/preprod.py b/src/sentry/scm/cases/preprod.py new file mode 100644 index 00000000000000..ffae6edeaa1981 --- /dev/null +++ b/src/sentry/scm/cases/preprod.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from scm.types import ( + GetIssueCommentsProtocol, + GetPullRequestCommentsProtocol, + GetPullRequestFilesProtocol, + GetPullRequestProtocol, +) + +from sentry.models.organization import Organization +from sentry.models.repository import Repository +from sentry.scm.cases._common import manager_for_repository +from sentry.scm.errors import SCMCapabilityUnsupported + +_REFERRER = "sentry.preprod.integration_utils" + + +def _resolve_repository(organization: Organization, repo_name: str) -> Repository | None: + return Repository.objects.filter( + organization_id=organization.id, + name=repo_name, + ).first() + + +def get_pull_request( + organization: Organization, repo_name: str, pr_number: str +) -> Mapping[str, Any] | None: + """Returns the raw provider payload, mirroring what callers currently consume.""" + repository = _resolve_repository(organization, repo_name) + if repository is None: + return None + + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetPullRequestProtocol): + raise SCMCapabilityUnsupported("get_pull_request", repository.provider) + + result = manager.get_pull_request(pr_number) + return result["raw"]["data"] + + +def get_pull_request_files( + organization: Organization, repo_name: str, pr_number: str +) -> Sequence[Mapping[str, Any]] | None: + repository = _resolve_repository(organization, repo_name) + if repository is None: + return None + + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetPullRequestFilesProtocol): + raise SCMCapabilityUnsupported("get_pull_request_files", repository.provider) + + paginated = manager.get_pull_request_files(pr_number) + return paginated["raw"]["data"] + + +def get_issue_comments( + organization: Organization, repo_name: str, pr_number: str +) -> Sequence[Mapping[str, Any]] | None: + repository = _resolve_repository(organization, repo_name) + if repository is None: + return None + + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetIssueCommentsProtocol): + raise SCMCapabilityUnsupported("get_issue_comments", repository.provider) + + paginated = manager.get_issue_comments(pr_number) + return paginated["raw"]["data"] + + +def get_pull_request_comments( + organization: Organization, repo_name: str, pr_number: str +) -> Sequence[Mapping[str, Any]] | None: + repository = _resolve_repository(organization, repo_name) + if repository is None: + return None + + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetPullRequestCommentsProtocol): + raise SCMCapabilityUnsupported("get_pull_request_comments", repository.provider) + + paginated = manager.get_pull_request_comments(pr_number) + return paginated["raw"]["data"] diff --git a/src/sentry/scm/cases/repository.py b/src/sentry/scm/cases/repository.py new file mode 100644 index 00000000000000..eaff67edaf783b --- /dev/null +++ b/src/sentry/scm/cases/repository.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import timezone +from typing import Any + +from scm.types import ( + CompareCommitsProtocol, + GetCommitProtocol, + GetCommitsProtocol, + GetPullRequestUrlProtocol, +) + +from sentry import options +from sentry.models.pullrequest import PullRequest +from sentry.models.repository import Repository +from sentry.scm.cases._common import manager_for_repository +from sentry.scm.errors import SCMCapabilityUnsupported + +MAX_COMPARE_COMMITS_OPTION_KEY = "github-app.fetch-commits.max-compare-commits" + +_REFERRER = "sentry.integrations.github.repository" + + +def fetch_recent_commits(repository: Repository, end_sha: str) -> Sequence[Mapping[str, Any]]: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetCommitsProtocol): + raise SCMCapabilityUnsupported("get_commits", repository.provider) + + paginated = manager.get_commits(ref=end_sha, pagination={"per_page": 20}) + return _format_commits(manager, repository, paginated["data"]) + + +def fetch_commits_for_compare_range( + repository: Repository, start_sha: str, end_sha: str +) -> Sequence[Mapping[str, Any]]: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, CompareCommitsProtocol): + raise SCMCapabilityUnsupported("compare_commits", repository.provider) + + paginated = manager.compare_commits(start_sha, end_sha) + commits = paginated["data"] + max_compare_commits = options.get(MAX_COMPARE_COMMITS_OPTION_KEY) + if max_compare_commits and len(commits) > max_compare_commits: + commits = commits[-max_compare_commits:] + return _format_commits(manager, repository, commits) + + +def compare_commits( + repository: Repository, start_sha: str | None, end_sha: str +) -> Sequence[Mapping[str, Any]]: + if start_sha is None: + return fetch_recent_commits(repository, end_sha) + return fetch_commits_for_compare_range(repository, start_sha, end_sha) + + +def pull_request_url(repository: Repository, pull_request: PullRequest) -> str: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetPullRequestUrlProtocol): + raise SCMCapabilityUnsupported("get_pull_request_url", repository.provider) + return manager.get_pull_request_url(pull_request.key) + + +def _format_commits( + manager: Any, repository: Repository, commits: Sequence[Mapping[str, Any]] +) -> list[Mapping[str, Any]]: + out: list[Mapping[str, Any]] = [] + for commit in commits: + author = commit.get("author") or {} + author_date = author.get("date") + timestamp = author_date.astimezone(timezone.utc) if author_date is not None else None + out.append( + { + "id": commit["id"], + "repository": repository.name, + "author_email": author.get("email"), + "author_name": author.get("name"), + "message": commit["message"], + "timestamp": timestamp, + "patch_set": _get_patchset(manager, repository, commit["id"]), + } + ) + return out + + +def _get_patchset(manager: Any, repository: Repository, sha: str) -> Sequence[Mapping[str, Any]]: + if not isinstance(manager, GetCommitProtocol): + raise SCMCapabilityUnsupported("get_commit", repository.provider) + result = manager.get_commit(sha) + files = result["data"].get("files") or [] + return _transform_patchset(files) + + +def _transform_patchset(diff: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]: + changes: list[Mapping[str, Any]] = [] + for change in diff: + status = change.get("status") + filename = change["filename"] + if status == "modified": + changes.append({"path": filename, "type": "M"}) + elif status == "added": + changes.append({"path": filename, "type": "A"}) + elif status in ("removed", "deleted"): + changes.append({"path": filename, "type": "D"}) + elif status == "renamed": + previous = change.get("previous_filename") + if previous: + changes.append({"path": previous, "type": "D"}) + changes.append({"path": filename, "type": "A"}) + return changes diff --git a/src/sentry/scm/cases/source_urls.py b/src/sentry/scm/cases/source_urls.py new file mode 100644 index 00000000000000..a909c07bbb02c0 --- /dev/null +++ b/src/sentry/scm/cases/source_urls.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from scm.types import GetFileUrlProtocol + +from sentry.models.repository import Repository +from sentry.scm.cases._common import manager_for_repository +from sentry.scm.errors import SCMCapabilityUnsupported + +_REFERRER = "sentry.integrations.github.integration" + + +def format_source_url(repository: Repository, filepath: str, branch: str | None) -> str: + manager = manager_for_repository(repository, _REFERRER) + if not isinstance(manager, GetFileUrlProtocol): + raise SCMCapabilityUnsupported("get_file_url", repository.provider) + return manager.get_file_url(filepath, branch or "") diff --git a/src/sentry/scm/errors.py b/src/sentry/scm/errors.py index 769a52d80528b6..d790068dea16da 100644 --- a/src/sentry/scm/errors.py +++ b/src/sentry/scm/errors.py @@ -11,3 +11,14 @@ class SCMProviderNotSupported(SCMError): def __init__(self, message: str) -> None: self.message = message super().__init__(message) + + +class SCMCapabilityUnsupported(SCMError): + """Raised when a SourceCodeManager instance does not implement the protocol the caller needs.""" + + def __init__(self, capability: str, provider_name: str | None = None) -> None: + self.capability = capability + self.provider_name = provider_name + super().__init__( + f"SCM capability {capability!r} not supported by provider {provider_name!r}" + )