diff --git a/src/sentry/integrations/perforce/client.py b/src/sentry/integrations/perforce/client.py index f4961ed2aaa935..2af04bd3bb4eeb 100644 --- a/src/sentry/integrations/perforce/client.py +++ b/src/sentry/integrations/perforce/client.py @@ -3,6 +3,7 @@ import logging from collections.abc import Generator, Sequence from contextlib import contextmanager +from datetime import datetime, timezone from typing import Any, TypedDict from P4 import P4, P4Exception @@ -12,6 +13,7 @@ from sentry.integrations.services.integration import RpcIntegration, RpcOrganizationIntegration from sentry.integrations.source_code_management.commit_context import ( CommitContextClient, + CommitInfo, FileBlameInfo, SourceLineInfo, ) @@ -165,7 +167,7 @@ def _connect(self) -> Generator[P4]: # Assert SSL trust after connection (if needed) # This must be done after p4.connect() but before p4.run_login() - if self.ssl_fingerprint and self.p4port.startswith("ssl:"): + if self.ssl_fingerprint and self.p4port.startswith("ssl"): try: p4.run_trust("-i", self.ssl_fingerprint) except P4Exception as trust_error: @@ -357,6 +359,46 @@ def get_user(self, username: str) -> P4UserInfo | None: # User not found - return None (not an error condition) return None + def get_author_info_from_cache( + self, username: str, user_cache: dict[str, P4UserInfo | None] + ) -> tuple[str, str]: + """ + Get author email and name from username with caching. + + Args: + username: Perforce username + user_cache: Cache dictionary for user lookups + + Returns: + Tuple of (author_email, author_name) + """ + author_email = f"{username}@perforce" + author_name = username + + # Fetch user info if not in cache + if username not in user_cache: + try: + user_cache[username] = self.get_user(username) + except Exception as e: + logger.warning( + "perforce.get_author_info.user_lookup_failed", + extra={ + "username": username, + "error": str(e), + "error_type": type(e).__name__, + }, + ) + user_cache[username] = None + + user_info = user_cache.get(username) + if user_info: + if user_info.get("email"): + author_email = user_info["email"] + if user_info.get("full_name"): + author_name = user_info["full_name"] + + return author_email, author_name + def get_changes( self, depot_path: str, @@ -435,17 +477,102 @@ def get_blame_for_files( self, files: Sequence[SourceLineInfo], extra: dict[str, Any] ) -> list[FileBlameInfo]: """ - Get blame information for multiple files using p4 filelog. + Get blame information for multiple files using p4 changes. - Uses 'p4 filelog' + 'p4 describe' which is much faster than 'p4 annotate'. - Returns the most recent changelist that modified each file. + Uses 'p4 changes -m 1 -l' to get the most recent changelist that modified each file. + This is simpler and faster than using p4 filelog + p4 describe. Note: This does not provide line-specific blame. It returns the most recent changelist for the entire file, which is sufficient for suspect commit detection. + API docs: + - p4 changes: https://www.perforce.com/manuals/cmdref/Content/CmdRef/p4_changes.html + Returns a list of FileBlameInfo objects containing commit details for each file. + + Performance notes: + - Makes ~2 P4 API calls per file: changes (with -l for description), user (cached) + - User lookups are cached within the request to minimize redundant calls + - Perforce doesn't have explicit rate limiting like GitHub + - Individual file failures are caught and logged without failing entire batch """ - return [] + blames: list[FileBlameInfo] = [] + user_cache: dict[str, P4UserInfo | None] = {} + + with self._connect() as p4: + for file in files: + try: + # Build depot path for the file (includes stream if specified) + # file.ref contains the stream but we are ignoring it since it's + # already part of the depot path we get from stacktrace (SourceLineInfo) + depot_path = self.build_depot_path(file.repo, file.path, None) + + # Use p4 changes -m 1 -l to get most recent change for this file + # -m 1: limit to 1 result (most recent) + # -l: include full changelist description + changes = p4.run("changes", "-m", "1", "-l", depot_path) + + if changes and len(changes) > 0: + change = changes[0] + changelist = change.get("change", "") + username = change.get("user", "unknown") + + # Get author email and name with caching + author_email, author_name = self.get_author_info_from_cache( + username, user_cache + ) + + # Handle potentially null/invalid time field + time_value = change.get("time") or 0 + try: + time_int = int(time_value) + except (TypeError, ValueError) as e: + logger.warning( + "perforce.client.get_blame_for_files.invalid_time_value", + extra={ + **extra, + "changelist": changelist, + "time_value": time_value, + "error": str(e), + "repo_name": file.repo.name, + "file_path": file.path, + }, + ) + time_int = 0 + + commit = CommitInfo( + commitId=str(changelist), + committedDate=datetime.fromtimestamp(time_int, tz=timezone.utc), + commitMessage=change.get("desc", "").strip(), + commitAuthorName=author_name, + commitAuthorEmail=author_email, + ) + + blame_info = FileBlameInfo( + lineno=file.lineno, + path=file.path, + ref=file.ref, + repo=file.repo, + code_mapping=file.code_mapping, + commit=commit, + ) + blames.append(blame_info) + + except P4Exception as e: + # Log but don't fail for individual file errors + logger.warning( + "perforce.client.get_blame_for_files.error", + extra={ + **extra, + "error": str(e), + "repo_name": file.repo.name, + "file_path": file.path, + "file_lineno": file.lineno, + }, + ) + continue + + return blames def get_file( self, repo: Repository, path: str, ref: str | None, codeowners: bool = False diff --git a/src/sentry/integrations/perforce/integration.py b/src/sentry/integrations/perforce/integration.py index bf034e292574bf..3262275bf5e5d2 100644 --- a/src/sentry/integrations/perforce/integration.py +++ b/src/sentry/integrations/perforce/integration.py @@ -45,7 +45,7 @@ class PerforceMetadata(TypedDict, total=False): DESCRIPTION = """ -Connect your Sentry organization to your Perforce/Helix Core server to enable +Connect your Sentry organization to your P4 Core server to enable stacktrace linking, commit tracking, suspect commit detection, and code ownership. View source code directly from error stack traces and identify suspect commits that may have introduced issues. @@ -55,7 +55,7 @@ class PerforceMetadata(TypedDict, total=False): FeatureDescription( """ Link your Sentry stack traces back to your Perforce depot files with support - for Helix Swarm web viewer. Automatically maps error locations to + for P4 Code Review viewer. Automatically maps error locations to source code using configurable code mappings. """, IntegrationFeatures.STACKTRACE_LINK, @@ -145,8 +145,8 @@ class PerforceInstallationForm(forms.Form): required=False, ) web_url = forms.URLField( - label=_("Helix Swarm URL (Optional)"), - help_text=_("Optional: URL to Helix Swarm web viewer for browsing files"), + label=_("P4 Code Review URL (Optional)"), + help_text=_("Optional: URL to P4 Code Review web viewer for browsing files"), widget=forms.URLInput(attrs={"placeholder": "https://swarm.company.com"}), required=False, assume_scheme="https", @@ -166,7 +166,7 @@ def clean_web_url(self) -> str: class PerforceIntegration(RepositoryIntegration, CommitContextIntegration): """ - Integration for Perforce/Helix Core version control system. + Integration for P4 Core version control system. Provides stacktrace linking to depot files and suspect commit detection. """ @@ -471,9 +471,9 @@ def get_organization_config(self) -> list[dict[str, Any]]: { "name": "web_url", "type": "string", - "label": "Helix Swarm URL (Optional)", + "label": "P4 Core URL (Optional)", "placeholder": "https://swarm.company.com", - "help": "Optional: URL to Helix Swarm web viewer for browsing files", + "help": "Optional: URL to P4 Core web viewer for browsing files", "required": False, }, ] diff --git a/src/sentry/integrations/perforce/repository.py b/src/sentry/integrations/perforce/repository.py index f2235b4649f8b4..1e27ac59d7a75a 100644 --- a/src/sentry/integrations/perforce/repository.py +++ b/src/sentry/integrations/perforce/repository.py @@ -164,37 +164,9 @@ def _extract_commit_info( # Convert Unix timestamp to ISO 8601 format timestamp = datetime.fromtimestamp(time_int, tz=timezone.utc).isoformat() - # Get user information from Perforce + # Get user information from Perforce using shared helper username = change.get("user", "unknown") - author_email = f"{username}@perforce" - author_name = username - - # Fetch user info if not in cache (skip "unknown" placeholder) - if username != "unknown" and username not in user_cache: - try: - user_cache[username] = client.get_user(username) - except Exception as e: - # Log user lookup failures but don't fail the entire commit processing - logger.warning( - "perforce.format_commits.user_lookup_failed", - extra={ - "changelist": change.get("change"), - "username": username, - "error": str(e), - "error_type": type(e).__name__, - }, - ) - # Cache None to avoid repeated failed lookups for the same user - user_cache[username] = None - - user_info = user_cache.get(username) - if user_info: - # Use actual email from Perforce if available - if user_info.get("email"): - author_email = user_info["email"] - # Use full name from Perforce if available - if user_info.get("full_name"): - author_name = user_info["full_name"] + author_email, author_name = client.get_author_info_from_cache(username, user_cache) return P4CommitInfo( id=str(change["change"]), diff --git a/src/sentry/templates/sentry/integrations/perforce-config.html b/src/sentry/templates/sentry/integrations/perforce-config.html index e7288bbe251c98..df2a500937a193 100644 --- a/src/sentry/templates/sentry/integrations/perforce-config.html +++ b/src/sentry/templates/sentry/integrations/perforce-config.html @@ -19,8 +19,8 @@ {% block title %} {% trans "Perforce Setup" %} | {{ block.super }} {% endblock %} {% block main %} -
{% trans "Enter your Perforce server credentials to connect Sentry with your Perforce/Helix Core server." %}
+{% trans "Enter your Perforce server credentials to connect Sentry with your P4 Core server." %}
{% trans "See the" %} {% trans "docs" %} {% trans "for more information." %}