diff --git a/src/sentry/integrations/bitbucket/integration.py b/src/sentry/integrations/bitbucket/integration.py index 757f717cab3593..5e2eedaac5d88c 100644 --- a/src/sentry/integrations/bitbucket/integration.py +++ b/src/sentry/integrations/bitbucket/integration.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +from itertools import chain from typing import Any from django.http.request import HttpRequest @@ -20,7 +21,10 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import RpcRepository, repository_service -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.integrations.tasks.migrate_repo import migrate_repo from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.atlassian_connect import ( @@ -103,14 +107,14 @@ scopes = ("issue:write", "pullrequest", "webhook", "repository") -class BitbucketIntegration(RepositoryIntegration, BitbucketIssuesSpec): +class BitbucketIntegration(RepositoryIntegration[BitbucketApiClient], BitbucketIssuesSpec): codeowners_locations = [".bitbucket/CODEOWNERS"] @property def integration_name(self) -> str: return IntegrationProviderSlug.BITBUCKET.value - def get_client(self): + def get_client(self) -> BitbucketApiClient: return BitbucketApiClient(integration=self.model) # IntegrationInstallation methods @@ -120,34 +124,50 @@ def error_message_from_json(self, data): # RepositoryIntegration methods + def get_repo_external_id(self, repo: Mapping[str, Any]) -> str: + return str(repo["uuid"]) + def get_repositories( self, query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: username = self.model.metadata.get("uuid", self.username) if not query: resp = self.get_client().get_repos(username) return [ - {"identifier": repo["full_name"], "name": repo["full_name"]} + { + "identifier": repo["full_name"], + "name": repo["full_name"], + "external_id": self.get_repo_external_id(repo), + } for repo in resp.get("values", []) ] + client = self.get_client() exact_query = f'name="{query}"' fuzzy_query = f'name~"{query}"' - exact_search_resp = self.get_client().search_repositories(username, exact_query) - fuzzy_search_resp = self.get_client().search_repositories(username, fuzzy_query) - - result: OrderedSet[str] = OrderedSet() - - for j in exact_search_resp.get("values", []): - result.add(j["full_name"]) - - for i in fuzzy_search_resp.get("values", []): - result.add(i["full_name"]) + exact_search_resp = client.search_repositories(username, exact_query) + fuzzy_search_resp = client.search_repositories(username, fuzzy_query) + + seen: OrderedSet[str] = OrderedSet() + repos: list[RepositoryInfo] = [] + for repo in chain( + exact_search_resp.get("values", []), + fuzzy_search_resp.get("values", []), + ): + if repo["full_name"] not in seen: + seen.add(repo["full_name"]) + repos.append( + { + "identifier": repo["full_name"], + "name": repo["full_name"], + "external_id": self.get_repo_external_id(repo), + } + ) - return [{"identifier": full_name, "name": full_name} for full_name in result] + return repos def has_repo_access(self, repo: RpcRepository) -> bool: client = self.get_client() diff --git a/src/sentry/integrations/bitbucket/repository.py b/src/sentry/integrations/bitbucket/repository.py index 17610a849ad399..4ea343d914fed4 100644 --- a/src/sentry/integrations/bitbucket/repository.py +++ b/src/sentry/integrations/bitbucket/repository.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from sentry.integrations.types import IntegrationProviderSlug from sentry.locks import locks @@ -12,8 +14,11 @@ from sentry.utils.email import parse_email, parse_user_name from sentry.utils.http import absolute_uri +if TYPE_CHECKING: + from sentry.integrations.bitbucket.integration import BitbucketIntegration # NOQA + -class BitbucketRepositoryProvider(IntegrationRepositoryProvider): +class BitbucketRepositoryProvider(IntegrationRepositoryProvider["BitbucketIntegration"]): name = "Bitbucket" repo_provider = IntegrationProviderSlug.BITBUCKET.value @@ -25,7 +30,7 @@ def get_repository_data(self, organization, config): except Exception as e: installation.raise_error(e) else: - config["external_id"] = str(repo["uuid"]) + config["external_id"] = installation.get_repo_external_id(repo) config["name"] = repo["full_name"] return config diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 8dbf0fa288471c..6d3dc20c34c1b6 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -27,7 +27,10 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import repository_service from sentry.integrations.services.repository.model import RpcRepository -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.integrations.tasks.migrate_repo import migrate_repo from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.metrics import ( @@ -253,7 +256,7 @@ def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpR ) -class BitbucketServerIntegration(RepositoryIntegration): +class BitbucketServerIntegration(RepositoryIntegration[BitbucketServerClient]): """ IntegrationInstallation implementation for Bitbucket Server """ @@ -285,13 +288,14 @@ def get_repositories( query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: if not query: resp = self.get_client().get_repos() return [ { "identifier": repo["project"]["key"] + "/" + repo["slug"], + "external_id": self.get_repo_external_id(repo), "project": repo["project"]["key"], "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], @@ -304,6 +308,7 @@ def get_repositories( return [ { "identifier": repo["project"]["key"] + "/" + repo["slug"], + "external_id": self.get_repo_external_id(repo), "project": repo["project"]["key"], "repo": repo["slug"], "name": repo["project"]["name"] + "/" + repo["name"], diff --git a/src/sentry/integrations/bitbucket_server/repository.py b/src/sentry/integrations/bitbucket_server/repository.py index 528e2bd6bd9466..13d15d84e9e113 100644 --- a/src/sentry/integrations/bitbucket_server/repository.py +++ b/src/sentry/integrations/bitbucket_server/repository.py @@ -1,6 +1,8 @@ +from __future__ import annotations + from collections.abc import Mapping from datetime import datetime, timezone -from typing import Any +from typing import TYPE_CHECKING, Any from django.core.cache import cache from django.urls import reverse @@ -15,8 +17,13 @@ from sentry.utils.hashlib import md5_text from sentry.utils.http import absolute_uri +if TYPE_CHECKING: + from sentry.integrations.bitbucket_server.integration import BitbucketServerIntegration # NOQA + -class BitbucketServerRepositoryProvider(IntegrationRepositoryProvider): +class BitbucketServerRepositoryProvider( + IntegrationRepositoryProvider["BitbucketServerIntegration"] +): name = "Bitbucket Server" repo_provider = IntegrationProviderSlug.BITBUCKET_SERVER.value @@ -29,7 +36,7 @@ def get_repository_data(self, organization, config): except Exception as e: installation.raise_error(e) else: - config["external_id"] = str(repo["id"]) + config["external_id"] = installation.get_repo_external_id(repo) config["name"] = repo["project"]["key"] + "/" + repo["name"] config["project"] = repo["project"]["key"] config["repo"] = repo["name"] diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index 2dc18b17334ec7..7c2a8c4461cd26 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -22,7 +22,10 @@ from sentry.integrations.services.integration.serial import serialize_integration from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.source_code_management.issues import SourceCodeIssueIntegration -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.views.base import PipelineView @@ -151,8 +154,8 @@ def get_repositories( query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: - return [{"name": "repo", "identifier": "user/repo"}] + ) -> list[RepositoryInfo]: + return [{"name": "repo", "identifier": "user/repo", "external_id": "1"}] def get_unmigratable_repositories(self): return [] diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index 88086dee4c34c8..7fb151b633cb12 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -47,7 +47,10 @@ PRCommentWorkflow, ) from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.integrations.tasks.migrate_repo import migrate_repo from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.metrics import ( @@ -237,7 +240,7 @@ def get_document_origin(org) -> str: class GitHubIntegration( - RepositoryIntegration, + RepositoryIntegration[GitHubBaseClient], GitHubIssuesSpec, GitHubIssueSyncSpec, CommitContextIntegration, @@ -322,7 +325,7 @@ def get_repositories( query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: """ args: * query - a query to filter the repositories by @@ -335,10 +338,11 @@ def get_repositories( """ if not query or accessible_only: all_repos = self.get_client().get_repos(page_number_limit=page_number_limit) - repos = [ + repos: list[RepositoryInfo] = [ { "name": i["name"], "identifier": i["full_name"], + "external_id": self.get_repo_external_id(i), "default_branch": i.get("default_branch"), } for i in all_repos @@ -346,19 +350,21 @@ def get_repositories( ] if query: query_lower = query.lower() - repos = [r for r in repos if query_lower in r["identifier"].lower()] + repos = [r for r in repos if query_lower in str(r["identifier"]).lower()] return repos full_query = build_repository_query(self.model.metadata, self.model.name, query) response = self.get_client().search_repositories(full_query) - return [ + search_repos: list[RepositoryInfo] = [ { "name": i["name"], "identifier": i["full_name"], + "external_id": self.get_repo_external_id(i), "default_branch": i.get("default_branch"), } for i in response.get("items", []) ] + return search_repos def get_unmigratable_repositories(self) -> list[RpcRepository]: accessible_repos = self.get_repositories() diff --git a/src/sentry/integrations/github/repository.py b/src/sentry/integrations/github/repository.py index 766c1e03a8a4d7..408eabde4ac5b6 100644 --- a/src/sentry/integrations/github/repository.py +++ b/src/sentry/integrations/github/repository.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any +from typing import TYPE_CHECKING, Any from sentry.constants import ObjectStatus from sentry.integrations.base import IntegrationInstallation @@ -15,10 +15,13 @@ from sentry.plugins.providers.integration_repository import RepositoryConfig from sentry.shared_integrations.exceptions import ApiError, IntegrationError +if TYPE_CHECKING: + from sentry.integrations.github.integration import GitHubIntegration # NOQA + WEBHOOK_EVENTS = ["push", "pull_request"] -class GitHubRepositoryProvider(IntegrationRepositoryProvider): +class GitHubRepositoryProvider(IntegrationRepositoryProvider["GitHubIntegration"]): name = "GitHub" repo_provider = IntegrationProviderSlug.GITHUB.value @@ -46,7 +49,7 @@ def get_repository_data( client = installation.get_client() repo = self._validate_repo(client, installation, config["identifier"]) - config["external_id"] = str(repo["id"]) + config["external_id"] = installation.get_repo_external_id(repo) config["integration_id"] = installation.model.id return config diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index 5981801345dcd7..84ae6d7576afa0 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -37,7 +37,10 @@ from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import RpcRepository from sentry.integrations.source_code_management.commit_context import CommitContextIntegration -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.integrations.types import IntegrationProviderSlug from sentry.models.repository import Repository from sentry.organizations.services.organization import organization_service @@ -173,7 +176,10 @@ def get_user_info(url, access_token): class GitHubEnterpriseIntegration( - RepositoryIntegration, GitHubIssuesSpec, GitHubIssueSyncSpec, CommitContextIntegration + RepositoryIntegration[GitHubEnterpriseApiClient], + GitHubIssuesSpec, + GitHubIssueSyncSpec, + CommitContextIntegration, ): codeowners_locations = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"] @@ -220,13 +226,14 @@ def get_repositories( query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: if not query: all_repos = self.get_client().get_repos(page_number_limit=page_number_limit) return [ { "name": i["name"], "identifier": i["full_name"], + "external_id": self.get_repo_external_id(i), "default_branch": i.get("default_branch"), } for i in all_repos @@ -239,6 +246,7 @@ def get_repositories( { "name": i["name"], "identifier": i["full_name"], + "external_id": self.get_repo_external_id(i), "default_branch": i.get("default_branch"), } for i in response.get("items", []) diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index 2054eb28b1436c..38e77b70a83ca9 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -33,7 +33,10 @@ CommitContextIntegration, PRCommentWorkflow, ) -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.integrations.types import IntegrationProviderSlug from sentry.models.group import Group from sentry.models.organization import Organization @@ -123,7 +126,10 @@ class GitlabIntegration( - RepositoryIntegration, GitlabIssuesSpec, GitlabIssueSyncSpec, CommitContextIntegration + RepositoryIntegration[GitLabApiClient], + GitlabIssuesSpec, + GitlabIssueSyncSpec, + CommitContextIntegration, ): codeowners_locations = ["CODEOWNERS", ".gitlab/CODEOWNERS", "docs/CODEOWNERS"] @@ -164,18 +170,27 @@ def has_repo_access(self, repo: RpcRepository) -> bool: # TODO: define this, used to migrate repositories return False + def get_repo_external_id(self, repo: Mapping[str, Any]) -> str: + instance = self.model.metadata["instance"] + return f"{instance}:{repo['id']}" + def get_repositories( self, query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: try: # Note: gitlab projects are the same things as repos everywhere else group = self.get_group_id() resp = self.get_client().search_projects(group, query) return [ - {"identifier": repo["id"], "name": repo["name_with_namespace"]} for repo in resp + { + "identifier": str(repo["id"]), + "name": repo["name_with_namespace"], + "external_id": self.get_repo_external_id(repo), + } + for repo in resp ] except (ApiForbiddenError, ApiUnauthorized) as e: raise IntegrationConfigurationError(self.message_from_error(e)) from e diff --git a/src/sentry/integrations/gitlab/issues.py b/src/sentry/integrations/gitlab/issues.py index 1bada17ecc8142..76d275df5b7168 100644 --- a/src/sentry/integrations/gitlab/issues.py +++ b/src/sentry/integrations/gitlab/issues.py @@ -47,7 +47,8 @@ def get_projects_and_default(self, group: Group | None, params: Mapping[str, Any # expects the param to be called 'repo', so we need to rename it here. # Django QueryDicts are immutable, so we need to copy it first. params_mut = dict(params) - params_mut["repo"] = params.get("project") or defaults.get("project") + repo = params.get("project") or defaults.get("project") + params_mut["repo"] = str(repo) if repo is not None else None default_project, project_choices = self.get_repository_choices(group, params_mut) return default_project, project_choices @@ -59,7 +60,7 @@ def create_default_repo_choice(self, default_repo): project = client.get_project(default_repo) except (ApiError, ApiUnauthorized): return ("", "") - return (project["id"], project["name_with_namespace"]) + return (str(project["id"]), project["name_with_namespace"]) @all_silo_function def get_create_issue_config( diff --git a/src/sentry/integrations/gitlab/repository.py b/src/sentry/integrations/gitlab/repository.py index d2285d73b195f0..d926fd37573214 100644 --- a/src/sentry/integrations/gitlab/repository.py +++ b/src/sentry/integrations/gitlab/repository.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from sentry.integrations.types import IntegrationProviderSlug from sentry.organizations.services.organization.model import RpcOrganization @@ -7,8 +9,11 @@ from sentry.plugins.providers.integration_repository import RepositoryConfig from sentry.shared_integrations.exceptions import ApiError +if TYPE_CHECKING: + from sentry.integrations.gitlab.integration import GitlabIntegration # NOQA + -class GitlabRepositoryProvider(IntegrationRepositoryProvider): +class GitlabRepositoryProvider(IntegrationRepositoryProvider["GitlabIntegration"]): name = "Gitlab" repo_provider = IntegrationProviderSlug.GITLAB.value @@ -28,7 +33,7 @@ def get_repository_data(self, organization, config): "instance": instance, "path": project["path_with_namespace"], "name": project["name_with_namespace"], - "external_id": "{}:{}".format(instance, project["id"]), + "external_id": installation.get_repo_external_id(project), "project_id": project["id"], "url": project["web_url"], } diff --git a/src/sentry/integrations/gitlab/search.py b/src/sentry/integrations/gitlab/search.py index 01cffd4ca7e56d..597b70bdf1b8f6 100644 --- a/src/sentry/integrations/gitlab/search.py +++ b/src/sentry/integrations/gitlab/search.py @@ -77,7 +77,7 @@ def handle_search_repositories( return Response({"detail": str(e)}, status=400) return Response( [ - {"label": project["name_with_namespace"], "value": project["id"]} + {"label": project["name_with_namespace"], "value": str(project["id"])} for project in response ] ) diff --git a/src/sentry/integrations/perforce/integration.py b/src/sentry/integrations/perforce/integration.py index 921ec2ba9ad48b..1f36340886dd7a 100644 --- a/src/sentry/integrations/perforce/integration.py +++ b/src/sentry/integrations/perforce/integration.py @@ -21,7 +21,10 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import RpcRepository from sentry.integrations.source_code_management.commit_context import CommitContextIntegration -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.pipeline.views.base import PipelineView @@ -164,7 +167,7 @@ def clean_web_url(self) -> str: return web_url -class PerforceIntegration(RepositoryIntegration, CommitContextIntegration): +class PerforceIntegration(RepositoryIntegration[PerforceClient], CommitContextIntegration): """ Integration for P4 Core version control system. Provides stacktrace linking to depot files and suspect commit detection. @@ -184,6 +187,11 @@ def __init__( super().__init__(model=model, organization_id=organization_id) self._client: PerforceClient | None = None + def get_repo_external_id(self, repo: Mapping[str, Any]) -> str: + raise NotImplementedError( + "Perforce external_id is derived from the depot path, not the API response" + ) + def get_client(self) -> PerforceClient: """Get the Perforce client instance.""" if self._client is not None: @@ -353,7 +361,7 @@ def get_repositories( query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: """ Get list of depots/streams from Perforce server. @@ -368,7 +376,7 @@ def get_repositories( client = self.get_client() depots = client.get_depots() - repositories = [] + repositories: list[RepositoryInfo] = [] for depot in depots: depot_name = depot["name"] depot_path = f"//{depot_name}" @@ -381,6 +389,7 @@ def get_repositories( { "name": depot_name, "identifier": depot_path, + "external_id": "", # Perforce derives external_id from the user-provided depot path, not the API response "default_branch": None, # Perforce uses depot paths, not branch refs } ) diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index 52d84dd91c13fa..8b1751de9a20e8 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -12,6 +12,7 @@ P4UserInfo, PerforceClient, ) +from sentry.integrations.perforce.integration import PerforceIntegration from sentry.integrations.services.integration import integration_service from sentry.models.organization import Organization from sentry.models.pullrequest import PullRequest @@ -24,7 +25,7 @@ logger = logging.getLogger(__name__) -class PerforceRepositoryProvider(IntegrationRepositoryProvider): +class PerforceRepositoryProvider(IntegrationRepositoryProvider[PerforceIntegration]): """Repository provider for Perforce integration.""" name = "Perforce" diff --git a/src/sentry/integrations/source_code_management/issues.py b/src/sentry/integrations/source_code_management/issues.py index da4690b3c8f2e3..8905f7e76d63c4 100644 --- a/src/sentry/integrations/source_code_management/issues.py +++ b/src/sentry/integrations/source_code_management/issues.py @@ -35,7 +35,7 @@ def _get_repository_choices( params: Mapping[str, Any], lifecycle: EventLifecycle, page_number_limit: int | None = None, - ) -> tuple[str, list[tuple[str, str | int]]]: + ) -> tuple[str, list[tuple[str, str]]]: try: repos = self.get_repositories(page_number_limit=page_number_limit) except ApiError as exc: @@ -67,7 +67,7 @@ def _get_repository_choices( def get_repository_choices( self, group: Group | None, params: Mapping[str, Any], page_number_limit: int | None = None - ) -> tuple[str, list[tuple[str, str | int]]]: + ) -> tuple[str, list[tuple[str, str]]]: """ Returns the default repository and a set/subset of repositories of associated with the installation """ diff --git a/src/sentry/integrations/source_code_management/repo_trees.py b/src/sentry/integrations/source_code_management/repo_trees.py index cdd54b15e4145a..8ff5a333bccfd8 100644 --- a/src/sentry/integrations/source_code_management/repo_trees.py +++ b/src/sentry/integrations/source_code_management/repo_trees.py @@ -6,6 +6,7 @@ from typing import Any, NamedTuple from sentry.integrations.services.integration import RpcOrganizationIntegration +from sentry.integrations.source_code_management.repository import RepositoryInfo from sentry.issues.auto_source_code_config.utils.platform import get_supported_extensions from sentry.shared_integrations.exceptions import ApiConflictError, ApiError, IntegrationError from sentry.utils import metrics @@ -51,7 +52,7 @@ def get_client(self) -> RepoTreesClient: raise NotImplementedError @abstractmethod - def get_repositories(self, query: str | None = None) -> list[dict[str, Any]]: + def get_repositories(self, query: str | None = None) -> list[RepositoryInfo]: raise NotImplementedError @property @@ -89,8 +90,8 @@ def _populate_repositories(self) -> list[dict[str, str]]: repositories = [ # Do not use RepoAndBranch so it stores in the cache as a simple dict { - "full_name": repo_info["identifier"], - "default_branch": repo_info["default_branch"], + "full_name": str(repo_info["identifier"]), + "default_branch": repo_info.get("default_branch") or "", } for repo_info in self.get_repositories() if not repo_info.get("archived") diff --git a/src/sentry/integrations/source_code_management/repository.py b/src/sentry/integrations/source_code_management/repository.py index da5e9ecc362ff4..0ba84a1bfd18f9 100644 --- a/src/sentry/integrations/source_code_management/repository.py +++ b/src/sentry/integrations/source_code_management/repository.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from collections.abc import Mapping -from typing import Any +from typing import Any, Generic, NotRequired, TypedDict, TypeVar from urllib.parse import quote as urlquote from urllib.parse import unquote, urlparse, urlunparse @@ -29,14 +29,37 @@ from sentry.users.models.identity import Identity +class RepositoryInfo(TypedDict): + """Common shape returned by get_repositories() across all SCM providers.""" + + name: str + identifier: str + external_id: str + default_branch: NotRequired[str | None] # GitHub, GitHub Enterprise + project: NotRequired[str] # Bitbucket Server + repo: NotRequired[str] # Bitbucket Server + + class BaseRepositoryIntegration(ABC): + def get_repo_external_id(self, repo: Mapping[str, Any]) -> str: + """ + Extract the external_id from a raw repository API response. + + This is the canonical definition of how each provider maps its + API response to the external_id stored in Sentry's Repository model. + Override in provider-specific installation classes as needed. + + Default assumes the API returns an ``id`` field. + """ + return str(repo["id"]) + @abstractmethod def get_repositories( self, query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: """ Get a list of available repositories for an installation @@ -46,12 +69,15 @@ def get_repositories( return [{ 'name': display_name, 'identifier': external_repo_id, + 'external_id': provider_internal_id, }] The shape of the `identifier` should match the data returned by the integration's IntegrationRepositoryProvider.repository_external_slug() + The `external_id` is derived from `self.get_repo_external_id(repo)`. + You can use the `query` argument to filter repositories. When `accessible_only` is True and a query is provided, only repositories the installation has access to are @@ -61,7 +87,12 @@ def get_repositories( raise NotImplementedError -class RepositoryIntegration(IntegrationInstallation, BaseRepositoryIntegration, ABC): +ClientT = TypeVar("ClientT", bound="RepositoryClient", default="RepositoryClient") + + +class RepositoryIntegration( + IntegrationInstallation, BaseRepositoryIntegration, Generic[ClientT], ABC +): @property def codeowners_locations(self) -> list[str] | None: """ @@ -79,7 +110,7 @@ def integration_name(self) -> str: raise NotImplementedError @abstractmethod - def get_client(self) -> RepositoryClient: + def get_client(self) -> ClientT: """Returns the client for the integration. The client must be a subclass of RepositoryClient.""" raise NotImplementedError @@ -109,7 +140,7 @@ def has_repo_access(self, repo: RpcRepository) -> bool: raise NotImplementedError @staticmethod - def find_repo_info(repositories: list[dict[str, Any]], repo_name: str) -> dict[str, Any] | None: + def find_repo_info(repositories: list[RepositoryInfo], repo_name: str) -> RepositoryInfo | None: """ Find a repository dict by matching identifier first, then name. """ diff --git a/src/sentry/integrations/vsts/integration.py b/src/sentry/integrations/vsts/integration.py index c701945cac60ec..77393319440e0b 100644 --- a/src/sentry/integrations/vsts/integration.py +++ b/src/sentry/integrations/vsts/integration.py @@ -32,7 +32,10 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import RpcRepository, repository_service -from sentry.integrations.source_code_management.repository import RepositoryIntegration +from sentry.integrations.source_code_management.repository import ( + RepositoryInfo, + RepositoryIntegration, +) from sentry.integrations.tasks.migrate_repo import migrate_repo from sentry.integrations.types import IntegrationProviderSlug from sentry.integrations.utils.metrics import ( @@ -132,7 +135,7 @@ logger = logging.getLogger("sentry.integrations") -class VstsIntegration(RepositoryIntegration, VstsIssuesSpec): +class VstsIntegration(RepositoryIntegration[VstsApiClient], VstsIssuesSpec): logger = logger comment_key = "sync_comments" outbound_status_key = "sync_status_forward" @@ -312,17 +315,18 @@ def get_repositories( query: str | None = None, page_number_limit: int | None = None, accessible_only: bool = False, - ) -> list[dict[str, Any]]: + ) -> list[RepositoryInfo]: try: repos = self.get_client().get_repos() except (ApiError, IdentityNotValid) as e: raise IntegrationError(self.message_from_error(e)) - data = [] + data: list[RepositoryInfo] = [] for repo in repos["value"]: data.append( { "name": "{}/{}".format(repo["project"]["name"], repo["name"]), - "identifier": repo["id"], + "identifier": str(repo["id"]), + "external_id": self.get_repo_external_id(repo), } ) return data diff --git a/src/sentry/integrations/vsts/repository.py b/src/sentry/integrations/vsts/repository.py index ac015771960172..9a7b9fd66250d7 100644 --- a/src/sentry/integrations/vsts/repository.py +++ b/src/sentry/integrations/vsts/repository.py @@ -2,7 +2,7 @@ import logging from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any +from typing import TYPE_CHECKING, Any from sentry.integrations.types import IntegrationProviderSlug from sentry.models.organization import Organization @@ -11,22 +11,22 @@ from sentry.plugins.providers import IntegrationRepositoryProvider from sentry.plugins.providers.integration_repository import RepositoryConfig +if TYPE_CHECKING: + from sentry.integrations.vsts.integration import VstsIntegration as VstsIntegrationType # NOQA + MAX_COMMIT_DATA_REQUESTS = 90 logger = logging.getLogger(__name__) -class VstsRepositoryProvider(IntegrationRepositoryProvider): +class VstsRepositoryProvider(IntegrationRepositoryProvider["VstsIntegrationType"]): name = "Azure DevOps" repo_provider = IntegrationProviderSlug.AZURE_DEVOPS.value def get_repository_data( self, organization: Organization, config: MutableMapping[str, Any] ) -> Mapping[str, str]: - from sentry.integrations.vsts.integration import VstsIntegration - installation = self.get_installation(config.get("installation"), organization.id) - assert isinstance(installation, VstsIntegration), installation client = installation.get_client() repo_id = config["identifier"] @@ -40,7 +40,7 @@ def get_repository_data( "instance": installation.instance, "project": repo["project"]["name"], "name": repo["name"], - "external_id": str(repo["id"]), + "external_id": installation.get_repo_external_id(repo), "url": repo["_links"]["web"]["href"], } ) @@ -78,6 +78,7 @@ def transform_changes( def zip_commit_data( self, repo: Repository, commit_list: list[dict[str, Any]], organization_id: int ) -> list[dict[str, Any]]: + assert repo.external_id is not None installation = self.get_installation(repo.integration_id, organization_id) client = installation.get_client() n = 0 @@ -104,6 +105,7 @@ def compare_commits( self, repo: Repository, start_sha: str | None, end_sha: str ) -> Sequence[Mapping[str, str]]: """TODO(mgaeta): This function is kinda a mess.""" + assert repo.external_id is not None installation = self.get_installation(repo.integration_id, repo.organization_id) client = installation.get_client() diff --git a/src/sentry/plugins/providers/integration_repository.py b/src/sentry/plugins/providers/integration_repository.py index 2834592c069e25..6ec07a7635be7e 100644 --- a/src/sentry/plugins/providers/integration_repository.py +++ b/src/sentry/plugins/providers/integration_repository.py @@ -3,7 +3,7 @@ import logging from collections.abc import Mapping from datetime import timezone -from typing import Any, ClassVar, NotRequired, TypedDict +from typing import Any, ClassVar, Generic, NotRequired, TypedDict, TypeVar, cast from dateutil.parser import parse as parse_date from rest_framework import status @@ -14,11 +14,11 @@ from sentry.api.exceptions import SentryAPIException from sentry.constants import ObjectStatus from sentry.integrations.analytics import IntegrationRepoAddedEvent -from sentry.integrations.base import IntegrationInstallation from sentry.integrations.models.integration import Integration from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import repository_service from sentry.integrations.services.repository.model import RpcCreateRepository, RpcRepository +from sentry.integrations.source_code_management.repository import RepositoryIntegration from sentry.models.repository import Repository from sentry.organizations.services.organization.model import RpcOrganization from sentry.shared_integrations.exceptions import IntegrationError @@ -27,6 +27,8 @@ from sentry.users.services.user.serial import serialize_rpc_user from sentry.utils import metrics +InstT = TypeVar("InstT", bound="RepositoryIntegration[Any]", default=RepositoryIntegration) + class RepositoryInputConfig(TypedDict): """Input config passed to create_repositories / build_repository_config. @@ -81,7 +83,7 @@ def get_integration_repository_provider(integration): return provider_cls(id=provider_key) -class IntegrationRepositoryProvider: +class IntegrationRepositoryProvider(Generic[InstT]): """ Repository Provider for Integrations in the Sentry Repository. Does not include plugins. @@ -98,7 +100,7 @@ def get_installation( self, integration_id: int | None, organization_id: int, - ) -> IntegrationInstallation: + ) -> InstT: if integration_id is None: raise IntegrationError(f"{self.name} requires an integration id.") @@ -114,7 +116,7 @@ def get_installation( if rpc_org_integration is None: raise Integration.DoesNotExist("Integration matching query does not exist.") - return rpc_integration.get_installation(organization_id=organization_id) + return cast(InstT, rpc_integration.get_installation(organization_id=organization_id)) def create_repository( self, diff --git a/tests/sentry/integrations/bitbucket/test_integration.py b/tests/sentry/integrations/bitbucket/test_integration.py index 965411ff5829e8..cf140802046f9e 100644 --- a/tests/sentry/integrations/bitbucket/test_integration.py +++ b/tests/sentry/integrations/bitbucket/test_integration.py @@ -47,11 +47,13 @@ def test_get_repositories_with_uuid(self) -> None: responses.add( responses.GET, url, - json={"values": [{"full_name": "sentryuser/stuf"}]}, + json={"values": [{"full_name": "sentryuser/stuf", "uuid": "{abc-001}"}]}, ) installation = self.integration.get_installation(self.organization.id) result = installation.get_repositories() - assert result == [{"identifier": "sentryuser/stuf", "name": "sentryuser/stuf"}] + assert result == [ + {"identifier": "sentryuser/stuf", "name": "sentryuser/stuf", "external_id": "{abc-001}"} + ] @responses.activate def test_get_repositories_exact_match(self) -> None: @@ -59,7 +61,7 @@ def test_get_repositories_exact_match(self) -> None: responses.add( responses.GET, f"https://api.bitbucket.org/2.0/repositories/sentryuser?{querystring}", - json={"values": [{"full_name": "sentryuser/stuf"}]}, + json={"values": [{"full_name": "sentryuser/stuf", "uuid": "{abc-001}"}]}, ) querystring = urlencode({"q": 'name~"stuf"'}) @@ -68,18 +70,18 @@ def test_get_repositories_exact_match(self) -> None: f"https://api.bitbucket.org/2.0/repositories/sentryuser?{querystring}", json={ "values": [ - {"full_name": "sentryuser/stuff"}, - {"full_name": "sentryuser/stuff-2010"}, - {"full_name": "sentryuser/stuff-2011"}, - {"full_name": "sentryuser/stuff-2012"}, - {"full_name": "sentryuser/stuff-2013"}, - {"full_name": "sentryuser/stuff-2014"}, - {"full_name": "sentryuser/stuff-2015"}, - {"full_name": "sentryuser/stuff-2016"}, - {"full_name": "sentryuser/stuff-2016"}, - {"full_name": "sentryuser/stuff-2017"}, - {"full_name": "sentryuser/stuff-2018"}, - {"full_name": "sentryuser/stuff-2019"}, + {"full_name": "sentryuser/stuff", "uuid": "{abc-002}"}, + {"full_name": "sentryuser/stuff-2010", "uuid": "{abc-003}"}, + {"full_name": "sentryuser/stuff-2011", "uuid": "{abc-004}"}, + {"full_name": "sentryuser/stuff-2012", "uuid": "{abc-005}"}, + {"full_name": "sentryuser/stuff-2013", "uuid": "{abc-006}"}, + {"full_name": "sentryuser/stuff-2014", "uuid": "{abc-007}"}, + {"full_name": "sentryuser/stuff-2015", "uuid": "{abc-008}"}, + {"full_name": "sentryuser/stuff-2016", "uuid": "{abc-009}"}, + {"full_name": "sentryuser/stuff-2016", "uuid": "{abc-009}"}, + {"full_name": "sentryuser/stuff-2017", "uuid": "{abc-010}"}, + {"full_name": "sentryuser/stuff-2018", "uuid": "{abc-011}"}, + {"full_name": "sentryuser/stuff-2019", "uuid": "{abc-012}"}, ] }, ) @@ -87,18 +89,66 @@ def test_get_repositories_exact_match(self) -> None: installation = self.integration.get_installation(self.organization.id) result = installation.get_repositories("stuf") assert result == [ - {"identifier": "sentryuser/stuf", "name": "sentryuser/stuf"}, - {"identifier": "sentryuser/stuff", "name": "sentryuser/stuff"}, - {"identifier": "sentryuser/stuff-2010", "name": "sentryuser/stuff-2010"}, - {"identifier": "sentryuser/stuff-2011", "name": "sentryuser/stuff-2011"}, - {"identifier": "sentryuser/stuff-2012", "name": "sentryuser/stuff-2012"}, - {"identifier": "sentryuser/stuff-2013", "name": "sentryuser/stuff-2013"}, - {"identifier": "sentryuser/stuff-2014", "name": "sentryuser/stuff-2014"}, - {"identifier": "sentryuser/stuff-2015", "name": "sentryuser/stuff-2015"}, - {"identifier": "sentryuser/stuff-2016", "name": "sentryuser/stuff-2016"}, - {"identifier": "sentryuser/stuff-2017", "name": "sentryuser/stuff-2017"}, - {"identifier": "sentryuser/stuff-2018", "name": "sentryuser/stuff-2018"}, - {"identifier": "sentryuser/stuff-2019", "name": "sentryuser/stuff-2019"}, + { + "identifier": "sentryuser/stuf", + "name": "sentryuser/stuf", + "external_id": "{abc-001}", + }, + { + "identifier": "sentryuser/stuff", + "name": "sentryuser/stuff", + "external_id": "{abc-002}", + }, + { + "identifier": "sentryuser/stuff-2010", + "name": "sentryuser/stuff-2010", + "external_id": "{abc-003}", + }, + { + "identifier": "sentryuser/stuff-2011", + "name": "sentryuser/stuff-2011", + "external_id": "{abc-004}", + }, + { + "identifier": "sentryuser/stuff-2012", + "name": "sentryuser/stuff-2012", + "external_id": "{abc-005}", + }, + { + "identifier": "sentryuser/stuff-2013", + "name": "sentryuser/stuff-2013", + "external_id": "{abc-006}", + }, + { + "identifier": "sentryuser/stuff-2014", + "name": "sentryuser/stuff-2014", + "external_id": "{abc-007}", + }, + { + "identifier": "sentryuser/stuff-2015", + "name": "sentryuser/stuff-2015", + "external_id": "{abc-008}", + }, + { + "identifier": "sentryuser/stuff-2016", + "name": "sentryuser/stuff-2016", + "external_id": "{abc-009}", + }, + { + "identifier": "sentryuser/stuff-2017", + "name": "sentryuser/stuff-2017", + "external_id": "{abc-010}", + }, + { + "identifier": "sentryuser/stuff-2018", + "name": "sentryuser/stuff-2018", + "external_id": "{abc-011}", + }, + { + "identifier": "sentryuser/stuff-2019", + "name": "sentryuser/stuff-2019", + "external_id": "{abc-012}", + }, ] @responses.activate @@ -109,18 +159,18 @@ def test_get_repositories_no_exact_match(self) -> None: f"https://api.bitbucket.org/2.0/repositories/sentryuser?{querystring}", json={ "values": [ - {"full_name": "sentryuser/stuff"}, - {"full_name": "sentryuser/stuff-2010"}, - {"full_name": "sentryuser/stuff-2011"}, - {"full_name": "sentryuser/stuff-2012"}, - {"full_name": "sentryuser/stuff-2013"}, - {"full_name": "sentryuser/stuff-2014"}, - {"full_name": "sentryuser/stuff-2015"}, - {"full_name": "sentryuser/stuff-2016"}, - {"full_name": "sentryuser/stuff-2016"}, - {"full_name": "sentryuser/stuff-2017"}, - {"full_name": "sentryuser/stuff-2018"}, - {"full_name": "sentryuser/stuff-2019"}, + {"full_name": "sentryuser/stuff", "uuid": "{abc-002}"}, + {"full_name": "sentryuser/stuff-2010", "uuid": "{abc-003}"}, + {"full_name": "sentryuser/stuff-2011", "uuid": "{abc-004}"}, + {"full_name": "sentryuser/stuff-2012", "uuid": "{abc-005}"}, + {"full_name": "sentryuser/stuff-2013", "uuid": "{abc-006}"}, + {"full_name": "sentryuser/stuff-2014", "uuid": "{abc-007}"}, + {"full_name": "sentryuser/stuff-2015", "uuid": "{abc-008}"}, + {"full_name": "sentryuser/stuff-2016", "uuid": "{abc-009}"}, + {"full_name": "sentryuser/stuff-2016", "uuid": "{abc-009}"}, + {"full_name": "sentryuser/stuff-2017", "uuid": "{abc-010}"}, + {"full_name": "sentryuser/stuff-2018", "uuid": "{abc-011}"}, + {"full_name": "sentryuser/stuff-2019", "uuid": "{abc-012}"}, ] }, ) @@ -135,17 +185,61 @@ def test_get_repositories_no_exact_match(self) -> None: installation = self.integration.get_installation(self.organization.id) result = installation.get_repositories("stu") assert result == [ - {"identifier": "sentryuser/stuff", "name": "sentryuser/stuff"}, - {"identifier": "sentryuser/stuff-2010", "name": "sentryuser/stuff-2010"}, - {"identifier": "sentryuser/stuff-2011", "name": "sentryuser/stuff-2011"}, - {"identifier": "sentryuser/stuff-2012", "name": "sentryuser/stuff-2012"}, - {"identifier": "sentryuser/stuff-2013", "name": "sentryuser/stuff-2013"}, - {"identifier": "sentryuser/stuff-2014", "name": "sentryuser/stuff-2014"}, - {"identifier": "sentryuser/stuff-2015", "name": "sentryuser/stuff-2015"}, - {"identifier": "sentryuser/stuff-2016", "name": "sentryuser/stuff-2016"}, - {"identifier": "sentryuser/stuff-2017", "name": "sentryuser/stuff-2017"}, - {"identifier": "sentryuser/stuff-2018", "name": "sentryuser/stuff-2018"}, - {"identifier": "sentryuser/stuff-2019", "name": "sentryuser/stuff-2019"}, + { + "identifier": "sentryuser/stuff", + "name": "sentryuser/stuff", + "external_id": "{abc-002}", + }, + { + "identifier": "sentryuser/stuff-2010", + "name": "sentryuser/stuff-2010", + "external_id": "{abc-003}", + }, + { + "identifier": "sentryuser/stuff-2011", + "name": "sentryuser/stuff-2011", + "external_id": "{abc-004}", + }, + { + "identifier": "sentryuser/stuff-2012", + "name": "sentryuser/stuff-2012", + "external_id": "{abc-005}", + }, + { + "identifier": "sentryuser/stuff-2013", + "name": "sentryuser/stuff-2013", + "external_id": "{abc-006}", + }, + { + "identifier": "sentryuser/stuff-2014", + "name": "sentryuser/stuff-2014", + "external_id": "{abc-007}", + }, + { + "identifier": "sentryuser/stuff-2015", + "name": "sentryuser/stuff-2015", + "external_id": "{abc-008}", + }, + { + "identifier": "sentryuser/stuff-2016", + "name": "sentryuser/stuff-2016", + "external_id": "{abc-009}", + }, + { + "identifier": "sentryuser/stuff-2017", + "name": "sentryuser/stuff-2017", + "external_id": "{abc-010}", + }, + { + "identifier": "sentryuser/stuff-2018", + "name": "sentryuser/stuff-2018", + "external_id": "{abc-011}", + }, + { + "identifier": "sentryuser/stuff-2019", + "name": "sentryuser/stuff-2019", + "external_id": "{abc-012}", + }, ] @responses.activate diff --git a/tests/sentry/integrations/bitbucket/test_issues.py b/tests/sentry/integrations/bitbucket/test_issues.py index af08e90451b4ce..f3947ab385e890 100644 --- a/tests/sentry/integrations/bitbucket/test_issues.py +++ b/tests/sentry/integrations/bitbucket/test_issues.py @@ -108,8 +108,8 @@ def test_default_repo_link_fields(self) -> None: "https://api.bitbucket.org/2.0/repositories/myaccount", body=b"""{ "values": [ - {"full_name": "myaccount/repo1"}, - {"full_name": "myaccount/repo2"} + {"full_name": "myaccount/repo1", "uuid": "{repo-1}"}, + {"full_name": "myaccount/repo2", "uuid": "{repo-2}"} ] }""", content_type="application/json", @@ -132,8 +132,8 @@ def test_default_repo_create_fields(self) -> None: "https://api.bitbucket.org/2.0/repositories/myaccount", body=b"""{ "values": [ - {"full_name": "myaccount/repo1"}, - {"full_name": "myaccount/repo2"} + {"full_name": "myaccount/repo1", "uuid": "{repo-1}"}, + {"full_name": "myaccount/repo2", "uuid": "{repo-2}"} ] }""", content_type="application/json", @@ -191,7 +191,12 @@ def test_get_create_issue_config(self) -> None: responses.add( responses.GET, "https://api.bitbucket.org/2.0/repositories/myaccount", - json={"values": [{"full_name": "myaccount/repo1"}, {"full_name": "myaccount/repo2"}]}, + json={ + "values": [ + {"full_name": "myaccount/repo1", "uuid": "{repo-1}"}, + {"full_name": "myaccount/repo2", "uuid": "{repo-2}"}, + ] + }, ) installation = self.integration.get_installation(self.organization.id) @@ -244,7 +249,12 @@ def test_get_create_issue_config_with_long_title(self) -> None: responses.add( responses.GET, "https://api.bitbucket.org/2.0/repositories/myaccount", - json={"values": [{"full_name": "myaccount/repo1"}, {"full_name": "myaccount/repo2"}]}, + json={ + "values": [ + {"full_name": "myaccount/repo1", "uuid": "{repo-1}"}, + {"full_name": "myaccount/repo2", "uuid": "{repo-2}"}, + ] + }, ) installation = self.integration.get_installation(self.organization.id) event = self.store_event( @@ -268,8 +278,8 @@ def test_get_create_issue_config_without_group(self) -> None: "https://api.bitbucket.org/2.0/repositories/myaccount", json={ "values": [ - {"full_name": "myaccount/repo1"}, - {"full_name": "myaccount/repo2"}, + {"full_name": "myaccount/repo1", "uuid": "{repo-1}"}, + {"full_name": "myaccount/repo2", "uuid": "{repo-2}"}, ], }, ) @@ -298,7 +308,12 @@ def test_get_link_issue_config(self) -> None: responses.add( responses.GET, "https://api.bitbucket.org/2.0/repositories/myaccount", - json={"values": [{"full_name": "myaccount/repo1"}, {"full_name": "myaccount/repo2"}]}, + json={ + "values": [ + {"full_name": "myaccount/repo1", "uuid": "{repo-1}"}, + {"full_name": "myaccount/repo2", "uuid": "{repo-2}"}, + ] + }, ) installation = self.integration.get_installation(self.organization.id) assert installation.get_link_issue_config(self.group) == [ diff --git a/tests/sentry/integrations/bitbucket/test_search.py b/tests/sentry/integrations/bitbucket/test_search.py index 27c391ec2cbc13..c320e8ae141d50 100644 --- a/tests/sentry/integrations/bitbucket/test_search.py +++ b/tests/sentry/integrations/bitbucket/test_search.py @@ -77,7 +77,7 @@ def test_search_repositories(self, mock_record: MagicMock) -> None: responses.add( responses.GET, "https://api.bitbucket.org/2.0/repositories/meredithanya", - json={"values": [{"full_name": "meredithanya/apples"}]}, + json={"values": [{"full_name": "meredithanya/apples", "uuid": "{abc-001}"}]}, ) resp = self.client.get(self.path, data={"field": "repo", "query": "apple"}) diff --git a/tests/sentry/integrations/github/test_integration.py b/tests/sentry/integrations/github/test_integration.py index 300385d48fe4a6..a8981259957280 100644 --- a/tests/sentry/integrations/github/test_integration.py +++ b/tests/sentry/integrations/github/test_integration.py @@ -222,6 +222,7 @@ def _stub_github(self): repositories: dict[str, Any] = { "xyz": { + "id": 1234567, "name": "xyz", "full_name": "Test-Organization/xyz", "default_branch": "master", @@ -245,6 +246,9 @@ def _stub_github(self): "default_branch": "master", }, "archived": { + "id": 9999999, + "name": "archived", + "full_name": "Test-Organization/archived", "archived": True, }, } @@ -637,8 +641,18 @@ def test_get_repositories_search_param(self) -> None: f"{self.base_url}/search/repositories?{querystring}", json={ "items": [ - {"name": "example", "full_name": "test/example", "default_branch": "master"}, - {"name": "exhaust", "full_name": "test/exhaust", "default_branch": "master"}, + { + "id": 10, + "name": "example", + "full_name": "test/example", + "default_branch": "master", + }, + { + "id": 11, + "name": "exhaust", + "full_name": "test/exhaust", + "default_branch": "master", + }, ] }, ) @@ -649,8 +663,18 @@ def test_get_repositories_search_param(self) -> None: # This searches for any repositories matching the term 'ex' result = installation.get_repositories("ex") assert result == [ - {"identifier": "test/example", "name": "example", "default_branch": "master"}, - {"identifier": "test/exhaust", "name": "exhaust", "default_branch": "master"}, + { + "identifier": "test/example", + "name": "example", + "external_id": "10", + "default_branch": "master", + }, + { + "identifier": "test/exhaust", + "name": "exhaust", + "external_id": "11", + "default_branch": "master", + }, ] @responses.activate @@ -666,7 +690,12 @@ def test_get_repositories_accessible_only(self) -> None: result = installation.get_repositories("foo", accessible_only=True) assert result == [ - {"name": "foo", "identifier": "Test-Organization/foo", "default_branch": "master"}, + { + "name": "foo", + "identifier": "Test-Organization/foo", + "external_id": "1296269", + "default_branch": "master", + }, ] @responses.activate @@ -697,9 +726,24 @@ def test_get_repositories_all_and_pagination(self) -> None: with patch.object(sentry.integrations.github.client.GitHubBaseClient, "page_size", 1): result = installation.get_repositories() assert result == [ - {"name": "foo", "identifier": "Test-Organization/foo", "default_branch": "master"}, - {"name": "bar", "identifier": "Test-Organization/bar", "default_branch": "main"}, - {"name": "baz", "identifier": "Test-Organization/baz", "default_branch": "master"}, + { + "name": "foo", + "identifier": "Test-Organization/foo", + "external_id": "1296269", + "default_branch": "master", + }, + { + "name": "bar", + "identifier": "Test-Organization/bar", + "external_id": "9876574", + "default_branch": "main", + }, + { + "name": "baz", + "identifier": "Test-Organization/baz", + "external_id": "1276555", + "default_branch": "master", + }, ] @responses.activate @@ -721,7 +765,12 @@ def test_get_repositories_only_first_page(self) -> None: ): result = installation.get_repositories() assert result == [ - {"name": "foo", "identifier": "Test-Organization/foo", "default_branch": "master"}, + { + "name": "foo", + "identifier": "Test-Organization/foo", + "external_id": "1296269", + "default_branch": "master", + }, ] @responses.activate @@ -2352,6 +2401,7 @@ def _stub_github(self) -> None: repositories: dict[str, Any] = { "xyz": { + "id": 1234567, "name": "xyz", "full_name": "Test-Organization/xyz", "default_branch": "master", diff --git a/tests/sentry/integrations/github/test_issues.py b/tests/sentry/integrations/github/test_issues.py index 8a6727cc3a58c3..50f59970113f34 100644 --- a/tests/sentry/integrations/github/test_issues.py +++ b/tests/sentry/integrations/github/test_issues.py @@ -67,8 +67,8 @@ def test_get_create_issue_config_without_group(self) -> None: json={ "total_count": 2, "repositories": [ - {"full_name": "getsentry/sentry", "name": "sentry"}, - {"full_name": "getsentry/other", "name": "other", "archived": True}, + {"id": 1, "full_name": "getsentry/sentry", "name": "sentry"}, + {"id": 2, "full_name": "getsentry/other", "name": "other", "archived": True}, ], }, ) @@ -642,8 +642,8 @@ def test_repo_dropdown_choices(self) -> None: json={ "total_count": 2, "repositories": [ - {"full_name": "getsentry/sentry", "name": "sentry"}, - {"full_name": "getsentry/other", "name": "other", "archived": True}, + {"id": 1, "full_name": "getsentry/sentry", "name": "sentry"}, + {"id": 2, "full_name": "getsentry/other", "name": "other", "archived": True}, ], }, ) @@ -691,8 +691,8 @@ def test_linked_issue_comment(self) -> None: json={ "total_count": 2, "repositories": [ - {"full_name": "getsentry/sentry", "name": "sentry"}, - {"full_name": "getsentry/other", "name": "other", "archived": True}, + {"id": 1, "full_name": "getsentry/sentry", "name": "sentry"}, + {"id": 2, "full_name": "getsentry/other", "name": "other", "archived": True}, ], }, ) @@ -745,7 +745,7 @@ def test_default_repo_link_fields(self) -> None: "https://api.github.com/installation/repositories", json={ "total_count": 1, - "repositories": [{"name": "sentry", "full_name": "getsentry/sentry"}], + "repositories": [{"id": 1, "name": "sentry", "full_name": "getsentry/sentry"}], }, ) event = self.store_event( @@ -774,7 +774,7 @@ def test_default_repo_create_fields(self) -> None: "https://api.github.com/installation/repositories", json={ "total_count": 1, - "repositories": [{"name": "sentry", "full_name": "getsentry/sentry"}], + "repositories": [{"id": 1, "name": "sentry", "full_name": "getsentry/sentry"}], }, ) responses.add( diff --git a/tests/sentry/integrations/github_enterprise/test_integration.py b/tests/sentry/integrations/github_enterprise/test_integration.py index 9fc9773591e815..b4e59be9fa0ea6 100644 --- a/tests/sentry/integrations/github_enterprise/test_integration.py +++ b/tests/sentry/integrations/github_enterprise/test_integration.py @@ -370,8 +370,18 @@ def test_get_repositories_search_param(self, mock_jwtm: MagicMock, _: MagicMock) f"{self.base_url}/search/repositories?{querystring}", json={ "items": [ - {"name": "example", "full_name": "test/example", "default_branch": "main"}, - {"name": "exhaust", "full_name": "test/exhaust", "default_branch": "main"}, + { + "id": 10, + "name": "example", + "full_name": "test/example", + "default_branch": "main", + }, + { + "id": 11, + "name": "exhaust", + "full_name": "test/exhaust", + "default_branch": "main", + }, ] }, ) @@ -381,8 +391,18 @@ def test_get_repositories_search_param(self, mock_jwtm: MagicMock, _: MagicMock) ) result = installation.get_repositories("ex") assert result == [ - {"identifier": "test/example", "name": "example", "default_branch": "main"}, - {"identifier": "test/exhaust", "name": "exhaust", "default_branch": "main"}, + { + "identifier": "test/example", + "name": "example", + "external_id": "10", + "default_branch": "main", + }, + { + "identifier": "test/exhaust", + "name": "exhaust", + "external_id": "11", + "default_branch": "main", + }, ] @patch("sentry.integrations.github_enterprise.integration.get_jwt", return_value="jwt_token_1") @@ -640,8 +660,18 @@ def test_get_organization_config(self) -> None: json={ "total_count": 2, "repositories": [ - {"name": "repo1", "full_name": "Test-Organization/repo1", "archived": False}, - {"name": "repo2", "full_name": "Test-Organization/repo2", "archived": False}, + { + "id": 1, + "name": "repo1", + "full_name": "Test-Organization/repo1", + "archived": False, + }, + { + "id": 2, + "name": "repo2", + "full_name": "Test-Organization/repo2", + "archived": False, + }, ], }, ) diff --git a/tests/sentry/integrations/gitlab/test_issues.py b/tests/sentry/integrations/gitlab/test_issues.py index acb23b01b7908d..b0098d9cfe9d34 100644 --- a/tests/sentry/integrations/gitlab/test_issues.py +++ b/tests/sentry/integrations/gitlab/test_issues.py @@ -71,8 +71,8 @@ def test_get_create_issue_config(self) -> None: "required": True, "type": "select", "label": "GitLab Project", - "choices": [(1, "getsentry / sentry"), (22, "getsentry / hello")], - "defaultValue": 1, + "choices": [("1", "getsentry / sentry"), ("22", "getsentry / hello")], + "defaultValue": "1", }, { "name": "title", @@ -108,8 +108,8 @@ def test_get_link_issue_config(self) -> None: "name": "project", "label": "GitLab Project", "type": "select", - "default": 1, - "choices": [(1, "getsentry / sentry"), (22, "getsentry / hello")], + "default": "1", + "choices": [("1", "getsentry / sentry"), ("22", "getsentry / hello")], "url": autocomplete_url, "updatesForm": True, "required": True, @@ -250,11 +250,11 @@ def test_create_issue_default_project_in_group_api_call(self) -> None: "name": "project", "required": True, "choices": [ - (1, "getsentry / sentry"), - (10, "This_is / a_project"), - (22, "getsentry / hello"), + ("1", "getsentry / sentry"), + ("10", "This_is / a_project"), + ("22", "getsentry / hello"), ], - "defaultValue": project_id, + "defaultValue": str(project_id), "type": "select", "label": "GitLab Project", }, @@ -318,11 +318,11 @@ def test_create_issue_default_project_not_in_api_call(self) -> None: "name": "project", "required": True, "choices": [ - (10, "This_is / a_project"), - (1, "getsentry / sentry"), - (22, "getsentry / hello"), + ("10", "This_is / a_project"), + ("1", "getsentry / sentry"), + ("22", "getsentry / hello"), ], - "defaultValue": project_id, + "defaultValue": str(project_id), "type": "select", "label": "GitLab Project", }, diff --git a/tests/sentry/integrations/vsts/test_integration.py b/tests/sentry/integrations/vsts/test_integration.py index 1919c95ac49229..d3868469ae89a7 100644 --- a/tests/sentry/integrations/vsts/test_integration.py +++ b/tests/sentry/integrations/vsts/test_integration.py @@ -728,7 +728,11 @@ def test_get_repositories(self) -> None: result = installation.get_repositories() assert len(result) == 1 - assert {"name": "ProjectA/cool-service", "identifier": self.repo_id} == result[0] + assert { + "name": "ProjectA/cool-service", + "identifier": str(self.repo_id), + "external_id": str(self.repo_id), + } == result[0] def test_get_repositories_identity_error(self) -> None: self.assert_installation() diff --git a/tests/sentry/integrations/vsts/test_repository.py b/tests/sentry/integrations/vsts/test_repository.py index 4c4a9ac95343f9..7b72e69a464e74 100644 --- a/tests/sentry/integrations/vsts/test_repository.py +++ b/tests/sentry/integrations/vsts/test_repository.py @@ -28,17 +28,17 @@ def provider(self): def test_compare_commits(self) -> None: responses.add( responses.POST, - "https://visualstudio.com/_apis/git/repositories/None/commitsBatch", + "https://visualstudio.com/_apis/git/repositories/123/commitsBatch", body=COMPARE_COMMITS_EXAMPLE, ) responses.add( responses.GET, - "https://visualstudio.com/_apis/git/repositories/None/commits/6c36052c58bde5e57040ebe6bdb9f6a52c906fff/changes", + "https://visualstudio.com/_apis/git/repositories/123/commits/6c36052c58bde5e57040ebe6bdb9f6a52c906fff/changes", body=FILE_CHANGES_EXAMPLE, ) responses.add( responses.GET, - "https://visualstudio.com/_apis/git/repositories/None/commits/6c36052c58bde5e57040ebe6bdb9f6a52c906fff", + "https://visualstudio.com/_apis/git/repositories/123/commits/6c36052c58bde5e57040ebe6bdb9f6a52c906fff", body=COMMIT_DETAILS_EXAMPLE, ) @@ -65,6 +65,7 @@ def test_compare_commits(self) -> None: provider="visualstudio", name="example", organization_id=self.organization.id, + external_id="123", config={"instance": self.base_url, "project": "project-name", "name": "example"}, integration_id=integration.id, )