Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/sentry/features/temporary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/sentry/integrations/github/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions src/sentry/integrations/github/issue_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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})
86 changes: 76 additions & 10 deletions src/sentry/integrations/github/issues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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"]
Expand Down Expand Up @@ -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"}
)
Expand All @@ -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"],
Expand All @@ -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:
Expand Down Expand Up @@ -324,24 +357,31 @@ 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"}
)

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:
Expand All @@ -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)
Expand All @@ -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)
Expand Down
27 changes: 23 additions & 4 deletions src/sentry/integrations/github/platform_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}",
Expand All @@ -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}
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading